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