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