1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use rayon::prelude::*;
12use serde::Serialize;
13use tree_sitter::Parser;
14
15use crate::calls::extract_calls_full;
16use crate::edit::line_col_to_byte;
17use crate::error::AftError;
18use crate::imports::{self, ImportBlock};
19use crate::language::LanguageProvider;
20use crate::parser::{detect_language, grammar_for, LangId};
21use crate::symbols::{Range, SymbolKind};
22
23type SharedPath = Arc<PathBuf>;
28type SharedStr = Arc<str>;
29type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
30
31#[derive(Debug, Clone)]
33pub struct CallSite {
34 pub callee_name: String,
36 pub full_callee: String,
38 pub line: u32,
40 pub byte_start: usize,
42 pub byte_end: usize,
43}
44
45#[derive(Debug, Clone, Serialize)]
47pub struct SymbolMeta {
48 pub kind: SymbolKind,
50 pub exported: bool,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub signature: Option<String>,
55 pub line: u32,
57 pub range: Range,
59}
60
61#[derive(Debug, Clone)]
64pub struct FileCallData {
65 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
67 pub exported_symbols: Vec<String>,
69 pub symbol_metadata: HashMap<String, SymbolMeta>,
71 pub import_block: ImportBlock,
73 pub lang: LangId,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum EdgeResolution {
80 Resolved { file: PathBuf, symbol: String },
82 Unresolved { callee_name: String },
84}
85
86#[derive(Debug, Clone, Serialize)]
88pub struct CallerSite {
89 pub caller_file: PathBuf,
91 pub caller_symbol: String,
93 pub line: u32,
95 pub col: u32,
97 pub resolved: bool,
99}
100
101#[derive(Debug, Clone)]
102struct IndexedCallerSite {
103 caller_file: SharedPath,
104 caller_symbol: SharedStr,
105 line: u32,
106 col: u32,
107 resolved: bool,
108}
109
110#[derive(Debug, Clone, Serialize)]
112pub struct CallerGroup {
113 pub file: String,
115 pub callers: Vec<CallerEntry>,
117}
118
119#[derive(Debug, Clone, Serialize)]
121pub struct CallerEntry {
122 pub symbol: String,
123 pub line: u32,
125}
126
127#[derive(Debug, Clone, Serialize)]
129pub struct CallersResult {
130 pub symbol: String,
132 pub file: String,
134 pub callers: Vec<CallerGroup>,
136 pub total_callers: usize,
138 pub scanned_files: usize,
140}
141
142#[derive(Debug, Clone, Serialize)]
144pub struct CallTreeNode {
145 pub name: String,
147 pub file: String,
149 pub line: u32,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub signature: Option<String>,
154 pub resolved: bool,
156 pub children: Vec<CallTreeNode>,
158}
159
160const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
166
167pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
174 if exported && *kind == SymbolKind::Function {
176 return true;
177 }
178
179 let lower = name.to_lowercase();
181 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
182 return true;
183 }
184
185 match lang {
187 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
188 matches!(lower.as_str(), "describe" | "it" | "test")
190 || lower.starts_with("test")
191 || lower.starts_with("spec")
192 }
193 LangId::Python => {
194 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
196 }
197 LangId::Rust => {
198 lower.starts_with("test_")
200 }
201 LangId::Go => {
202 name.starts_with("Test")
204 }
205 LangId::C
206 | LangId::Cpp
207 | LangId::Zig
208 | LangId::CSharp
209 | LangId::Bash
210 | LangId::Solidity
211 | LangId::Vue
212 | LangId::Json
213 | LangId::Scala
214 | LangId::Html
215 | LangId::Markdown => false,
216 }
217}
218
219#[derive(Debug, Clone, Serialize)]
225pub struct TraceHop {
226 pub symbol: String,
228 pub file: String,
230 pub line: u32,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub signature: Option<String>,
235 pub is_entry_point: bool,
237}
238
239#[derive(Debug, Clone, Serialize)]
241pub struct TracePath {
242 pub hops: Vec<TraceHop>,
244}
245
246#[derive(Debug, Clone, Serialize)]
248pub struct TraceToResult {
249 pub target_symbol: String,
251 pub target_file: String,
253 pub paths: Vec<TracePath>,
255 pub total_paths: usize,
257 pub entry_points_found: usize,
259 pub max_depth_reached: bool,
261 pub truncated_paths: usize,
263}
264
265#[derive(Debug, Clone, Serialize)]
271pub struct ImpactCaller {
272 pub caller_symbol: String,
274 pub caller_file: String,
276 pub line: u32,
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub signature: Option<String>,
281 pub is_entry_point: bool,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub call_expression: Option<String>,
286 pub parameters: Vec<String>,
288}
289
290#[derive(Debug, Clone, Serialize)]
292pub struct ImpactResult {
293 pub symbol: String,
295 pub file: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub signature: Option<String>,
300 pub parameters: Vec<String>,
302 pub total_affected: usize,
304 pub affected_files: usize,
306 pub callers: Vec<ImpactCaller>,
308}
309
310#[derive(Debug, Clone, Serialize)]
316pub struct DataFlowHop {
317 pub file: String,
319 pub symbol: String,
321 pub variable: String,
323 pub line: u32,
325 pub flow_type: String,
327 pub approximate: bool,
329}
330
331#[derive(Debug, Clone, Serialize)]
334pub struct TraceDataResult {
335 pub expression: String,
337 pub origin_file: String,
339 pub origin_symbol: String,
341 pub hops: Vec<DataFlowHop>,
343 pub depth_limited: bool,
345}
346
347pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
353 let start = match signature.find('(') {
355 Some(i) => i + 1,
356 None => return Vec::new(),
357 };
358 let end = match signature[start..].find(')') {
359 Some(i) => start + i,
360 None => return Vec::new(),
361 };
362
363 let params_str = &signature[start..end].trim();
364 if params_str.is_empty() {
365 return Vec::new();
366 }
367
368 let parts = split_params(params_str);
370
371 let mut result = Vec::new();
372 for part in parts {
373 let trimmed = part.trim();
374 if trimmed.is_empty() {
375 continue;
376 }
377
378 match lang {
380 LangId::Rust => {
381 if trimmed == "self"
382 || trimmed == "mut self"
383 || trimmed.starts_with("&self")
384 || trimmed.starts_with("&mut self")
385 {
386 continue;
387 }
388 }
389 LangId::Python => {
390 if trimmed == "self" || trimmed.starts_with("self:") {
391 continue;
392 }
393 }
394 _ => {}
395 }
396
397 let name = extract_param_name(trimmed, lang);
399 if !name.is_empty() {
400 result.push(name);
401 }
402 }
403
404 result
405}
406
407fn split_params(s: &str) -> Vec<String> {
409 let mut parts = Vec::new();
410 let mut current = String::new();
411 let mut depth = 0i32;
412
413 for ch in s.chars() {
414 match ch {
415 '<' | '[' | '{' | '(' => {
416 depth += 1;
417 current.push(ch);
418 }
419 '>' | ']' | '}' | ')' => {
420 depth -= 1;
421 current.push(ch);
422 }
423 ',' if depth == 0 => {
424 parts.push(current.clone());
425 current.clear();
426 }
427 _ => {
428 current.push(ch);
429 }
430 }
431 }
432 if !current.is_empty() {
433 parts.push(current);
434 }
435 parts
436}
437
438fn extract_param_name(param: &str, lang: LangId) -> String {
446 let trimmed = param.trim();
447
448 let working = if trimmed.starts_with("...") {
450 &trimmed[3..]
451 } else if trimmed.starts_with("**") {
452 &trimmed[2..]
453 } else if trimmed.starts_with('*') && lang == LangId::Python {
454 &trimmed[1..]
455 } else {
456 trimmed
457 };
458
459 let working = if lang == LangId::Rust && working.starts_with("mut ") {
461 &working[4..]
462 } else {
463 working
464 };
465
466 let name = working
469 .split(|c: char| c == ':' || c == '=')
470 .next()
471 .unwrap_or("")
472 .trim();
473
474 let name = name.trim_end_matches('?');
476
477 if lang == LangId::Go && !name.contains(' ') {
479 return name.to_string();
480 }
481 if lang == LangId::Go {
482 return name.split_whitespace().next().unwrap_or("").to_string();
483 }
484
485 name.to_string()
486}
487
488pub struct CallGraph {
497 data: HashMap<PathBuf, FileCallData>,
499 project_root: PathBuf,
501 project_files: Option<Vec<PathBuf>>,
503 reverse_index: Option<ReverseIndex>,
506}
507
508impl CallGraph {
509 pub fn new(project_root: PathBuf) -> Self {
511 Self {
512 data: HashMap::new(),
513 project_root,
514 project_files: None,
515 reverse_index: None,
516 }
517 }
518
519 pub fn project_root(&self) -> &Path {
521 &self.project_root
522 }
523
524 fn resolve_cross_file_edge_with_exports<F>(
525 full_callee: &str,
526 short_name: &str,
527 caller_file: &Path,
528 import_block: &ImportBlock,
529 mut file_exports_symbol: F,
530 ) -> EdgeResolution
531 where
532 F: FnMut(&Path, &str) -> bool,
533 {
534 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
535
536 if full_callee.contains('.') {
538 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
539 if parts.len() == 2 {
540 let namespace = parts[0];
541 let member = parts[1];
542
543 for imp in &import_block.imports {
544 if imp.namespace_import.as_deref() == Some(namespace) {
545 if let Some(resolved_path) =
546 resolve_module_path(caller_dir, &imp.module_path)
547 {
548 return EdgeResolution::Resolved {
549 file: resolved_path,
550 symbol: member.to_owned(),
551 };
552 }
553 }
554 }
555 }
556 }
557
558 for imp in &import_block.imports {
560 if imp.names.iter().any(|name| name == short_name) {
562 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
563 return EdgeResolution::Resolved {
565 file: resolved_path,
566 symbol: short_name.to_owned(),
567 };
568 }
569 }
570
571 if imp.default_import.as_deref() == Some(short_name) {
573 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
574 return EdgeResolution::Resolved {
575 file: resolved_path,
576 symbol: "default".to_owned(),
577 };
578 }
579 }
580 }
581
582 if let Some((original_name, resolved_path)) =
587 resolve_aliased_import(short_name, import_block, caller_dir)
588 {
589 return EdgeResolution::Resolved {
590 file: resolved_path,
591 symbol: original_name,
592 };
593 }
594
595 for imp in &import_block.imports {
598 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
599 if resolved_path.is_dir() {
601 if let Some(index_path) = find_index_file(&resolved_path) {
602 if file_exports_symbol(&index_path, short_name) {
604 return EdgeResolution::Resolved {
605 file: index_path,
606 symbol: short_name.to_owned(),
607 };
608 }
609 }
610 } else if file_exports_symbol(&resolved_path, short_name) {
611 return EdgeResolution::Resolved {
612 file: resolved_path,
613 symbol: short_name.to_owned(),
614 };
615 }
616 }
617 }
618
619 EdgeResolution::Unresolved {
620 callee_name: short_name.to_owned(),
621 }
622 }
623
624 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
626 let canon = self.canonicalize(path)?;
627
628 if !self.data.contains_key(&canon) {
629 let file_data = build_file_data(&canon)?;
630 self.data.insert(canon.clone(), file_data);
631 }
632
633 Ok(&self.data[&canon])
634 }
635
636 pub fn resolve_cross_file_edge(
641 &mut self,
642 full_callee: &str,
643 short_name: &str,
644 caller_file: &Path,
645 import_block: &ImportBlock,
646 ) -> EdgeResolution {
647 Self::resolve_cross_file_edge_with_exports(
648 full_callee,
649 short_name,
650 caller_file,
651 import_block,
652 |path, symbol_name| self.file_exports_symbol(path, symbol_name),
653 )
654 }
655
656 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
658 match self.build_file(path) {
659 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
660 Err(_) => false,
661 }
662 }
663
664 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
665 self.lookup_file_data(path)
666 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
667 .unwrap_or(false)
668 }
669
670 pub fn forward_tree(
675 &mut self,
676 file: &Path,
677 symbol: &str,
678 max_depth: usize,
679 ) -> Result<CallTreeNode, AftError> {
680 let mut visited = HashSet::new();
681 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
682 }
683
684 fn forward_tree_inner(
685 &mut self,
686 file: &Path,
687 symbol: &str,
688 max_depth: usize,
689 current_depth: usize,
690 visited: &mut HashSet<(PathBuf, String)>,
691 ) -> Result<CallTreeNode, AftError> {
692 let canon = self.canonicalize(file)?;
693 let visit_key = (canon.clone(), symbol.to_string());
694
695 if visited.contains(&visit_key) {
697 let (line, signature) = self
698 .lookup_file_data(&canon)
699 .map(|data| get_symbol_meta_from_data(data, symbol))
700 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
701 return Ok(CallTreeNode {
702 name: symbol.to_string(),
703 file: self.relative_path(&canon),
704 line,
705 signature,
706 resolved: true,
707 children: vec![], });
709 }
710
711 visited.insert(visit_key.clone());
712
713 let (import_block, call_sites, sym_line, sym_signature) = {
714 let file_data = self.build_file(&canon)?;
715 let meta = get_symbol_meta_from_data(file_data, symbol);
716
717 (
718 file_data.import_block.clone(),
719 file_data
720 .calls_by_symbol
721 .get(symbol)
722 .cloned()
723 .unwrap_or_default(),
724 meta.0,
725 meta.1,
726 )
727 };
728
729 let mut children = Vec::new();
731
732 if current_depth < max_depth {
733 for call_site in &call_sites {
734 let edge = self.resolve_cross_file_edge(
735 &call_site.full_callee,
736 &call_site.callee_name,
737 &canon,
738 &import_block,
739 );
740
741 match edge {
742 EdgeResolution::Resolved {
743 file: ref target_file,
744 ref symbol,
745 } => {
746 match self.forward_tree_inner(
747 target_file,
748 symbol,
749 max_depth,
750 current_depth + 1,
751 visited,
752 ) {
753 Ok(child) => children.push(child),
754 Err(_) => {
755 children.push(CallTreeNode {
757 name: call_site.callee_name.clone(),
758 file: self.relative_path(target_file),
759 line: call_site.line,
760 signature: None,
761 resolved: false,
762 children: vec![],
763 });
764 }
765 }
766 }
767 EdgeResolution::Unresolved { callee_name } => {
768 children.push(CallTreeNode {
769 name: callee_name,
770 file: self.relative_path(&canon),
771 line: call_site.line,
772 signature: None,
773 resolved: false,
774 children: vec![],
775 });
776 }
777 }
778 }
779 }
780
781 visited.remove(&visit_key);
782
783 Ok(CallTreeNode {
784 name: symbol.to_string(),
785 file: self.relative_path(&canon),
786 line: sym_line,
787 signature: sym_signature,
788 resolved: true,
789 children,
790 })
791 }
792
793 pub fn project_files(&mut self) -> &[PathBuf] {
795 if self.project_files.is_none() {
796 let project_root = self.project_root.clone();
797 self.project_files = Some(walk_project_files(&project_root).collect());
798 }
799 self.project_files.as_deref().unwrap_or(&[])
800 }
801
802 pub fn project_file_count(&mut self) -> usize {
808 self.project_files().len()
809 }
810
811 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
822 if let Some(files) = self.project_files.as_deref() {
823 return files.len();
824 }
825 walk_project_files(&self.project_root)
826 .take(limit.saturating_add(1))
827 .count()
828 }
829
830 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
836 let count = self.project_file_count_bounded(max_files);
841 if count > max_files {
842 return Err(AftError::ProjectTooLarge {
843 count,
844 max: max_files,
845 });
846 }
847
848 let all_files = self.project_files().to_vec();
852
853 let uncached_files: Vec<PathBuf> = all_files
855 .iter()
856 .filter(|f| self.lookup_file_data(f).is_none())
857 .cloned()
858 .collect();
859
860 let computed: Vec<(PathBuf, FileCallData)> = uncached_files
861 .par_iter()
862 .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
863 .collect();
864
865 for (file, data) in computed {
866 self.data.insert(file, data);
867 }
868
869 let mut reverse: ReverseIndex = HashMap::new();
871
872 for caller_file in &all_files {
873 let canon_caller = Arc::new(
875 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
876 );
877 let file_data = match self
878 .data
879 .get(caller_file)
880 .or_else(|| self.data.get(canon_caller.as_ref()))
881 {
882 Some(d) => d,
883 None => continue,
884 };
885
886 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
887 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
888
889 for call_site in call_sites {
890 let edge = Self::resolve_cross_file_edge_with_exports(
891 &call_site.full_callee,
892 &call_site.callee_name,
893 canon_caller.as_ref(),
894 &file_data.import_block,
895 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
896 );
897
898 let (target_file, target_symbol, resolved) = match edge {
899 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
900 EdgeResolution::Unresolved { callee_name } => {
901 (canon_caller.as_ref().clone(), callee_name, false)
902 }
903 };
904
905 reverse
906 .entry(target_file)
907 .or_default()
908 .entry(target_symbol)
909 .or_default()
910 .push(IndexedCallerSite {
911 caller_file: Arc::clone(&canon_caller),
912 caller_symbol: Arc::clone(&caller_symbol),
913 line: call_site.line,
914 col: 0,
915 resolved,
916 });
917 }
918 }
919 }
920
921 self.reverse_index = Some(reverse);
922 Ok(())
923 }
924
925 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
926 self.reverse_index
927 .as_ref()?
928 .get(file)?
929 .get(symbol)
930 .map(Vec::as_slice)
931 }
932
933 pub fn callers_of(
939 &mut self,
940 file: &Path,
941 symbol: &str,
942 depth: usize,
943 max_files: usize,
944 ) -> Result<CallersResult, AftError> {
945 let canon = self.canonicalize(file)?;
946
947 self.build_file(&canon)?;
949
950 if self.reverse_index.is_none() {
952 self.build_reverse_index(max_files)?;
953 }
954
955 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
956 let effective_depth = if depth == 0 { 1 } else { depth };
957
958 let mut visited = HashSet::new();
959 let mut all_sites: Vec<CallerSite> = Vec::new();
960 self.collect_callers_recursive(
961 &canon,
962 symbol,
963 effective_depth,
964 0,
965 &mut visited,
966 &mut all_sites,
967 );
968
969 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
972 let total_callers = all_sites.len();
973 for site in all_sites {
974 let caller_file: PathBuf = site.caller_file;
975 let caller_symbol: String = site.caller_symbol;
976 let line = site.line;
977 let entry = CallerEntry {
978 symbol: caller_symbol,
979 line,
980 };
981
982 if let Some(entries) = groups_map.get_mut(&caller_file) {
983 entries.push(entry);
984 } else {
985 groups_map.insert(caller_file, vec![entry]);
986 }
987 }
988
989 let mut callers: Vec<CallerGroup> = groups_map
990 .into_iter()
991 .map(|(file_path, entries)| CallerGroup {
992 file: self.relative_path(&file_path),
993 callers: entries,
994 })
995 .collect();
996
997 callers.sort_by(|a, b| a.file.cmp(&b.file));
999
1000 Ok(CallersResult {
1001 symbol: symbol.to_string(),
1002 file: self.relative_path(&canon),
1003 callers,
1004 total_callers,
1005 scanned_files,
1006 })
1007 }
1008
1009 pub fn trace_to(
1015 &mut self,
1016 file: &Path,
1017 symbol: &str,
1018 max_depth: usize,
1019 max_files: usize,
1020 ) -> Result<TraceToResult, AftError> {
1021 let canon = self.canonicalize(file)?;
1022
1023 self.build_file(&canon)?;
1025
1026 if self.reverse_index.is_none() {
1028 self.build_reverse_index(max_files)?;
1029 }
1030
1031 let target_rel = self.relative_path(&canon);
1032 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1033 if self.reverse_index.is_none() {
1034 return Err(AftError::ParseError {
1035 message: format!(
1036 "reverse index unavailable after building callers for {}",
1037 canon.display()
1038 ),
1039 });
1040 }
1041
1042 let (target_line, target_sig) = self
1044 .lookup_file_data(&canon)
1045 .map(|data| get_symbol_meta_from_data(data, symbol))
1046 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1047
1048 let target_is_entry = self
1050 .lookup_file_data(&canon)
1051 .and_then(|fd| {
1052 let meta = fd.symbol_metadata.get(symbol)?;
1053 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1054 })
1055 .unwrap_or(false);
1056
1057 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1060 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1061 let mut max_depth_reached = false;
1062 let mut truncated_paths: usize = 0;
1063
1064 let initial: Vec<PathElem> = vec![(
1066 Arc::new(canon.clone()),
1067 Arc::from(symbol),
1068 target_line,
1069 target_sig,
1070 )];
1071
1072 if target_is_entry {
1074 complete_paths.push(initial.clone());
1075 }
1076
1077 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1079
1080 while let Some((path, depth)) = queue.pop() {
1081 if depth >= effective_max {
1082 max_depth_reached = true;
1083 continue;
1084 }
1085
1086 let Some((current_file, current_symbol, _, _)) = path.last() else {
1087 continue;
1088 };
1089
1090 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1092 Some(sites) => sites,
1093 None => {
1094 if path.len() > 1 {
1097 truncated_paths += 1;
1100 }
1101 continue;
1102 }
1103 };
1104
1105 let mut has_new_path = false;
1106 for site in callers {
1107 if path.iter().any(|(file_path, sym, _, _)| {
1109 file_path.as_ref() == site.caller_file.as_ref()
1110 && sym.as_ref() == site.caller_symbol.as_ref()
1111 }) {
1112 continue;
1113 }
1114
1115 has_new_path = true;
1116
1117 let (caller_line, caller_sig) = self
1119 .lookup_file_data(site.caller_file.as_ref())
1120 .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1121 .unwrap_or_else(|| {
1122 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1123 });
1124
1125 let mut new_path = path.clone();
1126 new_path.push((
1127 Arc::clone(&site.caller_file),
1128 Arc::clone(&site.caller_symbol),
1129 caller_line,
1130 caller_sig,
1131 ));
1132
1133 let caller_is_entry = self
1137 .lookup_file_data(site.caller_file.as_ref())
1138 .and_then(|fd| {
1139 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1140 Some(is_entry_point(
1141 site.caller_symbol.as_ref(),
1142 &meta.kind,
1143 meta.exported,
1144 fd.lang,
1145 ))
1146 })
1147 .unwrap_or(false);
1148
1149 if caller_is_entry {
1150 complete_paths.push(new_path.clone());
1151 }
1152 queue.push((new_path, depth + 1));
1155 }
1156
1157 if !has_new_path && path.len() > 1 {
1159 truncated_paths += 1;
1160 }
1161 }
1162
1163 let mut paths: Vec<TracePath> = complete_paths
1166 .into_iter()
1167 .map(|mut elems| {
1168 elems.reverse();
1169 let hops: Vec<TraceHop> = elems
1170 .iter()
1171 .enumerate()
1172 .map(|(i, (file_path, sym, line, sig))| {
1173 let is_ep = if i == 0 {
1174 self.lookup_file_data(file_path.as_ref())
1176 .and_then(|fd| {
1177 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1178 Some(is_entry_point(
1179 sym.as_ref(),
1180 &meta.kind,
1181 meta.exported,
1182 fd.lang,
1183 ))
1184 })
1185 .unwrap_or(false)
1186 } else {
1187 false
1188 };
1189 TraceHop {
1190 symbol: sym.to_string(),
1191 file: self.relative_path(file_path.as_ref()),
1192 line: *line,
1193 signature: sig.clone(),
1194 is_entry_point: is_ep,
1195 }
1196 })
1197 .collect();
1198 TracePath { hops }
1199 })
1200 .collect();
1201
1202 paths.sort_by(|a, b| {
1204 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1205 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1206 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1207 });
1208
1209 let mut entry_point_names: HashSet<String> = HashSet::new();
1211 for p in &paths {
1212 if let Some(first) = p.hops.first() {
1213 if first.is_entry_point {
1214 entry_point_names.insert(first.symbol.clone());
1215 }
1216 }
1217 }
1218
1219 let total_paths = paths.len();
1220 let entry_points_found = entry_point_names.len();
1221
1222 Ok(TraceToResult {
1223 target_symbol: symbol.to_string(),
1224 target_file: target_rel,
1225 paths,
1226 total_paths,
1227 entry_points_found,
1228 max_depth_reached,
1229 truncated_paths,
1230 })
1231 }
1232
1233 pub fn impact(
1239 &mut self,
1240 file: &Path,
1241 symbol: &str,
1242 depth: usize,
1243 max_files: usize,
1244 ) -> Result<ImpactResult, AftError> {
1245 let canon = self.canonicalize(file)?;
1246
1247 self.build_file(&canon)?;
1249
1250 if self.reverse_index.is_none() {
1252 self.build_reverse_index(max_files)?;
1253 }
1254
1255 let effective_depth = if depth == 0 { 1 } else { depth };
1256
1257 let (target_signature, target_parameters, target_lang) = {
1259 let file_data = match self.data.get(&canon) {
1260 Some(d) => d,
1261 None => {
1262 return Err(AftError::InvalidRequest {
1263 message: "file data missing after build".to_string(),
1264 })
1265 }
1266 };
1267 let meta = file_data.symbol_metadata.get(symbol);
1268 let sig = meta.and_then(|m| m.signature.clone());
1269 let lang = file_data.lang;
1270 let params = sig
1271 .as_deref()
1272 .map(|s| extract_parameters(s, lang))
1273 .unwrap_or_default();
1274 (sig, params, lang)
1275 };
1276
1277 let mut visited = HashSet::new();
1279 let mut all_sites: Vec<CallerSite> = Vec::new();
1280 self.collect_callers_recursive(
1281 &canon,
1282 symbol,
1283 effective_depth,
1284 0,
1285 &mut visited,
1286 &mut all_sites,
1287 );
1288
1289 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1291 all_sites.retain(|site| {
1292 seen.insert((
1293 site.caller_file.clone(),
1294 site.caller_symbol.clone(),
1295 site.line,
1296 ))
1297 });
1298
1299 let mut callers = Vec::new();
1301 let mut affected_file_set = HashSet::new();
1302
1303 for site in &all_sites {
1304 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1306 log::debug!(
1307 "callgraph: skipping caller file {}: {}",
1308 site.caller_file.display(),
1309 e
1310 );
1311 }
1312
1313 let (sig, is_ep, params, _lang) = {
1314 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1315 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1316 let sig = meta.and_then(|m| m.signature.clone());
1317 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1318 let exported = meta.map(|m| m.exported).unwrap_or(false);
1319 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1320 let lang = fd.lang;
1321 let params = sig
1322 .as_deref()
1323 .map(|s| extract_parameters(s, lang))
1324 .unwrap_or_default();
1325 (sig, is_ep, params, lang)
1326 } else {
1327 (None, false, Vec::new(), target_lang)
1328 }
1329 };
1330
1331 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1333
1334 let rel_file = self.relative_path(site.caller_file.as_path());
1335 affected_file_set.insert(rel_file.clone());
1336
1337 callers.push(ImpactCaller {
1338 caller_symbol: site.caller_symbol.clone(),
1339 caller_file: rel_file,
1340 line: site.line,
1341 signature: sig,
1342 is_entry_point: is_ep,
1343 call_expression,
1344 parameters: params,
1345 });
1346 }
1347
1348 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1350
1351 let total_affected = callers.len();
1352 let affected_files = affected_file_set.len();
1353
1354 Ok(ImpactResult {
1355 symbol: symbol.to_string(),
1356 file: self.relative_path(&canon),
1357 signature: target_signature,
1358 parameters: target_parameters,
1359 total_affected,
1360 affected_files,
1361 callers,
1362 })
1363 }
1364
1365 pub fn trace_data(
1376 &mut self,
1377 file: &Path,
1378 symbol: &str,
1379 expression: &str,
1380 max_depth: usize,
1381 max_files: usize,
1382 ) -> Result<TraceDataResult, AftError> {
1383 let canon = self.canonicalize(file)?;
1384 let rel_file = self.relative_path(&canon);
1385
1386 self.build_file(&canon)?;
1388
1389 {
1391 let fd = match self.data.get(&canon) {
1392 Some(d) => d,
1393 None => {
1394 return Err(AftError::InvalidRequest {
1395 message: "file data missing after build".to_string(),
1396 })
1397 }
1398 };
1399 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1400 || fd.exported_symbols.iter().any(|name| name == symbol)
1401 || fd.symbol_metadata.contains_key(symbol);
1402 if !has_symbol {
1403 return Err(AftError::InvalidRequest {
1404 message: format!(
1405 "trace_data: symbol '{}' not found in {}",
1406 symbol,
1407 file.display()
1408 ),
1409 });
1410 }
1411 }
1412
1413 let count = self.project_file_count_bounded(max_files);
1417 if count > max_files {
1418 return Err(AftError::ProjectTooLarge {
1419 count,
1420 max: max_files,
1421 });
1422 }
1423
1424 let mut hops = Vec::new();
1425 let mut depth_limited = false;
1426
1427 self.trace_data_inner(
1428 &canon,
1429 symbol,
1430 expression,
1431 max_depth,
1432 0,
1433 &mut hops,
1434 &mut depth_limited,
1435 &mut HashSet::new(),
1436 );
1437
1438 Ok(TraceDataResult {
1439 expression: expression.to_string(),
1440 origin_file: rel_file,
1441 origin_symbol: symbol.to_string(),
1442 hops,
1443 depth_limited,
1444 })
1445 }
1446
1447 fn trace_data_inner(
1449 &mut self,
1450 file: &Path,
1451 symbol: &str,
1452 tracking_name: &str,
1453 max_depth: usize,
1454 current_depth: usize,
1455 hops: &mut Vec<DataFlowHop>,
1456 depth_limited: &mut bool,
1457 visited: &mut HashSet<(PathBuf, String, String)>,
1458 ) {
1459 let visit_key = (
1460 file.to_path_buf(),
1461 symbol.to_string(),
1462 tracking_name.to_string(),
1463 );
1464 if visited.contains(&visit_key) {
1465 return; }
1467 visited.insert(visit_key);
1468
1469 let source = match std::fs::read_to_string(file) {
1471 Ok(s) => s,
1472 Err(_) => return,
1473 };
1474
1475 let lang = match detect_language(file) {
1476 Some(l) => l,
1477 None => return,
1478 };
1479
1480 let grammar = grammar_for(lang);
1481 let mut parser = Parser::new();
1482 if parser.set_language(&grammar).is_err() {
1483 return;
1484 }
1485 let tree = match parser.parse(&source, None) {
1486 Some(t) => t,
1487 None => return,
1488 };
1489
1490 let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1492 Ok(symbols) => symbols,
1493 Err(_) => return,
1494 };
1495 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1496 Some(s) => s,
1497 None => return,
1498 };
1499
1500 let body_start =
1501 line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1502 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1503
1504 let root = tree.root_node();
1505
1506 let body_node = match find_node_covering_range(root, body_start, body_end) {
1508 Some(n) => n,
1509 None => return,
1510 };
1511
1512 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1514 let rel_file = self.relative_path(file);
1515
1516 self.walk_for_data_flow(
1518 body_node,
1519 &source,
1520 &mut tracked_names,
1521 file,
1522 symbol,
1523 &rel_file,
1524 lang,
1525 max_depth,
1526 current_depth,
1527 hops,
1528 depth_limited,
1529 visited,
1530 );
1531 }
1532
1533 #[allow(clippy::too_many_arguments)]
1536 fn walk_for_data_flow(
1537 &mut self,
1538 node: tree_sitter::Node,
1539 source: &str,
1540 tracked_names: &mut Vec<String>,
1541 file: &Path,
1542 symbol: &str,
1543 rel_file: &str,
1544 lang: LangId,
1545 max_depth: usize,
1546 current_depth: usize,
1547 hops: &mut Vec<DataFlowHop>,
1548 depth_limited: &mut bool,
1549 visited: &mut HashSet<(PathBuf, String, String)>,
1550 ) {
1551 let kind = node.kind();
1552
1553 let is_var_decl = matches!(
1555 kind,
1556 "variable_declarator"
1557 | "assignment_expression"
1558 | "augmented_assignment_expression"
1559 | "assignment"
1560 | "let_declaration"
1561 | "short_var_declaration"
1562 );
1563
1564 if is_var_decl {
1565 if let Some((new_name, init_text, line, is_approx)) =
1566 self.extract_assignment_info(node, source, lang, tracked_names)
1567 {
1568 if !is_approx {
1570 hops.push(DataFlowHop {
1571 file: rel_file.to_string(),
1572 symbol: symbol.to_string(),
1573 variable: new_name.clone(),
1574 line,
1575 flow_type: "assignment".to_string(),
1576 approximate: false,
1577 });
1578 tracked_names.push(new_name);
1579 } else {
1580 hops.push(DataFlowHop {
1582 file: rel_file.to_string(),
1583 symbol: symbol.to_string(),
1584 variable: init_text,
1585 line,
1586 flow_type: "assignment".to_string(),
1587 approximate: true,
1588 });
1589 return;
1591 }
1592 }
1593 }
1594
1595 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1597 self.check_call_for_data_flow(
1598 node,
1599 source,
1600 tracked_names,
1601 file,
1602 symbol,
1603 rel_file,
1604 lang,
1605 max_depth,
1606 current_depth,
1607 hops,
1608 depth_limited,
1609 visited,
1610 );
1611 }
1612
1613 let mut cursor = node.walk();
1615 if cursor.goto_first_child() {
1616 loop {
1617 let child = cursor.node();
1618 self.walk_for_data_flow(
1620 child,
1621 source,
1622 tracked_names,
1623 file,
1624 symbol,
1625 rel_file,
1626 lang,
1627 max_depth,
1628 current_depth,
1629 hops,
1630 depth_limited,
1631 visited,
1632 );
1633 if !cursor.goto_next_sibling() {
1634 break;
1635 }
1636 }
1637 }
1638 }
1639
1640 fn extract_assignment_info(
1643 &self,
1644 node: tree_sitter::Node,
1645 source: &str,
1646 _lang: LangId,
1647 tracked_names: &[String],
1648 ) -> Option<(String, String, u32, bool)> {
1649 let kind = node.kind();
1650 let line = node.start_position().row as u32 + 1;
1651
1652 match kind {
1653 "variable_declarator" => {
1654 let name_node = node.child_by_field_name("name")?;
1656 let value_node = node.child_by_field_name("value")?;
1657 let name_text = node_text(name_node, source);
1658 let value_text = node_text(value_node, source);
1659
1660 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1662 if tracked_names.iter().any(|t| value_text.contains(t)) {
1664 return Some((name_text.clone(), name_text, line, true));
1665 }
1666 return None;
1667 }
1668
1669 if tracked_names.iter().any(|t| {
1671 value_text == *t
1672 || value_text.starts_with(&format!("{}.", t))
1673 || value_text.starts_with(&format!("{}[", t))
1674 }) {
1675 return Some((name_text, value_text, line, false));
1676 }
1677 None
1678 }
1679 "assignment_expression" | "augmented_assignment_expression" => {
1680 let left = node.child_by_field_name("left")?;
1682 let right = node.child_by_field_name("right")?;
1683 let left_text = node_text(left, source);
1684 let right_text = node_text(right, source);
1685
1686 if tracked_names.iter().any(|t| right_text == *t) {
1687 return Some((left_text, right_text, line, false));
1688 }
1689 None
1690 }
1691 "assignment" => {
1692 let left = node.child_by_field_name("left")?;
1694 let right = node.child_by_field_name("right")?;
1695 let left_text = node_text(left, source);
1696 let right_text = node_text(right, source);
1697
1698 if tracked_names.iter().any(|t| right_text == *t) {
1699 return Some((left_text, right_text, line, false));
1700 }
1701 None
1702 }
1703 "let_declaration" | "short_var_declaration" => {
1704 let left = node
1706 .child_by_field_name("pattern")
1707 .or_else(|| node.child_by_field_name("left"))?;
1708 let right = node
1709 .child_by_field_name("value")
1710 .or_else(|| node.child_by_field_name("right"))?;
1711 let left_text = node_text(left, source);
1712 let right_text = node_text(right, source);
1713
1714 if tracked_names.iter().any(|t| right_text == *t) {
1715 return Some((left_text, right_text, line, false));
1716 }
1717 None
1718 }
1719 _ => None,
1720 }
1721 }
1722
1723 #[allow(clippy::too_many_arguments)]
1726 fn check_call_for_data_flow(
1727 &mut self,
1728 node: tree_sitter::Node,
1729 source: &str,
1730 tracked_names: &[String],
1731 file: &Path,
1732 _symbol: &str,
1733 rel_file: &str,
1734 _lang: LangId,
1735 max_depth: usize,
1736 current_depth: usize,
1737 hops: &mut Vec<DataFlowHop>,
1738 depth_limited: &mut bool,
1739 visited: &mut HashSet<(PathBuf, String, String)>,
1740 ) {
1741 let args_node = find_child_by_kind(node, "arguments")
1743 .or_else(|| find_child_by_kind(node, "argument_list"));
1744
1745 let args_node = match args_node {
1746 Some(n) => n,
1747 None => return,
1748 };
1749
1750 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1753
1754 let mut cursor = args_node.walk();
1755 if cursor.goto_first_child() {
1756 loop {
1757 let child = cursor.node();
1758 let child_kind = child.kind();
1759
1760 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1762 if !cursor.goto_next_sibling() {
1763 break;
1764 }
1765 continue;
1766 }
1767
1768 let arg_text = node_text(child, source);
1769
1770 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1772 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1773 hops.push(DataFlowHop {
1774 file: rel_file.to_string(),
1775 symbol: _symbol.to_string(),
1776 variable: arg_text,
1777 line: child.start_position().row as u32 + 1,
1778 flow_type: "parameter".to_string(),
1779 approximate: true,
1780 });
1781 }
1782 if !cursor.goto_next_sibling() {
1783 break;
1784 }
1785 arg_idx += 1;
1786 continue;
1787 }
1788
1789 if tracked_names.iter().any(|t| arg_text == *t) {
1790 arg_positions.push((arg_idx, arg_text));
1791 }
1792
1793 arg_idx += 1;
1794 if !cursor.goto_next_sibling() {
1795 break;
1796 }
1797 }
1798 }
1799
1800 if arg_positions.is_empty() {
1801 return;
1802 }
1803
1804 let (full_callee, short_callee) = extract_callee_names(node, source);
1806 let full_callee = match full_callee {
1807 Some(f) => f,
1808 None => return,
1809 };
1810 let short_callee = match short_callee {
1811 Some(s) => s,
1812 None => return,
1813 };
1814
1815 let import_block = {
1817 match self.data.get(file) {
1818 Some(fd) => fd.import_block.clone(),
1819 None => return,
1820 }
1821 };
1822
1823 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1824
1825 match edge {
1826 EdgeResolution::Resolved {
1827 file: target_file,
1828 symbol: target_symbol,
1829 } => {
1830 if current_depth + 1 > max_depth {
1831 *depth_limited = true;
1832 return;
1833 }
1834
1835 if let Err(e) = self.build_file(&target_file) {
1837 log::debug!(
1838 "callgraph: skipping target file {}: {}",
1839 target_file.display(),
1840 e
1841 );
1842 }
1843 let (params, target_line) = {
1844 match self.lookup_file_data(&target_file) {
1845 Some(fd) => {
1846 let meta = fd.symbol_metadata.get(&target_symbol);
1847 let sig = meta.and_then(|m| m.signature.clone());
1848 let params = sig
1849 .as_deref()
1850 .map(|s| extract_parameters(s, fd.lang))
1851 .unwrap_or_default();
1852 let line = meta.map(|m| m.line).unwrap_or(1);
1853 (params, line)
1854 }
1855 None => return,
1856 }
1857 };
1858
1859 let target_rel = self.relative_path(&target_file);
1860
1861 for (pos, _tracked) in &arg_positions {
1862 if let Some(param_name) = params.get(*pos) {
1863 hops.push(DataFlowHop {
1865 file: target_rel.clone(),
1866 symbol: target_symbol.clone(),
1867 variable: param_name.clone(),
1868 line: target_line,
1869 flow_type: "parameter".to_string(),
1870 approximate: false,
1871 });
1872
1873 self.trace_data_inner(
1875 &target_file.clone(),
1876 &target_symbol.clone(),
1877 param_name,
1878 max_depth,
1879 current_depth + 1,
1880 hops,
1881 depth_limited,
1882 visited,
1883 );
1884 }
1885 }
1886 }
1887 EdgeResolution::Unresolved { callee_name } => {
1888 let has_local = self
1890 .data
1891 .get(file)
1892 .map(|fd| {
1893 fd.calls_by_symbol.contains_key(&callee_name)
1894 || fd.symbol_metadata.contains_key(&callee_name)
1895 })
1896 .unwrap_or(false);
1897
1898 if has_local {
1899 let (params, target_line) = {
1901 let Some(fd) = self.data.get(file) else {
1902 return;
1903 };
1904 let meta = fd.symbol_metadata.get(&callee_name);
1905 let sig = meta.and_then(|m| m.signature.clone());
1906 let params = sig
1907 .as_deref()
1908 .map(|s| extract_parameters(s, fd.lang))
1909 .unwrap_or_default();
1910 let line = meta.map(|m| m.line).unwrap_or(1);
1911 (params, line)
1912 };
1913
1914 let file_rel = self.relative_path(file);
1915
1916 for (pos, _tracked) in &arg_positions {
1917 if let Some(param_name) = params.get(*pos) {
1918 hops.push(DataFlowHop {
1919 file: file_rel.clone(),
1920 symbol: callee_name.clone(),
1921 variable: param_name.clone(),
1922 line: target_line,
1923 flow_type: "parameter".to_string(),
1924 approximate: false,
1925 });
1926
1927 self.trace_data_inner(
1929 file,
1930 &callee_name.clone(),
1931 param_name,
1932 max_depth,
1933 current_depth + 1,
1934 hops,
1935 depth_limited,
1936 visited,
1937 );
1938 }
1939 }
1940 } else {
1941 for (_pos, tracked) in &arg_positions {
1943 hops.push(DataFlowHop {
1944 file: self.relative_path(file),
1945 symbol: callee_name.clone(),
1946 variable: tracked.clone(),
1947 line: node.start_position().row as u32 + 1,
1948 flow_type: "parameter".to_string(),
1949 approximate: true,
1950 });
1951 }
1952 }
1953 }
1954 }
1955 }
1956
1957 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1959 let content = std::fs::read_to_string(path).ok()?;
1960 content
1961 .lines()
1962 .nth(line.saturating_sub(1) as usize)
1963 .map(|l| l.trim().to_string())
1964 }
1965
1966 fn collect_callers_recursive(
1968 &self,
1969 file: &Path,
1970 symbol: &str,
1971 max_depth: usize,
1972 current_depth: usize,
1973 visited: &mut HashSet<(PathBuf, SharedStr)>,
1974 result: &mut Vec<CallerSite>,
1975 ) {
1976 if current_depth >= max_depth {
1977 return;
1978 }
1979
1980 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1982 let key_symbol: SharedStr = Arc::from(symbol);
1983 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
1984 return; }
1986
1987 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
1988 for site in sites {
1989 result.push(CallerSite {
1990 caller_file: site.caller_file.as_ref().clone(),
1991 caller_symbol: site.caller_symbol.to_string(),
1992 line: site.line,
1993 col: site.col,
1994 resolved: site.resolved,
1995 });
1996 if current_depth + 1 < max_depth {
1998 self.collect_callers_recursive(
1999 site.caller_file.as_ref(),
2000 site.caller_symbol.as_ref(),
2001 max_depth,
2002 current_depth + 1,
2003 visited,
2004 result,
2005 );
2006 }
2007 }
2008 }
2009 }
2010
2011 pub fn invalidate_file(&mut self, path: &Path) {
2016 self.data.remove(path);
2018 if let Ok(canon) = self.canonicalize(path) {
2019 self.data.remove(&canon);
2020 }
2021 self.reverse_index = None;
2023 self.project_files = None;
2025 }
2026
2027 fn relative_path(&self, path: &Path) -> String {
2030 path.strip_prefix(&self.project_root)
2031 .unwrap_or(path)
2032 .display()
2033 .to_string()
2034 }
2035
2036 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2038 let full_path = if path.is_relative() {
2040 self.project_root.join(path)
2041 } else {
2042 path.to_path_buf()
2043 };
2044
2045 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2047 }
2048
2049 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2053 if let Some(fd) = self.data.get(path) {
2054 return Some(fd);
2055 }
2056 let canon = std::fs::canonicalize(path).ok()?;
2058 self.data.get(&canon).or_else(|| {
2059 self.data.iter().find_map(|(k, v)| {
2061 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2062 Some(v)
2063 } else {
2064 None
2065 }
2066 })
2067 })
2068 }
2069}
2070
2071fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2077 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2078 message: format!("unsupported file for call graph: {}", path.display()),
2079 })?;
2080
2081 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2082 path: format!("{}: {}", path.display(), e),
2083 })?;
2084
2085 let grammar = grammar_for(lang);
2086 let mut parser = Parser::new();
2087 parser
2088 .set_language(&grammar)
2089 .map_err(|e| AftError::ParseError {
2090 message: format!("grammar init failed for {:?}: {}", lang, e),
2091 })?;
2092
2093 let tree = parser
2094 .parse(&source, None)
2095 .ok_or_else(|| AftError::ParseError {
2096 message: format!("parse failed for {}", path.display()),
2097 })?;
2098
2099 let import_block = imports::parse_imports(&source, &tree, lang);
2101
2102 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2104
2105 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2107 let root = tree.root_node();
2108
2109 for sym in &symbols {
2110 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2111 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2112
2113 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2114
2115 let sites: Vec<CallSite> = raw_calls
2116 .into_iter()
2117 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
2119 callee_name: short,
2120 full_callee: full,
2121 line,
2122 byte_start,
2123 byte_end,
2124 })
2125 .collect();
2126
2127 if !sites.is_empty() {
2128 calls_by_symbol.insert(sym.name.clone(), sites);
2129 }
2130 }
2131
2132 let exported_symbols: Vec<String> = symbols
2134 .iter()
2135 .filter(|s| s.exported)
2136 .map(|s| s.name.clone())
2137 .collect();
2138
2139 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
2141 .iter()
2142 .map(|s| {
2143 (
2144 s.name.clone(),
2145 SymbolMeta {
2146 kind: s.kind.clone(),
2147 exported: s.exported,
2148 signature: s.signature.clone(),
2149 line: s.range.start_line + 1,
2150 range: s.range.clone(),
2151 },
2152 )
2153 })
2154 .collect();
2155
2156 Ok(FileCallData {
2157 calls_by_symbol,
2158 exported_symbols,
2159 symbol_metadata,
2160 import_block,
2161 lang,
2162 })
2163}
2164
2165fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2166 file_data
2167 .symbol_metadata
2168 .get(symbol_name)
2169 .map(|meta| (meta.line, meta.signature.clone()))
2170 .unwrap_or((1, None))
2171}
2172
2173fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2175 let provider = crate::parser::TreeSitterProvider::new();
2176 match provider.list_symbols(path) {
2177 Ok(symbols) => {
2178 for s in &symbols {
2179 if s.name == symbol_name {
2180 return (s.range.start_line + 1, s.signature.clone());
2181 }
2182 }
2183 (1, None)
2184 }
2185 Err(_) => (1, None),
2186 }
2187}
2188
2189fn node_text(node: tree_sitter::Node, source: &str) -> String {
2195 source[node.start_byte()..node.end_byte()].to_string()
2196}
2197
2198fn find_node_covering_range(
2200 root: tree_sitter::Node,
2201 start: usize,
2202 end: usize,
2203) -> Option<tree_sitter::Node> {
2204 let mut best = None;
2205 let mut cursor = root.walk();
2206
2207 fn walk_covering<'a>(
2208 cursor: &mut tree_sitter::TreeCursor<'a>,
2209 start: usize,
2210 end: usize,
2211 best: &mut Option<tree_sitter::Node<'a>>,
2212 ) {
2213 let node = cursor.node();
2214 if node.start_byte() <= start && node.end_byte() >= end {
2215 *best = Some(node);
2216 if cursor.goto_first_child() {
2217 loop {
2218 walk_covering(cursor, start, end, best);
2219 if !cursor.goto_next_sibling() {
2220 break;
2221 }
2222 }
2223 cursor.goto_parent();
2224 }
2225 }
2226 }
2227
2228 walk_covering(&mut cursor, start, end, &mut best);
2229 best
2230}
2231
2232fn find_child_by_kind<'a>(
2234 node: tree_sitter::Node<'a>,
2235 kind: &str,
2236) -> Option<tree_sitter::Node<'a>> {
2237 let mut cursor = node.walk();
2238 if cursor.goto_first_child() {
2239 loop {
2240 if cursor.node().kind() == kind {
2241 return Some(cursor.node());
2242 }
2243 if !cursor.goto_next_sibling() {
2244 break;
2245 }
2246 }
2247 }
2248 None
2249}
2250
2251fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2253 let callee = match node.child_by_field_name("function") {
2255 Some(c) => c,
2256 None => return (None, None),
2257 };
2258
2259 let full = node_text(callee, source);
2260 let short = if full.contains('.') {
2261 full.rsplit('.').next().unwrap_or(&full).to_string()
2262 } else {
2263 full.clone()
2264 };
2265
2266 (Some(full), Some(short))
2267}
2268
2269pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2277 if !module_path.starts_with('.') {
2279 return None;
2280 }
2281
2282 let base = from_dir.join(module_path);
2283
2284 if base.is_file() {
2286 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2287 }
2288
2289 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2291 for ext in &extensions {
2292 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2293 if with_ext.is_file() {
2294 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2295 }
2296 }
2297
2298 if base.is_dir() {
2300 if let Some(index) = find_index_file(&base) {
2301 return Some(index);
2302 }
2303 }
2304
2305 None
2306}
2307
2308fn find_index_file(dir: &Path) -> Option<PathBuf> {
2310 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2311 for name in &candidates {
2312 let p = dir.join(name);
2313 if p.is_file() {
2314 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2315 }
2316 }
2317 None
2318}
2319
2320fn resolve_aliased_import(
2323 local_name: &str,
2324 import_block: &ImportBlock,
2325 caller_dir: &Path,
2326) -> Option<(String, PathBuf)> {
2327 for imp in &import_block.imports {
2328 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2331 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2332 return Some((original, resolved_path));
2333 }
2334 }
2335 }
2336 None
2337}
2338
2339fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2343 let search = format!(" as {}", local_name);
2346 if let Some(pos) = raw_import.find(&search) {
2347 let before = &raw_import[..pos];
2349 let original = before
2351 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2352 .find(|s| !s.is_empty())?;
2353 return Some(original.to_string());
2354 }
2355 None
2356}
2357
2358pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2366 use ignore::WalkBuilder;
2367
2368 let walker = WalkBuilder::new(root)
2369 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2374 let name = entry.file_name().to_string_lossy();
2375 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2377 return !matches!(
2378 name.as_ref(),
2379 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2380 | ".tox" | "dist" | "build"
2381 );
2382 }
2383 true
2384 })
2385 .build();
2386
2387 walker
2388 .filter_map(|entry| entry.ok())
2389 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2390 .filter(|entry| detect_language(entry.path()).is_some())
2391 .map(|entry| entry.into_path())
2392}
2393
2394#[cfg(test)]
2399mod tests {
2400 use super::*;
2401 use std::fs;
2402 use tempfile::TempDir;
2403
2404 fn setup_ts_project() -> TempDir {
2406 let dir = TempDir::new().unwrap();
2407
2408 fs::write(
2410 dir.path().join("main.ts"),
2411 r#"import { helper, compute } from './utils';
2412import * as math from './math';
2413
2414export function main() {
2415 const a = helper(1);
2416 const b = compute(a, 2);
2417 const c = math.add(a, b);
2418 return c;
2419}
2420"#,
2421 )
2422 .unwrap();
2423
2424 fs::write(
2426 dir.path().join("utils.ts"),
2427 r#"import { double } from './helpers';
2428
2429export function helper(x: number): number {
2430 return double(x);
2431}
2432
2433export function compute(a: number, b: number): number {
2434 return a + b;
2435}
2436"#,
2437 )
2438 .unwrap();
2439
2440 fs::write(
2442 dir.path().join("helpers.ts"),
2443 r#"export function double(x: number): number {
2444 return x * 2;
2445}
2446
2447export function triple(x: number): number {
2448 return x * 3;
2449}
2450"#,
2451 )
2452 .unwrap();
2453
2454 fs::write(
2456 dir.path().join("math.ts"),
2457 r#"export function add(a: number, b: number): number {
2458 return a + b;
2459}
2460
2461export function subtract(a: number, b: number): number {
2462 return a - b;
2463}
2464"#,
2465 )
2466 .unwrap();
2467
2468 dir
2469 }
2470
2471 fn setup_alias_project() -> TempDir {
2473 let dir = TempDir::new().unwrap();
2474
2475 fs::write(
2476 dir.path().join("main.ts"),
2477 r#"import { helper as h } from './utils';
2478
2479export function main() {
2480 return h(42);
2481}
2482"#,
2483 )
2484 .unwrap();
2485
2486 fs::write(
2487 dir.path().join("utils.ts"),
2488 r#"export function helper(x: number): number {
2489 return x + 1;
2490}
2491"#,
2492 )
2493 .unwrap();
2494
2495 dir
2496 }
2497
2498 fn setup_cycle_project() -> TempDir {
2500 let dir = TempDir::new().unwrap();
2501
2502 fs::write(
2503 dir.path().join("a.ts"),
2504 r#"import { funcB } from './b';
2505
2506export function funcA() {
2507 return funcB();
2508}
2509"#,
2510 )
2511 .unwrap();
2512
2513 fs::write(
2514 dir.path().join("b.ts"),
2515 r#"import { funcA } from './a';
2516
2517export function funcB() {
2518 return funcA();
2519}
2520"#,
2521 )
2522 .unwrap();
2523
2524 dir
2525 }
2526
2527 #[test]
2530 fn callgraph_single_file_call_extraction() {
2531 let dir = setup_ts_project();
2532 let mut graph = CallGraph::new(dir.path().to_path_buf());
2533
2534 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2535 let main_calls = &file_data.calls_by_symbol["main"];
2536
2537 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2538 assert!(
2539 callee_names.contains(&"helper"),
2540 "main should call helper, got: {:?}",
2541 callee_names
2542 );
2543 assert!(
2544 callee_names.contains(&"compute"),
2545 "main should call compute, got: {:?}",
2546 callee_names
2547 );
2548 assert!(
2549 callee_names.contains(&"add"),
2550 "main should call math.add (short name: add), got: {:?}",
2551 callee_names
2552 );
2553 }
2554
2555 #[test]
2556 fn callgraph_file_data_has_exports() {
2557 let dir = setup_ts_project();
2558 let mut graph = CallGraph::new(dir.path().to_path_buf());
2559
2560 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2561 assert!(
2562 file_data.exported_symbols.contains(&"helper".to_string()),
2563 "utils.ts should export helper, got: {:?}",
2564 file_data.exported_symbols
2565 );
2566 assert!(
2567 file_data.exported_symbols.contains(&"compute".to_string()),
2568 "utils.ts should export compute, got: {:?}",
2569 file_data.exported_symbols
2570 );
2571 }
2572
2573 #[test]
2576 fn callgraph_resolve_direct_import() {
2577 let dir = setup_ts_project();
2578 let mut graph = CallGraph::new(dir.path().to_path_buf());
2579
2580 let main_path = dir.path().join("main.ts");
2581 let file_data = graph.build_file(&main_path).unwrap();
2582 let import_block = file_data.import_block.clone();
2583
2584 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2585 match edge {
2586 EdgeResolution::Resolved { file, symbol } => {
2587 assert!(
2588 file.ends_with("utils.ts"),
2589 "helper should resolve to utils.ts, got: {:?}",
2590 file
2591 );
2592 assert_eq!(symbol, "helper");
2593 }
2594 EdgeResolution::Unresolved { callee_name } => {
2595 panic!("Expected resolved, got unresolved: {}", callee_name);
2596 }
2597 }
2598 }
2599
2600 #[test]
2601 fn callgraph_resolve_namespace_import() {
2602 let dir = setup_ts_project();
2603 let mut graph = CallGraph::new(dir.path().to_path_buf());
2604
2605 let main_path = dir.path().join("main.ts");
2606 let file_data = graph.build_file(&main_path).unwrap();
2607 let import_block = file_data.import_block.clone();
2608
2609 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2610 match edge {
2611 EdgeResolution::Resolved { file, symbol } => {
2612 assert!(
2613 file.ends_with("math.ts"),
2614 "math.add should resolve to math.ts, got: {:?}",
2615 file
2616 );
2617 assert_eq!(symbol, "add");
2618 }
2619 EdgeResolution::Unresolved { callee_name } => {
2620 panic!("Expected resolved, got unresolved: {}", callee_name);
2621 }
2622 }
2623 }
2624
2625 #[test]
2626 fn callgraph_resolve_aliased_import() {
2627 let dir = setup_alias_project();
2628 let mut graph = CallGraph::new(dir.path().to_path_buf());
2629
2630 let main_path = dir.path().join("main.ts");
2631 let file_data = graph.build_file(&main_path).unwrap();
2632 let import_block = file_data.import_block.clone();
2633
2634 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2635 match edge {
2636 EdgeResolution::Resolved { file, symbol } => {
2637 assert!(
2638 file.ends_with("utils.ts"),
2639 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2640 file
2641 );
2642 assert_eq!(symbol, "helper");
2643 }
2644 EdgeResolution::Unresolved { callee_name } => {
2645 panic!("Expected resolved, got unresolved: {}", callee_name);
2646 }
2647 }
2648 }
2649
2650 #[test]
2651 fn callgraph_unresolved_edge_marked() {
2652 let dir = setup_ts_project();
2653 let mut graph = CallGraph::new(dir.path().to_path_buf());
2654
2655 let main_path = dir.path().join("main.ts");
2656 let file_data = graph.build_file(&main_path).unwrap();
2657 let import_block = file_data.import_block.clone();
2658
2659 let edge =
2660 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2661 assert_eq!(
2662 edge,
2663 EdgeResolution::Unresolved {
2664 callee_name: "unknownFunc".to_string()
2665 },
2666 "Unknown callee should be unresolved"
2667 );
2668 }
2669
2670 #[test]
2673 fn callgraph_cycle_detection_stops() {
2674 let dir = setup_cycle_project();
2675 let mut graph = CallGraph::new(dir.path().to_path_buf());
2676
2677 let tree = graph
2679 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2680 .unwrap();
2681
2682 assert_eq!(tree.name, "funcA");
2683 assert!(tree.resolved);
2684
2685 fn count_depth(node: &CallTreeNode) -> usize {
2688 if node.children.is_empty() {
2689 1
2690 } else {
2691 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
2692 }
2693 }
2694
2695 let depth = count_depth(&tree);
2696 assert!(
2697 depth <= 4,
2698 "Cycle should be detected and bounded, depth was: {}",
2699 depth
2700 );
2701 }
2702
2703 #[test]
2706 fn callgraph_depth_limit_truncates() {
2707 let dir = setup_ts_project();
2708 let mut graph = CallGraph::new(dir.path().to_path_buf());
2709
2710 let tree = graph
2713 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2714 .unwrap();
2715
2716 assert_eq!(tree.name, "main");
2717
2718 for child in &tree.children {
2720 assert!(
2721 child.children.is_empty(),
2722 "At depth 1, child '{}' should have no children, got {:?}",
2723 child.name,
2724 child.children.len()
2725 );
2726 }
2727 }
2728
2729 #[test]
2730 fn callgraph_depth_zero_no_children() {
2731 let dir = setup_ts_project();
2732 let mut graph = CallGraph::new(dir.path().to_path_buf());
2733
2734 let tree = graph
2735 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2736 .unwrap();
2737
2738 assert_eq!(tree.name, "main");
2739 assert!(
2740 tree.children.is_empty(),
2741 "At depth 0, should have no children"
2742 );
2743 }
2744
2745 #[test]
2748 fn callgraph_forward_tree_cross_file() {
2749 let dir = setup_ts_project();
2750 let mut graph = CallGraph::new(dir.path().to_path_buf());
2751
2752 let tree = graph
2754 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2755 .unwrap();
2756
2757 assert_eq!(tree.name, "main");
2758 assert!(tree.resolved);
2759
2760 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2762 assert!(
2763 helper_child.is_some(),
2764 "main should have helper as child, children: {:?}",
2765 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2766 );
2767
2768 let helper = helper_child.unwrap();
2769 assert!(
2770 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2771 "helper should be in utils.ts, got: {}",
2772 helper.file
2773 );
2774
2775 let double_child = helper.children.iter().find(|c| c.name == "double");
2777 assert!(
2778 double_child.is_some(),
2779 "helper should call double, children: {:?}",
2780 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2781 );
2782
2783 let double = double_child.unwrap();
2784 assert!(
2785 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2786 "double should be in helpers.ts, got: {}",
2787 double.file
2788 );
2789 }
2790
2791 #[test]
2794 fn callgraph_walker_excludes_gitignored() {
2795 let dir = TempDir::new().unwrap();
2796
2797 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2799
2800 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2802 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2803 fs::write(
2804 dir.path().join("ignored_dir").join("secret.ts"),
2805 "export function secret() {}",
2806 )
2807 .unwrap();
2808
2809 fs::create_dir(dir.path().join("node_modules")).unwrap();
2811 fs::write(
2812 dir.path().join("node_modules").join("dep.ts"),
2813 "export function dep() {}",
2814 )
2815 .unwrap();
2816
2817 std::process::Command::new("git")
2819 .args(["init"])
2820 .current_dir(dir.path())
2821 .output()
2822 .unwrap();
2823
2824 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2825 let file_names: Vec<String> = files
2826 .iter()
2827 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2828 .collect();
2829
2830 assert!(
2831 file_names.contains(&"main.ts".to_string()),
2832 "Should include main.ts, got: {:?}",
2833 file_names
2834 );
2835 assert!(
2836 !file_names.contains(&"secret.ts".to_string()),
2837 "Should exclude gitignored secret.ts, got: {:?}",
2838 file_names
2839 );
2840 assert!(
2841 !file_names.contains(&"dep.ts".to_string()),
2842 "Should exclude node_modules, got: {:?}",
2843 file_names
2844 );
2845 }
2846
2847 #[test]
2848 fn callgraph_walker_only_source_files() {
2849 let dir = TempDir::new().unwrap();
2850
2851 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2852 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2853 fs::write(dir.path().join("data.json"), "{}").unwrap();
2854
2855 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2856 let file_names: Vec<String> = files
2857 .iter()
2858 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2859 .collect();
2860
2861 assert!(file_names.contains(&"main.ts".to_string()));
2862 assert!(
2863 file_names.contains(&"readme.md".to_string()),
2864 "Markdown is now a supported source language"
2865 );
2866 assert!(
2867 file_names.contains(&"data.json".to_string()),
2868 "JSON is now a supported source language"
2869 );
2870 }
2871
2872 #[test]
2875 fn callgraph_find_alias_original_simple() {
2876 let raw = "import { foo as bar } from './utils';";
2877 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2878 }
2879
2880 #[test]
2881 fn callgraph_find_alias_original_multiple() {
2882 let raw = "import { foo as bar, baz as qux } from './utils';";
2883 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2884 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2885 }
2886
2887 #[test]
2888 fn callgraph_find_alias_no_match() {
2889 let raw = "import { foo } from './utils';";
2890 assert_eq!(find_alias_original(raw, "foo"), None);
2891 }
2892
2893 #[test]
2896 fn callgraph_callers_of_direct() {
2897 let dir = setup_ts_project();
2898 let mut graph = CallGraph::new(dir.path().to_path_buf());
2899
2900 let result = graph
2902 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
2903 .unwrap();
2904
2905 assert_eq!(result.symbol, "double");
2906 assert!(result.total_callers > 0, "double should have callers");
2907 assert!(result.scanned_files > 0, "should have scanned files");
2908
2909 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2911 assert!(
2912 utils_group.is_some(),
2913 "double should be called from utils.ts, groups: {:?}",
2914 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2915 );
2916
2917 let group = utils_group.unwrap();
2918 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2919 assert!(
2920 helper_caller.is_some(),
2921 "double should be called by helper, callers: {:?}",
2922 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2923 );
2924 }
2925
2926 #[test]
2927 fn callgraph_callers_of_no_callers() {
2928 let dir = setup_ts_project();
2929 let mut graph = CallGraph::new(dir.path().to_path_buf());
2930
2931 let result = graph
2933 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
2934 .unwrap();
2935
2936 assert_eq!(result.symbol, "main");
2937 assert_eq!(result.total_callers, 0, "main should have no callers");
2938 assert!(result.callers.is_empty());
2939 }
2940
2941 #[test]
2942 fn callgraph_callers_recursive_depth() {
2943 let dir = setup_ts_project();
2944 let mut graph = CallGraph::new(dir.path().to_path_buf());
2945
2946 let result = graph
2950 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
2951 .unwrap();
2952
2953 assert!(
2954 result.total_callers >= 2,
2955 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2956 result.total_callers
2957 );
2958
2959 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2961 assert!(
2962 main_group.is_some(),
2963 "recursive callers should include main.ts, groups: {:?}",
2964 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2965 );
2966 }
2967
2968 #[test]
2969 fn callgraph_invalidate_file_clears_reverse_index() {
2970 let dir = setup_ts_project();
2971 let mut graph = CallGraph::new(dir.path().to_path_buf());
2972
2973 let _ = graph
2975 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
2976 .unwrap();
2977 assert!(
2978 graph.reverse_index.is_some(),
2979 "reverse index should be built"
2980 );
2981
2982 graph.invalidate_file(&dir.path().join("utils.ts"));
2984
2985 assert!(
2987 graph.reverse_index.is_none(),
2988 "invalidate_file should clear reverse index"
2989 );
2990 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2992 assert!(
2993 !graph.data.contains_key(&canon),
2994 "invalidate_file should remove file from data cache"
2995 );
2996 assert!(
2998 graph.project_files.is_none(),
2999 "invalidate_file should clear project_files"
3000 );
3001 }
3002
3003 #[test]
3006 fn is_entry_point_exported_function() {
3007 assert!(is_entry_point(
3008 "handleRequest",
3009 &SymbolKind::Function,
3010 true,
3011 LangId::TypeScript
3012 ));
3013 }
3014
3015 #[test]
3016 fn is_entry_point_exported_method_is_not_entry() {
3017 assert!(!is_entry_point(
3019 "handleRequest",
3020 &SymbolKind::Method,
3021 true,
3022 LangId::TypeScript
3023 ));
3024 }
3025
3026 #[test]
3027 fn is_entry_point_main_init_patterns() {
3028 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3029 assert!(
3030 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3031 "{} should be an entry point",
3032 name
3033 );
3034 }
3035 }
3036
3037 #[test]
3038 fn is_entry_point_test_patterns_ts() {
3039 assert!(is_entry_point(
3040 "describe",
3041 &SymbolKind::Function,
3042 false,
3043 LangId::TypeScript
3044 ));
3045 assert!(is_entry_point(
3046 "it",
3047 &SymbolKind::Function,
3048 false,
3049 LangId::TypeScript
3050 ));
3051 assert!(is_entry_point(
3052 "test",
3053 &SymbolKind::Function,
3054 false,
3055 LangId::TypeScript
3056 ));
3057 assert!(is_entry_point(
3058 "testValidation",
3059 &SymbolKind::Function,
3060 false,
3061 LangId::TypeScript
3062 ));
3063 assert!(is_entry_point(
3064 "specHelper",
3065 &SymbolKind::Function,
3066 false,
3067 LangId::TypeScript
3068 ));
3069 }
3070
3071 #[test]
3072 fn is_entry_point_test_patterns_python() {
3073 assert!(is_entry_point(
3074 "test_login",
3075 &SymbolKind::Function,
3076 false,
3077 LangId::Python
3078 ));
3079 assert!(is_entry_point(
3080 "setUp",
3081 &SymbolKind::Function,
3082 false,
3083 LangId::Python
3084 ));
3085 assert!(is_entry_point(
3086 "tearDown",
3087 &SymbolKind::Function,
3088 false,
3089 LangId::Python
3090 ));
3091 assert!(!is_entry_point(
3093 "testSomething",
3094 &SymbolKind::Function,
3095 false,
3096 LangId::Python
3097 ));
3098 }
3099
3100 #[test]
3101 fn is_entry_point_test_patterns_rust() {
3102 assert!(is_entry_point(
3103 "test_parse",
3104 &SymbolKind::Function,
3105 false,
3106 LangId::Rust
3107 ));
3108 assert!(!is_entry_point(
3109 "TestSomething",
3110 &SymbolKind::Function,
3111 false,
3112 LangId::Rust
3113 ));
3114 }
3115
3116 #[test]
3117 fn is_entry_point_test_patterns_go() {
3118 assert!(is_entry_point(
3119 "TestParsing",
3120 &SymbolKind::Function,
3121 false,
3122 LangId::Go
3123 ));
3124 assert!(!is_entry_point(
3126 "testParsing",
3127 &SymbolKind::Function,
3128 false,
3129 LangId::Go
3130 ));
3131 }
3132
3133 #[test]
3134 fn is_entry_point_non_exported_non_main_is_not_entry() {
3135 assert!(!is_entry_point(
3136 "helperUtil",
3137 &SymbolKind::Function,
3138 false,
3139 LangId::TypeScript
3140 ));
3141 }
3142
3143 #[test]
3146 fn callgraph_symbol_metadata_populated() {
3147 let dir = setup_ts_project();
3148 let mut graph = CallGraph::new(dir.path().to_path_buf());
3149
3150 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3151 assert!(
3152 file_data.symbol_metadata.contains_key("helper"),
3153 "symbol_metadata should contain helper"
3154 );
3155 let meta = &file_data.symbol_metadata["helper"];
3156 assert_eq!(meta.kind, SymbolKind::Function);
3157 assert!(meta.exported, "helper should be exported");
3158 }
3159
3160 fn setup_trace_project() -> TempDir {
3176 let dir = TempDir::new().unwrap();
3177
3178 fs::write(
3179 dir.path().join("main.ts"),
3180 r#"import { processData } from './utils';
3181
3182export function main() {
3183 const result = processData("hello");
3184 return result;
3185}
3186"#,
3187 )
3188 .unwrap();
3189
3190 fs::write(
3191 dir.path().join("service.ts"),
3192 r#"import { processData } from './utils';
3193
3194export function handleRequest(input: string): string {
3195 return processData(input);
3196}
3197"#,
3198 )
3199 .unwrap();
3200
3201 fs::write(
3202 dir.path().join("utils.ts"),
3203 r#"import { validate } from './helpers';
3204
3205export function processData(input: string): string {
3206 const valid = validate(input);
3207 if (!valid) {
3208 throw new Error("invalid input");
3209 }
3210 return input.toUpperCase();
3211}
3212"#,
3213 )
3214 .unwrap();
3215
3216 fs::write(
3217 dir.path().join("helpers.ts"),
3218 r#"export function validate(input: string): boolean {
3219 return checkFormat(input);
3220}
3221
3222function checkFormat(input: string): boolean {
3223 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3224}
3225"#,
3226 )
3227 .unwrap();
3228
3229 fs::write(
3230 dir.path().join("test_helpers.ts"),
3231 r#"import { validate } from './helpers';
3232
3233function testValidation() {
3234 const result = validate("hello");
3235 console.log(result);
3236}
3237"#,
3238 )
3239 .unwrap();
3240
3241 std::process::Command::new("git")
3243 .args(["init"])
3244 .current_dir(dir.path())
3245 .output()
3246 .unwrap();
3247
3248 dir
3249 }
3250
3251 #[test]
3252 fn trace_to_multi_path() {
3253 let dir = setup_trace_project();
3254 let mut graph = CallGraph::new(dir.path().to_path_buf());
3255
3256 let result = graph
3257 .trace_to(
3258 &dir.path().join("helpers.ts"),
3259 "checkFormat",
3260 10,
3261 usize::MAX,
3262 )
3263 .unwrap();
3264
3265 assert_eq!(result.target_symbol, "checkFormat");
3266 assert!(
3267 result.total_paths >= 2,
3268 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3269 result.total_paths,
3270 result
3271 .paths
3272 .iter()
3273 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3274 .collect::<Vec<_>>()
3275 );
3276
3277 for path in &result.paths {
3279 assert!(
3280 path.hops.first().unwrap().is_entry_point,
3281 "First hop should be an entry point, got: {}",
3282 path.hops.first().unwrap().symbol
3283 );
3284 assert_eq!(
3285 path.hops.last().unwrap().symbol,
3286 "checkFormat",
3287 "Last hop should be checkFormat"
3288 );
3289 }
3290
3291 assert!(
3293 result.entry_points_found >= 2,
3294 "should find at least 2 entry points, got {}",
3295 result.entry_points_found
3296 );
3297 }
3298
3299 #[test]
3300 fn trace_to_single_path() {
3301 let dir = setup_trace_project();
3302 let mut graph = CallGraph::new(dir.path().to_path_buf());
3303
3304 let result = graph
3308 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
3309 .unwrap();
3310
3311 assert_eq!(result.target_symbol, "validate");
3312 assert!(
3313 result.total_paths >= 2,
3314 "validate should have at least 2 paths, got {}",
3315 result.total_paths
3316 );
3317 }
3318
3319 #[test]
3320 fn trace_to_cycle_detection() {
3321 let dir = setup_cycle_project();
3322 let mut graph = CallGraph::new(dir.path().to_path_buf());
3323
3324 let result = graph
3326 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
3327 .unwrap();
3328
3329 assert_eq!(result.target_symbol, "funcA");
3331 }
3332
3333 #[test]
3334 fn trace_to_depth_limit() {
3335 let dir = setup_trace_project();
3336 let mut graph = CallGraph::new(dir.path().to_path_buf());
3337
3338 let result = graph
3340 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
3341 .unwrap();
3342
3343 assert_eq!(result.target_symbol, "checkFormat");
3347
3348 let deep_result = graph
3350 .trace_to(
3351 &dir.path().join("helpers.ts"),
3352 "checkFormat",
3353 10,
3354 usize::MAX,
3355 )
3356 .unwrap();
3357
3358 assert!(
3359 result.total_paths <= deep_result.total_paths,
3360 "shallow trace should find <= paths compared to deep: {} vs {}",
3361 result.total_paths,
3362 deep_result.total_paths
3363 );
3364 }
3365
3366 #[test]
3367 fn trace_to_entry_point_target() {
3368 let dir = setup_trace_project();
3369 let mut graph = CallGraph::new(dir.path().to_path_buf());
3370
3371 let result = graph
3373 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
3374 .unwrap();
3375
3376 assert_eq!(result.target_symbol, "main");
3377 assert!(
3378 result.total_paths >= 1,
3379 "main should have at least 1 path (itself), got {}",
3380 result.total_paths
3381 );
3382 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3384 assert!(
3385 trivial.is_some(),
3386 "should have a trivial path with just the entry point itself"
3387 );
3388 }
3389
3390 #[test]
3393 fn extract_parameters_typescript() {
3394 let params = extract_parameters(
3395 "function processData(input: string, count: number): void",
3396 LangId::TypeScript,
3397 );
3398 assert_eq!(params, vec!["input", "count"]);
3399 }
3400
3401 #[test]
3402 fn extract_parameters_typescript_optional() {
3403 let params = extract_parameters(
3404 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3405 LangId::TypeScript,
3406 );
3407 assert_eq!(params, vec!["url", "options"]);
3408 }
3409
3410 #[test]
3411 fn extract_parameters_typescript_defaults() {
3412 let params = extract_parameters(
3413 "function greet(name: string, greeting: string = \"hello\"): string",
3414 LangId::TypeScript,
3415 );
3416 assert_eq!(params, vec!["name", "greeting"]);
3417 }
3418
3419 #[test]
3420 fn extract_parameters_typescript_rest() {
3421 let params = extract_parameters(
3422 "function sum(...numbers: number[]): number",
3423 LangId::TypeScript,
3424 );
3425 assert_eq!(params, vec!["numbers"]);
3426 }
3427
3428 #[test]
3429 fn extract_parameters_python_self_skipped() {
3430 let params = extract_parameters(
3431 "def process(self, data: str, count: int) -> bool",
3432 LangId::Python,
3433 );
3434 assert_eq!(params, vec!["data", "count"]);
3435 }
3436
3437 #[test]
3438 fn extract_parameters_python_no_self() {
3439 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3440 assert_eq!(params, vec!["input"]);
3441 }
3442
3443 #[test]
3444 fn extract_parameters_python_star_args() {
3445 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3446 assert_eq!(params, vec!["args", "kwargs"]);
3447 }
3448
3449 #[test]
3450 fn extract_parameters_rust_self_skipped() {
3451 let params = extract_parameters(
3452 "fn process(&self, data: &str, count: usize) -> bool",
3453 LangId::Rust,
3454 );
3455 assert_eq!(params, vec!["data", "count"]);
3456 }
3457
3458 #[test]
3459 fn extract_parameters_rust_mut_self_skipped() {
3460 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3461 assert_eq!(params, vec!["value"]);
3462 }
3463
3464 #[test]
3465 fn extract_parameters_rust_no_self() {
3466 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3467 assert_eq!(params, vec!["input"]);
3468 }
3469
3470 #[test]
3471 fn extract_parameters_rust_mut_param() {
3472 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3473 assert_eq!(params, vec!["buf", "len"]);
3474 }
3475
3476 #[test]
3477 fn extract_parameters_go() {
3478 let params = extract_parameters(
3479 "func ProcessData(input string, count int) error",
3480 LangId::Go,
3481 );
3482 assert_eq!(params, vec!["input", "count"]);
3483 }
3484
3485 #[test]
3486 fn extract_parameters_empty() {
3487 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3488 assert!(
3489 params.is_empty(),
3490 "no-arg function should return empty params"
3491 );
3492 }
3493
3494 #[test]
3495 fn extract_parameters_no_parens() {
3496 let params = extract_parameters("const x = 42", LangId::TypeScript);
3497 assert!(params.is_empty(), "no parens should return empty params");
3498 }
3499
3500 #[test]
3501 fn extract_parameters_javascript() {
3502 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3503 assert_eq!(params, vec!["event", "target"]);
3504 }
3505}