gabb_cli/languages/
typescript.rs

1use crate::languages::ImportBindingInfo;
2use crate::store::{normalize_path, EdgeRecord, FileDependency, ReferenceRecord, SymbolRecord};
3use anyhow::{Context, Result};
4use once_cell::sync::Lazy;
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7use tree_sitter::{Language, Node, Parser, TreeCursor};
8
9static TS_LANGUAGE: Lazy<Language> =
10    Lazy::new(|| tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into());
11
12#[derive(Clone, Debug)]
13struct SymbolBinding {
14    id: String,
15    qualifier: Option<String>,
16}
17
18impl From<&SymbolRecord> for SymbolBinding {
19    fn from(value: &SymbolRecord) -> Self {
20        Self {
21            id: value.id.clone(),
22            qualifier: value.qualifier.clone(),
23        }
24    }
25}
26
27#[derive(Clone, Debug)]
28struct ImportBinding {
29    qualifier: Option<String>,
30    imported_name: Option<String>,
31}
32
33impl ImportBinding {
34    fn new(qualifier: Option<String>, imported_name: Option<String>) -> Self {
35        Self {
36            qualifier,
37            imported_name,
38        }
39    }
40
41    fn symbol_id(&self, fallback: &str) -> String {
42        let name = self.imported_name.as_deref().unwrap_or(fallback);
43        if let Some(q) = &self.qualifier {
44            format!("{q}::{name}")
45        } else {
46            fallback.to_string()
47        }
48    }
49}
50
51#[derive(Clone, Debug)]
52struct ResolvedTarget {
53    id: String,
54    qualifier: Option<String>,
55}
56
57impl ResolvedTarget {
58    fn member_id(&self, member: &str) -> String {
59        if let Some(q) = &self.qualifier {
60            format!("{q}::{member}")
61        } else {
62            format!("{}::{member}", self.id)
63        }
64    }
65}
66
67/// Index a TypeScript/TSX file, returning symbols, edges, references, file dependencies, and import bindings.
68#[allow(clippy::type_complexity)]
69pub fn index_file(
70    path: &Path,
71    source: &str,
72) -> Result<(
73    Vec<SymbolRecord>,
74    Vec<EdgeRecord>,
75    Vec<ReferenceRecord>,
76    Vec<FileDependency>,
77    Vec<ImportBindingInfo>,
78)> {
79    let mut parser = Parser::new();
80    parser
81        .set_language(&TS_LANGUAGE)
82        .context("failed to set TypeScript language")?;
83    let tree = parser
84        .parse(source, None)
85        .context("failed to parse TypeScript file")?;
86
87    let mut symbols = Vec::new();
88    let mut declared_spans: HashSet<(usize, usize)> = HashSet::new();
89    let mut symbol_by_name: HashMap<String, SymbolBinding> = HashMap::new();
90    let (imports, mut edges, dependencies, import_bindings) =
91        collect_import_bindings(path, source, &tree.root_node());
92
93    {
94        let mut cursor = tree.walk();
95        walk_symbols(
96            path,
97            source,
98            &mut cursor,
99            None,
100            &mut symbols,
101            &mut edges,
102            &mut declared_spans,
103            &mut symbol_by_name,
104            &imports,
105        );
106    }
107
108    let references = collect_references(
109        path,
110        source,
111        &tree.root_node(),
112        &declared_spans,
113        &symbol_by_name,
114        &imports,
115    );
116    edges.extend(collect_export_edges(
117        path,
118        source,
119        &tree.root_node(),
120        &symbol_by_name,
121        &imports,
122    ));
123
124    Ok((symbols, edges, references, dependencies, import_bindings))
125}
126
127#[allow(clippy::too_many_arguments)]
128fn walk_symbols(
129    path: &Path,
130    source: &str,
131    cursor: &mut TreeCursor,
132    container: Option<String>,
133    symbols: &mut Vec<SymbolRecord>,
134    edges: &mut Vec<EdgeRecord>,
135    declared_spans: &mut HashSet<(usize, usize)>,
136    symbol_by_name: &mut HashMap<String, SymbolBinding>,
137    imports: &HashMap<String, ImportBinding>,
138) {
139    loop {
140        let node = cursor.node();
141        match node.kind() {
142            "function_declaration" => {
143                if let Some(name_node) = node.child_by_field_name("name") {
144                    let name = slice(source, &name_node);
145                    let sym = make_symbol(
146                        path,
147                        &node,
148                        &name,
149                        "function",
150                        container.clone(),
151                        source.as_bytes(),
152                    );
153                    declared_spans.insert((sym.start as usize, sym.end as usize));
154                    symbol_by_name
155                        .entry(name.clone())
156                        .or_insert_with(|| SymbolBinding::from(&sym));
157                    symbols.push(sym);
158                }
159            }
160            "class_declaration" => {
161                if let Some(name_node) = node.child_by_field_name("name") {
162                    let name = slice(source, &name_node);
163                    let sym = make_symbol(
164                        path,
165                        &node,
166                        &name,
167                        "class",
168                        container.clone(),
169                        source.as_bytes(),
170                    );
171                    declared_spans.insert((sym.start as usize, sym.end as usize));
172                    symbol_by_name
173                        .entry(name.clone())
174                        .or_insert_with(|| SymbolBinding::from(&sym));
175                    let class_id = sym.id.clone();
176                    symbols.push(sym);
177
178                    let implements_node = node
179                        .child_by_field_name("implements")
180                        .or_else(|| find_child_kind(&node, "implements_clause"));
181                    if let Some(implements) = implements_node {
182                        for target in
183                            collect_type_targets(path, source, &implements, symbol_by_name, imports)
184                        {
185                            edges.push(EdgeRecord {
186                                src: class_id.clone(),
187                                dst: target.id,
188                                kind: "implements".to_string(),
189                            });
190                        }
191                    }
192                    let extends_node = node
193                        .child_by_field_name("superclass")
194                        .or_else(|| find_child_kind(&node, "extends_clause"));
195                    if let Some(extends) = extends_node {
196                        for target in
197                            collect_type_targets(path, source, &extends, symbol_by_name, imports)
198                        {
199                            edges.push(EdgeRecord {
200                                src: class_id.clone(),
201                                dst: target.id,
202                                kind: "extends".to_string(),
203                            });
204                        }
205                    }
206                }
207            }
208            "interface_declaration" => {
209                if let Some(name_node) = node.child_by_field_name("name") {
210                    let name = slice(source, &name_node);
211                    let sym = make_symbol(
212                        path,
213                        &node,
214                        &name,
215                        "interface",
216                        container.clone(),
217                        source.as_bytes(),
218                    );
219                    declared_spans.insert((sym.start as usize, sym.end as usize));
220                    symbol_by_name
221                        .entry(name.clone())
222                        .or_insert_with(|| SymbolBinding::from(&sym));
223                    symbols.push(sym);
224                }
225            }
226            "type_alias_declaration" => {
227                if let Some(name_node) = node.child_by_field_name("name") {
228                    let name = slice(source, &name_node);
229                    let sym = make_symbol(
230                        path,
231                        &node,
232                        &name,
233                        "type",
234                        container.clone(),
235                        source.as_bytes(),
236                    );
237                    declared_spans.insert((sym.start as usize, sym.end as usize));
238                    symbol_by_name
239                        .entry(name.clone())
240                        .or_insert_with(|| SymbolBinding::from(&sym));
241                    symbols.push(sym);
242                }
243            }
244            "enum_declaration" => {
245                if let Some(name_node) = node.child_by_field_name("name") {
246                    let name = slice(source, &name_node);
247                    let sym = make_symbol(
248                        path,
249                        &node,
250                        &name,
251                        "enum",
252                        container.clone(),
253                        source.as_bytes(),
254                    );
255                    declared_spans.insert((sym.start as usize, sym.end as usize));
256                    symbol_by_name
257                        .entry(name.clone())
258                        .or_insert_with(|| SymbolBinding::from(&sym));
259                    let enum_name = name.clone();
260                    symbols.push(sym);
261
262                    // Extract enum members
263                    if let Some(body) = node.child_by_field_name("body") {
264                        let mut body_cursor = body.walk();
265                        for child in body.children(&mut body_cursor) {
266                            if child.kind() == "property_identifier"
267                                || child.kind() == "enum_assignment"
268                            {
269                                let member_name_node = if child.kind() == "enum_assignment" {
270                                    child.child_by_field_name("name").unwrap_or(child)
271                                } else {
272                                    child
273                                };
274                                let member_name = slice(source, &member_name_node);
275                                let member_sym = make_symbol(
276                                    path,
277                                    &child,
278                                    &member_name,
279                                    "enum_member",
280                                    Some(enum_name.clone()),
281                                    source.as_bytes(),
282                                );
283                                declared_spans
284                                    .insert((member_sym.start as usize, member_sym.end as usize));
285                                symbols.push(member_sym);
286                            }
287                        }
288                    }
289                }
290            }
291            "lexical_declaration" => {
292                // Handle const/let declarations
293                let is_const = node.children(&mut node.walk()).any(|c| c.kind() == "const");
294                let kind = if is_const { "const" } else { "variable" };
295
296                let mut decl_cursor = node.walk();
297                for child in node.children(&mut decl_cursor) {
298                    if child.kind() == "variable_declarator" {
299                        if let Some(name_node) = child.child_by_field_name("name") {
300                            let name = slice(source, &name_node);
301
302                            // Check if value is an arrow function or function expression
303                            let value_kind = child
304                                .child_by_field_name("value")
305                                .map(|v| v.kind())
306                                .unwrap_or("");
307
308                            let sym_kind =
309                                if value_kind == "arrow_function" || value_kind == "function" {
310                                    "function"
311                                } else {
312                                    kind
313                                };
314
315                            let sym = make_symbol(
316                                path,
317                                &child,
318                                &name,
319                                sym_kind,
320                                container.clone(),
321                                source.as_bytes(),
322                            );
323                            declared_spans.insert((sym.start as usize, sym.end as usize));
324                            symbol_by_name
325                                .entry(name.clone())
326                                .or_insert_with(|| SymbolBinding::from(&sym));
327                            symbols.push(sym);
328                        }
329                    }
330                }
331            }
332            "method_definition" | "method_signature" => {
333                if let Some(name_node) = node.child_by_field_name("name") {
334                    let name = slice(source, &name_node);
335                    let sym = make_symbol(
336                        path,
337                        &node,
338                        &name,
339                        "method",
340                        container.clone(),
341                        source.as_bytes(),
342                    );
343                    declared_spans.insert((sym.start as usize, sym.end as usize));
344                    symbol_by_name
345                        .entry(name.clone())
346                        .or_insert_with(|| SymbolBinding::from(&sym));
347                    add_override_edges(
348                        path,
349                        source,
350                        &node,
351                        &name,
352                        &sym.id,
353                        edges,
354                        symbol_by_name,
355                        imports,
356                    );
357                    symbols.push(sym);
358                }
359            }
360            _ => {}
361        }
362
363        if cursor.goto_first_child() {
364            let child_container =
365                if matches!(node.kind(), "class_declaration" | "interface_declaration") {
366                    node.child_by_field_name("name")
367                        .map(|n| slice(source, &n))
368                        .or(container.clone())
369                } else {
370                    container.clone()
371                };
372            walk_symbols(
373                path,
374                source,
375                cursor,
376                child_container,
377                symbols,
378                edges,
379                declared_spans,
380                symbol_by_name,
381                imports,
382            );
383            cursor.goto_parent();
384        }
385
386        if !cursor.goto_next_sibling() {
387            break;
388        }
389    }
390}
391
392fn collect_type_targets(
393    path: &Path,
394    source: &str,
395    node: &Node,
396    symbol_by_name: &HashMap<String, SymbolBinding>,
397    imports: &HashMap<String, ImportBinding>,
398) -> Vec<ResolvedTarget> {
399    let mut targets = Vec::new();
400    for child in node.children(&mut node.walk()) {
401        if matches!(
402            child.kind(),
403            "identifier" | "type_identifier" | "nested_type_identifier"
404        ) {
405            targets.push(resolve_target(
406                path,
407                source,
408                &child,
409                symbol_by_name,
410                imports,
411            ));
412        }
413    }
414    targets
415}
416
417fn resolve_target(
418    path: &Path,
419    source: &str,
420    node: &Node,
421    symbol_by_name: &HashMap<String, SymbolBinding>,
422    imports: &HashMap<String, ImportBinding>,
423) -> ResolvedTarget {
424    let name = slice(source, node);
425    resolve_name(
426        &name,
427        Some((node.start_byte(), node.end_byte())),
428        path,
429        symbol_by_name,
430        imports,
431        None,
432    )
433}
434
435#[allow(clippy::too_many_arguments)]
436fn resolve_name(
437    name: &str,
438    span: Option<(usize, usize)>,
439    path: &Path,
440    symbol_by_name: &HashMap<String, SymbolBinding>,
441    imports: &HashMap<String, ImportBinding>,
442    qualifier_override: Option<String>,
443) -> ResolvedTarget {
444    if let Some(binding) = symbol_by_name.get(name) {
445        return ResolvedTarget {
446            id: binding.id.clone(),
447            qualifier: binding.qualifier.clone(),
448        };
449    }
450    if let Some(q) = qualifier_override {
451        return ResolvedTarget {
452            id: format!("{q}::{name}"),
453            qualifier: Some(q),
454        };
455    }
456    if let Some(binding) = imports.get(name) {
457        let id = binding.symbol_id(name);
458        return ResolvedTarget {
459            id,
460            qualifier: binding.qualifier.clone(),
461        };
462    }
463    let fallback = if let Some((start, end)) = span {
464        format!("{}#{}-{}", normalize_path(path), start, end)
465    } else {
466        format!("{}::{}", normalize_path(path), name)
467    };
468    ResolvedTarget {
469        id: fallback,
470        qualifier: None,
471    }
472}
473
474#[allow(clippy::too_many_arguments)]
475fn add_override_edges(
476    path: &Path,
477    source: &str,
478    node: &Node,
479    method_name: &str,
480    method_id: &str,
481    edges: &mut Vec<EdgeRecord>,
482    symbol_by_name: &HashMap<String, SymbolBinding>,
483    imports: &HashMap<String, ImportBinding>,
484) {
485    if let Some(class_node) = find_enclosing_class(node.parent()) {
486        let implements = class_node
487            .child_by_field_name("implements")
488            .or_else(|| find_child_kind(&class_node, "implements_clause"))
489            .map(|n| collect_type_targets(path, source, &n, symbol_by_name, imports))
490            .unwrap_or_default();
491        let supers = class_node
492            .child_by_field_name("superclass")
493            .or_else(|| find_child_kind(&class_node, "extends_clause"))
494            .map(|n| collect_type_targets(path, source, &n, symbol_by_name, imports))
495            .unwrap_or_default();
496
497        for target in implements.iter().chain(supers.iter()) {
498            edges.push(EdgeRecord {
499                src: method_id.to_string(),
500                dst: target.member_id(method_name),
501                kind: "overrides".to_string(),
502            });
503        }
504    }
505}
506
507fn find_enclosing_class(mut node: Option<Node>) -> Option<Node> {
508    while let Some(n) = node {
509        if n.kind() == "class_declaration" {
510            return Some(n);
511        }
512        node = n.parent();
513    }
514    None
515}
516
517fn collect_import_bindings(
518    path: &Path,
519    source: &str,
520    root: &Node,
521) -> (
522    HashMap<String, ImportBinding>,
523    Vec<EdgeRecord>,
524    Vec<FileDependency>,
525    Vec<ImportBindingInfo>,
526) {
527    let mut imports = HashMap::new();
528    let mut edges = Vec::new();
529    let mut dependencies = Vec::new();
530    let mut import_binding_infos = Vec::new();
531    let mut seen_deps: HashSet<String> = HashSet::new();
532    let mut stack = vec![*root];
533    let from_file = normalize_path(path);
534
535    while let Some(node) = stack.pop() {
536        if node.kind() == "import_statement" {
537            let raw_source = node
538                .child_by_field_name("source")
539                .map(|s| slice(source, &s));
540
541            let qualifier = raw_source.as_ref().map(|raw| import_qualifier(path, raw));
542
543            // Record file dependency with resolved path
544            let resolved_source = raw_source
545                .as_ref()
546                .and_then(|raw| resolve_import_path(path, raw));
547            if let Some(ref resolved) = resolved_source {
548                if !seen_deps.contains(resolved) {
549                    seen_deps.insert(resolved.clone());
550                    dependencies.push(FileDependency {
551                        from_file: from_file.clone(),
552                        to_file: resolved.clone(),
553                        kind: "import".to_string(),
554                    });
555                }
556            }
557
558            let mut import_stack = vec![node];
559            while let Some(n) = import_stack.pop() {
560                match n.kind() {
561                    "import_specifier" => {
562                        let imported_node = n.child_by_field_name("name").unwrap_or(n);
563                        let alias_node = n.child_by_field_name("alias").unwrap_or(imported_node);
564                        let imported_name = slice(source, &imported_node);
565                        let local_name = if let Some(alias) = n.child_by_field_name("alias") {
566                            slice(source, &alias)
567                        } else {
568                            imported_name.clone()
569                        };
570                        let binding =
571                            ImportBinding::new(qualifier.clone(), Some(imported_name.clone()));
572                        add_import_binding(
573                            path,
574                            &alias_node,
575                            local_name.clone(),
576                            binding,
577                            &mut imports,
578                            &mut edges,
579                        );
580                        // Track for two-phase resolution
581                        if let Some(ref source_file) = resolved_source {
582                            import_binding_infos.push(ImportBindingInfo {
583                                local_name,
584                                source_file: source_file.clone(),
585                                original_name: imported_name,
586                            });
587                        }
588                        continue;
589                    }
590                    "identifier" => {
591                        let name = slice(source, &n);
592                        let binding = ImportBinding::new(qualifier.clone(), None);
593                        add_import_binding(
594                            path,
595                            &n,
596                            name.clone(),
597                            binding,
598                            &mut imports,
599                            &mut edges,
600                        );
601                        // Default import - local name equals original name
602                        if let Some(ref source_file) = resolved_source {
603                            import_binding_infos.push(ImportBindingInfo {
604                                local_name: name.clone(),
605                                source_file: source_file.clone(),
606                                original_name: name,
607                            });
608                        }
609                        continue;
610                    }
611                    "namespace_import" => {
612                        if let Some(name_node) = n.child_by_field_name("name") {
613                            let name = slice(source, &name_node);
614                            let binding = ImportBinding::new(qualifier.clone(), None);
615                            add_import_binding(
616                                path,
617                                &name_node,
618                                name,
619                                binding,
620                                &mut imports,
621                                &mut edges,
622                            );
623                            // Namespace imports are handled specially - they don't map to a single symbol
624                        }
625                        continue;
626                    }
627                    _ => {}
628                }
629
630                let mut cursor = n.walk();
631                for child in n.children(&mut cursor) {
632                    import_stack.push(child);
633                }
634            }
635            continue;
636        }
637
638        let mut cursor = node.walk();
639        for child in node.children(&mut cursor) {
640            stack.push(child);
641        }
642    }
643
644    (imports, edges, dependencies, import_binding_infos)
645}
646
647fn add_import_binding(
648    path: &Path,
649    alias_node: &Node,
650    local_name: String,
651    binding: ImportBinding,
652    imports: &mut HashMap<String, ImportBinding>,
653    edges: &mut Vec<EdgeRecord>,
654) {
655    imports.entry(local_name.clone()).or_insert(binding.clone());
656    if binding.qualifier.is_some() {
657        edges.push(EdgeRecord {
658            src: import_edge_id(path, alias_node),
659            dst: binding.symbol_id(&local_name),
660            kind: "import".to_string(),
661        });
662    }
663}
664
665fn import_edge_id(path: &Path, node: &Node) -> String {
666    format!("{}#import-{}", normalize_path(path), node.start_byte())
667}
668
669fn export_edge_id(path: &Path, node: &Node) -> String {
670    format!("{}#export-{}", normalize_path(path), node.start_byte())
671}
672
673fn import_qualifier(path: &Path, raw: &str) -> String {
674    let cleaned = raw.trim().trim_matches('"').trim_matches('\'');
675    let mut target = PathBuf::from(cleaned);
676    if target.is_relative() {
677        if let Some(parent) = path.parent() {
678            target = parent.join(target);
679        }
680    }
681    let mut qualifier = normalize_path(&target);
682    if let Some(ext) = target.extension().and_then(|e| e.to_str()) {
683        let trim = ext.len() + 1;
684        if qualifier.len() > trim {
685            qualifier.truncate(qualifier.len() - trim);
686        }
687    }
688    qualifier
689}
690
691/// Resolve import specifier to actual file path for dependency tracking
692fn resolve_import_path(importing_file: &Path, specifier: &str) -> Option<String> {
693    let cleaned = specifier.trim().trim_matches('"').trim_matches('\'');
694
695    // Skip non-relative imports (node_modules, etc.)
696    if !cleaned.starts_with('.') && !cleaned.starts_with('/') {
697        return None;
698    }
699
700    let parent = importing_file.parent()?;
701    let base_path = parent.join(cleaned);
702
703    // Try common TypeScript extensions
704    let extensions = ["", ".ts", ".tsx", "/index.ts", "/index.tsx"];
705    for ext in extensions {
706        let candidate = if ext.is_empty() {
707            base_path.clone()
708        } else if let Some(stripped) = ext.strip_prefix('/') {
709            base_path.join(stripped)
710        } else {
711            PathBuf::from(format!("{}{}", base_path.display(), ext))
712        };
713
714        if candidate.exists() {
715            if let Ok(canonical) = candidate.canonicalize() {
716                return Some(normalize_path(&canonical));
717            }
718        }
719    }
720
721    // Return best-effort normalized path even if file doesn't exist
722    Some(normalize_path(&base_path))
723}
724
725fn collect_export_edges(
726    path: &Path,
727    source: &str,
728    root: &Node,
729    symbol_by_name: &HashMap<String, SymbolBinding>,
730    imports: &HashMap<String, ImportBinding>,
731) -> Vec<EdgeRecord> {
732    let mut edges = Vec::new();
733    let mut stack = vec![*root];
734
735    while let Some(node) = stack.pop() {
736        if node.kind() == "export_statement" {
737            let qualifier_override = node
738                .child_by_field_name("source")
739                .map(|s| slice(source, &s))
740                .map(|raw| import_qualifier(path, &raw));
741            let mut produced = false;
742            let mut export_stack = vec![node];
743            while let Some(n) = export_stack.pop() {
744                if n.kind() == "export_specifier" {
745                    let name_node = n.child_by_field_name("name").unwrap_or(n);
746                    let alias = n
747                        .child_by_field_name("alias")
748                        .map(|al| slice(source, &al))
749                        .unwrap_or_else(|| slice(source, &name_node));
750                    let resolved = resolve_name(
751                        &slice(source, &name_node),
752                        Some((name_node.start_byte(), name_node.end_byte())),
753                        path,
754                        symbol_by_name,
755                        imports,
756                        qualifier_override.clone(),
757                    );
758                    let target_id = resolved.id.clone();
759                    edges.push(EdgeRecord {
760                        src: export_edge_id(path, &name_node),
761                        dst: target_id.clone(),
762                        kind: "export".to_string(),
763                    });
764                    if alias != slice(source, &name_node) {
765                        edges.push(EdgeRecord {
766                            src: export_edge_id(path, &n),
767                            dst: target_id,
768                            kind: "export".to_string(),
769                        });
770                    }
771                    produced = true;
772                    continue;
773                }
774                let mut cursor = n.walk();
775                for child in n.children(&mut cursor) {
776                    export_stack.push(child);
777                }
778            }
779
780            if !produced {
781                if let Some(q) = qualifier_override {
782                    edges.push(EdgeRecord {
783                        src: export_edge_id(path, &node),
784                        dst: format!("{q}::*"),
785                        kind: "export".to_string(),
786                    });
787                }
788            }
789            continue;
790        }
791
792        let mut cursor = node.walk();
793        for child in node.children(&mut cursor) {
794            stack.push(child);
795        }
796    }
797
798    edges
799}
800
801fn find_child_kind<'a>(node: &'a Node<'a>, kind: &str) -> Option<Node<'a>> {
802    let mut stack = vec![*node];
803    while let Some(n) = stack.pop() {
804        if n.kind() == kind {
805            return Some(n);
806        }
807        let mut cursor = n.walk();
808        for child in n.children(&mut cursor) {
809            stack.push(child);
810        }
811    }
812    None
813}
814
815fn collect_references(
816    path: &Path,
817    source: &str,
818    root: &Node,
819    declared_spans: &HashSet<(usize, usize)>,
820    symbol_by_name: &HashMap<String, SymbolBinding>,
821    imports: &HashMap<String, ImportBinding>,
822) -> Vec<ReferenceRecord> {
823    let mut refs = Vec::new();
824    let mut stack = vec![*root];
825    let file = normalize_path(path);
826
827    while let Some(node) = stack.pop() {
828        if node.kind() == "identifier" {
829            let span = (node.start_byte(), node.end_byte());
830            if !declared_spans.contains(&span) {
831                let name = slice(source, &node);
832                // First try local symbols
833                if let Some(sym) = symbol_by_name.get(&name) {
834                    refs.push(ReferenceRecord {
835                        file: file.clone(),
836                        start: node.start_byte() as i64,
837                        end: node.end_byte() as i64,
838                        symbol_id: sym.id.clone(),
839                    });
840                } else if let Some(import) = imports.get(&name) {
841                    // Cross-file reference via import
842                    refs.push(ReferenceRecord {
843                        file: file.clone(),
844                        start: node.start_byte() as i64,
845                        end: node.end_byte() as i64,
846                        symbol_id: import.symbol_id(&name),
847                    });
848                }
849            }
850        }
851
852        let mut cursor = node.walk();
853        for child in node.children(&mut cursor) {
854            stack.push(child);
855        }
856    }
857
858    refs
859}
860
861fn make_symbol(
862    path: &Path,
863    node: &Node,
864    name: &str,
865    kind: &str,
866    container: Option<String>,
867    source: &[u8],
868) -> SymbolRecord {
869    let qualifier = Some(module_qualifier(path, &container));
870    let content_hash = super::compute_content_hash(source, node.start_byte(), node.end_byte());
871    SymbolRecord {
872        id: format!(
873            "{}#{}-{}",
874            normalize_path(path),
875            node.start_byte(),
876            node.end_byte()
877        ),
878        file: normalize_path(path),
879        kind: kind.to_string(),
880        name: name.to_string(),
881        start: node.start_byte() as i64,
882        end: node.end_byte() as i64,
883        qualifier,
884        visibility: None,
885        container,
886        content_hash,
887    }
888}
889
890fn module_qualifier(path: &Path, container: &Option<String>) -> String {
891    let mut base = normalize_path(path);
892    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
893        let trim = ext.len() + 1;
894        if base.len() > trim {
895            base.truncate(base.len() - trim);
896        }
897    }
898    if let Some(c) = container {
899        base.push_str("::");
900        base.push_str(c);
901    }
902    base
903}
904
905fn slice(source: &str, node: &Node) -> String {
906    let bytes = node.byte_range();
907    source
908        .get(bytes.clone())
909        .unwrap_or_default()
910        .trim()
911        .to_string()
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use std::fs;
918    use tempfile::tempdir;
919
920    #[test]
921    fn extracts_ts_symbols_and_edges() {
922        let dir = tempdir().unwrap();
923        let path = dir.path().join("foo.ts");
924        let source = r#"
925            interface Foo {
926                doThing(): void;
927            }
928            class Bar implements Foo {
929                doThing() {}
930            }
931        "#;
932        fs::write(&path, source).unwrap();
933
934        let (symbols, edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
935        let names: Vec<_> = symbols.iter().map(|s| s.name.as_str()).collect();
936        assert!(names.contains(&"Foo"));
937        assert!(names.contains(&"Bar"));
938        assert_eq!(symbols.len(), 4); // Foo, Foo.doThing, Bar, Bar.doThing
939
940        let foo = symbols.iter().find(|s| s.name == "Foo").unwrap();
941        assert!(foo.qualifier.as_deref().unwrap().contains("foo"));
942
943        assert!(
944            edges.iter().any(|e| e.kind == "implements"),
945            "expected implements edge, got {:?}",
946            edges
947        );
948        assert!(
949            edges.iter().any(|e| e.kind == "overrides"),
950            "expected method override edge, got {:?}",
951            edges
952        );
953    }
954
955    #[test]
956    fn links_extends_across_files_best_effort() {
957        let dir = tempdir().unwrap();
958        let base = dir.path();
959        let iface_path = base.join("base.ts");
960        let impl_path = base.join("impl.ts");
961
962        let iface_src = r#"
963            export interface Base {
964                run(): void;
965            }
966        "#;
967        let impl_src = r#"
968            import { Base } from "./base";
969            export class Child extends Base {
970                run() {}
971            }
972        "#;
973        fs::write(&iface_path, iface_src).unwrap();
974        fs::write(&impl_path, impl_src).unwrap();
975
976        let (iface_symbols, _, _, _, _) = index_file(&iface_path, iface_src).unwrap();
977        let (_, impl_edges, _, _, _) = index_file(&impl_path, impl_src).unwrap();
978
979        let _base = iface_symbols.iter().find(|s| s.name == "Base").unwrap();
980        assert!(
981            impl_edges.iter().any(|e| e.kind == "extends"),
982            "expected extends edge pointing to Base"
983        );
984        // We do not resolve cross-file edges yet; just assert we recorded an extends relationship.
985    }
986
987    #[test]
988    fn records_import_export_edges() {
989        let dir = tempdir().unwrap();
990        let path = dir.path().join("use.ts");
991        let source = r#"
992            import { Foo as Renamed } from "./defs";
993            export { Renamed as Visible };
994            export * from "./defs";
995        "#;
996        fs::write(&path, source).unwrap();
997
998        let (_symbols, edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
999        let import_edges: Vec<_> = edges
1000            .iter()
1001            .filter(|e| e.kind == "import")
1002            .map(|e| e.dst.clone())
1003            .collect();
1004        assert!(
1005            import_edges.iter().any(|d| d.ends_with("defs::Foo")),
1006            "expected import edge to defs::Foo, got {:?}",
1007            import_edges
1008        );
1009
1010        let export_edges: Vec<_> = edges.iter().filter(|e| e.kind == "export").collect();
1011        assert!(
1012            !export_edges.is_empty(),
1013            "expected export edges for re-exports"
1014        );
1015    }
1016
1017    #[test]
1018    fn extracts_types_enums_and_consts() {
1019        let dir = tempdir().unwrap();
1020        let path = dir.path().join("test.ts");
1021
1022        let source = r#"
1023// Type aliases
1024type Status = "active" | "inactive";
1025type Person = { name: string; age: number };
1026
1027// Enums
1028enum Color {
1029    Red,
1030    Green,
1031    Blue
1032}
1033
1034// Const declarations
1035const MAX_SIZE = 100;
1036const config = { debug: true };
1037export const API_URL = "https://example.com";
1038
1039// Arrow functions (should be detected as functions)
1040const add = (a: number, b: number) => a + b;
1041const greet = (name: string) => `Hello ${name}`;
1042
1043// Regular let/var
1044let count = 0;
1045
1046// Classes and interfaces
1047interface User {
1048    id: number;
1049    name: string;
1050}
1051
1052class UserService {
1053    getUser(id: number): User {
1054        return { id, name: "test" };
1055    }
1056}
1057"#;
1058        fs::write(&path, source).unwrap();
1059
1060        let (symbols, _edges, _refs, _deps, _imports) = index_file(&path, source).unwrap();
1061
1062        // Type aliases
1063        assert!(
1064            symbols
1065                .iter()
1066                .any(|s| s.name == "Status" && s.kind == "type"),
1067            "expected type alias Status"
1068        );
1069        assert!(
1070            symbols
1071                .iter()
1072                .any(|s| s.name == "Person" && s.kind == "type"),
1073            "expected type alias Person"
1074        );
1075
1076        // Enums
1077        assert!(
1078            symbols
1079                .iter()
1080                .any(|s| s.name == "Color" && s.kind == "enum"),
1081            "expected enum Color"
1082        );
1083        assert!(
1084            symbols
1085                .iter()
1086                .any(|s| s.name == "Red" && s.kind == "enum_member"),
1087            "expected enum member Red"
1088        );
1089
1090        // Const declarations
1091        assert!(
1092            symbols
1093                .iter()
1094                .any(|s| s.name == "MAX_SIZE" && s.kind == "const"),
1095            "expected const MAX_SIZE"
1096        );
1097        assert!(
1098            symbols
1099                .iter()
1100                .any(|s| s.name == "API_URL" && s.kind == "const"),
1101            "expected const API_URL"
1102        );
1103
1104        // Arrow functions as functions
1105        assert!(
1106            symbols
1107                .iter()
1108                .any(|s| s.name == "add" && s.kind == "function"),
1109            "expected arrow function add as function"
1110        );
1111
1112        // Let declarations
1113        assert!(
1114            symbols
1115                .iter()
1116                .any(|s| s.name == "count" && s.kind == "variable"),
1117            "expected variable count"
1118        );
1119
1120        // Existing types still work
1121        assert!(
1122            symbols
1123                .iter()
1124                .any(|s| s.name == "User" && s.kind == "interface"),
1125            "expected interface User"
1126        );
1127        assert!(
1128            symbols
1129                .iter()
1130                .any(|s| s.name == "UserService" && s.kind == "class"),
1131            "expected class UserService"
1132        );
1133    }
1134}