1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use serde::Serialize;
12use tree_sitter::{Parser, Tree};
13
14use crate::calls::extract_calls_full;
15use crate::edit::line_col_to_byte;
16use crate::error::AftError;
17use crate::imports::{self, ImportBlock};
18use crate::language::LanguageProvider;
19use crate::parser::{detect_language, grammar_for, LangId};
20use crate::symbols::SymbolKind;
21
22type SharedPath = Arc<PathBuf>;
27type SharedStr = Arc<str>;
28type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
29
30#[derive(Debug, Clone)]
32pub struct CallSite {
33 pub callee_name: String,
35 pub full_callee: String,
37 pub line: u32,
39 pub byte_start: usize,
41 pub byte_end: usize,
42}
43
44#[derive(Debug, Clone, Serialize)]
46pub struct SymbolMeta {
47 pub kind: SymbolKind,
49 pub exported: bool,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub signature: Option<String>,
54}
55
56#[derive(Debug, Clone)]
59pub struct FileCallData {
60 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
62 pub exported_symbols: Vec<String>,
64 pub symbol_metadata: HashMap<String, SymbolMeta>,
66 pub import_block: ImportBlock,
68 pub lang: LangId,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum EdgeResolution {
75 Resolved { file: PathBuf, symbol: String },
77 Unresolved { callee_name: String },
79}
80
81#[derive(Debug, Clone, Serialize)]
83pub struct CallerSite {
84 pub caller_file: PathBuf,
86 pub caller_symbol: String,
88 pub line: u32,
90 pub col: u32,
92 pub resolved: bool,
94}
95
96#[derive(Debug, Clone)]
97struct IndexedCallerSite {
98 caller_file: SharedPath,
99 caller_symbol: SharedStr,
100 line: u32,
101 col: u32,
102 resolved: bool,
103}
104
105#[derive(Debug, Clone, Serialize)]
107pub struct CallerGroup {
108 pub file: String,
110 pub callers: Vec<CallerEntry>,
112}
113
114#[derive(Debug, Clone, Serialize)]
116pub struct CallerEntry {
117 pub symbol: String,
118 pub line: u32,
120}
121
122#[derive(Debug, Clone, Serialize)]
124pub struct CallersResult {
125 pub symbol: String,
127 pub file: String,
129 pub callers: Vec<CallerGroup>,
131 pub total_callers: usize,
133 pub scanned_files: usize,
135}
136
137#[derive(Debug, Clone, Serialize)]
139pub struct CallTreeNode {
140 pub name: String,
142 pub file: String,
144 pub line: u32,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub signature: Option<String>,
149 pub resolved: bool,
151 pub children: Vec<CallTreeNode>,
153}
154
155const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
161
162pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
169 if exported && *kind == SymbolKind::Function {
171 return true;
172 }
173
174 let lower = name.to_lowercase();
176 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
177 return true;
178 }
179
180 match lang {
182 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
183 matches!(lower.as_str(), "describe" | "it" | "test")
185 || lower.starts_with("test")
186 || lower.starts_with("spec")
187 }
188 LangId::Python => {
189 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
191 }
192 LangId::Rust => {
193 lower.starts_with("test_")
195 }
196 LangId::Go => {
197 name.starts_with("Test")
199 }
200 LangId::C
201 | LangId::Cpp
202 | LangId::Zig
203 | LangId::CSharp
204 | LangId::Bash
205 | LangId::Html
206 | LangId::Markdown => false,
207 }
208}
209
210#[derive(Debug, Clone, Serialize)]
216pub struct TraceHop {
217 pub symbol: String,
219 pub file: String,
221 pub line: u32,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub signature: Option<String>,
226 pub is_entry_point: bool,
228}
229
230#[derive(Debug, Clone, Serialize)]
232pub struct TracePath {
233 pub hops: Vec<TraceHop>,
235}
236
237#[derive(Debug, Clone, Serialize)]
239pub struct TraceToResult {
240 pub target_symbol: String,
242 pub target_file: String,
244 pub paths: Vec<TracePath>,
246 pub total_paths: usize,
248 pub entry_points_found: usize,
250 pub max_depth_reached: bool,
252 pub truncated_paths: usize,
254}
255
256#[derive(Debug, Clone, Serialize)]
262pub struct ImpactCaller {
263 pub caller_symbol: String,
265 pub caller_file: String,
267 pub line: u32,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub signature: Option<String>,
272 pub is_entry_point: bool,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub call_expression: Option<String>,
277 pub parameters: Vec<String>,
279}
280
281#[derive(Debug, Clone, Serialize)]
283pub struct ImpactResult {
284 pub symbol: String,
286 pub file: String,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub signature: Option<String>,
291 pub parameters: Vec<String>,
293 pub total_affected: usize,
295 pub affected_files: usize,
297 pub callers: Vec<ImpactCaller>,
299}
300
301#[derive(Debug, Clone, Serialize)]
307pub struct DataFlowHop {
308 pub file: String,
310 pub symbol: String,
312 pub variable: String,
314 pub line: u32,
316 pub flow_type: String,
318 pub approximate: bool,
320}
321
322#[derive(Debug, Clone, Serialize)]
325pub struct TraceDataResult {
326 pub expression: String,
328 pub origin_file: String,
330 pub origin_symbol: String,
332 pub hops: Vec<DataFlowHop>,
334 pub depth_limited: bool,
336}
337
338pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
344 let start = match signature.find('(') {
346 Some(i) => i + 1,
347 None => return Vec::new(),
348 };
349 let end = match signature[start..].find(')') {
350 Some(i) => start + i,
351 None => return Vec::new(),
352 };
353
354 let params_str = &signature[start..end].trim();
355 if params_str.is_empty() {
356 return Vec::new();
357 }
358
359 let parts = split_params(params_str);
361
362 let mut result = Vec::new();
363 for part in parts {
364 let trimmed = part.trim();
365 if trimmed.is_empty() {
366 continue;
367 }
368
369 match lang {
371 LangId::Rust => {
372 if trimmed == "self"
373 || trimmed == "mut self"
374 || trimmed.starts_with("&self")
375 || trimmed.starts_with("&mut self")
376 {
377 continue;
378 }
379 }
380 LangId::Python => {
381 if trimmed == "self" || trimmed.starts_with("self:") {
382 continue;
383 }
384 }
385 _ => {}
386 }
387
388 let name = extract_param_name(trimmed, lang);
390 if !name.is_empty() {
391 result.push(name);
392 }
393 }
394
395 result
396}
397
398fn split_params(s: &str) -> Vec<String> {
400 let mut parts = Vec::new();
401 let mut current = String::new();
402 let mut depth = 0i32;
403
404 for ch in s.chars() {
405 match ch {
406 '<' | '[' | '{' | '(' => {
407 depth += 1;
408 current.push(ch);
409 }
410 '>' | ']' | '}' | ')' => {
411 depth -= 1;
412 current.push(ch);
413 }
414 ',' if depth == 0 => {
415 parts.push(current.clone());
416 current.clear();
417 }
418 _ => {
419 current.push(ch);
420 }
421 }
422 }
423 if !current.is_empty() {
424 parts.push(current);
425 }
426 parts
427}
428
429fn extract_param_name(param: &str, lang: LangId) -> String {
437 let trimmed = param.trim();
438
439 let working = if trimmed.starts_with("...") {
441 &trimmed[3..]
442 } else if trimmed.starts_with("**") {
443 &trimmed[2..]
444 } else if trimmed.starts_with('*') && lang == LangId::Python {
445 &trimmed[1..]
446 } else {
447 trimmed
448 };
449
450 let working = if lang == LangId::Rust && working.starts_with("mut ") {
452 &working[4..]
453 } else {
454 working
455 };
456
457 let name = working
460 .split(|c: char| c == ':' || c == '=')
461 .next()
462 .unwrap_or("")
463 .trim();
464
465 let name = name.trim_end_matches('?');
467
468 if lang == LangId::Go && !name.contains(' ') {
470 return name.to_string();
471 }
472 if lang == LangId::Go {
473 return name.split_whitespace().next().unwrap_or("").to_string();
474 }
475
476 name.to_string()
477}
478
479pub struct CallGraph {
488 data: HashMap<PathBuf, FileCallData>,
490 project_root: PathBuf,
492 project_files: Option<Vec<PathBuf>>,
494 reverse_index: Option<ReverseIndex>,
497}
498
499impl CallGraph {
500 pub fn new(project_root: PathBuf) -> Self {
502 Self {
503 data: HashMap::new(),
504 project_root,
505 project_files: None,
506 reverse_index: None,
507 }
508 }
509
510 pub fn project_root(&self) -> &Path {
512 &self.project_root
513 }
514
515 fn resolve_cross_file_edge_with_exports<F>(
516 full_callee: &str,
517 short_name: &str,
518 caller_file: &Path,
519 import_block: &ImportBlock,
520 mut file_exports_symbol: F,
521 ) -> EdgeResolution
522 where
523 F: FnMut(&Path, &str) -> bool,
524 {
525 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
526
527 if full_callee.contains('.') {
529 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
530 if parts.len() == 2 {
531 let namespace = parts[0];
532 let member = parts[1];
533
534 for imp in &import_block.imports {
535 if imp.namespace_import.as_deref() == Some(namespace) {
536 if let Some(resolved_path) =
537 resolve_module_path(caller_dir, &imp.module_path)
538 {
539 return EdgeResolution::Resolved {
540 file: resolved_path,
541 symbol: member.to_owned(),
542 };
543 }
544 }
545 }
546 }
547 }
548
549 for imp in &import_block.imports {
551 if imp.names.iter().any(|name| name == short_name) {
553 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
554 return EdgeResolution::Resolved {
556 file: resolved_path,
557 symbol: short_name.to_owned(),
558 };
559 }
560 }
561
562 if imp.default_import.as_deref() == Some(short_name) {
564 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
565 return EdgeResolution::Resolved {
566 file: resolved_path,
567 symbol: "default".to_owned(),
568 };
569 }
570 }
571 }
572
573 if let Some((original_name, resolved_path)) =
578 resolve_aliased_import(short_name, import_block, caller_dir)
579 {
580 return EdgeResolution::Resolved {
581 file: resolved_path,
582 symbol: original_name,
583 };
584 }
585
586 for imp in &import_block.imports {
589 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
590 if resolved_path.is_dir() {
592 if let Some(index_path) = find_index_file(&resolved_path) {
593 if file_exports_symbol(&index_path, short_name) {
595 return EdgeResolution::Resolved {
596 file: index_path,
597 symbol: short_name.to_owned(),
598 };
599 }
600 }
601 } else if file_exports_symbol(&resolved_path, short_name) {
602 return EdgeResolution::Resolved {
603 file: resolved_path,
604 symbol: short_name.to_owned(),
605 };
606 }
607 }
608 }
609
610 EdgeResolution::Unresolved {
611 callee_name: short_name.to_owned(),
612 }
613 }
614
615 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
617 let canon = self.canonicalize(path)?;
618
619 if !self.data.contains_key(&canon) {
620 let file_data = build_file_data(&canon)?;
621 self.data.insert(canon.clone(), file_data);
622 }
623
624 Ok(&self.data[&canon])
625 }
626
627 pub fn resolve_cross_file_edge(
632 &mut self,
633 full_callee: &str,
634 short_name: &str,
635 caller_file: &Path,
636 import_block: &ImportBlock,
637 ) -> EdgeResolution {
638 Self::resolve_cross_file_edge_with_exports(
639 full_callee,
640 short_name,
641 caller_file,
642 import_block,
643 |path, symbol_name| self.file_exports_symbol(path, symbol_name),
644 )
645 }
646
647 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
649 match self.build_file(path) {
650 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
651 Err(_) => false,
652 }
653 }
654
655 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
656 self.lookup_file_data(path)
657 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
658 .unwrap_or(false)
659 }
660
661 pub fn forward_tree(
666 &mut self,
667 file: &Path,
668 symbol: &str,
669 max_depth: usize,
670 ) -> Result<CallTreeNode, AftError> {
671 let mut visited = HashSet::new();
672 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
673 }
674
675 fn forward_tree_inner(
676 &mut self,
677 file: &Path,
678 symbol: &str,
679 max_depth: usize,
680 current_depth: usize,
681 visited: &mut HashSet<(PathBuf, String)>,
682 ) -> Result<CallTreeNode, AftError> {
683 let canon = self.canonicalize(file)?;
684 let visit_key = (canon.clone(), symbol.to_string());
685
686 if visited.contains(&visit_key) {
688 let (line, signature) = get_symbol_meta(&canon, symbol);
689 return Ok(CallTreeNode {
690 name: symbol.to_string(),
691 file: self.relative_path(&canon),
692 line,
693 signature,
694 resolved: true,
695 children: vec![], });
697 }
698
699 visited.insert(visit_key.clone());
700
701 let file_data = build_file_data(&canon)?;
703 let import_block = file_data.import_block.clone();
704 let _lang = file_data.lang;
705
706 let call_sites = file_data
708 .calls_by_symbol
709 .get(symbol)
710 .cloned()
711 .unwrap_or_default();
712
713 let (sym_line, sym_signature) = get_symbol_meta(&canon, symbol);
715
716 self.data.insert(canon.clone(), file_data);
718
719 let mut children = Vec::new();
721
722 if current_depth < max_depth {
723 for call_site in &call_sites {
724 let edge = self.resolve_cross_file_edge(
725 &call_site.full_callee,
726 &call_site.callee_name,
727 &canon,
728 &import_block,
729 );
730
731 match edge {
732 EdgeResolution::Resolved {
733 file: ref target_file,
734 ref symbol,
735 } => {
736 match self.forward_tree_inner(
737 target_file,
738 symbol,
739 max_depth,
740 current_depth + 1,
741 visited,
742 ) {
743 Ok(child) => children.push(child),
744 Err(_) => {
745 children.push(CallTreeNode {
747 name: call_site.callee_name.clone(),
748 file: self.relative_path(target_file),
749 line: call_site.line,
750 signature: None,
751 resolved: false,
752 children: vec![],
753 });
754 }
755 }
756 }
757 EdgeResolution::Unresolved { callee_name } => {
758 children.push(CallTreeNode {
759 name: callee_name,
760 file: self.relative_path(&canon),
761 line: call_site.line,
762 signature: None,
763 resolved: false,
764 children: vec![],
765 });
766 }
767 }
768 }
769 }
770
771 visited.remove(&visit_key);
772
773 Ok(CallTreeNode {
774 name: symbol.to_string(),
775 file: self.relative_path(&canon),
776 line: sym_line,
777 signature: sym_signature,
778 resolved: true,
779 children,
780 })
781 }
782
783 pub fn project_files(&mut self) -> &[PathBuf] {
785 if self.project_files.is_none() {
786 let project_root = self.project_root.clone();
787 self.project_files = Some(walk_project_files(&project_root).collect());
788 }
789 self.project_files.as_deref().unwrap_or(&[])
790 }
791
792 pub fn project_file_count(&mut self) -> usize {
798 self.project_files().len()
799 }
800
801 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
812 if let Some(files) = self.project_files.as_deref() {
813 return files.len();
814 }
815 walk_project_files(&self.project_root)
816 .take(limit.saturating_add(1))
817 .count()
818 }
819
820 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
826 let count = self.project_file_count_bounded(max_files);
831 if count > max_files {
832 return Err(AftError::ProjectTooLarge {
833 count,
834 max: max_files,
835 });
836 }
837
838 let all_files = self.project_files().to_vec();
842
843 for f in &all_files {
845 let _ = self.build_file(f);
846 }
847
848 let mut reverse: ReverseIndex = HashMap::new();
850
851 for caller_file in &all_files {
852 let canon_caller = Arc::new(
854 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
855 );
856 let file_data = match self
857 .data
858 .get(caller_file)
859 .or_else(|| self.data.get(canon_caller.as_ref()))
860 {
861 Some(d) => d,
862 None => continue,
863 };
864
865 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
866 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
867
868 for call_site in call_sites {
869 let edge = Self::resolve_cross_file_edge_with_exports(
870 &call_site.full_callee,
871 &call_site.callee_name,
872 canon_caller.as_ref(),
873 &file_data.import_block,
874 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
875 );
876
877 let (target_file, target_symbol, resolved) = match edge {
878 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
879 EdgeResolution::Unresolved { callee_name } => {
880 (canon_caller.as_ref().clone(), callee_name, false)
881 }
882 };
883
884 reverse
885 .entry(target_file)
886 .or_default()
887 .entry(target_symbol)
888 .or_default()
889 .push(IndexedCallerSite {
890 caller_file: Arc::clone(&canon_caller),
891 caller_symbol: Arc::clone(&caller_symbol),
892 line: call_site.line,
893 col: 0,
894 resolved,
895 });
896 }
897 }
898 }
899
900 self.reverse_index = Some(reverse);
901 Ok(())
902 }
903
904 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
905 self.reverse_index
906 .as_ref()?
907 .get(file)?
908 .get(symbol)
909 .map(Vec::as_slice)
910 }
911
912 pub fn callers_of(
918 &mut self,
919 file: &Path,
920 symbol: &str,
921 depth: usize,
922 max_files: usize,
923 ) -> Result<CallersResult, AftError> {
924 let canon = self.canonicalize(file)?;
925
926 self.build_file(&canon)?;
928
929 if self.reverse_index.is_none() {
931 self.build_reverse_index(max_files)?;
932 }
933
934 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
935 let effective_depth = if depth == 0 { 1 } else { depth };
936
937 let mut visited = HashSet::new();
938 let mut all_sites: Vec<CallerSite> = Vec::new();
939 self.collect_callers_recursive(
940 &canon,
941 symbol,
942 effective_depth,
943 0,
944 &mut visited,
945 &mut all_sites,
946 );
947
948 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
951 let total_callers = all_sites.len();
952 for site in all_sites {
953 let caller_file: PathBuf = site.caller_file;
954 let caller_symbol: String = site.caller_symbol;
955 let line = site.line;
956 let entry = CallerEntry {
957 symbol: caller_symbol,
958 line,
959 };
960
961 if let Some(entries) = groups_map.get_mut(&caller_file) {
962 entries.push(entry);
963 } else {
964 groups_map.insert(caller_file, vec![entry]);
965 }
966 }
967
968 let mut callers: Vec<CallerGroup> = groups_map
969 .into_iter()
970 .map(|(file_path, entries)| CallerGroup {
971 file: self.relative_path(&file_path),
972 callers: entries,
973 })
974 .collect();
975
976 callers.sort_by(|a, b| a.file.cmp(&b.file));
978
979 Ok(CallersResult {
980 symbol: symbol.to_string(),
981 file: self.relative_path(&canon),
982 callers,
983 total_callers,
984 scanned_files,
985 })
986 }
987
988 pub fn trace_to(
994 &mut self,
995 file: &Path,
996 symbol: &str,
997 max_depth: usize,
998 max_files: usize,
999 ) -> Result<TraceToResult, AftError> {
1000 let canon = self.canonicalize(file)?;
1001
1002 self.build_file(&canon)?;
1004
1005 if self.reverse_index.is_none() {
1007 self.build_reverse_index(max_files)?;
1008 }
1009
1010 let target_rel = self.relative_path(&canon);
1011 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1012 if self.reverse_index.is_none() {
1013 return Err(AftError::ParseError {
1014 message: format!(
1015 "reverse index unavailable after building callers for {}",
1016 canon.display()
1017 ),
1018 });
1019 }
1020
1021 let (target_line, target_sig) = get_symbol_meta(&canon, symbol);
1023
1024 let target_is_entry = self
1026 .lookup_file_data(&canon)
1027 .and_then(|fd| {
1028 let meta = fd.symbol_metadata.get(symbol)?;
1029 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1030 })
1031 .unwrap_or(false);
1032
1033 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1036 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1037 let mut max_depth_reached = false;
1038 let mut truncated_paths: usize = 0;
1039
1040 let initial: Vec<PathElem> = vec![(
1042 Arc::new(canon.clone()),
1043 Arc::from(symbol),
1044 target_line,
1045 target_sig,
1046 )];
1047
1048 if target_is_entry {
1050 complete_paths.push(initial.clone());
1051 }
1052
1053 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1055
1056 while let Some((path, depth)) = queue.pop() {
1057 if depth >= effective_max {
1058 max_depth_reached = true;
1059 continue;
1060 }
1061
1062 let Some((current_file, current_symbol, _, _)) = path.last() else {
1063 continue;
1064 };
1065
1066 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1068 Some(sites) => sites,
1069 None => {
1070 if path.len() > 1 {
1073 truncated_paths += 1;
1076 }
1077 continue;
1078 }
1079 };
1080
1081 let mut has_new_path = false;
1082 for site in callers {
1083 if path.iter().any(|(file_path, sym, _, _)| {
1085 file_path.as_ref() == site.caller_file.as_ref()
1086 && sym.as_ref() == site.caller_symbol.as_ref()
1087 }) {
1088 continue;
1089 }
1090
1091 has_new_path = true;
1092
1093 let (caller_line, caller_sig) =
1095 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref());
1096
1097 let mut new_path = path.clone();
1098 new_path.push((
1099 Arc::clone(&site.caller_file),
1100 Arc::clone(&site.caller_symbol),
1101 caller_line,
1102 caller_sig,
1103 ));
1104
1105 let caller_is_entry = self
1109 .lookup_file_data(site.caller_file.as_ref())
1110 .and_then(|fd| {
1111 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1112 Some(is_entry_point(
1113 site.caller_symbol.as_ref(),
1114 &meta.kind,
1115 meta.exported,
1116 fd.lang,
1117 ))
1118 })
1119 .unwrap_or(false);
1120
1121 if caller_is_entry {
1122 complete_paths.push(new_path.clone());
1123 }
1124 queue.push((new_path, depth + 1));
1127 }
1128
1129 if !has_new_path && path.len() > 1 {
1131 truncated_paths += 1;
1132 }
1133 }
1134
1135 let mut paths: Vec<TracePath> = complete_paths
1138 .into_iter()
1139 .map(|mut elems| {
1140 elems.reverse();
1141 let hops: Vec<TraceHop> = elems
1142 .iter()
1143 .enumerate()
1144 .map(|(i, (file_path, sym, line, sig))| {
1145 let is_ep = if i == 0 {
1146 self.lookup_file_data(file_path.as_ref())
1148 .and_then(|fd| {
1149 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1150 Some(is_entry_point(
1151 sym.as_ref(),
1152 &meta.kind,
1153 meta.exported,
1154 fd.lang,
1155 ))
1156 })
1157 .unwrap_or(false)
1158 } else {
1159 false
1160 };
1161 TraceHop {
1162 symbol: sym.to_string(),
1163 file: self.relative_path(file_path.as_ref()),
1164 line: *line,
1165 signature: sig.clone(),
1166 is_entry_point: is_ep,
1167 }
1168 })
1169 .collect();
1170 TracePath { hops }
1171 })
1172 .collect();
1173
1174 paths.sort_by(|a, b| {
1176 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1177 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1178 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1179 });
1180
1181 let mut entry_point_names: HashSet<String> = HashSet::new();
1183 for p in &paths {
1184 if let Some(first) = p.hops.first() {
1185 if first.is_entry_point {
1186 entry_point_names.insert(first.symbol.clone());
1187 }
1188 }
1189 }
1190
1191 let total_paths = paths.len();
1192 let entry_points_found = entry_point_names.len();
1193
1194 Ok(TraceToResult {
1195 target_symbol: symbol.to_string(),
1196 target_file: target_rel,
1197 paths,
1198 total_paths,
1199 entry_points_found,
1200 max_depth_reached,
1201 truncated_paths,
1202 })
1203 }
1204
1205 pub fn impact(
1211 &mut self,
1212 file: &Path,
1213 symbol: &str,
1214 depth: usize,
1215 max_files: usize,
1216 ) -> Result<ImpactResult, AftError> {
1217 let canon = self.canonicalize(file)?;
1218
1219 self.build_file(&canon)?;
1221
1222 if self.reverse_index.is_none() {
1224 self.build_reverse_index(max_files)?;
1225 }
1226
1227 let effective_depth = if depth == 0 { 1 } else { depth };
1228
1229 let (target_signature, target_parameters, target_lang) = {
1231 let file_data = match self.data.get(&canon) {
1232 Some(d) => d,
1233 None => {
1234 return Err(AftError::InvalidRequest {
1235 message: "file data missing after build".to_string(),
1236 })
1237 }
1238 };
1239 let meta = file_data.symbol_metadata.get(symbol);
1240 let sig = meta.and_then(|m| m.signature.clone());
1241 let lang = file_data.lang;
1242 let params = sig
1243 .as_deref()
1244 .map(|s| extract_parameters(s, lang))
1245 .unwrap_or_default();
1246 (sig, params, lang)
1247 };
1248
1249 let mut visited = HashSet::new();
1251 let mut all_sites: Vec<CallerSite> = Vec::new();
1252 self.collect_callers_recursive(
1253 &canon,
1254 symbol,
1255 effective_depth,
1256 0,
1257 &mut visited,
1258 &mut all_sites,
1259 );
1260
1261 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1263 all_sites.retain(|site| {
1264 seen.insert((
1265 site.caller_file.clone(),
1266 site.caller_symbol.clone(),
1267 site.line,
1268 ))
1269 });
1270
1271 let mut callers = Vec::new();
1273 let mut affected_file_set = HashSet::new();
1274
1275 for site in &all_sites {
1276 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1278 log::debug!(
1279 "callgraph: skipping caller file {}: {}",
1280 site.caller_file.display(),
1281 e
1282 );
1283 }
1284
1285 let (sig, is_ep, params, _lang) = {
1286 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1287 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1288 let sig = meta.and_then(|m| m.signature.clone());
1289 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1290 let exported = meta.map(|m| m.exported).unwrap_or(false);
1291 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1292 let lang = fd.lang;
1293 let params = sig
1294 .as_deref()
1295 .map(|s| extract_parameters(s, lang))
1296 .unwrap_or_default();
1297 (sig, is_ep, params, lang)
1298 } else {
1299 (None, false, Vec::new(), target_lang)
1300 }
1301 };
1302
1303 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1305
1306 let rel_file = self.relative_path(site.caller_file.as_path());
1307 affected_file_set.insert(rel_file.clone());
1308
1309 callers.push(ImpactCaller {
1310 caller_symbol: site.caller_symbol.clone(),
1311 caller_file: rel_file,
1312 line: site.line,
1313 signature: sig,
1314 is_entry_point: is_ep,
1315 call_expression,
1316 parameters: params,
1317 });
1318 }
1319
1320 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1322
1323 let total_affected = callers.len();
1324 let affected_files = affected_file_set.len();
1325
1326 Ok(ImpactResult {
1327 symbol: symbol.to_string(),
1328 file: self.relative_path(&canon),
1329 signature: target_signature,
1330 parameters: target_parameters,
1331 total_affected,
1332 affected_files,
1333 callers,
1334 })
1335 }
1336
1337 pub fn trace_data(
1348 &mut self,
1349 file: &Path,
1350 symbol: &str,
1351 expression: &str,
1352 max_depth: usize,
1353 max_files: usize,
1354 ) -> Result<TraceDataResult, AftError> {
1355 let canon = self.canonicalize(file)?;
1356 let rel_file = self.relative_path(&canon);
1357
1358 self.build_file(&canon)?;
1360
1361 {
1363 let fd = match self.data.get(&canon) {
1364 Some(d) => d,
1365 None => {
1366 return Err(AftError::InvalidRequest {
1367 message: "file data missing after build".to_string(),
1368 })
1369 }
1370 };
1371 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1372 || fd.exported_symbols.iter().any(|name| name == symbol)
1373 || fd.symbol_metadata.contains_key(symbol);
1374 if !has_symbol {
1375 return Err(AftError::InvalidRequest {
1376 message: format!(
1377 "trace_data: symbol '{}' not found in {}",
1378 symbol,
1379 file.display()
1380 ),
1381 });
1382 }
1383 }
1384
1385 let count = self.project_file_count_bounded(max_files);
1389 if count > max_files {
1390 return Err(AftError::ProjectTooLarge {
1391 count,
1392 max: max_files,
1393 });
1394 }
1395
1396 let mut hops = Vec::new();
1397 let mut depth_limited = false;
1398
1399 self.trace_data_inner(
1400 &canon,
1401 symbol,
1402 expression,
1403 max_depth,
1404 0,
1405 &mut hops,
1406 &mut depth_limited,
1407 &mut HashSet::new(),
1408 );
1409
1410 Ok(TraceDataResult {
1411 expression: expression.to_string(),
1412 origin_file: rel_file,
1413 origin_symbol: symbol.to_string(),
1414 hops,
1415 depth_limited,
1416 })
1417 }
1418
1419 fn trace_data_inner(
1421 &mut self,
1422 file: &Path,
1423 symbol: &str,
1424 tracking_name: &str,
1425 max_depth: usize,
1426 current_depth: usize,
1427 hops: &mut Vec<DataFlowHop>,
1428 depth_limited: &mut bool,
1429 visited: &mut HashSet<(PathBuf, String, String)>,
1430 ) {
1431 let visit_key = (
1432 file.to_path_buf(),
1433 symbol.to_string(),
1434 tracking_name.to_string(),
1435 );
1436 if visited.contains(&visit_key) {
1437 return; }
1439 visited.insert(visit_key);
1440
1441 let source = match std::fs::read_to_string(file) {
1443 Ok(s) => s,
1444 Err(_) => return,
1445 };
1446
1447 let lang = match detect_language(file) {
1448 Some(l) => l,
1449 None => return,
1450 };
1451
1452 let grammar = grammar_for(lang);
1453 let mut parser = Parser::new();
1454 if parser.set_language(&grammar).is_err() {
1455 return;
1456 }
1457 let tree = match parser.parse(&source, None) {
1458 Some(t) => t,
1459 None => return,
1460 };
1461
1462 let symbols = list_symbols_from_tree(&source, &tree, lang, file);
1464 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1465 Some(s) => s,
1466 None => return,
1467 };
1468
1469 let body_start = line_col_to_byte(&source, sym_info.start_line, sym_info.start_col);
1470 let body_end = line_col_to_byte(&source, sym_info.end_line, sym_info.end_col);
1471
1472 let root = tree.root_node();
1473
1474 let body_node = match find_node_covering_range(root, body_start, body_end) {
1476 Some(n) => n,
1477 None => return,
1478 };
1479
1480 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1482 let rel_file = self.relative_path(file);
1483
1484 self.walk_for_data_flow(
1486 body_node,
1487 &source,
1488 &mut tracked_names,
1489 file,
1490 symbol,
1491 &rel_file,
1492 lang,
1493 max_depth,
1494 current_depth,
1495 hops,
1496 depth_limited,
1497 visited,
1498 );
1499 }
1500
1501 #[allow(clippy::too_many_arguments)]
1504 fn walk_for_data_flow(
1505 &mut self,
1506 node: tree_sitter::Node,
1507 source: &str,
1508 tracked_names: &mut Vec<String>,
1509 file: &Path,
1510 symbol: &str,
1511 rel_file: &str,
1512 lang: LangId,
1513 max_depth: usize,
1514 current_depth: usize,
1515 hops: &mut Vec<DataFlowHop>,
1516 depth_limited: &mut bool,
1517 visited: &mut HashSet<(PathBuf, String, String)>,
1518 ) {
1519 let kind = node.kind();
1520
1521 let is_var_decl = matches!(
1523 kind,
1524 "variable_declarator"
1525 | "assignment_expression"
1526 | "augmented_assignment_expression"
1527 | "assignment"
1528 | "let_declaration"
1529 | "short_var_declaration"
1530 );
1531
1532 if is_var_decl {
1533 if let Some((new_name, init_text, line, is_approx)) =
1534 self.extract_assignment_info(node, source, lang, tracked_names)
1535 {
1536 if !is_approx {
1538 hops.push(DataFlowHop {
1539 file: rel_file.to_string(),
1540 symbol: symbol.to_string(),
1541 variable: new_name.clone(),
1542 line,
1543 flow_type: "assignment".to_string(),
1544 approximate: false,
1545 });
1546 tracked_names.push(new_name);
1547 } else {
1548 hops.push(DataFlowHop {
1550 file: rel_file.to_string(),
1551 symbol: symbol.to_string(),
1552 variable: init_text,
1553 line,
1554 flow_type: "assignment".to_string(),
1555 approximate: true,
1556 });
1557 return;
1559 }
1560 }
1561 }
1562
1563 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1565 self.check_call_for_data_flow(
1566 node,
1567 source,
1568 tracked_names,
1569 file,
1570 symbol,
1571 rel_file,
1572 lang,
1573 max_depth,
1574 current_depth,
1575 hops,
1576 depth_limited,
1577 visited,
1578 );
1579 }
1580
1581 let mut cursor = node.walk();
1583 if cursor.goto_first_child() {
1584 loop {
1585 let child = cursor.node();
1586 self.walk_for_data_flow(
1588 child,
1589 source,
1590 tracked_names,
1591 file,
1592 symbol,
1593 rel_file,
1594 lang,
1595 max_depth,
1596 current_depth,
1597 hops,
1598 depth_limited,
1599 visited,
1600 );
1601 if !cursor.goto_next_sibling() {
1602 break;
1603 }
1604 }
1605 }
1606 }
1607
1608 fn extract_assignment_info(
1611 &self,
1612 node: tree_sitter::Node,
1613 source: &str,
1614 _lang: LangId,
1615 tracked_names: &[String],
1616 ) -> Option<(String, String, u32, bool)> {
1617 let kind = node.kind();
1618 let line = node.start_position().row as u32 + 1;
1619
1620 match kind {
1621 "variable_declarator" => {
1622 let name_node = node.child_by_field_name("name")?;
1624 let value_node = node.child_by_field_name("value")?;
1625 let name_text = node_text(name_node, source);
1626 let value_text = node_text(value_node, source);
1627
1628 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1630 if tracked_names.iter().any(|t| value_text.contains(t)) {
1632 return Some((name_text.clone(), name_text, line, true));
1633 }
1634 return None;
1635 }
1636
1637 if tracked_names.iter().any(|t| {
1639 value_text == *t
1640 || value_text.starts_with(&format!("{}.", t))
1641 || value_text.starts_with(&format!("{}[", t))
1642 }) {
1643 return Some((name_text, value_text, line, false));
1644 }
1645 None
1646 }
1647 "assignment_expression" | "augmented_assignment_expression" => {
1648 let left = node.child_by_field_name("left")?;
1650 let right = node.child_by_field_name("right")?;
1651 let left_text = node_text(left, source);
1652 let right_text = node_text(right, source);
1653
1654 if tracked_names.iter().any(|t| right_text == *t) {
1655 return Some((left_text, right_text, line, false));
1656 }
1657 None
1658 }
1659 "assignment" => {
1660 let left = node.child_by_field_name("left")?;
1662 let right = node.child_by_field_name("right")?;
1663 let left_text = node_text(left, source);
1664 let right_text = node_text(right, source);
1665
1666 if tracked_names.iter().any(|t| right_text == *t) {
1667 return Some((left_text, right_text, line, false));
1668 }
1669 None
1670 }
1671 "let_declaration" | "short_var_declaration" => {
1672 let left = node
1674 .child_by_field_name("pattern")
1675 .or_else(|| node.child_by_field_name("left"))?;
1676 let right = node
1677 .child_by_field_name("value")
1678 .or_else(|| node.child_by_field_name("right"))?;
1679 let left_text = node_text(left, source);
1680 let right_text = node_text(right, source);
1681
1682 if tracked_names.iter().any(|t| right_text == *t) {
1683 return Some((left_text, right_text, line, false));
1684 }
1685 None
1686 }
1687 _ => None,
1688 }
1689 }
1690
1691 #[allow(clippy::too_many_arguments)]
1694 fn check_call_for_data_flow(
1695 &mut self,
1696 node: tree_sitter::Node,
1697 source: &str,
1698 tracked_names: &[String],
1699 file: &Path,
1700 _symbol: &str,
1701 rel_file: &str,
1702 _lang: LangId,
1703 max_depth: usize,
1704 current_depth: usize,
1705 hops: &mut Vec<DataFlowHop>,
1706 depth_limited: &mut bool,
1707 visited: &mut HashSet<(PathBuf, String, String)>,
1708 ) {
1709 let args_node = find_child_by_kind(node, "arguments")
1711 .or_else(|| find_child_by_kind(node, "argument_list"));
1712
1713 let args_node = match args_node {
1714 Some(n) => n,
1715 None => return,
1716 };
1717
1718 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1721
1722 let mut cursor = args_node.walk();
1723 if cursor.goto_first_child() {
1724 loop {
1725 let child = cursor.node();
1726 let child_kind = child.kind();
1727
1728 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1730 if !cursor.goto_next_sibling() {
1731 break;
1732 }
1733 continue;
1734 }
1735
1736 let arg_text = node_text(child, source);
1737
1738 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1740 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1741 hops.push(DataFlowHop {
1742 file: rel_file.to_string(),
1743 symbol: _symbol.to_string(),
1744 variable: arg_text,
1745 line: child.start_position().row as u32 + 1,
1746 flow_type: "parameter".to_string(),
1747 approximate: true,
1748 });
1749 }
1750 if !cursor.goto_next_sibling() {
1751 break;
1752 }
1753 arg_idx += 1;
1754 continue;
1755 }
1756
1757 if tracked_names.iter().any(|t| arg_text == *t) {
1758 arg_positions.push((arg_idx, arg_text));
1759 }
1760
1761 arg_idx += 1;
1762 if !cursor.goto_next_sibling() {
1763 break;
1764 }
1765 }
1766 }
1767
1768 if arg_positions.is_empty() {
1769 return;
1770 }
1771
1772 let (full_callee, short_callee) = extract_callee_names(node, source);
1774 let full_callee = match full_callee {
1775 Some(f) => f,
1776 None => return,
1777 };
1778 let short_callee = match short_callee {
1779 Some(s) => s,
1780 None => return,
1781 };
1782
1783 let import_block = {
1785 match self.data.get(file) {
1786 Some(fd) => fd.import_block.clone(),
1787 None => return,
1788 }
1789 };
1790
1791 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1792
1793 match edge {
1794 EdgeResolution::Resolved {
1795 file: target_file,
1796 symbol: target_symbol,
1797 } => {
1798 if current_depth + 1 > max_depth {
1799 *depth_limited = true;
1800 return;
1801 }
1802
1803 if let Err(e) = self.build_file(&target_file) {
1805 log::debug!(
1806 "callgraph: skipping target file {}: {}",
1807 target_file.display(),
1808 e
1809 );
1810 }
1811 let (params, _target_lang) = {
1812 match self.data.get(&target_file) {
1813 Some(fd) => {
1814 let meta = fd.symbol_metadata.get(&target_symbol);
1815 let sig = meta.and_then(|m| m.signature.clone());
1816 let params = sig
1817 .as_deref()
1818 .map(|s| extract_parameters(s, fd.lang))
1819 .unwrap_or_default();
1820 (params, fd.lang)
1821 }
1822 None => return,
1823 }
1824 };
1825
1826 let target_rel = self.relative_path(&target_file);
1827
1828 for (pos, _tracked) in &arg_positions {
1829 if let Some(param_name) = params.get(*pos) {
1830 hops.push(DataFlowHop {
1832 file: target_rel.clone(),
1833 symbol: target_symbol.clone(),
1834 variable: param_name.clone(),
1835 line: get_symbol_meta(&target_file, &target_symbol).0,
1836 flow_type: "parameter".to_string(),
1837 approximate: false,
1838 });
1839
1840 self.trace_data_inner(
1842 &target_file.clone(),
1843 &target_symbol.clone(),
1844 param_name,
1845 max_depth,
1846 current_depth + 1,
1847 hops,
1848 depth_limited,
1849 visited,
1850 );
1851 }
1852 }
1853 }
1854 EdgeResolution::Unresolved { callee_name } => {
1855 let has_local = self
1857 .data
1858 .get(file)
1859 .map(|fd| {
1860 fd.calls_by_symbol.contains_key(&callee_name)
1861 || fd.symbol_metadata.contains_key(&callee_name)
1862 })
1863 .unwrap_or(false);
1864
1865 if has_local {
1866 let (params, _target_lang) = {
1868 let Some(fd) = self.data.get(file) else {
1869 return;
1870 };
1871 let meta = fd.symbol_metadata.get(&callee_name);
1872 let sig = meta.and_then(|m| m.signature.clone());
1873 let params = sig
1874 .as_deref()
1875 .map(|s| extract_parameters(s, fd.lang))
1876 .unwrap_or_default();
1877 (params, fd.lang)
1878 };
1879
1880 let file_rel = self.relative_path(file);
1881
1882 for (pos, _tracked) in &arg_positions {
1883 if let Some(param_name) = params.get(*pos) {
1884 hops.push(DataFlowHop {
1885 file: file_rel.clone(),
1886 symbol: callee_name.clone(),
1887 variable: param_name.clone(),
1888 line: get_symbol_meta(file, &callee_name).0,
1889 flow_type: "parameter".to_string(),
1890 approximate: false,
1891 });
1892
1893 self.trace_data_inner(
1895 file,
1896 &callee_name.clone(),
1897 param_name,
1898 max_depth,
1899 current_depth + 1,
1900 hops,
1901 depth_limited,
1902 visited,
1903 );
1904 }
1905 }
1906 } else {
1907 for (_pos, tracked) in &arg_positions {
1909 hops.push(DataFlowHop {
1910 file: self.relative_path(file),
1911 symbol: callee_name.clone(),
1912 variable: tracked.clone(),
1913 line: node.start_position().row as u32 + 1,
1914 flow_type: "parameter".to_string(),
1915 approximate: true,
1916 });
1917 }
1918 }
1919 }
1920 }
1921 }
1922
1923 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1925 let content = std::fs::read_to_string(path).ok()?;
1926 content
1927 .lines()
1928 .nth(line.saturating_sub(1) as usize)
1929 .map(|l| l.trim().to_string())
1930 }
1931
1932 fn collect_callers_recursive(
1934 &self,
1935 file: &Path,
1936 symbol: &str,
1937 max_depth: usize,
1938 current_depth: usize,
1939 visited: &mut HashSet<(PathBuf, SharedStr)>,
1940 result: &mut Vec<CallerSite>,
1941 ) {
1942 if current_depth >= max_depth {
1943 return;
1944 }
1945
1946 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1948 let key_symbol: SharedStr = Arc::from(symbol);
1949 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
1950 return; }
1952
1953 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
1954 for site in sites {
1955 result.push(CallerSite {
1956 caller_file: site.caller_file.as_ref().clone(),
1957 caller_symbol: site.caller_symbol.to_string(),
1958 line: site.line,
1959 col: site.col,
1960 resolved: site.resolved,
1961 });
1962 if current_depth + 1 < max_depth {
1964 self.collect_callers_recursive(
1965 site.caller_file.as_ref(),
1966 site.caller_symbol.as_ref(),
1967 max_depth,
1968 current_depth + 1,
1969 visited,
1970 result,
1971 );
1972 }
1973 }
1974 }
1975 }
1976
1977 pub fn invalidate_file(&mut self, path: &Path) {
1982 self.data.remove(path);
1984 if let Ok(canon) = self.canonicalize(path) {
1985 self.data.remove(&canon);
1986 }
1987 self.reverse_index = None;
1989 self.project_files = None;
1991 }
1992
1993 fn relative_path(&self, path: &Path) -> String {
1996 path.strip_prefix(&self.project_root)
1997 .unwrap_or(path)
1998 .display()
1999 .to_string()
2000 }
2001
2002 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2004 let full_path = if path.is_relative() {
2006 self.project_root.join(path)
2007 } else {
2008 path.to_path_buf()
2009 };
2010
2011 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2013 }
2014
2015 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2019 if let Some(fd) = self.data.get(path) {
2020 return Some(fd);
2021 }
2022 let canon = std::fs::canonicalize(path).ok()?;
2024 self.data.get(&canon).or_else(|| {
2025 self.data.iter().find_map(|(k, v)| {
2027 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2028 Some(v)
2029 } else {
2030 None
2031 }
2032 })
2033 })
2034 }
2035}
2036
2037fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2043 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2044 message: format!("unsupported file for call graph: {}", path.display()),
2045 })?;
2046
2047 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2048 path: format!("{}: {}", path.display(), e),
2049 })?;
2050
2051 let grammar = grammar_for(lang);
2052 let mut parser = Parser::new();
2053 parser
2054 .set_language(&grammar)
2055 .map_err(|e| AftError::ParseError {
2056 message: format!("grammar init failed for {:?}: {}", lang, e),
2057 })?;
2058
2059 let tree = parser
2060 .parse(&source, None)
2061 .ok_or_else(|| AftError::ParseError {
2062 message: format!("parse failed for {}", path.display()),
2063 })?;
2064
2065 let import_block = imports::parse_imports(&source, &tree, lang);
2067
2068 let symbols = list_symbols_from_tree(&source, &tree, lang, path);
2070
2071 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2073 let root = tree.root_node();
2074
2075 for sym in &symbols {
2076 let byte_start = line_col_to_byte(&source, sym.start_line, sym.start_col);
2077 let byte_end = line_col_to_byte(&source, sym.end_line, sym.end_col);
2078
2079 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2080
2081 let sites: Vec<CallSite> = raw_calls
2082 .into_iter()
2083 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
2085 callee_name: short,
2086 full_callee: full,
2087 line,
2088 byte_start,
2089 byte_end,
2090 })
2091 .collect();
2092
2093 if !sites.is_empty() {
2094 calls_by_symbol.insert(sym.name.clone(), sites);
2095 }
2096 }
2097
2098 let exported_symbols: Vec<String> = symbols
2100 .iter()
2101 .filter(|s| s.exported)
2102 .map(|s| s.name.clone())
2103 .collect();
2104
2105 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
2107 .iter()
2108 .map(|s| {
2109 (
2110 s.name.clone(),
2111 SymbolMeta {
2112 kind: s.kind.clone(),
2113 exported: s.exported,
2114 signature: s.signature.clone(),
2115 },
2116 )
2117 })
2118 .collect();
2119
2120 Ok(FileCallData {
2121 calls_by_symbol,
2122 exported_symbols,
2123 symbol_metadata,
2124 import_block,
2125 lang,
2126 })
2127}
2128
2129#[derive(Debug)]
2131#[allow(dead_code)]
2132struct SymbolInfo {
2133 name: String,
2134 kind: SymbolKind,
2135 start_line: u32,
2136 start_col: u32,
2137 end_line: u32,
2138 end_col: u32,
2139 exported: bool,
2140 signature: Option<String>,
2141}
2142
2143fn list_symbols_from_tree(
2146 _source: &str,
2147 _tree: &Tree,
2148 _lang: LangId,
2149 path: &Path,
2150) -> Vec<SymbolInfo> {
2151 let mut file_parser = crate::parser::FileParser::new();
2153 match file_parser.parse(path) {
2154 Ok(_) => {}
2155 Err(_) => return vec![],
2156 }
2157
2158 let provider = crate::parser::TreeSitterProvider::new();
2160 match provider.list_symbols(path) {
2161 Ok(symbols) => symbols
2162 .into_iter()
2163 .map(|s| SymbolInfo {
2164 name: s.name,
2165 kind: s.kind,
2166 start_line: s.range.start_line,
2167 start_col: s.range.start_col,
2168 end_line: s.range.end_line,
2169 end_col: s.range.end_col,
2170 exported: s.exported,
2171 signature: s.signature,
2172 })
2173 .collect(),
2174 Err(_) => vec![],
2175 }
2176}
2177
2178fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2180 let provider = crate::parser::TreeSitterProvider::new();
2181 match provider.list_symbols(path) {
2182 Ok(symbols) => {
2183 for s in &symbols {
2184 if s.name == symbol_name {
2185 return (s.range.start_line + 1, s.signature.clone());
2186 }
2187 }
2188 (1, None)
2189 }
2190 Err(_) => (1, None),
2191 }
2192}
2193
2194fn node_text(node: tree_sitter::Node, source: &str) -> String {
2200 source[node.start_byte()..node.end_byte()].to_string()
2201}
2202
2203fn find_node_covering_range(
2205 root: tree_sitter::Node,
2206 start: usize,
2207 end: usize,
2208) -> Option<tree_sitter::Node> {
2209 let mut best = None;
2210 let mut cursor = root.walk();
2211
2212 fn walk_covering<'a>(
2213 cursor: &mut tree_sitter::TreeCursor<'a>,
2214 start: usize,
2215 end: usize,
2216 best: &mut Option<tree_sitter::Node<'a>>,
2217 ) {
2218 let node = cursor.node();
2219 if node.start_byte() <= start && node.end_byte() >= end {
2220 *best = Some(node);
2221 if cursor.goto_first_child() {
2222 loop {
2223 walk_covering(cursor, start, end, best);
2224 if !cursor.goto_next_sibling() {
2225 break;
2226 }
2227 }
2228 cursor.goto_parent();
2229 }
2230 }
2231 }
2232
2233 walk_covering(&mut cursor, start, end, &mut best);
2234 best
2235}
2236
2237fn find_child_by_kind<'a>(
2239 node: tree_sitter::Node<'a>,
2240 kind: &str,
2241) -> Option<tree_sitter::Node<'a>> {
2242 let mut cursor = node.walk();
2243 if cursor.goto_first_child() {
2244 loop {
2245 if cursor.node().kind() == kind {
2246 return Some(cursor.node());
2247 }
2248 if !cursor.goto_next_sibling() {
2249 break;
2250 }
2251 }
2252 }
2253 None
2254}
2255
2256fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2258 let callee = match node.child_by_field_name("function") {
2260 Some(c) => c,
2261 None => return (None, None),
2262 };
2263
2264 let full = node_text(callee, source);
2265 let short = if full.contains('.') {
2266 full.rsplit('.').next().unwrap_or(&full).to_string()
2267 } else {
2268 full.clone()
2269 };
2270
2271 (Some(full), Some(short))
2272}
2273
2274pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2282 if !module_path.starts_with('.') {
2284 return None;
2285 }
2286
2287 let base = from_dir.join(module_path);
2288
2289 if base.is_file() {
2291 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2292 }
2293
2294 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2296 for ext in &extensions {
2297 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2298 if with_ext.is_file() {
2299 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2300 }
2301 }
2302
2303 if base.is_dir() {
2305 if let Some(index) = find_index_file(&base) {
2306 return Some(index);
2307 }
2308 }
2309
2310 None
2311}
2312
2313fn find_index_file(dir: &Path) -> Option<PathBuf> {
2315 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2316 for name in &candidates {
2317 let p = dir.join(name);
2318 if p.is_file() {
2319 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2320 }
2321 }
2322 None
2323}
2324
2325fn resolve_aliased_import(
2328 local_name: &str,
2329 import_block: &ImportBlock,
2330 caller_dir: &Path,
2331) -> Option<(String, PathBuf)> {
2332 for imp in &import_block.imports {
2333 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2336 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2337 return Some((original, resolved_path));
2338 }
2339 }
2340 }
2341 None
2342}
2343
2344fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2348 let search = format!(" as {}", local_name);
2351 if let Some(pos) = raw_import.find(&search) {
2352 let before = &raw_import[..pos];
2354 let original = before
2356 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2357 .find(|s| !s.is_empty())?;
2358 return Some(original.to_string());
2359 }
2360 None
2361}
2362
2363pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2371 use ignore::WalkBuilder;
2372
2373 let walker = WalkBuilder::new(root)
2374 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2379 let name = entry.file_name().to_string_lossy();
2380 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2382 return !matches!(
2383 name.as_ref(),
2384 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2385 | ".tox" | "dist" | "build"
2386 );
2387 }
2388 true
2389 })
2390 .build();
2391
2392 walker
2393 .filter_map(|entry| entry.ok())
2394 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2395 .filter(|entry| detect_language(entry.path()).is_some())
2396 .map(|entry| entry.into_path())
2397}
2398
2399#[cfg(test)]
2404mod tests {
2405 use super::*;
2406 use std::fs;
2407 use tempfile::TempDir;
2408
2409 fn setup_ts_project() -> TempDir {
2411 let dir = TempDir::new().unwrap();
2412
2413 fs::write(
2415 dir.path().join("main.ts"),
2416 r#"import { helper, compute } from './utils';
2417import * as math from './math';
2418
2419export function main() {
2420 const a = helper(1);
2421 const b = compute(a, 2);
2422 const c = math.add(a, b);
2423 return c;
2424}
2425"#,
2426 )
2427 .unwrap();
2428
2429 fs::write(
2431 dir.path().join("utils.ts"),
2432 r#"import { double } from './helpers';
2433
2434export function helper(x: number): number {
2435 return double(x);
2436}
2437
2438export function compute(a: number, b: number): number {
2439 return a + b;
2440}
2441"#,
2442 )
2443 .unwrap();
2444
2445 fs::write(
2447 dir.path().join("helpers.ts"),
2448 r#"export function double(x: number): number {
2449 return x * 2;
2450}
2451
2452export function triple(x: number): number {
2453 return x * 3;
2454}
2455"#,
2456 )
2457 .unwrap();
2458
2459 fs::write(
2461 dir.path().join("math.ts"),
2462 r#"export function add(a: number, b: number): number {
2463 return a + b;
2464}
2465
2466export function subtract(a: number, b: number): number {
2467 return a - b;
2468}
2469"#,
2470 )
2471 .unwrap();
2472
2473 dir
2474 }
2475
2476 fn setup_alias_project() -> TempDir {
2478 let dir = TempDir::new().unwrap();
2479
2480 fs::write(
2481 dir.path().join("main.ts"),
2482 r#"import { helper as h } from './utils';
2483
2484export function main() {
2485 return h(42);
2486}
2487"#,
2488 )
2489 .unwrap();
2490
2491 fs::write(
2492 dir.path().join("utils.ts"),
2493 r#"export function helper(x: number): number {
2494 return x + 1;
2495}
2496"#,
2497 )
2498 .unwrap();
2499
2500 dir
2501 }
2502
2503 fn setup_cycle_project() -> TempDir {
2505 let dir = TempDir::new().unwrap();
2506
2507 fs::write(
2508 dir.path().join("a.ts"),
2509 r#"import { funcB } from './b';
2510
2511export function funcA() {
2512 return funcB();
2513}
2514"#,
2515 )
2516 .unwrap();
2517
2518 fs::write(
2519 dir.path().join("b.ts"),
2520 r#"import { funcA } from './a';
2521
2522export function funcB() {
2523 return funcA();
2524}
2525"#,
2526 )
2527 .unwrap();
2528
2529 dir
2530 }
2531
2532 #[test]
2535 fn callgraph_single_file_call_extraction() {
2536 let dir = setup_ts_project();
2537 let mut graph = CallGraph::new(dir.path().to_path_buf());
2538
2539 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2540 let main_calls = &file_data.calls_by_symbol["main"];
2541
2542 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2543 assert!(
2544 callee_names.contains(&"helper"),
2545 "main should call helper, got: {:?}",
2546 callee_names
2547 );
2548 assert!(
2549 callee_names.contains(&"compute"),
2550 "main should call compute, got: {:?}",
2551 callee_names
2552 );
2553 assert!(
2554 callee_names.contains(&"add"),
2555 "main should call math.add (short name: add), got: {:?}",
2556 callee_names
2557 );
2558 }
2559
2560 #[test]
2561 fn callgraph_file_data_has_exports() {
2562 let dir = setup_ts_project();
2563 let mut graph = CallGraph::new(dir.path().to_path_buf());
2564
2565 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2566 assert!(
2567 file_data.exported_symbols.contains(&"helper".to_string()),
2568 "utils.ts should export helper, got: {:?}",
2569 file_data.exported_symbols
2570 );
2571 assert!(
2572 file_data.exported_symbols.contains(&"compute".to_string()),
2573 "utils.ts should export compute, got: {:?}",
2574 file_data.exported_symbols
2575 );
2576 }
2577
2578 #[test]
2581 fn callgraph_resolve_direct_import() {
2582 let dir = setup_ts_project();
2583 let mut graph = CallGraph::new(dir.path().to_path_buf());
2584
2585 let main_path = dir.path().join("main.ts");
2586 let file_data = graph.build_file(&main_path).unwrap();
2587 let import_block = file_data.import_block.clone();
2588
2589 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2590 match edge {
2591 EdgeResolution::Resolved { file, symbol } => {
2592 assert!(
2593 file.ends_with("utils.ts"),
2594 "helper should resolve to utils.ts, got: {:?}",
2595 file
2596 );
2597 assert_eq!(symbol, "helper");
2598 }
2599 EdgeResolution::Unresolved { callee_name } => {
2600 panic!("Expected resolved, got unresolved: {}", callee_name);
2601 }
2602 }
2603 }
2604
2605 #[test]
2606 fn callgraph_resolve_namespace_import() {
2607 let dir = setup_ts_project();
2608 let mut graph = CallGraph::new(dir.path().to_path_buf());
2609
2610 let main_path = dir.path().join("main.ts");
2611 let file_data = graph.build_file(&main_path).unwrap();
2612 let import_block = file_data.import_block.clone();
2613
2614 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2615 match edge {
2616 EdgeResolution::Resolved { file, symbol } => {
2617 assert!(
2618 file.ends_with("math.ts"),
2619 "math.add should resolve to math.ts, got: {:?}",
2620 file
2621 );
2622 assert_eq!(symbol, "add");
2623 }
2624 EdgeResolution::Unresolved { callee_name } => {
2625 panic!("Expected resolved, got unresolved: {}", callee_name);
2626 }
2627 }
2628 }
2629
2630 #[test]
2631 fn callgraph_resolve_aliased_import() {
2632 let dir = setup_alias_project();
2633 let mut graph = CallGraph::new(dir.path().to_path_buf());
2634
2635 let main_path = dir.path().join("main.ts");
2636 let file_data = graph.build_file(&main_path).unwrap();
2637 let import_block = file_data.import_block.clone();
2638
2639 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2640 match edge {
2641 EdgeResolution::Resolved { file, symbol } => {
2642 assert!(
2643 file.ends_with("utils.ts"),
2644 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2645 file
2646 );
2647 assert_eq!(symbol, "helper");
2648 }
2649 EdgeResolution::Unresolved { callee_name } => {
2650 panic!("Expected resolved, got unresolved: {}", callee_name);
2651 }
2652 }
2653 }
2654
2655 #[test]
2656 fn callgraph_unresolved_edge_marked() {
2657 let dir = setup_ts_project();
2658 let mut graph = CallGraph::new(dir.path().to_path_buf());
2659
2660 let main_path = dir.path().join("main.ts");
2661 let file_data = graph.build_file(&main_path).unwrap();
2662 let import_block = file_data.import_block.clone();
2663
2664 let edge =
2665 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2666 assert_eq!(
2667 edge,
2668 EdgeResolution::Unresolved {
2669 callee_name: "unknownFunc".to_string()
2670 },
2671 "Unknown callee should be unresolved"
2672 );
2673 }
2674
2675 #[test]
2678 fn callgraph_cycle_detection_stops() {
2679 let dir = setup_cycle_project();
2680 let mut graph = CallGraph::new(dir.path().to_path_buf());
2681
2682 let tree = graph
2684 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2685 .unwrap();
2686
2687 assert_eq!(tree.name, "funcA");
2688 assert!(tree.resolved);
2689
2690 fn count_depth(node: &CallTreeNode) -> usize {
2693 if node.children.is_empty() {
2694 1
2695 } else {
2696 1 + node
2697 .children
2698 .iter()
2699 .map(|c| count_depth(c))
2700 .max()
2701 .unwrap_or(0)
2702 }
2703 }
2704
2705 let depth = count_depth(&tree);
2706 assert!(
2707 depth <= 4,
2708 "Cycle should be detected and bounded, depth was: {}",
2709 depth
2710 );
2711 }
2712
2713 #[test]
2716 fn callgraph_depth_limit_truncates() {
2717 let dir = setup_ts_project();
2718 let mut graph = CallGraph::new(dir.path().to_path_buf());
2719
2720 let tree = graph
2723 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2724 .unwrap();
2725
2726 assert_eq!(tree.name, "main");
2727
2728 for child in &tree.children {
2730 assert!(
2731 child.children.is_empty(),
2732 "At depth 1, child '{}' should have no children, got {:?}",
2733 child.name,
2734 child.children.len()
2735 );
2736 }
2737 }
2738
2739 #[test]
2740 fn callgraph_depth_zero_no_children() {
2741 let dir = setup_ts_project();
2742 let mut graph = CallGraph::new(dir.path().to_path_buf());
2743
2744 let tree = graph
2745 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2746 .unwrap();
2747
2748 assert_eq!(tree.name, "main");
2749 assert!(
2750 tree.children.is_empty(),
2751 "At depth 0, should have no children"
2752 );
2753 }
2754
2755 #[test]
2758 fn callgraph_forward_tree_cross_file() {
2759 let dir = setup_ts_project();
2760 let mut graph = CallGraph::new(dir.path().to_path_buf());
2761
2762 let tree = graph
2764 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2765 .unwrap();
2766
2767 assert_eq!(tree.name, "main");
2768 assert!(tree.resolved);
2769
2770 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2772 assert!(
2773 helper_child.is_some(),
2774 "main should have helper as child, children: {:?}",
2775 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2776 );
2777
2778 let helper = helper_child.unwrap();
2779 assert!(
2780 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2781 "helper should be in utils.ts, got: {}",
2782 helper.file
2783 );
2784
2785 let double_child = helper.children.iter().find(|c| c.name == "double");
2787 assert!(
2788 double_child.is_some(),
2789 "helper should call double, children: {:?}",
2790 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2791 );
2792
2793 let double = double_child.unwrap();
2794 assert!(
2795 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2796 "double should be in helpers.ts, got: {}",
2797 double.file
2798 );
2799 }
2800
2801 #[test]
2804 fn callgraph_walker_excludes_gitignored() {
2805 let dir = TempDir::new().unwrap();
2806
2807 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2809
2810 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2812 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2813 fs::write(
2814 dir.path().join("ignored_dir").join("secret.ts"),
2815 "export function secret() {}",
2816 )
2817 .unwrap();
2818
2819 fs::create_dir(dir.path().join("node_modules")).unwrap();
2821 fs::write(
2822 dir.path().join("node_modules").join("dep.ts"),
2823 "export function dep() {}",
2824 )
2825 .unwrap();
2826
2827 std::process::Command::new("git")
2829 .args(["init"])
2830 .current_dir(dir.path())
2831 .output()
2832 .unwrap();
2833
2834 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2835 let file_names: Vec<String> = files
2836 .iter()
2837 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2838 .collect();
2839
2840 assert!(
2841 file_names.contains(&"main.ts".to_string()),
2842 "Should include main.ts, got: {:?}",
2843 file_names
2844 );
2845 assert!(
2846 !file_names.contains(&"secret.ts".to_string()),
2847 "Should exclude gitignored secret.ts, got: {:?}",
2848 file_names
2849 );
2850 assert!(
2851 !file_names.contains(&"dep.ts".to_string()),
2852 "Should exclude node_modules, got: {:?}",
2853 file_names
2854 );
2855 }
2856
2857 #[test]
2858 fn callgraph_walker_only_source_files() {
2859 let dir = TempDir::new().unwrap();
2860
2861 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2862 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2863 fs::write(dir.path().join("data.json"), "{}").unwrap();
2864
2865 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2866 let file_names: Vec<String> = files
2867 .iter()
2868 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2869 .collect();
2870
2871 assert!(file_names.contains(&"main.ts".to_string()));
2872 assert!(
2873 file_names.contains(&"readme.md".to_string()),
2874 "Markdown is now a supported source language"
2875 );
2876 assert!(
2877 !file_names.contains(&"data.json".to_string()),
2878 "Should not include non-source files"
2879 );
2880 }
2881
2882 #[test]
2885 fn callgraph_find_alias_original_simple() {
2886 let raw = "import { foo as bar } from './utils';";
2887 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2888 }
2889
2890 #[test]
2891 fn callgraph_find_alias_original_multiple() {
2892 let raw = "import { foo as bar, baz as qux } from './utils';";
2893 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2894 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2895 }
2896
2897 #[test]
2898 fn callgraph_find_alias_no_match() {
2899 let raw = "import { foo } from './utils';";
2900 assert_eq!(find_alias_original(raw, "foo"), None);
2901 }
2902
2903 #[test]
2906 fn callgraph_callers_of_direct() {
2907 let dir = setup_ts_project();
2908 let mut graph = CallGraph::new(dir.path().to_path_buf());
2909
2910 let result = graph
2912 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
2913 .unwrap();
2914
2915 assert_eq!(result.symbol, "double");
2916 assert!(result.total_callers > 0, "double should have callers");
2917 assert!(result.scanned_files > 0, "should have scanned files");
2918
2919 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2921 assert!(
2922 utils_group.is_some(),
2923 "double should be called from utils.ts, groups: {:?}",
2924 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2925 );
2926
2927 let group = utils_group.unwrap();
2928 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2929 assert!(
2930 helper_caller.is_some(),
2931 "double should be called by helper, callers: {:?}",
2932 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2933 );
2934 }
2935
2936 #[test]
2937 fn callgraph_callers_of_no_callers() {
2938 let dir = setup_ts_project();
2939 let mut graph = CallGraph::new(dir.path().to_path_buf());
2940
2941 let result = graph
2943 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
2944 .unwrap();
2945
2946 assert_eq!(result.symbol, "main");
2947 assert_eq!(result.total_callers, 0, "main should have no callers");
2948 assert!(result.callers.is_empty());
2949 }
2950
2951 #[test]
2952 fn callgraph_callers_recursive_depth() {
2953 let dir = setup_ts_project();
2954 let mut graph = CallGraph::new(dir.path().to_path_buf());
2955
2956 let result = graph
2960 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
2961 .unwrap();
2962
2963 assert!(
2964 result.total_callers >= 2,
2965 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2966 result.total_callers
2967 );
2968
2969 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2971 assert!(
2972 main_group.is_some(),
2973 "recursive callers should include main.ts, groups: {:?}",
2974 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2975 );
2976 }
2977
2978 #[test]
2979 fn callgraph_invalidate_file_clears_reverse_index() {
2980 let dir = setup_ts_project();
2981 let mut graph = CallGraph::new(dir.path().to_path_buf());
2982
2983 let _ = graph
2985 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
2986 .unwrap();
2987 assert!(
2988 graph.reverse_index.is_some(),
2989 "reverse index should be built"
2990 );
2991
2992 graph.invalidate_file(&dir.path().join("utils.ts"));
2994
2995 assert!(
2997 graph.reverse_index.is_none(),
2998 "invalidate_file should clear reverse index"
2999 );
3000 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
3002 assert!(
3003 !graph.data.contains_key(&canon),
3004 "invalidate_file should remove file from data cache"
3005 );
3006 assert!(
3008 graph.project_files.is_none(),
3009 "invalidate_file should clear project_files"
3010 );
3011 }
3012
3013 #[test]
3016 fn is_entry_point_exported_function() {
3017 assert!(is_entry_point(
3018 "handleRequest",
3019 &SymbolKind::Function,
3020 true,
3021 LangId::TypeScript
3022 ));
3023 }
3024
3025 #[test]
3026 fn is_entry_point_exported_method_is_not_entry() {
3027 assert!(!is_entry_point(
3029 "handleRequest",
3030 &SymbolKind::Method,
3031 true,
3032 LangId::TypeScript
3033 ));
3034 }
3035
3036 #[test]
3037 fn is_entry_point_main_init_patterns() {
3038 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3039 assert!(
3040 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3041 "{} should be an entry point",
3042 name
3043 );
3044 }
3045 }
3046
3047 #[test]
3048 fn is_entry_point_test_patterns_ts() {
3049 assert!(is_entry_point(
3050 "describe",
3051 &SymbolKind::Function,
3052 false,
3053 LangId::TypeScript
3054 ));
3055 assert!(is_entry_point(
3056 "it",
3057 &SymbolKind::Function,
3058 false,
3059 LangId::TypeScript
3060 ));
3061 assert!(is_entry_point(
3062 "test",
3063 &SymbolKind::Function,
3064 false,
3065 LangId::TypeScript
3066 ));
3067 assert!(is_entry_point(
3068 "testValidation",
3069 &SymbolKind::Function,
3070 false,
3071 LangId::TypeScript
3072 ));
3073 assert!(is_entry_point(
3074 "specHelper",
3075 &SymbolKind::Function,
3076 false,
3077 LangId::TypeScript
3078 ));
3079 }
3080
3081 #[test]
3082 fn is_entry_point_test_patterns_python() {
3083 assert!(is_entry_point(
3084 "test_login",
3085 &SymbolKind::Function,
3086 false,
3087 LangId::Python
3088 ));
3089 assert!(is_entry_point(
3090 "setUp",
3091 &SymbolKind::Function,
3092 false,
3093 LangId::Python
3094 ));
3095 assert!(is_entry_point(
3096 "tearDown",
3097 &SymbolKind::Function,
3098 false,
3099 LangId::Python
3100 ));
3101 assert!(!is_entry_point(
3103 "testSomething",
3104 &SymbolKind::Function,
3105 false,
3106 LangId::Python
3107 ));
3108 }
3109
3110 #[test]
3111 fn is_entry_point_test_patterns_rust() {
3112 assert!(is_entry_point(
3113 "test_parse",
3114 &SymbolKind::Function,
3115 false,
3116 LangId::Rust
3117 ));
3118 assert!(!is_entry_point(
3119 "TestSomething",
3120 &SymbolKind::Function,
3121 false,
3122 LangId::Rust
3123 ));
3124 }
3125
3126 #[test]
3127 fn is_entry_point_test_patterns_go() {
3128 assert!(is_entry_point(
3129 "TestParsing",
3130 &SymbolKind::Function,
3131 false,
3132 LangId::Go
3133 ));
3134 assert!(!is_entry_point(
3136 "testParsing",
3137 &SymbolKind::Function,
3138 false,
3139 LangId::Go
3140 ));
3141 }
3142
3143 #[test]
3144 fn is_entry_point_non_exported_non_main_is_not_entry() {
3145 assert!(!is_entry_point(
3146 "helperUtil",
3147 &SymbolKind::Function,
3148 false,
3149 LangId::TypeScript
3150 ));
3151 }
3152
3153 #[test]
3156 fn callgraph_symbol_metadata_populated() {
3157 let dir = setup_ts_project();
3158 let mut graph = CallGraph::new(dir.path().to_path_buf());
3159
3160 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3161 assert!(
3162 file_data.symbol_metadata.contains_key("helper"),
3163 "symbol_metadata should contain helper"
3164 );
3165 let meta = &file_data.symbol_metadata["helper"];
3166 assert_eq!(meta.kind, SymbolKind::Function);
3167 assert!(meta.exported, "helper should be exported");
3168 }
3169
3170 fn setup_trace_project() -> TempDir {
3186 let dir = TempDir::new().unwrap();
3187
3188 fs::write(
3189 dir.path().join("main.ts"),
3190 r#"import { processData } from './utils';
3191
3192export function main() {
3193 const result = processData("hello");
3194 return result;
3195}
3196"#,
3197 )
3198 .unwrap();
3199
3200 fs::write(
3201 dir.path().join("service.ts"),
3202 r#"import { processData } from './utils';
3203
3204export function handleRequest(input: string): string {
3205 return processData(input);
3206}
3207"#,
3208 )
3209 .unwrap();
3210
3211 fs::write(
3212 dir.path().join("utils.ts"),
3213 r#"import { validate } from './helpers';
3214
3215export function processData(input: string): string {
3216 const valid = validate(input);
3217 if (!valid) {
3218 throw new Error("invalid input");
3219 }
3220 return input.toUpperCase();
3221}
3222"#,
3223 )
3224 .unwrap();
3225
3226 fs::write(
3227 dir.path().join("helpers.ts"),
3228 r#"export function validate(input: string): boolean {
3229 return checkFormat(input);
3230}
3231
3232function checkFormat(input: string): boolean {
3233 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3234}
3235"#,
3236 )
3237 .unwrap();
3238
3239 fs::write(
3240 dir.path().join("test_helpers.ts"),
3241 r#"import { validate } from './helpers';
3242
3243function testValidation() {
3244 const result = validate("hello");
3245 console.log(result);
3246}
3247"#,
3248 )
3249 .unwrap();
3250
3251 std::process::Command::new("git")
3253 .args(["init"])
3254 .current_dir(dir.path())
3255 .output()
3256 .unwrap();
3257
3258 dir
3259 }
3260
3261 #[test]
3262 fn trace_to_multi_path() {
3263 let dir = setup_trace_project();
3264 let mut graph = CallGraph::new(dir.path().to_path_buf());
3265
3266 let result = graph
3267 .trace_to(
3268 &dir.path().join("helpers.ts"),
3269 "checkFormat",
3270 10,
3271 usize::MAX,
3272 )
3273 .unwrap();
3274
3275 assert_eq!(result.target_symbol, "checkFormat");
3276 assert!(
3277 result.total_paths >= 2,
3278 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3279 result.total_paths,
3280 result
3281 .paths
3282 .iter()
3283 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3284 .collect::<Vec<_>>()
3285 );
3286
3287 for path in &result.paths {
3289 assert!(
3290 path.hops.first().unwrap().is_entry_point,
3291 "First hop should be an entry point, got: {}",
3292 path.hops.first().unwrap().symbol
3293 );
3294 assert_eq!(
3295 path.hops.last().unwrap().symbol,
3296 "checkFormat",
3297 "Last hop should be checkFormat"
3298 );
3299 }
3300
3301 assert!(
3303 result.entry_points_found >= 2,
3304 "should find at least 2 entry points, got {}",
3305 result.entry_points_found
3306 );
3307 }
3308
3309 #[test]
3310 fn trace_to_single_path() {
3311 let dir = setup_trace_project();
3312 let mut graph = CallGraph::new(dir.path().to_path_buf());
3313
3314 let result = graph
3318 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
3319 .unwrap();
3320
3321 assert_eq!(result.target_symbol, "validate");
3322 assert!(
3323 result.total_paths >= 2,
3324 "validate should have at least 2 paths, got {}",
3325 result.total_paths
3326 );
3327 }
3328
3329 #[test]
3330 fn trace_to_cycle_detection() {
3331 let dir = setup_cycle_project();
3332 let mut graph = CallGraph::new(dir.path().to_path_buf());
3333
3334 let result = graph
3336 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
3337 .unwrap();
3338
3339 assert_eq!(result.target_symbol, "funcA");
3341 }
3342
3343 #[test]
3344 fn trace_to_depth_limit() {
3345 let dir = setup_trace_project();
3346 let mut graph = CallGraph::new(dir.path().to_path_buf());
3347
3348 let result = graph
3350 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
3351 .unwrap();
3352
3353 assert_eq!(result.target_symbol, "checkFormat");
3357
3358 let deep_result = graph
3360 .trace_to(
3361 &dir.path().join("helpers.ts"),
3362 "checkFormat",
3363 10,
3364 usize::MAX,
3365 )
3366 .unwrap();
3367
3368 assert!(
3369 result.total_paths <= deep_result.total_paths,
3370 "shallow trace should find <= paths compared to deep: {} vs {}",
3371 result.total_paths,
3372 deep_result.total_paths
3373 );
3374 }
3375
3376 #[test]
3377 fn trace_to_entry_point_target() {
3378 let dir = setup_trace_project();
3379 let mut graph = CallGraph::new(dir.path().to_path_buf());
3380
3381 let result = graph
3383 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
3384 .unwrap();
3385
3386 assert_eq!(result.target_symbol, "main");
3387 assert!(
3388 result.total_paths >= 1,
3389 "main should have at least 1 path (itself), got {}",
3390 result.total_paths
3391 );
3392 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3394 assert!(
3395 trivial.is_some(),
3396 "should have a trivial path with just the entry point itself"
3397 );
3398 }
3399
3400 #[test]
3403 fn extract_parameters_typescript() {
3404 let params = extract_parameters(
3405 "function processData(input: string, count: number): void",
3406 LangId::TypeScript,
3407 );
3408 assert_eq!(params, vec!["input", "count"]);
3409 }
3410
3411 #[test]
3412 fn extract_parameters_typescript_optional() {
3413 let params = extract_parameters(
3414 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3415 LangId::TypeScript,
3416 );
3417 assert_eq!(params, vec!["url", "options"]);
3418 }
3419
3420 #[test]
3421 fn extract_parameters_typescript_defaults() {
3422 let params = extract_parameters(
3423 "function greet(name: string, greeting: string = \"hello\"): string",
3424 LangId::TypeScript,
3425 );
3426 assert_eq!(params, vec!["name", "greeting"]);
3427 }
3428
3429 #[test]
3430 fn extract_parameters_typescript_rest() {
3431 let params = extract_parameters(
3432 "function sum(...numbers: number[]): number",
3433 LangId::TypeScript,
3434 );
3435 assert_eq!(params, vec!["numbers"]);
3436 }
3437
3438 #[test]
3439 fn extract_parameters_python_self_skipped() {
3440 let params = extract_parameters(
3441 "def process(self, data: str, count: int) -> bool",
3442 LangId::Python,
3443 );
3444 assert_eq!(params, vec!["data", "count"]);
3445 }
3446
3447 #[test]
3448 fn extract_parameters_python_no_self() {
3449 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3450 assert_eq!(params, vec!["input"]);
3451 }
3452
3453 #[test]
3454 fn extract_parameters_python_star_args() {
3455 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3456 assert_eq!(params, vec!["args", "kwargs"]);
3457 }
3458
3459 #[test]
3460 fn extract_parameters_rust_self_skipped() {
3461 let params = extract_parameters(
3462 "fn process(&self, data: &str, count: usize) -> bool",
3463 LangId::Rust,
3464 );
3465 assert_eq!(params, vec!["data", "count"]);
3466 }
3467
3468 #[test]
3469 fn extract_parameters_rust_mut_self_skipped() {
3470 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3471 assert_eq!(params, vec!["value"]);
3472 }
3473
3474 #[test]
3475 fn extract_parameters_rust_no_self() {
3476 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3477 assert_eq!(params, vec!["input"]);
3478 }
3479
3480 #[test]
3481 fn extract_parameters_rust_mut_param() {
3482 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3483 assert_eq!(params, vec!["buf", "len"]);
3484 }
3485
3486 #[test]
3487 fn extract_parameters_go() {
3488 let params = extract_parameters(
3489 "func ProcessData(input string, count int) error",
3490 LangId::Go,
3491 );
3492 assert_eq!(params, vec!["input", "count"]);
3493 }
3494
3495 #[test]
3496 fn extract_parameters_empty() {
3497 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3498 assert!(
3499 params.is_empty(),
3500 "no-arg function should return empty params"
3501 );
3502 }
3503
3504 #[test]
3505 fn extract_parameters_no_parens() {
3506 let params = extract_parameters("const x = 42", LangId::TypeScript);
3507 assert!(params.is_empty(), "no parens should return empty params");
3508 }
3509
3510 #[test]
3511 fn extract_parameters_javascript() {
3512 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3513 assert_eq!(params, vec!["event", "target"]);
3514 }
3515}