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/// Dispatches on [`Document`](bynk_emit::project::Document) directly (P7.6,
113/// #1309) — no `CompiledFile` text intermediate. Every `Ts` document is
114/// printed through the one printer that owns a character (R7.3), then
115/// type-stripped and renamed to `.js` as its own `Document::Js`; `tsconfig.json`
116/// is dropped (a TypeScript-compiler config with no role for a JS artefact);
117/// `wrangler.toml`'s real tree gets its `main` entry patched *structurally*,
118/// in place — no print-then-reparse (Decision E) — since the Workers
119/// manifest names its entry module and a `main` left pointing at the
120/// stripped `.ts` breaks `wrangler dev`/deploy on the emitted output. Source
121/// maps and the debug sidecar are dropped — they map into the `.ts` the JS
122/// replaces. Import specifiers are already `.js` (the default `ImportExt`),
123/// so the renamed tree resolves as-is.
124pub 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            // Source maps/debug sidecars map into the `.ts` the JS replaces.
157            Document::SourceMap(_) | Document::DebugSidecar(_) => {}
158            // `bynk-emit` never produces this itself, but `strip_project_to_js`
159            // is `pub` over a `pub` `Artefacts`, so an already-stripped
160            // `Document::Js` reaching this function (e.g. run twice, or a
161            // caller assembling `Artefacts` by hand) is a real, reachable
162            // input — pass it through rather than panicking on it (review,
163            // #1309/#1310: `unreachable!` here aborted the process on input
164            // the public API permits).
165            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    /// Assert the stripped JS contains `needle` and none of the `absent` strings.
189    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        // `Ok`/`Err` are value imports and must survive even though they are
215        // unused here; `type Result`/`import type` must go.
216        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        // The shape the slice-0 emitter produces for a `given` provider.
234        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    /// P7.4 (#1305)/P7.6 (#1309): `strip_project_to_js`'s `wrangler.toml`
270    /// patch, exercised through the real function against a *real* compiled
271    /// `Document::Toml` tree (`compile_in_memory`, `BuildTarget::Workers`) —
272    /// not a hand-typed text fixture standing in for one, which is exactly
273    /// the shortcut P7.4's own defect report warns against (a hand-built
274    ///2-key snippet can't prove a whole-document reformat didn't happen).
275    /// P7.6's own Decision E moved this from a text re-parse
276    /// (`toml_edit`) to a structural, in-tree mutation
277    /// (`TomlDocument::set_main`) — asserting the *printed* text still
278    /// pins "nothing but `main` changed", the same discipline as before.
279    #[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        // No leftover `.ts` index — `Document::Ts` entries are all renamed
324        // to their own `.js` sibling.
325        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}