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::Html
205 | LangId::Markdown => false,
206 }
207}
208
209#[derive(Debug, Clone, Serialize)]
215pub struct TraceHop {
216 pub symbol: String,
218 pub file: String,
220 pub line: u32,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub signature: Option<String>,
225 pub is_entry_point: bool,
227}
228
229#[derive(Debug, Clone, Serialize)]
231pub struct TracePath {
232 pub hops: Vec<TraceHop>,
234}
235
236#[derive(Debug, Clone, Serialize)]
238pub struct TraceToResult {
239 pub target_symbol: String,
241 pub target_file: String,
243 pub paths: Vec<TracePath>,
245 pub total_paths: usize,
247 pub entry_points_found: usize,
249 pub max_depth_reached: bool,
251 pub truncated_paths: usize,
253}
254
255#[derive(Debug, Clone, Serialize)]
261pub struct ImpactCaller {
262 pub caller_symbol: String,
264 pub caller_file: String,
266 pub line: u32,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub signature: Option<String>,
271 pub is_entry_point: bool,
273 #[serde(skip_serializing_if = "Option::is_none")]
275 pub call_expression: Option<String>,
276 pub parameters: Vec<String>,
278}
279
280#[derive(Debug, Clone, Serialize)]
282pub struct ImpactResult {
283 pub symbol: String,
285 pub file: String,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub signature: Option<String>,
290 pub parameters: Vec<String>,
292 pub total_affected: usize,
294 pub affected_files: usize,
296 pub callers: Vec<ImpactCaller>,
298}
299
300#[derive(Debug, Clone, Serialize)]
306pub struct DataFlowHop {
307 pub file: String,
309 pub symbol: String,
311 pub variable: String,
313 pub line: u32,
315 pub flow_type: String,
317 pub approximate: bool,
319}
320
321#[derive(Debug, Clone, Serialize)]
324pub struct TraceDataResult {
325 pub expression: String,
327 pub origin_file: String,
329 pub origin_symbol: String,
331 pub hops: Vec<DataFlowHop>,
333 pub depth_limited: bool,
335}
336
337pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
343 let start = match signature.find('(') {
345 Some(i) => i + 1,
346 None => return Vec::new(),
347 };
348 let end = match signature[start..].find(')') {
349 Some(i) => start + i,
350 None => return Vec::new(),
351 };
352
353 let params_str = &signature[start..end].trim();
354 if params_str.is_empty() {
355 return Vec::new();
356 }
357
358 let parts = split_params(params_str);
360
361 let mut result = Vec::new();
362 for part in parts {
363 let trimmed = part.trim();
364 if trimmed.is_empty() {
365 continue;
366 }
367
368 match lang {
370 LangId::Rust => {
371 let normalized = trimmed.replace(' ', "");
372 if normalized == "self"
373 || normalized == "&self"
374 || normalized == "&mutself"
375 || normalized == "mutself"
376 {
377 continue;
378 }
379 }
380 LangId::Python => {
381 if trimmed == "self" || trimmed.starts_with("self:") {
382 continue;
383 }
384 }
385 _ => {}
386 }
387
388 let name = extract_param_name(trimmed, lang);
390 if !name.is_empty() {
391 result.push(name);
392 }
393 }
394
395 result
396}
397
398fn split_params(s: &str) -> Vec<String> {
400 let mut parts = Vec::new();
401 let mut current = String::new();
402 let mut depth = 0i32;
403
404 for ch in s.chars() {
405 match ch {
406 '<' | '[' | '{' | '(' => {
407 depth += 1;
408 current.push(ch);
409 }
410 '>' | ']' | '}' | ')' => {
411 depth -= 1;
412 current.push(ch);
413 }
414 ',' if depth == 0 => {
415 parts.push(current.clone());
416 current.clear();
417 }
418 _ => {
419 current.push(ch);
420 }
421 }
422 }
423 if !current.is_empty() {
424 parts.push(current);
425 }
426 parts
427}
428
429fn extract_param_name(param: &str, lang: LangId) -> String {
437 let trimmed = param.trim();
438
439 let working = if trimmed.starts_with("...") {
441 &trimmed[3..]
442 } else if trimmed.starts_with("**") {
443 &trimmed[2..]
444 } else if trimmed.starts_with('*') && lang == LangId::Python {
445 &trimmed[1..]
446 } else {
447 trimmed
448 };
449
450 let working = if lang == LangId::Rust && working.starts_with("mut ") {
452 &working[4..]
453 } else {
454 working
455 };
456
457 let name = working
460 .split(|c: char| c == ':' || c == '=')
461 .next()
462 .unwrap_or("")
463 .trim();
464
465 let name = name.trim_end_matches('?');
467
468 if lang == LangId::Go && !name.contains(' ') {
470 return name.to_string();
471 }
472 if lang == LangId::Go {
473 return name.split_whitespace().next().unwrap_or("").to_string();
474 }
475
476 name.to_string()
477}
478
479pub struct CallGraph {
488 data: HashMap<PathBuf, FileCallData>,
490 project_root: PathBuf,
492 project_files: Option<Vec<PathBuf>>,
494 reverse_index: Option<ReverseIndex>,
497}
498
499impl CallGraph {
500 pub fn new(project_root: PathBuf) -> Self {
502 Self {
503 data: HashMap::new(),
504 project_root,
505 project_files: None,
506 reverse_index: None,
507 }
508 }
509
510 pub fn project_root(&self) -> &Path {
512 &self.project_root
513 }
514
515 fn resolve_cross_file_edge_with_exports<F>(
516 full_callee: &str,
517 short_name: &str,
518 caller_file: &Path,
519 import_block: &ImportBlock,
520 mut file_exports_symbol: F,
521 ) -> EdgeResolution
522 where
523 F: FnMut(&Path, &str) -> bool,
524 {
525 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
526
527 if full_callee.contains('.') {
529 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
530 if parts.len() == 2 {
531 let namespace = parts[0];
532 let member = parts[1];
533
534 for imp in &import_block.imports {
535 if imp.namespace_import.as_deref() == Some(namespace) {
536 if let Some(resolved_path) =
537 resolve_module_path(caller_dir, &imp.module_path)
538 {
539 return EdgeResolution::Resolved {
540 file: resolved_path,
541 symbol: member.to_owned(),
542 };
543 }
544 }
545 }
546 }
547 }
548
549 for imp in &import_block.imports {
551 if imp.names.iter().any(|name| name == short_name) {
553 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
554 return EdgeResolution::Resolved {
556 file: resolved_path,
557 symbol: short_name.to_owned(),
558 };
559 }
560 }
561
562 if imp.default_import.as_deref() == Some(short_name) {
564 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
565 return EdgeResolution::Resolved {
566 file: resolved_path,
567 symbol: "default".to_owned(),
568 };
569 }
570 }
571 }
572
573 if let Some((original_name, resolved_path)) =
578 resolve_aliased_import(short_name, import_block, caller_dir)
579 {
580 return EdgeResolution::Resolved {
581 file: resolved_path,
582 symbol: original_name,
583 };
584 }
585
586 for imp in &import_block.imports {
589 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
590 if resolved_path.is_dir() {
592 if let Some(index_path) = find_index_file(&resolved_path) {
593 if file_exports_symbol(&index_path, short_name) {
595 return EdgeResolution::Resolved {
596 file: index_path,
597 symbol: short_name.to_owned(),
598 };
599 }
600 }
601 } else if file_exports_symbol(&resolved_path, short_name) {
602 return EdgeResolution::Resolved {
603 file: resolved_path,
604 symbol: short_name.to_owned(),
605 };
606 }
607 }
608 }
609
610 EdgeResolution::Unresolved {
611 callee_name: short_name.to_owned(),
612 }
613 }
614
615 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
617 let canon = self.canonicalize(path)?;
618
619 if !self.data.contains_key(&canon) {
620 let file_data = build_file_data(&canon)?;
621 self.data.insert(canon.clone(), file_data);
622 }
623
624 Ok(&self.data[&canon])
625 }
626
627 pub fn resolve_cross_file_edge(
632 &mut self,
633 full_callee: &str,
634 short_name: &str,
635 caller_file: &Path,
636 import_block: &ImportBlock,
637 ) -> EdgeResolution {
638 Self::resolve_cross_file_edge_with_exports(
639 full_callee,
640 short_name,
641 caller_file,
642 import_block,
643 |path, symbol_name| self.file_exports_symbol(path, symbol_name),
644 )
645 }
646
647 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
649 match self.build_file(path) {
650 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
651 Err(_) => false,
652 }
653 }
654
655 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
656 self.lookup_file_data(path)
657 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
658 .unwrap_or(false)
659 }
660
661 pub fn forward_tree(
666 &mut self,
667 file: &Path,
668 symbol: &str,
669 max_depth: usize,
670 ) -> Result<CallTreeNode, AftError> {
671 let mut visited = HashSet::new();
672 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
673 }
674
675 fn forward_tree_inner(
676 &mut self,
677 file: &Path,
678 symbol: &str,
679 max_depth: usize,
680 current_depth: usize,
681 visited: &mut HashSet<(PathBuf, String)>,
682 ) -> Result<CallTreeNode, AftError> {
683 let canon = self.canonicalize(file)?;
684 let visit_key = (canon.clone(), symbol.to_string());
685
686 if visited.contains(&visit_key) {
688 let (line, signature) = get_symbol_meta(&canon, symbol);
689 return Ok(CallTreeNode {
690 name: symbol.to_string(),
691 file: self.relative_path(&canon),
692 line,
693 signature,
694 resolved: true,
695 children: vec![], });
697 }
698
699 visited.insert(visit_key.clone());
700
701 let file_data = build_file_data(&canon)?;
703 let import_block = file_data.import_block.clone();
704 let _lang = file_data.lang;
705
706 let call_sites = file_data
708 .calls_by_symbol
709 .get(symbol)
710 .cloned()
711 .unwrap_or_default();
712
713 let (sym_line, sym_signature) = get_symbol_meta(&canon, symbol);
715
716 self.data.insert(canon.clone(), file_data);
718
719 let mut children = Vec::new();
721
722 if current_depth < max_depth {
723 for call_site in &call_sites {
724 let edge = self.resolve_cross_file_edge(
725 &call_site.full_callee,
726 &call_site.callee_name,
727 &canon,
728 &import_block,
729 );
730
731 match edge {
732 EdgeResolution::Resolved {
733 file: ref target_file,
734 ref symbol,
735 } => {
736 match self.forward_tree_inner(
737 target_file,
738 symbol,
739 max_depth,
740 current_depth + 1,
741 visited,
742 ) {
743 Ok(child) => children.push(child),
744 Err(_) => {
745 children.push(CallTreeNode {
747 name: call_site.callee_name.clone(),
748 file: self.relative_path(target_file),
749 line: call_site.line,
750 signature: None,
751 resolved: false,
752 children: vec![],
753 });
754 }
755 }
756 }
757 EdgeResolution::Unresolved { callee_name } => {
758 children.push(CallTreeNode {
759 name: callee_name,
760 file: self.relative_path(&canon),
761 line: call_site.line,
762 signature: None,
763 resolved: false,
764 children: vec![],
765 });
766 }
767 }
768 }
769 }
770
771 visited.remove(&visit_key);
772
773 Ok(CallTreeNode {
774 name: symbol.to_string(),
775 file: self.relative_path(&canon),
776 line: sym_line,
777 signature: sym_signature,
778 resolved: true,
779 children,
780 })
781 }
782
783 pub fn project_files(&mut self) -> &[PathBuf] {
785 if self.project_files.is_none() {
786 let project_root = self.project_root.clone();
787 self.project_files = Some(walk_project_files(&project_root).collect());
788 }
789 self.project_files.as_deref().unwrap_or(&[])
790 }
791
792 fn build_reverse_index(&mut self) {
798 let all_files = self.project_files().to_vec();
800
801 for f in &all_files {
803 let _ = self.build_file(f);
804 }
805
806 let mut reverse: ReverseIndex = HashMap::new();
808
809 for caller_file in &all_files {
810 let canon_caller = Arc::new(
812 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
813 );
814 let file_data = match self
815 .data
816 .get(caller_file)
817 .or_else(|| self.data.get(canon_caller.as_ref()))
818 {
819 Some(d) => d,
820 None => continue,
821 };
822
823 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
824 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
825
826 for call_site in call_sites {
827 let edge = Self::resolve_cross_file_edge_with_exports(
828 &call_site.full_callee,
829 &call_site.callee_name,
830 canon_caller.as_ref(),
831 &file_data.import_block,
832 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
833 );
834
835 let (target_file, target_symbol, resolved) = match edge {
836 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
837 EdgeResolution::Unresolved { callee_name } => {
838 (canon_caller.as_ref().clone(), callee_name, false)
839 }
840 };
841
842 reverse
843 .entry(target_file)
844 .or_default()
845 .entry(target_symbol)
846 .or_default()
847 .push(IndexedCallerSite {
848 caller_file: Arc::clone(&canon_caller),
849 caller_symbol: Arc::clone(&caller_symbol),
850 line: call_site.line,
851 col: 0,
852 resolved,
853 });
854 }
855 }
856 }
857
858 self.reverse_index = Some(reverse);
859 }
860
861 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
862 self.reverse_index
863 .as_ref()?
864 .get(file)?
865 .get(symbol)
866 .map(Vec::as_slice)
867 }
868
869 pub fn callers_of(
875 &mut self,
876 file: &Path,
877 symbol: &str,
878 depth: usize,
879 ) -> Result<CallersResult, AftError> {
880 let canon = self.canonicalize(file)?;
881
882 self.build_file(&canon)?;
884
885 if self.reverse_index.is_none() {
887 self.build_reverse_index();
888 }
889
890 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
891 let effective_depth = if depth == 0 { 1 } else { depth };
892
893 let mut visited = HashSet::new();
894 let mut all_sites: Vec<CallerSite> = Vec::new();
895 self.collect_callers_recursive(
896 &canon,
897 symbol,
898 effective_depth,
899 0,
900 &mut visited,
901 &mut all_sites,
902 );
903
904 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
907 let total_callers = all_sites.len();
908 for site in all_sites {
909 let caller_file: PathBuf = site.caller_file;
910 let caller_symbol: String = site.caller_symbol;
911 let line = site.line;
912 let entry = CallerEntry {
913 symbol: caller_symbol,
914 line,
915 };
916
917 if let Some(entries) = groups_map.get_mut(&caller_file) {
918 entries.push(entry);
919 } else {
920 groups_map.insert(caller_file, vec![entry]);
921 }
922 }
923
924 let mut callers: Vec<CallerGroup> = groups_map
925 .into_iter()
926 .map(|(file_path, entries)| CallerGroup {
927 file: self.relative_path(&file_path),
928 callers: entries,
929 })
930 .collect();
931
932 callers.sort_by(|a, b| a.file.cmp(&b.file));
934
935 Ok(CallersResult {
936 symbol: symbol.to_string(),
937 file: self.relative_path(&canon),
938 callers,
939 total_callers,
940 scanned_files,
941 })
942 }
943
944 pub fn trace_to(
950 &mut self,
951 file: &Path,
952 symbol: &str,
953 max_depth: usize,
954 ) -> Result<TraceToResult, AftError> {
955 let canon = self.canonicalize(file)?;
956
957 self.build_file(&canon)?;
959
960 if self.reverse_index.is_none() {
962 self.build_reverse_index();
963 }
964
965 let target_rel = self.relative_path(&canon);
966 let effective_max = if max_depth == 0 { 10 } else { max_depth };
967 if self.reverse_index.is_none() {
968 return Err(AftError::ParseError {
969 message: format!(
970 "reverse index unavailable after building callers for {}",
971 canon.display()
972 ),
973 });
974 }
975
976 let (target_line, target_sig) = get_symbol_meta(&canon, symbol);
978
979 let target_is_entry = self
981 .lookup_file_data(&canon)
982 .and_then(|fd| {
983 let meta = fd.symbol_metadata.get(symbol)?;
984 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
985 })
986 .unwrap_or(false);
987
988 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
991 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
992 let mut max_depth_reached = false;
993 let mut truncated_paths: usize = 0;
994
995 let initial: Vec<PathElem> = vec![(
997 Arc::new(canon.clone()),
998 Arc::from(symbol),
999 target_line,
1000 target_sig,
1001 )];
1002
1003 if target_is_entry {
1005 complete_paths.push(initial.clone());
1006 }
1007
1008 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1010
1011 while let Some((path, depth)) = queue.pop() {
1012 if depth >= effective_max {
1013 max_depth_reached = true;
1014 continue;
1015 }
1016
1017 let Some((current_file, current_symbol, _, _)) = path.last() else {
1018 continue;
1019 };
1020
1021 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1023 Some(sites) => sites,
1024 None => {
1025 if path.len() > 1 {
1028 truncated_paths += 1;
1031 }
1032 continue;
1033 }
1034 };
1035
1036 let mut has_new_path = false;
1037 for site in callers {
1038 if path.iter().any(|(file_path, sym, _, _)| {
1040 file_path.as_ref() == site.caller_file.as_ref()
1041 && sym.as_ref() == site.caller_symbol.as_ref()
1042 }) {
1043 continue;
1044 }
1045
1046 has_new_path = true;
1047
1048 let (caller_line, caller_sig) =
1050 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref());
1051
1052 let mut new_path = path.clone();
1053 new_path.push((
1054 Arc::clone(&site.caller_file),
1055 Arc::clone(&site.caller_symbol),
1056 caller_line,
1057 caller_sig,
1058 ));
1059
1060 let caller_is_entry = self
1064 .lookup_file_data(site.caller_file.as_ref())
1065 .and_then(|fd| {
1066 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1067 Some(is_entry_point(
1068 site.caller_symbol.as_ref(),
1069 &meta.kind,
1070 meta.exported,
1071 fd.lang,
1072 ))
1073 })
1074 .unwrap_or(false);
1075
1076 if caller_is_entry {
1077 complete_paths.push(new_path.clone());
1078 }
1079 queue.push((new_path, depth + 1));
1082 }
1083
1084 if !has_new_path && path.len() > 1 {
1086 truncated_paths += 1;
1087 }
1088 }
1089
1090 let mut paths: Vec<TracePath> = complete_paths
1093 .into_iter()
1094 .map(|mut elems| {
1095 elems.reverse();
1096 let hops: Vec<TraceHop> = elems
1097 .iter()
1098 .enumerate()
1099 .map(|(i, (file_path, sym, line, sig))| {
1100 let is_ep = if i == 0 {
1101 self.lookup_file_data(file_path.as_ref())
1103 .and_then(|fd| {
1104 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1105 Some(is_entry_point(
1106 sym.as_ref(),
1107 &meta.kind,
1108 meta.exported,
1109 fd.lang,
1110 ))
1111 })
1112 .unwrap_or(false)
1113 } else {
1114 false
1115 };
1116 TraceHop {
1117 symbol: sym.to_string(),
1118 file: self.relative_path(file_path.as_ref()),
1119 line: *line,
1120 signature: sig.clone(),
1121 is_entry_point: is_ep,
1122 }
1123 })
1124 .collect();
1125 TracePath { hops }
1126 })
1127 .collect();
1128
1129 paths.sort_by(|a, b| {
1131 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1132 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1133 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1134 });
1135
1136 let mut entry_point_names: HashSet<String> = HashSet::new();
1138 for p in &paths {
1139 if let Some(first) = p.hops.first() {
1140 if first.is_entry_point {
1141 entry_point_names.insert(first.symbol.clone());
1142 }
1143 }
1144 }
1145
1146 let total_paths = paths.len();
1147 let entry_points_found = entry_point_names.len();
1148
1149 Ok(TraceToResult {
1150 target_symbol: symbol.to_string(),
1151 target_file: target_rel,
1152 paths,
1153 total_paths,
1154 entry_points_found,
1155 max_depth_reached,
1156 truncated_paths,
1157 })
1158 }
1159
1160 pub fn impact(
1166 &mut self,
1167 file: &Path,
1168 symbol: &str,
1169 depth: usize,
1170 ) -> Result<ImpactResult, AftError> {
1171 let canon = self.canonicalize(file)?;
1172
1173 self.build_file(&canon)?;
1175
1176 if self.reverse_index.is_none() {
1178 self.build_reverse_index();
1179 }
1180
1181 let effective_depth = if depth == 0 { 1 } else { depth };
1182
1183 let (target_signature, target_parameters, target_lang) = {
1185 let file_data = match self.data.get(&canon) {
1186 Some(d) => d,
1187 None => {
1188 return Err(AftError::InvalidRequest {
1189 message: "file data missing after build".to_string(),
1190 })
1191 }
1192 };
1193 let meta = file_data.symbol_metadata.get(symbol);
1194 let sig = meta.and_then(|m| m.signature.clone());
1195 let lang = file_data.lang;
1196 let params = sig
1197 .as_deref()
1198 .map(|s| extract_parameters(s, lang))
1199 .unwrap_or_default();
1200 (sig, params, lang)
1201 };
1202
1203 let mut visited = HashSet::new();
1205 let mut all_sites: Vec<CallerSite> = Vec::new();
1206 self.collect_callers_recursive(
1207 &canon,
1208 symbol,
1209 effective_depth,
1210 0,
1211 &mut visited,
1212 &mut all_sites,
1213 );
1214
1215 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1217 all_sites.retain(|site| {
1218 seen.insert((
1219 site.caller_file.clone(),
1220 site.caller_symbol.clone(),
1221 site.line,
1222 ))
1223 });
1224
1225 let mut callers = Vec::new();
1227 let mut affected_file_set = HashSet::new();
1228
1229 for site in &all_sites {
1230 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1232 log::debug!(
1233 "callgraph: skipping caller file {}: {}",
1234 site.caller_file.display(),
1235 e
1236 );
1237 }
1238
1239 let (sig, is_ep, params, _lang) = {
1240 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1241 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1242 let sig = meta.and_then(|m| m.signature.clone());
1243 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1244 let exported = meta.map(|m| m.exported).unwrap_or(false);
1245 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1246 let lang = fd.lang;
1247 let params = sig
1248 .as_deref()
1249 .map(|s| extract_parameters(s, lang))
1250 .unwrap_or_default();
1251 (sig, is_ep, params, lang)
1252 } else {
1253 (None, false, Vec::new(), target_lang)
1254 }
1255 };
1256
1257 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1259
1260 let rel_file = self.relative_path(site.caller_file.as_path());
1261 affected_file_set.insert(rel_file.clone());
1262
1263 callers.push(ImpactCaller {
1264 caller_symbol: site.caller_symbol.clone(),
1265 caller_file: rel_file,
1266 line: site.line,
1267 signature: sig,
1268 is_entry_point: is_ep,
1269 call_expression,
1270 parameters: params,
1271 });
1272 }
1273
1274 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1276
1277 let total_affected = callers.len();
1278 let affected_files = affected_file_set.len();
1279
1280 Ok(ImpactResult {
1281 symbol: symbol.to_string(),
1282 file: self.relative_path(&canon),
1283 signature: target_signature,
1284 parameters: target_parameters,
1285 total_affected,
1286 affected_files,
1287 callers,
1288 })
1289 }
1290
1291 pub fn trace_data(
1302 &mut self,
1303 file: &Path,
1304 symbol: &str,
1305 expression: &str,
1306 max_depth: usize,
1307 ) -> Result<TraceDataResult, AftError> {
1308 let canon = self.canonicalize(file)?;
1309 let rel_file = self.relative_path(&canon);
1310
1311 self.build_file(&canon)?;
1313
1314 {
1316 let fd = match self.data.get(&canon) {
1317 Some(d) => d,
1318 None => {
1319 return Err(AftError::InvalidRequest {
1320 message: "file data missing after build".to_string(),
1321 })
1322 }
1323 };
1324 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1325 || fd.exported_symbols.iter().any(|name| name == symbol)
1326 || fd.symbol_metadata.contains_key(symbol);
1327 if !has_symbol {
1328 return Err(AftError::InvalidRequest {
1329 message: format!(
1330 "trace_data: symbol '{}' not found in {}",
1331 symbol,
1332 file.display()
1333 ),
1334 });
1335 }
1336 }
1337
1338 let mut hops = Vec::new();
1339 let mut depth_limited = false;
1340
1341 self.trace_data_inner(
1342 &canon,
1343 symbol,
1344 expression,
1345 max_depth,
1346 0,
1347 &mut hops,
1348 &mut depth_limited,
1349 &mut HashSet::new(),
1350 );
1351
1352 Ok(TraceDataResult {
1353 expression: expression.to_string(),
1354 origin_file: rel_file,
1355 origin_symbol: symbol.to_string(),
1356 hops,
1357 depth_limited,
1358 })
1359 }
1360
1361 fn trace_data_inner(
1363 &mut self,
1364 file: &Path,
1365 symbol: &str,
1366 tracking_name: &str,
1367 max_depth: usize,
1368 current_depth: usize,
1369 hops: &mut Vec<DataFlowHop>,
1370 depth_limited: &mut bool,
1371 visited: &mut HashSet<(PathBuf, String, String)>,
1372 ) {
1373 let visit_key = (
1374 file.to_path_buf(),
1375 symbol.to_string(),
1376 tracking_name.to_string(),
1377 );
1378 if visited.contains(&visit_key) {
1379 return; }
1381 visited.insert(visit_key);
1382
1383 let source = match std::fs::read_to_string(file) {
1385 Ok(s) => s,
1386 Err(_) => return,
1387 };
1388
1389 let lang = match detect_language(file) {
1390 Some(l) => l,
1391 None => return,
1392 };
1393
1394 let grammar = grammar_for(lang);
1395 let mut parser = Parser::new();
1396 if parser.set_language(&grammar).is_err() {
1397 return;
1398 }
1399 let tree = match parser.parse(&source, None) {
1400 Some(t) => t,
1401 None => return,
1402 };
1403
1404 let symbols = list_symbols_from_tree(&source, &tree, lang, file);
1406 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1407 Some(s) => s,
1408 None => return,
1409 };
1410
1411 let body_start = line_col_to_byte(&source, sym_info.start_line, sym_info.start_col);
1412 let body_end = line_col_to_byte(&source, sym_info.end_line, sym_info.end_col);
1413
1414 let root = tree.root_node();
1415
1416 let body_node = match find_node_covering_range(root, body_start, body_end) {
1418 Some(n) => n,
1419 None => return,
1420 };
1421
1422 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1424 let rel_file = self.relative_path(file);
1425
1426 self.walk_for_data_flow(
1428 body_node,
1429 &source,
1430 &mut tracked_names,
1431 file,
1432 symbol,
1433 &rel_file,
1434 lang,
1435 max_depth,
1436 current_depth,
1437 hops,
1438 depth_limited,
1439 visited,
1440 );
1441 }
1442
1443 #[allow(clippy::too_many_arguments)]
1446 fn walk_for_data_flow(
1447 &mut self,
1448 node: tree_sitter::Node,
1449 source: &str,
1450 tracked_names: &mut Vec<String>,
1451 file: &Path,
1452 symbol: &str,
1453 rel_file: &str,
1454 lang: LangId,
1455 max_depth: usize,
1456 current_depth: usize,
1457 hops: &mut Vec<DataFlowHop>,
1458 depth_limited: &mut bool,
1459 visited: &mut HashSet<(PathBuf, String, String)>,
1460 ) {
1461 let kind = node.kind();
1462
1463 let is_var_decl = matches!(
1465 kind,
1466 "variable_declarator"
1467 | "assignment_expression"
1468 | "augmented_assignment_expression"
1469 | "assignment"
1470 | "let_declaration"
1471 | "short_var_declaration"
1472 );
1473
1474 if is_var_decl {
1475 if let Some((new_name, init_text, line, is_approx)) =
1476 self.extract_assignment_info(node, source, lang, tracked_names)
1477 {
1478 if !is_approx {
1480 hops.push(DataFlowHop {
1481 file: rel_file.to_string(),
1482 symbol: symbol.to_string(),
1483 variable: new_name.clone(),
1484 line,
1485 flow_type: "assignment".to_string(),
1486 approximate: false,
1487 });
1488 tracked_names.push(new_name);
1489 } else {
1490 hops.push(DataFlowHop {
1492 file: rel_file.to_string(),
1493 symbol: symbol.to_string(),
1494 variable: init_text,
1495 line,
1496 flow_type: "assignment".to_string(),
1497 approximate: true,
1498 });
1499 return;
1501 }
1502 }
1503 }
1504
1505 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1507 self.check_call_for_data_flow(
1508 node,
1509 source,
1510 tracked_names,
1511 file,
1512 symbol,
1513 rel_file,
1514 lang,
1515 max_depth,
1516 current_depth,
1517 hops,
1518 depth_limited,
1519 visited,
1520 );
1521 }
1522
1523 let mut cursor = node.walk();
1525 if cursor.goto_first_child() {
1526 loop {
1527 let child = cursor.node();
1528 self.walk_for_data_flow(
1530 child,
1531 source,
1532 tracked_names,
1533 file,
1534 symbol,
1535 rel_file,
1536 lang,
1537 max_depth,
1538 current_depth,
1539 hops,
1540 depth_limited,
1541 visited,
1542 );
1543 if !cursor.goto_next_sibling() {
1544 break;
1545 }
1546 }
1547 }
1548 }
1549
1550 fn extract_assignment_info(
1553 &self,
1554 node: tree_sitter::Node,
1555 source: &str,
1556 _lang: LangId,
1557 tracked_names: &[String],
1558 ) -> Option<(String, String, u32, bool)> {
1559 let kind = node.kind();
1560 let line = node.start_position().row as u32 + 1;
1561
1562 match kind {
1563 "variable_declarator" => {
1564 let name_node = node.child_by_field_name("name")?;
1566 let value_node = node.child_by_field_name("value")?;
1567 let name_text = node_text(name_node, source);
1568 let value_text = node_text(value_node, source);
1569
1570 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1572 if tracked_names.iter().any(|t| value_text.contains(t)) {
1574 return Some((name_text.clone(), name_text, line, true));
1575 }
1576 return None;
1577 }
1578
1579 if tracked_names.iter().any(|t| {
1581 value_text == *t
1582 || value_text.starts_with(&format!("{}.", t))
1583 || value_text.starts_with(&format!("{}[", t))
1584 }) {
1585 return Some((name_text, value_text, line, false));
1586 }
1587 None
1588 }
1589 "assignment_expression" | "augmented_assignment_expression" => {
1590 let left = node.child_by_field_name("left")?;
1592 let right = node.child_by_field_name("right")?;
1593 let left_text = node_text(left, source);
1594 let right_text = node_text(right, source);
1595
1596 if tracked_names.iter().any(|t| right_text == *t) {
1597 return Some((left_text, right_text, line, false));
1598 }
1599 None
1600 }
1601 "assignment" => {
1602 let left = node.child_by_field_name("left")?;
1604 let right = node.child_by_field_name("right")?;
1605 let left_text = node_text(left, source);
1606 let right_text = node_text(right, source);
1607
1608 if tracked_names.iter().any(|t| right_text == *t) {
1609 return Some((left_text, right_text, line, false));
1610 }
1611 None
1612 }
1613 "let_declaration" | "short_var_declaration" => {
1614 let left = node
1616 .child_by_field_name("pattern")
1617 .or_else(|| node.child_by_field_name("left"))?;
1618 let right = node
1619 .child_by_field_name("value")
1620 .or_else(|| node.child_by_field_name("right"))?;
1621 let left_text = node_text(left, source);
1622 let right_text = node_text(right, source);
1623
1624 if tracked_names.iter().any(|t| right_text == *t) {
1625 return Some((left_text, right_text, line, false));
1626 }
1627 None
1628 }
1629 _ => None,
1630 }
1631 }
1632
1633 #[allow(clippy::too_many_arguments)]
1636 fn check_call_for_data_flow(
1637 &mut self,
1638 node: tree_sitter::Node,
1639 source: &str,
1640 tracked_names: &[String],
1641 file: &Path,
1642 _symbol: &str,
1643 rel_file: &str,
1644 _lang: LangId,
1645 max_depth: usize,
1646 current_depth: usize,
1647 hops: &mut Vec<DataFlowHop>,
1648 depth_limited: &mut bool,
1649 visited: &mut HashSet<(PathBuf, String, String)>,
1650 ) {
1651 let args_node = find_child_by_kind(node, "arguments")
1653 .or_else(|| find_child_by_kind(node, "argument_list"));
1654
1655 let args_node = match args_node {
1656 Some(n) => n,
1657 None => return,
1658 };
1659
1660 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1663
1664 let mut cursor = args_node.walk();
1665 if cursor.goto_first_child() {
1666 loop {
1667 let child = cursor.node();
1668 let child_kind = child.kind();
1669
1670 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1672 if !cursor.goto_next_sibling() {
1673 break;
1674 }
1675 continue;
1676 }
1677
1678 let arg_text = node_text(child, source);
1679
1680 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1682 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1683 hops.push(DataFlowHop {
1684 file: rel_file.to_string(),
1685 symbol: _symbol.to_string(),
1686 variable: arg_text,
1687 line: child.start_position().row as u32 + 1,
1688 flow_type: "parameter".to_string(),
1689 approximate: true,
1690 });
1691 }
1692 if !cursor.goto_next_sibling() {
1693 break;
1694 }
1695 arg_idx += 1;
1696 continue;
1697 }
1698
1699 if tracked_names.iter().any(|t| arg_text == *t) {
1700 arg_positions.push((arg_idx, arg_text));
1701 }
1702
1703 arg_idx += 1;
1704 if !cursor.goto_next_sibling() {
1705 break;
1706 }
1707 }
1708 }
1709
1710 if arg_positions.is_empty() {
1711 return;
1712 }
1713
1714 let (full_callee, short_callee) = extract_callee_names(node, source);
1716 let full_callee = match full_callee {
1717 Some(f) => f,
1718 None => return,
1719 };
1720 let short_callee = match short_callee {
1721 Some(s) => s,
1722 None => return,
1723 };
1724
1725 let import_block = {
1727 match self.data.get(file) {
1728 Some(fd) => fd.import_block.clone(),
1729 None => return,
1730 }
1731 };
1732
1733 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1734
1735 match edge {
1736 EdgeResolution::Resolved {
1737 file: target_file,
1738 symbol: target_symbol,
1739 } => {
1740 if current_depth + 1 > max_depth {
1741 *depth_limited = true;
1742 return;
1743 }
1744
1745 if let Err(e) = self.build_file(&target_file) {
1747 log::debug!(
1748 "callgraph: skipping target file {}: {}",
1749 target_file.display(),
1750 e
1751 );
1752 }
1753 let (params, _target_lang) = {
1754 match self.data.get(&target_file) {
1755 Some(fd) => {
1756 let meta = fd.symbol_metadata.get(&target_symbol);
1757 let sig = meta.and_then(|m| m.signature.clone());
1758 let params = sig
1759 .as_deref()
1760 .map(|s| extract_parameters(s, fd.lang))
1761 .unwrap_or_default();
1762 (params, fd.lang)
1763 }
1764 None => return,
1765 }
1766 };
1767
1768 let target_rel = self.relative_path(&target_file);
1769
1770 for (pos, _tracked) in &arg_positions {
1771 if let Some(param_name) = params.get(*pos) {
1772 hops.push(DataFlowHop {
1774 file: target_rel.clone(),
1775 symbol: target_symbol.clone(),
1776 variable: param_name.clone(),
1777 line: get_symbol_meta(&target_file, &target_symbol).0,
1778 flow_type: "parameter".to_string(),
1779 approximate: false,
1780 });
1781
1782 self.trace_data_inner(
1784 &target_file.clone(),
1785 &target_symbol.clone(),
1786 param_name,
1787 max_depth,
1788 current_depth + 1,
1789 hops,
1790 depth_limited,
1791 visited,
1792 );
1793 }
1794 }
1795 }
1796 EdgeResolution::Unresolved { callee_name } => {
1797 let has_local = self
1799 .data
1800 .get(file)
1801 .map(|fd| {
1802 fd.calls_by_symbol.contains_key(&callee_name)
1803 || fd.symbol_metadata.contains_key(&callee_name)
1804 })
1805 .unwrap_or(false);
1806
1807 if has_local {
1808 let (params, _target_lang) = {
1810 let Some(fd) = self.data.get(file) else {
1811 return;
1812 };
1813 let meta = fd.symbol_metadata.get(&callee_name);
1814 let sig = meta.and_then(|m| m.signature.clone());
1815 let params = sig
1816 .as_deref()
1817 .map(|s| extract_parameters(s, fd.lang))
1818 .unwrap_or_default();
1819 (params, fd.lang)
1820 };
1821
1822 let file_rel = self.relative_path(file);
1823
1824 for (pos, _tracked) in &arg_positions {
1825 if let Some(param_name) = params.get(*pos) {
1826 hops.push(DataFlowHop {
1827 file: file_rel.clone(),
1828 symbol: callee_name.clone(),
1829 variable: param_name.clone(),
1830 line: get_symbol_meta(file, &callee_name).0,
1831 flow_type: "parameter".to_string(),
1832 approximate: false,
1833 });
1834
1835 self.trace_data_inner(
1837 file,
1838 &callee_name.clone(),
1839 param_name,
1840 max_depth,
1841 current_depth + 1,
1842 hops,
1843 depth_limited,
1844 visited,
1845 );
1846 }
1847 }
1848 } else {
1849 for (_pos, tracked) in &arg_positions {
1851 hops.push(DataFlowHop {
1852 file: self.relative_path(file),
1853 symbol: callee_name.clone(),
1854 variable: tracked.clone(),
1855 line: node.start_position().row as u32 + 1,
1856 flow_type: "parameter".to_string(),
1857 approximate: true,
1858 });
1859 }
1860 }
1861 }
1862 }
1863 }
1864
1865 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1867 let content = std::fs::read_to_string(path).ok()?;
1868 content
1869 .lines()
1870 .nth(line.saturating_sub(1) as usize)
1871 .map(|l| l.trim().to_string())
1872 }
1873
1874 fn collect_callers_recursive(
1876 &self,
1877 file: &Path,
1878 symbol: &str,
1879 max_depth: usize,
1880 current_depth: usize,
1881 visited: &mut HashSet<(PathBuf, SharedStr)>,
1882 result: &mut Vec<CallerSite>,
1883 ) {
1884 if current_depth >= max_depth {
1885 return;
1886 }
1887
1888 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1890 let key_symbol: SharedStr = Arc::from(symbol);
1891 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
1892 return; }
1894
1895 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
1896 for site in sites {
1897 result.push(CallerSite {
1898 caller_file: site.caller_file.as_ref().clone(),
1899 caller_symbol: site.caller_symbol.to_string(),
1900 line: site.line,
1901 col: site.col,
1902 resolved: site.resolved,
1903 });
1904 if current_depth + 1 < max_depth {
1906 self.collect_callers_recursive(
1907 site.caller_file.as_ref(),
1908 site.caller_symbol.as_ref(),
1909 max_depth,
1910 current_depth + 1,
1911 visited,
1912 result,
1913 );
1914 }
1915 }
1916 }
1917 }
1918
1919 pub fn invalidate_file(&mut self, path: &Path) {
1924 self.data.remove(path);
1926 if let Ok(canon) = self.canonicalize(path) {
1927 self.data.remove(&canon);
1928 }
1929 self.reverse_index = None;
1931 self.project_files = None;
1933 }
1934
1935 fn relative_path(&self, path: &Path) -> String {
1938 path.strip_prefix(&self.project_root)
1939 .unwrap_or(path)
1940 .display()
1941 .to_string()
1942 }
1943
1944 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1946 let full_path = if path.is_relative() {
1948 self.project_root.join(path)
1949 } else {
1950 path.to_path_buf()
1951 };
1952
1953 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1955 }
1956
1957 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
1961 if let Some(fd) = self.data.get(path) {
1962 return Some(fd);
1963 }
1964 let canon = std::fs::canonicalize(path).ok()?;
1966 self.data.get(&canon).or_else(|| {
1967 self.data.iter().find_map(|(k, v)| {
1969 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
1970 Some(v)
1971 } else {
1972 None
1973 }
1974 })
1975 })
1976 }
1977}
1978
1979fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1985 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1986 message: format!("unsupported file for call graph: {}", path.display()),
1987 })?;
1988
1989 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1990 path: format!("{}: {}", path.display(), e),
1991 })?;
1992
1993 let grammar = grammar_for(lang);
1994 let mut parser = Parser::new();
1995 parser
1996 .set_language(&grammar)
1997 .map_err(|e| AftError::ParseError {
1998 message: format!("grammar init failed for {:?}: {}", lang, e),
1999 })?;
2000
2001 let tree = parser
2002 .parse(&source, None)
2003 .ok_or_else(|| AftError::ParseError {
2004 message: format!("parse failed for {}", path.display()),
2005 })?;
2006
2007 let import_block = imports::parse_imports(&source, &tree, lang);
2009
2010 let symbols = list_symbols_from_tree(&source, &tree, lang, path);
2012
2013 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2015 let root = tree.root_node();
2016
2017 for sym in &symbols {
2018 let byte_start = line_col_to_byte(&source, sym.start_line, sym.start_col);
2019 let byte_end = line_col_to_byte(&source, sym.end_line, sym.end_col);
2020
2021 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2022
2023 let sites: Vec<CallSite> = raw_calls
2024 .into_iter()
2025 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
2027 callee_name: short,
2028 full_callee: full,
2029 line,
2030 byte_start,
2031 byte_end,
2032 })
2033 .collect();
2034
2035 if !sites.is_empty() {
2036 calls_by_symbol.insert(sym.name.clone(), sites);
2037 }
2038 }
2039
2040 let exported_symbols: Vec<String> = symbols
2042 .iter()
2043 .filter(|s| s.exported)
2044 .map(|s| s.name.clone())
2045 .collect();
2046
2047 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
2049 .iter()
2050 .map(|s| {
2051 (
2052 s.name.clone(),
2053 SymbolMeta {
2054 kind: s.kind.clone(),
2055 exported: s.exported,
2056 signature: s.signature.clone(),
2057 },
2058 )
2059 })
2060 .collect();
2061
2062 Ok(FileCallData {
2063 calls_by_symbol,
2064 exported_symbols,
2065 symbol_metadata,
2066 import_block,
2067 lang,
2068 })
2069}
2070
2071#[derive(Debug)]
2073#[allow(dead_code)]
2074struct SymbolInfo {
2075 name: String,
2076 kind: SymbolKind,
2077 start_line: u32,
2078 start_col: u32,
2079 end_line: u32,
2080 end_col: u32,
2081 exported: bool,
2082 signature: Option<String>,
2083}
2084
2085fn list_symbols_from_tree(
2088 _source: &str,
2089 _tree: &Tree,
2090 _lang: LangId,
2091 path: &Path,
2092) -> Vec<SymbolInfo> {
2093 let mut file_parser = crate::parser::FileParser::new();
2095 match file_parser.parse(path) {
2096 Ok(_) => {}
2097 Err(_) => return vec![],
2098 }
2099
2100 let provider = crate::parser::TreeSitterProvider::new();
2102 match provider.list_symbols(path) {
2103 Ok(symbols) => symbols
2104 .into_iter()
2105 .map(|s| SymbolInfo {
2106 name: s.name,
2107 kind: s.kind,
2108 start_line: s.range.start_line,
2109 start_col: s.range.start_col,
2110 end_line: s.range.end_line,
2111 end_col: s.range.end_col,
2112 exported: s.exported,
2113 signature: s.signature,
2114 })
2115 .collect(),
2116 Err(_) => vec![],
2117 }
2118}
2119
2120fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2122 let provider = crate::parser::TreeSitterProvider::new();
2123 match provider.list_symbols(path) {
2124 Ok(symbols) => {
2125 for s in &symbols {
2126 if s.name == symbol_name {
2127 return (s.range.start_line + 1, s.signature.clone());
2128 }
2129 }
2130 (1, None)
2131 }
2132 Err(_) => (1, None),
2133 }
2134}
2135
2136fn node_text(node: tree_sitter::Node, source: &str) -> String {
2142 source[node.start_byte()..node.end_byte()].to_string()
2143}
2144
2145fn find_node_covering_range(
2147 root: tree_sitter::Node,
2148 start: usize,
2149 end: usize,
2150) -> Option<tree_sitter::Node> {
2151 let mut best = None;
2152 let mut cursor = root.walk();
2153
2154 fn walk_covering<'a>(
2155 cursor: &mut tree_sitter::TreeCursor<'a>,
2156 start: usize,
2157 end: usize,
2158 best: &mut Option<tree_sitter::Node<'a>>,
2159 ) {
2160 let node = cursor.node();
2161 if node.start_byte() <= start && node.end_byte() >= end {
2162 *best = Some(node);
2163 if cursor.goto_first_child() {
2164 loop {
2165 walk_covering(cursor, start, end, best);
2166 if !cursor.goto_next_sibling() {
2167 break;
2168 }
2169 }
2170 cursor.goto_parent();
2171 }
2172 }
2173 }
2174
2175 walk_covering(&mut cursor, start, end, &mut best);
2176 best
2177}
2178
2179fn find_child_by_kind<'a>(
2181 node: tree_sitter::Node<'a>,
2182 kind: &str,
2183) -> Option<tree_sitter::Node<'a>> {
2184 let mut cursor = node.walk();
2185 if cursor.goto_first_child() {
2186 loop {
2187 if cursor.node().kind() == kind {
2188 return Some(cursor.node());
2189 }
2190 if !cursor.goto_next_sibling() {
2191 break;
2192 }
2193 }
2194 }
2195 None
2196}
2197
2198fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2200 let callee = match node.child_by_field_name("function") {
2202 Some(c) => c,
2203 None => return (None, None),
2204 };
2205
2206 let full = node_text(callee, source);
2207 let short = if full.contains('.') {
2208 full.rsplit('.').next().unwrap_or(&full).to_string()
2209 } else {
2210 full.clone()
2211 };
2212
2213 (Some(full), Some(short))
2214}
2215
2216pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2224 if !module_path.starts_with('.') {
2226 return None;
2227 }
2228
2229 let base = from_dir.join(module_path);
2230
2231 if base.is_file() {
2233 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2234 }
2235
2236 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2238 for ext in &extensions {
2239 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2240 if with_ext.is_file() {
2241 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2242 }
2243 }
2244
2245 if base.is_dir() {
2247 if let Some(index) = find_index_file(&base) {
2248 return Some(index);
2249 }
2250 }
2251
2252 None
2253}
2254
2255fn find_index_file(dir: &Path) -> Option<PathBuf> {
2257 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2258 for name in &candidates {
2259 let p = dir.join(name);
2260 if p.is_file() {
2261 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2262 }
2263 }
2264 None
2265}
2266
2267fn resolve_aliased_import(
2270 local_name: &str,
2271 import_block: &ImportBlock,
2272 caller_dir: &Path,
2273) -> Option<(String, PathBuf)> {
2274 for imp in &import_block.imports {
2275 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2278 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2279 return Some((original, resolved_path));
2280 }
2281 }
2282 }
2283 None
2284}
2285
2286fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2290 let search = format!(" as {}", local_name);
2293 if let Some(pos) = raw_import.find(&search) {
2294 let before = &raw_import[..pos];
2296 let original = before
2298 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2299 .find(|s| !s.is_empty())?;
2300 return Some(original.to_string());
2301 }
2302 None
2303}
2304
2305pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2313 use ignore::WalkBuilder;
2314
2315 let walker = WalkBuilder::new(root)
2316 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2321 let name = entry.file_name().to_string_lossy();
2322 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2324 return !matches!(
2325 name.as_ref(),
2326 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2327 | ".tox" | "dist" | "build"
2328 );
2329 }
2330 true
2331 })
2332 .build();
2333
2334 walker
2335 .filter_map(|entry| entry.ok())
2336 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2337 .filter(|entry| detect_language(entry.path()).is_some())
2338 .map(|entry| entry.into_path())
2339}
2340
2341#[cfg(test)]
2346mod tests {
2347 use super::*;
2348 use std::fs;
2349 use tempfile::TempDir;
2350
2351 fn setup_ts_project() -> TempDir {
2353 let dir = TempDir::new().unwrap();
2354
2355 fs::write(
2357 dir.path().join("main.ts"),
2358 r#"import { helper, compute } from './utils';
2359import * as math from './math';
2360
2361export function main() {
2362 const a = helper(1);
2363 const b = compute(a, 2);
2364 const c = math.add(a, b);
2365 return c;
2366}
2367"#,
2368 )
2369 .unwrap();
2370
2371 fs::write(
2373 dir.path().join("utils.ts"),
2374 r#"import { double } from './helpers';
2375
2376export function helper(x: number): number {
2377 return double(x);
2378}
2379
2380export function compute(a: number, b: number): number {
2381 return a + b;
2382}
2383"#,
2384 )
2385 .unwrap();
2386
2387 fs::write(
2389 dir.path().join("helpers.ts"),
2390 r#"export function double(x: number): number {
2391 return x * 2;
2392}
2393
2394export function triple(x: number): number {
2395 return x * 3;
2396}
2397"#,
2398 )
2399 .unwrap();
2400
2401 fs::write(
2403 dir.path().join("math.ts"),
2404 r#"export function add(a: number, b: number): number {
2405 return a + b;
2406}
2407
2408export function subtract(a: number, b: number): number {
2409 return a - b;
2410}
2411"#,
2412 )
2413 .unwrap();
2414
2415 dir
2416 }
2417
2418 fn setup_alias_project() -> TempDir {
2420 let dir = TempDir::new().unwrap();
2421
2422 fs::write(
2423 dir.path().join("main.ts"),
2424 r#"import { helper as h } from './utils';
2425
2426export function main() {
2427 return h(42);
2428}
2429"#,
2430 )
2431 .unwrap();
2432
2433 fs::write(
2434 dir.path().join("utils.ts"),
2435 r#"export function helper(x: number): number {
2436 return x + 1;
2437}
2438"#,
2439 )
2440 .unwrap();
2441
2442 dir
2443 }
2444
2445 fn setup_cycle_project() -> TempDir {
2447 let dir = TempDir::new().unwrap();
2448
2449 fs::write(
2450 dir.path().join("a.ts"),
2451 r#"import { funcB } from './b';
2452
2453export function funcA() {
2454 return funcB();
2455}
2456"#,
2457 )
2458 .unwrap();
2459
2460 fs::write(
2461 dir.path().join("b.ts"),
2462 r#"import { funcA } from './a';
2463
2464export function funcB() {
2465 return funcA();
2466}
2467"#,
2468 )
2469 .unwrap();
2470
2471 dir
2472 }
2473
2474 #[test]
2477 fn callgraph_single_file_call_extraction() {
2478 let dir = setup_ts_project();
2479 let mut graph = CallGraph::new(dir.path().to_path_buf());
2480
2481 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2482 let main_calls = &file_data.calls_by_symbol["main"];
2483
2484 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2485 assert!(
2486 callee_names.contains(&"helper"),
2487 "main should call helper, got: {:?}",
2488 callee_names
2489 );
2490 assert!(
2491 callee_names.contains(&"compute"),
2492 "main should call compute, got: {:?}",
2493 callee_names
2494 );
2495 assert!(
2496 callee_names.contains(&"add"),
2497 "main should call math.add (short name: add), got: {:?}",
2498 callee_names
2499 );
2500 }
2501
2502 #[test]
2503 fn callgraph_file_data_has_exports() {
2504 let dir = setup_ts_project();
2505 let mut graph = CallGraph::new(dir.path().to_path_buf());
2506
2507 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2508 assert!(
2509 file_data.exported_symbols.contains(&"helper".to_string()),
2510 "utils.ts should export helper, got: {:?}",
2511 file_data.exported_symbols
2512 );
2513 assert!(
2514 file_data.exported_symbols.contains(&"compute".to_string()),
2515 "utils.ts should export compute, got: {:?}",
2516 file_data.exported_symbols
2517 );
2518 }
2519
2520 #[test]
2523 fn callgraph_resolve_direct_import() {
2524 let dir = setup_ts_project();
2525 let mut graph = CallGraph::new(dir.path().to_path_buf());
2526
2527 let main_path = dir.path().join("main.ts");
2528 let file_data = graph.build_file(&main_path).unwrap();
2529 let import_block = file_data.import_block.clone();
2530
2531 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2532 match edge {
2533 EdgeResolution::Resolved { file, symbol } => {
2534 assert!(
2535 file.ends_with("utils.ts"),
2536 "helper should resolve to utils.ts, got: {:?}",
2537 file
2538 );
2539 assert_eq!(symbol, "helper");
2540 }
2541 EdgeResolution::Unresolved { callee_name } => {
2542 panic!("Expected resolved, got unresolved: {}", callee_name);
2543 }
2544 }
2545 }
2546
2547 #[test]
2548 fn callgraph_resolve_namespace_import() {
2549 let dir = setup_ts_project();
2550 let mut graph = CallGraph::new(dir.path().to_path_buf());
2551
2552 let main_path = dir.path().join("main.ts");
2553 let file_data = graph.build_file(&main_path).unwrap();
2554 let import_block = file_data.import_block.clone();
2555
2556 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2557 match edge {
2558 EdgeResolution::Resolved { file, symbol } => {
2559 assert!(
2560 file.ends_with("math.ts"),
2561 "math.add should resolve to math.ts, got: {:?}",
2562 file
2563 );
2564 assert_eq!(symbol, "add");
2565 }
2566 EdgeResolution::Unresolved { callee_name } => {
2567 panic!("Expected resolved, got unresolved: {}", callee_name);
2568 }
2569 }
2570 }
2571
2572 #[test]
2573 fn callgraph_resolve_aliased_import() {
2574 let dir = setup_alias_project();
2575 let mut graph = CallGraph::new(dir.path().to_path_buf());
2576
2577 let main_path = dir.path().join("main.ts");
2578 let file_data = graph.build_file(&main_path).unwrap();
2579 let import_block = file_data.import_block.clone();
2580
2581 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2582 match edge {
2583 EdgeResolution::Resolved { file, symbol } => {
2584 assert!(
2585 file.ends_with("utils.ts"),
2586 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2587 file
2588 );
2589 assert_eq!(symbol, "helper");
2590 }
2591 EdgeResolution::Unresolved { callee_name } => {
2592 panic!("Expected resolved, got unresolved: {}", callee_name);
2593 }
2594 }
2595 }
2596
2597 #[test]
2598 fn callgraph_unresolved_edge_marked() {
2599 let dir = setup_ts_project();
2600 let mut graph = CallGraph::new(dir.path().to_path_buf());
2601
2602 let main_path = dir.path().join("main.ts");
2603 let file_data = graph.build_file(&main_path).unwrap();
2604 let import_block = file_data.import_block.clone();
2605
2606 let edge =
2607 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2608 assert_eq!(
2609 edge,
2610 EdgeResolution::Unresolved {
2611 callee_name: "unknownFunc".to_string()
2612 },
2613 "Unknown callee should be unresolved"
2614 );
2615 }
2616
2617 #[test]
2620 fn callgraph_cycle_detection_stops() {
2621 let dir = setup_cycle_project();
2622 let mut graph = CallGraph::new(dir.path().to_path_buf());
2623
2624 let tree = graph
2626 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2627 .unwrap();
2628
2629 assert_eq!(tree.name, "funcA");
2630 assert!(tree.resolved);
2631
2632 fn count_depth(node: &CallTreeNode) -> usize {
2635 if node.children.is_empty() {
2636 1
2637 } else {
2638 1 + node
2639 .children
2640 .iter()
2641 .map(|c| count_depth(c))
2642 .max()
2643 .unwrap_or(0)
2644 }
2645 }
2646
2647 let depth = count_depth(&tree);
2648 assert!(
2649 depth <= 4,
2650 "Cycle should be detected and bounded, depth was: {}",
2651 depth
2652 );
2653 }
2654
2655 #[test]
2658 fn callgraph_depth_limit_truncates() {
2659 let dir = setup_ts_project();
2660 let mut graph = CallGraph::new(dir.path().to_path_buf());
2661
2662 let tree = graph
2665 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2666 .unwrap();
2667
2668 assert_eq!(tree.name, "main");
2669
2670 for child in &tree.children {
2672 assert!(
2673 child.children.is_empty(),
2674 "At depth 1, child '{}' should have no children, got {:?}",
2675 child.name,
2676 child.children.len()
2677 );
2678 }
2679 }
2680
2681 #[test]
2682 fn callgraph_depth_zero_no_children() {
2683 let dir = setup_ts_project();
2684 let mut graph = CallGraph::new(dir.path().to_path_buf());
2685
2686 let tree = graph
2687 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2688 .unwrap();
2689
2690 assert_eq!(tree.name, "main");
2691 assert!(
2692 tree.children.is_empty(),
2693 "At depth 0, should have no children"
2694 );
2695 }
2696
2697 #[test]
2700 fn callgraph_forward_tree_cross_file() {
2701 let dir = setup_ts_project();
2702 let mut graph = CallGraph::new(dir.path().to_path_buf());
2703
2704 let tree = graph
2706 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2707 .unwrap();
2708
2709 assert_eq!(tree.name, "main");
2710 assert!(tree.resolved);
2711
2712 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2714 assert!(
2715 helper_child.is_some(),
2716 "main should have helper as child, children: {:?}",
2717 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2718 );
2719
2720 let helper = helper_child.unwrap();
2721 assert!(
2722 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2723 "helper should be in utils.ts, got: {}",
2724 helper.file
2725 );
2726
2727 let double_child = helper.children.iter().find(|c| c.name == "double");
2729 assert!(
2730 double_child.is_some(),
2731 "helper should call double, children: {:?}",
2732 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2733 );
2734
2735 let double = double_child.unwrap();
2736 assert!(
2737 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2738 "double should be in helpers.ts, got: {}",
2739 double.file
2740 );
2741 }
2742
2743 #[test]
2746 fn callgraph_walker_excludes_gitignored() {
2747 let dir = TempDir::new().unwrap();
2748
2749 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2751
2752 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2754 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2755 fs::write(
2756 dir.path().join("ignored_dir").join("secret.ts"),
2757 "export function secret() {}",
2758 )
2759 .unwrap();
2760
2761 fs::create_dir(dir.path().join("node_modules")).unwrap();
2763 fs::write(
2764 dir.path().join("node_modules").join("dep.ts"),
2765 "export function dep() {}",
2766 )
2767 .unwrap();
2768
2769 std::process::Command::new("git")
2771 .args(["init"])
2772 .current_dir(dir.path())
2773 .output()
2774 .unwrap();
2775
2776 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2777 let file_names: Vec<String> = files
2778 .iter()
2779 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2780 .collect();
2781
2782 assert!(
2783 file_names.contains(&"main.ts".to_string()),
2784 "Should include main.ts, got: {:?}",
2785 file_names
2786 );
2787 assert!(
2788 !file_names.contains(&"secret.ts".to_string()),
2789 "Should exclude gitignored secret.ts, got: {:?}",
2790 file_names
2791 );
2792 assert!(
2793 !file_names.contains(&"dep.ts".to_string()),
2794 "Should exclude node_modules, got: {:?}",
2795 file_names
2796 );
2797 }
2798
2799 #[test]
2800 fn callgraph_walker_only_source_files() {
2801 let dir = TempDir::new().unwrap();
2802
2803 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2804 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2805 fs::write(dir.path().join("data.json"), "{}").unwrap();
2806
2807 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2808 let file_names: Vec<String> = files
2809 .iter()
2810 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2811 .collect();
2812
2813 assert!(file_names.contains(&"main.ts".to_string()));
2814 assert!(
2815 file_names.contains(&"readme.md".to_string()),
2816 "Markdown is now a supported source language"
2817 );
2818 assert!(
2819 !file_names.contains(&"data.json".to_string()),
2820 "Should not include non-source files"
2821 );
2822 }
2823
2824 #[test]
2827 fn callgraph_find_alias_original_simple() {
2828 let raw = "import { foo as bar } from './utils';";
2829 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2830 }
2831
2832 #[test]
2833 fn callgraph_find_alias_original_multiple() {
2834 let raw = "import { foo as bar, baz as qux } from './utils';";
2835 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2836 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2837 }
2838
2839 #[test]
2840 fn callgraph_find_alias_no_match() {
2841 let raw = "import { foo } from './utils';";
2842 assert_eq!(find_alias_original(raw, "foo"), None);
2843 }
2844
2845 #[test]
2848 fn callgraph_callers_of_direct() {
2849 let dir = setup_ts_project();
2850 let mut graph = CallGraph::new(dir.path().to_path_buf());
2851
2852 let result = graph
2854 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2855 .unwrap();
2856
2857 assert_eq!(result.symbol, "double");
2858 assert!(result.total_callers > 0, "double should have callers");
2859 assert!(result.scanned_files > 0, "should have scanned files");
2860
2861 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2863 assert!(
2864 utils_group.is_some(),
2865 "double should be called from utils.ts, groups: {:?}",
2866 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2867 );
2868
2869 let group = utils_group.unwrap();
2870 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2871 assert!(
2872 helper_caller.is_some(),
2873 "double should be called by helper, callers: {:?}",
2874 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2875 );
2876 }
2877
2878 #[test]
2879 fn callgraph_callers_of_no_callers() {
2880 let dir = setup_ts_project();
2881 let mut graph = CallGraph::new(dir.path().to_path_buf());
2882
2883 let result = graph
2885 .callers_of(&dir.path().join("main.ts"), "main", 1)
2886 .unwrap();
2887
2888 assert_eq!(result.symbol, "main");
2889 assert_eq!(result.total_callers, 0, "main should have no callers");
2890 assert!(result.callers.is_empty());
2891 }
2892
2893 #[test]
2894 fn callgraph_callers_recursive_depth() {
2895 let dir = setup_ts_project();
2896 let mut graph = CallGraph::new(dir.path().to_path_buf());
2897
2898 let result = graph
2902 .callers_of(&dir.path().join("helpers.ts"), "double", 2)
2903 .unwrap();
2904
2905 assert!(
2906 result.total_callers >= 2,
2907 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2908 result.total_callers
2909 );
2910
2911 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2913 assert!(
2914 main_group.is_some(),
2915 "recursive callers should include main.ts, groups: {:?}",
2916 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2917 );
2918 }
2919
2920 #[test]
2921 fn callgraph_invalidate_file_clears_reverse_index() {
2922 let dir = setup_ts_project();
2923 let mut graph = CallGraph::new(dir.path().to_path_buf());
2924
2925 let _ = graph
2927 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2928 .unwrap();
2929 assert!(
2930 graph.reverse_index.is_some(),
2931 "reverse index should be built"
2932 );
2933
2934 graph.invalidate_file(&dir.path().join("utils.ts"));
2936
2937 assert!(
2939 graph.reverse_index.is_none(),
2940 "invalidate_file should clear reverse index"
2941 );
2942 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2944 assert!(
2945 !graph.data.contains_key(&canon),
2946 "invalidate_file should remove file from data cache"
2947 );
2948 assert!(
2950 graph.project_files.is_none(),
2951 "invalidate_file should clear project_files"
2952 );
2953 }
2954
2955 #[test]
2958 fn is_entry_point_exported_function() {
2959 assert!(is_entry_point(
2960 "handleRequest",
2961 &SymbolKind::Function,
2962 true,
2963 LangId::TypeScript
2964 ));
2965 }
2966
2967 #[test]
2968 fn is_entry_point_exported_method_is_not_entry() {
2969 assert!(!is_entry_point(
2971 "handleRequest",
2972 &SymbolKind::Method,
2973 true,
2974 LangId::TypeScript
2975 ));
2976 }
2977
2978 #[test]
2979 fn is_entry_point_main_init_patterns() {
2980 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
2981 assert!(
2982 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
2983 "{} should be an entry point",
2984 name
2985 );
2986 }
2987 }
2988
2989 #[test]
2990 fn is_entry_point_test_patterns_ts() {
2991 assert!(is_entry_point(
2992 "describe",
2993 &SymbolKind::Function,
2994 false,
2995 LangId::TypeScript
2996 ));
2997 assert!(is_entry_point(
2998 "it",
2999 &SymbolKind::Function,
3000 false,
3001 LangId::TypeScript
3002 ));
3003 assert!(is_entry_point(
3004 "test",
3005 &SymbolKind::Function,
3006 false,
3007 LangId::TypeScript
3008 ));
3009 assert!(is_entry_point(
3010 "testValidation",
3011 &SymbolKind::Function,
3012 false,
3013 LangId::TypeScript
3014 ));
3015 assert!(is_entry_point(
3016 "specHelper",
3017 &SymbolKind::Function,
3018 false,
3019 LangId::TypeScript
3020 ));
3021 }
3022
3023 #[test]
3024 fn is_entry_point_test_patterns_python() {
3025 assert!(is_entry_point(
3026 "test_login",
3027 &SymbolKind::Function,
3028 false,
3029 LangId::Python
3030 ));
3031 assert!(is_entry_point(
3032 "setUp",
3033 &SymbolKind::Function,
3034 false,
3035 LangId::Python
3036 ));
3037 assert!(is_entry_point(
3038 "tearDown",
3039 &SymbolKind::Function,
3040 false,
3041 LangId::Python
3042 ));
3043 assert!(!is_entry_point(
3045 "testSomething",
3046 &SymbolKind::Function,
3047 false,
3048 LangId::Python
3049 ));
3050 }
3051
3052 #[test]
3053 fn is_entry_point_test_patterns_rust() {
3054 assert!(is_entry_point(
3055 "test_parse",
3056 &SymbolKind::Function,
3057 false,
3058 LangId::Rust
3059 ));
3060 assert!(!is_entry_point(
3061 "TestSomething",
3062 &SymbolKind::Function,
3063 false,
3064 LangId::Rust
3065 ));
3066 }
3067
3068 #[test]
3069 fn is_entry_point_test_patterns_go() {
3070 assert!(is_entry_point(
3071 "TestParsing",
3072 &SymbolKind::Function,
3073 false,
3074 LangId::Go
3075 ));
3076 assert!(!is_entry_point(
3078 "testParsing",
3079 &SymbolKind::Function,
3080 false,
3081 LangId::Go
3082 ));
3083 }
3084
3085 #[test]
3086 fn is_entry_point_non_exported_non_main_is_not_entry() {
3087 assert!(!is_entry_point(
3088 "helperUtil",
3089 &SymbolKind::Function,
3090 false,
3091 LangId::TypeScript
3092 ));
3093 }
3094
3095 #[test]
3098 fn callgraph_symbol_metadata_populated() {
3099 let dir = setup_ts_project();
3100 let mut graph = CallGraph::new(dir.path().to_path_buf());
3101
3102 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3103 assert!(
3104 file_data.symbol_metadata.contains_key("helper"),
3105 "symbol_metadata should contain helper"
3106 );
3107 let meta = &file_data.symbol_metadata["helper"];
3108 assert_eq!(meta.kind, SymbolKind::Function);
3109 assert!(meta.exported, "helper should be exported");
3110 }
3111
3112 fn setup_trace_project() -> TempDir {
3128 let dir = TempDir::new().unwrap();
3129
3130 fs::write(
3131 dir.path().join("main.ts"),
3132 r#"import { processData } from './utils';
3133
3134export function main() {
3135 const result = processData("hello");
3136 return result;
3137}
3138"#,
3139 )
3140 .unwrap();
3141
3142 fs::write(
3143 dir.path().join("service.ts"),
3144 r#"import { processData } from './utils';
3145
3146export function handleRequest(input: string): string {
3147 return processData(input);
3148}
3149"#,
3150 )
3151 .unwrap();
3152
3153 fs::write(
3154 dir.path().join("utils.ts"),
3155 r#"import { validate } from './helpers';
3156
3157export function processData(input: string): string {
3158 const valid = validate(input);
3159 if (!valid) {
3160 throw new Error("invalid input");
3161 }
3162 return input.toUpperCase();
3163}
3164"#,
3165 )
3166 .unwrap();
3167
3168 fs::write(
3169 dir.path().join("helpers.ts"),
3170 r#"export function validate(input: string): boolean {
3171 return checkFormat(input);
3172}
3173
3174function checkFormat(input: string): boolean {
3175 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3176}
3177"#,
3178 )
3179 .unwrap();
3180
3181 fs::write(
3182 dir.path().join("test_helpers.ts"),
3183 r#"import { validate } from './helpers';
3184
3185function testValidation() {
3186 const result = validate("hello");
3187 console.log(result);
3188}
3189"#,
3190 )
3191 .unwrap();
3192
3193 std::process::Command::new("git")
3195 .args(["init"])
3196 .current_dir(dir.path())
3197 .output()
3198 .unwrap();
3199
3200 dir
3201 }
3202
3203 #[test]
3204 fn trace_to_multi_path() {
3205 let dir = setup_trace_project();
3206 let mut graph = CallGraph::new(dir.path().to_path_buf());
3207
3208 let result = graph
3209 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3210 .unwrap();
3211
3212 assert_eq!(result.target_symbol, "checkFormat");
3213 assert!(
3214 result.total_paths >= 2,
3215 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3216 result.total_paths,
3217 result
3218 .paths
3219 .iter()
3220 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3221 .collect::<Vec<_>>()
3222 );
3223
3224 for path in &result.paths {
3226 assert!(
3227 path.hops.first().unwrap().is_entry_point,
3228 "First hop should be an entry point, got: {}",
3229 path.hops.first().unwrap().symbol
3230 );
3231 assert_eq!(
3232 path.hops.last().unwrap().symbol,
3233 "checkFormat",
3234 "Last hop should be checkFormat"
3235 );
3236 }
3237
3238 assert!(
3240 result.entry_points_found >= 2,
3241 "should find at least 2 entry points, got {}",
3242 result.entry_points_found
3243 );
3244 }
3245
3246 #[test]
3247 fn trace_to_single_path() {
3248 let dir = setup_trace_project();
3249 let mut graph = CallGraph::new(dir.path().to_path_buf());
3250
3251 let result = graph
3255 .trace_to(&dir.path().join("helpers.ts"), "validate", 10)
3256 .unwrap();
3257
3258 assert_eq!(result.target_symbol, "validate");
3259 assert!(
3260 result.total_paths >= 2,
3261 "validate should have at least 2 paths, got {}",
3262 result.total_paths
3263 );
3264 }
3265
3266 #[test]
3267 fn trace_to_cycle_detection() {
3268 let dir = setup_cycle_project();
3269 let mut graph = CallGraph::new(dir.path().to_path_buf());
3270
3271 let result = graph
3273 .trace_to(&dir.path().join("a.ts"), "funcA", 10)
3274 .unwrap();
3275
3276 assert_eq!(result.target_symbol, "funcA");
3278 }
3279
3280 #[test]
3281 fn trace_to_depth_limit() {
3282 let dir = setup_trace_project();
3283 let mut graph = CallGraph::new(dir.path().to_path_buf());
3284
3285 let result = graph
3287 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1)
3288 .unwrap();
3289
3290 assert_eq!(result.target_symbol, "checkFormat");
3294
3295 let deep_result = graph
3297 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3298 .unwrap();
3299
3300 assert!(
3301 result.total_paths <= deep_result.total_paths,
3302 "shallow trace should find <= paths compared to deep: {} vs {}",
3303 result.total_paths,
3304 deep_result.total_paths
3305 );
3306 }
3307
3308 #[test]
3309 fn trace_to_entry_point_target() {
3310 let dir = setup_trace_project();
3311 let mut graph = CallGraph::new(dir.path().to_path_buf());
3312
3313 let result = graph
3315 .trace_to(&dir.path().join("main.ts"), "main", 10)
3316 .unwrap();
3317
3318 assert_eq!(result.target_symbol, "main");
3319 assert!(
3320 result.total_paths >= 1,
3321 "main should have at least 1 path (itself), got {}",
3322 result.total_paths
3323 );
3324 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3326 assert!(
3327 trivial.is_some(),
3328 "should have a trivial path with just the entry point itself"
3329 );
3330 }
3331
3332 #[test]
3335 fn extract_parameters_typescript() {
3336 let params = extract_parameters(
3337 "function processData(input: string, count: number): void",
3338 LangId::TypeScript,
3339 );
3340 assert_eq!(params, vec!["input", "count"]);
3341 }
3342
3343 #[test]
3344 fn extract_parameters_typescript_optional() {
3345 let params = extract_parameters(
3346 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3347 LangId::TypeScript,
3348 );
3349 assert_eq!(params, vec!["url", "options"]);
3350 }
3351
3352 #[test]
3353 fn extract_parameters_typescript_defaults() {
3354 let params = extract_parameters(
3355 "function greet(name: string, greeting: string = \"hello\"): string",
3356 LangId::TypeScript,
3357 );
3358 assert_eq!(params, vec!["name", "greeting"]);
3359 }
3360
3361 #[test]
3362 fn extract_parameters_typescript_rest() {
3363 let params = extract_parameters(
3364 "function sum(...numbers: number[]): number",
3365 LangId::TypeScript,
3366 );
3367 assert_eq!(params, vec!["numbers"]);
3368 }
3369
3370 #[test]
3371 fn extract_parameters_python_self_skipped() {
3372 let params = extract_parameters(
3373 "def process(self, data: str, count: int) -> bool",
3374 LangId::Python,
3375 );
3376 assert_eq!(params, vec!["data", "count"]);
3377 }
3378
3379 #[test]
3380 fn extract_parameters_python_no_self() {
3381 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3382 assert_eq!(params, vec!["input"]);
3383 }
3384
3385 #[test]
3386 fn extract_parameters_python_star_args() {
3387 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3388 assert_eq!(params, vec!["args", "kwargs"]);
3389 }
3390
3391 #[test]
3392 fn extract_parameters_rust_self_skipped() {
3393 let params = extract_parameters(
3394 "fn process(&self, data: &str, count: usize) -> bool",
3395 LangId::Rust,
3396 );
3397 assert_eq!(params, vec!["data", "count"]);
3398 }
3399
3400 #[test]
3401 fn extract_parameters_rust_mut_self_skipped() {
3402 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3403 assert_eq!(params, vec!["value"]);
3404 }
3405
3406 #[test]
3407 fn extract_parameters_rust_no_self() {
3408 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3409 assert_eq!(params, vec!["input"]);
3410 }
3411
3412 #[test]
3413 fn extract_parameters_rust_mut_param() {
3414 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3415 assert_eq!(params, vec!["buf", "len"]);
3416 }
3417
3418 #[test]
3419 fn extract_parameters_go() {
3420 let params = extract_parameters(
3421 "func ProcessData(input string, count int) error",
3422 LangId::Go,
3423 );
3424 assert_eq!(params, vec!["input", "count"]);
3425 }
3426
3427 #[test]
3428 fn extract_parameters_empty() {
3429 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3430 assert!(
3431 params.is_empty(),
3432 "no-arg function should return empty params"
3433 );
3434 }
3435
3436 #[test]
3437 fn extract_parameters_no_parens() {
3438 let params = extract_parameters("const x = 42", LangId::TypeScript);
3439 assert!(params.is_empty(), "no parens should return empty params");
3440 }
3441
3442 #[test]
3443 fn extract_parameters_javascript() {
3444 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3445 assert_eq!(params, vec!["event", "target"]);
3446 }
3447}