1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_json::Value;
16use tree_sitter::{Node, Parser};
17
18use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
19use crate::edit::line_col_to_byte;
20use crate::error::AftError;
21use crate::imports::{self, ImportBlock};
22use crate::language::LanguageProvider;
23use crate::parser::{detect_language, grammar_for, LangId};
24use crate::symbols::{Range, SymbolKind};
25
26type SharedPath = Arc<PathBuf>;
31type SharedStr = Arc<str>;
32type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
33type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36 LazyLock::new(|| RwLock::new(HashMap::new()));
37
38const TOP_LEVEL_SYMBOL: &str = "<top-level>";
39
40#[derive(Debug, Clone)]
42pub struct CallSite {
43 pub callee_name: String,
45 pub full_callee: String,
47 pub line: u32,
49 pub byte_start: usize,
51 pub byte_end: usize,
52}
53
54#[derive(Debug, Clone, Serialize)]
56pub struct SymbolMeta {
57 pub kind: SymbolKind,
59 pub exported: bool,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub signature: Option<String>,
64 pub line: u32,
66 pub range: Range,
68}
69
70#[derive(Debug, Clone)]
73pub struct FileCallData {
74 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
76 pub exported_symbols: Vec<String>,
78 pub symbol_metadata: HashMap<String, SymbolMeta>,
80 pub default_export_symbol: Option<String>,
82 pub import_block: ImportBlock,
84 pub lang: LangId,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum EdgeResolution {
91 Resolved { file: PathBuf, symbol: String },
93 Unresolved { callee_name: String },
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98struct ResolvedSymbol {
99 file: PathBuf,
100 symbol: String,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct CallerSite {
106 pub caller_file: PathBuf,
108 pub caller_symbol: String,
110 pub line: u32,
112 pub col: u32,
114 pub resolved: bool,
116}
117
118#[derive(Debug, Clone)]
119struct IndexedCallerSite {
120 caller_file: SharedPath,
121 caller_symbol: SharedStr,
122 line: u32,
123 col: u32,
124 resolved: bool,
125}
126
127#[derive(Debug, Clone, Serialize)]
129pub struct CallerGroup {
130 pub file: String,
132 pub callers: Vec<CallerEntry>,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct CallerEntry {
139 pub symbol: String,
140 pub line: u32,
142}
143
144#[derive(Debug, Clone, Serialize)]
146pub struct CallersResult {
147 pub symbol: String,
149 pub file: String,
151 pub callers: Vec<CallerGroup>,
153 pub total_callers: usize,
155 pub scanned_files: usize,
157}
158
159#[derive(Debug, Clone, Serialize)]
161pub struct CallTreeNode {
162 pub name: String,
164 pub file: String,
166 pub line: u32,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub signature: Option<String>,
171 pub resolved: bool,
173 pub children: Vec<CallTreeNode>,
175}
176
177const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
183
184pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
191 if exported && *kind == SymbolKind::Function {
193 return true;
194 }
195
196 let lower = name.to_lowercase();
198 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
199 return true;
200 }
201
202 match lang {
204 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
205 matches!(lower.as_str(), "describe" | "it" | "test")
207 || lower.starts_with("test")
208 || lower.starts_with("spec")
209 }
210 LangId::Python => {
211 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
213 }
214 LangId::Rust => {
215 lower.starts_with("test_")
217 }
218 LangId::Go => {
219 name.starts_with("Test")
221 }
222 LangId::C
223 | LangId::Cpp
224 | LangId::Zig
225 | LangId::CSharp
226 | LangId::Bash
227 | LangId::Solidity
228 | LangId::Vue
229 | LangId::Json
230 | LangId::Scala
231 | LangId::Java
232 | LangId::Ruby
233 | LangId::Kotlin
234 | LangId::Swift
235 | LangId::Php
236 | LangId::Lua
237 | LangId::Perl
238 | LangId::Html
239 | LangId::Markdown => false,
240 }
241}
242
243#[derive(Debug, Clone, Serialize)]
249pub struct TraceHop {
250 pub symbol: String,
252 pub file: String,
254 pub line: u32,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub signature: Option<String>,
259 pub is_entry_point: bool,
261}
262
263#[derive(Debug, Clone, Serialize)]
265pub struct TracePath {
266 pub hops: Vec<TraceHop>,
268}
269
270#[derive(Debug, Clone, Serialize)]
272pub struct TraceToResult {
273 pub target_symbol: String,
275 pub target_file: String,
277 pub paths: Vec<TracePath>,
279 pub total_paths: usize,
281 pub entry_points_found: usize,
283 pub max_depth_reached: bool,
285 pub truncated_paths: usize,
287}
288
289#[derive(Debug, Clone, Serialize)]
295pub struct ImpactCaller {
296 pub caller_symbol: String,
298 pub caller_file: String,
300 pub line: u32,
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub signature: Option<String>,
305 pub is_entry_point: bool,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub call_expression: Option<String>,
310 pub parameters: Vec<String>,
312}
313
314#[derive(Debug, Clone, Serialize)]
316pub struct ImpactResult {
317 pub symbol: String,
319 pub file: String,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub signature: Option<String>,
324 pub parameters: Vec<String>,
326 pub total_affected: usize,
328 pub affected_files: usize,
330 pub callers: Vec<ImpactCaller>,
332}
333
334#[derive(Debug, Clone, Serialize)]
340pub struct DataFlowHop {
341 pub file: String,
343 pub symbol: String,
345 pub variable: String,
347 pub line: u32,
349 pub flow_type: String,
351 pub approximate: bool,
353}
354
355#[derive(Debug, Clone, Serialize)]
358pub struct TraceDataResult {
359 pub expression: String,
361 pub origin_file: String,
363 pub origin_symbol: String,
365 pub hops: Vec<DataFlowHop>,
367 pub depth_limited: bool,
369}
370
371pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
377 let start = match signature.find('(') {
379 Some(i) => i + 1,
380 None => return Vec::new(),
381 };
382 let end = match signature[start..].find(')') {
383 Some(i) => start + i,
384 None => return Vec::new(),
385 };
386
387 let params_str = &signature[start..end].trim();
388 if params_str.is_empty() {
389 return Vec::new();
390 }
391
392 let parts = split_params(params_str);
394
395 let mut result = Vec::new();
396 for part in parts {
397 let trimmed = part.trim();
398 if trimmed.is_empty() {
399 continue;
400 }
401
402 match lang {
404 LangId::Rust => {
405 if trimmed == "self"
406 || trimmed == "mut self"
407 || trimmed.starts_with("&self")
408 || trimmed.starts_with("&mut self")
409 {
410 continue;
411 }
412 }
413 LangId::Python => {
414 if trimmed == "self" || trimmed.starts_with("self:") {
415 continue;
416 }
417 }
418 _ => {}
419 }
420
421 let name = extract_param_name(trimmed, lang);
423 if !name.is_empty() {
424 result.push(name);
425 }
426 }
427
428 result
429}
430
431fn split_params(s: &str) -> Vec<String> {
433 let mut parts = Vec::new();
434 let mut current = String::new();
435 let mut depth = 0i32;
436
437 for ch in s.chars() {
438 match ch {
439 '<' | '[' | '{' | '(' => {
440 depth += 1;
441 current.push(ch);
442 }
443 '>' | ']' | '}' | ')' => {
444 depth -= 1;
445 current.push(ch);
446 }
447 ',' if depth == 0 => {
448 parts.push(current.clone());
449 current.clear();
450 }
451 _ => {
452 current.push(ch);
453 }
454 }
455 }
456 if !current.is_empty() {
457 parts.push(current);
458 }
459 parts
460}
461
462fn extract_param_name(param: &str, lang: LangId) -> String {
470 let trimmed = param.trim();
471
472 let working = if trimmed.starts_with("...") {
474 &trimmed[3..]
475 } else if trimmed.starts_with("**") {
476 &trimmed[2..]
477 } else if trimmed.starts_with('*') && lang == LangId::Python {
478 &trimmed[1..]
479 } else {
480 trimmed
481 };
482
483 let working = if lang == LangId::Rust && working.starts_with("mut ") {
485 &working[4..]
486 } else {
487 working
488 };
489
490 let name = working
493 .split(|c: char| c == ':' || c == '=')
494 .next()
495 .unwrap_or("")
496 .trim();
497
498 let name = name.trim_end_matches('?');
500
501 if lang == LangId::Go && !name.contains(' ') {
503 return name.to_string();
504 }
505 if lang == LangId::Go {
506 return name.split_whitespace().next().unwrap_or("").to_string();
507 }
508
509 name.to_string()
510}
511
512pub struct CallGraph {
521 data: HashMap<PathBuf, FileCallData>,
523 project_root: PathBuf,
525 project_files: Option<Vec<PathBuf>>,
527 reverse_index: Option<ReverseIndex>,
530}
531
532impl CallGraph {
533 pub fn new(project_root: PathBuf) -> Self {
535 clear_workspace_package_cache();
536 Self {
537 data: HashMap::new(),
538 project_root,
539 project_files: None,
540 reverse_index: None,
541 }
542 }
543
544 pub fn project_root(&self) -> &Path {
546 &self.project_root
547 }
548
549 fn resolve_cross_file_edge_with_exports<F, D>(
550 full_callee: &str,
551 short_name: &str,
552 caller_file: &Path,
553 import_block: &ImportBlock,
554 mut file_exports_symbol: F,
555 mut file_default_export_symbol: D,
556 ) -> EdgeResolution
557 where
558 F: FnMut(&Path, &str) -> bool,
559 D: FnMut(&Path) -> Option<String>,
560 {
561 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
562
563 if full_callee.contains('.') {
565 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
566 if parts.len() == 2 {
567 let namespace = parts[0];
568 let member = parts[1];
569
570 for imp in &import_block.imports {
571 if imp.namespace_import.as_deref() == Some(namespace) {
572 if let Some(resolved_path) =
573 resolve_module_path(caller_dir, &imp.module_path)
574 {
575 return EdgeResolution::Resolved {
576 file: resolved_path,
577 symbol: member.to_owned(),
578 };
579 }
580 }
581 }
582 }
583 }
584
585 for imp in &import_block.imports {
587 if imp.names.iter().any(|name| name == short_name) {
589 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
590 let target = resolve_reexported_symbol(
591 &resolved_path,
592 short_name,
593 &mut file_exports_symbol,
594 &mut file_default_export_symbol,
595 )
596 .unwrap_or(ResolvedSymbol {
597 file: resolved_path,
598 symbol: short_name.to_owned(),
599 });
600 return EdgeResolution::Resolved {
601 file: target.file,
602 symbol: target.symbol,
603 };
604 }
605 }
606
607 if imp.default_import.as_deref() == Some(short_name) {
609 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
610 let target = resolve_reexported_symbol(
611 &resolved_path,
612 "default",
613 &mut file_exports_symbol,
614 &mut file_default_export_symbol,
615 )
616 .unwrap_or_else(|| ResolvedSymbol {
617 symbol: file_default_export_symbol(&resolved_path)
618 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
619 file: resolved_path,
620 });
621 return EdgeResolution::Resolved {
622 file: target.file,
623 symbol: target.symbol,
624 };
625 }
626 }
627 }
628
629 if let Some((original_name, resolved_path)) =
634 resolve_aliased_import(short_name, import_block, caller_dir)
635 {
636 let target = resolve_reexported_symbol(
637 &resolved_path,
638 &original_name,
639 &mut file_exports_symbol,
640 &mut file_default_export_symbol,
641 )
642 .unwrap_or(ResolvedSymbol {
643 file: resolved_path,
644 symbol: original_name,
645 });
646 return EdgeResolution::Resolved {
647 file: target.file,
648 symbol: target.symbol,
649 };
650 }
651
652 for imp in &import_block.imports {
655 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
656 if resolved_path.is_dir() {
658 if let Some(index_path) = find_index_file(&resolved_path) {
659 if file_exports_symbol(&index_path, short_name) {
661 return EdgeResolution::Resolved {
662 file: index_path,
663 symbol: short_name.to_owned(),
664 };
665 }
666 }
667 } else if file_exports_symbol(&resolved_path, short_name) {
668 return EdgeResolution::Resolved {
669 file: resolved_path,
670 symbol: short_name.to_owned(),
671 };
672 }
673 }
674 }
675
676 EdgeResolution::Unresolved {
677 callee_name: short_name.to_owned(),
678 }
679 }
680
681 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
683 let canon = self.canonicalize(path)?;
684
685 if !self.data.contains_key(&canon) {
686 let file_data = build_file_data(&canon)?;
687 self.data.insert(canon.clone(), file_data);
688 }
689
690 Ok(&self.data[&canon])
691 }
692
693 pub fn resolve_cross_file_edge(
698 &mut self,
699 full_callee: &str,
700 short_name: &str,
701 caller_file: &Path,
702 import_block: &ImportBlock,
703 ) -> EdgeResolution {
704 let graph = RefCell::new(self);
705 Self::resolve_cross_file_edge_with_exports(
706 full_callee,
707 short_name,
708 caller_file,
709 import_block,
710 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
711 |path| graph.borrow_mut().file_default_export_symbol(path),
712 )
713 }
714
715 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
717 match self.build_file(path) {
718 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
719 Err(_) => false,
720 }
721 }
722
723 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
724 self.build_file(path)
725 .ok()
726 .and_then(|data| data.default_export_symbol.clone())
727 }
728
729 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
730 self.lookup_file_data(path)
731 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
732 .unwrap_or(false)
733 }
734
735 fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
736 self.lookup_file_data(path)
737 .and_then(|data| data.default_export_symbol.clone())
738 }
739
740 pub fn forward_tree(
745 &mut self,
746 file: &Path,
747 symbol: &str,
748 max_depth: usize,
749 ) -> Result<CallTreeNode, AftError> {
750 let mut visited = HashSet::new();
751 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
752 }
753
754 fn forward_tree_inner(
755 &mut self,
756 file: &Path,
757 symbol: &str,
758 max_depth: usize,
759 current_depth: usize,
760 visited: &mut HashSet<(PathBuf, String)>,
761 ) -> Result<CallTreeNode, AftError> {
762 let canon = self.canonicalize(file)?;
763 let visit_key = (canon.clone(), symbol.to_string());
764
765 if visited.contains(&visit_key) {
767 let (line, signature) = self
768 .lookup_file_data(&canon)
769 .map(|data| get_symbol_meta_from_data(data, symbol))
770 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
771 return Ok(CallTreeNode {
772 name: symbol.to_string(),
773 file: self.relative_path(&canon),
774 line,
775 signature,
776 resolved: true,
777 children: vec![], });
779 }
780
781 visited.insert(visit_key.clone());
782
783 let (import_block, call_sites, sym_line, sym_signature) = {
784 let file_data = self.build_file(&canon)?;
785 let meta = get_symbol_meta_from_data(file_data, symbol);
786
787 (
788 file_data.import_block.clone(),
789 file_data
790 .calls_by_symbol
791 .get(symbol)
792 .cloned()
793 .unwrap_or_default(),
794 meta.0,
795 meta.1,
796 )
797 };
798
799 let mut children = Vec::new();
801
802 if current_depth < max_depth {
803 for call_site in &call_sites {
804 let edge = self.resolve_cross_file_edge(
805 &call_site.full_callee,
806 &call_site.callee_name,
807 &canon,
808 &import_block,
809 );
810
811 match edge {
812 EdgeResolution::Resolved {
813 file: ref target_file,
814 ref symbol,
815 } => {
816 match self.forward_tree_inner(
817 target_file,
818 symbol,
819 max_depth,
820 current_depth + 1,
821 visited,
822 ) {
823 Ok(child) => children.push(child),
824 Err(_) => {
825 children.push(CallTreeNode {
827 name: call_site.callee_name.clone(),
828 file: self.relative_path(target_file),
829 line: call_site.line,
830 signature: None,
831 resolved: false,
832 children: vec![],
833 });
834 }
835 }
836 }
837 EdgeResolution::Unresolved { callee_name } => {
838 if let Some(local_child) = self.resolve_local_call_tree_child(
839 &canon,
840 symbol,
841 call_site,
842 &callee_name,
843 max_depth,
844 current_depth,
845 visited,
846 )? {
847 children.push(local_child);
848 continue;
849 }
850 children.push(CallTreeNode {
851 name: callee_name,
852 file: self.relative_path(&canon),
853 line: call_site.line,
854 signature: None,
855 resolved: false,
856 children: vec![],
857 });
858 }
859 }
860 }
861 }
862
863 visited.remove(&visit_key);
864
865 Ok(CallTreeNode {
866 name: symbol.to_string(),
867 file: self.relative_path(&canon),
868 line: sym_line,
869 signature: sym_signature,
870 resolved: true,
871 children,
872 })
873 }
874
875 fn resolve_local_call_tree_child(
876 &mut self,
877 canon: &Path,
878 current_symbol: &str,
879 call_site: &CallSite,
880 callee_name: &str,
881 max_depth: usize,
882 current_depth: usize,
883 visited: &mut HashSet<(PathBuf, String)>,
884 ) -> Result<Option<CallTreeNode>, AftError> {
885 let has_local_symbol = self
886 .lookup_file_data(canon)
887 .map(|data| data.symbol_metadata.contains_key(callee_name))
888 .unwrap_or(false);
889 if !has_local_symbol {
890 return Ok(None);
891 }
892 if callee_name == current_symbol {
893 return Ok(None);
894 }
895
896 match self.forward_tree_inner(canon, callee_name, max_depth, current_depth + 1, visited) {
897 Ok(child) => Ok(Some(child)),
898 Err(_) => Ok(Some(CallTreeNode {
899 name: callee_name.to_string(),
900 file: self.relative_path(canon),
901 line: call_site.line,
902 signature: None,
903 resolved: false,
904 children: vec![],
905 })),
906 }
907 }
908
909 pub fn project_files(&mut self) -> &[PathBuf] {
911 if self.project_files.is_none() {
912 let project_root = self.project_root.clone();
913 self.project_files = Some(walk_project_files(&project_root).collect());
914 }
915 self.project_files.as_deref().unwrap_or(&[])
916 }
917
918 pub fn project_file_count(&mut self) -> usize {
924 self.project_files().len()
925 }
926
927 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
938 if let Some(files) = self.project_files.as_deref() {
939 return files.len();
940 }
941 walk_project_files(&self.project_root)
942 .take(limit.saturating_add(1))
943 .count()
944 }
945
946 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
952 let count = self.project_file_count_bounded(max_files);
957 if count > max_files {
958 return Err(AftError::ProjectTooLarge {
959 count,
960 max: max_files,
961 });
962 }
963
964 let all_files = self.project_files().to_vec();
968
969 let uncached_files: Vec<PathBuf> = all_files
971 .iter()
972 .filter(|f| self.lookup_file_data(f).is_none())
973 .cloned()
974 .collect();
975
976 let computed: Vec<(PathBuf, FileCallData)> = uncached_files
977 .par_iter()
978 .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
979 .collect();
980
981 for (file, data) in computed {
982 self.data.insert(file, data);
983 }
984
985 let mut reverse: ReverseIndex = HashMap::new();
987
988 for caller_file in &all_files {
989 let canon_caller = Arc::new(
991 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
992 );
993 let file_data = match self
994 .data
995 .get(caller_file)
996 .or_else(|| self.data.get(canon_caller.as_ref()))
997 {
998 Some(d) => d,
999 None => continue,
1000 };
1001
1002 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
1003 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
1004
1005 for call_site in call_sites {
1006 let edge = Self::resolve_cross_file_edge_with_exports(
1007 &call_site.full_callee,
1008 &call_site.callee_name,
1009 canon_caller.as_ref(),
1010 &file_data.import_block,
1011 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1012 |path| self.file_default_export_symbol_cached(path),
1013 );
1014
1015 let (target_file, target_symbol, resolved) = match edge {
1016 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1017 EdgeResolution::Unresolved { callee_name } => {
1018 (canon_caller.as_ref().clone(), callee_name, false)
1019 }
1020 };
1021
1022 if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1023 continue;
1024 }
1025
1026 reverse
1027 .entry(target_file)
1028 .or_default()
1029 .entry(target_symbol)
1030 .or_default()
1031 .push(IndexedCallerSite {
1032 caller_file: Arc::clone(&canon_caller),
1033 caller_symbol: Arc::clone(&caller_symbol),
1034 line: call_site.line,
1035 col: 0,
1036 resolved,
1037 });
1038 }
1039 }
1040 }
1041
1042 self.reverse_index = Some(reverse);
1043 Ok(())
1044 }
1045
1046 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1047 self.reverse_index
1048 .as_ref()?
1049 .get(file)?
1050 .get(symbol)
1051 .map(Vec::as_slice)
1052 }
1053
1054 pub fn callers_of(
1060 &mut self,
1061 file: &Path,
1062 symbol: &str,
1063 depth: usize,
1064 max_files: usize,
1065 ) -> Result<CallersResult, AftError> {
1066 let canon = self.canonicalize(file)?;
1067
1068 self.build_file(&canon)?;
1070
1071 if self.reverse_index.is_none() {
1073 self.build_reverse_index(max_files)?;
1074 }
1075
1076 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1077 let effective_depth = if depth == 0 { 1 } else { depth };
1078
1079 let mut visited = HashSet::new();
1080 let mut all_sites: Vec<CallerSite> = Vec::new();
1081 self.collect_callers_recursive(
1082 &canon,
1083 symbol,
1084 effective_depth,
1085 0,
1086 &mut visited,
1087 &mut all_sites,
1088 );
1089
1090 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1093 let total_callers = all_sites.len();
1094 for site in all_sites {
1095 let caller_file: PathBuf = site.caller_file;
1096 let caller_symbol: String = site.caller_symbol;
1097 let line = site.line;
1098 let entry = CallerEntry {
1099 symbol: caller_symbol,
1100 line,
1101 };
1102
1103 if let Some(entries) = groups_map.get_mut(&caller_file) {
1104 entries.push(entry);
1105 } else {
1106 groups_map.insert(caller_file, vec![entry]);
1107 }
1108 }
1109
1110 let mut callers: Vec<CallerGroup> = groups_map
1111 .into_iter()
1112 .map(|(file_path, entries)| CallerGroup {
1113 file: self.relative_path(&file_path),
1114 callers: entries,
1115 })
1116 .collect();
1117
1118 callers.sort_by(|a, b| a.file.cmp(&b.file));
1120
1121 Ok(CallersResult {
1122 symbol: symbol.to_string(),
1123 file: self.relative_path(&canon),
1124 callers,
1125 total_callers,
1126 scanned_files,
1127 })
1128 }
1129
1130 pub fn trace_to(
1136 &mut self,
1137 file: &Path,
1138 symbol: &str,
1139 max_depth: usize,
1140 max_files: usize,
1141 ) -> Result<TraceToResult, AftError> {
1142 let canon = self.canonicalize(file)?;
1143
1144 self.build_file(&canon)?;
1146
1147 if self.reverse_index.is_none() {
1149 self.build_reverse_index(max_files)?;
1150 }
1151
1152 let target_rel = self.relative_path(&canon);
1153 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1154 if self.reverse_index.is_none() {
1155 return Err(AftError::ParseError {
1156 message: format!(
1157 "reverse index unavailable after building callers for {}",
1158 canon.display()
1159 ),
1160 });
1161 }
1162
1163 let (target_line, target_sig) = self
1165 .lookup_file_data(&canon)
1166 .map(|data| get_symbol_meta_from_data(data, symbol))
1167 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1168
1169 let target_is_entry = self
1171 .lookup_file_data(&canon)
1172 .and_then(|fd| {
1173 let meta = fd.symbol_metadata.get(symbol)?;
1174 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1175 })
1176 .unwrap_or(false);
1177
1178 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1181 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1182 let mut max_depth_reached = false;
1183 let mut truncated_paths: usize = 0;
1184
1185 let initial: Vec<PathElem> = vec![(
1187 Arc::new(canon.clone()),
1188 Arc::from(symbol),
1189 target_line,
1190 target_sig,
1191 )];
1192
1193 if target_is_entry {
1195 complete_paths.push(initial.clone());
1196 }
1197
1198 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1200
1201 while let Some((path, depth)) = queue.pop() {
1202 if depth >= effective_max {
1203 max_depth_reached = true;
1204 continue;
1205 }
1206
1207 let Some((current_file, current_symbol, _, _)) = path.last() else {
1208 continue;
1209 };
1210
1211 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1213 Some(sites) => sites,
1214 None => {
1215 if path.len() > 1 {
1218 truncated_paths += 1;
1221 }
1222 continue;
1223 }
1224 };
1225
1226 let mut has_new_path = false;
1227 for site in callers {
1228 if path.iter().any(|(file_path, sym, _, _)| {
1230 file_path.as_ref() == site.caller_file.as_ref()
1231 && sym.as_ref() == site.caller_symbol.as_ref()
1232 }) {
1233 continue;
1234 }
1235
1236 has_new_path = true;
1237
1238 let (caller_line, caller_sig) = self
1240 .lookup_file_data(site.caller_file.as_ref())
1241 .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1242 .unwrap_or_else(|| {
1243 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1244 });
1245
1246 let mut new_path = path.clone();
1247 new_path.push((
1248 Arc::clone(&site.caller_file),
1249 Arc::clone(&site.caller_symbol),
1250 caller_line,
1251 caller_sig,
1252 ));
1253
1254 let caller_is_entry = self
1258 .lookup_file_data(site.caller_file.as_ref())
1259 .and_then(|fd| {
1260 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1261 Some(is_entry_point(
1262 site.caller_symbol.as_ref(),
1263 &meta.kind,
1264 meta.exported,
1265 fd.lang,
1266 ))
1267 })
1268 .unwrap_or(false);
1269
1270 if caller_is_entry {
1271 complete_paths.push(new_path.clone());
1272 }
1273 queue.push((new_path, depth + 1));
1276 }
1277
1278 if !has_new_path && path.len() > 1 {
1280 truncated_paths += 1;
1281 }
1282 }
1283
1284 let mut paths: Vec<TracePath> = complete_paths
1287 .into_iter()
1288 .map(|mut elems| {
1289 elems.reverse();
1290 let hops: Vec<TraceHop> = elems
1291 .iter()
1292 .enumerate()
1293 .map(|(i, (file_path, sym, line, sig))| {
1294 let is_ep = if i == 0 {
1295 self.lookup_file_data(file_path.as_ref())
1297 .and_then(|fd| {
1298 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1299 Some(is_entry_point(
1300 sym.as_ref(),
1301 &meta.kind,
1302 meta.exported,
1303 fd.lang,
1304 ))
1305 })
1306 .unwrap_or(false)
1307 } else {
1308 false
1309 };
1310 TraceHop {
1311 symbol: sym.to_string(),
1312 file: self.relative_path(file_path.as_ref()),
1313 line: *line,
1314 signature: sig.clone(),
1315 is_entry_point: is_ep,
1316 }
1317 })
1318 .collect();
1319 TracePath { hops }
1320 })
1321 .collect();
1322
1323 paths.sort_by(|a, b| {
1325 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1326 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1327 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1328 });
1329
1330 let mut entry_point_names: HashSet<String> = HashSet::new();
1332 for p in &paths {
1333 if let Some(first) = p.hops.first() {
1334 if first.is_entry_point {
1335 entry_point_names.insert(first.symbol.clone());
1336 }
1337 }
1338 }
1339
1340 let total_paths = paths.len();
1341 let entry_points_found = entry_point_names.len();
1342
1343 Ok(TraceToResult {
1344 target_symbol: symbol.to_string(),
1345 target_file: target_rel,
1346 paths,
1347 total_paths,
1348 entry_points_found,
1349 max_depth_reached,
1350 truncated_paths,
1351 })
1352 }
1353
1354 pub fn impact(
1360 &mut self,
1361 file: &Path,
1362 symbol: &str,
1363 depth: usize,
1364 max_files: usize,
1365 ) -> Result<ImpactResult, AftError> {
1366 let canon = self.canonicalize(file)?;
1367
1368 self.build_file(&canon)?;
1370
1371 if self.reverse_index.is_none() {
1373 self.build_reverse_index(max_files)?;
1374 }
1375
1376 let effective_depth = if depth == 0 { 1 } else { depth };
1377
1378 let (target_signature, target_parameters, target_lang) = {
1380 let file_data = match self.data.get(&canon) {
1381 Some(d) => d,
1382 None => {
1383 return Err(AftError::InvalidRequest {
1384 message: "file data missing after build".to_string(),
1385 })
1386 }
1387 };
1388 let meta = file_data.symbol_metadata.get(symbol);
1389 let sig = meta.and_then(|m| m.signature.clone());
1390 let lang = file_data.lang;
1391 let params = sig
1392 .as_deref()
1393 .map(|s| extract_parameters(s, lang))
1394 .unwrap_or_default();
1395 (sig, params, lang)
1396 };
1397
1398 let mut visited = HashSet::new();
1400 let mut all_sites: Vec<CallerSite> = Vec::new();
1401 self.collect_callers_recursive(
1402 &canon,
1403 symbol,
1404 effective_depth,
1405 0,
1406 &mut visited,
1407 &mut all_sites,
1408 );
1409
1410 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1412 all_sites.retain(|site| {
1413 seen.insert((
1414 site.caller_file.clone(),
1415 site.caller_symbol.clone(),
1416 site.line,
1417 ))
1418 });
1419
1420 let mut callers = Vec::new();
1422 let mut affected_file_set = HashSet::new();
1423
1424 for site in &all_sites {
1425 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1427 log::debug!(
1428 "callgraph: skipping caller file {}: {}",
1429 site.caller_file.display(),
1430 e
1431 );
1432 }
1433
1434 let (sig, is_ep, params, _lang) = {
1435 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1436 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1437 let sig = meta.and_then(|m| m.signature.clone());
1438 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1439 let exported = meta.map(|m| m.exported).unwrap_or(false);
1440 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1441 let lang = fd.lang;
1442 let params = sig
1443 .as_deref()
1444 .map(|s| extract_parameters(s, lang))
1445 .unwrap_or_default();
1446 (sig, is_ep, params, lang)
1447 } else {
1448 (None, false, Vec::new(), target_lang)
1449 }
1450 };
1451
1452 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1454
1455 let rel_file = self.relative_path(site.caller_file.as_path());
1456 affected_file_set.insert(rel_file.clone());
1457
1458 callers.push(ImpactCaller {
1459 caller_symbol: site.caller_symbol.clone(),
1460 caller_file: rel_file,
1461 line: site.line,
1462 signature: sig,
1463 is_entry_point: is_ep,
1464 call_expression,
1465 parameters: params,
1466 });
1467 }
1468
1469 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1471
1472 let total_affected = callers.len();
1473 let affected_files = affected_file_set.len();
1474
1475 Ok(ImpactResult {
1476 symbol: symbol.to_string(),
1477 file: self.relative_path(&canon),
1478 signature: target_signature,
1479 parameters: target_parameters,
1480 total_affected,
1481 affected_files,
1482 callers,
1483 })
1484 }
1485
1486 pub fn trace_data(
1497 &mut self,
1498 file: &Path,
1499 symbol: &str,
1500 expression: &str,
1501 max_depth: usize,
1502 max_files: usize,
1503 ) -> Result<TraceDataResult, AftError> {
1504 let canon = self.canonicalize(file)?;
1505 let rel_file = self.relative_path(&canon);
1506
1507 self.build_file(&canon)?;
1509
1510 {
1512 let fd = match self.data.get(&canon) {
1513 Some(d) => d,
1514 None => {
1515 return Err(AftError::InvalidRequest {
1516 message: "file data missing after build".to_string(),
1517 })
1518 }
1519 };
1520 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1521 || fd.exported_symbols.iter().any(|name| name == symbol)
1522 || fd.symbol_metadata.contains_key(symbol);
1523 if !has_symbol {
1524 return Err(AftError::InvalidRequest {
1525 message: format!(
1526 "trace_data: symbol '{}' not found in {}",
1527 symbol,
1528 file.display()
1529 ),
1530 });
1531 }
1532 }
1533
1534 let count = self.project_file_count_bounded(max_files);
1538 if count > max_files {
1539 return Err(AftError::ProjectTooLarge {
1540 count,
1541 max: max_files,
1542 });
1543 }
1544
1545 let mut hops = Vec::new();
1546 let mut depth_limited = false;
1547
1548 self.trace_data_inner(
1549 &canon,
1550 symbol,
1551 expression,
1552 max_depth,
1553 0,
1554 &mut hops,
1555 &mut depth_limited,
1556 &mut HashSet::new(),
1557 );
1558
1559 Ok(TraceDataResult {
1560 expression: expression.to_string(),
1561 origin_file: rel_file,
1562 origin_symbol: symbol.to_string(),
1563 hops,
1564 depth_limited,
1565 })
1566 }
1567
1568 fn trace_data_inner(
1570 &mut self,
1571 file: &Path,
1572 symbol: &str,
1573 tracking_name: &str,
1574 max_depth: usize,
1575 current_depth: usize,
1576 hops: &mut Vec<DataFlowHop>,
1577 depth_limited: &mut bool,
1578 visited: &mut HashSet<(PathBuf, String, String)>,
1579 ) {
1580 let visit_key = (
1581 file.to_path_buf(),
1582 symbol.to_string(),
1583 tracking_name.to_string(),
1584 );
1585 if visited.contains(&visit_key) {
1586 return; }
1588 visited.insert(visit_key);
1589
1590 let source = match std::fs::read_to_string(file) {
1592 Ok(s) => s,
1593 Err(_) => return,
1594 };
1595
1596 let lang = match detect_language(file) {
1597 Some(l) => l,
1598 None => return,
1599 };
1600
1601 let grammar = grammar_for(lang);
1602 let mut parser = Parser::new();
1603 if parser.set_language(&grammar).is_err() {
1604 return;
1605 }
1606 let tree = match parser.parse(&source, None) {
1607 Some(t) => t,
1608 None => return,
1609 };
1610
1611 let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1613 Ok(symbols) => symbols,
1614 Err(_) => return,
1615 };
1616 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1617 Some(s) => s,
1618 None => return,
1619 };
1620
1621 let body_start =
1622 line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1623 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1624
1625 let root = tree.root_node();
1626
1627 let body_node = match find_node_covering_range(root, body_start, body_end) {
1629 Some(n) => n,
1630 None => return,
1631 };
1632
1633 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1635 let rel_file = self.relative_path(file);
1636
1637 self.walk_for_data_flow(
1639 body_node,
1640 &source,
1641 &mut tracked_names,
1642 file,
1643 symbol,
1644 &rel_file,
1645 lang,
1646 max_depth,
1647 current_depth,
1648 hops,
1649 depth_limited,
1650 visited,
1651 );
1652 }
1653
1654 #[allow(clippy::too_many_arguments)]
1657 fn walk_for_data_flow(
1658 &mut self,
1659 node: tree_sitter::Node,
1660 source: &str,
1661 tracked_names: &mut Vec<String>,
1662 file: &Path,
1663 symbol: &str,
1664 rel_file: &str,
1665 lang: LangId,
1666 max_depth: usize,
1667 current_depth: usize,
1668 hops: &mut Vec<DataFlowHop>,
1669 depth_limited: &mut bool,
1670 visited: &mut HashSet<(PathBuf, String, String)>,
1671 ) {
1672 let kind = node.kind();
1673
1674 let is_var_decl = matches!(
1676 kind,
1677 "variable_declarator"
1678 | "assignment_expression"
1679 | "augmented_assignment_expression"
1680 | "assignment"
1681 | "let_declaration"
1682 | "short_var_declaration"
1683 );
1684
1685 if is_var_decl {
1686 if let Some((new_name, init_text, line, is_approx)) =
1687 self.extract_assignment_info(node, source, lang, tracked_names)
1688 {
1689 if !is_approx {
1691 hops.push(DataFlowHop {
1692 file: rel_file.to_string(),
1693 symbol: symbol.to_string(),
1694 variable: new_name.clone(),
1695 line,
1696 flow_type: "assignment".to_string(),
1697 approximate: false,
1698 });
1699 tracked_names.push(new_name);
1700 } else {
1701 hops.push(DataFlowHop {
1703 file: rel_file.to_string(),
1704 symbol: symbol.to_string(),
1705 variable: init_text,
1706 line,
1707 flow_type: "assignment".to_string(),
1708 approximate: true,
1709 });
1710 return;
1712 }
1713 }
1714 }
1715
1716 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1718 self.check_call_for_data_flow(
1719 node,
1720 source,
1721 tracked_names,
1722 file,
1723 symbol,
1724 rel_file,
1725 lang,
1726 max_depth,
1727 current_depth,
1728 hops,
1729 depth_limited,
1730 visited,
1731 );
1732 }
1733
1734 let mut cursor = node.walk();
1736 if cursor.goto_first_child() {
1737 loop {
1738 let child = cursor.node();
1739 self.walk_for_data_flow(
1741 child,
1742 source,
1743 tracked_names,
1744 file,
1745 symbol,
1746 rel_file,
1747 lang,
1748 max_depth,
1749 current_depth,
1750 hops,
1751 depth_limited,
1752 visited,
1753 );
1754 if !cursor.goto_next_sibling() {
1755 break;
1756 }
1757 }
1758 }
1759 }
1760
1761 fn extract_assignment_info(
1764 &self,
1765 node: tree_sitter::Node,
1766 source: &str,
1767 _lang: LangId,
1768 tracked_names: &[String],
1769 ) -> Option<(String, String, u32, bool)> {
1770 let kind = node.kind();
1771 let line = node.start_position().row as u32 + 1;
1772
1773 match kind {
1774 "variable_declarator" => {
1775 let name_node = node.child_by_field_name("name")?;
1777 let value_node = node.child_by_field_name("value")?;
1778 let name_text = node_text(name_node, source);
1779 let value_text = node_text(value_node, source);
1780
1781 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1783 if tracked_names.iter().any(|t| value_text.contains(t)) {
1785 return Some((name_text.clone(), name_text, line, true));
1786 }
1787 return None;
1788 }
1789
1790 if tracked_names.iter().any(|t| {
1792 value_text == *t
1793 || value_text.starts_with(&format!("{}.", t))
1794 || value_text.starts_with(&format!("{}[", t))
1795 }) {
1796 return Some((name_text, value_text, line, false));
1797 }
1798 None
1799 }
1800 "assignment_expression" | "augmented_assignment_expression" => {
1801 let left = node.child_by_field_name("left")?;
1803 let right = node.child_by_field_name("right")?;
1804 let left_text = node_text(left, source);
1805 let right_text = node_text(right, source);
1806
1807 if tracked_names.iter().any(|t| right_text == *t) {
1808 return Some((left_text, right_text, line, false));
1809 }
1810 None
1811 }
1812 "assignment" => {
1813 let left = node.child_by_field_name("left")?;
1815 let right = node.child_by_field_name("right")?;
1816 let left_text = node_text(left, source);
1817 let right_text = node_text(right, source);
1818
1819 if tracked_names.iter().any(|t| right_text == *t) {
1820 return Some((left_text, right_text, line, false));
1821 }
1822 None
1823 }
1824 "let_declaration" | "short_var_declaration" => {
1825 let left = node
1827 .child_by_field_name("pattern")
1828 .or_else(|| node.child_by_field_name("left"))?;
1829 let right = node
1830 .child_by_field_name("value")
1831 .or_else(|| node.child_by_field_name("right"))?;
1832 let left_text = node_text(left, source);
1833 let right_text = node_text(right, source);
1834
1835 if tracked_names.iter().any(|t| right_text == *t) {
1836 return Some((left_text, right_text, line, false));
1837 }
1838 None
1839 }
1840 _ => None,
1841 }
1842 }
1843
1844 #[allow(clippy::too_many_arguments)]
1847 fn check_call_for_data_flow(
1848 &mut self,
1849 node: tree_sitter::Node,
1850 source: &str,
1851 tracked_names: &[String],
1852 file: &Path,
1853 _symbol: &str,
1854 rel_file: &str,
1855 _lang: LangId,
1856 max_depth: usize,
1857 current_depth: usize,
1858 hops: &mut Vec<DataFlowHop>,
1859 depth_limited: &mut bool,
1860 visited: &mut HashSet<(PathBuf, String, String)>,
1861 ) {
1862 let args_node = find_child_by_kind(node, "arguments")
1864 .or_else(|| find_child_by_kind(node, "argument_list"));
1865
1866 let args_node = match args_node {
1867 Some(n) => n,
1868 None => return,
1869 };
1870
1871 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1874
1875 let mut cursor = args_node.walk();
1876 if cursor.goto_first_child() {
1877 loop {
1878 let child = cursor.node();
1879 let child_kind = child.kind();
1880
1881 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1883 if !cursor.goto_next_sibling() {
1884 break;
1885 }
1886 continue;
1887 }
1888
1889 let arg_text = node_text(child, source);
1890
1891 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1893 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1894 hops.push(DataFlowHop {
1895 file: rel_file.to_string(),
1896 symbol: _symbol.to_string(),
1897 variable: arg_text,
1898 line: child.start_position().row as u32 + 1,
1899 flow_type: "parameter".to_string(),
1900 approximate: true,
1901 });
1902 }
1903 if !cursor.goto_next_sibling() {
1904 break;
1905 }
1906 arg_idx += 1;
1907 continue;
1908 }
1909
1910 if tracked_names.iter().any(|t| arg_text == *t) {
1911 arg_positions.push((arg_idx, arg_text));
1912 }
1913
1914 arg_idx += 1;
1915 if !cursor.goto_next_sibling() {
1916 break;
1917 }
1918 }
1919 }
1920
1921 if arg_positions.is_empty() {
1922 return;
1923 }
1924
1925 let (full_callee, short_callee) = extract_callee_names(node, source);
1927 let full_callee = match full_callee {
1928 Some(f) => f,
1929 None => return,
1930 };
1931 let short_callee = match short_callee {
1932 Some(s) => s,
1933 None => return,
1934 };
1935
1936 let import_block = {
1938 match self.data.get(file) {
1939 Some(fd) => fd.import_block.clone(),
1940 None => return,
1941 }
1942 };
1943
1944 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1945
1946 match edge {
1947 EdgeResolution::Resolved {
1948 file: target_file,
1949 symbol: target_symbol,
1950 } => {
1951 if current_depth + 1 > max_depth {
1952 *depth_limited = true;
1953 return;
1954 }
1955
1956 if let Err(e) = self.build_file(&target_file) {
1958 log::debug!(
1959 "callgraph: skipping target file {}: {}",
1960 target_file.display(),
1961 e
1962 );
1963 }
1964 let (params, target_line) = {
1965 match self.lookup_file_data(&target_file) {
1966 Some(fd) => {
1967 let meta = fd.symbol_metadata.get(&target_symbol);
1968 let sig = meta.and_then(|m| m.signature.clone());
1969 let params = sig
1970 .as_deref()
1971 .map(|s| extract_parameters(s, fd.lang))
1972 .unwrap_or_default();
1973 let line = meta.map(|m| m.line).unwrap_or(1);
1974 (params, line)
1975 }
1976 None => return,
1977 }
1978 };
1979
1980 let target_rel = self.relative_path(&target_file);
1981
1982 for (pos, _tracked) in &arg_positions {
1983 if let Some(param_name) = params.get(*pos) {
1984 hops.push(DataFlowHop {
1986 file: target_rel.clone(),
1987 symbol: target_symbol.clone(),
1988 variable: param_name.clone(),
1989 line: target_line,
1990 flow_type: "parameter".to_string(),
1991 approximate: false,
1992 });
1993
1994 self.trace_data_inner(
1996 &target_file.clone(),
1997 &target_symbol.clone(),
1998 param_name,
1999 max_depth,
2000 current_depth + 1,
2001 hops,
2002 depth_limited,
2003 visited,
2004 );
2005 }
2006 }
2007 }
2008 EdgeResolution::Unresolved { callee_name } => {
2009 let has_local = self
2011 .data
2012 .get(file)
2013 .map(|fd| {
2014 fd.calls_by_symbol.contains_key(&callee_name)
2015 || fd.symbol_metadata.contains_key(&callee_name)
2016 })
2017 .unwrap_or(false);
2018
2019 if has_local {
2020 let (params, target_line) = {
2022 let Some(fd) = self.data.get(file) else {
2023 return;
2024 };
2025 let meta = fd.symbol_metadata.get(&callee_name);
2026 let sig = meta.and_then(|m| m.signature.clone());
2027 let params = sig
2028 .as_deref()
2029 .map(|s| extract_parameters(s, fd.lang))
2030 .unwrap_or_default();
2031 let line = meta.map(|m| m.line).unwrap_or(1);
2032 (params, line)
2033 };
2034
2035 let file_rel = self.relative_path(file);
2036
2037 for (pos, _tracked) in &arg_positions {
2038 if let Some(param_name) = params.get(*pos) {
2039 hops.push(DataFlowHop {
2040 file: file_rel.clone(),
2041 symbol: callee_name.clone(),
2042 variable: param_name.clone(),
2043 line: target_line,
2044 flow_type: "parameter".to_string(),
2045 approximate: false,
2046 });
2047
2048 self.trace_data_inner(
2050 file,
2051 &callee_name.clone(),
2052 param_name,
2053 max_depth,
2054 current_depth + 1,
2055 hops,
2056 depth_limited,
2057 visited,
2058 );
2059 }
2060 }
2061 } else {
2062 for (_pos, tracked) in &arg_positions {
2064 hops.push(DataFlowHop {
2065 file: self.relative_path(file),
2066 symbol: callee_name.clone(),
2067 variable: tracked.clone(),
2068 line: node.start_position().row as u32 + 1,
2069 flow_type: "parameter".to_string(),
2070 approximate: true,
2071 });
2072 }
2073 }
2074 }
2075 }
2076 }
2077
2078 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2080 let content = std::fs::read_to_string(path).ok()?;
2081 content
2082 .lines()
2083 .nth(line.saturating_sub(1) as usize)
2084 .map(|l| l.trim().to_string())
2085 }
2086
2087 fn collect_callers_recursive(
2089 &self,
2090 file: &Path,
2091 symbol: &str,
2092 max_depth: usize,
2093 current_depth: usize,
2094 visited: &mut HashSet<(PathBuf, SharedStr)>,
2095 result: &mut Vec<CallerSite>,
2096 ) {
2097 if current_depth >= max_depth {
2098 return;
2099 }
2100
2101 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2103 let key_symbol: SharedStr = Arc::from(symbol);
2104 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2105 return; }
2107
2108 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2109 for site in sites {
2110 result.push(CallerSite {
2111 caller_file: site.caller_file.as_ref().clone(),
2112 caller_symbol: site.caller_symbol.to_string(),
2113 line: site.line,
2114 col: site.col,
2115 resolved: site.resolved,
2116 });
2117 if current_depth + 1 < max_depth {
2119 self.collect_callers_recursive(
2120 site.caller_file.as_ref(),
2121 site.caller_symbol.as_ref(),
2122 max_depth,
2123 current_depth + 1,
2124 visited,
2125 result,
2126 );
2127 }
2128 }
2129 }
2130 }
2131
2132 pub fn invalidate_file(&mut self, path: &Path) {
2137 self.data.remove(path);
2139 if let Ok(canon) = self.canonicalize(path) {
2140 self.data.remove(&canon);
2141 }
2142 self.reverse_index = None;
2144 self.project_files = None;
2146 clear_workspace_package_cache();
2147 }
2148
2149 fn relative_path(&self, path: &Path) -> String {
2152 path.strip_prefix(&self.project_root)
2153 .unwrap_or(path)
2154 .display()
2155 .to_string()
2156 }
2157
2158 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2160 let full_path = if path.is_relative() {
2162 self.project_root.join(path)
2163 } else {
2164 path.to_path_buf()
2165 };
2166
2167 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2169 }
2170
2171 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2175 if let Some(fd) = self.data.get(path) {
2176 return Some(fd);
2177 }
2178 let canon = std::fs::canonicalize(path).ok()?;
2180 self.data.get(&canon).or_else(|| {
2181 self.data.iter().find_map(|(k, v)| {
2183 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2184 Some(v)
2185 } else {
2186 None
2187 }
2188 })
2189 })
2190 }
2191}
2192
2193fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2199 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2200 message: format!("unsupported file for call graph: {}", path.display()),
2201 })?;
2202
2203 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2204 path: format!("{}: {}", path.display(), e),
2205 })?;
2206
2207 let grammar = grammar_for(lang);
2208 let mut parser = Parser::new();
2209 parser
2210 .set_language(&grammar)
2211 .map_err(|e| AftError::ParseError {
2212 message: format!("grammar init failed for {:?}: {}", lang, e),
2213 })?;
2214
2215 let tree = parser
2216 .parse(&source, None)
2217 .ok_or_else(|| AftError::ParseError {
2218 message: format!("parse failed for {}", path.display()),
2219 })?;
2220
2221 let import_block = imports::parse_imports(&source, &tree, lang);
2223
2224 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2226
2227 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2229 let root = tree.root_node();
2230
2231 for sym in &symbols {
2232 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2233 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2234
2235 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2236
2237 let sites: Vec<CallSite> = raw_calls
2238 .into_iter()
2239 .map(|(full, short, line)| CallSite {
2240 callee_name: short,
2241 full_callee: full,
2242 line,
2243 byte_start,
2244 byte_end,
2245 })
2246 .collect();
2247
2248 if !sites.is_empty() {
2249 calls_by_symbol.insert(sym.name.clone(), sites);
2250 }
2251 }
2252
2253 let symbol_ranges: Vec<(usize, usize)> = symbols
2254 .iter()
2255 .map(|sym| {
2256 (
2257 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2258 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2259 )
2260 })
2261 .collect();
2262
2263 let top_level_sites: Vec<CallSite> =
2264 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2265 .into_iter()
2266 .filter(|site| {
2267 !symbol_ranges
2268 .iter()
2269 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2270 })
2271 .map(|site| CallSite {
2272 callee_name: site.short,
2273 full_callee: site.full,
2274 line: site.line,
2275 byte_start: site.byte_start,
2276 byte_end: site.byte_end,
2277 })
2278 .collect();
2279
2280 if !top_level_sites.is_empty() {
2281 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2282 }
2283
2284 let default_export = find_default_export(&source, root, path, lang);
2285
2286 if let Some(default_export) = &default_export {
2287 if default_export.synthetic {
2288 let byte_start = default_export.node.byte_range().start;
2289 let byte_end = default_export.node.byte_range().end;
2290 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2291 let sites: Vec<CallSite> = raw_calls
2292 .into_iter()
2293 .filter(|(_, short, _)| *short != default_export.symbol)
2294 .map(|(full, short, line)| CallSite {
2295 callee_name: short,
2296 full_callee: full,
2297 line,
2298 byte_start,
2299 byte_end,
2300 })
2301 .collect();
2302 if !sites.is_empty() {
2303 calls_by_symbol.insert(default_export.symbol.clone(), sites);
2304 }
2305 }
2306 }
2307
2308 let mut exported_symbols: Vec<String> = symbols
2310 .iter()
2311 .filter(|s| s.exported)
2312 .map(|s| s.name.clone())
2313 .collect();
2314 if let Some(default_export) = &default_export {
2315 if !exported_symbols
2316 .iter()
2317 .any(|name| name == &default_export.symbol)
2318 {
2319 exported_symbols.push(default_export.symbol.clone());
2320 }
2321 }
2322
2323 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2325 .iter()
2326 .map(|s| {
2327 (
2328 s.name.clone(),
2329 SymbolMeta {
2330 kind: s.kind.clone(),
2331 exported: s.exported,
2332 signature: s.signature.clone(),
2333 line: s.range.start_line + 1,
2334 range: s.range.clone(),
2335 },
2336 )
2337 })
2338 .collect();
2339 if let Some(default_export) = &default_export {
2340 symbol_metadata
2341 .entry(default_export.symbol.clone())
2342 .or_insert_with(|| SymbolMeta {
2343 kind: default_export.kind.clone(),
2344 exported: true,
2345 signature: Some(first_line_signature(&source, &default_export.node)),
2346 line: default_export.node.start_position().row as u32 + 1,
2347 range: crate::parser::node_range(&default_export.node),
2348 });
2349 }
2350 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2351 symbol_metadata
2352 .entry(TOP_LEVEL_SYMBOL.to_string())
2353 .or_insert(SymbolMeta {
2354 kind: SymbolKind::Function,
2355 exported: false,
2356 signature: None,
2357 line: 1,
2358 range: Range {
2359 start_line: 0,
2360 start_col: 0,
2361 end_line: 0,
2362 end_col: 0,
2363 },
2364 });
2365 }
2366
2367 Ok(FileCallData {
2368 calls_by_symbol,
2369 exported_symbols,
2370 symbol_metadata,
2371 default_export_symbol: default_export.map(|export| export.symbol),
2372 import_block,
2373 lang,
2374 })
2375}
2376
2377#[derive(Debug, Clone)]
2378struct DefaultExport<'tree> {
2379 symbol: String,
2380 synthetic: bool,
2381 kind: SymbolKind,
2382 node: Node<'tree>,
2383}
2384
2385fn find_default_export<'tree>(
2386 source: &str,
2387 root: Node<'tree>,
2388 path: &Path,
2389 lang: LangId,
2390) -> Option<DefaultExport<'tree>> {
2391 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2392 return None;
2393 }
2394 find_default_export_inner(source, root, path)
2395}
2396
2397fn find_default_export_inner<'tree>(
2398 source: &str,
2399 node: Node<'tree>,
2400 path: &Path,
2401) -> Option<DefaultExport<'tree>> {
2402 if node.kind() == "export_statement" {
2403 if let Some(default_export) = default_export_from_statement(source, node, path) {
2404 return Some(default_export);
2405 }
2406 }
2407
2408 let mut cursor = node.walk();
2409 if !cursor.goto_first_child() {
2410 return None;
2411 }
2412
2413 loop {
2414 let child = cursor.node();
2415 if let Some(default_export) = find_default_export_inner(source, child, path) {
2416 return Some(default_export);
2417 }
2418 if !cursor.goto_next_sibling() {
2419 break;
2420 }
2421 }
2422
2423 None
2424}
2425
2426fn default_export_from_statement<'tree>(
2427 source: &str,
2428 node: Node<'tree>,
2429 path: &Path,
2430) -> Option<DefaultExport<'tree>> {
2431 let mut cursor = node.walk();
2432 if !cursor.goto_first_child() {
2433 return None;
2434 }
2435
2436 let mut saw_default = false;
2437 loop {
2438 let child = cursor.node();
2439 match child.kind() {
2440 "default" => saw_default = true,
2441 "function_declaration" | "generator_function_declaration" | "class_declaration"
2442 if saw_default =>
2443 {
2444 if let Some(name_node) = child.child_by_field_name("name") {
2445 return Some(DefaultExport {
2446 symbol: source[name_node.byte_range()].to_string(),
2447 synthetic: false,
2448 kind: default_export_kind(&child),
2449 node: child,
2450 });
2451 }
2452 return Some(DefaultExport {
2453 symbol: synthetic_default_symbol(path),
2454 synthetic: true,
2455 kind: default_export_kind(&child),
2456 node: child,
2457 });
2458 }
2459 "arrow_function"
2460 | "function"
2461 | "function_expression"
2462 | "class"
2463 | "class_expression"
2464 if saw_default =>
2465 {
2466 return Some(DefaultExport {
2467 symbol: synthetic_default_symbol(path),
2468 synthetic: true,
2469 kind: default_export_kind(&child),
2470 node: child,
2471 });
2472 }
2473 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
2474 return Some(DefaultExport {
2475 symbol: source[child.byte_range()].to_string(),
2476 synthetic: false,
2477 kind: SymbolKind::Function,
2478 node: child,
2479 });
2480 }
2481 _ => {}
2482 }
2483 if !cursor.goto_next_sibling() {
2484 break;
2485 }
2486 }
2487
2488 None
2489}
2490
2491fn default_export_kind(node: &Node) -> SymbolKind {
2492 if node.kind().contains("class") {
2493 SymbolKind::Class
2494 } else {
2495 SymbolKind::Function
2496 }
2497}
2498
2499fn synthetic_default_symbol(path: &Path) -> String {
2500 let file_name = path
2501 .file_name()
2502 .and_then(|name| name.to_str())
2503 .unwrap_or("unknown");
2504 format!("<default:{file_name}>")
2505}
2506
2507fn first_line_signature(source: &str, node: &Node) -> String {
2508 let text = &source[node.byte_range()];
2509 let first_line = text.lines().next().unwrap_or(text);
2510 first_line
2511 .trim_end()
2512 .trim_end_matches('{')
2513 .trim_end()
2514 .to_string()
2515}
2516
2517fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2518 file_data
2519 .symbol_metadata
2520 .get(symbol_name)
2521 .map(|meta| (meta.line, meta.signature.clone()))
2522 .unwrap_or((1, None))
2523}
2524
2525fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2527 let provider = crate::parser::TreeSitterProvider::new();
2528 match provider.list_symbols(path) {
2529 Ok(symbols) => {
2530 for s in &symbols {
2531 if s.name == symbol_name {
2532 return (s.range.start_line + 1, s.signature.clone());
2533 }
2534 }
2535 (1, None)
2536 }
2537 Err(_) => (1, None),
2538 }
2539}
2540
2541fn node_text(node: tree_sitter::Node, source: &str) -> String {
2547 source[node.start_byte()..node.end_byte()].to_string()
2548}
2549
2550fn find_node_covering_range(
2552 root: tree_sitter::Node,
2553 start: usize,
2554 end: usize,
2555) -> Option<tree_sitter::Node> {
2556 let mut best = None;
2557 let mut cursor = root.walk();
2558
2559 fn walk_covering<'a>(
2560 cursor: &mut tree_sitter::TreeCursor<'a>,
2561 start: usize,
2562 end: usize,
2563 best: &mut Option<tree_sitter::Node<'a>>,
2564 ) {
2565 let node = cursor.node();
2566 if node.start_byte() <= start && node.end_byte() >= end {
2567 *best = Some(node);
2568 if cursor.goto_first_child() {
2569 loop {
2570 walk_covering(cursor, start, end, best);
2571 if !cursor.goto_next_sibling() {
2572 break;
2573 }
2574 }
2575 cursor.goto_parent();
2576 }
2577 }
2578 }
2579
2580 walk_covering(&mut cursor, start, end, &mut best);
2581 best
2582}
2583
2584fn find_child_by_kind<'a>(
2586 node: tree_sitter::Node<'a>,
2587 kind: &str,
2588) -> Option<tree_sitter::Node<'a>> {
2589 let mut cursor = node.walk();
2590 if cursor.goto_first_child() {
2591 loop {
2592 if cursor.node().kind() == kind {
2593 return Some(cursor.node());
2594 }
2595 if !cursor.goto_next_sibling() {
2596 break;
2597 }
2598 }
2599 }
2600 None
2601}
2602
2603#[derive(Debug, Clone)]
2604struct CallSiteWithRange {
2605 full: String,
2606 short: String,
2607 line: u32,
2608 byte_start: usize,
2609 byte_end: usize,
2610}
2611
2612fn collect_calls_full_with_ranges(
2613 root: tree_sitter::Node,
2614 source: &str,
2615 byte_start: usize,
2616 byte_end: usize,
2617 lang: LangId,
2618) -> Vec<CallSiteWithRange> {
2619 let mut results = Vec::new();
2620 let call_kinds = call_node_kinds(lang);
2621 collect_calls_full_with_ranges_inner(
2622 root,
2623 source,
2624 byte_start,
2625 byte_end,
2626 &call_kinds,
2627 &mut results,
2628 );
2629 results
2630}
2631
2632fn collect_calls_full_with_ranges_inner(
2633 node: tree_sitter::Node,
2634 source: &str,
2635 byte_start: usize,
2636 byte_end: usize,
2637 call_kinds: &[&str],
2638 results: &mut Vec<CallSiteWithRange>,
2639) {
2640 let node_start = node.start_byte();
2641 let node_end = node.end_byte();
2642
2643 if node_end <= byte_start || node_start >= byte_end {
2644 return;
2645 }
2646
2647 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
2648 if let (Some(full), Some(short)) = (
2649 extract_full_callee(&node, source),
2650 extract_callee_name(&node, source),
2651 ) {
2652 results.push(CallSiteWithRange {
2653 full,
2654 short,
2655 line: node.start_position().row as u32 + 1,
2656 byte_start: node_start,
2657 byte_end: node_end,
2658 });
2659 }
2660 }
2661
2662 let mut cursor = node.walk();
2663 if cursor.goto_first_child() {
2664 loop {
2665 collect_calls_full_with_ranges_inner(
2666 cursor.node(),
2667 source,
2668 byte_start,
2669 byte_end,
2670 call_kinds,
2671 results,
2672 );
2673 if !cursor.goto_next_sibling() {
2674 break;
2675 }
2676 }
2677 }
2678}
2679
2680fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2682 let callee = match node.child_by_field_name("function") {
2684 Some(c) => c,
2685 None => return (None, None),
2686 };
2687
2688 let full = node_text(callee, source);
2689 let short = if full.contains('.') {
2690 full.rsplit('.').next().unwrap_or(&full).to_string()
2691 } else {
2692 full.clone()
2693 };
2694
2695 (Some(full), Some(short))
2696}
2697
2698pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2706 if module_path.starts_with('.') {
2707 return resolve_relative_module_path(from_dir, module_path);
2708 }
2709
2710 if module_path.starts_with('/') {
2711 return None;
2712 }
2713
2714 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
2715 return Some(path);
2716 }
2717
2718 resolve_workspace_module_path(from_dir, module_path)
2719}
2720
2721fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2722 let base = from_dir.join(module_path);
2723 resolve_file_like_path(&base)
2724}
2725
2726fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
2727 let base = base.to_path_buf();
2728
2729 if base.is_file() {
2731 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2732 }
2733
2734 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2736 for ext in &extensions {
2737 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2738 if with_ext.is_file() {
2739 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2740 }
2741 }
2742
2743 if base.is_dir() {
2745 if let Some(index) = find_index_file(&base) {
2746 return Some(index);
2747 }
2748 }
2749
2750 None
2751}
2752
2753fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2754 let (package_name, subpath) = split_package_import(module_path)?;
2755 let package_root = find_package_root_for_import(from_dir, &package_name)?;
2756 resolve_package_entry(&package_root, &subpath)
2757}
2758
2759fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2760 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2761 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2762 let compiler_options = tsconfig.get("compilerOptions")?;
2763 let paths = compiler_options.get("paths")?.as_object()?;
2764 let base_url = compiler_options
2765 .get("baseUrl")
2766 .and_then(Value::as_str)
2767 .unwrap_or(".");
2768 let base_dir = tsconfig_dir.join(base_url);
2769
2770 for (alias, targets) in paths {
2771 let Some(capture) = ts_path_capture(alias, module_path) else {
2772 continue;
2773 };
2774 let Some(targets) = targets.as_array() else {
2775 continue;
2776 };
2777 for target in targets.iter().filter_map(Value::as_str) {
2778 let target = if target.contains('*') {
2779 target.replace('*', capture)
2780 } else {
2781 target.to_string()
2782 };
2783 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2784 return Some(path);
2785 }
2786 }
2787 }
2788
2789 None
2790}
2791
2792fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2793 let mut current = Some(from_dir);
2794 while let Some(dir) = current {
2795 if dir.join("tsconfig.json").is_file() {
2796 return Some(dir.to_path_buf());
2797 }
2798 current = dir.parent();
2799 }
2800 None
2801}
2802
2803fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2804 if let Some(star_index) = alias.find('*') {
2805 let (prefix, suffix_with_star) = alias.split_at(star_index);
2806 let suffix = &suffix_with_star[1..];
2807 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2808 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2809 }
2810 return None;
2811 }
2812
2813 (alias == module_path).then_some("")
2814}
2815
2816fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2817 let mut parts = module_path.split('/');
2818 let first = parts.next()?;
2819 if first.is_empty() {
2820 return None;
2821 }
2822
2823 if first.starts_with('@') {
2824 let second = parts.next()?;
2825 if second.is_empty() {
2826 return None;
2827 }
2828 let package_name = format!("{first}/{second}");
2829 let subpath = parts.collect::<Vec<_>>().join("/");
2830 let subpath = (!subpath.is_empty()).then_some(subpath);
2831 Some((package_name, subpath))
2832 } else {
2833 let package_name = first.to_string();
2834 let subpath = parts.collect::<Vec<_>>().join("/");
2835 let subpath = (!subpath.is_empty()).then_some(subpath);
2836 Some((package_name, subpath))
2837 }
2838}
2839
2840fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2841 let mut current = Some(from_dir);
2842 while let Some(dir) = current {
2843 if package_json_name(dir).as_deref() == Some(package_name) {
2844 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2845 }
2846 current = dir.parent();
2847 }
2848
2849 find_workspace_root(from_dir)
2850 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2851}
2852
2853fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2854 let mut current = Some(from_dir);
2855 while let Some(dir) = current {
2856 if is_workspace_root(dir) {
2857 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2858 }
2859 current = dir.parent();
2860 }
2861 None
2862}
2863
2864fn is_workspace_root(dir: &Path) -> bool {
2865 package_json_value(dir)
2866 .map(|value| !workspace_patterns(&value).is_empty())
2867 .unwrap_or(false)
2868 || !pnpm_workspace_patterns(dir).is_empty()
2869}
2870
2871fn clear_workspace_package_cache() {
2872 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2873 cache.clear();
2874 }
2875}
2876
2877fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2878 let workspace_root =
2879 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2880 let cache_key = (workspace_root.clone(), package_name.to_string());
2881
2882 if let Some(cached) = WORKSPACE_PACKAGE_CACHE
2883 .read()
2884 .ok()
2885 .and_then(|cache| cache.get(&cache_key).cloned())
2886 {
2887 return cached;
2888 }
2889
2890 let resolved = workspace_member_dirs(&workspace_root)
2891 .into_iter()
2892 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2893 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2894
2895 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2896 cache.insert(cache_key, resolved.clone());
2897 }
2898
2899 resolved
2900}
2901
2902fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2903 let mut patterns = package_json_value(workspace_root)
2904 .map(|package_json| workspace_patterns(&package_json))
2905 .unwrap_or_default();
2906 patterns.extend(pnpm_workspace_patterns(workspace_root));
2907
2908 expand_workspace_patterns(workspace_root, &patterns)
2909}
2910
2911fn workspace_patterns(package_json: &Value) -> Vec<String> {
2912 match package_json.get("workspaces") {
2913 Some(Value::Array(items)) => items
2914 .iter()
2915 .filter_map(non_empty_workspace_pattern)
2916 .collect(),
2917 Some(Value::Object(map)) => map
2918 .get("packages")
2919 .and_then(Value::as_array)
2920 .map(|items| {
2921 items
2922 .iter()
2923 .filter_map(non_empty_workspace_pattern)
2924 .collect()
2925 })
2926 .unwrap_or_default(),
2927 _ => Vec::new(),
2928 }
2929}
2930
2931fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2932 let pattern = value.as_str()?.trim();
2933 (!pattern.is_empty()).then(|| pattern.to_string())
2934}
2935
2936fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2937 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2938 return Vec::new();
2939 };
2940
2941 let mut patterns = Vec::new();
2942 let mut in_packages = false;
2943 for line in source.lines() {
2944 let without_comment = line.split('#').next().unwrap_or("").trim_end();
2945 let trimmed = without_comment.trim();
2946 if trimmed.is_empty() {
2947 continue;
2948 }
2949 if trimmed == "packages:" {
2950 in_packages = true;
2951 continue;
2952 }
2953 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2954 in_packages = false;
2955 }
2956 if in_packages {
2957 if let Some(pattern) = trimmed.strip_prefix('-') {
2958 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2959 if !pattern.is_empty() {
2960 patterns.push(pattern.to_string());
2961 }
2962 }
2963 }
2964 }
2965 patterns
2966}
2967
2968fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2969 let positive_patterns: Vec<&str> = patterns
2970 .iter()
2971 .map(|pattern| pattern.trim())
2972 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2973 .collect();
2974 if positive_patterns.is_empty() {
2975 return Vec::new();
2976 }
2977
2978 let positives = build_glob_set(&positive_patterns);
2979 let negative_patterns: Vec<&str> = patterns
2980 .iter()
2981 .map(|pattern| pattern.trim())
2982 .filter_map(|pattern| pattern.strip_prefix('!'))
2983 .map(str::trim)
2984 .filter(|pattern| !pattern.is_empty())
2985 .collect();
2986 let negatives = build_glob_set(&negative_patterns);
2987
2988 let mut members = Vec::new();
2989 collect_workspace_member_dirs(
2990 workspace_root,
2991 workspace_root,
2992 &positives,
2993 &negatives,
2994 &mut members,
2995 );
2996 members
2997}
2998
2999fn build_glob_set(patterns: &[&str]) -> GlobSet {
3000 let mut builder = GlobSetBuilder::new();
3001 for pattern in patterns {
3002 if let Ok(glob) = Glob::new(pattern) {
3003 builder.add(glob);
3004 }
3005 }
3006 builder
3007 .build()
3008 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
3009}
3010
3011fn collect_workspace_member_dirs(
3012 workspace_root: &Path,
3013 dir: &Path,
3014 positives: &GlobSet,
3015 negatives: &GlobSet,
3016 members: &mut Vec<PathBuf>,
3017) {
3018 let Ok(entries) = std::fs::read_dir(dir) else {
3019 return;
3020 };
3021
3022 for entry in entries.filter_map(Result::ok) {
3023 let path = entry.path();
3024 let Ok(file_type) = entry.file_type() else {
3025 continue;
3026 };
3027 if !file_type.is_dir() {
3028 continue;
3029 }
3030 let name = entry.file_name();
3031 let name = name.to_string_lossy();
3032 if matches!(
3033 name.as_ref(),
3034 "node_modules" | ".git" | "target" | "dist" | "build"
3035 ) {
3036 continue;
3037 }
3038
3039 if path.join("package.json").is_file() {
3040 if let Ok(rel) = path.strip_prefix(workspace_root) {
3041 let rel = rel.to_string_lossy().replace('\\', "/");
3042 if positives.is_match(&rel) && !negatives.is_match(&rel) {
3043 members.push(path.clone());
3044 }
3045 }
3046 }
3047
3048 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
3049 }
3050}
3051
3052fn package_json_value(dir: &Path) -> Option<Value> {
3053 package_json_like_value(&dir.join("package.json"))
3054}
3055
3056fn package_json_like_value(path: &Path) -> Option<Value> {
3057 let json = std::fs::read_to_string(path).ok()?;
3058 serde_json::from_str(&json).ok()
3059}
3060
3061fn package_json_name(dir: &Path) -> Option<String> {
3062 package_json_value(dir)?
3063 .get("name")?
3064 .as_str()
3065 .map(ToOwned::to_owned)
3066}
3067
3068fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
3069 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
3070
3071 if let Some(exports) = package_json.get("exports") {
3072 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
3073 if let Some(path) = resolve_package_target(package_root, &target) {
3074 return Some(path);
3075 }
3076 }
3077 }
3078
3079 if subpath.is_none() {
3080 for field in ["module", "main"] {
3081 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
3082 if let Some(path) = resolve_package_target(package_root, target) {
3083 return Some(path);
3084 }
3085 }
3086 }
3087 }
3088
3089 resolve_package_fallback(package_root, subpath.as_deref())
3090}
3091
3092fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
3093 let key = subpath
3094 .map(|value| format!("./{value}"))
3095 .unwrap_or_else(|| ".".to_string());
3096
3097 match exports {
3098 Value::String(target) if key == "." => Some(target.clone()),
3099 Value::Object(map) => {
3100 if let Some(target) = map.get(&key).and_then(export_condition_target) {
3101 return Some(target);
3102 }
3103
3104 if let Some(target) = wildcard_export_target(map, &key) {
3105 return Some(target);
3106 }
3107
3108 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
3109 return export_condition_target(exports);
3110 }
3111
3112 None
3113 }
3114 _ => None,
3115 }
3116}
3117
3118fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
3119 for (pattern, target) in map {
3120 let Some(star_index) = pattern.find('*') else {
3121 continue;
3122 };
3123 let (prefix, suffix_with_star) = pattern.split_at(star_index);
3124 let suffix = &suffix_with_star[1..];
3125 if !key.starts_with(prefix) || !key.ends_with(suffix) {
3126 continue;
3127 }
3128 let matched = &key[prefix.len()..key.len() - suffix.len()];
3129 if let Some(target_pattern) = export_condition_target(target) {
3130 return Some(target_pattern.replace('*', matched));
3131 }
3132 }
3133 None
3134}
3135
3136fn export_condition_target(value: &Value) -> Option<String> {
3137 match value {
3138 Value::String(target) => Some(target.clone()),
3139 Value::Object(map) => ["source", "import", "module", "default", "types"]
3140 .into_iter()
3141 .find_map(|field| map.get(field).and_then(export_condition_target)),
3142 _ => None,
3143 }
3144}
3145
3146fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
3147 let target = target.strip_prefix("./").unwrap_or(target);
3148 if let Some(src_relative) = target.strip_prefix("dist/") {
3151 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
3152 return Some(path);
3153 }
3154 }
3155
3156 resolve_file_like_path(&package_root.join(target))
3157}
3158
3159fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
3160 match subpath {
3161 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
3162 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
3163 None => resolve_file_like_path(&package_root.join("src").join("index"))
3164 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
3165 }
3166}
3167
3168fn resolve_reexported_symbol<F, D>(
3169 file: &Path,
3170 symbol_name: &str,
3171 file_exports_symbol: &mut F,
3172 file_default_export_symbol: &mut D,
3173) -> Option<ResolvedSymbol>
3174where
3175 F: FnMut(&Path, &str) -> bool,
3176 D: FnMut(&Path) -> Option<String>,
3177{
3178 let mut visited = HashSet::new();
3179 resolve_reexported_symbol_inner(
3180 file,
3181 symbol_name,
3182 file_exports_symbol,
3183 file_default_export_symbol,
3184 &mut visited,
3185 )
3186}
3187
3188fn resolve_reexported_symbol_inner<F, D>(
3189 file: &Path,
3190 symbol_name: &str,
3191 file_exports_symbol: &mut F,
3192 file_default_export_symbol: &mut D,
3193 visited: &mut HashSet<(PathBuf, String)>,
3194) -> Option<ResolvedSymbol>
3195where
3196 F: FnMut(&Path, &str) -> bool,
3197 D: FnMut(&Path) -> Option<String>,
3198{
3199 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
3200 if !visited.insert((canon.clone(), symbol_name.to_string())) {
3201 return None;
3202 }
3203
3204 let source = std::fs::read_to_string(&canon).ok()?;
3205 let lang = detect_language(&canon)?;
3206 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3207 if symbol_name == "default" {
3208 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
3209 file: canon,
3210 symbol,
3211 });
3212 }
3213 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
3214 file: canon,
3215 symbol: symbol_name.to_string(),
3216 });
3217 }
3218
3219 let grammar = grammar_for(lang);
3220 let mut parser = Parser::new();
3221 parser.set_language(&grammar).ok()?;
3222 let tree = parser.parse(&source, None)?;
3223 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
3224
3225 let mut cursor = tree.root_node().walk();
3226 if !cursor.goto_first_child() {
3227 return None;
3228 }
3229
3230 loop {
3231 let node = cursor.node();
3232 if node.kind() == "export_statement" {
3233 if let Some(target) = resolve_reexport_statement(
3234 &source,
3235 node,
3236 from_dir,
3237 symbol_name,
3238 file_exports_symbol,
3239 file_default_export_symbol,
3240 visited,
3241 ) {
3242 return Some(target);
3243 }
3244 }
3245
3246 if !cursor.goto_next_sibling() {
3247 break;
3248 }
3249 }
3250
3251 if symbol_name == "default" {
3252 if let Some(symbol) = file_default_export_symbol(&canon) {
3253 return Some(ResolvedSymbol {
3254 file: canon,
3255 symbol,
3256 });
3257 }
3258 }
3259
3260 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
3261 return Some(ResolvedSymbol {
3262 file: canon,
3263 symbol,
3264 });
3265 }
3266
3267 if file_exports_symbol(&canon, symbol_name) {
3268 let symbol = symbol_name.to_string();
3269 return Some(ResolvedSymbol {
3270 file: canon,
3271 symbol,
3272 });
3273 }
3274
3275 None
3276}
3277
3278fn resolve_reexport_statement<F, D>(
3279 source: &str,
3280 node: tree_sitter::Node,
3281 from_dir: &Path,
3282 symbol_name: &str,
3283 file_exports_symbol: &mut F,
3284 file_default_export_symbol: &mut D,
3285 visited: &mut HashSet<(PathBuf, String)>,
3286) -> Option<ResolvedSymbol>
3287where
3288 F: FnMut(&Path, &str) -> bool,
3289 D: FnMut(&Path) -> Option<String>,
3290{
3291 let source_node = node.child_by_field_name("source")?;
3292 let module_path = string_literal_content(source, source_node)?;
3293 let target_file = resolve_module_path(from_dir, &module_path)?;
3294 let raw_export = node_text(node, source);
3295
3296 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
3297 return resolve_reexported_symbol_inner(
3298 &target_file,
3299 &source_symbol,
3300 file_exports_symbol,
3301 file_default_export_symbol,
3302 visited,
3303 )
3304 .or(Some(ResolvedSymbol {
3305 file: target_file,
3306 symbol: source_symbol,
3307 }));
3308 }
3309
3310 if raw_export.contains('*') {
3311 return resolve_reexported_symbol_inner(
3312 &target_file,
3313 symbol_name,
3314 file_exports_symbol,
3315 file_default_export_symbol,
3316 visited,
3317 );
3318 }
3319
3320 None
3321}
3322
3323fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
3324 let lang = detect_language(file)?;
3325 let grammar = grammar_for(lang);
3326 let mut parser = Parser::new();
3327 parser.set_language(&grammar).ok()?;
3328 let tree = parser.parse(source, None)?;
3329
3330 let mut cursor = tree.root_node().walk();
3331 if !cursor.goto_first_child() {
3332 return None;
3333 }
3334
3335 loop {
3336 let node = cursor.node();
3337 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
3338 let raw_export = node_text(node, source);
3339 if let Some(source_symbol) =
3340 reexport_clause_source_symbol(&raw_export, requested_export)
3341 {
3342 return Some(source_symbol);
3343 }
3344 }
3345
3346 if !cursor.goto_next_sibling() {
3347 break;
3348 }
3349 }
3350
3351 None
3352}
3353
3354fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3355 let start = raw_export.find('{')? + 1;
3356 let end = raw_export[start..].find('}')? + start;
3357 for specifier in raw_export[start..end].split(',') {
3358 let specifier = specifier.trim();
3359 if specifier.is_empty() {
3360 continue;
3361 }
3362 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3363 if let Some((imported, exported)) = specifier.split_once(" as ") {
3364 if exported.trim() == requested_export {
3365 return Some(imported.trim().to_string());
3366 }
3367 } else if specifier == requested_export {
3368 return Some(requested_export.to_string());
3369 }
3370 }
3371 None
3372}
3373
3374fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3375 let raw = source[node.byte_range()].trim();
3376 let quote = raw.chars().next()?;
3377 if quote != '\'' && quote != '"' {
3378 return None;
3379 }
3380 raw.strip_prefix(quote)
3381 .and_then(|value| value.strip_suffix(quote))
3382 .map(ToOwned::to_owned)
3383}
3384
3385fn find_index_file(dir: &Path) -> Option<PathBuf> {
3387 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
3388 for name in &candidates {
3389 let p = dir.join(name);
3390 if p.is_file() {
3391 return Some(std::fs::canonicalize(&p).unwrap_or(p));
3392 }
3393 }
3394 None
3395}
3396
3397fn resolve_aliased_import(
3400 local_name: &str,
3401 import_block: &ImportBlock,
3402 caller_dir: &Path,
3403) -> Option<(String, PathBuf)> {
3404 for imp in &import_block.imports {
3405 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3408 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3409 return Some((original, resolved_path));
3410 }
3411 }
3412 }
3413 None
3414}
3415
3416fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3420 let search = format!(" as {}", local_name);
3423 if let Some(pos) = raw_import.find(&search) {
3424 let before = &raw_import[..pos];
3426 let original = before
3428 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3429 .find(|s| !s.is_empty())?;
3430 return Some(original.to_string());
3431 }
3432 None
3433}
3434
3435pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3443 use ignore::WalkBuilder;
3444
3445 let walker = WalkBuilder::new(root)
3446 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
3451 let name = entry.file_name().to_string_lossy();
3452 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3454 return !matches!(
3455 name.as_ref(),
3456 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3457 | ".tox" | "dist" | "build"
3458 );
3459 }
3460 true
3461 })
3462 .build();
3463
3464 walker
3465 .filter_map(|entry| entry.ok())
3466 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3467 .filter(|entry| detect_language(entry.path()).is_some())
3468 .map(|entry| entry.into_path())
3469}
3470
3471#[cfg(test)]
3476mod tests {
3477 use super::*;
3478 use std::fs;
3479 use tempfile::TempDir;
3480
3481 fn setup_ts_project() -> TempDir {
3483 let dir = TempDir::new().unwrap();
3484
3485 fs::write(
3487 dir.path().join("main.ts"),
3488 r#"import { helper, compute } from './utils';
3489import * as math from './math';
3490
3491export function main() {
3492 const a = helper(1);
3493 const b = compute(a, 2);
3494 const c = math.add(a, b);
3495 return c;
3496}
3497"#,
3498 )
3499 .unwrap();
3500
3501 fs::write(
3503 dir.path().join("utils.ts"),
3504 r#"import { double } from './helpers';
3505
3506export function helper(x: number): number {
3507 return double(x);
3508}
3509
3510export function compute(a: number, b: number): number {
3511 return a + b;
3512}
3513"#,
3514 )
3515 .unwrap();
3516
3517 fs::write(
3519 dir.path().join("helpers.ts"),
3520 r#"export function double(x: number): number {
3521 return x * 2;
3522}
3523
3524export function triple(x: number): number {
3525 return x * 3;
3526}
3527"#,
3528 )
3529 .unwrap();
3530
3531 fs::write(
3533 dir.path().join("math.ts"),
3534 r#"export function add(a: number, b: number): number {
3535 return a + b;
3536}
3537
3538export function subtract(a: number, b: number): number {
3539 return a - b;
3540}
3541"#,
3542 )
3543 .unwrap();
3544
3545 dir
3546 }
3547
3548 fn setup_alias_project() -> TempDir {
3550 let dir = TempDir::new().unwrap();
3551
3552 fs::write(
3553 dir.path().join("main.ts"),
3554 r#"import { helper as h } from './utils';
3555
3556export function main() {
3557 return h(42);
3558}
3559"#,
3560 )
3561 .unwrap();
3562
3563 fs::write(
3564 dir.path().join("utils.ts"),
3565 r#"export function helper(x: number): number {
3566 return x + 1;
3567}
3568"#,
3569 )
3570 .unwrap();
3571
3572 dir
3573 }
3574
3575 fn setup_cycle_project() -> TempDir {
3577 let dir = TempDir::new().unwrap();
3578
3579 fs::write(
3580 dir.path().join("a.ts"),
3581 r#"import { funcB } from './b';
3582
3583export function funcA() {
3584 return funcB();
3585}
3586"#,
3587 )
3588 .unwrap();
3589
3590 fs::write(
3591 dir.path().join("b.ts"),
3592 r#"import { funcA } from './a';
3593
3594export function funcB() {
3595 return funcA();
3596}
3597"#,
3598 )
3599 .unwrap();
3600
3601 dir
3602 }
3603
3604 #[test]
3607 fn callgraph_single_file_call_extraction() {
3608 let dir = setup_ts_project();
3609 let mut graph = CallGraph::new(dir.path().to_path_buf());
3610
3611 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3612 let main_calls = &file_data.calls_by_symbol["main"];
3613
3614 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3615 assert!(
3616 callee_names.contains(&"helper"),
3617 "main should call helper, got: {:?}",
3618 callee_names
3619 );
3620 assert!(
3621 callee_names.contains(&"compute"),
3622 "main should call compute, got: {:?}",
3623 callee_names
3624 );
3625 assert!(
3626 callee_names.contains(&"add"),
3627 "main should call math.add (short name: add), got: {:?}",
3628 callee_names
3629 );
3630 }
3631
3632 #[test]
3633 fn callgraph_file_data_has_exports() {
3634 let dir = setup_ts_project();
3635 let mut graph = CallGraph::new(dir.path().to_path_buf());
3636
3637 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3638 assert!(
3639 file_data.exported_symbols.contains(&"helper".to_string()),
3640 "utils.ts should export helper, got: {:?}",
3641 file_data.exported_symbols
3642 );
3643 assert!(
3644 file_data.exported_symbols.contains(&"compute".to_string()),
3645 "utils.ts should export compute, got: {:?}",
3646 file_data.exported_symbols
3647 );
3648 }
3649
3650 #[test]
3653 fn callgraph_resolve_direct_import() {
3654 let dir = setup_ts_project();
3655 let mut graph = CallGraph::new(dir.path().to_path_buf());
3656
3657 let main_path = dir.path().join("main.ts");
3658 let file_data = graph.build_file(&main_path).unwrap();
3659 let import_block = file_data.import_block.clone();
3660
3661 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3662 match edge {
3663 EdgeResolution::Resolved { file, symbol } => {
3664 assert!(
3665 file.ends_with("utils.ts"),
3666 "helper should resolve to utils.ts, got: {:?}",
3667 file
3668 );
3669 assert_eq!(symbol, "helper");
3670 }
3671 EdgeResolution::Unresolved { callee_name } => {
3672 panic!("Expected resolved, got unresolved: {}", callee_name);
3673 }
3674 }
3675 }
3676
3677 #[test]
3678 fn callgraph_resolve_namespace_import() {
3679 let dir = setup_ts_project();
3680 let mut graph = CallGraph::new(dir.path().to_path_buf());
3681
3682 let main_path = dir.path().join("main.ts");
3683 let file_data = graph.build_file(&main_path).unwrap();
3684 let import_block = file_data.import_block.clone();
3685
3686 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3687 match edge {
3688 EdgeResolution::Resolved { file, symbol } => {
3689 assert!(
3690 file.ends_with("math.ts"),
3691 "math.add should resolve to math.ts, got: {:?}",
3692 file
3693 );
3694 assert_eq!(symbol, "add");
3695 }
3696 EdgeResolution::Unresolved { callee_name } => {
3697 panic!("Expected resolved, got unresolved: {}", callee_name);
3698 }
3699 }
3700 }
3701
3702 #[test]
3703 fn callgraph_resolve_aliased_import() {
3704 let dir = setup_alias_project();
3705 let mut graph = CallGraph::new(dir.path().to_path_buf());
3706
3707 let main_path = dir.path().join("main.ts");
3708 let file_data = graph.build_file(&main_path).unwrap();
3709 let import_block = file_data.import_block.clone();
3710
3711 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3712 match edge {
3713 EdgeResolution::Resolved { file, symbol } => {
3714 assert!(
3715 file.ends_with("utils.ts"),
3716 "h (alias for helper) should resolve to utils.ts, got: {:?}",
3717 file
3718 );
3719 assert_eq!(symbol, "helper");
3720 }
3721 EdgeResolution::Unresolved { callee_name } => {
3722 panic!("Expected resolved, got unresolved: {}", callee_name);
3723 }
3724 }
3725 }
3726
3727 #[test]
3728 fn callgraph_unresolved_edge_marked() {
3729 let dir = setup_ts_project();
3730 let mut graph = CallGraph::new(dir.path().to_path_buf());
3731
3732 let main_path = dir.path().join("main.ts");
3733 let file_data = graph.build_file(&main_path).unwrap();
3734 let import_block = file_data.import_block.clone();
3735
3736 let edge =
3737 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3738 assert_eq!(
3739 edge,
3740 EdgeResolution::Unresolved {
3741 callee_name: "unknownFunc".to_string()
3742 },
3743 "Unknown callee should be unresolved"
3744 );
3745 }
3746
3747 #[test]
3750 fn callgraph_cycle_detection_stops() {
3751 let dir = setup_cycle_project();
3752 let mut graph = CallGraph::new(dir.path().to_path_buf());
3753
3754 let tree = graph
3756 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
3757 .unwrap();
3758
3759 assert_eq!(tree.name, "funcA");
3760 assert!(tree.resolved);
3761
3762 fn count_depth(node: &CallTreeNode) -> usize {
3765 if node.children.is_empty() {
3766 1
3767 } else {
3768 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
3769 }
3770 }
3771
3772 let depth = count_depth(&tree);
3773 assert!(
3774 depth <= 4,
3775 "Cycle should be detected and bounded, depth was: {}",
3776 depth
3777 );
3778 }
3779
3780 #[test]
3783 fn callgraph_depth_limit_truncates() {
3784 let dir = setup_ts_project();
3785 let mut graph = CallGraph::new(dir.path().to_path_buf());
3786
3787 let tree = graph
3790 .forward_tree(&dir.path().join("main.ts"), "main", 1)
3791 .unwrap();
3792
3793 assert_eq!(tree.name, "main");
3794
3795 for child in &tree.children {
3797 assert!(
3798 child.children.is_empty(),
3799 "At depth 1, child '{}' should have no children, got {:?}",
3800 child.name,
3801 child.children.len()
3802 );
3803 }
3804 }
3805
3806 #[test]
3807 fn callgraph_depth_zero_no_children() {
3808 let dir = setup_ts_project();
3809 let mut graph = CallGraph::new(dir.path().to_path_buf());
3810
3811 let tree = graph
3812 .forward_tree(&dir.path().join("main.ts"), "main", 0)
3813 .unwrap();
3814
3815 assert_eq!(tree.name, "main");
3816 assert!(
3817 tree.children.is_empty(),
3818 "At depth 0, should have no children"
3819 );
3820 }
3821
3822 #[test]
3825 fn callgraph_forward_tree_cross_file() {
3826 let dir = setup_ts_project();
3827 let mut graph = CallGraph::new(dir.path().to_path_buf());
3828
3829 let tree = graph
3831 .forward_tree(&dir.path().join("main.ts"), "main", 5)
3832 .unwrap();
3833
3834 assert_eq!(tree.name, "main");
3835 assert!(tree.resolved);
3836
3837 let helper_child = tree.children.iter().find(|c| c.name == "helper");
3839 assert!(
3840 helper_child.is_some(),
3841 "main should have helper as child, children: {:?}",
3842 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3843 );
3844
3845 let helper = helper_child.unwrap();
3846 assert!(
3847 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
3848 "helper should be in utils.ts, got: {}",
3849 helper.file
3850 );
3851
3852 let double_child = helper.children.iter().find(|c| c.name == "double");
3854 assert!(
3855 double_child.is_some(),
3856 "helper should call double, children: {:?}",
3857 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3858 );
3859
3860 let double = double_child.unwrap();
3861 assert!(
3862 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
3863 "double should be in helpers.ts, got: {}",
3864 double.file
3865 );
3866 }
3867
3868 #[test]
3871 fn callgraph_walker_excludes_gitignored() {
3872 let dir = TempDir::new().unwrap();
3873
3874 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3876
3877 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3879 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3880 fs::write(
3881 dir.path().join("ignored_dir").join("secret.ts"),
3882 "export function secret() {}",
3883 )
3884 .unwrap();
3885
3886 fs::create_dir(dir.path().join("node_modules")).unwrap();
3888 fs::write(
3889 dir.path().join("node_modules").join("dep.ts"),
3890 "export function dep() {}",
3891 )
3892 .unwrap();
3893
3894 std::process::Command::new("git")
3896 .args(["init"])
3897 .current_dir(dir.path())
3898 .output()
3899 .unwrap();
3900
3901 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3902 let file_names: Vec<String> = files
3903 .iter()
3904 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3905 .collect();
3906
3907 assert!(
3908 file_names.contains(&"main.ts".to_string()),
3909 "Should include main.ts, got: {:?}",
3910 file_names
3911 );
3912 assert!(
3913 !file_names.contains(&"secret.ts".to_string()),
3914 "Should exclude gitignored secret.ts, got: {:?}",
3915 file_names
3916 );
3917 assert!(
3918 !file_names.contains(&"dep.ts".to_string()),
3919 "Should exclude node_modules, got: {:?}",
3920 file_names
3921 );
3922 }
3923
3924 #[test]
3925 fn callgraph_walker_only_source_files() {
3926 let dir = TempDir::new().unwrap();
3927
3928 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3929 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3930 fs::write(dir.path().join("data.json"), "{}").unwrap();
3931
3932 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3933 let file_names: Vec<String> = files
3934 .iter()
3935 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3936 .collect();
3937
3938 assert!(file_names.contains(&"main.ts".to_string()));
3939 assert!(
3940 file_names.contains(&"readme.md".to_string()),
3941 "Markdown is now a supported source language"
3942 );
3943 assert!(
3944 file_names.contains(&"data.json".to_string()),
3945 "JSON is now a supported source language"
3946 );
3947 }
3948
3949 #[test]
3952 fn callgraph_find_alias_original_simple() {
3953 let raw = "import { foo as bar } from './utils';";
3954 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3955 }
3956
3957 #[test]
3958 fn callgraph_find_alias_original_multiple() {
3959 let raw = "import { foo as bar, baz as qux } from './utils';";
3960 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3961 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3962 }
3963
3964 #[test]
3965 fn callgraph_find_alias_no_match() {
3966 let raw = "import { foo } from './utils';";
3967 assert_eq!(find_alias_original(raw, "foo"), None);
3968 }
3969
3970 #[test]
3973 fn callgraph_callers_of_direct() {
3974 let dir = setup_ts_project();
3975 let mut graph = CallGraph::new(dir.path().to_path_buf());
3976
3977 let result = graph
3979 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
3980 .unwrap();
3981
3982 assert_eq!(result.symbol, "double");
3983 assert!(result.total_callers > 0, "double should have callers");
3984 assert!(result.scanned_files > 0, "should have scanned files");
3985
3986 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
3988 assert!(
3989 utils_group.is_some(),
3990 "double should be called from utils.ts, groups: {:?}",
3991 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
3992 );
3993
3994 let group = utils_group.unwrap();
3995 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
3996 assert!(
3997 helper_caller.is_some(),
3998 "double should be called by helper, callers: {:?}",
3999 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
4000 );
4001 }
4002
4003 #[test]
4004 fn callgraph_callers_of_no_callers() {
4005 let dir = setup_ts_project();
4006 let mut graph = CallGraph::new(dir.path().to_path_buf());
4007
4008 let result = graph
4010 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
4011 .unwrap();
4012
4013 assert_eq!(result.symbol, "main");
4014 assert_eq!(result.total_callers, 0, "main should have no callers");
4015 assert!(result.callers.is_empty());
4016 }
4017
4018 #[test]
4019 fn callgraph_callers_recursive_depth() {
4020 let dir = setup_ts_project();
4021 let mut graph = CallGraph::new(dir.path().to_path_buf());
4022
4023 let result = graph
4027 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
4028 .unwrap();
4029
4030 assert!(
4031 result.total_callers >= 2,
4032 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
4033 result.total_callers
4034 );
4035
4036 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
4038 assert!(
4039 main_group.is_some(),
4040 "recursive callers should include main.ts, groups: {:?}",
4041 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
4042 );
4043 }
4044
4045 #[test]
4046 fn callgraph_invalidate_file_clears_reverse_index() {
4047 let dir = setup_ts_project();
4048 let mut graph = CallGraph::new(dir.path().to_path_buf());
4049
4050 let _ = graph
4052 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
4053 .unwrap();
4054 assert!(
4055 graph.reverse_index.is_some(),
4056 "reverse index should be built"
4057 );
4058
4059 graph.invalidate_file(&dir.path().join("utils.ts"));
4061
4062 assert!(
4064 graph.reverse_index.is_none(),
4065 "invalidate_file should clear reverse index"
4066 );
4067 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
4069 assert!(
4070 !graph.data.contains_key(&canon),
4071 "invalidate_file should remove file from data cache"
4072 );
4073 assert!(
4075 graph.project_files.is_none(),
4076 "invalidate_file should clear project_files"
4077 );
4078 }
4079
4080 #[test]
4083 fn is_entry_point_exported_function() {
4084 assert!(is_entry_point(
4085 "handleRequest",
4086 &SymbolKind::Function,
4087 true,
4088 LangId::TypeScript
4089 ));
4090 }
4091
4092 #[test]
4093 fn is_entry_point_exported_method_is_not_entry() {
4094 assert!(!is_entry_point(
4096 "handleRequest",
4097 &SymbolKind::Method,
4098 true,
4099 LangId::TypeScript
4100 ));
4101 }
4102
4103 #[test]
4104 fn is_entry_point_main_init_patterns() {
4105 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
4106 assert!(
4107 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
4108 "{} should be an entry point",
4109 name
4110 );
4111 }
4112 }
4113
4114 #[test]
4115 fn is_entry_point_test_patterns_ts() {
4116 assert!(is_entry_point(
4117 "describe",
4118 &SymbolKind::Function,
4119 false,
4120 LangId::TypeScript
4121 ));
4122 assert!(is_entry_point(
4123 "it",
4124 &SymbolKind::Function,
4125 false,
4126 LangId::TypeScript
4127 ));
4128 assert!(is_entry_point(
4129 "test",
4130 &SymbolKind::Function,
4131 false,
4132 LangId::TypeScript
4133 ));
4134 assert!(is_entry_point(
4135 "testValidation",
4136 &SymbolKind::Function,
4137 false,
4138 LangId::TypeScript
4139 ));
4140 assert!(is_entry_point(
4141 "specHelper",
4142 &SymbolKind::Function,
4143 false,
4144 LangId::TypeScript
4145 ));
4146 }
4147
4148 #[test]
4149 fn is_entry_point_test_patterns_python() {
4150 assert!(is_entry_point(
4151 "test_login",
4152 &SymbolKind::Function,
4153 false,
4154 LangId::Python
4155 ));
4156 assert!(is_entry_point(
4157 "setUp",
4158 &SymbolKind::Function,
4159 false,
4160 LangId::Python
4161 ));
4162 assert!(is_entry_point(
4163 "tearDown",
4164 &SymbolKind::Function,
4165 false,
4166 LangId::Python
4167 ));
4168 assert!(!is_entry_point(
4170 "testSomething",
4171 &SymbolKind::Function,
4172 false,
4173 LangId::Python
4174 ));
4175 }
4176
4177 #[test]
4178 fn is_entry_point_test_patterns_rust() {
4179 assert!(is_entry_point(
4180 "test_parse",
4181 &SymbolKind::Function,
4182 false,
4183 LangId::Rust
4184 ));
4185 assert!(!is_entry_point(
4186 "TestSomething",
4187 &SymbolKind::Function,
4188 false,
4189 LangId::Rust
4190 ));
4191 }
4192
4193 #[test]
4194 fn is_entry_point_test_patterns_go() {
4195 assert!(is_entry_point(
4196 "TestParsing",
4197 &SymbolKind::Function,
4198 false,
4199 LangId::Go
4200 ));
4201 assert!(!is_entry_point(
4203 "testParsing",
4204 &SymbolKind::Function,
4205 false,
4206 LangId::Go
4207 ));
4208 }
4209
4210 #[test]
4211 fn is_entry_point_non_exported_non_main_is_not_entry() {
4212 assert!(!is_entry_point(
4213 "helperUtil",
4214 &SymbolKind::Function,
4215 false,
4216 LangId::TypeScript
4217 ));
4218 }
4219
4220 #[test]
4223 fn callgraph_symbol_metadata_populated() {
4224 let dir = setup_ts_project();
4225 let mut graph = CallGraph::new(dir.path().to_path_buf());
4226
4227 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4228 assert!(
4229 file_data.symbol_metadata.contains_key("helper"),
4230 "symbol_metadata should contain helper"
4231 );
4232 let meta = &file_data.symbol_metadata["helper"];
4233 assert_eq!(meta.kind, SymbolKind::Function);
4234 assert!(meta.exported, "helper should be exported");
4235 }
4236
4237 fn setup_trace_project() -> TempDir {
4253 let dir = TempDir::new().unwrap();
4254
4255 fs::write(
4256 dir.path().join("main.ts"),
4257 r#"import { processData } from './utils';
4258
4259export function main() {
4260 const result = processData("hello");
4261 return result;
4262}
4263"#,
4264 )
4265 .unwrap();
4266
4267 fs::write(
4268 dir.path().join("service.ts"),
4269 r#"import { processData } from './utils';
4270
4271export function handleRequest(input: string): string {
4272 return processData(input);
4273}
4274"#,
4275 )
4276 .unwrap();
4277
4278 fs::write(
4279 dir.path().join("utils.ts"),
4280 r#"import { validate } from './helpers';
4281
4282export function processData(input: string): string {
4283 const valid = validate(input);
4284 if (!valid) {
4285 throw new Error("invalid input");
4286 }
4287 return input.toUpperCase();
4288}
4289"#,
4290 )
4291 .unwrap();
4292
4293 fs::write(
4294 dir.path().join("helpers.ts"),
4295 r#"export function validate(input: string): boolean {
4296 return checkFormat(input);
4297}
4298
4299function checkFormat(input: string): boolean {
4300 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
4301}
4302"#,
4303 )
4304 .unwrap();
4305
4306 fs::write(
4307 dir.path().join("test_helpers.ts"),
4308 r#"import { validate } from './helpers';
4309
4310function testValidation() {
4311 const result = validate("hello");
4312 console.log(result);
4313}
4314"#,
4315 )
4316 .unwrap();
4317
4318 std::process::Command::new("git")
4320 .args(["init"])
4321 .current_dir(dir.path())
4322 .output()
4323 .unwrap();
4324
4325 dir
4326 }
4327
4328 #[test]
4329 fn trace_to_multi_path() {
4330 let dir = setup_trace_project();
4331 let mut graph = CallGraph::new(dir.path().to_path_buf());
4332
4333 let result = graph
4334 .trace_to(
4335 &dir.path().join("helpers.ts"),
4336 "checkFormat",
4337 10,
4338 usize::MAX,
4339 )
4340 .unwrap();
4341
4342 assert_eq!(result.target_symbol, "checkFormat");
4343 assert!(
4344 result.total_paths >= 2,
4345 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
4346 result.total_paths,
4347 result
4348 .paths
4349 .iter()
4350 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
4351 .collect::<Vec<_>>()
4352 );
4353
4354 for path in &result.paths {
4356 assert!(
4357 path.hops.first().unwrap().is_entry_point,
4358 "First hop should be an entry point, got: {}",
4359 path.hops.first().unwrap().symbol
4360 );
4361 assert_eq!(
4362 path.hops.last().unwrap().symbol,
4363 "checkFormat",
4364 "Last hop should be checkFormat"
4365 );
4366 }
4367
4368 assert!(
4370 result.entry_points_found >= 2,
4371 "should find at least 2 entry points, got {}",
4372 result.entry_points_found
4373 );
4374 }
4375
4376 #[test]
4377 fn trace_to_single_path() {
4378 let dir = setup_trace_project();
4379 let mut graph = CallGraph::new(dir.path().to_path_buf());
4380
4381 let result = graph
4385 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
4386 .unwrap();
4387
4388 assert_eq!(result.target_symbol, "validate");
4389 assert!(
4390 result.total_paths >= 2,
4391 "validate should have at least 2 paths, got {}",
4392 result.total_paths
4393 );
4394 }
4395
4396 #[test]
4397 fn trace_to_cycle_detection() {
4398 let dir = setup_cycle_project();
4399 let mut graph = CallGraph::new(dir.path().to_path_buf());
4400
4401 let result = graph
4403 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
4404 .unwrap();
4405
4406 assert_eq!(result.target_symbol, "funcA");
4408 }
4409
4410 #[test]
4411 fn trace_to_depth_limit() {
4412 let dir = setup_trace_project();
4413 let mut graph = CallGraph::new(dir.path().to_path_buf());
4414
4415 let result = graph
4417 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
4418 .unwrap();
4419
4420 assert_eq!(result.target_symbol, "checkFormat");
4424
4425 let deep_result = graph
4427 .trace_to(
4428 &dir.path().join("helpers.ts"),
4429 "checkFormat",
4430 10,
4431 usize::MAX,
4432 )
4433 .unwrap();
4434
4435 assert!(
4436 result.total_paths <= deep_result.total_paths,
4437 "shallow trace should find <= paths compared to deep: {} vs {}",
4438 result.total_paths,
4439 deep_result.total_paths
4440 );
4441 }
4442
4443 #[test]
4444 fn trace_to_entry_point_target() {
4445 let dir = setup_trace_project();
4446 let mut graph = CallGraph::new(dir.path().to_path_buf());
4447
4448 let result = graph
4450 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
4451 .unwrap();
4452
4453 assert_eq!(result.target_symbol, "main");
4454 assert!(
4455 result.total_paths >= 1,
4456 "main should have at least 1 path (itself), got {}",
4457 result.total_paths
4458 );
4459 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
4461 assert!(
4462 trivial.is_some(),
4463 "should have a trivial path with just the entry point itself"
4464 );
4465 }
4466
4467 #[test]
4470 fn extract_parameters_typescript() {
4471 let params = extract_parameters(
4472 "function processData(input: string, count: number): void",
4473 LangId::TypeScript,
4474 );
4475 assert_eq!(params, vec!["input", "count"]);
4476 }
4477
4478 #[test]
4479 fn extract_parameters_typescript_optional() {
4480 let params = extract_parameters(
4481 "function fetch(url: string, options?: RequestInit): Promise<Response>",
4482 LangId::TypeScript,
4483 );
4484 assert_eq!(params, vec!["url", "options"]);
4485 }
4486
4487 #[test]
4488 fn extract_parameters_typescript_defaults() {
4489 let params = extract_parameters(
4490 "function greet(name: string, greeting: string = \"hello\"): string",
4491 LangId::TypeScript,
4492 );
4493 assert_eq!(params, vec!["name", "greeting"]);
4494 }
4495
4496 #[test]
4497 fn extract_parameters_typescript_rest() {
4498 let params = extract_parameters(
4499 "function sum(...numbers: number[]): number",
4500 LangId::TypeScript,
4501 );
4502 assert_eq!(params, vec!["numbers"]);
4503 }
4504
4505 #[test]
4506 fn extract_parameters_python_self_skipped() {
4507 let params = extract_parameters(
4508 "def process(self, data: str, count: int) -> bool",
4509 LangId::Python,
4510 );
4511 assert_eq!(params, vec!["data", "count"]);
4512 }
4513
4514 #[test]
4515 fn extract_parameters_python_no_self() {
4516 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4517 assert_eq!(params, vec!["input"]);
4518 }
4519
4520 #[test]
4521 fn extract_parameters_python_star_args() {
4522 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4523 assert_eq!(params, vec!["args", "kwargs"]);
4524 }
4525
4526 #[test]
4527 fn extract_parameters_rust_self_skipped() {
4528 let params = extract_parameters(
4529 "fn process(&self, data: &str, count: usize) -> bool",
4530 LangId::Rust,
4531 );
4532 assert_eq!(params, vec!["data", "count"]);
4533 }
4534
4535 #[test]
4536 fn extract_parameters_rust_mut_self_skipped() {
4537 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4538 assert_eq!(params, vec!["value"]);
4539 }
4540
4541 #[test]
4542 fn extract_parameters_rust_no_self() {
4543 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4544 assert_eq!(params, vec!["input"]);
4545 }
4546
4547 #[test]
4548 fn extract_parameters_rust_mut_param() {
4549 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4550 assert_eq!(params, vec!["buf", "len"]);
4551 }
4552
4553 #[test]
4554 fn extract_parameters_go() {
4555 let params = extract_parameters(
4556 "func ProcessData(input string, count int) error",
4557 LangId::Go,
4558 );
4559 assert_eq!(params, vec!["input", "count"]);
4560 }
4561
4562 #[test]
4563 fn extract_parameters_empty() {
4564 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4565 assert!(
4566 params.is_empty(),
4567 "no-arg function should return empty params"
4568 );
4569 }
4570
4571 #[test]
4572 fn extract_parameters_no_parens() {
4573 let params = extract_parameters("const x = 42", LangId::TypeScript);
4574 assert!(params.is_empty(), "no parens should return empty params");
4575 }
4576
4577 #[test]
4578 fn extract_parameters_javascript() {
4579 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4580 assert_eq!(params, vec!["event", "target"]);
4581 }
4582}