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#[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 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 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 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 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 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 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 }
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
691fn resolve_import_path(importing_file: &Path, specifier: &str) -> Option<String> {
693 let cleaned = specifier.trim().trim_matches('"').trim_matches('\'');
694
695 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 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 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 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 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); 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 }
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 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 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 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 assert!(
1106 symbols
1107 .iter()
1108 .any(|s| s.name == "add" && s.kind == "function"),
1109 "expected arrow function add as function"
1110 );
1111
1112 assert!(
1114 symbols
1115 .iter()
1116 .any(|s| s.name == "count" && s.kind == "variable"),
1117 "expected variable count"
1118 );
1119
1120 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}