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();
185 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
221fn 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 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_statement" | "export_statement" if has_leading_type_keyword(node) => {
251 blank(output, node.byte_range());
252 return Ok(());
253 }
254
255 "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 "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 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 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 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
332fn is_this_parameter(parameter: Node<'_>) -> bool {
334 parameter
335 .named_child(0)
336 .is_some_and(|first| first.kind() == "this")
337}
338
339fn 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
347fn in_constructor(parameter: Node<'_>, source: &str) -> bool {
350 let mut current = parameter.parent();
351 while let Some(node) = current {
352 match node.kind() {
353 "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 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 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 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 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 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 #[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 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 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 #[test]
633 fn every_stripped_result_parses_as_javascript() {
634 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}