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 fn build_reverse_index(&mut self) {
799 let all_files = self.project_files().to_vec();
801
802 for f in &all_files {
804 let _ = self.build_file(f);
805 }
806
807 let mut reverse: ReverseIndex = HashMap::new();
809
810 for caller_file in &all_files {
811 let canon_caller = Arc::new(
813 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
814 );
815 let file_data = match self
816 .data
817 .get(caller_file)
818 .or_else(|| self.data.get(canon_caller.as_ref()))
819 {
820 Some(d) => d,
821 None => continue,
822 };
823
824 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
825 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
826
827 for call_site in call_sites {
828 let edge = Self::resolve_cross_file_edge_with_exports(
829 &call_site.full_callee,
830 &call_site.callee_name,
831 canon_caller.as_ref(),
832 &file_data.import_block,
833 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
834 );
835
836 let (target_file, target_symbol, resolved) = match edge {
837 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
838 EdgeResolution::Unresolved { callee_name } => {
839 (canon_caller.as_ref().clone(), callee_name, false)
840 }
841 };
842
843 reverse
844 .entry(target_file)
845 .or_default()
846 .entry(target_symbol)
847 .or_default()
848 .push(IndexedCallerSite {
849 caller_file: Arc::clone(&canon_caller),
850 caller_symbol: Arc::clone(&caller_symbol),
851 line: call_site.line,
852 col: 0,
853 resolved,
854 });
855 }
856 }
857 }
858
859 self.reverse_index = Some(reverse);
860 }
861
862 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
863 self.reverse_index
864 .as_ref()?
865 .get(file)?
866 .get(symbol)
867 .map(Vec::as_slice)
868 }
869
870 pub fn callers_of(
876 &mut self,
877 file: &Path,
878 symbol: &str,
879 depth: usize,
880 ) -> Result<CallersResult, AftError> {
881 let canon = self.canonicalize(file)?;
882
883 self.build_file(&canon)?;
885
886 if self.reverse_index.is_none() {
888 self.build_reverse_index();
889 }
890
891 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
892 let effective_depth = if depth == 0 { 1 } else { depth };
893
894 let mut visited = HashSet::new();
895 let mut all_sites: Vec<CallerSite> = Vec::new();
896 self.collect_callers_recursive(
897 &canon,
898 symbol,
899 effective_depth,
900 0,
901 &mut visited,
902 &mut all_sites,
903 );
904
905 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
908 let total_callers = all_sites.len();
909 for site in all_sites {
910 let caller_file: PathBuf = site.caller_file;
911 let caller_symbol: String = site.caller_symbol;
912 let line = site.line;
913 let entry = CallerEntry {
914 symbol: caller_symbol,
915 line,
916 };
917
918 if let Some(entries) = groups_map.get_mut(&caller_file) {
919 entries.push(entry);
920 } else {
921 groups_map.insert(caller_file, vec![entry]);
922 }
923 }
924
925 let mut callers: Vec<CallerGroup> = groups_map
926 .into_iter()
927 .map(|(file_path, entries)| CallerGroup {
928 file: self.relative_path(&file_path),
929 callers: entries,
930 })
931 .collect();
932
933 callers.sort_by(|a, b| a.file.cmp(&b.file));
935
936 Ok(CallersResult {
937 symbol: symbol.to_string(),
938 file: self.relative_path(&canon),
939 callers,
940 total_callers,
941 scanned_files,
942 })
943 }
944
945 pub fn trace_to(
951 &mut self,
952 file: &Path,
953 symbol: &str,
954 max_depth: usize,
955 ) -> Result<TraceToResult, AftError> {
956 let canon = self.canonicalize(file)?;
957
958 self.build_file(&canon)?;
960
961 if self.reverse_index.is_none() {
963 self.build_reverse_index();
964 }
965
966 let target_rel = self.relative_path(&canon);
967 let effective_max = if max_depth == 0 { 10 } else { max_depth };
968 if self.reverse_index.is_none() {
969 return Err(AftError::ParseError {
970 message: format!(
971 "reverse index unavailable after building callers for {}",
972 canon.display()
973 ),
974 });
975 }
976
977 let (target_line, target_sig) = get_symbol_meta(&canon, symbol);
979
980 let target_is_entry = self
982 .lookup_file_data(&canon)
983 .and_then(|fd| {
984 let meta = fd.symbol_metadata.get(symbol)?;
985 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
986 })
987 .unwrap_or(false);
988
989 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
992 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
993 let mut max_depth_reached = false;
994 let mut truncated_paths: usize = 0;
995
996 let initial: Vec<PathElem> = vec![(
998 Arc::new(canon.clone()),
999 Arc::from(symbol),
1000 target_line,
1001 target_sig,
1002 )];
1003
1004 if target_is_entry {
1006 complete_paths.push(initial.clone());
1007 }
1008
1009 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1011
1012 while let Some((path, depth)) = queue.pop() {
1013 if depth >= effective_max {
1014 max_depth_reached = true;
1015 continue;
1016 }
1017
1018 let Some((current_file, current_symbol, _, _)) = path.last() else {
1019 continue;
1020 };
1021
1022 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1024 Some(sites) => sites,
1025 None => {
1026 if path.len() > 1 {
1029 truncated_paths += 1;
1032 }
1033 continue;
1034 }
1035 };
1036
1037 let mut has_new_path = false;
1038 for site in callers {
1039 if path.iter().any(|(file_path, sym, _, _)| {
1041 file_path.as_ref() == site.caller_file.as_ref()
1042 && sym.as_ref() == site.caller_symbol.as_ref()
1043 }) {
1044 continue;
1045 }
1046
1047 has_new_path = true;
1048
1049 let (caller_line, caller_sig) =
1051 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref());
1052
1053 let mut new_path = path.clone();
1054 new_path.push((
1055 Arc::clone(&site.caller_file),
1056 Arc::clone(&site.caller_symbol),
1057 caller_line,
1058 caller_sig,
1059 ));
1060
1061 let caller_is_entry = self
1065 .lookup_file_data(site.caller_file.as_ref())
1066 .and_then(|fd| {
1067 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1068 Some(is_entry_point(
1069 site.caller_symbol.as_ref(),
1070 &meta.kind,
1071 meta.exported,
1072 fd.lang,
1073 ))
1074 })
1075 .unwrap_or(false);
1076
1077 if caller_is_entry {
1078 complete_paths.push(new_path.clone());
1079 }
1080 queue.push((new_path, depth + 1));
1083 }
1084
1085 if !has_new_path && path.len() > 1 {
1087 truncated_paths += 1;
1088 }
1089 }
1090
1091 let mut paths: Vec<TracePath> = complete_paths
1094 .into_iter()
1095 .map(|mut elems| {
1096 elems.reverse();
1097 let hops: Vec<TraceHop> = elems
1098 .iter()
1099 .enumerate()
1100 .map(|(i, (file_path, sym, line, sig))| {
1101 let is_ep = if i == 0 {
1102 self.lookup_file_data(file_path.as_ref())
1104 .and_then(|fd| {
1105 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1106 Some(is_entry_point(
1107 sym.as_ref(),
1108 &meta.kind,
1109 meta.exported,
1110 fd.lang,
1111 ))
1112 })
1113 .unwrap_or(false)
1114 } else {
1115 false
1116 };
1117 TraceHop {
1118 symbol: sym.to_string(),
1119 file: self.relative_path(file_path.as_ref()),
1120 line: *line,
1121 signature: sig.clone(),
1122 is_entry_point: is_ep,
1123 }
1124 })
1125 .collect();
1126 TracePath { hops }
1127 })
1128 .collect();
1129
1130 paths.sort_by(|a, b| {
1132 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1133 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1134 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1135 });
1136
1137 let mut entry_point_names: HashSet<String> = HashSet::new();
1139 for p in &paths {
1140 if let Some(first) = p.hops.first() {
1141 if first.is_entry_point {
1142 entry_point_names.insert(first.symbol.clone());
1143 }
1144 }
1145 }
1146
1147 let total_paths = paths.len();
1148 let entry_points_found = entry_point_names.len();
1149
1150 Ok(TraceToResult {
1151 target_symbol: symbol.to_string(),
1152 target_file: target_rel,
1153 paths,
1154 total_paths,
1155 entry_points_found,
1156 max_depth_reached,
1157 truncated_paths,
1158 })
1159 }
1160
1161 pub fn impact(
1167 &mut self,
1168 file: &Path,
1169 symbol: &str,
1170 depth: usize,
1171 ) -> Result<ImpactResult, AftError> {
1172 let canon = self.canonicalize(file)?;
1173
1174 self.build_file(&canon)?;
1176
1177 if self.reverse_index.is_none() {
1179 self.build_reverse_index();
1180 }
1181
1182 let effective_depth = if depth == 0 { 1 } else { depth };
1183
1184 let (target_signature, target_parameters, target_lang) = {
1186 let file_data = match self.data.get(&canon) {
1187 Some(d) => d,
1188 None => {
1189 return Err(AftError::InvalidRequest {
1190 message: "file data missing after build".to_string(),
1191 })
1192 }
1193 };
1194 let meta = file_data.symbol_metadata.get(symbol);
1195 let sig = meta.and_then(|m| m.signature.clone());
1196 let lang = file_data.lang;
1197 let params = sig
1198 .as_deref()
1199 .map(|s| extract_parameters(s, lang))
1200 .unwrap_or_default();
1201 (sig, params, lang)
1202 };
1203
1204 let mut visited = HashSet::new();
1206 let mut all_sites: Vec<CallerSite> = Vec::new();
1207 self.collect_callers_recursive(
1208 &canon,
1209 symbol,
1210 effective_depth,
1211 0,
1212 &mut visited,
1213 &mut all_sites,
1214 );
1215
1216 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1218 all_sites.retain(|site| {
1219 seen.insert((
1220 site.caller_file.clone(),
1221 site.caller_symbol.clone(),
1222 site.line,
1223 ))
1224 });
1225
1226 let mut callers = Vec::new();
1228 let mut affected_file_set = HashSet::new();
1229
1230 for site in &all_sites {
1231 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1233 log::debug!(
1234 "callgraph: skipping caller file {}: {}",
1235 site.caller_file.display(),
1236 e
1237 );
1238 }
1239
1240 let (sig, is_ep, params, _lang) = {
1241 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1242 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1243 let sig = meta.and_then(|m| m.signature.clone());
1244 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1245 let exported = meta.map(|m| m.exported).unwrap_or(false);
1246 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1247 let lang = fd.lang;
1248 let params = sig
1249 .as_deref()
1250 .map(|s| extract_parameters(s, lang))
1251 .unwrap_or_default();
1252 (sig, is_ep, params, lang)
1253 } else {
1254 (None, false, Vec::new(), target_lang)
1255 }
1256 };
1257
1258 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1260
1261 let rel_file = self.relative_path(site.caller_file.as_path());
1262 affected_file_set.insert(rel_file.clone());
1263
1264 callers.push(ImpactCaller {
1265 caller_symbol: site.caller_symbol.clone(),
1266 caller_file: rel_file,
1267 line: site.line,
1268 signature: sig,
1269 is_entry_point: is_ep,
1270 call_expression,
1271 parameters: params,
1272 });
1273 }
1274
1275 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1277
1278 let total_affected = callers.len();
1279 let affected_files = affected_file_set.len();
1280
1281 Ok(ImpactResult {
1282 symbol: symbol.to_string(),
1283 file: self.relative_path(&canon),
1284 signature: target_signature,
1285 parameters: target_parameters,
1286 total_affected,
1287 affected_files,
1288 callers,
1289 })
1290 }
1291
1292 pub fn trace_data(
1303 &mut self,
1304 file: &Path,
1305 symbol: &str,
1306 expression: &str,
1307 max_depth: usize,
1308 ) -> Result<TraceDataResult, AftError> {
1309 let canon = self.canonicalize(file)?;
1310 let rel_file = self.relative_path(&canon);
1311
1312 self.build_file(&canon)?;
1314
1315 {
1317 let fd = match self.data.get(&canon) {
1318 Some(d) => d,
1319 None => {
1320 return Err(AftError::InvalidRequest {
1321 message: "file data missing after build".to_string(),
1322 })
1323 }
1324 };
1325 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1326 || fd.exported_symbols.iter().any(|name| name == symbol)
1327 || fd.symbol_metadata.contains_key(symbol);
1328 if !has_symbol {
1329 return Err(AftError::InvalidRequest {
1330 message: format!(
1331 "trace_data: symbol '{}' not found in {}",
1332 symbol,
1333 file.display()
1334 ),
1335 });
1336 }
1337 }
1338
1339 let mut hops = Vec::new();
1340 let mut depth_limited = false;
1341
1342 self.trace_data_inner(
1343 &canon,
1344 symbol,
1345 expression,
1346 max_depth,
1347 0,
1348 &mut hops,
1349 &mut depth_limited,
1350 &mut HashSet::new(),
1351 );
1352
1353 Ok(TraceDataResult {
1354 expression: expression.to_string(),
1355 origin_file: rel_file,
1356 origin_symbol: symbol.to_string(),
1357 hops,
1358 depth_limited,
1359 })
1360 }
1361
1362 fn trace_data_inner(
1364 &mut self,
1365 file: &Path,
1366 symbol: &str,
1367 tracking_name: &str,
1368 max_depth: usize,
1369 current_depth: usize,
1370 hops: &mut Vec<DataFlowHop>,
1371 depth_limited: &mut bool,
1372 visited: &mut HashSet<(PathBuf, String, String)>,
1373 ) {
1374 let visit_key = (
1375 file.to_path_buf(),
1376 symbol.to_string(),
1377 tracking_name.to_string(),
1378 );
1379 if visited.contains(&visit_key) {
1380 return; }
1382 visited.insert(visit_key);
1383
1384 let source = match std::fs::read_to_string(file) {
1386 Ok(s) => s,
1387 Err(_) => return,
1388 };
1389
1390 let lang = match detect_language(file) {
1391 Some(l) => l,
1392 None => return,
1393 };
1394
1395 let grammar = grammar_for(lang);
1396 let mut parser = Parser::new();
1397 if parser.set_language(&grammar).is_err() {
1398 return;
1399 }
1400 let tree = match parser.parse(&source, None) {
1401 Some(t) => t,
1402 None => return,
1403 };
1404
1405 let symbols = list_symbols_from_tree(&source, &tree, lang, file);
1407 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1408 Some(s) => s,
1409 None => return,
1410 };
1411
1412 let body_start = line_col_to_byte(&source, sym_info.start_line, sym_info.start_col);
1413 let body_end = line_col_to_byte(&source, sym_info.end_line, sym_info.end_col);
1414
1415 let root = tree.root_node();
1416
1417 let body_node = match find_node_covering_range(root, body_start, body_end) {
1419 Some(n) => n,
1420 None => return,
1421 };
1422
1423 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1425 let rel_file = self.relative_path(file);
1426
1427 self.walk_for_data_flow(
1429 body_node,
1430 &source,
1431 &mut tracked_names,
1432 file,
1433 symbol,
1434 &rel_file,
1435 lang,
1436 max_depth,
1437 current_depth,
1438 hops,
1439 depth_limited,
1440 visited,
1441 );
1442 }
1443
1444 #[allow(clippy::too_many_arguments)]
1447 fn walk_for_data_flow(
1448 &mut self,
1449 node: tree_sitter::Node,
1450 source: &str,
1451 tracked_names: &mut Vec<String>,
1452 file: &Path,
1453 symbol: &str,
1454 rel_file: &str,
1455 lang: LangId,
1456 max_depth: usize,
1457 current_depth: usize,
1458 hops: &mut Vec<DataFlowHop>,
1459 depth_limited: &mut bool,
1460 visited: &mut HashSet<(PathBuf, String, String)>,
1461 ) {
1462 let kind = node.kind();
1463
1464 let is_var_decl = matches!(
1466 kind,
1467 "variable_declarator"
1468 | "assignment_expression"
1469 | "augmented_assignment_expression"
1470 | "assignment"
1471 | "let_declaration"
1472 | "short_var_declaration"
1473 );
1474
1475 if is_var_decl {
1476 if let Some((new_name, init_text, line, is_approx)) =
1477 self.extract_assignment_info(node, source, lang, tracked_names)
1478 {
1479 if !is_approx {
1481 hops.push(DataFlowHop {
1482 file: rel_file.to_string(),
1483 symbol: symbol.to_string(),
1484 variable: new_name.clone(),
1485 line,
1486 flow_type: "assignment".to_string(),
1487 approximate: false,
1488 });
1489 tracked_names.push(new_name);
1490 } else {
1491 hops.push(DataFlowHop {
1493 file: rel_file.to_string(),
1494 symbol: symbol.to_string(),
1495 variable: init_text,
1496 line,
1497 flow_type: "assignment".to_string(),
1498 approximate: true,
1499 });
1500 return;
1502 }
1503 }
1504 }
1505
1506 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1508 self.check_call_for_data_flow(
1509 node,
1510 source,
1511 tracked_names,
1512 file,
1513 symbol,
1514 rel_file,
1515 lang,
1516 max_depth,
1517 current_depth,
1518 hops,
1519 depth_limited,
1520 visited,
1521 );
1522 }
1523
1524 let mut cursor = node.walk();
1526 if cursor.goto_first_child() {
1527 loop {
1528 let child = cursor.node();
1529 self.walk_for_data_flow(
1531 child,
1532 source,
1533 tracked_names,
1534 file,
1535 symbol,
1536 rel_file,
1537 lang,
1538 max_depth,
1539 current_depth,
1540 hops,
1541 depth_limited,
1542 visited,
1543 );
1544 if !cursor.goto_next_sibling() {
1545 break;
1546 }
1547 }
1548 }
1549 }
1550
1551 fn extract_assignment_info(
1554 &self,
1555 node: tree_sitter::Node,
1556 source: &str,
1557 _lang: LangId,
1558 tracked_names: &[String],
1559 ) -> Option<(String, String, u32, bool)> {
1560 let kind = node.kind();
1561 let line = node.start_position().row as u32 + 1;
1562
1563 match kind {
1564 "variable_declarator" => {
1565 let name_node = node.child_by_field_name("name")?;
1567 let value_node = node.child_by_field_name("value")?;
1568 let name_text = node_text(name_node, source);
1569 let value_text = node_text(value_node, source);
1570
1571 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1573 if tracked_names.iter().any(|t| value_text.contains(t)) {
1575 return Some((name_text.clone(), name_text, line, true));
1576 }
1577 return None;
1578 }
1579
1580 if tracked_names.iter().any(|t| {
1582 value_text == *t
1583 || value_text.starts_with(&format!("{}.", t))
1584 || value_text.starts_with(&format!("{}[", t))
1585 }) {
1586 return Some((name_text, value_text, line, false));
1587 }
1588 None
1589 }
1590 "assignment_expression" | "augmented_assignment_expression" => {
1591 let left = node.child_by_field_name("left")?;
1593 let right = node.child_by_field_name("right")?;
1594 let left_text = node_text(left, source);
1595 let right_text = node_text(right, source);
1596
1597 if tracked_names.iter().any(|t| right_text == *t) {
1598 return Some((left_text, right_text, line, false));
1599 }
1600 None
1601 }
1602 "assignment" => {
1603 let left = node.child_by_field_name("left")?;
1605 let right = node.child_by_field_name("right")?;
1606 let left_text = node_text(left, source);
1607 let right_text = node_text(right, source);
1608
1609 if tracked_names.iter().any(|t| right_text == *t) {
1610 return Some((left_text, right_text, line, false));
1611 }
1612 None
1613 }
1614 "let_declaration" | "short_var_declaration" => {
1615 let left = node
1617 .child_by_field_name("pattern")
1618 .or_else(|| node.child_by_field_name("left"))?;
1619 let right = node
1620 .child_by_field_name("value")
1621 .or_else(|| node.child_by_field_name("right"))?;
1622 let left_text = node_text(left, source);
1623 let right_text = node_text(right, source);
1624
1625 if tracked_names.iter().any(|t| right_text == *t) {
1626 return Some((left_text, right_text, line, false));
1627 }
1628 None
1629 }
1630 _ => None,
1631 }
1632 }
1633
1634 #[allow(clippy::too_many_arguments)]
1637 fn check_call_for_data_flow(
1638 &mut self,
1639 node: tree_sitter::Node,
1640 source: &str,
1641 tracked_names: &[String],
1642 file: &Path,
1643 _symbol: &str,
1644 rel_file: &str,
1645 _lang: LangId,
1646 max_depth: usize,
1647 current_depth: usize,
1648 hops: &mut Vec<DataFlowHop>,
1649 depth_limited: &mut bool,
1650 visited: &mut HashSet<(PathBuf, String, String)>,
1651 ) {
1652 let args_node = find_child_by_kind(node, "arguments")
1654 .or_else(|| find_child_by_kind(node, "argument_list"));
1655
1656 let args_node = match args_node {
1657 Some(n) => n,
1658 None => return,
1659 };
1660
1661 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1664
1665 let mut cursor = args_node.walk();
1666 if cursor.goto_first_child() {
1667 loop {
1668 let child = cursor.node();
1669 let child_kind = child.kind();
1670
1671 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1673 if !cursor.goto_next_sibling() {
1674 break;
1675 }
1676 continue;
1677 }
1678
1679 let arg_text = node_text(child, source);
1680
1681 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1683 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1684 hops.push(DataFlowHop {
1685 file: rel_file.to_string(),
1686 symbol: _symbol.to_string(),
1687 variable: arg_text,
1688 line: child.start_position().row as u32 + 1,
1689 flow_type: "parameter".to_string(),
1690 approximate: true,
1691 });
1692 }
1693 if !cursor.goto_next_sibling() {
1694 break;
1695 }
1696 arg_idx += 1;
1697 continue;
1698 }
1699
1700 if tracked_names.iter().any(|t| arg_text == *t) {
1701 arg_positions.push((arg_idx, arg_text));
1702 }
1703
1704 arg_idx += 1;
1705 if !cursor.goto_next_sibling() {
1706 break;
1707 }
1708 }
1709 }
1710
1711 if arg_positions.is_empty() {
1712 return;
1713 }
1714
1715 let (full_callee, short_callee) = extract_callee_names(node, source);
1717 let full_callee = match full_callee {
1718 Some(f) => f,
1719 None => return,
1720 };
1721 let short_callee = match short_callee {
1722 Some(s) => s,
1723 None => return,
1724 };
1725
1726 let import_block = {
1728 match self.data.get(file) {
1729 Some(fd) => fd.import_block.clone(),
1730 None => return,
1731 }
1732 };
1733
1734 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1735
1736 match edge {
1737 EdgeResolution::Resolved {
1738 file: target_file,
1739 symbol: target_symbol,
1740 } => {
1741 if current_depth + 1 > max_depth {
1742 *depth_limited = true;
1743 return;
1744 }
1745
1746 if let Err(e) = self.build_file(&target_file) {
1748 log::debug!(
1749 "callgraph: skipping target file {}: {}",
1750 target_file.display(),
1751 e
1752 );
1753 }
1754 let (params, _target_lang) = {
1755 match self.data.get(&target_file) {
1756 Some(fd) => {
1757 let meta = fd.symbol_metadata.get(&target_symbol);
1758 let sig = meta.and_then(|m| m.signature.clone());
1759 let params = sig
1760 .as_deref()
1761 .map(|s| extract_parameters(s, fd.lang))
1762 .unwrap_or_default();
1763 (params, fd.lang)
1764 }
1765 None => return,
1766 }
1767 };
1768
1769 let target_rel = self.relative_path(&target_file);
1770
1771 for (pos, _tracked) in &arg_positions {
1772 if let Some(param_name) = params.get(*pos) {
1773 hops.push(DataFlowHop {
1775 file: target_rel.clone(),
1776 symbol: target_symbol.clone(),
1777 variable: param_name.clone(),
1778 line: get_symbol_meta(&target_file, &target_symbol).0,
1779 flow_type: "parameter".to_string(),
1780 approximate: false,
1781 });
1782
1783 self.trace_data_inner(
1785 &target_file.clone(),
1786 &target_symbol.clone(),
1787 param_name,
1788 max_depth,
1789 current_depth + 1,
1790 hops,
1791 depth_limited,
1792 visited,
1793 );
1794 }
1795 }
1796 }
1797 EdgeResolution::Unresolved { callee_name } => {
1798 let has_local = self
1800 .data
1801 .get(file)
1802 .map(|fd| {
1803 fd.calls_by_symbol.contains_key(&callee_name)
1804 || fd.symbol_metadata.contains_key(&callee_name)
1805 })
1806 .unwrap_or(false);
1807
1808 if has_local {
1809 let (params, _target_lang) = {
1811 let Some(fd) = self.data.get(file) else {
1812 return;
1813 };
1814 let meta = fd.symbol_metadata.get(&callee_name);
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
1823 let file_rel = self.relative_path(file);
1824
1825 for (pos, _tracked) in &arg_positions {
1826 if let Some(param_name) = params.get(*pos) {
1827 hops.push(DataFlowHop {
1828 file: file_rel.clone(),
1829 symbol: callee_name.clone(),
1830 variable: param_name.clone(),
1831 line: get_symbol_meta(file, &callee_name).0,
1832 flow_type: "parameter".to_string(),
1833 approximate: false,
1834 });
1835
1836 self.trace_data_inner(
1838 file,
1839 &callee_name.clone(),
1840 param_name,
1841 max_depth,
1842 current_depth + 1,
1843 hops,
1844 depth_limited,
1845 visited,
1846 );
1847 }
1848 }
1849 } else {
1850 for (_pos, tracked) in &arg_positions {
1852 hops.push(DataFlowHop {
1853 file: self.relative_path(file),
1854 symbol: callee_name.clone(),
1855 variable: tracked.clone(),
1856 line: node.start_position().row as u32 + 1,
1857 flow_type: "parameter".to_string(),
1858 approximate: true,
1859 });
1860 }
1861 }
1862 }
1863 }
1864 }
1865
1866 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1868 let content = std::fs::read_to_string(path).ok()?;
1869 content
1870 .lines()
1871 .nth(line.saturating_sub(1) as usize)
1872 .map(|l| l.trim().to_string())
1873 }
1874
1875 fn collect_callers_recursive(
1877 &self,
1878 file: &Path,
1879 symbol: &str,
1880 max_depth: usize,
1881 current_depth: usize,
1882 visited: &mut HashSet<(PathBuf, SharedStr)>,
1883 result: &mut Vec<CallerSite>,
1884 ) {
1885 if current_depth >= max_depth {
1886 return;
1887 }
1888
1889 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1891 let key_symbol: SharedStr = Arc::from(symbol);
1892 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
1893 return; }
1895
1896 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
1897 for site in sites {
1898 result.push(CallerSite {
1899 caller_file: site.caller_file.as_ref().clone(),
1900 caller_symbol: site.caller_symbol.to_string(),
1901 line: site.line,
1902 col: site.col,
1903 resolved: site.resolved,
1904 });
1905 if current_depth + 1 < max_depth {
1907 self.collect_callers_recursive(
1908 site.caller_file.as_ref(),
1909 site.caller_symbol.as_ref(),
1910 max_depth,
1911 current_depth + 1,
1912 visited,
1913 result,
1914 );
1915 }
1916 }
1917 }
1918 }
1919
1920 pub fn invalidate_file(&mut self, path: &Path) {
1925 self.data.remove(path);
1927 if let Ok(canon) = self.canonicalize(path) {
1928 self.data.remove(&canon);
1929 }
1930 self.reverse_index = None;
1932 self.project_files = None;
1934 }
1935
1936 fn relative_path(&self, path: &Path) -> String {
1939 path.strip_prefix(&self.project_root)
1940 .unwrap_or(path)
1941 .display()
1942 .to_string()
1943 }
1944
1945 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1947 let full_path = if path.is_relative() {
1949 self.project_root.join(path)
1950 } else {
1951 path.to_path_buf()
1952 };
1953
1954 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1956 }
1957
1958 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
1962 if let Some(fd) = self.data.get(path) {
1963 return Some(fd);
1964 }
1965 let canon = std::fs::canonicalize(path).ok()?;
1967 self.data.get(&canon).or_else(|| {
1968 self.data.iter().find_map(|(k, v)| {
1970 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
1971 Some(v)
1972 } else {
1973 None
1974 }
1975 })
1976 })
1977 }
1978}
1979
1980fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1986 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1987 message: format!("unsupported file for call graph: {}", path.display()),
1988 })?;
1989
1990 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1991 path: format!("{}: {}", path.display(), e),
1992 })?;
1993
1994 let grammar = grammar_for(lang);
1995 let mut parser = Parser::new();
1996 parser
1997 .set_language(&grammar)
1998 .map_err(|e| AftError::ParseError {
1999 message: format!("grammar init failed for {:?}: {}", lang, e),
2000 })?;
2001
2002 let tree = parser
2003 .parse(&source, None)
2004 .ok_or_else(|| AftError::ParseError {
2005 message: format!("parse failed for {}", path.display()),
2006 })?;
2007
2008 let import_block = imports::parse_imports(&source, &tree, lang);
2010
2011 let symbols = list_symbols_from_tree(&source, &tree, lang, path);
2013
2014 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2016 let root = tree.root_node();
2017
2018 for sym in &symbols {
2019 let byte_start = line_col_to_byte(&source, sym.start_line, sym.start_col);
2020 let byte_end = line_col_to_byte(&source, sym.end_line, sym.end_col);
2021
2022 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2023
2024 let sites: Vec<CallSite> = raw_calls
2025 .into_iter()
2026 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
2028 callee_name: short,
2029 full_callee: full,
2030 line,
2031 byte_start,
2032 byte_end,
2033 })
2034 .collect();
2035
2036 if !sites.is_empty() {
2037 calls_by_symbol.insert(sym.name.clone(), sites);
2038 }
2039 }
2040
2041 let exported_symbols: Vec<String> = symbols
2043 .iter()
2044 .filter(|s| s.exported)
2045 .map(|s| s.name.clone())
2046 .collect();
2047
2048 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
2050 .iter()
2051 .map(|s| {
2052 (
2053 s.name.clone(),
2054 SymbolMeta {
2055 kind: s.kind.clone(),
2056 exported: s.exported,
2057 signature: s.signature.clone(),
2058 },
2059 )
2060 })
2061 .collect();
2062
2063 Ok(FileCallData {
2064 calls_by_symbol,
2065 exported_symbols,
2066 symbol_metadata,
2067 import_block,
2068 lang,
2069 })
2070}
2071
2072#[derive(Debug)]
2074#[allow(dead_code)]
2075struct SymbolInfo {
2076 name: String,
2077 kind: SymbolKind,
2078 start_line: u32,
2079 start_col: u32,
2080 end_line: u32,
2081 end_col: u32,
2082 exported: bool,
2083 signature: Option<String>,
2084}
2085
2086fn list_symbols_from_tree(
2089 _source: &str,
2090 _tree: &Tree,
2091 _lang: LangId,
2092 path: &Path,
2093) -> Vec<SymbolInfo> {
2094 let mut file_parser = crate::parser::FileParser::new();
2096 match file_parser.parse(path) {
2097 Ok(_) => {}
2098 Err(_) => return vec![],
2099 }
2100
2101 let provider = crate::parser::TreeSitterProvider::new();
2103 match provider.list_symbols(path) {
2104 Ok(symbols) => symbols
2105 .into_iter()
2106 .map(|s| SymbolInfo {
2107 name: s.name,
2108 kind: s.kind,
2109 start_line: s.range.start_line,
2110 start_col: s.range.start_col,
2111 end_line: s.range.end_line,
2112 end_col: s.range.end_col,
2113 exported: s.exported,
2114 signature: s.signature,
2115 })
2116 .collect(),
2117 Err(_) => vec![],
2118 }
2119}
2120
2121fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2123 let provider = crate::parser::TreeSitterProvider::new();
2124 match provider.list_symbols(path) {
2125 Ok(symbols) => {
2126 for s in &symbols {
2127 if s.name == symbol_name {
2128 return (s.range.start_line + 1, s.signature.clone());
2129 }
2130 }
2131 (1, None)
2132 }
2133 Err(_) => (1, None),
2134 }
2135}
2136
2137fn node_text(node: tree_sitter::Node, source: &str) -> String {
2143 source[node.start_byte()..node.end_byte()].to_string()
2144}
2145
2146fn find_node_covering_range(
2148 root: tree_sitter::Node,
2149 start: usize,
2150 end: usize,
2151) -> Option<tree_sitter::Node> {
2152 let mut best = None;
2153 let mut cursor = root.walk();
2154
2155 fn walk_covering<'a>(
2156 cursor: &mut tree_sitter::TreeCursor<'a>,
2157 start: usize,
2158 end: usize,
2159 best: &mut Option<tree_sitter::Node<'a>>,
2160 ) {
2161 let node = cursor.node();
2162 if node.start_byte() <= start && node.end_byte() >= end {
2163 *best = Some(node);
2164 if cursor.goto_first_child() {
2165 loop {
2166 walk_covering(cursor, start, end, best);
2167 if !cursor.goto_next_sibling() {
2168 break;
2169 }
2170 }
2171 cursor.goto_parent();
2172 }
2173 }
2174 }
2175
2176 walk_covering(&mut cursor, start, end, &mut best);
2177 best
2178}
2179
2180fn find_child_by_kind<'a>(
2182 node: tree_sitter::Node<'a>,
2183 kind: &str,
2184) -> Option<tree_sitter::Node<'a>> {
2185 let mut cursor = node.walk();
2186 if cursor.goto_first_child() {
2187 loop {
2188 if cursor.node().kind() == kind {
2189 return Some(cursor.node());
2190 }
2191 if !cursor.goto_next_sibling() {
2192 break;
2193 }
2194 }
2195 }
2196 None
2197}
2198
2199fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2201 let callee = match node.child_by_field_name("function") {
2203 Some(c) => c,
2204 None => return (None, None),
2205 };
2206
2207 let full = node_text(callee, source);
2208 let short = if full.contains('.') {
2209 full.rsplit('.').next().unwrap_or(&full).to_string()
2210 } else {
2211 full.clone()
2212 };
2213
2214 (Some(full), Some(short))
2215}
2216
2217pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2225 if !module_path.starts_with('.') {
2227 return None;
2228 }
2229
2230 let base = from_dir.join(module_path);
2231
2232 if base.is_file() {
2234 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2235 }
2236
2237 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2239 for ext in &extensions {
2240 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2241 if with_ext.is_file() {
2242 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2243 }
2244 }
2245
2246 if base.is_dir() {
2248 if let Some(index) = find_index_file(&base) {
2249 return Some(index);
2250 }
2251 }
2252
2253 None
2254}
2255
2256fn find_index_file(dir: &Path) -> Option<PathBuf> {
2258 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2259 for name in &candidates {
2260 let p = dir.join(name);
2261 if p.is_file() {
2262 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2263 }
2264 }
2265 None
2266}
2267
2268fn resolve_aliased_import(
2271 local_name: &str,
2272 import_block: &ImportBlock,
2273 caller_dir: &Path,
2274) -> Option<(String, PathBuf)> {
2275 for imp in &import_block.imports {
2276 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2279 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2280 return Some((original, resolved_path));
2281 }
2282 }
2283 }
2284 None
2285}
2286
2287fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2291 let search = format!(" as {}", local_name);
2294 if let Some(pos) = raw_import.find(&search) {
2295 let before = &raw_import[..pos];
2297 let original = before
2299 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2300 .find(|s| !s.is_empty())?;
2301 return Some(original.to_string());
2302 }
2303 None
2304}
2305
2306pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2314 use ignore::WalkBuilder;
2315
2316 let walker = WalkBuilder::new(root)
2317 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2322 let name = entry.file_name().to_string_lossy();
2323 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2325 return !matches!(
2326 name.as_ref(),
2327 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2328 | ".tox" | "dist" | "build"
2329 );
2330 }
2331 true
2332 })
2333 .build();
2334
2335 walker
2336 .filter_map(|entry| entry.ok())
2337 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2338 .filter(|entry| detect_language(entry.path()).is_some())
2339 .map(|entry| entry.into_path())
2340}
2341
2342#[cfg(test)]
2347mod tests {
2348 use super::*;
2349 use std::fs;
2350 use tempfile::TempDir;
2351
2352 fn setup_ts_project() -> TempDir {
2354 let dir = TempDir::new().unwrap();
2355
2356 fs::write(
2358 dir.path().join("main.ts"),
2359 r#"import { helper, compute } from './utils';
2360import * as math from './math';
2361
2362export function main() {
2363 const a = helper(1);
2364 const b = compute(a, 2);
2365 const c = math.add(a, b);
2366 return c;
2367}
2368"#,
2369 )
2370 .unwrap();
2371
2372 fs::write(
2374 dir.path().join("utils.ts"),
2375 r#"import { double } from './helpers';
2376
2377export function helper(x: number): number {
2378 return double(x);
2379}
2380
2381export function compute(a: number, b: number): number {
2382 return a + b;
2383}
2384"#,
2385 )
2386 .unwrap();
2387
2388 fs::write(
2390 dir.path().join("helpers.ts"),
2391 r#"export function double(x: number): number {
2392 return x * 2;
2393}
2394
2395export function triple(x: number): number {
2396 return x * 3;
2397}
2398"#,
2399 )
2400 .unwrap();
2401
2402 fs::write(
2404 dir.path().join("math.ts"),
2405 r#"export function add(a: number, b: number): number {
2406 return a + b;
2407}
2408
2409export function subtract(a: number, b: number): number {
2410 return a - b;
2411}
2412"#,
2413 )
2414 .unwrap();
2415
2416 dir
2417 }
2418
2419 fn setup_alias_project() -> TempDir {
2421 let dir = TempDir::new().unwrap();
2422
2423 fs::write(
2424 dir.path().join("main.ts"),
2425 r#"import { helper as h } from './utils';
2426
2427export function main() {
2428 return h(42);
2429}
2430"#,
2431 )
2432 .unwrap();
2433
2434 fs::write(
2435 dir.path().join("utils.ts"),
2436 r#"export function helper(x: number): number {
2437 return x + 1;
2438}
2439"#,
2440 )
2441 .unwrap();
2442
2443 dir
2444 }
2445
2446 fn setup_cycle_project() -> TempDir {
2448 let dir = TempDir::new().unwrap();
2449
2450 fs::write(
2451 dir.path().join("a.ts"),
2452 r#"import { funcB } from './b';
2453
2454export function funcA() {
2455 return funcB();
2456}
2457"#,
2458 )
2459 .unwrap();
2460
2461 fs::write(
2462 dir.path().join("b.ts"),
2463 r#"import { funcA } from './a';
2464
2465export function funcB() {
2466 return funcA();
2467}
2468"#,
2469 )
2470 .unwrap();
2471
2472 dir
2473 }
2474
2475 #[test]
2478 fn callgraph_single_file_call_extraction() {
2479 let dir = setup_ts_project();
2480 let mut graph = CallGraph::new(dir.path().to_path_buf());
2481
2482 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2483 let main_calls = &file_data.calls_by_symbol["main"];
2484
2485 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2486 assert!(
2487 callee_names.contains(&"helper"),
2488 "main should call helper, got: {:?}",
2489 callee_names
2490 );
2491 assert!(
2492 callee_names.contains(&"compute"),
2493 "main should call compute, got: {:?}",
2494 callee_names
2495 );
2496 assert!(
2497 callee_names.contains(&"add"),
2498 "main should call math.add (short name: add), got: {:?}",
2499 callee_names
2500 );
2501 }
2502
2503 #[test]
2504 fn callgraph_file_data_has_exports() {
2505 let dir = setup_ts_project();
2506 let mut graph = CallGraph::new(dir.path().to_path_buf());
2507
2508 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2509 assert!(
2510 file_data.exported_symbols.contains(&"helper".to_string()),
2511 "utils.ts should export helper, got: {:?}",
2512 file_data.exported_symbols
2513 );
2514 assert!(
2515 file_data.exported_symbols.contains(&"compute".to_string()),
2516 "utils.ts should export compute, got: {:?}",
2517 file_data.exported_symbols
2518 );
2519 }
2520
2521 #[test]
2524 fn callgraph_resolve_direct_import() {
2525 let dir = setup_ts_project();
2526 let mut graph = CallGraph::new(dir.path().to_path_buf());
2527
2528 let main_path = dir.path().join("main.ts");
2529 let file_data = graph.build_file(&main_path).unwrap();
2530 let import_block = file_data.import_block.clone();
2531
2532 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2533 match edge {
2534 EdgeResolution::Resolved { file, symbol } => {
2535 assert!(
2536 file.ends_with("utils.ts"),
2537 "helper should resolve to utils.ts, got: {:?}",
2538 file
2539 );
2540 assert_eq!(symbol, "helper");
2541 }
2542 EdgeResolution::Unresolved { callee_name } => {
2543 panic!("Expected resolved, got unresolved: {}", callee_name);
2544 }
2545 }
2546 }
2547
2548 #[test]
2549 fn callgraph_resolve_namespace_import() {
2550 let dir = setup_ts_project();
2551 let mut graph = CallGraph::new(dir.path().to_path_buf());
2552
2553 let main_path = dir.path().join("main.ts");
2554 let file_data = graph.build_file(&main_path).unwrap();
2555 let import_block = file_data.import_block.clone();
2556
2557 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2558 match edge {
2559 EdgeResolution::Resolved { file, symbol } => {
2560 assert!(
2561 file.ends_with("math.ts"),
2562 "math.add should resolve to math.ts, got: {:?}",
2563 file
2564 );
2565 assert_eq!(symbol, "add");
2566 }
2567 EdgeResolution::Unresolved { callee_name } => {
2568 panic!("Expected resolved, got unresolved: {}", callee_name);
2569 }
2570 }
2571 }
2572
2573 #[test]
2574 fn callgraph_resolve_aliased_import() {
2575 let dir = setup_alias_project();
2576 let mut graph = CallGraph::new(dir.path().to_path_buf());
2577
2578 let main_path = dir.path().join("main.ts");
2579 let file_data = graph.build_file(&main_path).unwrap();
2580 let import_block = file_data.import_block.clone();
2581
2582 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2583 match edge {
2584 EdgeResolution::Resolved { file, symbol } => {
2585 assert!(
2586 file.ends_with("utils.ts"),
2587 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2588 file
2589 );
2590 assert_eq!(symbol, "helper");
2591 }
2592 EdgeResolution::Unresolved { callee_name } => {
2593 panic!("Expected resolved, got unresolved: {}", callee_name);
2594 }
2595 }
2596 }
2597
2598 #[test]
2599 fn callgraph_unresolved_edge_marked() {
2600 let dir = setup_ts_project();
2601 let mut graph = CallGraph::new(dir.path().to_path_buf());
2602
2603 let main_path = dir.path().join("main.ts");
2604 let file_data = graph.build_file(&main_path).unwrap();
2605 let import_block = file_data.import_block.clone();
2606
2607 let edge =
2608 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2609 assert_eq!(
2610 edge,
2611 EdgeResolution::Unresolved {
2612 callee_name: "unknownFunc".to_string()
2613 },
2614 "Unknown callee should be unresolved"
2615 );
2616 }
2617
2618 #[test]
2621 fn callgraph_cycle_detection_stops() {
2622 let dir = setup_cycle_project();
2623 let mut graph = CallGraph::new(dir.path().to_path_buf());
2624
2625 let tree = graph
2627 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2628 .unwrap();
2629
2630 assert_eq!(tree.name, "funcA");
2631 assert!(tree.resolved);
2632
2633 fn count_depth(node: &CallTreeNode) -> usize {
2636 if node.children.is_empty() {
2637 1
2638 } else {
2639 1 + node
2640 .children
2641 .iter()
2642 .map(|c| count_depth(c))
2643 .max()
2644 .unwrap_or(0)
2645 }
2646 }
2647
2648 let depth = count_depth(&tree);
2649 assert!(
2650 depth <= 4,
2651 "Cycle should be detected and bounded, depth was: {}",
2652 depth
2653 );
2654 }
2655
2656 #[test]
2659 fn callgraph_depth_limit_truncates() {
2660 let dir = setup_ts_project();
2661 let mut graph = CallGraph::new(dir.path().to_path_buf());
2662
2663 let tree = graph
2666 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2667 .unwrap();
2668
2669 assert_eq!(tree.name, "main");
2670
2671 for child in &tree.children {
2673 assert!(
2674 child.children.is_empty(),
2675 "At depth 1, child '{}' should have no children, got {:?}",
2676 child.name,
2677 child.children.len()
2678 );
2679 }
2680 }
2681
2682 #[test]
2683 fn callgraph_depth_zero_no_children() {
2684 let dir = setup_ts_project();
2685 let mut graph = CallGraph::new(dir.path().to_path_buf());
2686
2687 let tree = graph
2688 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2689 .unwrap();
2690
2691 assert_eq!(tree.name, "main");
2692 assert!(
2693 tree.children.is_empty(),
2694 "At depth 0, should have no children"
2695 );
2696 }
2697
2698 #[test]
2701 fn callgraph_forward_tree_cross_file() {
2702 let dir = setup_ts_project();
2703 let mut graph = CallGraph::new(dir.path().to_path_buf());
2704
2705 let tree = graph
2707 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2708 .unwrap();
2709
2710 assert_eq!(tree.name, "main");
2711 assert!(tree.resolved);
2712
2713 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2715 assert!(
2716 helper_child.is_some(),
2717 "main should have helper as child, children: {:?}",
2718 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2719 );
2720
2721 let helper = helper_child.unwrap();
2722 assert!(
2723 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2724 "helper should be in utils.ts, got: {}",
2725 helper.file
2726 );
2727
2728 let double_child = helper.children.iter().find(|c| c.name == "double");
2730 assert!(
2731 double_child.is_some(),
2732 "helper should call double, children: {:?}",
2733 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2734 );
2735
2736 let double = double_child.unwrap();
2737 assert!(
2738 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2739 "double should be in helpers.ts, got: {}",
2740 double.file
2741 );
2742 }
2743
2744 #[test]
2747 fn callgraph_walker_excludes_gitignored() {
2748 let dir = TempDir::new().unwrap();
2749
2750 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2752
2753 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2755 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2756 fs::write(
2757 dir.path().join("ignored_dir").join("secret.ts"),
2758 "export function secret() {}",
2759 )
2760 .unwrap();
2761
2762 fs::create_dir(dir.path().join("node_modules")).unwrap();
2764 fs::write(
2765 dir.path().join("node_modules").join("dep.ts"),
2766 "export function dep() {}",
2767 )
2768 .unwrap();
2769
2770 std::process::Command::new("git")
2772 .args(["init"])
2773 .current_dir(dir.path())
2774 .output()
2775 .unwrap();
2776
2777 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2778 let file_names: Vec<String> = files
2779 .iter()
2780 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2781 .collect();
2782
2783 assert!(
2784 file_names.contains(&"main.ts".to_string()),
2785 "Should include main.ts, got: {:?}",
2786 file_names
2787 );
2788 assert!(
2789 !file_names.contains(&"secret.ts".to_string()),
2790 "Should exclude gitignored secret.ts, got: {:?}",
2791 file_names
2792 );
2793 assert!(
2794 !file_names.contains(&"dep.ts".to_string()),
2795 "Should exclude node_modules, got: {:?}",
2796 file_names
2797 );
2798 }
2799
2800 #[test]
2801 fn callgraph_walker_only_source_files() {
2802 let dir = TempDir::new().unwrap();
2803
2804 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2805 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2806 fs::write(dir.path().join("data.json"), "{}").unwrap();
2807
2808 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2809 let file_names: Vec<String> = files
2810 .iter()
2811 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2812 .collect();
2813
2814 assert!(file_names.contains(&"main.ts".to_string()));
2815 assert!(
2816 file_names.contains(&"readme.md".to_string()),
2817 "Markdown is now a supported source language"
2818 );
2819 assert!(
2820 !file_names.contains(&"data.json".to_string()),
2821 "Should not include non-source files"
2822 );
2823 }
2824
2825 #[test]
2828 fn callgraph_find_alias_original_simple() {
2829 let raw = "import { foo as bar } from './utils';";
2830 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2831 }
2832
2833 #[test]
2834 fn callgraph_find_alias_original_multiple() {
2835 let raw = "import { foo as bar, baz as qux } from './utils';";
2836 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2837 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2838 }
2839
2840 #[test]
2841 fn callgraph_find_alias_no_match() {
2842 let raw = "import { foo } from './utils';";
2843 assert_eq!(find_alias_original(raw, "foo"), None);
2844 }
2845
2846 #[test]
2849 fn callgraph_callers_of_direct() {
2850 let dir = setup_ts_project();
2851 let mut graph = CallGraph::new(dir.path().to_path_buf());
2852
2853 let result = graph
2855 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2856 .unwrap();
2857
2858 assert_eq!(result.symbol, "double");
2859 assert!(result.total_callers > 0, "double should have callers");
2860 assert!(result.scanned_files > 0, "should have scanned files");
2861
2862 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2864 assert!(
2865 utils_group.is_some(),
2866 "double should be called from utils.ts, groups: {:?}",
2867 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2868 );
2869
2870 let group = utils_group.unwrap();
2871 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2872 assert!(
2873 helper_caller.is_some(),
2874 "double should be called by helper, callers: {:?}",
2875 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2876 );
2877 }
2878
2879 #[test]
2880 fn callgraph_callers_of_no_callers() {
2881 let dir = setup_ts_project();
2882 let mut graph = CallGraph::new(dir.path().to_path_buf());
2883
2884 let result = graph
2886 .callers_of(&dir.path().join("main.ts"), "main", 1)
2887 .unwrap();
2888
2889 assert_eq!(result.symbol, "main");
2890 assert_eq!(result.total_callers, 0, "main should have no callers");
2891 assert!(result.callers.is_empty());
2892 }
2893
2894 #[test]
2895 fn callgraph_callers_recursive_depth() {
2896 let dir = setup_ts_project();
2897 let mut graph = CallGraph::new(dir.path().to_path_buf());
2898
2899 let result = graph
2903 .callers_of(&dir.path().join("helpers.ts"), "double", 2)
2904 .unwrap();
2905
2906 assert!(
2907 result.total_callers >= 2,
2908 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2909 result.total_callers
2910 );
2911
2912 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2914 assert!(
2915 main_group.is_some(),
2916 "recursive callers should include main.ts, groups: {:?}",
2917 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2918 );
2919 }
2920
2921 #[test]
2922 fn callgraph_invalidate_file_clears_reverse_index() {
2923 let dir = setup_ts_project();
2924 let mut graph = CallGraph::new(dir.path().to_path_buf());
2925
2926 let _ = graph
2928 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2929 .unwrap();
2930 assert!(
2931 graph.reverse_index.is_some(),
2932 "reverse index should be built"
2933 );
2934
2935 graph.invalidate_file(&dir.path().join("utils.ts"));
2937
2938 assert!(
2940 graph.reverse_index.is_none(),
2941 "invalidate_file should clear reverse index"
2942 );
2943 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2945 assert!(
2946 !graph.data.contains_key(&canon),
2947 "invalidate_file should remove file from data cache"
2948 );
2949 assert!(
2951 graph.project_files.is_none(),
2952 "invalidate_file should clear project_files"
2953 );
2954 }
2955
2956 #[test]
2959 fn is_entry_point_exported_function() {
2960 assert!(is_entry_point(
2961 "handleRequest",
2962 &SymbolKind::Function,
2963 true,
2964 LangId::TypeScript
2965 ));
2966 }
2967
2968 #[test]
2969 fn is_entry_point_exported_method_is_not_entry() {
2970 assert!(!is_entry_point(
2972 "handleRequest",
2973 &SymbolKind::Method,
2974 true,
2975 LangId::TypeScript
2976 ));
2977 }
2978
2979 #[test]
2980 fn is_entry_point_main_init_patterns() {
2981 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
2982 assert!(
2983 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
2984 "{} should be an entry point",
2985 name
2986 );
2987 }
2988 }
2989
2990 #[test]
2991 fn is_entry_point_test_patterns_ts() {
2992 assert!(is_entry_point(
2993 "describe",
2994 &SymbolKind::Function,
2995 false,
2996 LangId::TypeScript
2997 ));
2998 assert!(is_entry_point(
2999 "it",
3000 &SymbolKind::Function,
3001 false,
3002 LangId::TypeScript
3003 ));
3004 assert!(is_entry_point(
3005 "test",
3006 &SymbolKind::Function,
3007 false,
3008 LangId::TypeScript
3009 ));
3010 assert!(is_entry_point(
3011 "testValidation",
3012 &SymbolKind::Function,
3013 false,
3014 LangId::TypeScript
3015 ));
3016 assert!(is_entry_point(
3017 "specHelper",
3018 &SymbolKind::Function,
3019 false,
3020 LangId::TypeScript
3021 ));
3022 }
3023
3024 #[test]
3025 fn is_entry_point_test_patterns_python() {
3026 assert!(is_entry_point(
3027 "test_login",
3028 &SymbolKind::Function,
3029 false,
3030 LangId::Python
3031 ));
3032 assert!(is_entry_point(
3033 "setUp",
3034 &SymbolKind::Function,
3035 false,
3036 LangId::Python
3037 ));
3038 assert!(is_entry_point(
3039 "tearDown",
3040 &SymbolKind::Function,
3041 false,
3042 LangId::Python
3043 ));
3044 assert!(!is_entry_point(
3046 "testSomething",
3047 &SymbolKind::Function,
3048 false,
3049 LangId::Python
3050 ));
3051 }
3052
3053 #[test]
3054 fn is_entry_point_test_patterns_rust() {
3055 assert!(is_entry_point(
3056 "test_parse",
3057 &SymbolKind::Function,
3058 false,
3059 LangId::Rust
3060 ));
3061 assert!(!is_entry_point(
3062 "TestSomething",
3063 &SymbolKind::Function,
3064 false,
3065 LangId::Rust
3066 ));
3067 }
3068
3069 #[test]
3070 fn is_entry_point_test_patterns_go() {
3071 assert!(is_entry_point(
3072 "TestParsing",
3073 &SymbolKind::Function,
3074 false,
3075 LangId::Go
3076 ));
3077 assert!(!is_entry_point(
3079 "testParsing",
3080 &SymbolKind::Function,
3081 false,
3082 LangId::Go
3083 ));
3084 }
3085
3086 #[test]
3087 fn is_entry_point_non_exported_non_main_is_not_entry() {
3088 assert!(!is_entry_point(
3089 "helperUtil",
3090 &SymbolKind::Function,
3091 false,
3092 LangId::TypeScript
3093 ));
3094 }
3095
3096 #[test]
3099 fn callgraph_symbol_metadata_populated() {
3100 let dir = setup_ts_project();
3101 let mut graph = CallGraph::new(dir.path().to_path_buf());
3102
3103 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3104 assert!(
3105 file_data.symbol_metadata.contains_key("helper"),
3106 "symbol_metadata should contain helper"
3107 );
3108 let meta = &file_data.symbol_metadata["helper"];
3109 assert_eq!(meta.kind, SymbolKind::Function);
3110 assert!(meta.exported, "helper should be exported");
3111 }
3112
3113 fn setup_trace_project() -> TempDir {
3129 let dir = TempDir::new().unwrap();
3130
3131 fs::write(
3132 dir.path().join("main.ts"),
3133 r#"import { processData } from './utils';
3134
3135export function main() {
3136 const result = processData("hello");
3137 return result;
3138}
3139"#,
3140 )
3141 .unwrap();
3142
3143 fs::write(
3144 dir.path().join("service.ts"),
3145 r#"import { processData } from './utils';
3146
3147export function handleRequest(input: string): string {
3148 return processData(input);
3149}
3150"#,
3151 )
3152 .unwrap();
3153
3154 fs::write(
3155 dir.path().join("utils.ts"),
3156 r#"import { validate } from './helpers';
3157
3158export function processData(input: string): string {
3159 const valid = validate(input);
3160 if (!valid) {
3161 throw new Error("invalid input");
3162 }
3163 return input.toUpperCase();
3164}
3165"#,
3166 )
3167 .unwrap();
3168
3169 fs::write(
3170 dir.path().join("helpers.ts"),
3171 r#"export function validate(input: string): boolean {
3172 return checkFormat(input);
3173}
3174
3175function checkFormat(input: string): boolean {
3176 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3177}
3178"#,
3179 )
3180 .unwrap();
3181
3182 fs::write(
3183 dir.path().join("test_helpers.ts"),
3184 r#"import { validate } from './helpers';
3185
3186function testValidation() {
3187 const result = validate("hello");
3188 console.log(result);
3189}
3190"#,
3191 )
3192 .unwrap();
3193
3194 std::process::Command::new("git")
3196 .args(["init"])
3197 .current_dir(dir.path())
3198 .output()
3199 .unwrap();
3200
3201 dir
3202 }
3203
3204 #[test]
3205 fn trace_to_multi_path() {
3206 let dir = setup_trace_project();
3207 let mut graph = CallGraph::new(dir.path().to_path_buf());
3208
3209 let result = graph
3210 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3211 .unwrap();
3212
3213 assert_eq!(result.target_symbol, "checkFormat");
3214 assert!(
3215 result.total_paths >= 2,
3216 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3217 result.total_paths,
3218 result
3219 .paths
3220 .iter()
3221 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3222 .collect::<Vec<_>>()
3223 );
3224
3225 for path in &result.paths {
3227 assert!(
3228 path.hops.first().unwrap().is_entry_point,
3229 "First hop should be an entry point, got: {}",
3230 path.hops.first().unwrap().symbol
3231 );
3232 assert_eq!(
3233 path.hops.last().unwrap().symbol,
3234 "checkFormat",
3235 "Last hop should be checkFormat"
3236 );
3237 }
3238
3239 assert!(
3241 result.entry_points_found >= 2,
3242 "should find at least 2 entry points, got {}",
3243 result.entry_points_found
3244 );
3245 }
3246
3247 #[test]
3248 fn trace_to_single_path() {
3249 let dir = setup_trace_project();
3250 let mut graph = CallGraph::new(dir.path().to_path_buf());
3251
3252 let result = graph
3256 .trace_to(&dir.path().join("helpers.ts"), "validate", 10)
3257 .unwrap();
3258
3259 assert_eq!(result.target_symbol, "validate");
3260 assert!(
3261 result.total_paths >= 2,
3262 "validate should have at least 2 paths, got {}",
3263 result.total_paths
3264 );
3265 }
3266
3267 #[test]
3268 fn trace_to_cycle_detection() {
3269 let dir = setup_cycle_project();
3270 let mut graph = CallGraph::new(dir.path().to_path_buf());
3271
3272 let result = graph
3274 .trace_to(&dir.path().join("a.ts"), "funcA", 10)
3275 .unwrap();
3276
3277 assert_eq!(result.target_symbol, "funcA");
3279 }
3280
3281 #[test]
3282 fn trace_to_depth_limit() {
3283 let dir = setup_trace_project();
3284 let mut graph = CallGraph::new(dir.path().to_path_buf());
3285
3286 let result = graph
3288 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1)
3289 .unwrap();
3290
3291 assert_eq!(result.target_symbol, "checkFormat");
3295
3296 let deep_result = graph
3298 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3299 .unwrap();
3300
3301 assert!(
3302 result.total_paths <= deep_result.total_paths,
3303 "shallow trace should find <= paths compared to deep: {} vs {}",
3304 result.total_paths,
3305 deep_result.total_paths
3306 );
3307 }
3308
3309 #[test]
3310 fn trace_to_entry_point_target() {
3311 let dir = setup_trace_project();
3312 let mut graph = CallGraph::new(dir.path().to_path_buf());
3313
3314 let result = graph
3316 .trace_to(&dir.path().join("main.ts"), "main", 10)
3317 .unwrap();
3318
3319 assert_eq!(result.target_symbol, "main");
3320 assert!(
3321 result.total_paths >= 1,
3322 "main should have at least 1 path (itself), got {}",
3323 result.total_paths
3324 );
3325 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3327 assert!(
3328 trivial.is_some(),
3329 "should have a trivial path with just the entry point itself"
3330 );
3331 }
3332
3333 #[test]
3336 fn extract_parameters_typescript() {
3337 let params = extract_parameters(
3338 "function processData(input: string, count: number): void",
3339 LangId::TypeScript,
3340 );
3341 assert_eq!(params, vec!["input", "count"]);
3342 }
3343
3344 #[test]
3345 fn extract_parameters_typescript_optional() {
3346 let params = extract_parameters(
3347 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3348 LangId::TypeScript,
3349 );
3350 assert_eq!(params, vec!["url", "options"]);
3351 }
3352
3353 #[test]
3354 fn extract_parameters_typescript_defaults() {
3355 let params = extract_parameters(
3356 "function greet(name: string, greeting: string = \"hello\"): string",
3357 LangId::TypeScript,
3358 );
3359 assert_eq!(params, vec!["name", "greeting"]);
3360 }
3361
3362 #[test]
3363 fn extract_parameters_typescript_rest() {
3364 let params = extract_parameters(
3365 "function sum(...numbers: number[]): number",
3366 LangId::TypeScript,
3367 );
3368 assert_eq!(params, vec!["numbers"]);
3369 }
3370
3371 #[test]
3372 fn extract_parameters_python_self_skipped() {
3373 let params = extract_parameters(
3374 "def process(self, data: str, count: int) -> bool",
3375 LangId::Python,
3376 );
3377 assert_eq!(params, vec!["data", "count"]);
3378 }
3379
3380 #[test]
3381 fn extract_parameters_python_no_self() {
3382 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3383 assert_eq!(params, vec!["input"]);
3384 }
3385
3386 #[test]
3387 fn extract_parameters_python_star_args() {
3388 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3389 assert_eq!(params, vec!["args", "kwargs"]);
3390 }
3391
3392 #[test]
3393 fn extract_parameters_rust_self_skipped() {
3394 let params = extract_parameters(
3395 "fn process(&self, data: &str, count: usize) -> bool",
3396 LangId::Rust,
3397 );
3398 assert_eq!(params, vec!["data", "count"]);
3399 }
3400
3401 #[test]
3402 fn extract_parameters_rust_mut_self_skipped() {
3403 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3404 assert_eq!(params, vec!["value"]);
3405 }
3406
3407 #[test]
3408 fn extract_parameters_rust_no_self() {
3409 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3410 assert_eq!(params, vec!["input"]);
3411 }
3412
3413 #[test]
3414 fn extract_parameters_rust_mut_param() {
3415 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3416 assert_eq!(params, vec!["buf", "len"]);
3417 }
3418
3419 #[test]
3420 fn extract_parameters_go() {
3421 let params = extract_parameters(
3422 "func ProcessData(input string, count int) error",
3423 LangId::Go,
3424 );
3425 assert_eq!(params, vec!["input", "count"]);
3426 }
3427
3428 #[test]
3429 fn extract_parameters_empty() {
3430 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3431 assert!(
3432 params.is_empty(),
3433 "no-arg function should return empty params"
3434 );
3435 }
3436
3437 #[test]
3438 fn extract_parameters_no_parens() {
3439 let params = extract_parameters("const x = 42", LangId::TypeScript);
3440 assert!(params.is_empty(), "no parens should return empty params");
3441 }
3442
3443 #[test]
3444 fn extract_parameters_javascript() {
3445 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3446 assert_eq!(params, vec!["event", "target"]);
3447 }
3448}