Skip to main content

normalize_languages/
typescript.rs

1//! TypeScript language support.
2
3use std::path::{Path, PathBuf};
4
5use crate::ecmascript;
6use crate::{
7    ContainerBody, Import, ImportSpec, Language, LanguageSymbols, ModuleId, ModuleResolver,
8    Resolution, ResolverConfig, Visibility,
9};
10use tree_sitter::Node;
11
12/// TypeScript language support.
13pub struct TypeScript;
14
15/// TSX language support (TypeScript + JSX).
16pub struct Tsx;
17
18impl Language for TypeScript {
19    fn name(&self) -> &'static str {
20        "TypeScript"
21    }
22    fn extensions(&self) -> &'static [&'static str] {
23        &["ts", "mts", "cts"]
24    }
25    fn grammar_name(&self) -> &'static str {
26        "typescript"
27    }
28
29    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
30        Some(self)
31    }
32
33    fn signature_suffix(&self) -> &'static str {
34        " {}"
35    }
36
37    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
38        ecmascript::extract_jsdoc(node, content)
39    }
40
41    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
42        ecmascript::extract_implements(node, content)
43    }
44
45    fn build_signature(&self, node: &Node, content: &str) -> String {
46        let name = match self.node_name(node, content) {
47            Some(n) => n,
48            None => {
49                return content[node.byte_range()]
50                    .lines()
51                    .next()
52                    .unwrap_or("")
53                    .trim()
54                    .to_string();
55            }
56        };
57        ecmascript::build_signature(node, content, name)
58    }
59
60    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
61        ecmascript::extract_imports(node, content)
62    }
63
64    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
65        ecmascript::format_import(import, names)
66    }
67
68    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
69        let name = symbol.name.as_str();
70        match symbol.kind {
71            crate::SymbolKind::Function | crate::SymbolKind::Method => {
72                name.starts_with("test_")
73                    || name.starts_with("Test")
74                    || name == "describe"
75                    || name == "it"
76                    || name == "test"
77            }
78            crate::SymbolKind::Module => name == "tests" || name == "test" || name == "__tests__",
79            _ => false,
80        }
81    }
82
83    fn test_file_globs(&self) -> &'static [&'static str] {
84        &[
85            "**/__tests__/**/*.ts",
86            "**/__mocks__/**/*.ts",
87            "**/*.test.ts",
88            "**/*.spec.ts",
89            "**/*.test.tsx",
90            "**/*.spec.tsx",
91        ]
92    }
93
94    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
95        ecmascript::extract_decorators(node, content)
96    }
97
98    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
99        // Try 'body' field first, then look for interface_body or class_body child
100        if let Some(body) = node.child_by_field_name("body") {
101            return Some(body);
102        }
103        // Fallback: find interface_body or class_body child
104        for i in 0..node.child_count() as u32 {
105            if let Some(child) = node.child(i)
106                && (child.kind() == "interface_body" || child.kind() == "class_body")
107            {
108                return Some(child);
109            }
110        }
111        None
112    }
113
114    fn analyze_container_body(
115        &self,
116        body_node: &Node,
117        content: &str,
118        inner_indent: &str,
119    ) -> Option<ContainerBody> {
120        crate::body::analyze_brace_body(body_node, content, inner_indent)
121    }
122
123    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
124        ecmascript::get_visibility(node, content)
125    }
126
127    fn extract_module_doc(&self, src: &str) -> Option<String> {
128        ecmascript::extract_js_module_doc(src)
129    }
130
131    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
132        static RESOLVER: TsModuleResolver = TsModuleResolver;
133        Some(&RESOLVER)
134    }
135}
136
137impl LanguageSymbols for TypeScript {}
138
139// TSX shares the same implementation as TypeScript, just with a different grammar
140impl Language for Tsx {
141    fn name(&self) -> &'static str {
142        "TSX"
143    }
144    fn extensions(&self) -> &'static [&'static str] {
145        &["tsx"]
146    }
147    fn grammar_name(&self) -> &'static str {
148        "tsx"
149    }
150
151    fn signature_suffix(&self) -> &'static str {
152        " {}"
153    }
154
155    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
156        ecmascript::extract_jsdoc(node, content)
157    }
158
159    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
160        ecmascript::extract_implements(node, content)
161    }
162
163    fn build_signature(&self, node: &Node, content: &str) -> String {
164        let name = match self.node_name(node, content) {
165            Some(n) => n,
166            None => {
167                return content[node.byte_range()]
168                    .lines()
169                    .next()
170                    .unwrap_or("")
171                    .trim()
172                    .to_string();
173            }
174        };
175        ecmascript::build_signature(node, content, name)
176    }
177
178    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
179        ecmascript::extract_imports(node, content)
180    }
181
182    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
183        ecmascript::format_import(import, names)
184    }
185
186    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
187        let name = symbol.name.as_str();
188        match symbol.kind {
189            crate::SymbolKind::Function | crate::SymbolKind::Method => {
190                name.starts_with("test_")
191                    || name.starts_with("Test")
192                    || name == "describe"
193                    || name == "it"
194                    || name == "test"
195            }
196            crate::SymbolKind::Module => name == "tests" || name == "test" || name == "__tests__",
197            _ => false,
198        }
199    }
200
201    fn test_file_globs(&self) -> &'static [&'static str] {
202        &[
203            "**/__tests__/**/*.ts",
204            "**/__mocks__/**/*.ts",
205            "**/*.test.ts",
206            "**/*.spec.ts",
207            "**/*.test.tsx",
208            "**/*.spec.tsx",
209        ]
210    }
211
212    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
213        ecmascript::extract_decorators(node, content)
214    }
215
216    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
217        // Try 'body' field first, then look for interface_body or class_body child
218        if let Some(body) = node.child_by_field_name("body") {
219            return Some(body);
220        }
221        // Fallback: find interface_body or class_body child
222        for i in 0..node.child_count() as u32 {
223            if let Some(child) = node.child(i)
224                && (child.kind() == "interface_body" || child.kind() == "class_body")
225            {
226                return Some(child);
227            }
228        }
229        None
230    }
231
232    fn analyze_container_body(
233        &self,
234        body_node: &Node,
235        content: &str,
236        inner_indent: &str,
237    ) -> Option<ContainerBody> {
238        crate::body::analyze_brace_body(body_node, content, inner_indent)
239    }
240
241    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
242        ecmascript::get_visibility(node, content)
243    }
244
245    fn extract_module_doc(&self, src: &str) -> Option<String> {
246        ecmascript::extract_js_module_doc(src)
247    }
248
249    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
250        static RESOLVER: TsModuleResolver = TsModuleResolver;
251        Some(&RESOLVER)
252    }
253}
254
255// =============================================================================
256// TypeScript / TSX Module Resolver
257// =============================================================================
258
259/// Module resolver for TypeScript/TSX.
260///
261/// Handles:
262/// - Relative imports (`./`, `../`)
263/// - tsconfig.json `compilerOptions.paths` (alias mappings)
264/// - tsconfig.json `compilerOptions.baseUrl` (search root)
265/// - `.js` → `.ts` extension elision (TS compiles `.js` imports as `.ts`)
266pub struct TsModuleResolver;
267
268impl ModuleResolver for TsModuleResolver {
269    fn workspace_config(&self, root: &Path) -> ResolverConfig {
270        let mut path_mappings: Vec<(String, PathBuf)> = Vec::new();
271        let mut search_roots: Vec<PathBuf> = Vec::new();
272
273        // Try to read tsconfig.json
274        let tsconfig_path = root.join("tsconfig.json");
275        if let Ok(content) = std::fs::read_to_string(&tsconfig_path)
276            && let Ok(tsconfig) = serde_json::from_str::<serde_json::Value>(&content)
277        {
278            let compiler_opts = tsconfig.get("compilerOptions");
279
280            // Parse baseUrl
281            if let Some(base_url) = compiler_opts
282                .and_then(|o| o.get("baseUrl"))
283                .and_then(|v| v.as_str())
284            {
285                let base = root.join(base_url);
286                search_roots.push(base);
287            }
288
289            // Parse paths aliases
290            if let Some(paths) = compiler_opts
291                .and_then(|o| o.get("paths"))
292                .and_then(|v| v.as_object())
293            {
294                for (alias, targets) in paths {
295                    if let Some(first) = targets
296                        .as_array()
297                        .and_then(|arr| arr.first())
298                        .and_then(|v| v.as_str())
299                    {
300                        // Strip trailing /* from alias pattern and target
301                        let alias_key = alias.trim_end_matches("/*").to_string();
302                        let target_path = root.join(first.trim_end_matches("/*"));
303                        path_mappings.push((alias_key, target_path));
304                    }
305                }
306            }
307        }
308
309        ResolverConfig {
310            workspace_root: root.to_path_buf(),
311            path_mappings,
312            search_roots,
313        }
314    }
315
316    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
317        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
318        if !matches!(ext, "ts" | "tsx" | "mts" | "cts") {
319            return Vec::new();
320        }
321
322        // Derive module path relative to workspace root (or first search root)
323        let base = cfg.search_roots.first().unwrap_or(&cfg.workspace_root);
324
325        let rel = file
326            .strip_prefix(base)
327            .or_else(|_| file.strip_prefix(&cfg.workspace_root))
328            .unwrap_or(file);
329
330        // Strip extension
331        let stem = rel.with_extension("");
332        let module_path = stem
333            .components()
334            .filter_map(|c| {
335                if let std::path::Component::Normal(s) = c {
336                    s.to_str()
337                } else {
338                    None
339                }
340            })
341            .collect::<Vec<_>>()
342            .join("/");
343
344        if module_path.is_empty() {
345            return Vec::new();
346        }
347
348        vec![ModuleId {
349            canonical_path: module_path,
350        }]
351    }
352
353    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
354        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
355        if !matches!(ext, "ts" | "tsx" | "mts" | "cts") {
356            return Resolution::NotApplicable;
357        }
358
359        let raw = &spec.raw;
360
361        // Skip node_modules / bare node_modules imports
362        if raw.starts_with("node_modules/") {
363            return Resolution::NotFound;
364        }
365
366        // 1. Relative imports
367        if spec.is_relative || raw.starts_with("./") || raw.starts_with("../") {
368            let base_dir = from_file.parent().unwrap_or(from_file);
369            return resolve_ts_relative(base_dir, raw);
370        }
371
372        // 2. Path alias (tsconfig paths)
373        for (alias, target_dir) in &cfg.path_mappings {
374            if raw == alias || raw.starts_with(&format!("{}/", alias)) {
375                let rest = raw.strip_prefix(alias).unwrap_or("");
376                let rest = rest.strip_prefix('/').unwrap_or(rest);
377                let candidate = if rest.is_empty() {
378                    target_dir.clone()
379                } else {
380                    target_dir.join(rest)
381                };
382                let result = resolve_ts_file_candidates(&candidate);
383                if !matches!(result, Resolution::NotFound) {
384                    return result;
385                }
386            }
387        }
388
389        // 3. baseUrl-relative bare imports
390        for search_root in &cfg.search_roots {
391            let candidate = search_root.join(raw);
392            let result = resolve_ts_file_candidates(&candidate);
393            if !matches!(result, Resolution::NotFound) {
394                return result;
395            }
396        }
397
398        Resolution::NotFound
399    }
400}
401
402/// Try .ts, .tsx, /index.ts, /index.tsx candidates for a base path.
403fn resolve_ts_file_candidates(base: &Path) -> Resolution {
404    // Try as-is with ts extensions
405    let candidates = [
406        base.with_extension("ts"),
407        base.with_extension("tsx"),
408        base.join("index.ts"),
409        base.join("index.tsx"),
410    ];
411    for c in &candidates {
412        if c.exists() {
413            return Resolution::Resolved(c.clone(), String::new());
414        }
415    }
416    Resolution::NotFound
417}
418
419/// Resolve a relative specifier from a directory.
420fn resolve_ts_relative(base_dir: &Path, raw: &str) -> Resolution {
421    // Normalize the path
422    let joined = base_dir.join(raw);
423    let normalized = normalize_path(&joined);
424
425    // Strip .js extension (TS compiles .js imports as .ts)
426    let base = if normalized.extension().and_then(|e| e.to_str()) == Some("js") {
427        normalized.with_extension("")
428    } else {
429        normalized.clone()
430    };
431
432    resolve_ts_file_candidates(&base)
433}
434
435/// Simple path normalization (handle `..` components).
436fn normalize_path(path: &Path) -> PathBuf {
437    let mut out = PathBuf::new();
438    for component in path.components() {
439        match component {
440            std::path::Component::ParentDir => {
441                out.pop();
442            }
443            std::path::Component::CurDir => {}
444            c => out.push(c),
445        }
446    }
447    out
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::validate_unused_kinds_audit;
454
455    /// Documents node kinds that exist in the TypeScript grammar but aren't used in trait methods.
456    /// Run `cross_check_node_kinds` in registry.rs to see all potentially useful kinds.
457    #[test]
458    fn unused_node_kinds_audit() {
459        #[rustfmt::skip]
460        let documented_unused: &[&str] = &[
461            // STRUCTURAL
462            "class_body",              // class body block
463            "class_heritage",          // extends clause
464            "class_static_block",      // static { }
465            "enum_assignment",         // enum value assignment
466            "enum_body",               // enum body
467            "formal_parameters",       // function params
468            "identifier",              // too common
469            "interface_body",          // interface body
470            "nested_identifier",       // a.b.c path
471            "nested_type_identifier",  // a.b.Type path
472            "private_property_identifier", // #field
473            "property_identifier",     // obj.prop
474            "public_field_definition", // class field
475            "shorthand_property_identifier", // { x } shorthand
476            "shorthand_property_identifier_pattern", // destructuring
477            "statement_block",         // { }
478            "statement_identifier",    // label name
479            "switch_body",             // switch cases
480
481            // CLAUSE
482            "default_type",            // default type param
483            "else_clause",             // else branch
484            "extends_clause",          // class extends
485            "extends_type_clause",     // T extends U
486            "finally_clause",          // finally block
487            "implements_clause",       // implements X
488
489            // EXPRESSION
490            "as_expression",           // x as T
491            "assignment_expression",   // x = y
492            "augmented_assignment_expression", // x += y
493            "await_expression",        // await foo
494            "call_expression",         // foo()
495            "function_expression",     // function() {}
496            "instantiation_expression",// generic call
497            "member_expression",       // foo.bar          // new Foo()
498            "non_null_expression",     // x!
499            "parenthesized_expression",// (expr)
500            "satisfies_expression",    // x satisfies T
501            "sequence_expression",     // a, b
502            "subscript_expression",    // arr[i]
503            "unary_expression",        // -x, !x
504            "update_expression",       // x++
505            "yield_expression",        // yield x
506
507            // TYPE NODES
508            "adding_type_annotation",  // : T
509            "array_type",              // T[]
510            "conditional_type",        // T extends U ? V : W
511            "construct_signature",     // new(): T
512            "constructor_type",        // new (x: T) => U
513            "existential_type",        // *
514            "flow_maybe_type",         // ?T      // function sig
515            "function_type",           // (x: T) => U
516            "generic_type",            // T<U>
517            "index_type_query",        // keyof T
518            "infer_type",              // infer T
519            "intersection_type",       // T & U
520            "literal_type",            // "foo" type
521            "lookup_type",             // T[K]
522            "mapped_type_clause",      // [K in T]
523            "object_type",             // { x: T }
524            "omitting_type_annotation",// omit annotation
525            "opting_type_annotation",  // optional annotation
526            "optional_type",           // T?
527            "override_modifier",       // override
528            "parenthesized_type",      // (T)
529            "predefined_type",         // string, number
530            "readonly_type",           // readonly T
531            "rest_type",               // ...T
532            "template_literal_type",   // `${T}`
533            "template_type",           // template type
534            "this_type",               // this
535            "tuple_type",              // [T, U]         // : T
536            "type_arguments",          // <T, U>
537            "type_assertion",          // <T>x         // type name
538            "type_parameter",          // T
539            "type_parameters",         // <T, U>
540            "type_predicate",          // x is T
541            "type_predicate_annotation", // : x is T
542            "type_query",              // typeof x
543            "union_type",              // T | U
544
545            // IMPORT/EXPORT DETAILS
546            "accessibility_modifier",  // public/private/protected
547            "export_clause",           // export { a, b }
548            "export_specifier",        // export { a as b }
549            "import",                  // import keyword
550            "import_alias",            // import X = Y
551            "import_attribute",        // import attributes
552            "import_clause",           // import clause
553            "import_require_clause",   // require()
554            "import_specifier",        // import { a }
555            "named_imports",           // { a, b }
556            "namespace_export",        // export * as ns
557            "namespace_import",        // import * as ns
558
559            // DECLARATION // abstract class // abstract method
560            "ambient_declaration",     // declare
561            "debugger_statement",      // debugger;
562            "empty_statement",         // ;
563            "expression_statement",    // expr;
564            "generator_function",      // function* foo
565            "generator_function_declaration", // function* declaration
566            "internal_module",         // namespace/module
567            "labeled_statement",       // label: stmt
568            "lexical_declaration",     // let/const                  // module keyword
569            "using_declaration",       // using x = ...
570            "variable_declaration",    // var x
571            "with_statement",          // with (obj) - deprecated
572            // control flow — not extracted as symbols
573            "for_in_statement",
574            "switch_case",
575            "continue_statement",
576            "do_statement",
577            "return_statement",
578            "class",
579            "switch_statement",
580            "binary_expression",
581            "while_statement",
582            "for_statement",
583            "if_statement",
584            "throw_statement",
585            "try_statement",
586            "break_statement",
587            "arrow_function",
588            "catch_clause",
589            "ternary_expression",
590            "import_statement",
591            "export_statement",
592        ];
593
594        validate_unused_kinds_audit(&TypeScript, documented_unused)
595            .expect("TypeScript unused node kinds audit failed");
596    }
597}