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(
119 out: bynk_emit::project::ProjectOutput,
120) -> Result<bynk_emit::project::ProjectOutput, StripError> {
121 use bynk_emit::project::CompiledFile;
122 let mut files = Vec::with_capacity(out.files.len());
123 for file in out.files {
124 let is_ts = file
125 .output_path
126 .extension()
127 .and_then(|e| e.to_str())
128 .is_some_and(|e| e == "ts");
129 if !is_ts {
130 if file.output_path.file_name().and_then(|n| n.to_str()) == Some("tsconfig.json") {
131 continue;
132 }
133 if file.output_path.file_name().and_then(|n| n.to_str()) == Some("wrangler.toml") {
137 let patched = file
138 .typescript
139 .replace("main = \"index.ts\"", "main = \"index.js\"");
140 files.push(CompiledFile {
141 typescript: patched,
142 ..file
143 });
144 continue;
145 }
146 files.push(file);
147 continue;
148 }
149 let js = strip_types(&file.typescript, &file.output_path.to_string_lossy())?;
150 files.push(CompiledFile {
151 output_path: file.output_path.with_extension("js"),
152 typescript: js,
153 source_map: None,
154 debug_metadata: None,
155 ..file
156 });
157 }
158 Ok(bynk_emit::project::ProjectOutput { files, ..out })
159}
160
161fn join_diagnostics(diags: &[oxc::diagnostics::OxcDiagnostic]) -> String {
162 diags
163 .iter()
164 .map(|d| d.to_string())
165 .collect::<Vec<_>>()
166 .join("; ")
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn strip(src: &str) -> String {
175 strip_types(src, "test.ts").expect("strip should succeed on valid strip-only TS")
176 }
177
178 #[test]
179 fn erases_annotations_and_keeps_values() {
180 let js = strip("export const add = (a: number, b: number): number => a + b;\n");
181 assert!(js.contains("export const add"));
182 assert!(!js.contains(": number"), "annotations erased:\n{js}");
183 }
184
185 #[test]
186 fn removes_type_aliases_and_interfaces() {
187 let js = strip(
188 "export type Id = string & { readonly __brand: \"x\" };\n\
189 export interface Logger { info(m: string): Promise<void>; }\n\
190 export const v = 1;\n",
191 );
192 assert!(!js.contains("interface"), "interface erased:\n{js}");
193 assert!(!js.contains("type Id"), "type alias erased:\n{js}");
194 assert!(js.contains("export const v = 1"));
195 }
196
197 #[test]
198 fn preserves_value_imports_drops_type_specifiers() {
199 let js = strip(
202 "import { Ok, Err, type Result } from \"./runtime.js\";\n\
203 import type { Foo } from \"./foo.js\";\n\
204 export const x = 1;\n",
205 );
206 assert!(js.contains("Ok"), "value import Ok kept:\n{js}");
207 assert!(js.contains("Err"), "value import Err kept:\n{js}");
208 assert!(!js.contains("Result"), "type specifier dropped:\n{js}");
209 assert!(!js.contains("Foo"), "import type dropped:\n{js}");
210 assert!(
211 !js.contains("./foo.js"),
212 "type-only import line dropped:\n{js}"
213 );
214 }
215
216 #[test]
217 fn de_sugared_provider_constructor_strips() {
218 let js = strip(
220 "export class P {\n\
221 \x20 private deps: { Log: unknown };\n\
222 \x20 constructor(deps: { Log: unknown }) { this.deps = deps; }\n\
223 }\n",
224 );
225 assert!(js.contains("class P"));
226 assert!(
227 js.contains("constructor(deps)"),
228 "ctor param keeps name:\n{js}"
229 );
230 assert!(
231 js.contains("this.deps = deps"),
232 "assignment preserved:\n{js}"
233 );
234 assert!(!js.contains(": { Log"), "field/param types erased:\n{js}");
235 }
236
237 #[test]
238 fn as_casts_and_unique_symbol_erased() {
239 let js = strip(
240 "export const Tok: unique symbol = Symbol(\"T\");\n\
241 export const id = (v: string) => v as string;\n",
242 );
243 assert!(!js.contains("unique symbol"), "unique symbol erased:\n{js}");
244 assert!(!js.contains(" as string"), "as-cast erased:\n{js}");
245 assert!(js.contains("Symbol(\"T\")"));
246 }
247
248 #[test]
249 fn invalid_source_is_an_error_not_a_panic() {
250 let err = strip_types("const = = =;", "bad.ts");
251 assert!(err.is_err(), "malformed source is an error");
252 }
253}