Skip to main content

the_code_graph_parser/
typescript.rs

1use std::cell::RefCell;
2use std::path::Path;
3
4use tree_sitter::{Node, Parser};
5use tree_sitter_language::LanguageFn;
6
7use domain::error::CodeGraphError;
8use domain::model::{Edge, EdgeKind, Language, Location, SymbolKind, SymbolNode, Visibility};
9
10use crate::{ImportName, LanguageParser, ParseResult, RawImport};
11
12// Shared thread-local parser instance. Language is switched per file via `set_language()`.
13thread_local! {
14    static TS_PARSER: RefCell<Parser> = RefCell::new(Parser::new());
15}
16
17/// Parse source using the thread-local parser with the given grammar.
18fn parse_with_grammar(
19    source: &[u8],
20    path: &Path,
21    lang_fn: LanguageFn,
22) -> domain::error::Result<ParseResult> {
23    let lang: tree_sitter::Language = lang_fn.into();
24
25    TS_PARSER.with(|parser_cell| {
26        let mut parser = parser_cell.borrow_mut();
27        parser
28            .set_language(&lang)
29            .map_err(|e| CodeGraphError::Parse {
30                file: path.to_path_buf(),
31                message: format!("failed to set language: {e}"),
32            })?;
33
34        let tree = parser
35            .parse(source, None)
36            .ok_or_else(|| CodeGraphError::Parse {
37                file: path.to_path_buf(),
38                message: "tree-sitter parse returned None".into(),
39            })?;
40
41        extract_all(source, path, &tree)
42    })
43}
44
45// ---------------------------------------------------------------------------
46// TypeScriptParser — handles .ts and .tsx
47// ---------------------------------------------------------------------------
48
49/// Parser for TypeScript (.ts) and TSX (.tsx) files.
50pub struct TypeScriptParser {
51    ts_language: LanguageFn,
52    tsx_language: LanguageFn,
53}
54
55impl TypeScriptParser {
56    pub fn new() -> Self {
57        Self {
58            ts_language: tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
59            tsx_language: tree_sitter_typescript::LANGUAGE_TSX,
60        }
61    }
62
63    fn language_fn_for_path(&self, path: &Path) -> LanguageFn {
64        match path.extension().and_then(|e| e.to_str()) {
65            Some("tsx") => self.tsx_language,
66            _ => self.ts_language,
67        }
68    }
69}
70
71impl Default for TypeScriptParser {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl LanguageParser for TypeScriptParser {
78    fn language(&self) -> Language {
79        Language::TypeScript
80    }
81
82    fn file_extensions(&self) -> &[&str] {
83        &["ts", "tsx"]
84    }
85
86    fn parse(&self, source: &[u8], path: &Path) -> domain::error::Result<ParseResult> {
87        parse_with_grammar(source, path, self.language_fn_for_path(path))
88    }
89}
90
91// ---------------------------------------------------------------------------
92// JavaScriptParser — handles .js and .jsx
93// ---------------------------------------------------------------------------
94
95/// Parser for JavaScript (.js) and JSX (.jsx) files.
96pub struct JavaScriptParser {
97    js_language: LanguageFn,
98    jsx_language: LanguageFn,
99}
100
101impl JavaScriptParser {
102    pub fn new() -> Self {
103        Self {
104            js_language: tree_sitter_javascript::LANGUAGE,
105            jsx_language: tree_sitter_typescript::LANGUAGE_TSX,
106        }
107    }
108
109    fn language_fn_for_path(&self, path: &Path) -> LanguageFn {
110        match path.extension().and_then(|e| e.to_str()) {
111            Some("jsx") => self.jsx_language,
112            _ => self.js_language,
113        }
114    }
115}
116
117impl Default for JavaScriptParser {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl LanguageParser for JavaScriptParser {
124    fn language(&self) -> Language {
125        Language::JavaScript
126    }
127
128    fn file_extensions(&self) -> &[&str] {
129        &["js", "jsx"]
130    }
131
132    fn parse(&self, source: &[u8], path: &Path) -> domain::error::Result<ParseResult> {
133        parse_with_grammar(source, path, self.language_fn_for_path(path))
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Shared extraction logic
139// ---------------------------------------------------------------------------
140
141fn extract_all(
142    source: &[u8],
143    path: &Path,
144    tree: &tree_sitter::Tree,
145) -> domain::error::Result<ParseResult> {
146    let mut symbols = Vec::new();
147    let mut edges = Vec::new();
148    let file_path = path.to_string_lossy().to_string();
149    let root = tree.root_node();
150    let mut cursor = root.walk();
151
152    for child in root.children(&mut cursor) {
153        if !child.is_named() {
154            continue;
155        }
156        match child.kind() {
157            "export_statement" => {
158                // Check if this is a default export
159                let is_default = {
160                    let mut dc = child.walk();
161                    let result = child
162                        .children(&mut dc)
163                        .any(|c| !c.is_named() && c.kind() == "default");
164                    result
165                };
166
167                let mut found_declaration = false;
168                let mut inner_cursor = child.walk();
169                for inner in child.children(&mut inner_cursor) {
170                    if !inner.is_named() {
171                        continue;
172                    }
173                    if is_declaration_kind(inner.kind()) {
174                        found_declaration = true;
175                        extract_declaration(
176                            source,
177                            &file_path,
178                            inner,
179                            true,
180                            &mut symbols,
181                            &mut edges,
182                        );
183                        // If anonymous default declaration, emit "default" symbol
184                        if is_default && symbol_name(inner, source).is_none() {
185                            let qn = format!("{file_path}::default");
186                            symbols.push(SymbolNode {
187                                name: "default".to_string(),
188                                qualified_name: qn.clone(),
189                                kind: match inner.kind() {
190                                    "function_declaration" => SymbolKind::Function,
191                                    "class_declaration" | "abstract_class_declaration" => {
192                                        SymbolKind::Class
193                                    }
194                                    _ => SymbolKind::Variable,
195                                },
196                                location: node_location(&file_path, inner),
197                                visibility: Visibility::Public,
198                                is_exported: true,
199                                is_async: has_async_keyword(inner),
200                                is_test: false,
201                                decorators: Vec::new(),
202                                signature: build_signature(source, inner),
203                            });
204                            edges.push(contains_edge(&file_path, &qn));
205                        }
206                    }
207                }
208
209                // Handle `export default <expression>` (function_expression, class_expression, etc.)
210                if is_default && !found_declaration {
211                    if let Some(value) = child.child_by_field_name("value") {
212                        let kind = match value.kind() {
213                            "function_expression" | "arrow_function" => Some(SymbolKind::Function),
214                            "class" => Some(SymbolKind::Class),
215                            _ => None,
216                        };
217                        if let Some(sym_kind) = kind {
218                            let qn = format!("{file_path}::default");
219                            symbols.push(SymbolNode {
220                                name: "default".to_string(),
221                                qualified_name: qn.clone(),
222                                kind: sym_kind,
223                                location: node_location(&file_path, value),
224                                visibility: Visibility::Public,
225                                is_exported: true,
226                                is_async: has_async_keyword(value),
227                                is_test: false,
228                                decorators: Vec::new(),
229                                signature: build_signature(source, value),
230                            });
231                            edges.push(contains_edge(&file_path, &qn));
232                        }
233                    }
234                }
235            }
236            kind if is_declaration_kind(kind) => {
237                extract_declaration(source, &file_path, child, false, &mut symbols, &mut edges);
238            }
239            _ => {}
240        }
241    }
242
243    let imports = extract_imports(&root, source);
244    let exports = extract_exports(&root, source);
245
246    Ok(ParseResult {
247        symbols,
248        edges,
249        imports,
250        exports,
251    })
252}
253
254// ---------------------------------------------------------------------------
255// Symbol extraction helpers
256// ---------------------------------------------------------------------------
257
258fn is_declaration_kind(kind: &str) -> bool {
259    matches!(
260        kind,
261        "function_declaration"
262            | "class_declaration"
263            | "abstract_class_declaration"
264            | "interface_declaration"
265            | "type_alias_declaration"
266            | "enum_declaration"
267            | "lexical_declaration"
268            | "variable_declaration"
269    )
270}
271
272/// Extract a declaration node into symbols and edges.
273fn extract_declaration(
274    source: &[u8],
275    file_path: &str,
276    node: Node,
277    is_exported: bool,
278    symbols: &mut Vec<SymbolNode>,
279    edges: &mut Vec<Edge>,
280) {
281    match node.kind() {
282        "function_declaration" => {
283            if let Some(sym) = extract_function(source, file_path, node, is_exported) {
284                edges.push(contains_edge(file_path, &sym.qualified_name));
285                symbols.push(sym);
286            }
287        }
288        "class_declaration" | "abstract_class_declaration" => {
289            extract_class(source, file_path, node, is_exported, symbols, edges);
290        }
291        "interface_declaration" => {
292            extract_interface(source, file_path, node, is_exported, symbols, edges);
293        }
294        "type_alias_declaration" => {
295            if let Some(sym) = extract_type_alias(source, file_path, node, is_exported) {
296                edges.push(contains_edge(file_path, &sym.qualified_name));
297                symbols.push(sym);
298            }
299        }
300        "enum_declaration" => {
301            if let Some(sym) = extract_enum(source, file_path, node, is_exported) {
302                edges.push(contains_edge(file_path, &sym.qualified_name));
303                symbols.push(sym);
304            }
305        }
306        "lexical_declaration" | "variable_declaration" => {
307            extract_variable_declaration(source, file_path, node, is_exported, symbols, edges);
308        }
309        _ => {}
310    }
311}
312
313fn extract_function(
314    source: &[u8],
315    file_path: &str,
316    node: Node,
317    is_exported: bool,
318) -> Option<SymbolNode> {
319    let name = symbol_name(node, source)?;
320    let qualified_name = format!("{file_path}::{name}");
321    let is_async = has_async_keyword(node);
322    let signature = build_signature(source, node);
323    let decorators = extract_decorators(source, node);
324
325    Some(SymbolNode {
326        name: name.clone(),
327        qualified_name,
328        kind: SymbolKind::Function,
329        location: node_location(file_path, node),
330        visibility: export_visibility(is_exported),
331        is_exported,
332        is_async,
333        is_test: is_test_name(&name),
334        decorators,
335        signature,
336    })
337}
338
339fn extract_class(
340    source: &[u8],
341    file_path: &str,
342    node: Node,
343    is_exported: bool,
344    symbols: &mut Vec<SymbolNode>,
345    edges: &mut Vec<Edge>,
346) {
347    let name = match symbol_name(node, source) {
348        Some(n) => n,
349        None => return,
350    };
351    let qualified_name = format!("{file_path}::{name}");
352    let decorators = extract_decorators(source, node);
353
354    let class_sym = SymbolNode {
355        name: name.clone(),
356        qualified_name: qualified_name.clone(),
357        kind: SymbolKind::Class,
358        location: node_location(file_path, node),
359        visibility: export_visibility(is_exported),
360        is_exported,
361        is_async: false,
362        is_test: is_test_name(&name),
363        decorators,
364        signature: None,
365    };
366    edges.push(contains_edge(file_path, &class_sym.qualified_name));
367    symbols.push(class_sym);
368
369    // Extract class body members
370    if let Some(body) = node.child_by_field_name("body") {
371        let mut body_cursor = body.walk();
372        for member in body.children(&mut body_cursor) {
373            if !member.is_named() {
374                continue;
375            }
376            extract_class_member(
377                source,
378                file_path,
379                &name,
380                &qualified_name,
381                member,
382                is_exported,
383                symbols,
384                edges,
385            );
386        }
387    }
388}
389
390#[allow(clippy::too_many_arguments)]
391fn extract_class_member(
392    source: &[u8],
393    file_path: &str,
394    class_name: &str,
395    class_qualified_name: &str,
396    member: Node,
397    is_exported: bool,
398    symbols: &mut Vec<SymbolNode>,
399    edges: &mut Vec<Edge>,
400) {
401    let (kind, member_name) = match member.kind() {
402        "method_definition" => (SymbolKind::Method, symbol_name(member, source)),
403        "public_field_definition" => {
404            // TS grammar: field name via "name" field
405            let n = member
406                .child_by_field_name("name")
407                .and_then(|n| n.utf8_text(source).ok())
408                .map(|s| s.to_string());
409            (SymbolKind::Property, n)
410        }
411        "field_definition" => {
412            // JS grammar: field name via "property" field
413            let n = member
414                .child_by_field_name("property")
415                .and_then(|n| n.utf8_text(source).ok())
416                .map(|s| s.to_string());
417            (SymbolKind::Property, n)
418        }
419        _ => return,
420    };
421
422    let member_name = match member_name {
423        Some(n) => n,
424        None => return,
425    };
426
427    let member_qualified = format!("{file_path}::{class_name}.{member_name}");
428    let is_async = has_async_keyword(member);
429    let signature = if kind == SymbolKind::Method {
430        build_signature(source, member)
431    } else {
432        None
433    };
434    let decorators = extract_decorators(source, member);
435
436    let sym = SymbolNode {
437        name: member_name.clone(),
438        qualified_name: member_qualified.clone(),
439        kind,
440        location: node_location(file_path, member),
441        visibility: export_visibility(is_exported),
442        is_exported,
443        is_async,
444        is_test: is_test_name(&member_name),
445        decorators,
446        signature,
447    };
448    symbols.push(sym);
449    edges.push(Edge {
450        kind: EdgeKind::ChildOf,
451        source: member_qualified,
452        target: class_qualified_name.to_string(),
453        metadata: None,
454    });
455}
456
457fn extract_interface(
458    source: &[u8],
459    file_path: &str,
460    node: Node,
461    is_exported: bool,
462    symbols: &mut Vec<SymbolNode>,
463    edges: &mut Vec<Edge>,
464) {
465    let name = match symbol_name(node, source) {
466        Some(n) => n,
467        None => return,
468    };
469    let qualified_name = format!("{file_path}::{name}");
470
471    let iface_sym = SymbolNode {
472        name: name.clone(),
473        qualified_name: qualified_name.clone(),
474        kind: SymbolKind::Interface,
475        location: node_location(file_path, node),
476        visibility: export_visibility(is_exported),
477        is_exported,
478        is_async: false,
479        is_test: false,
480        decorators: Vec::new(),
481        signature: None,
482    };
483    edges.push(contains_edge(file_path, &iface_sym.qualified_name));
484    symbols.push(iface_sym);
485
486    // Extract interface body members
487    if let Some(body) = node.child_by_field_name("body") {
488        let mut body_cursor = body.walk();
489        for member in body.children(&mut body_cursor) {
490            if !member.is_named() {
491                continue;
492            }
493            extract_interface_member(
494                source,
495                file_path,
496                &name,
497                &qualified_name,
498                member,
499                is_exported,
500                symbols,
501                edges,
502            );
503        }
504    }
505}
506
507#[allow(clippy::too_many_arguments)]
508fn extract_interface_member(
509    source: &[u8],
510    file_path: &str,
511    iface_name: &str,
512    iface_qualified_name: &str,
513    member: Node,
514    is_exported: bool,
515    symbols: &mut Vec<SymbolNode>,
516    edges: &mut Vec<Edge>,
517) {
518    let (kind, member_name) = match member.kind() {
519        "property_signature" => {
520            let n = member
521                .child_by_field_name("name")
522                .and_then(|n| n.utf8_text(source).ok())
523                .map(|s| s.to_string());
524            (SymbolKind::Property, n)
525        }
526        "method_signature" => {
527            let n = symbol_name(member, source);
528            (SymbolKind::Method, n)
529        }
530        _ => return,
531    };
532
533    let member_name = match member_name {
534        Some(n) => n,
535        None => return,
536    };
537
538    let member_qualified = format!("{file_path}::{iface_name}.{member_name}");
539    let signature = if kind == SymbolKind::Method {
540        build_signature(source, member)
541    } else {
542        None
543    };
544
545    let sym = SymbolNode {
546        name: member_name.clone(),
547        qualified_name: member_qualified.clone(),
548        kind,
549        location: node_location(file_path, member),
550        visibility: export_visibility(is_exported),
551        is_exported,
552        is_async: false,
553        is_test: false,
554        decorators: Vec::new(),
555        signature,
556    };
557    symbols.push(sym);
558    edges.push(Edge {
559        kind: EdgeKind::ChildOf,
560        source: member_qualified,
561        target: iface_qualified_name.to_string(),
562        metadata: None,
563    });
564}
565
566fn extract_type_alias(
567    source: &[u8],
568    file_path: &str,
569    node: Node,
570    is_exported: bool,
571) -> Option<SymbolNode> {
572    let name = symbol_name(node, source)?;
573    let qualified_name = format!("{file_path}::{name}");
574
575    Some(SymbolNode {
576        name: name.clone(),
577        qualified_name,
578        kind: SymbolKind::TypeAlias,
579        location: node_location(file_path, node),
580        visibility: export_visibility(is_exported),
581        is_exported,
582        is_async: false,
583        is_test: false,
584        decorators: Vec::new(),
585        signature: None,
586    })
587}
588
589fn extract_enum(
590    source: &[u8],
591    file_path: &str,
592    node: Node,
593    is_exported: bool,
594) -> Option<SymbolNode> {
595    let name = symbol_name(node, source)?;
596    let qualified_name = format!("{file_path}::{name}");
597
598    Some(SymbolNode {
599        name: name.clone(),
600        qualified_name,
601        kind: SymbolKind::Enum,
602        location: node_location(file_path, node),
603        visibility: export_visibility(is_exported),
604        is_exported,
605        is_async: false,
606        is_test: false,
607        decorators: Vec::new(),
608        signature: None,
609    })
610}
611
612fn extract_variable_declaration(
613    source: &[u8],
614    file_path: &str,
615    node: Node,
616    is_exported: bool,
617    symbols: &mut Vec<SymbolNode>,
618    edges: &mut Vec<Edge>,
619) {
620    let is_const = is_const_declaration(source, node);
621
622    // Iterate over variable_declarator children
623    let mut cursor = node.walk();
624    for child in node.children(&mut cursor) {
625        if child.kind() != "variable_declarator" {
626            continue;
627        }
628        let name = match child
629            .child_by_field_name("name")
630            .and_then(|n| n.utf8_text(source).ok())
631            .map(|s| s.to_string())
632        {
633            Some(n) => n,
634            None => continue,
635        };
636
637        // Check if the value is an arrow function or function expression
638        let value_node = child.child_by_field_name("value");
639        let is_function_value = value_node
640            .map(|v| {
641                matches!(
642                    v.kind(),
643                    "arrow_function" | "function_expression" | "function"
644                )
645            })
646            .unwrap_or(false);
647
648        let kind = if is_function_value {
649            SymbolKind::Function
650        } else if is_const {
651            SymbolKind::Const
652        } else {
653            SymbolKind::Variable
654        };
655
656        // Check for async on the value (arrow function / function expression)
657        let is_async = value_node.map(|v| has_async_keyword(v)).unwrap_or(false);
658
659        let qualified_name = format!("{file_path}::{name}");
660        let signature = if is_function_value {
661            value_node.and_then(|v| build_signature(source, v))
662        } else {
663            None
664        };
665        let decorators = extract_decorators(source, node);
666
667        let sym = SymbolNode {
668            name: name.clone(),
669            qualified_name: qualified_name.clone(),
670            kind,
671            location: node_location(file_path, node),
672            visibility: export_visibility(is_exported),
673            is_exported,
674            is_async,
675            is_test: is_test_name(&name),
676            decorators,
677            signature,
678        };
679        edges.push(contains_edge(file_path, &sym.qualified_name));
680        symbols.push(sym);
681    }
682}
683
684/// Get the name of a node via the "name" field.
685fn symbol_name(node: Node, source: &[u8]) -> Option<String> {
686    node.child_by_field_name("name")
687        .and_then(|n| n.utf8_text(source).ok())
688        .map(|s| s.to_string())
689}
690
691/// Build a Location from a tree-sitter node.
692fn node_location(file_path: &str, node: Node) -> Location {
693    let start = node.start_position();
694    let end = node.end_position();
695    Location {
696        file: file_path.into(),
697        line_start: start.row + 1, // Convert 0-based to 1-based
698        line_end: end.row + 1,
699        col_start: start.column,
700        col_end: end.column,
701    }
702}
703
704/// Check if a node has an unnamed "async" child token.
705fn has_async_keyword(node: Node) -> bool {
706    let mut cursor = node.walk();
707    for child in node.children(&mut cursor) {
708        if !child.is_named() && child.kind() == "async" {
709            return true;
710        }
711    }
712    false
713}
714
715/// Check whether a lexical/variable declaration uses `const`.
716fn is_const_declaration(source: &[u8], node: Node) -> bool {
717    let mut cursor = node.walk();
718    for child in node.children(&mut cursor) {
719        if !child.is_named() {
720            if let Ok(text) = child.utf8_text(source) {
721                if text == "const" {
722                    return true;
723                }
724            }
725        }
726        // Keyword comes before any named children
727        if child.is_named() {
728            break;
729        }
730    }
731    false
732}
733
734/// Build a function/method signature string from parameters and return type.
735fn build_signature(source: &[u8], node: Node) -> Option<String> {
736    let params = node
737        .child_by_field_name("parameters")
738        .and_then(|n| n.utf8_text(source).ok())?;
739
740    let return_type = node
741        .child_by_field_name("return_type")
742        .and_then(|n| n.utf8_text(source).ok());
743
744    if let Some(ret) = return_type {
745        Some(format!("{params}{ret}"))
746    } else {
747        Some(params.to_string())
748    }
749}
750
751/// Extract decorator text from named "decorator" children.
752fn extract_decorators(source: &[u8], node: Node) -> Vec<String> {
753    let mut decorators = Vec::new();
754    let mut cursor = node.walk();
755    for child in node.children(&mut cursor) {
756        if child.is_named() && child.kind() == "decorator" {
757            if let Ok(text) = child.utf8_text(source) {
758                decorators.push(text.to_string());
759            }
760        }
761    }
762    decorators
763}
764
765/// Check if a name looks like a test function.
766/// Matches: test*, it (exact), itShould... (camelCase), it_... (snake_case), describe*
767fn is_test_name(name: &str) -> bool {
768    if name.starts_with("test") || name.starts_with("describe") {
769        return true;
770    }
771    if name == "it" {
772        return true;
773    }
774    // Match camelCase "itSomething" but not "items", "iterator", etc.
775    if let Some(rest) = name.strip_prefix("it") {
776        if rest.starts_with('_') || rest.starts_with(|c: char| c.is_ascii_uppercase()) {
777            return true;
778        }
779    }
780    false
781}
782
783/// Map export status to visibility.
784fn export_visibility(is_exported: bool) -> Visibility {
785    if is_exported {
786        Visibility::Public
787    } else {
788        Visibility::Private
789    }
790}
791
792/// Create a Contains edge from file path to a qualified name.
793fn contains_edge(file_path: &str, qualified_name: &str) -> Edge {
794    Edge {
795        kind: EdgeKind::Contains,
796        source: file_path.to_string(),
797        target: qualified_name.to_string(),
798        metadata: None,
799    }
800}
801
802// ---------------------------------------------------------------------------
803// Import extraction (standalone — will be integrated into extract() later)
804// ---------------------------------------------------------------------------
805
806/// Extract all import statements from a tree-sitter CST root node.
807pub(crate) fn extract_imports(root: &tree_sitter::Node, source: &[u8]) -> Vec<RawImport> {
808    let mut imports = Vec::new();
809    let mut cursor = root.walk();
810
811    for child in root.children(&mut cursor) {
812        if child.kind() == "import_statement" {
813            if let Some(imp) = parse_import_statement(&child, source) {
814                imports.push(imp);
815            }
816        }
817    }
818
819    imports
820}
821
822fn node_text<'a>(node: &tree_sitter::Node, source: &'a [u8]) -> &'a str {
823    node.utf8_text(source).unwrap_or("")
824}
825
826fn strip_quotes(s: &str) -> &str {
827    s.trim_matches(|c: char| c == '"' || c == '\'' || c == '`')
828}
829
830fn parse_import_statement(node: &tree_sitter::Node, source: &[u8]) -> Option<RawImport> {
831    // Extract the specifier from the "source" field (the string literal).
832    let specifier_node = node.child_by_field_name("source")?;
833    let specifier = strip_quotes(node_text(&specifier_node, source)).to_string();
834
835    let line = node.start_position().row + 1; // 0-based → 1-based
836
837    // Detect `import type` by looking for an unnamed "type" token among children.
838    let mut is_type_only = false;
839    let mut cursor = node.walk();
840    for child in node.children(&mut cursor) {
841        if !child.is_named() && child.kind() == "type" {
842            is_type_only = true;
843            break;
844        }
845    }
846
847    // Find the import_clause child (if any).
848    let mut import_clause = None;
849    let mut cursor2 = node.walk();
850    for child in node.children(&mut cursor2) {
851        if child.kind() == "import_clause" {
852            import_clause = Some(child);
853            break;
854        }
855    }
856
857    // Side-effect import: no import_clause
858    let Some(clause) = import_clause else {
859        return Some(RawImport {
860            specifier,
861            names: Vec::new(),
862            is_type_only,
863            is_side_effect: true,
864            is_namespace: false,
865            line,
866        });
867    };
868
869    let mut names = Vec::new();
870    let mut is_namespace = false;
871
872    let mut clause_cursor = clause.walk();
873    for child in clause.children(&mut clause_cursor) {
874        match child.kind() {
875            // Default import: a direct identifier child of import_clause
876            "identifier" => {
877                let local_name = node_text(&child, source).to_string();
878                names.push(ImportName {
879                    name: "default".to_string(),
880                    alias: Some(local_name),
881                    is_type: false,
882                });
883            }
884            // Named imports: { a, b as c }
885            "named_imports" => {
886                let mut ni_cursor = child.walk();
887                for spec in child.children(&mut ni_cursor) {
888                    if spec.kind() == "import_specifier" {
889                        let name_node = spec.child_by_field_name("name");
890                        let alias_node = spec.child_by_field_name("alias");
891
892                        if let Some(n) = name_node {
893                            let name = node_text(&n, source).to_string();
894                            let alias = alias_node.map(|a| node_text(&a, source).to_string());
895                            names.push(ImportName {
896                                name,
897                                alias,
898                                is_type: false,
899                            });
900                        }
901                    }
902                }
903            }
904            // Namespace import: * as ns
905            "namespace_import" => {
906                is_namespace = true;
907                let mut ns_cursor = child.walk();
908                for ns_child in child.children(&mut ns_cursor) {
909                    if ns_child.kind() == "identifier" {
910                        let alias = node_text(&ns_child, source).to_string();
911                        names.push(ImportName {
912                            name: "*".to_string(),
913                            alias: Some(alias),
914                            is_type: false,
915                        });
916                        break;
917                    }
918                }
919            }
920            _ => {}
921        }
922    }
923
924    Some(RawImport {
925        specifier,
926        names,
927        is_type_only,
928        is_side_effect: false,
929        is_namespace,
930        line,
931    })
932}
933
934// ---------------------------------------------------------------------------
935// Export extraction (standalone — will be integrated into extract() later)
936// ---------------------------------------------------------------------------
937
938/// Extract all names from a declaration node.
939/// For `lexical_declaration`/`variable_declaration`, returns all `variable_declarator` names.
940/// For other declarations, returns a single name.
941fn declaration_names(node: &tree_sitter::Node, source: &[u8]) -> Vec<String> {
942    if node.kind() == "lexical_declaration" || node.kind() == "variable_declaration" {
943        let mut names = Vec::new();
944        let mut cursor = node.walk();
945        for child in node.children(&mut cursor) {
946            if child.kind() == "variable_declarator" {
947                if let Some(name) = child
948                    .child_by_field_name("name")
949                    .and_then(|n| n.utf8_text(source).ok())
950                {
951                    names.push(name.to_string());
952                }
953            }
954        }
955        names
956    } else {
957        node.child_by_field_name("name")
958            .and_then(|n| n.utf8_text(source).ok())
959            .map(|s| vec![s.to_string()])
960            .unwrap_or_default()
961    }
962}
963
964/// Extract all export statements from a tree-sitter CST root node.
965///
966/// This is a standalone function — it does **not** modify `TypeScriptParser::extract`.
967/// Integration into the main `extract()` method will happen in a later slice.
968pub(crate) fn extract_exports(root: &tree_sitter::Node, source: &[u8]) -> Vec<crate::Export> {
969    let mut exports = Vec::new();
970    let mut cursor = root.walk();
971
972    for top_node in root.children(&mut cursor) {
973        if top_node.kind() != "export_statement" {
974            continue;
975        }
976
977        let node = &top_node;
978
979        // Detect boolean flags from unnamed children
980        let mut has_default = false;
981        let mut has_type = false;
982        let mut has_star = false;
983        {
984            let mut child_cursor = node.walk();
985            for child in node.children(&mut child_cursor) {
986                if !child.is_named() {
987                    match child.kind() {
988                        "default" => has_default = true,
989                        "type" => has_type = true,
990                        "*" => has_star = true,
991                        _ => {}
992                    }
993                }
994            }
995        }
996
997        // Source specifier (re-export target)
998        let source_specifier = node
999            .child_by_field_name("source")
1000            .and_then(|n| n.utf8_text(source).ok())
1001            .map(|s| strip_quotes(s).to_string());
1002        let is_reexport = source_specifier.is_some();
1003
1004        // Star re-export: `export * from "./barrel"`
1005        if has_star {
1006            exports.push(crate::Export {
1007                name: "*".to_string(),
1008                is_reexport: true,
1009                source_specifier,
1010                ..crate::Export::default()
1011            });
1012            continue;
1013        }
1014
1015        // Export clause: `export { foo, bar as baz }` or `export { foo } from "./mod"`
1016        let export_clause = {
1017            let mut child_cursor = node.walk();
1018            let mut found = None;
1019            for child in node.children(&mut child_cursor) {
1020                if child.kind() == "export_clause" {
1021                    found = Some(child);
1022                    break;
1023                }
1024            }
1025            found
1026        };
1027
1028        if let Some(clause) = export_clause {
1029            let mut spec_cursor = clause.walk();
1030            for spec in clause.children(&mut spec_cursor) {
1031                if spec.kind() != "export_specifier" {
1032                    continue;
1033                }
1034                let name_node = spec.child_by_field_name("name");
1035                let alias_node = spec.child_by_field_name("alias");
1036
1037                let local_text = name_node
1038                    .and_then(|n| n.utf8_text(source).ok())
1039                    .unwrap_or("")
1040                    .to_string();
1041
1042                let (export_name, local_name) = if let Some(alias) = alias_node {
1043                    let alias_text = alias.utf8_text(source).unwrap_or("").to_string();
1044                    (alias_text, Some(local_text))
1045                } else {
1046                    (local_text, None)
1047                };
1048
1049                exports.push(crate::Export {
1050                    name: export_name,
1051                    local_name,
1052                    is_type_only: has_type,
1053                    is_reexport,
1054                    source_specifier: source_specifier.clone(),
1055                    ..crate::Export::default()
1056                });
1057            }
1058            continue;
1059        }
1060
1061        // Declaration export: `export function foo() {}`, `export default class Bar {}`
1062        if let Some(decl) = node.child_by_field_name("declaration") {
1063            let names = declaration_names(&decl, source);
1064
1065            if has_default {
1066                exports.push(crate::Export {
1067                    name: "default".to_string(),
1068                    local_name: names.into_iter().next(),
1069                    is_default: true,
1070                    ..crate::Export::default()
1071                });
1072            } else {
1073                for name in names {
1074                    exports.push(crate::Export {
1075                        name,
1076                        ..crate::Export::default()
1077                    });
1078                }
1079            }
1080            continue;
1081        }
1082
1083        // Default expression export: `export default 42`, `export default myVar`
1084        if has_default {
1085            let value_node = node.child_by_field_name("value");
1086            let local_name = value_node.and_then(|n| {
1087                if n.kind() == "identifier" {
1088                    n.utf8_text(source).ok().map(|s| s.to_string())
1089                } else {
1090                    None
1091                }
1092            });
1093
1094            exports.push(crate::Export {
1095                name: "default".to_string(),
1096                local_name,
1097                is_default: true,
1098                ..crate::Export::default()
1099            });
1100        }
1101    }
1102
1103    exports
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109
1110    #[test]
1111    fn language_returns_typescript() {
1112        let parser = TypeScriptParser::new();
1113        assert_eq!(parser.language(), Language::TypeScript);
1114    }
1115
1116    #[test]
1117    fn ts_file_extensions() {
1118        let parser = TypeScriptParser::new();
1119        let exts = parser.file_extensions();
1120        assert!(exts.contains(&"ts"));
1121        assert!(exts.contains(&"tsx"));
1122        assert!(!exts.contains(&"js"));
1123    }
1124
1125    #[test]
1126    fn js_file_extensions() {
1127        let parser = JavaScriptParser::new();
1128        let exts = parser.file_extensions();
1129        assert!(exts.contains(&"js"));
1130        assert!(exts.contains(&"jsx"));
1131        assert!(!exts.contains(&"ts"));
1132    }
1133
1134    #[test]
1135    fn js_language_returns_javascript() {
1136        let parser = JavaScriptParser::new();
1137        assert_eq!(parser.language(), Language::JavaScript);
1138    }
1139
1140    #[test]
1141    fn parse_empty_ts_file() {
1142        let parser = TypeScriptParser::new();
1143        let result = parser
1144            .parse(b"", Path::new("test.ts"))
1145            .expect("should parse empty file");
1146        assert!(result.symbols.is_empty());
1147        assert!(result.edges.is_empty());
1148        assert!(result.imports.is_empty());
1149        assert!(result.exports.is_empty());
1150    }
1151
1152    #[test]
1153    fn parse_empty_js_file() {
1154        let parser = JavaScriptParser::new();
1155        let result = parser
1156            .parse(b"", Path::new("test.js"))
1157            .expect("should parse empty file");
1158        assert!(result.symbols.is_empty());
1159    }
1160
1161    #[test]
1162    fn language_fn_selects_correct_grammar() {
1163        let ts_parser = TypeScriptParser::new();
1164        let _ = ts_parser.language_fn_for_path(Path::new("a.ts"));
1165        let _ = ts_parser.language_fn_for_path(Path::new("a.tsx"));
1166
1167        let js_parser = JavaScriptParser::new();
1168        let _ = js_parser.language_fn_for_path(Path::new("a.js"));
1169        let _ = js_parser.language_fn_for_path(Path::new("a.jsx"));
1170    }
1171
1172    // -----------------------------------------------------------------------
1173    // Import extraction tests
1174    // -----------------------------------------------------------------------
1175
1176    fn parse_ts_imports(source: &str) -> Vec<crate::RawImport> {
1177        let lang: tree_sitter::Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
1178        let mut ts_parser = tree_sitter::Parser::new();
1179        ts_parser.set_language(&lang).unwrap();
1180        let tree = ts_parser.parse(source.as_bytes(), None).unwrap();
1181        extract_imports(&tree.root_node(), source.as_bytes())
1182    }
1183
1184    #[test]
1185    fn import_named() {
1186        let imports = parse_ts_imports(r#"import { a, b } from "./mod""#);
1187        assert_eq!(imports.len(), 1);
1188        assert_eq!(imports[0].specifier, "./mod");
1189        assert_eq!(imports[0].names.len(), 2);
1190        assert_eq!(imports[0].names[0].name, "a");
1191        assert_eq!(imports[0].names[1].name, "b");
1192        assert!(!imports[0].is_type_only);
1193    }
1194
1195    #[test]
1196    fn import_type_only() {
1197        let imports = parse_ts_imports(r#"import type { T } from "./types""#);
1198        assert_eq!(imports.len(), 1);
1199        assert!(imports[0].is_type_only);
1200    }
1201
1202    #[test]
1203    fn import_namespace() {
1204        let imports = parse_ts_imports(r#"import * as ns from "./ns""#);
1205        assert_eq!(imports.len(), 1);
1206        assert!(imports[0].is_namespace);
1207    }
1208
1209    #[test]
1210    fn import_side_effect() {
1211        let imports = parse_ts_imports(r#"import "./polyfill""#);
1212        assert_eq!(imports.len(), 1);
1213        assert!(imports[0].is_side_effect);
1214        assert!(imports[0].names.is_empty());
1215    }
1216
1217    #[test]
1218    fn import_default() {
1219        let imports = parse_ts_imports(r#"import def from "./mod""#);
1220        assert_eq!(imports.len(), 1);
1221        assert_eq!(imports[0].names.len(), 1);
1222        assert_eq!(imports[0].names[0].name, "default");
1223        assert_eq!(imports[0].names[0].alias, Some("def".to_string()));
1224    }
1225
1226    #[test]
1227    fn import_mixed_default_and_named() {
1228        let imports = parse_ts_imports(r#"import def, { a } from "./mod""#);
1229        assert_eq!(imports.len(), 1);
1230        assert_eq!(imports[0].names.len(), 2);
1231    }
1232
1233    // -----------------------------------------------------------------------
1234    // Export extraction tests
1235    // -----------------------------------------------------------------------
1236
1237    fn parse_ts_exports(source: &str) -> Vec<crate::Export> {
1238        let lang: tree_sitter::Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
1239        let mut ts_parser = tree_sitter::Parser::new();
1240        ts_parser.set_language(&lang).unwrap();
1241        let tree = ts_parser.parse(source.as_bytes(), None).unwrap();
1242        extract_exports(&tree.root_node(), source.as_bytes())
1243    }
1244
1245    #[test]
1246    fn export_function() {
1247        let exports = parse_ts_exports("export function foo() {}");
1248        assert_eq!(exports.len(), 1);
1249        assert_eq!(exports[0].name, "foo");
1250        assert!(!exports[0].is_default);
1251        assert!(!exports[0].is_reexport);
1252    }
1253
1254    #[test]
1255    fn export_default_class() {
1256        let exports = parse_ts_exports("export default class Bar {}");
1257        assert_eq!(exports.len(), 1);
1258        assert_eq!(exports[0].name, "default");
1259        assert_eq!(exports[0].local_name, Some("Bar".to_string()));
1260        assert!(exports[0].is_default);
1261    }
1262
1263    #[test]
1264    fn export_reexport() {
1265        let exports = parse_ts_exports(r#"export { foo } from "./mod""#);
1266        assert_eq!(exports.len(), 1);
1267        assert_eq!(exports[0].name, "foo");
1268        assert!(exports[0].is_reexport);
1269        assert_eq!(exports[0].source_specifier, Some("./mod".to_string()));
1270    }
1271
1272    #[test]
1273    fn export_with_alias() {
1274        let exports = parse_ts_exports("export { foo as bar }");
1275        assert_eq!(exports.len(), 1);
1276        assert_eq!(exports[0].name, "bar");
1277        assert_eq!(exports[0].local_name, Some("foo".to_string()));
1278    }
1279
1280    #[test]
1281    fn export_star() {
1282        let exports = parse_ts_exports(r#"export * from "./barrel""#);
1283        assert_eq!(exports.len(), 1);
1284        assert_eq!(exports[0].name, "*");
1285        assert!(exports[0].is_reexport);
1286        assert_eq!(exports[0].source_specifier, Some("./barrel".to_string()));
1287    }
1288
1289    #[test]
1290    fn export_type_only() {
1291        let exports = parse_ts_exports("export type { Foo }");
1292        assert_eq!(exports.len(), 1);
1293        assert_eq!(exports[0].name, "Foo");
1294        assert!(exports[0].is_type_only);
1295    }
1296
1297    #[test]
1298    fn export_default_expression() {
1299        let exports = parse_ts_exports("export default 42");
1300        assert_eq!(exports.len(), 1);
1301        assert_eq!(exports[0].name, "default");
1302        assert!(exports[0].is_default);
1303        assert!(exports[0].local_name.is_none());
1304    }
1305
1306    // -----------------------------------------------------------------------
1307    // Symbol extraction tests
1308    // -----------------------------------------------------------------------
1309
1310    fn parse_ts(source: &str) -> ParseResult {
1311        let parser = TypeScriptParser::new();
1312        parser
1313            .parse(source.as_bytes(), Path::new("test.ts"))
1314            .expect("parse failed")
1315    }
1316
1317    #[test]
1318    fn symbol_function_declaration() {
1319        let result = parse_ts("function foo() {}");
1320        assert_eq!(result.symbols.len(), 1);
1321        let sym = &result.symbols[0];
1322        assert_eq!(sym.name, "foo");
1323        assert_eq!(sym.kind, SymbolKind::Function);
1324        assert!(!sym.is_exported);
1325        assert_eq!(sym.visibility, Visibility::Private);
1326    }
1327
1328    #[test]
1329    fn symbol_class_with_method() {
1330        let result = parse_ts("class Bar { baz() {} }");
1331        assert_eq!(result.symbols.len(), 2);
1332
1333        let class_sym = result.symbols.iter().find(|s| s.name == "Bar").unwrap();
1334        assert_eq!(class_sym.kind, SymbolKind::Class);
1335
1336        let method_sym = result.symbols.iter().find(|s| s.name == "baz").unwrap();
1337        assert_eq!(method_sym.kind, SymbolKind::Method);
1338
1339        // Verify ChildOf edge exists
1340        let child_of = result
1341            .edges
1342            .iter()
1343            .find(|e| e.kind == EdgeKind::ChildOf)
1344            .unwrap();
1345        assert_eq!(child_of.source, "test.ts::Bar.baz");
1346        assert_eq!(child_of.target, "test.ts::Bar");
1347    }
1348
1349    #[test]
1350    fn symbol_interface_with_property() {
1351        let result = parse_ts("interface IFoo { prop: string }");
1352        assert_eq!(result.symbols.len(), 2);
1353
1354        let iface = result.symbols.iter().find(|s| s.name == "IFoo").unwrap();
1355        assert_eq!(iface.kind, SymbolKind::Interface);
1356
1357        let prop = result.symbols.iter().find(|s| s.name == "prop").unwrap();
1358        assert_eq!(prop.kind, SymbolKind::Property);
1359    }
1360
1361    #[test]
1362    fn symbol_type_alias() {
1363        let result = parse_ts("type Alias = string");
1364        assert_eq!(result.symbols.len(), 1);
1365        let sym = &result.symbols[0];
1366        assert_eq!(sym.name, "Alias");
1367        assert_eq!(sym.kind, SymbolKind::TypeAlias);
1368    }
1369
1370    #[test]
1371    fn symbol_enum_declaration() {
1372        let result = parse_ts("enum Color { Red, Green }");
1373        assert_eq!(result.symbols.len(), 1);
1374        let sym = &result.symbols[0];
1375        assert_eq!(sym.name, "Color");
1376        assert_eq!(sym.kind, SymbolKind::Enum);
1377    }
1378
1379    #[test]
1380    fn symbol_exported_async_arrow() {
1381        let result = parse_ts("export const handler = async () => {}");
1382        assert_eq!(result.symbols.len(), 1);
1383        let sym = &result.symbols[0];
1384        assert_eq!(sym.name, "handler");
1385        assert_eq!(sym.kind, SymbolKind::Function);
1386        assert!(sym.is_async);
1387        assert!(sym.is_exported);
1388        assert_eq!(sym.visibility, Visibility::Public);
1389    }
1390
1391    #[test]
1392    fn symbol_export_default_function() {
1393        let result = parse_ts("export default function main() {}");
1394        assert_eq!(result.symbols.len(), 1);
1395        let sym = &result.symbols[0];
1396        assert_eq!(sym.name, "main");
1397        assert_eq!(sym.kind, SymbolKind::Function);
1398        assert!(sym.is_exported);
1399    }
1400
1401    #[test]
1402    fn symbol_non_exported_private() {
1403        let result = parse_ts("function helper() {}");
1404        assert_eq!(result.symbols.len(), 1);
1405        let sym = &result.symbols[0];
1406        assert_eq!(sym.visibility, Visibility::Private);
1407        assert!(!sym.is_exported);
1408    }
1409
1410    #[test]
1411    fn symbol_contains_edges() {
1412        let result = parse_ts("function a() {}\nfunction b() {}");
1413        let contains: Vec<_> = result
1414            .edges
1415            .iter()
1416            .filter(|e| e.kind == EdgeKind::Contains)
1417            .collect();
1418        assert_eq!(contains.len(), 2);
1419        for edge in &contains {
1420            assert_eq!(edge.source, "test.ts");
1421        }
1422        assert!(contains.iter().any(|e| e.target == "test.ts::a"));
1423        assert!(contains.iter().any(|e| e.target == "test.ts::b"));
1424    }
1425
1426    #[test]
1427    fn symbol_child_of_edges() {
1428        let result = parse_ts("class Foo { bar() {} }");
1429        let child_of: Vec<_> = result
1430            .edges
1431            .iter()
1432            .filter(|e| e.kind == EdgeKind::ChildOf)
1433            .collect();
1434        assert_eq!(child_of.len(), 1);
1435        assert_eq!(child_of[0].source, "test.ts::Foo.bar");
1436        assert_eq!(child_of[0].target, "test.ts::Foo");
1437    }
1438
1439    #[test]
1440    fn symbol_qualified_names() {
1441        let result = parse_ts("class MyClass { myMethod() {} }");
1442        let class_sym = result.symbols.iter().find(|s| s.name == "MyClass").unwrap();
1443        assert_eq!(class_sym.qualified_name, "test.ts::MyClass");
1444
1445        let method_sym = result
1446            .symbols
1447            .iter()
1448            .find(|s| s.name == "myMethod")
1449            .unwrap();
1450        assert_eq!(method_sym.qualified_name, "test.ts::MyClass.myMethod");
1451    }
1452
1453    #[test]
1454    fn symbol_const_variable() {
1455        let result = parse_ts("const MAX = 100");
1456        assert_eq!(result.symbols.len(), 1);
1457        let sym = &result.symbols[0];
1458        assert_eq!(sym.name, "MAX");
1459        assert_eq!(sym.kind, SymbolKind::Const);
1460    }
1461
1462    #[test]
1463    fn symbol_let_variable() {
1464        let result = parse_ts("let count = 0");
1465        assert_eq!(result.symbols.len(), 1);
1466        let sym = &result.symbols[0];
1467        assert_eq!(sym.name, "count");
1468        assert_eq!(sym.kind, SymbolKind::Variable);
1469    }
1470
1471    #[test]
1472    fn symbol_function_signature() {
1473        let result = parse_ts("function add(x: number, y: number): number { return x + y; }");
1474        let sym = &result.symbols[0];
1475        assert!(sym.signature.is_some());
1476        let sig = sym.signature.as_ref().unwrap();
1477        assert!(sig.contains("x: number"));
1478        assert!(sig.contains("number")); // return type
1479    }
1480
1481    #[test]
1482    fn symbol_interface_method_signature() {
1483        let result = parse_ts("interface ICalc { add(a: number, b: number): number }");
1484        let method = result.symbols.iter().find(|s| s.name == "add").unwrap();
1485        assert_eq!(method.kind, SymbolKind::Method);
1486        assert!(method.signature.is_some());
1487    }
1488
1489    #[test]
1490    fn symbol_is_test_heuristic() {
1491        let result = parse_ts("function testSomething() {}");
1492        let sym = &result.symbols[0];
1493        assert!(sym.is_test);
1494
1495        let result2 = parse_ts("function helper() {}");
1496        let sym2 = &result2.symbols[0];
1497        assert!(!sym2.is_test);
1498    }
1499
1500    #[test]
1501    fn symbol_location_is_populated() {
1502        let result = parse_ts("function foo() {}");
1503        let sym = &result.symbols[0];
1504        assert_eq!(sym.location.file.to_string_lossy(), "test.ts");
1505        assert_eq!(sym.location.line_start, 1);
1506        assert!(sym.location.line_end >= 1);
1507    }
1508
1509    // -----------------------------------------------------------------------
1510    // Error handling tests (AC29, AC30)
1511    // -----------------------------------------------------------------------
1512
1513    #[test]
1514    fn parse_invalid_source_returns_error_not_panic() {
1515        // Empty source should not panic — tree-sitter handles it gracefully
1516        let parser = TypeScriptParser::new();
1517        let result = parser.parse(b"", Path::new("empty.ts"));
1518        assert!(result.is_ok());
1519    }
1520
1521    #[test]
1522    fn parse_syntax_errors_returns_partial_result() {
1523        // Source with syntax errors should still extract what it can (AC30)
1524        let source = r#"
1525function valid() {}
1526const x = {{{;
1527function alsoValid() {}
1528"#;
1529        let parser = TypeScriptParser::new();
1530        let result = parser
1531            .parse(source.as_bytes(), Path::new("partial.ts"))
1532            .expect("should not error on syntax errors");
1533        // Should have extracted at least 'valid' — tree-sitter does best-effort
1534        assert!(
1535            !result.symbols.is_empty(),
1536            "should extract at least some symbols from partially broken source"
1537        );
1538        assert!(result.symbols.iter().any(|s| s.name == "valid"));
1539    }
1540
1541    // -----------------------------------------------------------------------
1542    // Thread safety test (AC31)
1543    // -----------------------------------------------------------------------
1544
1545    #[test]
1546    fn parse_from_two_threads_concurrently() {
1547        use std::thread;
1548
1549        let handles: Vec<_> = (0..2)
1550            .map(|i| {
1551                thread::spawn(move || {
1552                    let parser = TypeScriptParser::new();
1553                    let source = format!("function thread{i}() {{}}");
1554                    let result = parser
1555                        .parse(source.as_bytes(), Path::new(&format!("thread{i}.ts")))
1556                        .expect("concurrent parse failed");
1557                    assert_eq!(result.symbols.len(), 1);
1558                    assert_eq!(result.symbols[0].name, format!("thread{i}"));
1559                })
1560            })
1561            .collect();
1562
1563        for h in handles {
1564            h.join().expect("thread panicked");
1565        }
1566    }
1567
1568    // -----------------------------------------------------------------------
1569    // Integration test (multi-construct file)
1570    // -----------------------------------------------------------------------
1571
1572    #[test]
1573    fn integration_multi_construct_file() {
1574        let source = r#"
1575import { helper } from "./utils";
1576import type { Config } from "./config";
1577
1578export function processData(data: string[]): number {
1579    return data.length;
1580}
1581
1582class DataProcessor {
1583    private cache: Map<string, number>;
1584
1585    constructor() {
1586        this.cache = new Map();
1587    }
1588
1589    process(input: string): number {
1590        return input.length;
1591    }
1592}
1593
1594interface IProcessor {
1595    process(input: string): number;
1596}
1597
1598type ProcessorFn = (input: string) => number;
1599
1600enum Status {
1601    Active,
1602    Inactive,
1603}
1604
1605export const transformer = async (x: number) => x * 2;
1606
1607export default class MainProcessor {}
1608
1609export { DataProcessor };
1610export * from "./helpers";
1611"#;
1612        let result = parse_ts(source);
1613
1614        // Symbols
1615        assert!(result
1616            .symbols
1617            .iter()
1618            .any(|s| s.name == "processData" && s.kind == SymbolKind::Function));
1619        assert!(result
1620            .symbols
1621            .iter()
1622            .any(|s| s.name == "DataProcessor" && s.kind == SymbolKind::Class));
1623        assert!(result
1624            .symbols
1625            .iter()
1626            .any(|s| s.name == "process" && s.kind == SymbolKind::Method));
1627        assert!(result
1628            .symbols
1629            .iter()
1630            .any(|s| s.name == "IProcessor" && s.kind == SymbolKind::Interface));
1631        assert!(result
1632            .symbols
1633            .iter()
1634            .any(|s| s.name == "ProcessorFn" && s.kind == SymbolKind::TypeAlias));
1635        assert!(result
1636            .symbols
1637            .iter()
1638            .any(|s| s.name == "Status" && s.kind == SymbolKind::Enum));
1639        assert!(result
1640            .symbols
1641            .iter()
1642            .any(|s| s.name == "transformer" && s.kind == SymbolKind::Function && s.is_async));
1643        assert!(result
1644            .symbols
1645            .iter()
1646            .any(|s| s.name == "MainProcessor" && s.kind == SymbolKind::Class));
1647
1648        // Edges
1649        let contains_count = result
1650            .edges
1651            .iter()
1652            .filter(|e| e.kind == EdgeKind::Contains)
1653            .count();
1654        assert!(
1655            contains_count >= 7,
1656            "expected at least 7 Contains edges, got {contains_count}"
1657        );
1658
1659        let child_of_count = result
1660            .edges
1661            .iter()
1662            .filter(|e| e.kind == EdgeKind::ChildOf)
1663            .count();
1664        assert!(
1665            child_of_count >= 2,
1666            "expected at least 2 ChildOf edges, got {child_of_count}"
1667        );
1668
1669        // Imports
1670        assert_eq!(result.imports.len(), 2);
1671        assert!(result.imports.iter().any(|i| i.specifier == "./utils"));
1672        assert!(result
1673            .imports
1674            .iter()
1675            .any(|i| i.specifier == "./config" && i.is_type_only));
1676
1677        // Exports
1678        assert!(result.exports.iter().any(|e| e.name == "processData"));
1679        assert!(result.exports.iter().any(|e| e.name == "transformer"));
1680        assert!(result
1681            .exports
1682            .iter()
1683            .any(|e| e.name == "default" && e.is_default));
1684        assert!(result.exports.iter().any(|e| e.name == "DataProcessor"));
1685        assert!(result
1686            .exports
1687            .iter()
1688            .any(|e| e.name == "*" && e.is_reexport));
1689    }
1690
1691    #[test]
1692    fn extract_integrates_imports_and_exports() {
1693        let source = r#"
1694import { x } from "./mod";
1695export function foo() {}
1696"#;
1697        let result = parse_ts(source);
1698        assert_eq!(result.imports.len(), 1);
1699        assert_eq!(result.imports[0].specifier, "./mod");
1700        assert_eq!(result.exports.len(), 1);
1701        assert_eq!(result.exports[0].name, "foo");
1702    }
1703
1704    // -----------------------------------------------------------------------
1705    // Review fix tests (W1, W2, W3)
1706    // -----------------------------------------------------------------------
1707
1708    #[test]
1709    fn is_test_no_false_positive_on_items() {
1710        // W1: "items", "iterator" should NOT be marked as test
1711        let result = parse_ts("function items() {}");
1712        let sym = &result.symbols[0];
1713        assert!(!sym.is_test, "'items' should not be detected as test");
1714
1715        let result2 = parse_ts("function iterator() {}");
1716        let sym2 = &result2.symbols[0];
1717        assert!(!sym2.is_test, "'iterator' should not be detected as test");
1718    }
1719
1720    #[test]
1721    fn is_test_matches_it_exact_and_camel() {
1722        // W1: "it" (exact) and "itShould" (camelCase) are test names
1723        let result = parse_ts("function itShouldWork() {}");
1724        assert!(result.symbols[0].is_test);
1725    }
1726
1727    #[test]
1728    fn export_multi_declarator() {
1729        // W2: `export const a = 1, b = 2` should produce two export entries
1730        let exports = parse_ts_exports("export const a = 1, b = 2");
1731        assert_eq!(exports.len(), 2);
1732        assert!(exports.iter().any(|e| e.name == "a"));
1733        assert!(exports.iter().any(|e| e.name == "b"));
1734    }
1735
1736    #[test]
1737    fn export_default_anonymous_function() {
1738        // W3: `export default function() {}` should produce a "default" symbol
1739        let result = parse_ts("export default function() {}");
1740        assert!(
1741            result
1742                .symbols
1743                .iter()
1744                .any(|s| s.name == "default" && s.kind == SymbolKind::Function),
1745            "anonymous default export should produce a 'default' symbol, got: {:?}",
1746            result
1747                .symbols
1748                .iter()
1749                .map(|s| (&s.name, &s.kind))
1750                .collect::<Vec<_>>()
1751        );
1752    }
1753}