Skip to main content

bynk_strip/
lib.rs

1//! Strip-only TypeScript → JavaScript for Bynk's first-class JS artefact (the
2//! in-browser track, slice 1 — ADR 0137).
3//!
4//! `bynkc` emits TypeScript. A JS artefact is *emit-then-strip*: the same emitter
5//! output with type annotations erased and nothing else changed. Because the
6//! emitter is **strip-only** (ADR 0136 — every emitted `.ts` is erasable by pure
7//! type-stripping), the transform here is total and lossless for runtime
8//! behaviour: it never has to lower a type-directed construct (parameter
9//! property, `enum`, `namespace`), only delete type syntax.
10//!
11//! The engine is [`oxc`] — a pure-Rust TS parser, type-erasing transform, and
12//! codegen — so neither `bynkc --emit js` nor the in-browser compile path has any
13//! Node/`tsc` dependency, and the crate compiles to `wasm32` for the playground.
14//!
15//! The transform is configured for **pure type-stripping**, matching Node's
16//! `stripTypeScriptTypes` (the slice-0 strip oracle): `only_remove_type_imports`
17//! keeps every *value* import even when unused, eliding only `import type` and
18//! `type` specifiers — TypeScript's import-elision-by-usage is deliberately off,
19//! so stripping is a syntactic erase, not a semantics-aware rewrite.
20
21use 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/// A failure to strip TypeScript to JavaScript. For input produced by the Bynk
32/// emitter this should never occur — the emitter only emits valid, strip-only
33/// TypeScript (ADR 0136) — so a `StripError` indicates an emitter or toolchain
34/// bug rather than user error.
35#[derive(Debug, Clone)]
36pub struct StripError {
37    /// The file being stripped (for diagnostics).
38    pub filename: String,
39    /// What went wrong (parse or transform diagnostics).
40    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
55/// Strip TypeScript types from `source`, returning equivalent JavaScript.
56///
57/// `filename` selects the source flavour (`.ts`/`.tsx`/`.mts`) and labels
58/// diagnostics; it does not have to exist on disk. Value imports are preserved
59/// verbatim (see the module docs); only type syntax is erased.
60pub 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    // `with_enum_eval(true)`: the transformer *panics* on an `enum` without it.
74    // Strip-only emitter output never contains one (ADR 0136), but evaluating
75    // enums keeps this a total function — graceful transform, never a panic — if
76    // a non-strip-only source is ever handed in.
77    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            // Pure type-stripping: keep every value import (even unused),
86            // erase only `import type` / `type` specifiers. Matches Node's
87            // strip-only mode rather than TypeScript's usage-based elision.
88            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
106/// Rewrite a compiled [`ProjectOutput`](bynk_emit::project::ProjectOutput) from
107/// TypeScript into a JavaScript artefact (the in-browser track's first-class JS
108/// output — ADR 0137). The emitter always produces TypeScript; a JS artefact is
109/// that same output with types stripped, which is total because the emitter is
110/// strip-only (ADR 0136).
111///
112/// Every `.ts` module is type-stripped and renamed to `.js`; the `tsconfig.json`
113/// is dropped (a TypeScript-compiler config with no role for a JS artefact); any
114/// other file (e.g. `wrangler.toml`) passes through unchanged. Source maps and the
115/// debug sidecar are dropped — they map into the `.ts` the JS replaces. Import
116/// specifiers are already `.js` (the default `ImportExt`), so the renamed tree
117/// resolves as-is.
118pub 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    /// Assert the stripped JS contains `needle` and none of the `absent` strings.
161    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        // `Ok`/`Err` are value imports and must survive even though they are
187        // unused here; `type Result`/`import type` must go.
188        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        // The shape the slice-0 emitter produces for a `given` provider.
206        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}