1use lanekeep_lang::Language;
37use thiserror::Error;
38use tree_sitter::{Node, Parser, Tree};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Unsupported {
43 Enum,
45 Namespace,
47 Decorator,
49 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
75pub enum StripError {
76 #[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 construct: Unsupported,
87 line: u32,
89 column: u32,
91 },
92
93 #[error("rule module is not valid TypeScript\n at line {line}, column {column}")]
95 Syntax {
96 line: u32,
98 column: u32,
100 },
101
102 #[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 line: u32,
112 column: u32,
114 },
115}
116
117const 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
139const BLANK_KEYWORD: &[&str] = &["abstract", "declare", "override", "readonly"];
141
142pub 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 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();
186 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
222fn 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 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_statement" | "export_statement" if has_leading_type_keyword(node) => {
252 blank(output, node.byte_range());
253 return Ok(());
254 }
255
256 "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 "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 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 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 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
333fn is_this_parameter(parameter: Node<'_>) -> bool {
335 parameter
336 .named_child(0)
337 .is_some_and(|first| first.kind() == "this")
338}
339
340fn 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
348fn in_constructor(parameter: Node<'_>, source: &str) -> bool {
351 let mut current = parameter.parent();
352 while let Some(node) = current {
353 match node.kind() {
354 "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 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 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 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 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 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 #[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 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 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 #[test]
634 fn every_stripped_result_parses_as_javascript() {
635 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}