Skip to main content

lanekeep_js/
typescript.rs

1//! Turning TypeScript rule modules into JavaScript the engine can run.
2//!
3//! # Blanking, not rewriting
4//!
5//! Type syntax is overwritten with spaces in place rather than removed. Every byte that
6//! survives keeps its original offset, and newlines inside a blanked range are preserved,
7//! so a line and column in the generated JavaScript is the same line and column in the
8//! author's TypeScript.
9//!
10//! That is the whole reason for this approach. A stack trace from a rule that threw points
11//! at the author's source directly, with no source map to generate, ship, parse or get
12//! subtly wrong. For a tool whose value rests on the quality of what it tells you when
13//! something is wrong, that is worth more than it costs.
14//!
15//! The alternative was a full TypeScript transformer, which would handle every construct
16//! but add roughly eighty crates to a dependency graph that is currently thirty-six — on a
17//! tool that runs as a pre-commit hook and is therefore a supply-chain target. See
18//! `docs/architecture.md` §13.
19//!
20//! # What is not supported
21//!
22//! Blanking works only for syntax that has no runtime meaning. Four TypeScript features
23//! generate code, so there is nothing to blank and they are rejected with an explanation:
24//! `enum`, `namespace`, decorators, and constructor parameter properties.
25//!
26//! Rule modules are small and self-contained, and each of these has a plain alternative.
27//! Rejecting loudly is much better than emitting JavaScript that silently means something
28//! else.
29//!
30//! # The safety net
31//!
32//! Stripping is verified rather than trusted: the result is parsed as JavaScript, and a
33//! syntax error means the stripper is wrong, not the author. That check turns a whole class
34//! of subtle stripping bugs into a loud failure at the point of the mistake.
35
36use lanekeep_lang::Language;
37use thiserror::Error;
38use tree_sitter::{Node, Parser, Tree};
39
40/// A TypeScript construct that cannot be stripped.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Unsupported {
43    /// `enum E { A }` — emits a runtime object.
44    Enum,
45    /// `namespace N {}` or `module N {}` — emits a runtime object.
46    Namespace,
47    /// `@decorator` — calls a function at class definition time.
48    Decorator,
49    /// `constructor(private x: T)` — assigns a field at construction time.
50    ParameterProperty,
51}
52
53impl Unsupported {
54    const fn describe(self) -> &'static str {
55        match self {
56            Self::Enum => "`enum` declarations",
57            Self::Namespace => "`namespace` and `module` declarations",
58            Self::Decorator => "decorators",
59            Self::ParameterProperty => "constructor parameter properties",
60        }
61    }
62
63    const fn alternative(self) -> &'static str {
64        match self {
65            Self::Enum => "use a plain object with `as const`, or a union of string literals",
66            Self::Namespace => "use a module — a rule file is already one",
67            Self::Decorator => "call the function directly instead",
68            Self::ParameterProperty => "declare the field and assign it in the constructor body",
69        }
70    }
71}
72
73/// Why a rule module could not be prepared for execution.
74#[derive(Debug, Clone, PartialEq, Eq, Error)]
75pub enum StripError {
76    /// The source uses a construct that generates runtime code.
77    #[error(
78        "{} are not supported in rule files\n  \
79         at line {line}, column {column}\n  \
80         they generate runtime code, so there is no type syntax to remove — {}",
81        .construct.describe(),
82        .construct.alternative()
83    )]
84    Unsupported {
85        /// Which construct.
86        construct: Unsupported,
87        /// One-based line.
88        line: u32,
89        /// One-based column.
90        column: u32,
91    },
92
93    /// The source does not parse as TypeScript.
94    #[error("rule module is not valid TypeScript\n  at line {line}, column {column}")]
95    Syntax {
96        /// One-based line.
97        line: u32,
98        /// One-based column.
99        column: u32,
100    },
101
102    /// The stripped output does not parse as JavaScript, which means this stripper has a
103    /// bug rather than the rule having one.
104    #[error(
105        "internal error: type stripping produced invalid JavaScript at line {line}, \
106         column {column}\n  this is a bug in lanekeep, not in the rule — please report it \
107         with the rule source"
108    )]
109    StripperBug {
110        /// One-based line in the generated output.
111        line: u32,
112        /// One-based column.
113        column: u32,
114    },
115}
116
117/// Node kinds whose entire span is type-only.
118const BLANK_WHOLE: &[&str] = &[
119    "type_annotation",
120    "omitting_type_annotation",
121    "adding_type_annotation",
122    "opting_type_annotation",
123    "asserts_annotation",
124    "type_predicate_annotation",
125    "type_parameters",
126    "type_arguments",
127    "interface_declaration",
128    "type_alias_declaration",
129    "ambient_declaration",
130    "implements_clause",
131    "abstract_method_signature",
132    "method_signature",
133    "property_signature",
134    "construct_signature",
135    "index_signature",
136    "call_signature",
137];
138
139/// Keywords that are type-only when they appear as a bare token.
140const BLANK_KEYWORD: &[&str] = &["abstract", "declare", "override", "readonly"];
141
142/// Strip TypeScript type syntax, returning JavaScript with identical byte offsets.
143///
144/// # Errors
145///
146/// Returns [`StripError::Unsupported`] for a construct that generates runtime code,
147/// [`StripError::Syntax`] if the input is not valid TypeScript, and
148/// [`StripError::StripperBug`] if the output fails to parse as JavaScript.
149pub fn strip_types(
150    typescript: &dyn Language,
151    javascript: &dyn Language,
152    source: &str,
153) -> Result<String, StripError> {
154    let tree = parse(typescript, source)?;
155    if let Some(node) = first_error(tree.root_node()) {
156        let position = node.start_position();
157        return Err(StripError::Syntax {
158            line: one_based(position.row),
159            column: one_based(position.column),
160        });
161    }
162
163    let mut output: Vec<u8> = source.as_bytes().to_vec();
164    strip_node(tree.root_node(), source, &mut output)?;
165
166    let stripped = String::from_utf8(output).unwrap_or_else(|_| source.to_owned());
167
168    // Verification, not decoration. Every stripping bug that produces syntactically broken
169    // JavaScript is caught here, at the point of the mistake, instead of surfacing later as
170    // an incomprehensible error from inside the engine.
171    let check = parse(javascript, &stripped)?;
172    if let Some(node) = first_error(check.root_node()) {
173        let position = node.start_position();
174        return Err(StripError::StripperBug {
175            line: one_based(position.row),
176            column: one_based(position.column),
177        });
178    }
179
180    Ok(stripped)
181}
182
183fn parse(language: &dyn Language, source: &str) -> Result<Tree, StripError> {
184    let mut parser = Parser::new();
185    // A grammar that will not load is a broken build, and the caller has no better
186    // response than the one the verification path already gives.
187    if parser.set_language(&language.grammar()).is_err() {
188        return Err(StripError::Syntax { line: 1, column: 1 });
189    }
190    parser
191        .parse(source, None)
192        .ok_or(StripError::Syntax { line: 1, column: 1 })
193}
194
195fn first_error(node: Node<'_>) -> Option<Node<'_>> {
196    if node.is_error() || node.is_missing() {
197        return Some(node);
198    }
199    if !node.has_error() {
200        return None;
201    }
202    let mut cursor = node.walk();
203    node.children(&mut cursor).find_map(first_error)
204}
205
206fn one_based(zero_based: usize) -> u32 {
207    u32::try_from(zero_based)
208        .unwrap_or(u32::MAX)
209        .saturating_add(1)
210}
211
212fn reject(node: Node<'_>, construct: Unsupported) -> StripError {
213    let position = node.start_position();
214    StripError::Unsupported {
215        construct,
216        line: one_based(position.row),
217        column: one_based(position.column),
218    }
219}
220
221/// Overwrite a byte range with spaces, keeping newlines so line numbers do not move.
222fn blank(output: &mut [u8], range: std::ops::Range<usize>) {
223    for byte in &mut output[range] {
224        if *byte != b'\n' && *byte != b'\r' {
225            *byte = b' ';
226        }
227    }
228}
229
230fn strip_node(node: Node<'_>, source: &str, output: &mut Vec<u8>) -> Result<(), StripError> {
231    let kind = node.kind();
232
233    // Constructs that generate runtime code. There is no type syntax to remove, so
234    // stripping would change what the module means rather than only how it is annotated.
235    match kind {
236        "enum_declaration" => return Err(reject(node, Unsupported::Enum)),
237        "internal_module" | "module" => return Err(reject(node, Unsupported::Namespace)),
238        "decorator" => return Err(reject(node, Unsupported::Decorator)),
239        _ => {}
240    }
241
242    if BLANK_WHOLE.contains(&kind) {
243        blank(output, node.byte_range());
244        return Ok(());
245    }
246
247    match kind {
248        // `import type {...}` and `export type {...}` are entirely type-only. A `type`
249        // marker on individual specifiers is handled when those specifiers are visited.
250        "import_statement" | "export_statement" if has_leading_type_keyword(node) => {
251            blank(output, node.byte_range());
252            return Ok(());
253        }
254
255        // `x as T`, `x satisfies T`, `x!` — all of the form "an expression followed by
256        // type-only syntax". Keep the expression, blank everything after it, and keep
257        // descending so nested assertions inside it are stripped too.
258        "as_expression" | "satisfies_expression" | "non_null_expression" => {
259            if let Some(expression) = node.named_child(0) {
260                blank(output, expression.end_byte()..node.end_byte());
261                return strip_node(expression, source, output);
262            }
263        }
264
265        // An accessibility modifier on a constructor parameter is a parameter property:
266        // it declares and assigns a field. Anywhere else it is type-only.
267        // `function f(this: Window, a: number)` — a `this` parameter is TypeScript-only,
268        // and `this` is not a valid binding name in JavaScript. Blanking only its type
269        // annotation would leave `function f(this , a)`, which does not parse. The
270        // separating comma has to go with it, since `(, a)` does not parse either.
271        "required_parameter" if is_this_parameter(node) => {
272            let mut end = node.end_byte();
273            if let Some(next) = node.next_sibling()
274                && next.kind() == ","
275            {
276                end = next.end_byte();
277            }
278            blank(output, node.start_byte()..end);
279            return Ok(());
280        }
281
282        "required_parameter" | "optional_parameter" => {
283            let mut cursor = node.walk();
284            for child in node.children(&mut cursor) {
285                if child.kind() == "accessibility_modifier" && in_constructor(node, source) {
286                    return Err(reject(child, Unsupported::ParameterProperty));
287                }
288            }
289            // `b?: T` — the marker is an anonymous `?` token.
290            let mut cursor = node.walk();
291            for child in node.children(&mut cursor) {
292                if child.kind() == "?" {
293                    blank(output, child.byte_range());
294                }
295            }
296        }
297
298        _ => {}
299    }
300
301    // Bare type-only keywords: `abstract class`, `declare`, `readonly x`, `override m()`.
302    if BLANK_KEYWORD.contains(&kind) && !node.is_named() {
303        blank(output, node.byte_range());
304        return Ok(());
305    }
306
307    let mut cursor = node.walk();
308    for child in node.children(&mut cursor) {
309        // Anonymous tokens carry the type-only keywords, and `type` inside a specifier.
310        if !child.is_named() {
311            let text = &source[child.byte_range()];
312            if BLANK_KEYWORD.contains(&text)
313                || (text == "type" && matches!(kind, "import_specifier" | "export_specifier"))
314            {
315                blank(output, child.byte_range());
316                continue;
317            }
318        }
319        if child.kind() == "accessibility_modifier" {
320            if in_constructor(node, source) {
321                return Err(reject(child, Unsupported::ParameterProperty));
322            }
323            blank(output, child.byte_range());
324            continue;
325        }
326        strip_node(child, source, output)?;
327    }
328
329    Ok(())
330}
331
332/// Whether this parameter is TypeScript's `this` parameter rather than a real binding.
333fn is_this_parameter(parameter: Node<'_>) -> bool {
334    parameter
335        .named_child(0)
336        .is_some_and(|first| first.kind() == "this")
337}
338
339/// Whether an `import`/`export` statement is wholly type-only, as in `import type {...}`.
340fn has_leading_type_keyword(node: Node<'_>) -> bool {
341    let mut cursor = node.walk();
342    node.children(&mut cursor)
343        .nth(1)
344        .is_some_and(|second| !second.is_named() && second.kind() == "type")
345}
346
347/// Whether a parameter belongs to a constructor, which is what makes an accessibility
348/// modifier on it a runtime field declaration rather than an annotation.
349fn in_constructor(parameter: Node<'_>, source: &str) -> bool {
350    let mut current = parameter.parent();
351    while let Some(node) = current {
352        match node.kind() {
353            // Only a constructor's parameters declare fields. `private` on any other
354            // method's parameter is not valid TypeScript in the first place, and treating
355            // it as a parameter property would produce a misleading diagnostic for what is
356            // really a type error.
357            "method_definition" => {
358                return node
359                    .child_by_field_name("name")
360                    .is_some_and(|name| &source[name.byte_range()] == "constructor");
361            }
362            "formal_parameters" | "required_parameter" | "optional_parameter" => {
363                current = node.parent();
364            }
365            _ => return false,
366        }
367    }
368    false
369}
370
371#[cfg(test)]
372mod tests {
373    use lanekeep_lang_js::{JavaScript, TypeScript};
374
375    use super::*;
376
377    fn strip(source: &str) -> Result<String, StripError> {
378        strip_types(&TypeScript, &JavaScript, source)
379    }
380
381    fn stripped(source: &str) -> String {
382        strip(source).expect("should strip")
383    }
384
385    /// Collapse runs of spaces so assertions read as intent rather than as whitespace.
386    fn normalized(source: &str) -> String {
387        stripped(source)
388            .split_whitespace()
389            .collect::<Vec<_>>()
390            .join(" ")
391    }
392
393    #[test]
394    fn positions_are_preserved_exactly() {
395        // The property the whole approach exists for. Byte length identical, newlines
396        // untouched, so a line and column in the output is the same one in the input.
397        let source = "const x: number = 1;\ninterface A { b: string }\nconst y: A = { b: 'q' };\n";
398        let out = stripped(source);
399
400        assert_eq!(out.len(), source.len(), "byte length must not change");
401        assert_eq!(
402            out.lines().count(),
403            source.lines().count(),
404            "line count must not change"
405        );
406        for (index, (before, after)) in source.lines().zip(out.lines()).enumerate() {
407            assert_eq!(
408                before.len(),
409                after.len(),
410                "line {} changed length",
411                index + 1
412            );
413        }
414    }
415
416    #[test]
417    fn strips_type_annotations() {
418        assert_eq!(normalized("const x: number = 1;"), "const x = 1;");
419        assert_eq!(
420            normalized("function f(a: string, b: number): void {}"),
421            "function f(a , b ) {}"
422        );
423    }
424
425    #[test]
426    fn strips_interfaces_and_type_aliases() {
427        assert_eq!(
428            normalized("interface A { b: string }\nconst c = 1;"),
429            "const c = 1;"
430        );
431        assert_eq!(
432            normalized("type B = string | null;\nconst c = 1;"),
433            "const c = 1;"
434        );
435    }
436
437    #[test]
438    fn strips_generics() {
439        assert_eq!(
440            normalized("function f<T>(a: T): T { return a }"),
441            "function f (a ) { return a }"
442        );
443        assert_eq!(
444            normalized("const m = new Map<string, number>();"),
445            "const m = new Map ();"
446        );
447    }
448
449    #[test]
450    fn strips_assertions_but_keeps_the_expression() {
451        assert_eq!(normalized("const y = z as Foo;"), "const y = z ;");
452        assert_eq!(normalized("const w = v satisfies Bar;"), "const w = v ;");
453        assert_eq!(normalized("const u = t!;"), "const u = t ;");
454        // Nested, to prove the inner expression is still visited.
455        assert_eq!(normalized("const a = (b as C).d;"), "const a = (b ).d;");
456    }
457
458    #[test]
459    fn strips_optional_parameter_markers() {
460        assert_eq!(normalized("function f(a?: string) {}"), "function f(a ) {}");
461    }
462
463    #[test]
464    fn strips_type_only_imports_and_exports() {
465        assert_eq!(
466            normalized("import type { A } from './a';\nconst c = 1;"),
467            "const c = 1;"
468        );
469        assert_eq!(
470            normalized("export type { Z };\nconst c = 1;"),
471            "const c = 1;"
472        );
473    }
474
475    #[test]
476    fn strips_inline_type_specifiers_but_keeps_the_value_import() {
477        // `import { type B, C }` must keep C — blanking the whole statement would delete a
478        // real binding and produce a ReferenceError at runtime.
479        let out = normalized("import { type B, C } from './b';");
480        assert!(out.contains('C'), "value import must survive: {out}");
481        assert!(
482            out.contains("from './b'"),
483            "the module specifier must survive: {out}"
484        );
485        assert!(!out.contains("type"), "the type marker must go: {out}");
486    }
487
488    #[test]
489    fn strips_declare_and_ambient_declarations() {
490        assert_eq!(
491            normalized("declare const g: number;\nconst c = 1;"),
492            "const c = 1;"
493        );
494    }
495
496    #[test]
497    fn strips_class_type_syntax() {
498        let out = normalized("class K implements I { readonly n: number = 1; }");
499        assert!(!out.contains("implements"), "{out}");
500        assert!(!out.contains("readonly"), "{out}");
501        assert!(
502            out.contains("n = 1"),
503            "the field initializer must survive: {out}"
504        );
505    }
506
507    #[test]
508    fn strips_abstract_classes() {
509        let out = normalized("abstract class M { go() { return 1 } }");
510        assert!(!out.contains("abstract"), "{out}");
511        assert!(out.contains("class M"), "{out}");
512    }
513
514    #[test]
515    fn strips_type_predicates() {
516        let out = normalized("function isFoo(x: unknown): x is Foo { return true }");
517        assert!(!out.contains(" is Foo"), "{out}");
518        assert!(out.contains("return true"), "{out}");
519    }
520
521    #[test]
522    fn strips_this_parameters_entirely() {
523        // `this` is not a valid binding name in JavaScript, so blanking only its type
524        // annotation would leave source that does not parse. The safety net catches that,
525        // but the right behavior is to remove the parameter and its comma.
526        let out = normalized("function f(this: Window, a: number) { return a }");
527        assert!(!out.contains("this"), "{out}");
528        assert!(out.contains("function f("), "{out}");
529        assert!(out.contains("return a"), "{out}");
530
531        let only = normalized("function g(this: Window) { return 1 }");
532        assert!(!only.contains("this"), "{only}");
533    }
534
535    #[test]
536    fn leaves_plain_javascript_untouched() {
537        for source in [
538            "const a = 1;",
539            "export default function () { return [1,2,3].map(x => x * 2) }",
540            "class A extends B { #p = 1; static s() {} }",
541            "const { a, ...rest } = obj; const [x, y] = arr;",
542            "async function f() { for await (const x of y) {} }",
543        ] {
544            assert_eq!(
545                stripped(source),
546                source,
547                "plain JavaScript should be unchanged"
548            );
549        }
550    }
551
552    // --- rejections ------------------------------------------------------------------
553
554    #[test]
555    fn rejects_enums() {
556        let err = strip("enum E { A, B }").expect_err("enums generate runtime code");
557        assert!(matches!(
558            err,
559            StripError::Unsupported {
560                construct: Unsupported::Enum,
561                ..
562            }
563        ));
564
565        let rendered = err.to_string();
566        assert!(
567            rendered.contains("as const"),
568            "should suggest the alternative: {rendered}"
569        );
570        assert!(rendered.contains("line 1"), "should say where: {rendered}");
571    }
572
573    #[test]
574    fn rejects_namespaces() {
575        let err = strip("namespace N { export const q = 1 }").expect_err("namespaces emit code");
576        assert!(matches!(
577            err,
578            StripError::Unsupported {
579                construct: Unsupported::Namespace,
580                ..
581            }
582        ));
583    }
584
585    #[test]
586    fn rejects_parameter_properties() {
587        // The subtle one: `private` here declares and assigns a field, so blanking it
588        // would silently produce a class whose field is never set.
589        let err = strip("class K { constructor(private p: string) {} }")
590            .expect_err("parameter properties emit code");
591        assert!(matches!(
592            err,
593            StripError::Unsupported {
594                construct: Unsupported::ParameterProperty,
595                ..
596            }
597        ));
598    }
599
600    #[test]
601    fn an_accessibility_modifier_outside_a_constructor_is_type_only() {
602        // The counterpart to the case above: `private` on a field is an annotation, and
603        // rejecting it too would be over-broad.
604        let out = normalized("class K { private n = 1; }");
605        assert!(!out.contains("private"), "{out}");
606        assert!(out.contains("n = 1"), "{out}");
607    }
608
609    #[test]
610    fn reports_the_line_of_the_offending_construct() {
611        let err = strip("const a = 1;\nconst b = 2;\nenum E { X }").expect_err("rejects");
612        match err {
613            StripError::Unsupported { line, .. } => assert_eq!(line, 3),
614            other => panic!("wrong error: {other:?}"),
615        }
616    }
617
618    #[test]
619    fn rejects_source_that_is_not_typescript() {
620        let err = strip("function ( { ] }").expect_err("does not parse");
621        assert!(matches!(err, StripError::Syntax { .. }), "{err:?}");
622    }
623
624    #[test]
625    fn handles_empty_input() {
626        assert_eq!(stripped(""), "");
627        assert_eq!(stripped("\n\n"), "\n\n");
628    }
629
630    // --- the safety net ------------------------------------------------------------------
631
632    #[test]
633    fn every_stripped_result_parses_as_javascript() {
634        // strip_types verifies this internally, so reaching Ok here means the check passed.
635        // Running a realistic module through it is what makes that check meaningful.
636        let source = r"
637import type { Rule } from 'lanekeep';
638import { defineRule } from 'lanekeep';
639
640interface Options {
641  readonly max: number;
642}
643
644type Names = 'a' | 'b';
645
646export default defineRule({
647  id: 'local/example',
648  query: '(identifier) @id',
649  check(ctx: unknown, m: { id: unknown }): void {
650    const names: Names[] = ['a', 'b'];
651    const n = (ctx as Options).max;
652    for (const name of names) {
653      if (n! > 0) { (ctx as { report(x: unknown): void }).report(m.id); }
654    }
655  },
656});
657";
658        let out = stripped(source);
659        assert_eq!(
660            out.len(),
661            source.len(),
662            "positions must survive a realistic module"
663        );
664        assert!(!out.contains("interface"), "{out}");
665        assert!(!out.contains(": number"), "{out}");
666        assert!(out.contains("defineRule"), "the runtime code must survive");
667        assert!(
668            out.contains("report(m.id)"),
669            "the runtime code must survive"
670        );
671    }
672}