Skip to main content

astmap_languages/language/
typescript.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use tracing::warn;
5use tree_sitter::Language;
6
7use astmap_core::SymbolKind;
8
9use super::{
10    collect_type_refs_by_kind, find_enclosing_symbol, node_text, parse_with_tree_sitter,
11    symbol_from_node, ExtractedCall, ExtractedImport, ExtractedSymbol, LanguageParser,
12    LanguageResolver, LanguageSupport, ParseResult,
13};
14
15pub(super) fn lang() -> LanguageSupport {
16    LanguageSupport {
17        name: "typescript",
18        extensions: &["ts", "tsx"],
19        parser: &TypeScriptParser,
20        resolver_factory: |root| Box::new(TypeScriptResolver::new(root)),
21        config_files: &["tsconfig.json"],
22        sibling_fn: |_| None, // TypeScript has no sibling expansion
23    }
24}
25
26// ── Parser ──
27
28pub(crate) struct TypeScriptParser;
29
30impl LanguageParser for TypeScriptParser {
31    fn parse(&self, source: &str, file_path: &Path) -> ParseResult {
32        let language = if file_path.extension().is_some_and(|e| e == "tsx") {
33            tsx_language()
34        } else {
35            ts_language()
36        };
37        parse_ts(source, language)
38    }
39
40    fn language_name(&self) -> &str {
41        "typescript"
42    }
43}
44
45fn ts_language() -> Language {
46    tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
47}
48
49fn tsx_language() -> Language {
50    tree_sitter_typescript::LANGUAGE_TSX.into()
51}
52
53fn parse_ts(source: &str, language: Language) -> ParseResult {
54    parse_with_tree_sitter(
55        source,
56        &language,
57        |root, src, symbols, imports| extract_from_node(root, src, symbols, imports, None),
58        extract_calls,
59        |root, src, symbols| {
60            collect_type_refs_by_kind(root, src, symbols, "type_identifier", &is_ts_builtin_type)
61        },
62    )
63}
64
65fn extract_from_node(
66    node: tree_sitter::Node,
67    source: &str,
68    symbols: &mut Vec<ExtractedSymbol>,
69    imports: &mut Vec<ExtractedImport>,
70    parent_index: Option<usize>,
71) {
72    let kind = node.kind();
73
74    match kind {
75        "function_declaration" => {
76            if let Some(sym) = extract_function_or_component(&node, source, parent_index) {
77                symbols.push(sym);
78                return;
79            }
80        }
81        "class_declaration" => {
82            if let Some(sym) = extract_class(&node, source, parent_index) {
83                let idx = symbols.len();
84                symbols.push(sym);
85                // Recurse into class body for methods
86                if let Some(body) = node.child_by_field_name("body") {
87                    for i in 0..body.child_count() {
88                        if let Some(child) = body.child(i as u32) {
89                            extract_from_node(child, source, symbols, imports, Some(idx));
90                        }
91                    }
92                }
93                return;
94            }
95        }
96        "method_definition" => {
97            if let Some(sym) = extract_method(&node, source, parent_index) {
98                symbols.push(sym);
99                return;
100            }
101        }
102        "interface_declaration" => {
103            if let Some(sym) =
104                extract_named_symbol(&node, source, SymbolKind::Interface, parent_index)
105            {
106                symbols.push(sym);
107                return;
108            }
109        }
110        "type_alias_declaration" => {
111            if let Some(sym) =
112                extract_named_symbol(&node, source, SymbolKind::TypeAlias, parent_index)
113            {
114                symbols.push(sym);
115                return;
116            }
117        }
118        "enum_declaration" => {
119            if let Some(sym) = extract_named_symbol(&node, source, SymbolKind::Enum, parent_index) {
120                symbols.push(sym);
121                return;
122            }
123        }
124        "export_statement" => {
125            for i in 0..node.child_count() {
126                if let Some(child) = node.child(i as u32) {
127                    extract_from_node(child, source, symbols, imports, parent_index);
128                }
129            }
130            return;
131        }
132        "import_statement" => {
133            if let Some(imp) = extract_import(&node, source) {
134                imports.push(imp);
135            }
136            return;
137        }
138        "lexical_declaration" => {
139            let is_exported = node
140                .parent()
141                .is_some_and(|p| p.kind() == "export_statement");
142            for i in 0..node.child_count() {
143                if let Some(child) = node.child(i as u32) {
144                    if child.kind() == "variable_declarator" {
145                        if let Some(sym) =
146                            extract_arrow_function_symbol(&child, source, parent_index, is_exported)
147                        {
148                            symbols.push(sym);
149                        } else if is_exported {
150                            if let Some(sym) = extract_variable_symbol(&child, source, parent_index)
151                            {
152                                symbols.push(sym);
153                            }
154                        }
155                    }
156                }
157            }
158            return;
159        }
160        _ => {}
161    }
162
163    // Recurse into children
164    for i in 0..node.child_count() {
165        if let Some(child) = node.child(i as u32) {
166            extract_from_node(child, source, symbols, imports, parent_index);
167        }
168    }
169}
170
171fn extract_function_or_component(
172    node: &tree_sitter::Node,
173    source: &str,
174    parent_index: Option<usize>,
175) -> Option<ExtractedSymbol> {
176    let name_node = node.child_by_field_name("name")?;
177    let name = node_text(&name_node, source);
178    let is_component = is_pascal_case(&name) && body_returns_jsx(node, source);
179
180    let kind = if parent_index.is_some() {
181        SymbolKind::Method
182    } else if is_component {
183        SymbolKind::Component
184    } else {
185        SymbolKind::Function
186    };
187
188    Some(symbol_from_node(name, kind, node, source, parent_index))
189}
190
191fn extract_class(
192    node: &tree_sitter::Node,
193    source: &str,
194    parent_index: Option<usize>,
195) -> Option<ExtractedSymbol> {
196    let name_node = node.child_by_field_name("name")?;
197    let name = node_text(&name_node, source);
198
199    // Check if it extends React.Component / Component
200    let kind = if extends_react_component(node, source) {
201        SymbolKind::Component
202    } else {
203        SymbolKind::Class
204    };
205
206    Some(symbol_from_node(name, kind, node, source, parent_index))
207}
208
209fn extract_method(
210    node: &tree_sitter::Node,
211    source: &str,
212    parent_index: Option<usize>,
213) -> Option<ExtractedSymbol> {
214    let name_node = node.child_by_field_name("name")?;
215    let name = node_text(&name_node, source);
216    Some(symbol_from_node(
217        name,
218        SymbolKind::Method,
219        node,
220        source,
221        parent_index,
222    ))
223}
224
225fn extract_named_symbol(
226    node: &tree_sitter::Node,
227    source: &str,
228    kind: SymbolKind,
229    parent_index: Option<usize>,
230) -> Option<ExtractedSymbol> {
231    let name_node = node.child_by_field_name("name")?;
232    let name = node_text(&name_node, source);
233    Some(symbol_from_node(name, kind, node, source, parent_index))
234}
235
236fn extract_variable_symbol(
237    declarator: &tree_sitter::Node,
238    source: &str,
239    parent_index: Option<usize>,
240) -> Option<ExtractedSymbol> {
241    let name_node = declarator.child_by_field_name("name")?;
242    let name = node_text(&name_node, source);
243
244    // Use the outermost statement node (export_statement) for line range
245    let parent = declarator.parent()?;
246    let top = parent.parent().unwrap_or(parent);
247
248    Some(symbol_from_node(
249        name,
250        SymbolKind::Variable,
251        &top,
252        source,
253        parent_index,
254    ))
255}
256
257fn extract_arrow_function_symbol(
258    declarator: &tree_sitter::Node,
259    source: &str,
260    parent_index: Option<usize>,
261    is_exported: bool,
262) -> Option<ExtractedSymbol> {
263    let name_node = declarator.child_by_field_name("name")?;
264    let name = node_text(&name_node, source);
265
266    let value = declarator.child_by_field_name("value")?;
267    if value.kind() != "arrow_function" {
268        return None;
269    }
270
271    if !is_exported {
272        return None;
273    }
274
275    let is_component = is_pascal_case(&name) && arrow_returns_jsx(&value, source);
276
277    let kind = if is_component {
278        SymbolKind::Component
279    } else {
280        SymbolKind::Function
281    };
282
283    // Use the parent export_statement or lexical_declaration for line range
284    let parent = declarator.parent()?;
285    let top = parent.parent().unwrap_or(parent);
286
287    Some(symbol_from_node(name, kind, &top, source, parent_index))
288}
289
290fn extract_import(node: &tree_sitter::Node, source: &str) -> Option<ExtractedImport> {
291    let src_node = node.child_by_field_name("source")?;
292    let raw = node_text(&src_node, source);
293    // Strip quotes: "'./foo'" or '"./foo"' -> "./foo"
294    let path = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
295
296    // Extract named imports: import { Foo, Bar } from '...'
297    // and default imports: import Foo from '...'
298    let imported_symbols: Vec<String> = (0..node.child_count())
299        .filter_map(|i| node.child(i as u32))
300        .flat_map(|child| match child.kind() {
301            "import_clause" => collect_import_names(&child, source),
302            "identifier" => vec![node_text(&child, source)],
303            _ => Vec::new(),
304        })
305        .collect();
306
307    Some(ExtractedImport {
308        path,
309        imported_symbols,
310    })
311}
312
313fn collect_import_names(node: &tree_sitter::Node, source: &str) -> Vec<String> {
314    (0..node.child_count())
315        .filter_map(|i| node.child(i as u32))
316        .flat_map(|child| match child.kind() {
317            "identifier" => vec![node_text(&child, source)],
318            "named_imports" => (0..child.child_count())
319                .filter_map(|j| child.child(j as u32))
320                .filter(|spec| spec.kind() == "import_specifier")
321                .filter_map(|spec| {
322                    let local = spec
323                        .child_by_field_name("alias")
324                        .or_else(|| spec.child_by_field_name("name"))
325                        .unwrap_or(spec);
326                    (local.kind() == "identifier").then(|| node_text(&local, source))
327                })
328                .collect(),
329            "namespace_import" => (0..child.child_count())
330                .filter_map(|k| child.child(k as u32))
331                .filter(|ns_child| ns_child.kind() == "identifier")
332                .map(|ns_child| node_text(&ns_child, source))
333                .collect(),
334            _ => collect_import_names(&child, source),
335        })
336        .collect()
337}
338
339// --- Call extraction ---
340
341fn extract_calls(
342    root: tree_sitter::Node,
343    source: &str,
344    symbols: &[ExtractedSymbol],
345) -> Vec<ExtractedCall> {
346    let mut calls = Vec::new();
347    collect_calls(root, source, symbols, &mut calls);
348    calls
349}
350
351fn collect_calls(
352    node: tree_sitter::Node,
353    source: &str,
354    symbols: &[ExtractedSymbol],
355    calls: &mut Vec<ExtractedCall>,
356) {
357    match node.kind() {
358        "call_expression" => {
359            if let Some(func_node) = node.child_by_field_name("function") {
360                let callee_text = node_text(&func_node, source);
361                let callee_name = callee_text.rsplit('.').next().unwrap_or(&callee_text);
362                let call_line = node.start_position().row + 1;
363
364                if let Some(caller) = find_enclosing_symbol(symbols, call_line) {
365                    calls.push(ExtractedCall {
366                        caller_name: caller.to_string(),
367                        callee_name: callee_name.to_string(),
368                        line: call_line,
369                    });
370                }
371            }
372        }
373        "new_expression" => {
374            if let Some(constructor) = node.child_by_field_name("constructor") {
375                let callee_name = node_text(&constructor, source);
376                let call_line = node.start_position().row + 1;
377
378                if let Some(caller) = find_enclosing_symbol(symbols, call_line) {
379                    calls.push(ExtractedCall {
380                        caller_name: caller.to_string(),
381                        callee_name: callee_name.to_string(),
382                        line: call_line,
383                    });
384                }
385            }
386        }
387        // JSX component references: <UserAvatar /> creates a "renders" dependency
388        "jsx_opening_element" | "jsx_self_closing_element" => {
389            // Find the tag name child (skip punctuation like < and />)
390            for ci in 0..node.child_count() {
391                if let Some(tag) = node.child(ci as u32) {
392                    if tag.kind() == "identifier" || tag.kind() == "member_expression" {
393                        let tag_name = node_text(&tag, source);
394                        // Only PascalCase tags are components
395                        if is_pascal_case(&tag_name) {
396                            let line = node.start_position().row + 1;
397                            if let Some(caller) = find_enclosing_symbol(symbols, line) {
398                                calls.push(ExtractedCall {
399                                    caller_name: caller.to_string(),
400                                    callee_name: tag_name,
401                                    line,
402                                });
403                            }
404                        }
405                        break;
406                    }
407                }
408            }
409        }
410        _ => {}
411    }
412
413    for i in 0..node.child_count() {
414        if let Some(child) = node.child(i as u32) {
415            collect_calls(child, source, symbols, calls);
416        }
417    }
418}
419
420// --- Helpers ---
421
422fn is_pascal_case(name: &str) -> bool {
423    name.starts_with(|c: char| c.is_ascii_uppercase())
424}
425
426fn is_ts_builtin_type(name: &str) -> bool {
427    matches!(
428        name,
429        "string"
430            | "number"
431            | "boolean"
432            | "void"
433            | "any"
434            | "unknown"
435            | "never"
436            | "null"
437            | "undefined"
438            | "object"
439            | "symbol"
440            | "bigint"
441            | "Array"
442            | "Promise"
443            | "Map"
444            | "Set"
445            | "Record"
446            | "Partial"
447            | "Required"
448            | "Readonly"
449            | "Pick"
450            | "Omit"
451            | "Exclude"
452            | "Extract"
453            | "ReturnType"
454            | "Parameters"
455    )
456}
457
458fn body_returns_jsx(func_node: &tree_sitter::Node, source: &str) -> bool {
459    if let Some(body) = func_node.child_by_field_name("body") {
460        return contains_jsx_return(&body, source);
461    }
462    false
463}
464
465fn arrow_returns_jsx(arrow: &tree_sitter::Node, source: &str) -> bool {
466    if let Some(body) = arrow.child_by_field_name("body") {
467        // Arrow with expression body: () => <div/>
468        if is_jsx_node(&body) {
469            return true;
470        }
471        // Arrow with parenthesized expression body: () => (<div/>)
472        if body.kind() == "parenthesized_expression" {
473            for i in 0..body.child_count() {
474                if let Some(child) = body.child(i as u32) {
475                    if is_jsx_node(&child) {
476                        return true;
477                    }
478                }
479            }
480        }
481        // Arrow with block body: () => { return <div/> }
482        return contains_jsx_return(&body, source);
483    }
484    false
485}
486
487fn contains_jsx_return(node: &tree_sitter::Node, _source: &str) -> bool {
488    if node.kind() == "return_statement" {
489        for i in 0..node.child_count() {
490            if let Some(child) = node.child(i as u32) {
491                if is_jsx_node(&child) {
492                    return true;
493                }
494                // Check parenthesized: return (<div/>)
495                if child.kind() == "parenthesized_expression" {
496                    for j in 0..child.child_count() {
497                        if let Some(grandchild) = child.child(j as u32) {
498                            if is_jsx_node(&grandchild) {
499                                return true;
500                            }
501                        }
502                    }
503                }
504            }
505        }
506    }
507    // Recurse into block children
508    for i in 0..node.child_count() {
509        if let Some(child) = node.child(i as u32) {
510            if contains_jsx_return(&child, _source) {
511                return true;
512            }
513        }
514    }
515    false
516}
517
518fn is_jsx_node(node: &tree_sitter::Node) -> bool {
519    matches!(
520        node.kind(),
521        "jsx_element" | "jsx_self_closing_element" | "jsx_fragment"
522    )
523}
524
525fn extends_react_component(class_node: &tree_sitter::Node, source: &str) -> bool {
526    // Look for class_heritage / extends clause
527    for i in 0..class_node.child_count() {
528        if let Some(child) = class_node.child(i as u32) {
529            if child.kind() == "class_heritage" {
530                let text = node_text(&child, source);
531                return text.contains("Component") || text.contains("PureComponent");
532            }
533        }
534    }
535    false
536}
537
538// ── Resolver ──
539
540/// TypeScript import resolver.
541/// Resolves relative paths and tsconfig.json `paths` aliases.
542/// External packages (node_modules) return None.
543pub(crate) struct TypeScriptResolver {
544    /// Cached tsconfig paths mapping: alias pattern -> replacement paths
545    /// e.g., "@/*" -> ["./src/*"]
546    tsconfig_paths: HashMap<String, Vec<String>>,
547    /// Base URL from tsconfig.json (defaults to project root)
548    base_url: Option<String>,
549}
550
551impl TypeScriptResolver {
552    pub fn new(project_root: &Path) -> Self {
553        let (tsconfig_paths, base_url) = load_tsconfig_paths(project_root);
554        Self {
555            tsconfig_paths,
556            base_url,
557        }
558    }
559}
560
561impl LanguageResolver for TypeScriptResolver {
562    fn resolve_import(
563        &self,
564        import_path: &str,
565        source_file: &str,
566        project_root: &Path,
567    ) -> Option<PathBuf> {
568        if import_path.is_empty() {
569            return None;
570        }
571
572        // Relative imports: ./foo or ../foo
573        if import_path.starts_with("./") || import_path.starts_with("../") {
574            let source_abs = project_root.join(source_file);
575            let source_dir = source_abs.parent()?;
576            let target = source_dir.join(import_path);
577            return resolve_ts_file(&target);
578        }
579
580        // tsconfig paths aliases
581        if let Some(resolved) = self.resolve_via_tsconfig(import_path, project_root) {
582            return Some(resolved);
583        }
584
585        // External package (no ./ or ../ and not a configured alias)
586        None
587    }
588}
589
590impl TypeScriptResolver {
591    fn resolve_via_tsconfig(&self, import_path: &str, project_root: &Path) -> Option<PathBuf> {
592        let base = if let Some(ref base_url) = self.base_url {
593            project_root.join(base_url)
594        } else {
595            project_root.to_path_buf()
596        };
597
598        for (pattern, replacements) in &self.tsconfig_paths {
599            if let Some(matched) = match_tsconfig_pattern(pattern, import_path) {
600                for replacement in replacements {
601                    let resolved_path = replacement.replace('*', &matched);
602                    let target = base.join(&resolved_path);
603                    if let Some(file) = resolve_ts_file(&target) {
604                        return Some(file);
605                    }
606                }
607            }
608        }
609
610        None
611    }
612}
613
614/// Try to resolve a path to an actual TypeScript file.
615/// Resolution order: path.ts, path.tsx, path/index.ts, path/index.tsx
616/// Returns a canonicalized path to ensure consistent path comparisons.
617fn resolve_ts_file(target: &Path) -> Option<PathBuf> {
618    // Direct match (already has extension)
619    if target.exists() && target.is_file() {
620        return target.canonicalize().ok();
621    }
622
623    // Try .ts extension
624    let ts = target.with_extension("ts");
625    if ts.exists() {
626        return ts.canonicalize().ok();
627    }
628
629    // Try .tsx extension
630    let tsx = target.with_extension("tsx");
631    if tsx.exists() {
632        return tsx.canonicalize().ok();
633    }
634
635    // Try index.ts in directory
636    let index_ts = target.join("index.ts");
637    if index_ts.exists() {
638        return index_ts.canonicalize().ok();
639    }
640
641    // Try index.tsx in directory
642    let index_tsx = target.join("index.tsx");
643    if index_tsx.exists() {
644        return index_tsx.canonicalize().ok();
645    }
646
647    None
648}
649
650/// Match a tsconfig path pattern against an import path.
651/// Pattern "@ /*" matches "@/foo/bar" and returns "foo/bar".
652/// Pattern "utils" matches "utils" exactly and returns "".
653fn match_tsconfig_pattern(pattern: &str, import_path: &str) -> Option<String> {
654    if let Some(prefix) = pattern.strip_suffix('*') {
655        if let Some(rest) = import_path.strip_prefix(prefix) {
656            return Some(rest.to_string());
657        }
658    } else if pattern == import_path {
659        return Some(String::new());
660    }
661    None
662}
663
664/// Load paths configuration from tsconfig.json at project root.
665/// Returns empty map if tsconfig.json doesn't exist or is invalid.
666fn load_tsconfig_paths(project_root: &Path) -> (HashMap<String, Vec<String>>, Option<String>) {
667    let tsconfig_path = project_root.join("tsconfig.json");
668    if !tsconfig_path.exists() {
669        return (HashMap::new(), None);
670    }
671
672    let content = match std::fs::read_to_string(&tsconfig_path) {
673        Ok(c) => c,
674        Err(e) => {
675            warn!("cannot read tsconfig.json: {}", e);
676            return (HashMap::new(), None);
677        }
678    };
679
680    let json: serde_json::Value = match serde_json::from_str(&content) {
681        Ok(v) => v,
682        Err(e) => {
683            warn!("cannot parse tsconfig.json: {}", e);
684            return (HashMap::new(), None);
685        }
686    };
687
688    let compiler_options = json.get("compilerOptions");
689    let base_url = compiler_options
690        .and_then(|co| co.get("baseUrl"))
691        .and_then(|v| v.as_str())
692        .map(String::from);
693
694    let mut paths = HashMap::new();
695    if let Some(paths_obj) = compiler_options
696        .and_then(|co| co.get("paths"))
697        .and_then(|v| v.as_object())
698    {
699        for (key, value) in paths_obj {
700            if let Some(arr) = value.as_array() {
701                let targets: Vec<String> = arr
702                    .iter()
703                    .filter_map(|v| v.as_str().map(String::from))
704                    .collect();
705                if !targets.is_empty() {
706                    paths.insert(key.clone(), targets);
707                }
708            }
709        }
710    }
711
712    (paths, base_url)
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use std::fs;
719    use std::path::PathBuf;
720
721    // --- Parser test helpers ---
722
723    fn parse_ts_source(source: &str) -> ParseResult {
724        let parser = TypeScriptParser;
725        parser.parse(source, &PathBuf::from("test.ts"))
726    }
727
728    fn parse_tsx_source(source: &str) -> ParseResult {
729        let parser = TypeScriptParser;
730        parser.parse(source, &PathBuf::from("test.tsx"))
731    }
732
733    // --- Parser tests ---
734
735    #[test]
736    fn test_parse_function() {
737        let result =
738            parse_ts_source("function greet(name: string): string { return `Hello ${name}`; }");
739        assert!(!result.has_errors);
740        assert_eq!(result.symbols.len(), 1);
741        assert_eq!(result.symbols[0].name, "greet");
742        assert_eq!(result.symbols[0].kind, SymbolKind::Function);
743    }
744
745    #[test]
746    fn test_parse_class_with_methods() {
747        let result = parse_ts_source(
748            r#"
749class UserService {
750    private name: string;
751    constructor(name: string) {
752        this.name = name;
753    }
754    getName(): string {
755        return this.name;
756    }
757}
758"#,
759        );
760        assert!(!result.has_errors);
761        let names: Vec<&str> = result.symbols.iter().map(|s| s.name.as_str()).collect();
762        assert!(names.contains(&"UserService"));
763        assert!(names.contains(&"constructor"));
764        assert!(names.contains(&"getName"));
765        let class_sym = result
766            .symbols
767            .iter()
768            .find(|s| s.name == "UserService")
769            .unwrap();
770        assert_eq!(class_sym.kind, SymbolKind::Class);
771    }
772
773    #[test]
774    fn test_parse_interface() {
775        let result = parse_ts_source("interface UserProps { name: string; age: number; }");
776        assert!(!result.has_errors);
777        assert_eq!(result.symbols.len(), 1);
778        assert_eq!(result.symbols[0].name, "UserProps");
779        assert_eq!(result.symbols[0].kind, SymbolKind::Interface);
780    }
781
782    #[test]
783    fn test_parse_type_alias() {
784        let result = parse_ts_source(
785            "type Result<T> = { ok: true; value: T } | { ok: false; error: string };",
786        );
787        assert!(!result.has_errors);
788        assert_eq!(result.symbols.len(), 1);
789        assert_eq!(result.symbols[0].name, "Result");
790        assert_eq!(result.symbols[0].kind, SymbolKind::TypeAlias);
791    }
792
793    #[test]
794    fn test_parse_enum() {
795        let result = parse_ts_source("enum Direction { Up, Down, Left, Right }");
796        assert!(!result.has_errors);
797        assert_eq!(result.symbols.len(), 1);
798        assert_eq!(result.symbols[0].name, "Direction");
799        assert_eq!(result.symbols[0].kind, SymbolKind::Enum);
800    }
801
802    #[test]
803    fn test_parse_imports() {
804        let result = parse_ts_source(
805            r#"
806import { useState, useEffect } from 'react';
807import UserService from './services/UserService';
808import type { Config } from '../config';
809"#,
810        );
811        assert_eq!(result.imports.len(), 3);
812        assert_eq!(result.imports[0].path, "react");
813        assert_eq!(result.imports[1].path, "./services/UserService");
814        assert_eq!(result.imports[2].path, "../config");
815    }
816
817    #[test]
818    fn test_parse_exported_arrow_component() {
819        let result = parse_tsx_source(
820            r#"
821export const Button = ({ label }: ButtonProps) => {
822    return <button>{label}</button>;
823};
824"#,
825        );
826        assert!(!result.has_errors, "parse errors");
827        let button = result.symbols.iter().find(|s| s.name == "Button");
828        assert!(button.is_some(), "Button not found in {:?}", result.symbols);
829        assert_eq!(button.unwrap().kind, SymbolKind::Component);
830    }
831
832    #[test]
833    fn test_parse_function_component() {
834        let result = parse_tsx_source(
835            r#"
836export function UserCard({ user }: { user: User }) {
837    return (
838        <div>
839            <span>{user.name}</span>
840        </div>
841    );
842}
843"#,
844        );
845        assert!(!result.has_errors);
846        let card = result.symbols.iter().find(|s| s.name == "UserCard");
847        assert!(card.is_some(), "UserCard not found in {:?}", result.symbols);
848        assert_eq!(card.unwrap().kind, SymbolKind::Component);
849    }
850
851    #[test]
852    fn test_parse_class_component() {
853        let result = parse_tsx_source(
854            r#"
855class Dashboard extends React.Component<Props> {
856    render() {
857        return <div>Dashboard</div>;
858    }
859}
860"#,
861        );
862        assert!(!result.has_errors);
863        let dash = result.symbols.iter().find(|s| s.name == "Dashboard");
864        assert!(
865            dash.is_some(),
866            "Dashboard not found in {:?}",
867            result.symbols
868        );
869        assert_eq!(dash.unwrap().kind, SymbolKind::Component);
870    }
871
872    #[test]
873    fn test_call_extraction() {
874        let result = parse_ts_source(
875            r#"
876function fetchData() { return fetch('/api'); }
877function main() {
878    const data = fetchData();
879    console.log(data);
880}
881"#,
882        );
883        let callee_names: Vec<&str> = result
884            .calls
885            .iter()
886            .map(|c| c.callee_name.as_str())
887            .collect();
888        assert!(
889            callee_names.contains(&"fetchData"),
890            "calls: {:?}",
891            result.calls
892        );
893    }
894
895    #[test]
896    fn test_new_expression() {
897        let result = parse_ts_source(
898            r#"
899class Foo {}
900function create() {
901    const f = new Foo();
902}
903"#,
904        );
905        let callee_names: Vec<&str> = result
906            .calls
907            .iter()
908            .map(|c| c.callee_name.as_str())
909            .collect();
910        assert!(callee_names.contains(&"Foo"), "calls: {:?}", result.calls);
911    }
912
913    #[test]
914    fn test_jsx_component_reference() {
915        let result = parse_tsx_source(
916            r#"
917export function App() {
918    return (
919        <div>
920            <UserAvatar />
921            <ProfileCard name="test" />
922        </div>
923    );
924}
925"#,
926        );
927        let callee_names: Vec<&str> = result
928            .calls
929            .iter()
930            .map(|c| c.callee_name.as_str())
931            .collect();
932        assert!(
933            callee_names.contains(&"UserAvatar"),
934            "calls: {:?}",
935            result.calls
936        );
937        assert!(
938            callee_names.contains(&"ProfileCard"),
939            "calls: {:?}",
940            result.calls
941        );
942        // HTML tags should not be in calls
943        assert!(!callee_names.contains(&"div"));
944    }
945
946    #[test]
947    fn test_type_ref_extraction() {
948        let result = parse_ts_source(
949            r#"
950interface Config { port: number; }
951function createServer(config: Config): void {}
952"#,
953        );
954        let ref_types: Vec<&str> = result
955            .type_refs
956            .iter()
957            .map(|r| r.to_type.as_str())
958            .collect();
959        assert!(
960            ref_types.contains(&"Config"),
961            "type_refs: {:?}",
962            result.type_refs
963        );
964    }
965
966    #[test]
967    fn test_ts_builtin_types_filtered() {
968        let result = parse_ts_source(
969            r#"
970function test(name: string, age: number, active: boolean): void {}
971"#,
972        );
973        // All types are builtins, so no type_refs should be extracted
974        assert!(
975            result.type_refs.is_empty(),
976            "should filter builtins: {:?}",
977            result.type_refs
978        );
979    }
980
981    #[test]
982    fn test_empty_ts_file() {
983        let result = parse_ts_source("");
984        assert!(!result.has_errors);
985        assert_eq!(result.symbols.len(), 0);
986    }
987
988    #[test]
989    fn test_malformed_ts_file() {
990        let result = parse_ts_source("function broken( { }");
991        assert!(result.has_errors);
992    }
993
994    #[test]
995    fn test_import_extracts_named_symbols() {
996        let result = parse_ts_source("import { Foo, Bar } from './baz';");
997        assert_eq!(result.imports.len(), 1);
998        assert_eq!(result.imports[0].imported_symbols, vec!["Foo", "Bar"]);
999    }
1000
1001    #[test]
1002    fn test_import_extracts_default_symbol() {
1003        let result = parse_ts_source("import React from 'react';");
1004        assert_eq!(result.imports.len(), 1);
1005        assert_eq!(result.imports[0].imported_symbols, vec!["React"]);
1006    }
1007
1008    #[test]
1009    fn test_import_extracts_aliased_symbol() {
1010        let result = parse_ts_source("import { Foo as Bar } from './baz';");
1011        assert_eq!(result.imports.len(), 1);
1012        // Should use the local alias name
1013        assert!(result.imports[0]
1014            .imported_symbols
1015            .contains(&"Bar".to_string()));
1016    }
1017
1018    #[test]
1019    fn test_import_namespace() {
1020        let result = parse_ts_source("import * as Utils from './utils';");
1021        assert_eq!(result.imports.len(), 1);
1022        assert_eq!(result.imports[0].imported_symbols, vec!["Utils"]);
1023    }
1024
1025    #[test]
1026    fn test_export_const_variable() {
1027        let result = parse_ts_source(
1028            r#"
1029import { compile } from 'zod-aot';
1030import { z } from 'zod';
1031
1032export const UserSchema = compile(
1033  z.object({ name: z.string(), age: z.number() }),
1034);
1035"#,
1036        );
1037        assert!(!result.has_errors);
1038        let sym = result.symbols.iter().find(|s| s.name == "UserSchema");
1039        assert!(
1040            sym.is_some(),
1041            "UserSchema not found in {:?}",
1042            result.symbols
1043        );
1044        assert_eq!(sym.unwrap().kind, SymbolKind::Variable);
1045    }
1046
1047    #[test]
1048    fn test_export_const_simple_value() {
1049        let result = parse_ts_source("export const API_URL = 'https://api.example.com';");
1050        assert!(!result.has_errors);
1051        let sym = result.symbols.iter().find(|s| s.name == "API_URL");
1052        assert!(sym.is_some(), "API_URL not found in {:?}", result.symbols);
1053        assert_eq!(sym.unwrap().kind, SymbolKind::Variable);
1054    }
1055
1056    #[test]
1057    fn test_export_const_object() {
1058        let result = parse_ts_source("export const config = { port: 3000, host: 'localhost' };");
1059        assert!(!result.has_errors);
1060        let sym = result.symbols.iter().find(|s| s.name == "config");
1061        assert!(sym.is_some(), "config not found in {:?}", result.symbols);
1062        assert_eq!(sym.unwrap().kind, SymbolKind::Variable);
1063    }
1064
1065    #[test]
1066    fn test_non_exported_const_not_extracted() {
1067        let result = parse_ts_source("const internal = { port: 3000 };");
1068        assert!(!result.has_errors);
1069        let sym = result.symbols.iter().find(|s| s.name == "internal");
1070        assert!(sym.is_none(), "non-exported const should not be extracted");
1071    }
1072
1073    #[test]
1074    fn test_export_const_arrow_still_function() {
1075        let result = parse_ts_source("export const add = (a: number, b: number) => a + b;");
1076        assert!(!result.has_errors);
1077        let sym = result.symbols.iter().find(|s| s.name == "add");
1078        assert!(sym.is_some(), "add not found in {:?}", result.symbols);
1079        assert_eq!(sym.unwrap().kind, SymbolKind::Function);
1080    }
1081
1082    #[test]
1083    fn test_is_pascal_case_positive() {
1084        assert!(is_pascal_case("Button"));
1085        assert!(is_pascal_case("FooBar"));
1086        assert!(is_pascal_case("A"));
1087        assert!(is_pascal_case("UserAvatar"));
1088    }
1089
1090    #[test]
1091    fn test_is_pascal_case_negative() {
1092        assert!(!is_pascal_case("button"));
1093        assert!(!is_pascal_case(""));
1094        assert!(!is_pascal_case("aButton"));
1095        assert!(!is_pascal_case("_Private"));
1096    }
1097
1098    #[test]
1099    fn test_is_ts_builtin_type_positive() {
1100        assert!(is_ts_builtin_type("string"));
1101        assert!(is_ts_builtin_type("number"));
1102        assert!(is_ts_builtin_type("boolean"));
1103        assert!(is_ts_builtin_type("Array"));
1104        assert!(is_ts_builtin_type("Promise"));
1105        assert!(is_ts_builtin_type("Record"));
1106        assert!(is_ts_builtin_type("ReturnType"));
1107    }
1108
1109    #[test]
1110    fn test_is_ts_builtin_type_negative() {
1111        assert!(!is_ts_builtin_type("MyType"));
1112        assert!(!is_ts_builtin_type("Config"));
1113        assert!(!is_ts_builtin_type("UserService"));
1114        assert!(!is_ts_builtin_type(""));
1115    }
1116
1117    #[test]
1118    fn test_extends_react_component_positive() {
1119        let result = parse_tsx_source(
1120            r#"
1121class MyComponent extends React.Component<Props> {
1122    render() { return <div/>; }
1123}
1124"#,
1125        );
1126        let comp = result.symbols.iter().find(|s| s.name == "MyComponent");
1127        assert!(comp.is_some());
1128        assert_eq!(comp.unwrap().kind, SymbolKind::Component);
1129    }
1130
1131    #[test]
1132    fn test_extends_pure_component() {
1133        let result = parse_tsx_source(
1134            r#"
1135class MyPure extends React.PureComponent<Props> {
1136    render() { return <div/>; }
1137}
1138"#,
1139        );
1140        let comp = result.symbols.iter().find(|s| s.name == "MyPure");
1141        assert!(comp.is_some());
1142        assert_eq!(comp.unwrap().kind, SymbolKind::Component);
1143    }
1144
1145    #[test]
1146    fn test_class_without_heritage_is_not_component() {
1147        let result = parse_tsx_source(
1148            r#"
1149class UserService {
1150    fetch() { return null; }
1151}
1152"#,
1153        );
1154        let svc = result.symbols.iter().find(|s| s.name == "UserService");
1155        assert!(svc.is_some());
1156        assert_eq!(svc.unwrap().kind, SymbolKind::Class);
1157    }
1158
1159    #[test]
1160    fn test_export_const_calls_captured() {
1161        let result = parse_ts_source(
1162            r#"
1163export const Schema = compile(z.object({ name: z.string() }));
1164"#,
1165        );
1166        assert!(!result.has_errors);
1167        let sym = result.symbols.iter().find(|s| s.name == "Schema");
1168        assert!(sym.is_some(), "Schema not found");
1169        // The call to compile should be captured within the Schema symbol
1170        let compile_calls: Vec<_> = result
1171            .calls
1172            .iter()
1173            .filter(|c| c.callee_name == "compile")
1174            .collect();
1175        assert!(
1176            !compile_calls.is_empty(),
1177            "compile() call should be captured, calls: {:?}",
1178            result.calls
1179        );
1180        assert_eq!(compile_calls[0].caller_name, "Schema");
1181    }
1182
1183    // --- Resolver tests ---
1184
1185    #[test]
1186    fn test_resolve_relative_ts() {
1187        let tmp = tempfile::tempdir().unwrap();
1188        let src = tmp.path().join("src");
1189        fs::create_dir_all(&src).unwrap();
1190        fs::write(src.join("utils.ts"), "export function foo() {}").unwrap();
1191
1192        let resolver = TypeScriptResolver::new(tmp.path());
1193        let result = resolver.resolve_import("./utils", "src/main.ts", tmp.path());
1194        assert!(result.is_some(), "should resolve ./utils");
1195        assert!(result.unwrap().ends_with("utils.ts"));
1196    }
1197
1198    #[test]
1199    fn test_resolve_relative_tsx() {
1200        let tmp = tempfile::tempdir().unwrap();
1201        let src = tmp.path().join("src");
1202        fs::create_dir_all(&src).unwrap();
1203        fs::write(
1204            src.join("Button.tsx"),
1205            "export const Button = () => <button/>;",
1206        )
1207        .unwrap();
1208
1209        let resolver = TypeScriptResolver::new(tmp.path());
1210        let result = resolver.resolve_import("./Button", "src/App.tsx", tmp.path());
1211        assert!(result.is_some(), "should resolve ./Button");
1212        assert!(result.unwrap().ends_with("Button.tsx"));
1213    }
1214
1215    #[test]
1216    fn test_resolve_index_ts() {
1217        let tmp = tempfile::tempdir().unwrap();
1218        let components = tmp.path().join("src").join("components");
1219        fs::create_dir_all(&components).unwrap();
1220        fs::write(components.join("index.ts"), "export * from './Button';").unwrap();
1221
1222        let resolver = TypeScriptResolver::new(tmp.path());
1223        let result = resolver.resolve_import("./components", "src/App.tsx", tmp.path());
1224        assert!(result.is_some(), "should resolve ./components to index.ts");
1225        assert!(result.unwrap().ends_with("index.ts"));
1226    }
1227
1228    #[test]
1229    fn test_resolve_parent_relative() {
1230        let tmp = tempfile::tempdir().unwrap();
1231        let src = tmp.path().join("src");
1232        fs::create_dir_all(src.join("components")).unwrap();
1233        fs::write(src.join("utils.ts"), "").unwrap();
1234
1235        let resolver = TypeScriptResolver::new(tmp.path());
1236        let result = resolver.resolve_import("../utils", "src/components/Button.tsx", tmp.path());
1237        assert!(result.is_some(), "should resolve ../utils");
1238        assert!(result.unwrap().ends_with("utils.ts"));
1239    }
1240
1241    #[test]
1242    fn test_resolve_tsconfig_paths() {
1243        let tmp = tempfile::tempdir().unwrap();
1244        let src = tmp.path().join("src");
1245        fs::create_dir_all(src.join("components")).unwrap();
1246        fs::write(src.join("components").join("Button.tsx"), "").unwrap();
1247
1248        fs::write(
1249            tmp.path().join("tsconfig.json"),
1250            r#"{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }"#,
1251        )
1252        .unwrap();
1253
1254        let resolver = TypeScriptResolver::new(tmp.path());
1255        let result = resolver.resolve_import("@/components/Button", "src/App.tsx", tmp.path());
1256        assert!(result.is_some(), "should resolve @/components/Button");
1257        assert!(result.unwrap().ends_with("Button.tsx"));
1258    }
1259
1260    #[test]
1261    fn test_resolve_external_package_returns_none() {
1262        let tmp = tempfile::tempdir().unwrap();
1263        let resolver = TypeScriptResolver::new(tmp.path());
1264        assert_eq!(
1265            resolver.resolve_import("react", "src/App.tsx", tmp.path()),
1266            None
1267        );
1268        assert_eq!(
1269            resolver.resolve_import("@tanstack/react-query", "src/App.tsx", tmp.path()),
1270            None
1271        );
1272    }
1273
1274    #[test]
1275    fn test_resolve_no_tsconfig() {
1276        let tmp = tempfile::tempdir().unwrap();
1277        let src = tmp.path().join("src");
1278        fs::create_dir_all(&src).unwrap();
1279        fs::write(src.join("foo.ts"), "").unwrap();
1280
1281        let resolver = TypeScriptResolver::new(tmp.path());
1282        // Relative still works without tsconfig
1283        let result = resolver.resolve_import("./foo", "src/main.ts", tmp.path());
1284        assert!(result.is_some());
1285        // Alias doesn't work without tsconfig
1286        let result = resolver.resolve_import("@/foo", "src/main.ts", tmp.path());
1287        assert!(result.is_none());
1288    }
1289
1290    #[test]
1291    fn test_resolve_invalid_tsconfig() {
1292        let tmp = tempfile::tempdir().unwrap();
1293        fs::write(tmp.path().join("tsconfig.json"), "not json!!!").unwrap();
1294        let src = tmp.path().join("src");
1295        fs::create_dir_all(&src).unwrap();
1296        fs::write(src.join("foo.ts"), "").unwrap();
1297
1298        // Should not panic, just fall back to no paths
1299        let resolver = TypeScriptResolver::new(tmp.path());
1300        let result = resolver.resolve_import("./foo", "src/main.ts", tmp.path());
1301        assert!(result.is_some(), "relative import should still work");
1302    }
1303
1304    #[test]
1305    fn test_match_tsconfig_pattern() {
1306        assert_eq!(
1307            match_tsconfig_pattern("@/*", "@/foo/bar"),
1308            Some("foo/bar".to_string())
1309        );
1310        assert_eq!(match_tsconfig_pattern("@/*", "react"), None);
1311        assert_eq!(
1312            match_tsconfig_pattern("utils", "utils"),
1313            Some(String::new())
1314        );
1315        assert_eq!(match_tsconfig_pattern("utils", "utils/foo"), None);
1316    }
1317
1318    // --- Additional coverage tests ---
1319
1320    #[test]
1321    fn test_parse_arrow_component_expression_body_jsx() {
1322        // Arrow with expression body returning JSX directly: () => <div/>
1323        let result = parse_tsx_source(
1324            r#"
1325export const Icon = () => <svg><path d="M0 0"/></svg>;
1326"#,
1327        );
1328        assert!(!result.has_errors);
1329        let icon = result.symbols.iter().find(|s| s.name == "Icon");
1330        assert!(icon.is_some(), "Icon not found in {:?}", result.symbols);
1331        assert_eq!(icon.unwrap().kind, SymbolKind::Component);
1332    }
1333
1334    #[test]
1335    fn test_parse_arrow_component_parenthesized_jsx() {
1336        // Arrow with parenthesized expression body: () => (<div/>)
1337        let result = parse_tsx_source(
1338            r#"
1339export const Badge = () => (<span className="badge">Hi</span>);
1340"#,
1341        );
1342        assert!(!result.has_errors);
1343        let badge = result.symbols.iter().find(|s| s.name == "Badge");
1344        assert!(badge.is_some(), "Badge not found in {:?}", result.symbols);
1345        assert_eq!(badge.unwrap().kind, SymbolKind::Component);
1346    }
1347
1348    #[test]
1349    fn test_parse_function_component_parenthesized_return() {
1350        // Function component with parenthesized return: return (<div/>)
1351        let result = parse_tsx_source(
1352            r#"
1353export function Layout() {
1354    return (
1355        <main>
1356            <h1>Title</h1>
1357        </main>
1358    );
1359}
1360"#,
1361        );
1362        assert!(!result.has_errors);
1363        let layout = result.symbols.iter().find(|s| s.name == "Layout");
1364        assert!(layout.is_some(), "Layout not found in {:?}", result.symbols);
1365        assert_eq!(layout.unwrap().kind, SymbolKind::Component);
1366    }
1367
1368    #[test]
1369    fn test_non_component_arrow_lowercase() {
1370        // Lowercase arrow function should NOT be a component even if exported
1371        let result = parse_tsx_source(
1372            r#"
1373export const helper = () => <div/>;
1374"#,
1375        );
1376        assert!(!result.has_errors);
1377        let helper = result.symbols.iter().find(|s| s.name == "helper");
1378        assert!(helper.is_some(), "helper not found in {:?}", result.symbols);
1379        // lowercase = not a component even with JSX
1380        assert_eq!(helper.unwrap().kind, SymbolKind::Function);
1381    }
1382
1383    #[test]
1384    fn test_class_extends_non_component() {
1385        // Class that extends something that is NOT Component/PureComponent
1386        let result = parse_tsx_source(
1387            r#"
1388class MyService extends BaseService {
1389    fetch() { return null; }
1390}
1391"#,
1392        );
1393        assert!(!result.has_errors);
1394        let svc = result.symbols.iter().find(|s| s.name == "MyService");
1395        assert!(svc.is_some());
1396        assert_eq!(
1397            svc.unwrap().kind,
1398            SymbolKind::Class,
1399            "should be class, not component"
1400        );
1401    }
1402
1403    #[test]
1404    fn test_resolve_index_tsx() {
1405        // Resolution should fall through to index.tsx
1406        let tmp = tempfile::tempdir().unwrap();
1407        let components = tmp.path().join("src").join("components");
1408        fs::create_dir_all(&components).unwrap();
1409        fs::write(
1410            components.join("index.tsx"),
1411            "export const Foo = () => <div/>;",
1412        )
1413        .unwrap();
1414
1415        let resolver = TypeScriptResolver::new(tmp.path());
1416        let result = resolver.resolve_import("./components", "src/App.tsx", tmp.path());
1417        assert!(result.is_some(), "should resolve ./components to index.tsx");
1418        assert!(result.unwrap().ends_with("index.tsx"));
1419    }
1420
1421    #[test]
1422    fn test_resolve_tsx_extension() {
1423        // Direct .tsx resolution
1424        let tmp = tempfile::tempdir().unwrap();
1425        let src = tmp.path().join("src");
1426        fs::create_dir_all(&src).unwrap();
1427        fs::write(
1428            src.join("Widget.tsx"),
1429            "export const Widget = () => <div/>;",
1430        )
1431        .unwrap();
1432
1433        let resolver = TypeScriptResolver::new(tmp.path());
1434        let result = resolver.resolve_import("./Widget", "src/App.tsx", tmp.path());
1435        assert!(result.is_some(), "should resolve ./Widget to .tsx");
1436        assert!(result.unwrap().ends_with("Widget.tsx"));
1437    }
1438
1439    #[test]
1440    fn test_resolve_empty_import_path() {
1441        let tmp = tempfile::tempdir().unwrap();
1442        let resolver = TypeScriptResolver::new(tmp.path());
1443        let result = resolver.resolve_import("", "src/App.tsx", tmp.path());
1444        assert!(result.is_none(), "empty import path should return None");
1445    }
1446
1447    #[test]
1448    fn test_resolve_tsconfig_with_base_url_only() {
1449        // tsconfig with baseUrl but no paths
1450        let tmp = tempfile::tempdir().unwrap();
1451        let src = tmp.path().join("src");
1452        fs::create_dir_all(&src).unwrap();
1453        fs::write(src.join("utils.ts"), "export function foo() {}").unwrap();
1454
1455        fs::write(
1456            tmp.path().join("tsconfig.json"),
1457            r#"{ "compilerOptions": { "baseUrl": "." } }"#,
1458        )
1459        .unwrap();
1460
1461        let resolver = TypeScriptResolver::new(tmp.path());
1462        // Without paths config, non-relative non-alias imports return None
1463        let result = resolver.resolve_import("src/utils", "src/App.tsx", tmp.path());
1464        assert!(result.is_none());
1465    }
1466
1467    #[test]
1468    fn test_resolve_tsconfig_exact_match_pattern() {
1469        // tsconfig with exact (non-wildcard) path alias
1470        let tmp = tempfile::tempdir().unwrap();
1471        let src = tmp.path().join("src");
1472        fs::create_dir_all(&src).unwrap();
1473        fs::write(src.join("config.ts"), "export const config = {};").unwrap();
1474
1475        fs::write(
1476            tmp.path().join("tsconfig.json"),
1477            r#"{ "compilerOptions": { "baseUrl": ".", "paths": { "config": ["./src/config"] } } }"#,
1478        )
1479        .unwrap();
1480
1481        let resolver = TypeScriptResolver::new(tmp.path());
1482        let result = resolver.resolve_import("config", "src/App.tsx", tmp.path());
1483        assert!(result.is_some(), "exact tsconfig pattern should resolve");
1484        assert!(result.unwrap().ends_with("config.ts"));
1485    }
1486
1487    #[test]
1488    fn test_parse_jsx_fragment() {
1489        // JSX fragment: <><Child/></>
1490        let result = parse_tsx_source(
1491            r#"
1492export function Wrapper() {
1493    return (
1494        <>
1495            <ChildComp />
1496        </>
1497    );
1498}
1499"#,
1500        );
1501        assert!(!result.has_errors);
1502        let wrapper = result.symbols.iter().find(|s| s.name == "Wrapper");
1503        assert!(wrapper.is_some());
1504        assert_eq!(wrapper.unwrap().kind, SymbolKind::Component);
1505        // ChildComp should be in calls
1506        let callee_names: Vec<&str> = result
1507            .calls
1508            .iter()
1509            .map(|c| c.callee_name.as_str())
1510            .collect();
1511        assert!(
1512            callee_names.contains(&"ChildComp"),
1513            "calls: {:?}",
1514            result.calls
1515        );
1516    }
1517
1518    #[test]
1519    fn test_parse_namespace_import() {
1520        // import * as Foo from './bar'
1521        let result = parse_ts_source("import * as Helpers from './helpers';");
1522        assert_eq!(result.imports.len(), 1);
1523        assert_eq!(result.imports[0].imported_symbols, vec!["Helpers"]);
1524    }
1525
1526    #[test]
1527    fn test_parse_default_and_named_imports() {
1528        let result = parse_ts_source("import React, { useState, useEffect } from 'react';");
1529        assert_eq!(result.imports.len(), 1);
1530        let names = &result.imports[0].imported_symbols;
1531        assert!(names.contains(&"React".to_string()), "imports: {:?}", names);
1532        assert!(
1533            names.contains(&"useState".to_string()),
1534            "imports: {:?}",
1535            names
1536        );
1537        assert!(
1538            names.contains(&"useEffect".to_string()),
1539            "imports: {:?}",
1540            names
1541        );
1542    }
1543
1544    #[test]
1545    fn test_load_tsconfig_no_paths_key() {
1546        // tsconfig.json exists but has no "paths" key
1547        let tmp = tempfile::tempdir().unwrap();
1548        fs::write(
1549            tmp.path().join("tsconfig.json"),
1550            r#"{ "compilerOptions": { "strict": true } }"#,
1551        )
1552        .unwrap();
1553
1554        let (paths, base_url) = load_tsconfig_paths(tmp.path());
1555        assert!(paths.is_empty());
1556        assert!(base_url.is_none());
1557    }
1558
1559    #[test]
1560    fn test_load_tsconfig_no_compiler_options() {
1561        // tsconfig.json with no compilerOptions at all
1562        let tmp = tempfile::tempdir().unwrap();
1563        fs::write(
1564            tmp.path().join("tsconfig.json"),
1565            r#"{ "include": ["src"] }"#,
1566        )
1567        .unwrap();
1568
1569        let (paths, base_url) = load_tsconfig_paths(tmp.path());
1570        assert!(paths.is_empty());
1571        assert!(base_url.is_none());
1572    }
1573
1574    #[test]
1575    fn test_arrow_function_non_exported_not_extracted() {
1576        let result = parse_ts_source("const helper = () => 42;");
1577        assert!(!result.has_errors);
1578        let sym = result.symbols.iter().find(|s| s.name == "helper");
1579        assert!(
1580            sym.is_none(),
1581            "non-exported arrow function should not be extracted"
1582        );
1583    }
1584
1585    #[test]
1586    fn test_lexical_declaration_non_arrow_value() {
1587        let result = parse_ts_source("const x = 42;");
1588        assert!(!result.has_errors);
1589        assert!(
1590            result.symbols.is_empty(),
1591            "non-exported non-arrow const should not be extracted"
1592        );
1593    }
1594
1595    #[test]
1596    fn test_method_in_class_is_method_kind() {
1597        let result = parse_ts_source(
1598            r#"
1599class Service {
1600    async fetch(): Promise<void> {}
1601    private handle(): void {}
1602}
1603"#,
1604        );
1605        assert!(!result.has_errors);
1606        let methods: Vec<&str> = result
1607            .symbols
1608            .iter()
1609            .filter(|s| s.kind == SymbolKind::Method)
1610            .map(|s| s.name.as_str())
1611            .collect();
1612        assert!(methods.contains(&"fetch"), "methods: {:?}", methods);
1613        assert!(methods.contains(&"handle"), "methods: {:?}", methods);
1614    }
1615
1616    #[test]
1617    fn test_body_returns_jsx_no_jsx() {
1618        let result = parse_ts_source("function greet(): string { return 'hello'; }");
1619        assert!(!result.has_errors);
1620        let greet = result.symbols.iter().find(|s| s.name == "greet").unwrap();
1621        assert_eq!(
1622            greet.kind,
1623            SymbolKind::Function,
1624            "non-jsx function should not be component"
1625        );
1626    }
1627
1628    #[test]
1629    fn test_is_jsx_node_fragment() {
1630        let result = parse_tsx_source(
1631            r#"
1632export function Empty() {
1633    return <></>;
1634}
1635"#,
1636        );
1637        assert!(!result.has_errors);
1638        let empty = result.symbols.iter().find(|s| s.name == "Empty");
1639        assert!(empty.is_some());
1640        assert_eq!(empty.unwrap().kind, SymbolKind::Component);
1641    }
1642
1643    #[test]
1644    fn test_arrow_no_jsx_is_function() {
1645        let result = parse_ts_source(
1646            r#"
1647export const compute = (x: number) => {
1648    return x * 2;
1649};
1650"#,
1651        );
1652        assert!(!result.has_errors);
1653        let compute = result.symbols.iter().find(|s| s.name == "compute");
1654        assert!(compute.is_some());
1655        assert_eq!(compute.unwrap().kind, SymbolKind::Function);
1656    }
1657
1658    #[test]
1659    fn test_is_ts_builtin_type_exhaustive() {
1660        for ty in &[
1661            "string",
1662            "number",
1663            "boolean",
1664            "void",
1665            "any",
1666            "unknown",
1667            "never",
1668            "null",
1669            "undefined",
1670            "object",
1671            "symbol",
1672            "bigint",
1673            "Array",
1674            "Promise",
1675            "Map",
1676            "Set",
1677            "Record",
1678            "Partial",
1679            "Required",
1680            "Readonly",
1681            "Pick",
1682            "Omit",
1683            "Exclude",
1684            "Extract",
1685            "ReturnType",
1686            "Parameters",
1687        ] {
1688            assert!(is_ts_builtin_type(ty), "'{}' should be builtin", ty);
1689        }
1690    }
1691
1692    #[test]
1693    fn test_import_side_effect_only() {
1694        let result = parse_ts_source("import './polyfills';");
1695        assert!(!result.has_errors);
1696        assert_eq!(result.imports.len(), 1);
1697        assert_eq!(result.imports[0].path, "./polyfills");
1698        assert!(
1699            result.imports[0].imported_symbols.is_empty(),
1700            "side-effect import should have no imported symbols"
1701        );
1702    }
1703
1704    #[test]
1705    fn test_export_statement_wraps_children() {
1706        let result = parse_ts_source("export function exported() { return 1; }");
1707        assert!(!result.has_errors);
1708        let sym = result.symbols.iter().find(|s| s.name == "exported");
1709        assert!(sym.is_some(), "exported function should be found");
1710        assert_eq!(sym.unwrap().kind, SymbolKind::Function);
1711    }
1712
1713    #[test]
1714    fn test_tsconfig_paths_with_multiple_replacements() {
1715        let tmp = tempfile::tempdir().unwrap();
1716        let src = tmp.path().join("src");
1717        let lib = tmp.path().join("lib");
1718        fs::create_dir_all(&src).unwrap();
1719        fs::create_dir_all(&lib).unwrap();
1720        fs::write(lib.join("utils.ts"), "export function foo() {}").unwrap();
1721
1722        fs::write(
1723            tmp.path().join("tsconfig.json"),
1724            r#"{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*", "./lib/*"] } } }"#,
1725        )
1726        .unwrap();
1727
1728        let resolver = TypeScriptResolver::new(tmp.path());
1729        let result = resolver.resolve_import("@/utils", "src/App.tsx", tmp.path());
1730        assert!(
1731            result.is_some(),
1732            "should resolve via second path replacement"
1733        );
1734        assert!(result.unwrap().ends_with("utils.ts"));
1735    }
1736
1737    #[test]
1738    fn test_resolve_direct_file_with_extension() {
1739        let tmp = tempfile::tempdir().unwrap();
1740        let src = tmp.path().join("src");
1741        fs::create_dir_all(&src).unwrap();
1742        fs::write(src.join("data.json"), r#"{"key": "value"}"#).unwrap();
1743
1744        let target = src.join("data.json");
1745        let result = resolve_ts_file(&target);
1746        assert!(result.is_some(), "direct file should resolve");
1747    }
1748
1749    #[test]
1750    fn test_resolve_ts_file_nonexistent() {
1751        let tmp = tempfile::tempdir().unwrap();
1752        let target = tmp.path().join("nonexistent");
1753        let result = resolve_ts_file(&target);
1754        assert!(result.is_none(), "nonexistent path should return None");
1755    }
1756
1757    // ── Additional edge case tests ──
1758
1759    #[test]
1760    fn test_parse_getter_setter() {
1761        let result = parse_ts_source(
1762            r#"
1763class Config {
1764    private _name: string = '';
1765    get name(): string { return this._name; }
1766    set name(v: string) { this._name = v; }
1767}
1768"#,
1769        );
1770        assert!(!result.has_errors);
1771        let methods: Vec<&str> = result
1772            .symbols
1773            .iter()
1774            .filter(|s| s.kind == SymbolKind::Method)
1775            .map(|s| s.name.as_str())
1776            .collect();
1777        // getter and setter should both be extracted as methods
1778        assert!(
1779            methods.iter().filter(|&&n| n == "name").count() >= 2,
1780            "getter and setter should both appear: {:?}",
1781            methods
1782        );
1783    }
1784
1785    #[test]
1786    fn test_parse_static_method() {
1787        let result = parse_ts_source(
1788            r#"
1789class Factory {
1790    static create(): Factory { return new Factory(); }
1791    build(): void {}
1792}
1793"#,
1794        );
1795        assert!(!result.has_errors);
1796        let methods: Vec<&str> = result
1797            .symbols
1798            .iter()
1799            .filter(|s| s.kind == SymbolKind::Method)
1800            .map(|s| s.name.as_str())
1801            .collect();
1802        assert!(methods.contains(&"create"), "static method: {:?}", methods);
1803        assert!(methods.contains(&"build"), "instance method: {:?}", methods);
1804    }
1805
1806    #[test]
1807    fn test_parse_abstract_class_not_extracted() {
1808        // abstract class uses "abstract_class_declaration" node kind in tree-sitter,
1809        // which is not matched by the parser (only "class_declaration" is).
1810        // This test documents the current behavior.
1811        let result = parse_ts_source(
1812            r#"
1813abstract class BaseHandler {
1814    abstract handle(): void;
1815    log(): void { console.log('log'); }
1816}
1817"#,
1818        );
1819        assert!(!result.has_errors);
1820        let class = result.symbols.iter().find(|s| s.name == "BaseHandler");
1821        assert!(
1822            class.is_none(),
1823            "abstract class is not extracted by current parser: {:?}",
1824            result.symbols
1825        );
1826    }
1827
1828    #[test]
1829    fn test_parse_declare_function() {
1830        // declare statements should be parsed without errors
1831        let result = parse_ts_source("declare function readFile(path: string): string;");
1832        assert!(!result.has_errors);
1833    }
1834
1835    #[test]
1836    fn test_parse_declare_module() {
1837        let result = parse_ts_source(
1838            r#"
1839declare module 'my-lib' {
1840    export function init(): void;
1841}
1842"#,
1843        );
1844        assert!(!result.has_errors);
1845    }
1846
1847    #[test]
1848    fn test_type_ref_union_types() {
1849        let result = parse_ts_source(
1850            r#"
1851interface Config { port: number; }
1852interface Options { timeout: number; }
1853function create(input: Config | Options): void {}
1854"#,
1855        );
1856        let ref_types: Vec<&str> = result
1857            .type_refs
1858            .iter()
1859            .map(|r| r.to_type.as_str())
1860            .collect();
1861        assert!(
1862            ref_types.contains(&"Config"),
1863            "union type Config: {:?}",
1864            ref_types
1865        );
1866        assert!(
1867            ref_types.contains(&"Options"),
1868            "union type Options: {:?}",
1869            ref_types
1870        );
1871    }
1872
1873    #[test]
1874    fn test_type_ref_intersection_types() {
1875        let result = parse_ts_source(
1876            r#"
1877interface Base { id: number; }
1878interface Named { name: string; }
1879function process(item: Base & Named): void {}
1880"#,
1881        );
1882        let ref_types: Vec<&str> = result
1883            .type_refs
1884            .iter()
1885            .map(|r| r.to_type.as_str())
1886            .collect();
1887        assert!(
1888            ref_types.contains(&"Base"),
1889            "intersection type: {:?}",
1890            ref_types
1891        );
1892        assert!(
1893            ref_types.contains(&"Named"),
1894            "intersection type: {:?}",
1895            ref_types
1896        );
1897    }
1898
1899    #[test]
1900    fn test_export_default_function() {
1901        let result = parse_ts_source("export default function handler() { return 1; }");
1902        assert!(!result.has_errors);
1903        let sym = result.symbols.iter().find(|s| s.name == "handler");
1904        assert!(sym.is_some(), "export default function should be extracted");
1905    }
1906
1907    #[test]
1908    fn test_multiple_classes_with_methods() {
1909        let result = parse_ts_source(
1910            r#"
1911class A {
1912    methodA(): void {}
1913}
1914class B {
1915    methodB(): void {}
1916}
1917"#,
1918        );
1919        assert!(!result.has_errors);
1920        let names: Vec<&str> = result.symbols.iter().map(|s| s.name.as_str()).collect();
1921        assert!(names.contains(&"A"));
1922        assert!(names.contains(&"methodA"));
1923        assert!(names.contains(&"B"));
1924        assert!(names.contains(&"methodB"));
1925        // methodA should be child of A, not B
1926        let a_idx = result.symbols.iter().position(|s| s.name == "A").unwrap();
1927        let method_a = result.symbols.iter().find(|s| s.name == "methodA").unwrap();
1928        assert_eq!(method_a.parent_index, Some(a_idx));
1929    }
1930
1931    #[test]
1932    fn test_conditional_jsx_return() {
1933        let result = parse_tsx_source(
1934            r#"
1935export function Toggle({ on }: { on: boolean }) {
1936    if (on) {
1937        return <span>On</span>;
1938    }
1939    return <span>Off</span>;
1940}
1941"#,
1942        );
1943        assert!(!result.has_errors);
1944        let toggle = result.symbols.iter().find(|s| s.name == "Toggle").unwrap();
1945        assert_eq!(toggle.kind, SymbolKind::Component);
1946    }
1947
1948    #[test]
1949    fn test_arrow_component_with_block_body() {
1950        let result = parse_tsx_source(
1951            r#"
1952export const Card = ({ title }: { title: string }) => {
1953    const formatted = title.toUpperCase();
1954    return <div>{formatted}</div>;
1955};
1956"#,
1957        );
1958        assert!(!result.has_errors);
1959        let card = result.symbols.iter().find(|s| s.name == "Card").unwrap();
1960        assert_eq!(card.kind, SymbolKind::Component);
1961    }
1962
1963    #[test]
1964    fn test_import_type_only() {
1965        let result = parse_ts_source("import type { Config, Options } from './types';");
1966        assert!(!result.has_errors);
1967        assert_eq!(result.imports.len(), 1);
1968        assert_eq!(result.imports[0].path, "./types");
1969        assert!(result.imports[0]
1970            .imported_symbols
1971            .contains(&"Config".to_string()));
1972    }
1973
1974    #[test]
1975    fn test_re_export() {
1976        let result = parse_ts_source("export { default as Button } from './Button';");
1977        assert!(!result.has_errors);
1978    }
1979
1980    #[test]
1981    fn test_language_parser_trait_name() {
1982        let parser = TypeScriptParser;
1983        assert_eq!(parser.language_name(), "typescript");
1984    }
1985
1986    #[test]
1987    fn test_language_parser_tsx_vs_ts() {
1988        let parser = TypeScriptParser;
1989        // .ts file should not parse JSX
1990        let ts_result = parser.parse("function foo() { return 1; }", &PathBuf::from("test.ts"));
1991        assert!(!ts_result.has_errors);
1992
1993        // .tsx file should parse JSX
1994        let tsx_result = parser.parse(
1995            "export function App() { return <div/>; }",
1996            &PathBuf::from("test.tsx"),
1997        );
1998        assert!(!tsx_result.has_errors);
1999        let app = tsx_result.symbols.iter().find(|s| s.name == "App").unwrap();
2000        assert_eq!(app.kind, SymbolKind::Component);
2001    }
2002
2003    #[test]
2004    fn test_match_tsconfig_pattern_no_wildcard() {
2005        // Exact match without wildcard
2006        assert_eq!(
2007            match_tsconfig_pattern("lodash", "lodash"),
2008            Some(String::new())
2009        );
2010        assert_eq!(match_tsconfig_pattern("lodash", "lodash/fp"), None);
2011        assert_eq!(match_tsconfig_pattern("lodash", "underscore"), None);
2012    }
2013
2014    #[test]
2015    fn test_calls_in_arrow_function() {
2016        let result = parse_ts_source(
2017            r#"
2018export const init = () => {
2019    setup();
2020    configure();
2021};
2022"#,
2023        );
2024        let callee_names: Vec<&str> = result
2025            .calls
2026            .iter()
2027            .map(|c| c.callee_name.as_str())
2028            .collect();
2029        assert!(callee_names.contains(&"setup"), "calls: {:?}", result.calls);
2030        assert!(
2031            callee_names.contains(&"configure"),
2032            "calls: {:?}",
2033            result.calls
2034        );
2035    }
2036}