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 {
173 artefacts: Artefacts {
174 docs,
175 assets: out.artefacts.assets,
176 modules: out.artefacts.modules,
177 },
178 ..out
179 })
180}
181
182fn join_diagnostics(diags: &[oxc::diagnostics::OxcDiagnostic]) -> String {
183 diags
184 .iter()
185 .map(|d| d.to_string())
186 .collect::<Vec<_>>()
187 .join("; ")
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn strip(src: &str) -> String {
196 strip_types(src, "test.ts").expect("strip should succeed on valid strip-only TS")
197 }
198
199 #[test]
200 fn erases_annotations_and_keeps_values() {
201 let js = strip("export const add = (a: number, b: number): number => a + b;\n");
202 assert!(js.contains("export const add"));
203 assert!(!js.contains(": number"), "annotations erased:\n{js}");
204 }
205
206 #[test]
207 fn removes_type_aliases_and_interfaces() {
208 let js = strip(
209 "export type Id = string & { readonly __brand: \"x\" };\n\
210 export interface Logger { info(m: string): Promise<void>; }\n\
211 export const v = 1;\n",
212 );
213 assert!(!js.contains("interface"), "interface erased:\n{js}");
214 assert!(!js.contains("type Id"), "type alias erased:\n{js}");
215 assert!(js.contains("export const v = 1"));
216 }
217
218 #[test]
219 fn preserves_value_imports_drops_type_specifiers() {
220 let js = strip(
223 "import { Ok, Err, type Result } from \"./runtime.js\";\n\
224 import type { Foo } from \"./foo.js\";\n\
225 export const x = 1;\n",
226 );
227 assert!(js.contains("Ok"), "value import Ok kept:\n{js}");
228 assert!(js.contains("Err"), "value import Err kept:\n{js}");
229 assert!(!js.contains("Result"), "type specifier dropped:\n{js}");
230 assert!(!js.contains("Foo"), "import type dropped:\n{js}");
231 assert!(
232 !js.contains("./foo.js"),
233 "type-only import line dropped:\n{js}"
234 );
235 }
236
237 #[test]
238 fn de_sugared_provider_constructor_strips() {
239 let js = strip(
241 "export class P {\n\
242 \x20 private deps: { Log: unknown };\n\
243 \x20 constructor(deps: { Log: unknown }) { this.deps = deps; }\n\
244 }\n",
245 );
246 assert!(js.contains("class P"));
247 assert!(
248 js.contains("constructor(deps)"),
249 "ctor param keeps name:\n{js}"
250 );
251 assert!(
252 js.contains("this.deps = deps"),
253 "assignment preserved:\n{js}"
254 );
255 assert!(!js.contains(": { Log"), "field/param types erased:\n{js}");
256 }
257
258 #[test]
259 fn as_casts_and_unique_symbol_erased() {
260 let js = strip(
261 "export const Tok: unique symbol = Symbol(\"T\");\n\
262 export const id = (v: string) => v as string;\n",
263 );
264 assert!(!js.contains("unique symbol"), "unique symbol erased:\n{js}");
265 assert!(!js.contains(" as string"), "as-cast erased:\n{js}");
266 assert!(js.contains("Symbol(\"T\")"));
267 }
268
269 #[test]
270 fn invalid_source_is_an_error_not_a_panic() {
271 let err = strip_types("const = = =;", "bad.ts");
272 assert!(err.is_err(), "malformed source is an error");
273 }
274
275 #[test]
286 fn strip_project_to_js_patches_wrangler_toml_main_structurally() {
287 use bynk_emit::project::{BuildTarget, Document, compile_in_memory};
288
289 let out = compile_in_memory(
290 "context api {\n service routes from http {\n on GET(\"/\") () -> Effect[HttpResult[String]] by v: Visitor {\n Ok(\"ok\")\n }\n }\n}\n",
291 BuildTarget::Workers,
292 Default::default(),
293 )
294 .unwrap_or_else(|f| {
295 panic!(
296 "workers-target fixture should compile: {:?}",
297 f.flatten()
298 )
299 });
300 let wrangler_path = out
301 .artefacts
302 .docs
303 .keys()
304 .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("wrangler.toml"))
305 .cloned()
306 .expect("a Workers-target compile emits a wrangler.toml");
307 let original_text = out.artefacts.docs[&wrangler_path].text();
308 assert!(
309 original_text.contains("main = \"index.ts\""),
310 "expected the pre-strip manifest to point at the .ts entry:\n{original_text}"
311 );
312
313 let stripped = strip_project_to_js(out).expect("wrangler.toml patches cleanly");
314 let patched = stripped
315 .artefacts
316 .docs
317 .get(&wrangler_path)
318 .unwrap_or_else(|| panic!("{} should survive stripping", wrangler_path.display()));
319 assert!(
320 matches!(patched, Document::Toml(_)),
321 "wrangler.toml should stay a real tree through stripping, not become opaque text"
322 );
323 let expected = original_text.replacen("main = \"index.ts\"", "main = \"index.js\"", 1);
324 assert_eq!(
325 patched.text(),
326 expected,
327 "only `main` should differ from the pre-strip manifest"
328 );
329 let index_ts = wrangler_path.with_file_name("index.ts");
332 assert!(
333 !stripped.artefacts.docs.contains_key(&index_ts),
334 "the stripped output must not keep the pre-strip .ts path: {:?}",
335 stripped.artefacts.docs.keys().collect::<Vec<_>>()
336 );
337 }
338
339 #[test]
345 fn strip_project_to_js_passes_static_assets_through_unchanged() {
346 use bynk_emit::project::{BuildTarget, compile_in_memory, worker_assets_dir};
347
348 let mut out = compile_in_memory(
349 "commons only\n\nfn f() -> Int {\n 1\n}\n",
350 BuildTarget::Workers,
351 Default::default(),
352 )
353 .unwrap_or_else(|f| panic!("fixture should compile: {:?}", f.flatten()));
354 let path = worker_assets_dir("only").join("_assets/icon.9b1d4e.png");
355 let bytes = vec![0x89, b'P', b'N', b'G', 0xff, 0x00];
356 out.artefacts.assets.insert(path.clone(), bytes.clone());
357
358 let stripped = strip_project_to_js(out).expect("strips cleanly");
359 assert_eq!(stripped.artefacts.assets.len(), 1);
360 assert_eq!(stripped.artefacts.assets.get(&path), Some(&bytes));
361 }
362}