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    // lanekeep-ignore-next-line local/one-parser-per-file reason: the TS-stripping parse, distinct from the per-rule shared parse
185    let mut parser = Parser::new();
186    // A grammar that will not load is a broken build, and the caller has no better
187    // response than the one the verification path already gives.
188    if parser.set_language(&language.grammar()).is_err() {
189        return Err(StripError::Syntax { line: 1, column: 1 });
190    }
191    parser
192        .parse(source, None)
193        .ok_or(StripError::Syntax { line: 1, column: 1 })
194}
195
196fn first_error(node: Node<'_>) -> Option<Node<'_>> {
197    if node.is_error() || node.is_missing() {
198        return Some(node);
199    }
200    if !node.has_error() {
201        return None;
202    }
203    let mut cursor = node.walk();
204    node.children(&mut cursor).find_map(first_error)
205}
206
207fn one_based(zero_based: usize) -> u32 {
208    u32::try_from(zero_based)
209        .unwrap_or(u32::MAX)
210        .saturating_add(1)
211}
212
213fn reject(node: Node<'_>, construct: Unsupported) -> StripError {
214    let position = node.start_position();
215    StripError::Unsupported {
216        construct,
217        line: one_based(position.row),
218        column: one_based(position.column),
219    }
220}
221
222/// Overwrite a byte range with spaces, keeping newlines so line numbers do not move.
223fn blank(output: &mut [u8], range: std::ops::Range<usize>) {
224    for byte in &mut output[range] {
225        if *byte != b'\n' && *byte != b'\r' {
226            *byte = b' ';
227        }
228    }
229}
230
231fn strip_node(node: Node<'_>, source: &str, output: &mut Vec<u8>) -> Result<(), StripError> {
232    let kind = node.kind();
233
234    // Constructs that generate runtime code. There is no type syntax to remove, so
235    // stripping would change what the module means rather than only how it is annotated.
236    match kind {
237        "enum_declaration" => return Err(reject(node, Unsupported::Enum)),
238        "internal_module" | "module" => return Err(reject(node, Unsupported::Namespace)),
239        "decorator" => return Err(reject(node, Unsupported::Decorator)),
240        _ => {}
241    }
242
243    if BLANK_WHOLE.contains(&kind) {
244        blank(output, node.byte_range());
245        return Ok(());
246    }
247
248    match kind {
249        // `import type {...}` and `export type {...}` are entirely type-only. A `type`
250        // marker on individual specifiers is handled when those specifiers are visited.
251        "import_statement" | "export_statement" if has_leading_type_keyword(node) => {
252            blank(output, node.byte_range());
253            return Ok(());
254        }
255
256        // `x as T`, `x satisfies T`, `x!` — all of the form "an expression followed by
257        // type-only syntax". Keep the expression, blank everything after it, and keep
258        // descending so nested assertions inside it are stripped too.
259        "as_expression" | "satisfies_expression" | "non_null_expression" => {
260            if let Some(expression) = node.named_child(0) {
261                blank(output, expression.end_byte()..node.end_byte());
262                return strip_node(expression, source, output);
263            }
264        }
265
266        // An accessibility modifier on a constructor parameter is a parameter property:
267        // it declares and assigns a field. Anywhere else it is type-only.
268        // `function f(this: Window, a: number)` — a `this` parameter is TypeScript-only,
269        // and `this` is not a valid binding name in JavaScript. Blanking only its type
270        // annotation would leave `function f(this , a)`, which does not parse. The
271        // separating comma has to go with it, since `(, a)` does not parse either.
272        "required_parameter" if is_this_parameter(node) => {
273            let mut end = node.end_byte();
274            if let Some(next) = node.next_sibling()
275                && next.kind() == ","
276            {
277                end = next.end_byte();
278            }
279            blank(output, node.start_byte()..end);
280            return Ok(());
281        }
282
283        "required_parameter" | "optional_parameter" => {
284            let mut cursor = node.walk();
285            for child in node.children(&mut cursor) {
286                if child.kind() == "accessibility_modifier" && in_constructor(node, source) {
287                    return Err(reject(child, Unsupported::ParameterProperty));
288                }
289            }
290            // `b?: T` — the marker is an anonymous `?` token.
291            let mut cursor = node.walk();
292            for child in node.children(&mut cursor) {
293                if child.kind() == "?" {
294                    blank(output, child.byte_range());
295                }
296            }
297        }
298
299        _ => {}
300    }
301
302    // Bare type-only keywords: `abstract class`, `declare`, `readonly x`, `override m()`.
303    if BLANK_KEYWORD.contains(&kind) && !node.is_named() {
304        blank(output, node.byte_range());
305        return Ok(());
306    }
307
308    let mut cursor = node.walk();
309    for child in node.children(&mut cursor) {
310        // Anonymous tokens carry the type-only keywords, and `type` inside a specifier.
311        if !child.is_named() {
312            let text = &source[child.byte_range()];
313            if BLANK_KEYWORD.contains(&text)
314                || (text == "type" && matches!(kind, "import_specifier" | "export_specifier"))
315            {
316                blank(output, child.byte_range());
317                continue;
318            }
319        }
320        if child.kind() == "accessibility_modifier" {
321            if in_constructor(node, source) {
322                return Err(reject(child, Unsupported::ParameterProperty));
323            }
324            blank(output, child.byte_range());
325            continue;
326        }
327        strip_node(child, source, output)?;
328    }
329
330    Ok(())
331}
332
333/// Whether this parameter is TypeScript's `this` parameter rather than a real binding.
334fn is_this_parameter(parameter: Node<'_>) -> bool {
335    parameter
336        .named_child(0)
337        .is_some_and(|first| first.kind() == "this")
338}
339
340/// Whether an `import`/`export` statement is wholly type-only, as in `import type {...}`.
341fn has_leading_type_keyword(node: Node<'_>) -> bool {
342    let mut cursor = node.walk();
343    node.children(&mut cursor)
344        .nth(1)
345        .is_some_and(|second| !second.is_named() && second.kind() == "type")
346}
347
348/// Whether a parameter belongs to a constructor, which is what makes an accessibility
349/// modifier on it a runtime field declaration rather than an annotation.
350fn in_constructor(parameter: Node<'_>, source: &str) -> bool {
351    let mut current = parameter.parent();
352    while let Some(node) = current {
353        match node.kind() {
354            // Only a constructor's parameters declare fields. `private` on any other
355            // method's parameter is not valid TypeScript in the first place, and treating
356            // it as a parameter property would produce a misleading diagnostic for what is
357            // really a type error.
358            "method_definition" => {
359                return node
360                    .child_by_field_name("name")
361                    .is_some_and(|name| &source[name.byte_range()] == "constructor");
362            }
363            "formal_parameters" | "required_parameter" | "optional_parameter" => {
364                current = node.parent();
365            }
366            _ => return false,
367        }
368    }
369    false
370}
371
372#[cfg(test)]
373mod tests {
374    use lanekeep_lang_js::{JavaScript, TypeScript};
375
376    use super::*;
377
378    fn strip(source: &str) -> Result<String, StripError> {
379        strip_types(&TypeScript, &JavaScript, source)
380    }
381
382    fn stripped(source: &str) -> String {
383        strip(source).expect("should strip")
384    }
385
386    /// Collapse runs of spaces so assertions read as intent rather than as whitespace.
387    fn normalized(source: &str) -> String {
388        stripped(source)
389            .split_whitespace()
390            .collect::<Vec<_>>()
391            .join(" ")
392    }
393
394    #[test]
395    fn positions_are_preserved_exactly() {
396        // The property the whole approach exists for. Byte length identical, newlines
397        // untouched, so a line and column in the output is the same one in the input.
398        let source = "const x: number = 1;\ninterface A { b: string }\nconst y: A = { b: 'q' };\n";
399        let out = stripped(source);
400
401        assert_eq!(out.len(), source.len(), "byte length must not change");
402        assert_eq!(
403            out.lines().count(),
404            source.lines().count(),
405            "line count must not change"
406        );
407        for (index, (before, after)) in source.lines().zip(out.lines()).enumerate() {
408            assert_eq!(
409                before.len(),
410                after.len(),
411                "line {} changed length",
412                index + 1
413            );
414        }
415    }
416
417    #[test]
418    fn strips_type_annotations() {
419        assert_eq!(normalized("const x: number = 1;"), "const x = 1;");
420        assert_eq!(
421            normalized("function f(a: string, b: number): void {}"),
422            "function f(a , b ) {}"
423        );
424    }
425
426    #[test]
427    fn strips_interfaces_and_type_aliases() {
428        assert_eq!(
429            normalized("interface A { b: string }\nconst c = 1;"),
430            "const c = 1;"
431        );
432        assert_eq!(
433            normalized("type B = string | null;\nconst c = 1;"),
434            "const c = 1;"
435        );
436    }
437
438    #[test]
439    fn strips_generics() {
440        assert_eq!(
441            normalized("function f<T>(a: T): T { return a }"),
442            "function f (a ) { return a }"
443        );
444        assert_eq!(
445            normalized("const m = new Map<string, number>();"),
446            "const m = new Map ();"
447        );
448    }
449
450    #[test]
451    fn strips_assertions_but_keeps_the_expression() {
452        assert_eq!(normalized("const y = z as Foo;"), "const y = z ;");
453        assert_eq!(normalized("const w = v satisfies Bar;"), "const w = v ;");
454        assert_eq!(normalized("const u = t!;"), "const u = t ;");
455        // Nested, to prove the inner expression is still visited.
456        assert_eq!(normalized("const a = (b as C).d;"), "const a = (b ).d;");
457    }
458
459    #[test]
460    fn strips_optional_parameter_markers() {
461        assert_eq!(normalized("function f(a?: string) {}"), "function f(a ) {}");
462    }
463
464    #[test]
465    fn strips_type_only_imports_and_exports() {
466        assert_eq!(
467            normalized("import type { A } from './a';\nconst c = 1;"),
468            "const c = 1;"
469        );
470        assert_eq!(
471            normalized("export type { Z };\nconst c = 1;"),
472            "const c = 1;"
473        );
474    }
475
476    #[test]
477    fn strips_inline_type_specifiers_but_keeps_the_value_import() {
478        // `import { type B, C }` must keep C — blanking the whole statement would delete a
479        // real binding and produce a ReferenceError at runtime.
480        let out = normalized("import { type B, C } from './b';");
481        assert!(out.contains('C'), "value import must survive: {out}");
482        assert!(
483            out.contains("from './b'"),
484            "the module specifier must survive: {out}"
485        );
486        assert!(!out.contains("type"), "the type marker must go: {out}");
487    }
488
489    #[test]
490    fn strips_declare_and_ambient_declarations() {
491        assert_eq!(
492            normalized("declare const g: number;\nconst c = 1;"),
493            "const c = 1;"
494        );
495    }
496
497    #[test]
498    fn strips_class_type_syntax() {
499        let out = normalized("class K implements I { readonly n: number = 1; }");
500        assert!(!out.contains("implements"), "{out}");
501        assert!(!out.contains("readonly"), "{out}");
502        assert!(
503            out.contains("n = 1"),
504            "the field initializer must survive: {out}"
505        );
506    }
507
508    #[test]
509    fn strips_abstract_classes() {
510        let out = normalized("abstract class M { go() { return 1 } }");
511        assert!(!out.contains("abstract"), "{out}");
512        assert!(out.contains("class M"), "{out}");
513    }
514
515    #[test]
516    fn strips_type_predicates() {
517        let out = normalized("function isFoo(x: unknown): x is Foo { return true }");
518        assert!(!out.contains(" is Foo"), "{out}");
519        assert!(out.contains("return true"), "{out}");
520    }
521
522    #[test]
523    fn strips_this_parameters_entirely() {
524        // `this` is not a valid binding name in JavaScript, so blanking only its type
525        // annotation would leave source that does not parse. The safety net catches that,
526        // but the right behavior is to remove the parameter and its comma.
527        let out = normalized("function f(this: Window, a: number) { return a }");
528        assert!(!out.contains("this"), "{out}");
529        assert!(out.contains("function f("), "{out}");
530        assert!(out.contains("return a"), "{out}");
531
532        let only = normalized("function g(this: Window) { return 1 }");
533        assert!(!only.contains("this"), "{only}");
534    }
535
536    #[test]
537    fn leaves_plain_javascript_untouched() {
538        for source in [
539            "const a = 1;",
540            "export default function () { return [1,2,3].map(x => x * 2) }",
541            "class A extends B { #p = 1; static s() {} }",
542            "const { a, ...rest } = obj; const [x, y] = arr;",
543            "async function f() { for await (const x of y) {} }",
544        ] {
545            assert_eq!(
546                stripped(source),
547                source,
548                "plain JavaScript should be unchanged"
549            );
550        }
551    }
552
553    // --- rejections ------------------------------------------------------------------
554
555    #[test]
556    fn rejects_enums() {
557        let err = strip("enum E { A, B }").expect_err("enums generate runtime code");
558        assert!(matches!(
559            err,
560            StripError::Unsupported {
561                construct: Unsupported::Enum,
562                ..
563            }
564        ));
565
566        let rendered = err.to_string();
567        assert!(
568            rendered.contains("as const"),
569            "should suggest the alternative: {rendered}"
570        );
571        assert!(rendered.contains("line 1"), "should say where: {rendered}");
572    }
573
574    #[test]
575    fn rejects_namespaces() {
576        let err = strip("namespace N { export const q = 1 }").expect_err("namespaces emit code");
577        assert!(matches!(
578            err,
579            StripError::Unsupported {
580                construct: Unsupported::Namespace,
581                ..
582            }
583        ));
584    }
585
586    #[test]
587    fn rejects_parameter_properties() {
588        // The subtle one: `private` here declares and assigns a field, so blanking it
589        // would silently produce a class whose field is never set.
590        let err = strip("class K { constructor(private p: string) {} }")
591            .expect_err("parameter properties emit code");
592        assert!(matches!(
593            err,
594            StripError::Unsupported {
595                construct: Unsupported::ParameterProperty,
596                ..
597            }
598        ));
599    }
600
601    #[test]
602    fn an_accessibility_modifier_outside_a_constructor_is_type_only() {
603        // The counterpart to the case above: `private` on a field is an annotation, and
604        // rejecting it too would be over-broad.
605        let out = normalized("class K { private n = 1; }");
606        assert!(!out.contains("private"), "{out}");
607        assert!(out.contains("n = 1"), "{out}");
608    }
609
610    #[test]
611    fn reports_the_line_of_the_offending_construct() {
612        let err = strip("const a = 1;\nconst b = 2;\nenum E { X }").expect_err("rejects");
613        match err {
614            StripError::Unsupported { line, .. } => assert_eq!(line, 3),
615            other => panic!("wrong error: {other:?}"),
616        }
617    }
618
619    #[test]
620    fn rejects_source_that_is_not_typescript() {
621        let err = strip("function ( { ] }").expect_err("does not parse");
622        assert!(matches!(err, StripError::Syntax { .. }), "{err:?}");
623    }
624
625    #[test]
626    fn handles_empty_input() {
627        assert_eq!(stripped(""), "");
628        assert_eq!(stripped("\n\n"), "\n\n");
629    }
630
631    // --- the safety net ------------------------------------------------------------------
632
633    #[test]
634    fn every_stripped_result_parses_as_javascript() {
635        // strip_types verifies this internally, so reaching Ok here means the check passed.
636        // Running a realistic module through it is what makes that check meaningful.
637        let source = r"
638import type { Rule } from 'lanekeep';
639import { defineRule } from 'lanekeep';
640
641interface Options {
642  readonly max: number;
643}
644
645type Names = 'a' | 'b';
646
647export default defineRule({
648  id: 'local/example',
649  query: '(identifier) @id',
650  check(ctx: unknown, m: { id: unknown }): void {
651    const names: Names[] = ['a', 'b'];
652    const n = (ctx as Options).max;
653    for (const name of names) {
654      if (n! > 0) { (ctx as { report(x: unknown): void }).report(m.id); }
655    }
656  },
657});
658";
659        let out = stripped(source);
660        assert_eq!(
661            out.len(),
662            source.len(),
663            "positions must survive a realistic module"
664        );
665        assert!(!out.contains("interface"), "{out}");
666        assert!(!out.contains(": number"), "{out}");
667        assert!(out.contains("defineRule"), "the runtime code must survive");
668        assert!(
669            out.contains("report(m.id)"),
670            "the runtime code must survive"
671        );
672    }
673}