1use std::fmt;
22use std::path::Path;
23
24use oxc::allocator::Allocator;
25use oxc::codegen::Codegen;
26use oxc::parser::Parser;
27use oxc::semantic::SemanticBuilder;
28use oxc::span::SourceType;
29use oxc::transformer::{TransformOptions, Transformer, TypeScriptOptions};
30
31#[derive(Debug, Clone)]
36pub struct StripError {
37 pub filename: String,
39 pub message: String,
41}
42
43impl fmt::Display for StripError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(
46 f,
47 "failed to strip types from {}: {}",
48 self.filename, self.message
49 )
50 }
51}
52
53impl std::error::Error for StripError {}
54
55pub fn strip_types(source: &str, filename: &str) -> Result<String, StripError> {
61 let allocator = Allocator::default();
62 let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts());
63
64 let parsed = Parser::new(&allocator, source, source_type).parse();
65 if parsed.panicked || !parsed.diagnostics.is_empty() {
66 return Err(StripError {
67 filename: filename.to_string(),
68 message: format!("parse error: {}", join_diagnostics(&parsed.diagnostics)),
69 });
70 }
71
72 let mut program = parsed.program;
73 let scoping = SemanticBuilder::new()
78 .with_enum_eval(true)
79 .build(&program)
80 .semantic
81 .into_scoping();
82
83 let options = TransformOptions {
84 typescript: TypeScriptOptions {
85 only_remove_type_imports: true,
89 ..TypeScriptOptions::default()
90 },
91 ..TransformOptions::default()
92 };
93
94 let ret = Transformer::new(&allocator, Path::new(filename), &options)
95 .build_with_scoping(scoping, &mut program);
96 if !ret.diagnostics.is_empty() {
97 return Err(StripError {
98 filename: filename.to_string(),
99 message: format!("transform error: {}", join_diagnostics(&ret.diagnostics)),
100 });
101 }
102
103 Ok(Codegen::new().build(&program).code)
104}
105
106pub fn strip_project_to_js(
125 out: bynk_emit::project::ProjectOutput,
126) -> Result<bynk_emit::project::ProjectOutput, StripError> {
127 use bynk_emit::project::{Artefacts, Document};
128 use std::collections::BTreeMap;
129 use std::path::PathBuf;
130
131 let mut docs: BTreeMap<PathBuf, Document> = BTreeMap::new();
132 for (path, doc) in out.artefacts.docs {
133 match doc {
134 Document::Ts(program) => {
135 let printed = bynk_ts::print(&program, "", "", &path.to_string_lossy());
136 let js = strip_types(&printed.text, &path.to_string_lossy())?;
137 docs.insert(path.with_extension("js"), Document::Js(js));
138 }
139 Document::Toml(mut toml) => {
140 if path.file_name().and_then(|n| n.to_str()) == Some("wrangler.toml")
141 && !toml.set_main("index.js")
142 {
143 return Err(StripError {
144 filename: path.to_string_lossy().into_owned(),
145 message: "wrangler.toml has no root `main` key".to_string(),
146 });
147 }
148 docs.insert(path, Document::Toml(toml));
149 }
150 Document::Json(s) => {
151 if path.file_name().and_then(|n| n.to_str()) == Some("tsconfig.json") {
152 continue;
153 }
154 docs.insert(path, Document::Json(s));
155 }
156 Document::SourceMap(_) | Document::DebugSidecar(_) => {}
158 Document::Js(s) => {
166 docs.insert(path, Document::Js(s));
167 }
168 }
169 }
170 Ok(bynk_emit::project::ProjectOutput {
171 artefacts: Artefacts { docs },
172 ..out
173 })
174}
175
176fn join_diagnostics(diags: &[oxc::diagnostics::OxcDiagnostic]) -> String {
177 diags
178 .iter()
179 .map(|d| d.to_string())
180 .collect::<Vec<_>>()
181 .join("; ")
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn strip(src: &str) -> String {
190 strip_types(src, "test.ts").expect("strip should succeed on valid strip-only TS")
191 }
192
193 #[test]
194 fn erases_annotations_and_keeps_values() {
195 let js = strip("export const add = (a: number, b: number): number => a + b;\n");
196 assert!(js.contains("export const add"));
197 assert!(!js.contains(": number"), "annotations erased:\n{js}");
198 }
199
200 #[test]
201 fn removes_type_aliases_and_interfaces() {
202 let js = strip(
203 "export type Id = string & { readonly __brand: \"x\" };\n\
204 export interface Logger { info(m: string): Promise<void>; }\n\
205 export const v = 1;\n",
206 );
207 assert!(!js.contains("interface"), "interface erased:\n{js}");
208 assert!(!js.contains("type Id"), "type alias erased:\n{js}");
209 assert!(js.contains("export const v = 1"));
210 }
211
212 #[test]
213 fn preserves_value_imports_drops_type_specifiers() {
214 let js = strip(
217 "import { Ok, Err, type Result } from \"./runtime.js\";\n\
218 import type { Foo } from \"./foo.js\";\n\
219 export const x = 1;\n",
220 );
221 assert!(js.contains("Ok"), "value import Ok kept:\n{js}");
222 assert!(js.contains("Err"), "value import Err kept:\n{js}");
223 assert!(!js.contains("Result"), "type specifier dropped:\n{js}");
224 assert!(!js.contains("Foo"), "import type dropped:\n{js}");
225 assert!(
226 !js.contains("./foo.js"),
227 "type-only import line dropped:\n{js}"
228 );
229 }
230
231 #[test]
232 fn de_sugared_provider_constructor_strips() {
233 let js = strip(
235 "export class P {\n\
236 \x20 private deps: { Log: unknown };\n\
237 \x20 constructor(deps: { Log: unknown }) { this.deps = deps; }\n\
238 }\n",
239 );
240 assert!(js.contains("class P"));
241 assert!(
242 js.contains("constructor(deps)"),
243 "ctor param keeps name:\n{js}"
244 );
245 assert!(
246 js.contains("this.deps = deps"),
247 "assignment preserved:\n{js}"
248 );
249 assert!(!js.contains(": { Log"), "field/param types erased:\n{js}");
250 }
251
252 #[test]
253 fn as_casts_and_unique_symbol_erased() {
254 let js = strip(
255 "export const Tok: unique symbol = Symbol(\"T\");\n\
256 export const id = (v: string) => v as string;\n",
257 );
258 assert!(!js.contains("unique symbol"), "unique symbol erased:\n{js}");
259 assert!(!js.contains(" as string"), "as-cast erased:\n{js}");
260 assert!(js.contains("Symbol(\"T\")"));
261 }
262
263 #[test]
264 fn invalid_source_is_an_error_not_a_panic() {
265 let err = strip_types("const = = =;", "bad.ts");
266 assert!(err.is_err(), "malformed source is an error");
267 }
268
269 #[test]
280 fn strip_project_to_js_patches_wrangler_toml_main_structurally() {
281 use bynk_emit::project::{BuildTarget, Document, compile_in_memory};
282
283 let out = compile_in_memory(
284 "context api {\n service routes from http {\n on GET(\"/\") () -> Effect[HttpResult[String]] by v: Visitor {\n Ok(\"ok\")\n }\n }\n}\n",
285 BuildTarget::Workers,
286 Default::default(),
287 )
288 .unwrap_or_else(|f| {
289 panic!(
290 "workers-target fixture should compile: {:?}",
291 f.flatten()
292 )
293 });
294 let wrangler_path = out
295 .artefacts
296 .docs
297 .keys()
298 .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("wrangler.toml"))
299 .cloned()
300 .expect("a Workers-target compile emits a wrangler.toml");
301 let original_text = out.artefacts.docs[&wrangler_path].text();
302 assert!(
303 original_text.contains("main = \"index.ts\""),
304 "expected the pre-strip manifest to point at the .ts entry:\n{original_text}"
305 );
306
307 let stripped = strip_project_to_js(out).expect("wrangler.toml patches cleanly");
308 let patched = stripped
309 .artefacts
310 .docs
311 .get(&wrangler_path)
312 .unwrap_or_else(|| panic!("{} should survive stripping", wrangler_path.display()));
313 assert!(
314 matches!(patched, Document::Toml(_)),
315 "wrangler.toml should stay a real tree through stripping, not become opaque text"
316 );
317 let expected = original_text.replacen("main = \"index.ts\"", "main = \"index.js\"", 1);
318 assert_eq!(
319 patched.text(),
320 expected,
321 "only `main` should differ from the pre-strip manifest"
322 );
323 let index_ts = wrangler_path.with_file_name("index.ts");
326 assert!(
327 !stripped.artefacts.docs.contains_key(&index_ts),
328 "the stripped output must not keep the pre-strip .ts path: {:?}",
329 stripped.artefacts.docs.keys().collect::<Vec<_>>()
330 );
331 }
332}