1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use tree_sitter::{Parser, Tree};
12
13use crate::calls::extract_calls_full;
14use crate::error::AftError;
15use crate::imports::{self, ImportBlock};
16use crate::language::LanguageProvider;
17use crate::parser::{detect_language, grammar_for, LangId};
18use crate::symbols::SymbolKind;
19
20#[derive(Debug, Clone)]
26pub struct CallSite {
27 pub callee_name: String,
29 pub full_callee: String,
31 pub line: u32,
33 pub byte_start: usize,
35 pub byte_end: usize,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct SymbolMeta {
41 pub kind: SymbolKind,
43 pub exported: bool,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub signature: Option<String>,
48}
49
50#[derive(Debug, Clone)]
53pub struct FileCallData {
54 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
56 pub exported_symbols: Vec<String>,
58 pub symbol_metadata: HashMap<String, SymbolMeta>,
60 pub import_block: ImportBlock,
62 pub lang: LangId,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum EdgeResolution {
69 Resolved { file: PathBuf, symbol: String },
71 Unresolved { callee_name: String },
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct CallerSite {
78 pub caller_file: PathBuf,
80 pub caller_symbol: String,
82 pub line: u32,
84 pub col: u32,
86 pub resolved: bool,
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct CallerGroup {
93 pub file: String,
95 pub callers: Vec<CallerEntry>,
97}
98
99#[derive(Debug, Clone, Serialize)]
101pub struct CallerEntry {
102 pub symbol: String,
103 pub line: u32,
105}
106
107#[derive(Debug, Clone, Serialize)]
109pub struct CallersResult {
110 pub symbol: String,
112 pub file: String,
114 pub callers: Vec<CallerGroup>,
116 pub total_callers: usize,
118 pub scanned_files: usize,
120}
121
122#[derive(Debug, Clone, Serialize)]
124pub struct CallTreeNode {
125 pub name: String,
127 pub file: String,
129 pub line: u32,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub signature: Option<String>,
134 pub resolved: bool,
136 pub children: Vec<CallTreeNode>,
138}
139
140const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
146
147pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
154 if exported && *kind == SymbolKind::Function {
156 return true;
157 }
158
159 let lower = name.to_lowercase();
161 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
162 return true;
163 }
164
165 match lang {
167 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
168 matches!(lower.as_str(), "describe" | "it" | "test")
170 || lower.starts_with("test")
171 || lower.starts_with("spec")
172 }
173 LangId::Python => {
174 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
176 }
177 LangId::Rust => {
178 lower.starts_with("test_")
180 }
181 LangId::Go => {
182 name.starts_with("Test")
184 }
185 LangId::Markdown => false,
186 }
187}
188
189#[derive(Debug, Clone, Serialize)]
195pub struct TraceHop {
196 pub symbol: String,
198 pub file: String,
200 pub line: u32,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub signature: Option<String>,
205 pub is_entry_point: bool,
207}
208
209#[derive(Debug, Clone, Serialize)]
211pub struct TracePath {
212 pub hops: Vec<TraceHop>,
214}
215
216#[derive(Debug, Clone, Serialize)]
218pub struct TraceToResult {
219 pub target_symbol: String,
221 pub target_file: String,
223 pub paths: Vec<TracePath>,
225 pub total_paths: usize,
227 pub entry_points_found: usize,
229 pub max_depth_reached: bool,
231 pub truncated_paths: usize,
233}
234
235#[derive(Debug, Clone, Serialize)]
241pub struct ImpactCaller {
242 pub caller_symbol: String,
244 pub caller_file: String,
246 pub line: u32,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub signature: Option<String>,
251 pub is_entry_point: bool,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub call_expression: Option<String>,
256 pub parameters: Vec<String>,
258}
259
260#[derive(Debug, Clone, Serialize)]
262pub struct ImpactResult {
263 pub symbol: String,
265 pub file: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub signature: Option<String>,
270 pub parameters: Vec<String>,
272 pub total_affected: usize,
274 pub affected_files: usize,
276 pub callers: Vec<ImpactCaller>,
278}
279
280#[derive(Debug, Clone, Serialize)]
286pub struct DataFlowHop {
287 pub file: String,
289 pub symbol: String,
291 pub variable: String,
293 pub line: u32,
295 pub flow_type: String,
297 pub approximate: bool,
299}
300
301#[derive(Debug, Clone, Serialize)]
304pub struct TraceDataResult {
305 pub expression: String,
307 pub origin_file: String,
309 pub origin_symbol: String,
311 pub hops: Vec<DataFlowHop>,
313 pub depth_limited: bool,
315}
316
317pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
323 let start = match signature.find('(') {
325 Some(i) => i + 1,
326 None => return Vec::new(),
327 };
328 let end = match signature[start..].find(')') {
329 Some(i) => start + i,
330 None => return Vec::new(),
331 };
332
333 let params_str = &signature[start..end].trim();
334 if params_str.is_empty() {
335 return Vec::new();
336 }
337
338 let parts = split_params(params_str);
340
341 let mut result = Vec::new();
342 for part in parts {
343 let trimmed = part.trim();
344 if trimmed.is_empty() {
345 continue;
346 }
347
348 match lang {
350 LangId::Rust => {
351 let normalized = trimmed.replace(' ', "");
352 if normalized == "self"
353 || normalized == "&self"
354 || normalized == "&mutself"
355 || normalized == "mutself"
356 {
357 continue;
358 }
359 }
360 LangId::Python => {
361 if trimmed == "self" || trimmed.starts_with("self:") {
362 continue;
363 }
364 }
365 _ => {}
366 }
367
368 let name = extract_param_name(trimmed, lang);
370 if !name.is_empty() {
371 result.push(name);
372 }
373 }
374
375 result
376}
377
378fn split_params(s: &str) -> Vec<String> {
380 let mut parts = Vec::new();
381 let mut current = String::new();
382 let mut depth = 0i32;
383
384 for ch in s.chars() {
385 match ch {
386 '<' | '[' | '{' | '(' => {
387 depth += 1;
388 current.push(ch);
389 }
390 '>' | ']' | '}' | ')' => {
391 depth -= 1;
392 current.push(ch);
393 }
394 ',' if depth == 0 => {
395 parts.push(current.clone());
396 current.clear();
397 }
398 _ => {
399 current.push(ch);
400 }
401 }
402 }
403 if !current.is_empty() {
404 parts.push(current);
405 }
406 parts
407}
408
409fn extract_param_name(param: &str, lang: LangId) -> String {
417 let trimmed = param.trim();
418
419 let working = if trimmed.starts_with("...") {
421 &trimmed[3..]
422 } else if trimmed.starts_with("**") {
423 &trimmed[2..]
424 } else if trimmed.starts_with('*') && lang == LangId::Python {
425 &trimmed[1..]
426 } else {
427 trimmed
428 };
429
430 let working = if lang == LangId::Rust && working.starts_with("mut ") {
432 &working[4..]
433 } else {
434 working
435 };
436
437 let name = working
440 .split(|c: char| c == ':' || c == '=')
441 .next()
442 .unwrap_or("")
443 .trim();
444
445 let name = name.trim_end_matches('?');
447
448 if lang == LangId::Go && !name.contains(' ') {
450 return name.to_string();
451 }
452 if lang == LangId::Go {
453 return name.split_whitespace().next().unwrap_or("").to_string();
454 }
455
456 name.to_string()
457}
458
459pub struct CallGraph {
468 data: HashMap<PathBuf, FileCallData>,
470 project_root: PathBuf,
472 project_files: Option<Vec<PathBuf>>,
474 reverse_index: Option<HashMap<(PathBuf, String), Vec<CallerSite>>>,
477}
478
479impl CallGraph {
480 pub fn new(project_root: PathBuf) -> Self {
482 Self {
483 data: HashMap::new(),
484 project_root,
485 project_files: None,
486 reverse_index: None,
487 }
488 }
489
490 pub fn project_root(&self) -> &Path {
492 &self.project_root
493 }
494
495 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
497 let canon = self.canonicalize(path)?;
498
499 if !self.data.contains_key(&canon) {
500 let file_data = build_file_data(&canon)?;
501 self.data.insert(canon.clone(), file_data);
502 }
503
504 Ok(&self.data[&canon])
505 }
506
507 pub fn resolve_cross_file_edge(
512 &mut self,
513 full_callee: &str,
514 short_name: &str,
515 caller_file: &Path,
516 import_block: &ImportBlock,
517 ) -> EdgeResolution {
518 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
525
526 if full_callee.contains('.') {
528 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
529 if parts.len() == 2 {
530 let namespace = parts[0];
531 let member = parts[1];
532
533 for imp in &import_block.imports {
534 if imp.namespace_import.as_deref() == Some(namespace) {
535 if let Some(resolved_path) =
536 resolve_module_path(caller_dir, &imp.module_path)
537 {
538 return EdgeResolution::Resolved {
539 file: resolved_path,
540 symbol: member.to_string(),
541 };
542 }
543 }
544 }
545 }
546 }
547
548 for imp in &import_block.imports {
550 if imp.names.contains(&short_name.to_string()) {
552 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
553 return EdgeResolution::Resolved {
555 file: resolved_path,
556 symbol: short_name.to_string(),
557 };
558 }
559 }
560
561 if imp.default_import.as_deref() == Some(short_name) {
563 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
564 return EdgeResolution::Resolved {
565 file: resolved_path,
566 symbol: "default".to_string(),
567 };
568 }
569 }
570 }
571
572 if let Some((original_name, resolved_path)) =
577 resolve_aliased_import(short_name, import_block, caller_dir)
578 {
579 return EdgeResolution::Resolved {
580 file: resolved_path,
581 symbol: original_name,
582 };
583 }
584
585 for imp in &import_block.imports {
588 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
589 if resolved_path.is_dir() {
591 if let Some(index_path) = find_index_file(&resolved_path) {
592 if self.file_exports_symbol(&index_path, short_name) {
594 return EdgeResolution::Resolved {
595 file: index_path,
596 symbol: short_name.to_string(),
597 };
598 }
599 }
600 } else if self.file_exports_symbol(&resolved_path, short_name) {
601 return EdgeResolution::Resolved {
602 file: resolved_path,
603 symbol: short_name.to_string(),
604 };
605 }
606 }
607 }
608
609 EdgeResolution::Unresolved {
610 callee_name: short_name.to_string(),
611 }
612 }
613
614 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
616 match self.build_file(path) {
617 Ok(data) => data.exported_symbols.contains(&symbol_name.to_string()),
618 Err(_) => false,
619 }
620 }
621
622 pub fn forward_tree(
627 &mut self,
628 file: &Path,
629 symbol: &str,
630 max_depth: usize,
631 ) -> Result<CallTreeNode, AftError> {
632 let mut visited = HashSet::new();
633 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
634 }
635
636 fn forward_tree_inner(
637 &mut self,
638 file: &Path,
639 symbol: &str,
640 max_depth: usize,
641 current_depth: usize,
642 visited: &mut HashSet<(PathBuf, String)>,
643 ) -> Result<CallTreeNode, AftError> {
644 let canon = self.canonicalize(file)?;
645 let visit_key = (canon.clone(), symbol.to_string());
646
647 if visited.contains(&visit_key) {
649 let (line, signature) = get_symbol_meta(&canon, symbol);
650 return Ok(CallTreeNode {
651 name: symbol.to_string(),
652 file: self.relative_path(&canon),
653 line,
654 signature,
655 resolved: true,
656 children: vec![], });
658 }
659
660 visited.insert(visit_key.clone());
661
662 let file_data = build_file_data(&canon)?;
664 let import_block = file_data.import_block.clone();
665 let _lang = file_data.lang;
666
667 let call_sites = file_data
669 .calls_by_symbol
670 .get(symbol)
671 .cloned()
672 .unwrap_or_default();
673
674 let (sym_line, sym_signature) = get_symbol_meta(&canon, symbol);
676
677 self.data.insert(canon.clone(), file_data);
679
680 let mut children = Vec::new();
682
683 if current_depth < max_depth {
684 for call_site in &call_sites {
685 let edge = self.resolve_cross_file_edge(
686 &call_site.full_callee,
687 &call_site.callee_name,
688 &canon,
689 &import_block,
690 );
691
692 match edge {
693 EdgeResolution::Resolved {
694 file: ref target_file,
695 ref symbol,
696 } => {
697 match self.forward_tree_inner(
698 target_file,
699 symbol,
700 max_depth,
701 current_depth + 1,
702 visited,
703 ) {
704 Ok(child) => children.push(child),
705 Err(_) => {
706 children.push(CallTreeNode {
708 name: call_site.callee_name.clone(),
709 file: self.relative_path(target_file),
710 line: call_site.line,
711 signature: None,
712 resolved: false,
713 children: vec![],
714 });
715 }
716 }
717 }
718 EdgeResolution::Unresolved { callee_name } => {
719 children.push(CallTreeNode {
720 name: callee_name,
721 file: self.relative_path(&canon),
722 line: call_site.line,
723 signature: None,
724 resolved: false,
725 children: vec![],
726 });
727 }
728 }
729 }
730 }
731
732 visited.remove(&visit_key);
733
734 Ok(CallTreeNode {
735 name: symbol.to_string(),
736 file: self.relative_path(&canon),
737 line: sym_line,
738 signature: sym_signature,
739 resolved: true,
740 children,
741 })
742 }
743
744 pub fn project_files(&mut self) -> &[PathBuf] {
746 if self.project_files.is_none() {
747 let project_root = self.project_root.clone();
748 self.project_files = Some(walk_project_files(&project_root).collect());
749 }
750 self.project_files.as_deref().unwrap_or(&[])
751 }
752
753 fn build_reverse_index(&mut self) {
759 let all_files = self.project_files().to_vec();
761
762 for f in &all_files {
764 if !self.data.contains_key(f) {
765 if let Ok(fd) = build_file_data(f) {
766 self.data.insert(f.clone(), fd);
767 }
768 }
769 }
770
771 let mut reverse: HashMap<(PathBuf, String), Vec<CallerSite>> = HashMap::new();
773
774 for caller_file in &all_files {
775 let canon_caller =
777 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone());
778 let file_data = match self
779 .data
780 .get(caller_file)
781 .or_else(|| self.data.get(&canon_caller))
782 {
783 Some(d) => d.clone(),
784 None => continue,
785 };
786
787 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
788 for call_site in call_sites {
789 let edge = self.resolve_cross_file_edge(
790 &call_site.full_callee,
791 &call_site.callee_name,
792 caller_file,
793 &file_data.import_block,
794 );
795
796 match edge {
797 EdgeResolution::Resolved {
798 file: ref target_file,
799 ref symbol,
800 } => {
801 let key = (target_file.clone(), symbol.clone());
802 reverse.entry(key).or_default().push(CallerSite {
803 caller_file: canon_caller.clone(),
804 caller_symbol: symbol_name.clone(),
805 line: call_site.line,
806 col: 0,
807 resolved: true,
808 });
809 }
810 EdgeResolution::Unresolved { ref callee_name } => {
811 let key = (canon_caller.clone(), callee_name.clone());
814 reverse.entry(key).or_default().push(CallerSite {
815 caller_file: canon_caller.clone(),
816 caller_symbol: symbol_name.clone(),
817 line: call_site.line,
818 col: 0,
819 resolved: false,
820 });
821 }
822 }
823 }
824 }
825 }
826
827 self.reverse_index = Some(reverse);
828 }
829
830 pub fn callers_of(
836 &mut self,
837 file: &Path,
838 symbol: &str,
839 depth: usize,
840 ) -> Result<CallersResult, AftError> {
841 let canon = self.canonicalize(file)?;
842
843 self.build_file(&canon)?;
845
846 if self.reverse_index.is_none() {
848 self.build_reverse_index();
849 }
850
851 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
852 let effective_depth = if depth == 0 { 1 } else { depth };
853
854 let mut visited = HashSet::new();
855 let mut all_sites: Vec<CallerSite> = Vec::new();
856 self.collect_callers_recursive(
857 &canon,
858 symbol,
859 effective_depth,
860 0,
861 &mut visited,
862 &mut all_sites,
863 );
864
865 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
867 for site in &all_sites {
868 groups_map
869 .entry(site.caller_file.clone())
870 .or_default()
871 .push(CallerEntry {
872 symbol: site.caller_symbol.clone(),
873 line: site.line,
874 });
875 }
876
877 let total_callers = all_sites.len();
878 let mut callers: Vec<CallerGroup> = groups_map
879 .into_iter()
880 .map(|(file_path, entries)| CallerGroup {
881 file: self.relative_path(&file_path),
882 callers: entries,
883 })
884 .collect();
885
886 callers.sort_by(|a, b| a.file.cmp(&b.file));
888
889 Ok(CallersResult {
890 symbol: symbol.to_string(),
891 file: self.relative_path(&canon),
892 callers,
893 total_callers,
894 scanned_files,
895 })
896 }
897
898 pub fn trace_to(
904 &mut self,
905 file: &Path,
906 symbol: &str,
907 max_depth: usize,
908 ) -> Result<TraceToResult, AftError> {
909 let canon = self.canonicalize(file)?;
910
911 self.build_file(&canon)?;
913
914 if self.reverse_index.is_none() {
916 self.build_reverse_index();
917 }
918
919 let target_rel = self.relative_path(&canon);
920 let effective_max = if max_depth == 0 { 10 } else { max_depth };
921 let reverse_index = self
922 .reverse_index
923 .as_ref()
924 .ok_or_else(|| AftError::ParseError {
925 message: format!(
926 "reverse index unavailable after building callers for {}",
927 canon.display()
928 ),
929 })?;
930
931 let (target_line, target_sig) = get_symbol_meta(&canon, symbol);
933
934 let target_is_entry = self
936 .lookup_file_data(&canon)
937 .and_then(|fd| {
938 let meta = fd.symbol_metadata.get(symbol)?;
939 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
940 })
941 .unwrap_or(false);
942
943 type PathElem = (PathBuf, String, u32, Option<String>);
946 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
947 let mut max_depth_reached = false;
948 let mut truncated_paths: usize = 0;
949
950 let initial: Vec<PathElem> =
952 vec![(canon.clone(), symbol.to_string(), target_line, target_sig)];
953
954 if target_is_entry {
956 complete_paths.push(initial.clone());
957 }
958
959 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
961
962 while let Some((path, depth)) = queue.pop() {
963 if depth >= effective_max {
964 max_depth_reached = true;
965 continue;
966 }
967
968 let Some((current_file, current_symbol, _, _)) = path.last().cloned() else {
969 continue;
970 };
971
972 let path_visited: HashSet<(PathBuf, String)> = path
974 .iter()
975 .map(|(f, s, _, _)| (f.clone(), s.clone()))
976 .collect();
977
978 let lookup_key = (current_file.clone(), current_symbol.clone());
980 let callers = match reverse_index.get(&lookup_key) {
981 Some(sites) => sites.clone(),
982 None => {
983 if path.len() > 1 {
986 truncated_paths += 1;
989 }
990 continue;
991 }
992 };
993
994 let mut has_new_path = false;
995 for site in &callers {
996 let caller_key = (site.caller_file.clone(), site.caller_symbol.clone());
997
998 if path_visited.contains(&caller_key) {
1000 continue;
1001 }
1002
1003 has_new_path = true;
1004
1005 let (caller_line, caller_sig) =
1007 get_symbol_meta(&site.caller_file, &site.caller_symbol);
1008
1009 let mut new_path = path.clone();
1010 new_path.push((
1011 site.caller_file.clone(),
1012 site.caller_symbol.clone(),
1013 caller_line,
1014 caller_sig,
1015 ));
1016
1017 let caller_is_entry = self
1021 .lookup_file_data(&site.caller_file)
1022 .and_then(|fd| {
1023 let meta = fd.symbol_metadata.get(&site.caller_symbol)?;
1024 Some(is_entry_point(
1025 &site.caller_symbol,
1026 &meta.kind,
1027 meta.exported,
1028 fd.lang,
1029 ))
1030 })
1031 .unwrap_or(false);
1032
1033 if caller_is_entry {
1034 complete_paths.push(new_path.clone());
1035 }
1036 queue.push((new_path, depth + 1));
1039 }
1040
1041 if !has_new_path && path.len() > 1 {
1043 truncated_paths += 1;
1044 }
1045 }
1046
1047 let mut paths: Vec<TracePath> = complete_paths
1050 .into_iter()
1051 .map(|mut elems| {
1052 elems.reverse();
1053 let hops: Vec<TraceHop> = elems
1054 .iter()
1055 .enumerate()
1056 .map(|(i, (file_path, sym, line, sig))| {
1057 let is_ep = if i == 0 {
1058 self.lookup_file_data(file_path)
1060 .and_then(|fd| {
1061 let meta = fd.symbol_metadata.get(sym)?;
1062 Some(is_entry_point(sym, &meta.kind, meta.exported, fd.lang))
1063 })
1064 .unwrap_or(false)
1065 } else {
1066 false
1067 };
1068 TraceHop {
1069 symbol: sym.clone(),
1070 file: self.relative_path(file_path),
1071 line: *line,
1072 signature: sig.clone(),
1073 is_entry_point: is_ep,
1074 }
1075 })
1076 .collect();
1077 TracePath { hops }
1078 })
1079 .collect();
1080
1081 paths.sort_by(|a, b| {
1083 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1084 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1085 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1086 });
1087
1088 let mut entry_point_names: HashSet<String> = HashSet::new();
1090 for p in &paths {
1091 if let Some(first) = p.hops.first() {
1092 if first.is_entry_point {
1093 entry_point_names.insert(first.symbol.clone());
1094 }
1095 }
1096 }
1097
1098 let total_paths = paths.len();
1099 let entry_points_found = entry_point_names.len();
1100
1101 Ok(TraceToResult {
1102 target_symbol: symbol.to_string(),
1103 target_file: target_rel,
1104 paths,
1105 total_paths,
1106 entry_points_found,
1107 max_depth_reached,
1108 truncated_paths,
1109 })
1110 }
1111
1112 pub fn impact(
1118 &mut self,
1119 file: &Path,
1120 symbol: &str,
1121 depth: usize,
1122 ) -> Result<ImpactResult, AftError> {
1123 let canon = self.canonicalize(file)?;
1124
1125 self.build_file(&canon)?;
1127
1128 if self.reverse_index.is_none() {
1130 self.build_reverse_index();
1131 }
1132
1133 let effective_depth = if depth == 0 { 1 } else { depth };
1134
1135 let (target_signature, target_parameters, target_lang) = {
1137 let file_data = match self.data.get(&canon) {
1138 Some(d) => d,
1139 None => {
1140 return Err(AftError::InvalidRequest {
1141 message: "file data missing after build".to_string(),
1142 })
1143 }
1144 };
1145 let meta = file_data.symbol_metadata.get(symbol);
1146 let sig = meta.and_then(|m| m.signature.clone());
1147 let lang = file_data.lang;
1148 let params = sig
1149 .as_deref()
1150 .map(|s| extract_parameters(s, lang))
1151 .unwrap_or_default();
1152 (sig, params, lang)
1153 };
1154
1155 let mut visited = HashSet::new();
1157 let mut all_sites: Vec<CallerSite> = Vec::new();
1158 self.collect_callers_recursive(
1159 &canon,
1160 symbol,
1161 effective_depth,
1162 0,
1163 &mut visited,
1164 &mut all_sites,
1165 );
1166
1167 let mut seen = HashSet::new();
1169 all_sites.retain(|s| seen.insert((s.caller_file.clone(), s.caller_symbol.clone(), s.line)));
1170
1171 let mut callers = Vec::new();
1173 let mut affected_file_set = HashSet::new();
1174
1175 for site in &all_sites {
1176 let caller_canon = std::fs::canonicalize(&site.caller_file)
1178 .unwrap_or_else(|_| site.caller_file.clone());
1179 if let Err(e) = self.build_file(&caller_canon) {
1180 log::debug!(
1181 "callgraph: skipping caller file {}: {}",
1182 caller_canon.display(),
1183 e
1184 );
1185 }
1186
1187 let (sig, is_ep, params, _lang) = {
1188 if let Some(fd) = self.data.get(&caller_canon) {
1189 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1190 let sig = meta.and_then(|m| m.signature.clone());
1191 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1192 let exported = meta.map(|m| m.exported).unwrap_or(false);
1193 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1194 let lang = fd.lang;
1195 let params = sig
1196 .as_deref()
1197 .map(|s| extract_parameters(s, lang))
1198 .unwrap_or_default();
1199 (sig, is_ep, params, lang)
1200 } else {
1201 (None, false, Vec::new(), target_lang)
1202 }
1203 };
1204
1205 let call_expression = self.read_source_line(&caller_canon, site.line);
1207
1208 let rel_file = self.relative_path(&site.caller_file);
1209 affected_file_set.insert(rel_file.clone());
1210
1211 callers.push(ImpactCaller {
1212 caller_symbol: site.caller_symbol.clone(),
1213 caller_file: rel_file,
1214 line: site.line,
1215 signature: sig,
1216 is_entry_point: is_ep,
1217 call_expression,
1218 parameters: params,
1219 });
1220 }
1221
1222 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1224
1225 let total_affected = callers.len();
1226 let affected_files = affected_file_set.len();
1227
1228 Ok(ImpactResult {
1229 symbol: symbol.to_string(),
1230 file: self.relative_path(&canon),
1231 signature: target_signature,
1232 parameters: target_parameters,
1233 total_affected,
1234 affected_files,
1235 callers,
1236 })
1237 }
1238
1239 pub fn trace_data(
1250 &mut self,
1251 file: &Path,
1252 symbol: &str,
1253 expression: &str,
1254 max_depth: usize,
1255 ) -> Result<TraceDataResult, AftError> {
1256 let canon = self.canonicalize(file)?;
1257 let rel_file = self.relative_path(&canon);
1258
1259 self.build_file(&canon)?;
1261
1262 {
1264 let fd = match self.data.get(&canon) {
1265 Some(d) => d,
1266 None => {
1267 return Err(AftError::InvalidRequest {
1268 message: "file data missing after build".to_string(),
1269 })
1270 }
1271 };
1272 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1273 || fd.exported_symbols.contains(&symbol.to_string())
1274 || fd.symbol_metadata.contains_key(symbol);
1275 if !has_symbol {
1276 return Err(AftError::InvalidRequest {
1277 message: format!(
1278 "trace_data: symbol '{}' not found in {}",
1279 symbol,
1280 file.display()
1281 ),
1282 });
1283 }
1284 }
1285
1286 let mut hops = Vec::new();
1287 let mut depth_limited = false;
1288
1289 self.trace_data_inner(
1290 &canon,
1291 symbol,
1292 expression,
1293 max_depth,
1294 0,
1295 &mut hops,
1296 &mut depth_limited,
1297 &mut HashSet::new(),
1298 );
1299
1300 Ok(TraceDataResult {
1301 expression: expression.to_string(),
1302 origin_file: rel_file,
1303 origin_symbol: symbol.to_string(),
1304 hops,
1305 depth_limited,
1306 })
1307 }
1308
1309 fn trace_data_inner(
1311 &mut self,
1312 file: &Path,
1313 symbol: &str,
1314 tracking_name: &str,
1315 max_depth: usize,
1316 current_depth: usize,
1317 hops: &mut Vec<DataFlowHop>,
1318 depth_limited: &mut bool,
1319 visited: &mut HashSet<(PathBuf, String, String)>,
1320 ) {
1321 let visit_key = (
1322 file.to_path_buf(),
1323 symbol.to_string(),
1324 tracking_name.to_string(),
1325 );
1326 if visited.contains(&visit_key) {
1327 return; }
1329 visited.insert(visit_key);
1330
1331 let source = match std::fs::read_to_string(file) {
1333 Ok(s) => s,
1334 Err(_) => return,
1335 };
1336
1337 let lang = match detect_language(file) {
1338 Some(l) => l,
1339 None => return,
1340 };
1341
1342 let grammar = grammar_for(lang);
1343 let mut parser = Parser::new();
1344 if parser.set_language(&grammar).is_err() {
1345 return;
1346 }
1347 let tree = match parser.parse(&source, None) {
1348 Some(t) => t,
1349 None => return,
1350 };
1351
1352 let symbols = list_symbols_from_tree(&source, &tree, lang, file);
1354 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1355 Some(s) => s,
1356 None => return,
1357 };
1358
1359 let body_start = line_col_to_byte(&source, sym_info.start_line, sym_info.start_col);
1360 let body_end = line_col_to_byte(&source, sym_info.end_line, sym_info.end_col);
1361
1362 let root = tree.root_node();
1363
1364 let body_node = match find_node_covering_range(root, body_start, body_end) {
1366 Some(n) => n,
1367 None => return,
1368 };
1369
1370 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1372 let rel_file = self.relative_path(file);
1373
1374 self.walk_for_data_flow(
1376 body_node,
1377 &source,
1378 &mut tracked_names,
1379 file,
1380 symbol,
1381 &rel_file,
1382 lang,
1383 max_depth,
1384 current_depth,
1385 hops,
1386 depth_limited,
1387 visited,
1388 );
1389 }
1390
1391 #[allow(clippy::too_many_arguments)]
1394 fn walk_for_data_flow(
1395 &mut self,
1396 node: tree_sitter::Node,
1397 source: &str,
1398 tracked_names: &mut Vec<String>,
1399 file: &Path,
1400 symbol: &str,
1401 rel_file: &str,
1402 lang: LangId,
1403 max_depth: usize,
1404 current_depth: usize,
1405 hops: &mut Vec<DataFlowHop>,
1406 depth_limited: &mut bool,
1407 visited: &mut HashSet<(PathBuf, String, String)>,
1408 ) {
1409 let kind = node.kind();
1410
1411 let is_var_decl = matches!(
1413 kind,
1414 "variable_declarator"
1415 | "assignment_expression"
1416 | "augmented_assignment_expression"
1417 | "assignment"
1418 | "let_declaration"
1419 | "short_var_declaration"
1420 );
1421
1422 if is_var_decl {
1423 if let Some((new_name, init_text, line, is_approx)) =
1424 self.extract_assignment_info(node, source, lang, tracked_names)
1425 {
1426 if !is_approx {
1428 hops.push(DataFlowHop {
1429 file: rel_file.to_string(),
1430 symbol: symbol.to_string(),
1431 variable: new_name.clone(),
1432 line,
1433 flow_type: "assignment".to_string(),
1434 approximate: false,
1435 });
1436 tracked_names.push(new_name);
1437 } else {
1438 hops.push(DataFlowHop {
1440 file: rel_file.to_string(),
1441 symbol: symbol.to_string(),
1442 variable: init_text,
1443 line,
1444 flow_type: "assignment".to_string(),
1445 approximate: true,
1446 });
1447 return;
1449 }
1450 }
1451 }
1452
1453 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1455 self.check_call_for_data_flow(
1456 node,
1457 source,
1458 tracked_names,
1459 file,
1460 symbol,
1461 rel_file,
1462 lang,
1463 max_depth,
1464 current_depth,
1465 hops,
1466 depth_limited,
1467 visited,
1468 );
1469 }
1470
1471 let mut cursor = node.walk();
1473 if cursor.goto_first_child() {
1474 loop {
1475 let child = cursor.node();
1476 self.walk_for_data_flow(
1478 child,
1479 source,
1480 tracked_names,
1481 file,
1482 symbol,
1483 rel_file,
1484 lang,
1485 max_depth,
1486 current_depth,
1487 hops,
1488 depth_limited,
1489 visited,
1490 );
1491 if !cursor.goto_next_sibling() {
1492 break;
1493 }
1494 }
1495 }
1496 }
1497
1498 fn extract_assignment_info(
1501 &self,
1502 node: tree_sitter::Node,
1503 source: &str,
1504 _lang: LangId,
1505 tracked_names: &[String],
1506 ) -> Option<(String, String, u32, bool)> {
1507 let kind = node.kind();
1508 let line = node.start_position().row as u32 + 1;
1509
1510 match kind {
1511 "variable_declarator" => {
1512 let name_node = node.child_by_field_name("name")?;
1514 let value_node = node.child_by_field_name("value")?;
1515 let name_text = node_text(name_node, source);
1516 let value_text = node_text(value_node, source);
1517
1518 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1520 if tracked_names.iter().any(|t| value_text.contains(t)) {
1522 return Some((name_text.clone(), name_text, line, true));
1523 }
1524 return None;
1525 }
1526
1527 if tracked_names.iter().any(|t| {
1529 value_text == *t
1530 || value_text.starts_with(&format!("{}.", t))
1531 || value_text.starts_with(&format!("{}[", t))
1532 }) {
1533 return Some((name_text, value_text, line, false));
1534 }
1535 None
1536 }
1537 "assignment_expression" | "augmented_assignment_expression" => {
1538 let left = node.child_by_field_name("left")?;
1540 let right = node.child_by_field_name("right")?;
1541 let left_text = node_text(left, source);
1542 let right_text = node_text(right, source);
1543
1544 if tracked_names.iter().any(|t| right_text == *t) {
1545 return Some((left_text, right_text, line, false));
1546 }
1547 None
1548 }
1549 "assignment" => {
1550 let left = node.child_by_field_name("left")?;
1552 let right = node.child_by_field_name("right")?;
1553 let left_text = node_text(left, source);
1554 let right_text = node_text(right, source);
1555
1556 if tracked_names.iter().any(|t| right_text == *t) {
1557 return Some((left_text, right_text, line, false));
1558 }
1559 None
1560 }
1561 "let_declaration" | "short_var_declaration" => {
1562 let left = node
1564 .child_by_field_name("pattern")
1565 .or_else(|| node.child_by_field_name("left"))?;
1566 let right = node
1567 .child_by_field_name("value")
1568 .or_else(|| node.child_by_field_name("right"))?;
1569 let left_text = node_text(left, source);
1570 let right_text = node_text(right, source);
1571
1572 if tracked_names.iter().any(|t| right_text == *t) {
1573 return Some((left_text, right_text, line, false));
1574 }
1575 None
1576 }
1577 _ => None,
1578 }
1579 }
1580
1581 #[allow(clippy::too_many_arguments)]
1584 fn check_call_for_data_flow(
1585 &mut self,
1586 node: tree_sitter::Node,
1587 source: &str,
1588 tracked_names: &[String],
1589 file: &Path,
1590 _symbol: &str,
1591 rel_file: &str,
1592 _lang: LangId,
1593 max_depth: usize,
1594 current_depth: usize,
1595 hops: &mut Vec<DataFlowHop>,
1596 depth_limited: &mut bool,
1597 visited: &mut HashSet<(PathBuf, String, String)>,
1598 ) {
1599 let args_node = find_child_by_kind(node, "arguments")
1601 .or_else(|| find_child_by_kind(node, "argument_list"));
1602
1603 let args_node = match args_node {
1604 Some(n) => n,
1605 None => return,
1606 };
1607
1608 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1611
1612 let mut cursor = args_node.walk();
1613 if cursor.goto_first_child() {
1614 loop {
1615 let child = cursor.node();
1616 let child_kind = child.kind();
1617
1618 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1620 if !cursor.goto_next_sibling() {
1621 break;
1622 }
1623 continue;
1624 }
1625
1626 let arg_text = node_text(child, source);
1627
1628 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1630 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1631 hops.push(DataFlowHop {
1632 file: rel_file.to_string(),
1633 symbol: _symbol.to_string(),
1634 variable: arg_text,
1635 line: child.start_position().row as u32 + 1,
1636 flow_type: "parameter".to_string(),
1637 approximate: true,
1638 });
1639 }
1640 if !cursor.goto_next_sibling() {
1641 break;
1642 }
1643 arg_idx += 1;
1644 continue;
1645 }
1646
1647 if tracked_names.iter().any(|t| arg_text == *t) {
1648 arg_positions.push((arg_idx, arg_text));
1649 }
1650
1651 arg_idx += 1;
1652 if !cursor.goto_next_sibling() {
1653 break;
1654 }
1655 }
1656 }
1657
1658 if arg_positions.is_empty() {
1659 return;
1660 }
1661
1662 let (full_callee, short_callee) = extract_callee_names(node, source);
1664 let full_callee = match full_callee {
1665 Some(f) => f,
1666 None => return,
1667 };
1668 let short_callee = match short_callee {
1669 Some(s) => s,
1670 None => return,
1671 };
1672
1673 let import_block = {
1675 match self.data.get(file) {
1676 Some(fd) => fd.import_block.clone(),
1677 None => return,
1678 }
1679 };
1680
1681 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1682
1683 match edge {
1684 EdgeResolution::Resolved {
1685 file: target_file,
1686 symbol: target_symbol,
1687 } => {
1688 if current_depth + 1 > max_depth {
1689 *depth_limited = true;
1690 return;
1691 }
1692
1693 if let Err(e) = self.build_file(&target_file) {
1695 log::debug!(
1696 "callgraph: skipping target file {}: {}",
1697 target_file.display(),
1698 e
1699 );
1700 }
1701 let (params, _target_lang) = {
1702 match self.data.get(&target_file) {
1703 Some(fd) => {
1704 let meta = fd.symbol_metadata.get(&target_symbol);
1705 let sig = meta.and_then(|m| m.signature.clone());
1706 let params = sig
1707 .as_deref()
1708 .map(|s| extract_parameters(s, fd.lang))
1709 .unwrap_or_default();
1710 (params, fd.lang)
1711 }
1712 None => return,
1713 }
1714 };
1715
1716 let target_rel = self.relative_path(&target_file);
1717
1718 for (pos, _tracked) in &arg_positions {
1719 if let Some(param_name) = params.get(*pos) {
1720 hops.push(DataFlowHop {
1722 file: target_rel.clone(),
1723 symbol: target_symbol.clone(),
1724 variable: param_name.clone(),
1725 line: get_symbol_meta(&target_file, &target_symbol).0,
1726 flow_type: "parameter".to_string(),
1727 approximate: false,
1728 });
1729
1730 self.trace_data_inner(
1732 &target_file.clone(),
1733 &target_symbol.clone(),
1734 param_name,
1735 max_depth,
1736 current_depth + 1,
1737 hops,
1738 depth_limited,
1739 visited,
1740 );
1741 }
1742 }
1743 }
1744 EdgeResolution::Unresolved { callee_name } => {
1745 let has_local = self
1747 .data
1748 .get(file)
1749 .map(|fd| {
1750 fd.calls_by_symbol.contains_key(&callee_name)
1751 || fd.symbol_metadata.contains_key(&callee_name)
1752 })
1753 .unwrap_or(false);
1754
1755 if has_local {
1756 let (params, _target_lang) = {
1758 let Some(fd) = self.data.get(file) else {
1759 return;
1760 };
1761 let meta = fd.symbol_metadata.get(&callee_name);
1762 let sig = meta.and_then(|m| m.signature.clone());
1763 let params = sig
1764 .as_deref()
1765 .map(|s| extract_parameters(s, fd.lang))
1766 .unwrap_or_default();
1767 (params, fd.lang)
1768 };
1769
1770 let file_rel = self.relative_path(file);
1771
1772 for (pos, _tracked) in &arg_positions {
1773 if let Some(param_name) = params.get(*pos) {
1774 hops.push(DataFlowHop {
1775 file: file_rel.clone(),
1776 symbol: callee_name.clone(),
1777 variable: param_name.clone(),
1778 line: get_symbol_meta(file, &callee_name).0,
1779 flow_type: "parameter".to_string(),
1780 approximate: false,
1781 });
1782
1783 self.trace_data_inner(
1785 file,
1786 &callee_name.clone(),
1787 param_name,
1788 max_depth,
1789 current_depth + 1,
1790 hops,
1791 depth_limited,
1792 visited,
1793 );
1794 }
1795 }
1796 } else {
1797 for (_pos, tracked) in &arg_positions {
1799 hops.push(DataFlowHop {
1800 file: self.relative_path(file),
1801 symbol: callee_name.clone(),
1802 variable: tracked.clone(),
1803 line: node.start_position().row as u32 + 1,
1804 flow_type: "parameter".to_string(),
1805 approximate: true,
1806 });
1807 }
1808 }
1809 }
1810 }
1811 }
1812
1813 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1815 let content = std::fs::read_to_string(path).ok()?;
1816 content
1817 .lines()
1818 .nth(line.saturating_sub(1) as usize)
1819 .map(|l| l.trim().to_string())
1820 }
1821
1822 fn collect_callers_recursive(
1824 &self,
1825 file: &Path,
1826 symbol: &str,
1827 max_depth: usize,
1828 current_depth: usize,
1829 visited: &mut HashSet<(PathBuf, String)>,
1830 result: &mut Vec<CallerSite>,
1831 ) {
1832 if current_depth >= max_depth {
1833 return;
1834 }
1835
1836 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1838 let key = (canon.clone(), symbol.to_string());
1839 if visited.contains(&key) {
1840 return; }
1842 visited.insert(key);
1843
1844 let reverse_index = match &self.reverse_index {
1845 Some(ri) => ri,
1846 None => return,
1847 };
1848
1849 let lookup_key = (canon, symbol.to_string());
1850 if let Some(sites) = reverse_index.get(&lookup_key) {
1851 for site in sites {
1852 result.push(site.clone());
1853 if current_depth + 1 < max_depth {
1855 self.collect_callers_recursive(
1856 &site.caller_file,
1857 &site.caller_symbol,
1858 max_depth,
1859 current_depth + 1,
1860 visited,
1861 result,
1862 );
1863 }
1864 }
1865 }
1866 }
1867
1868 pub fn invalidate_file(&mut self, path: &Path) {
1873 self.data.remove(path);
1875 if let Ok(canon) = self.canonicalize(path) {
1876 self.data.remove(&canon);
1877 }
1878 self.reverse_index = None;
1880 self.project_files = None;
1882 }
1883
1884 fn relative_path(&self, path: &Path) -> String {
1887 path.strip_prefix(&self.project_root)
1888 .unwrap_or(path)
1889 .display()
1890 .to_string()
1891 }
1892
1893 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1895 let full_path = if path.is_relative() {
1897 self.project_root.join(path)
1898 } else {
1899 path.to_path_buf()
1900 };
1901
1902 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1904 }
1905
1906 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
1910 if let Some(fd) = self.data.get(path) {
1911 return Some(fd);
1912 }
1913 let canon = std::fs::canonicalize(path).ok()?;
1915 self.data.get(&canon).or_else(|| {
1916 self.data.iter().find_map(|(k, v)| {
1918 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
1919 Some(v)
1920 } else {
1921 None
1922 }
1923 })
1924 })
1925 }
1926}
1927
1928fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1934 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1935 message: format!("unsupported file for call graph: {}", path.display()),
1936 })?;
1937
1938 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1939 path: format!("{}: {}", path.display(), e),
1940 })?;
1941
1942 let grammar = grammar_for(lang);
1943 let mut parser = Parser::new();
1944 parser
1945 .set_language(&grammar)
1946 .map_err(|e| AftError::ParseError {
1947 message: format!("grammar init failed for {:?}: {}", lang, e),
1948 })?;
1949
1950 let tree = parser
1951 .parse(&source, None)
1952 .ok_or_else(|| AftError::ParseError {
1953 message: format!("parse failed for {}", path.display()),
1954 })?;
1955
1956 let import_block = imports::parse_imports(&source, &tree, lang);
1958
1959 let symbols = list_symbols_from_tree(&source, &tree, lang, path);
1961
1962 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
1964 let root = tree.root_node();
1965
1966 for sym in &symbols {
1967 let byte_start = line_col_to_byte(&source, sym.start_line, sym.start_col);
1968 let byte_end = line_col_to_byte(&source, sym.end_line, sym.end_col);
1969
1970 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1971
1972 let sites: Vec<CallSite> = raw_calls
1973 .into_iter()
1974 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
1976 callee_name: short,
1977 full_callee: full,
1978 line,
1979 byte_start,
1980 byte_end,
1981 })
1982 .collect();
1983
1984 if !sites.is_empty() {
1985 calls_by_symbol.insert(sym.name.clone(), sites);
1986 }
1987 }
1988
1989 let exported_symbols: Vec<String> = symbols
1991 .iter()
1992 .filter(|s| s.exported)
1993 .map(|s| s.name.clone())
1994 .collect();
1995
1996 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
1998 .iter()
1999 .map(|s| {
2000 (
2001 s.name.clone(),
2002 SymbolMeta {
2003 kind: s.kind.clone(),
2004 exported: s.exported,
2005 signature: s.signature.clone(),
2006 },
2007 )
2008 })
2009 .collect();
2010
2011 Ok(FileCallData {
2012 calls_by_symbol,
2013 exported_symbols,
2014 symbol_metadata,
2015 import_block,
2016 lang,
2017 })
2018}
2019
2020#[derive(Debug)]
2022#[allow(dead_code)]
2023struct SymbolInfo {
2024 name: String,
2025 kind: SymbolKind,
2026 start_line: u32,
2027 start_col: u32,
2028 end_line: u32,
2029 end_col: u32,
2030 exported: bool,
2031 signature: Option<String>,
2032}
2033
2034fn list_symbols_from_tree(
2037 _source: &str,
2038 _tree: &Tree,
2039 _lang: LangId,
2040 path: &Path,
2041) -> Vec<SymbolInfo> {
2042 let mut file_parser = crate::parser::FileParser::new();
2044 match file_parser.parse(path) {
2045 Ok(_) => {}
2046 Err(_) => return vec![],
2047 }
2048
2049 let provider = crate::parser::TreeSitterProvider::new();
2051 match provider.list_symbols(path) {
2052 Ok(symbols) => symbols
2053 .into_iter()
2054 .map(|s| SymbolInfo {
2055 name: s.name,
2056 kind: s.kind,
2057 start_line: s.range.start_line,
2058 start_col: s.range.start_col,
2059 end_line: s.range.end_line,
2060 end_col: s.range.end_col,
2061 exported: s.exported,
2062 signature: s.signature,
2063 })
2064 .collect(),
2065 Err(_) => vec![],
2066 }
2067}
2068
2069fn line_col_to_byte(source: &str, line: u32, col: u32) -> usize {
2071 let mut byte = 0;
2072 for (i, l) in source.lines().enumerate() {
2073 if i == line as usize {
2074 return byte + (col as usize).min(l.len());
2075 }
2076 byte += l.len() + 1;
2077 }
2078 source.len()
2079}
2080
2081fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2083 let provider = crate::parser::TreeSitterProvider::new();
2084 match provider.list_symbols(path) {
2085 Ok(symbols) => {
2086 for s in &symbols {
2087 if s.name == symbol_name {
2088 return (s.range.start_line + 1, s.signature.clone());
2089 }
2090 }
2091 (1, None)
2092 }
2093 Err(_) => (1, None),
2094 }
2095}
2096
2097fn node_text(node: tree_sitter::Node, source: &str) -> String {
2103 source[node.start_byte()..node.end_byte()].to_string()
2104}
2105
2106fn find_node_covering_range(
2108 root: tree_sitter::Node,
2109 start: usize,
2110 end: usize,
2111) -> Option<tree_sitter::Node> {
2112 let mut best = None;
2113 let mut cursor = root.walk();
2114
2115 fn walk_covering<'a>(
2116 cursor: &mut tree_sitter::TreeCursor<'a>,
2117 start: usize,
2118 end: usize,
2119 best: &mut Option<tree_sitter::Node<'a>>,
2120 ) {
2121 let node = cursor.node();
2122 if node.start_byte() <= start && node.end_byte() >= end {
2123 *best = Some(node);
2124 if cursor.goto_first_child() {
2125 loop {
2126 walk_covering(cursor, start, end, best);
2127 if !cursor.goto_next_sibling() {
2128 break;
2129 }
2130 }
2131 cursor.goto_parent();
2132 }
2133 }
2134 }
2135
2136 walk_covering(&mut cursor, start, end, &mut best);
2137 best
2138}
2139
2140fn find_child_by_kind<'a>(
2142 node: tree_sitter::Node<'a>,
2143 kind: &str,
2144) -> Option<tree_sitter::Node<'a>> {
2145 let mut cursor = node.walk();
2146 if cursor.goto_first_child() {
2147 loop {
2148 if cursor.node().kind() == kind {
2149 return Some(cursor.node());
2150 }
2151 if !cursor.goto_next_sibling() {
2152 break;
2153 }
2154 }
2155 }
2156 None
2157}
2158
2159fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2161 let callee = match node.child_by_field_name("function") {
2163 Some(c) => c,
2164 None => return (None, None),
2165 };
2166
2167 let full = node_text(callee, source);
2168 let short = if full.contains('.') {
2169 full.rsplit('.').next().unwrap_or(&full).to_string()
2170 } else {
2171 full.clone()
2172 };
2173
2174 (Some(full), Some(short))
2175}
2176
2177pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2185 if !module_path.starts_with('.') {
2187 return None;
2188 }
2189
2190 let base = from_dir.join(module_path);
2191
2192 if base.is_file() {
2194 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2195 }
2196
2197 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2199 for ext in &extensions {
2200 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2201 if with_ext.is_file() {
2202 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2203 }
2204 }
2205
2206 if base.is_dir() {
2208 if let Some(index) = find_index_file(&base) {
2209 return Some(index);
2210 }
2211 }
2212
2213 None
2214}
2215
2216fn find_index_file(dir: &Path) -> Option<PathBuf> {
2218 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2219 for name in &candidates {
2220 let p = dir.join(name);
2221 if p.is_file() {
2222 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2223 }
2224 }
2225 None
2226}
2227
2228fn resolve_aliased_import(
2231 local_name: &str,
2232 import_block: &ImportBlock,
2233 caller_dir: &Path,
2234) -> Option<(String, PathBuf)> {
2235 for imp in &import_block.imports {
2236 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2239 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2240 return Some((original, resolved_path));
2241 }
2242 }
2243 }
2244 None
2245}
2246
2247fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2251 let search = format!(" as {}", local_name);
2254 if let Some(pos) = raw_import.find(&search) {
2255 let before = &raw_import[..pos];
2257 let original = before
2259 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2260 .find(|s| !s.is_empty())?;
2261 return Some(original.to_string());
2262 }
2263 None
2264}
2265
2266pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2274 use ignore::WalkBuilder;
2275
2276 let walker = WalkBuilder::new(root)
2277 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2282 let name = entry.file_name().to_string_lossy();
2283 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2285 return !matches!(
2286 name.as_ref(),
2287 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2288 | ".tox" | "dist" | "build"
2289 );
2290 }
2291 true
2292 })
2293 .build();
2294
2295 walker
2296 .filter_map(|entry| entry.ok())
2297 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2298 .filter(|entry| detect_language(entry.path()).is_some())
2299 .map(|entry| entry.into_path())
2300}
2301
2302#[cfg(test)]
2307mod tests {
2308 use super::*;
2309 use std::fs;
2310 use tempfile::TempDir;
2311
2312 fn setup_ts_project() -> TempDir {
2314 let dir = TempDir::new().unwrap();
2315
2316 fs::write(
2318 dir.path().join("main.ts"),
2319 r#"import { helper, compute } from './utils';
2320import * as math from './math';
2321
2322export function main() {
2323 const a = helper(1);
2324 const b = compute(a, 2);
2325 const c = math.add(a, b);
2326 return c;
2327}
2328"#,
2329 )
2330 .unwrap();
2331
2332 fs::write(
2334 dir.path().join("utils.ts"),
2335 r#"import { double } from './helpers';
2336
2337export function helper(x: number): number {
2338 return double(x);
2339}
2340
2341export function compute(a: number, b: number): number {
2342 return a + b;
2343}
2344"#,
2345 )
2346 .unwrap();
2347
2348 fs::write(
2350 dir.path().join("helpers.ts"),
2351 r#"export function double(x: number): number {
2352 return x * 2;
2353}
2354
2355export function triple(x: number): number {
2356 return x * 3;
2357}
2358"#,
2359 )
2360 .unwrap();
2361
2362 fs::write(
2364 dir.path().join("math.ts"),
2365 r#"export function add(a: number, b: number): number {
2366 return a + b;
2367}
2368
2369export function subtract(a: number, b: number): number {
2370 return a - b;
2371}
2372"#,
2373 )
2374 .unwrap();
2375
2376 dir
2377 }
2378
2379 fn setup_alias_project() -> TempDir {
2381 let dir = TempDir::new().unwrap();
2382
2383 fs::write(
2384 dir.path().join("main.ts"),
2385 r#"import { helper as h } from './utils';
2386
2387export function main() {
2388 return h(42);
2389}
2390"#,
2391 )
2392 .unwrap();
2393
2394 fs::write(
2395 dir.path().join("utils.ts"),
2396 r#"export function helper(x: number): number {
2397 return x + 1;
2398}
2399"#,
2400 )
2401 .unwrap();
2402
2403 dir
2404 }
2405
2406 fn setup_cycle_project() -> TempDir {
2408 let dir = TempDir::new().unwrap();
2409
2410 fs::write(
2411 dir.path().join("a.ts"),
2412 r#"import { funcB } from './b';
2413
2414export function funcA() {
2415 return funcB();
2416}
2417"#,
2418 )
2419 .unwrap();
2420
2421 fs::write(
2422 dir.path().join("b.ts"),
2423 r#"import { funcA } from './a';
2424
2425export function funcB() {
2426 return funcA();
2427}
2428"#,
2429 )
2430 .unwrap();
2431
2432 dir
2433 }
2434
2435 #[test]
2438 fn callgraph_single_file_call_extraction() {
2439 let dir = setup_ts_project();
2440 let mut graph = CallGraph::new(dir.path().to_path_buf());
2441
2442 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2443 let main_calls = &file_data.calls_by_symbol["main"];
2444
2445 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2446 assert!(
2447 callee_names.contains(&"helper"),
2448 "main should call helper, got: {:?}",
2449 callee_names
2450 );
2451 assert!(
2452 callee_names.contains(&"compute"),
2453 "main should call compute, got: {:?}",
2454 callee_names
2455 );
2456 assert!(
2457 callee_names.contains(&"add"),
2458 "main should call math.add (short name: add), got: {:?}",
2459 callee_names
2460 );
2461 }
2462
2463 #[test]
2464 fn callgraph_file_data_has_exports() {
2465 let dir = setup_ts_project();
2466 let mut graph = CallGraph::new(dir.path().to_path_buf());
2467
2468 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2469 assert!(
2470 file_data.exported_symbols.contains(&"helper".to_string()),
2471 "utils.ts should export helper, got: {:?}",
2472 file_data.exported_symbols
2473 );
2474 assert!(
2475 file_data.exported_symbols.contains(&"compute".to_string()),
2476 "utils.ts should export compute, got: {:?}",
2477 file_data.exported_symbols
2478 );
2479 }
2480
2481 #[test]
2484 fn callgraph_resolve_direct_import() {
2485 let dir = setup_ts_project();
2486 let mut graph = CallGraph::new(dir.path().to_path_buf());
2487
2488 let main_path = dir.path().join("main.ts");
2489 let file_data = graph.build_file(&main_path).unwrap();
2490 let import_block = file_data.import_block.clone();
2491
2492 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2493 match edge {
2494 EdgeResolution::Resolved { file, symbol } => {
2495 assert!(
2496 file.ends_with("utils.ts"),
2497 "helper should resolve to utils.ts, got: {:?}",
2498 file
2499 );
2500 assert_eq!(symbol, "helper");
2501 }
2502 EdgeResolution::Unresolved { callee_name } => {
2503 panic!("Expected resolved, got unresolved: {}", callee_name);
2504 }
2505 }
2506 }
2507
2508 #[test]
2509 fn callgraph_resolve_namespace_import() {
2510 let dir = setup_ts_project();
2511 let mut graph = CallGraph::new(dir.path().to_path_buf());
2512
2513 let main_path = dir.path().join("main.ts");
2514 let file_data = graph.build_file(&main_path).unwrap();
2515 let import_block = file_data.import_block.clone();
2516
2517 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2518 match edge {
2519 EdgeResolution::Resolved { file, symbol } => {
2520 assert!(
2521 file.ends_with("math.ts"),
2522 "math.add should resolve to math.ts, got: {:?}",
2523 file
2524 );
2525 assert_eq!(symbol, "add");
2526 }
2527 EdgeResolution::Unresolved { callee_name } => {
2528 panic!("Expected resolved, got unresolved: {}", callee_name);
2529 }
2530 }
2531 }
2532
2533 #[test]
2534 fn callgraph_resolve_aliased_import() {
2535 let dir = setup_alias_project();
2536 let mut graph = CallGraph::new(dir.path().to_path_buf());
2537
2538 let main_path = dir.path().join("main.ts");
2539 let file_data = graph.build_file(&main_path).unwrap();
2540 let import_block = file_data.import_block.clone();
2541
2542 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2543 match edge {
2544 EdgeResolution::Resolved { file, symbol } => {
2545 assert!(
2546 file.ends_with("utils.ts"),
2547 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2548 file
2549 );
2550 assert_eq!(symbol, "helper");
2551 }
2552 EdgeResolution::Unresolved { callee_name } => {
2553 panic!("Expected resolved, got unresolved: {}", callee_name);
2554 }
2555 }
2556 }
2557
2558 #[test]
2559 fn callgraph_unresolved_edge_marked() {
2560 let dir = setup_ts_project();
2561 let mut graph = CallGraph::new(dir.path().to_path_buf());
2562
2563 let main_path = dir.path().join("main.ts");
2564 let file_data = graph.build_file(&main_path).unwrap();
2565 let import_block = file_data.import_block.clone();
2566
2567 let edge =
2568 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2569 assert_eq!(
2570 edge,
2571 EdgeResolution::Unresolved {
2572 callee_name: "unknownFunc".to_string()
2573 },
2574 "Unknown callee should be unresolved"
2575 );
2576 }
2577
2578 #[test]
2581 fn callgraph_cycle_detection_stops() {
2582 let dir = setup_cycle_project();
2583 let mut graph = CallGraph::new(dir.path().to_path_buf());
2584
2585 let tree = graph
2587 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2588 .unwrap();
2589
2590 assert_eq!(tree.name, "funcA");
2591 assert!(tree.resolved);
2592
2593 fn count_depth(node: &CallTreeNode) -> usize {
2596 if node.children.is_empty() {
2597 1
2598 } else {
2599 1 + node
2600 .children
2601 .iter()
2602 .map(|c| count_depth(c))
2603 .max()
2604 .unwrap_or(0)
2605 }
2606 }
2607
2608 let depth = count_depth(&tree);
2609 assert!(
2610 depth <= 4,
2611 "Cycle should be detected and bounded, depth was: {}",
2612 depth
2613 );
2614 }
2615
2616 #[test]
2619 fn callgraph_depth_limit_truncates() {
2620 let dir = setup_ts_project();
2621 let mut graph = CallGraph::new(dir.path().to_path_buf());
2622
2623 let tree = graph
2626 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2627 .unwrap();
2628
2629 assert_eq!(tree.name, "main");
2630
2631 for child in &tree.children {
2633 assert!(
2634 child.children.is_empty(),
2635 "At depth 1, child '{}' should have no children, got {:?}",
2636 child.name,
2637 child.children.len()
2638 );
2639 }
2640 }
2641
2642 #[test]
2643 fn callgraph_depth_zero_no_children() {
2644 let dir = setup_ts_project();
2645 let mut graph = CallGraph::new(dir.path().to_path_buf());
2646
2647 let tree = graph
2648 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2649 .unwrap();
2650
2651 assert_eq!(tree.name, "main");
2652 assert!(
2653 tree.children.is_empty(),
2654 "At depth 0, should have no children"
2655 );
2656 }
2657
2658 #[test]
2661 fn callgraph_forward_tree_cross_file() {
2662 let dir = setup_ts_project();
2663 let mut graph = CallGraph::new(dir.path().to_path_buf());
2664
2665 let tree = graph
2667 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2668 .unwrap();
2669
2670 assert_eq!(tree.name, "main");
2671 assert!(tree.resolved);
2672
2673 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2675 assert!(
2676 helper_child.is_some(),
2677 "main should have helper as child, children: {:?}",
2678 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2679 );
2680
2681 let helper = helper_child.unwrap();
2682 assert!(
2683 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2684 "helper should be in utils.ts, got: {}",
2685 helper.file
2686 );
2687
2688 let double_child = helper.children.iter().find(|c| c.name == "double");
2690 assert!(
2691 double_child.is_some(),
2692 "helper should call double, children: {:?}",
2693 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2694 );
2695
2696 let double = double_child.unwrap();
2697 assert!(
2698 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2699 "double should be in helpers.ts, got: {}",
2700 double.file
2701 );
2702 }
2703
2704 #[test]
2707 fn callgraph_walker_excludes_gitignored() {
2708 let dir = TempDir::new().unwrap();
2709
2710 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2712
2713 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2715 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2716 fs::write(
2717 dir.path().join("ignored_dir").join("secret.ts"),
2718 "export function secret() {}",
2719 )
2720 .unwrap();
2721
2722 fs::create_dir(dir.path().join("node_modules")).unwrap();
2724 fs::write(
2725 dir.path().join("node_modules").join("dep.ts"),
2726 "export function dep() {}",
2727 )
2728 .unwrap();
2729
2730 std::process::Command::new("git")
2732 .args(["init"])
2733 .current_dir(dir.path())
2734 .output()
2735 .unwrap();
2736
2737 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2738 let file_names: Vec<String> = files
2739 .iter()
2740 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2741 .collect();
2742
2743 assert!(
2744 file_names.contains(&"main.ts".to_string()),
2745 "Should include main.ts, got: {:?}",
2746 file_names
2747 );
2748 assert!(
2749 !file_names.contains(&"secret.ts".to_string()),
2750 "Should exclude gitignored secret.ts, got: {:?}",
2751 file_names
2752 );
2753 assert!(
2754 !file_names.contains(&"dep.ts".to_string()),
2755 "Should exclude node_modules, got: {:?}",
2756 file_names
2757 );
2758 }
2759
2760 #[test]
2761 fn callgraph_walker_only_source_files() {
2762 let dir = TempDir::new().unwrap();
2763
2764 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2765 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2766 fs::write(dir.path().join("data.json"), "{}").unwrap();
2767
2768 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2769 let file_names: Vec<String> = files
2770 .iter()
2771 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2772 .collect();
2773
2774 assert!(file_names.contains(&"main.ts".to_string()));
2775 assert!(
2776 file_names.contains(&"readme.md".to_string()),
2777 "Markdown is now a supported source language"
2778 );
2779 assert!(
2780 !file_names.contains(&"data.json".to_string()),
2781 "Should not include non-source files"
2782 );
2783 }
2784
2785 #[test]
2788 fn callgraph_find_alias_original_simple() {
2789 let raw = "import { foo as bar } from './utils';";
2790 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2791 }
2792
2793 #[test]
2794 fn callgraph_find_alias_original_multiple() {
2795 let raw = "import { foo as bar, baz as qux } from './utils';";
2796 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2797 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2798 }
2799
2800 #[test]
2801 fn callgraph_find_alias_no_match() {
2802 let raw = "import { foo } from './utils';";
2803 assert_eq!(find_alias_original(raw, "foo"), None);
2804 }
2805
2806 #[test]
2809 fn callgraph_callers_of_direct() {
2810 let dir = setup_ts_project();
2811 let mut graph = CallGraph::new(dir.path().to_path_buf());
2812
2813 let result = graph
2815 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2816 .unwrap();
2817
2818 assert_eq!(result.symbol, "double");
2819 assert!(result.total_callers > 0, "double should have callers");
2820 assert!(result.scanned_files > 0, "should have scanned files");
2821
2822 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2824 assert!(
2825 utils_group.is_some(),
2826 "double should be called from utils.ts, groups: {:?}",
2827 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2828 );
2829
2830 let group = utils_group.unwrap();
2831 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2832 assert!(
2833 helper_caller.is_some(),
2834 "double should be called by helper, callers: {:?}",
2835 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2836 );
2837 }
2838
2839 #[test]
2840 fn callgraph_callers_of_no_callers() {
2841 let dir = setup_ts_project();
2842 let mut graph = CallGraph::new(dir.path().to_path_buf());
2843
2844 let result = graph
2846 .callers_of(&dir.path().join("main.ts"), "main", 1)
2847 .unwrap();
2848
2849 assert_eq!(result.symbol, "main");
2850 assert_eq!(result.total_callers, 0, "main should have no callers");
2851 assert!(result.callers.is_empty());
2852 }
2853
2854 #[test]
2855 fn callgraph_callers_recursive_depth() {
2856 let dir = setup_ts_project();
2857 let mut graph = CallGraph::new(dir.path().to_path_buf());
2858
2859 let result = graph
2863 .callers_of(&dir.path().join("helpers.ts"), "double", 2)
2864 .unwrap();
2865
2866 assert!(
2867 result.total_callers >= 2,
2868 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2869 result.total_callers
2870 );
2871
2872 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2874 assert!(
2875 main_group.is_some(),
2876 "recursive callers should include main.ts, groups: {:?}",
2877 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2878 );
2879 }
2880
2881 #[test]
2882 fn callgraph_invalidate_file_clears_reverse_index() {
2883 let dir = setup_ts_project();
2884 let mut graph = CallGraph::new(dir.path().to_path_buf());
2885
2886 let _ = graph
2888 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2889 .unwrap();
2890 assert!(
2891 graph.reverse_index.is_some(),
2892 "reverse index should be built"
2893 );
2894
2895 graph.invalidate_file(&dir.path().join("utils.ts"));
2897
2898 assert!(
2900 graph.reverse_index.is_none(),
2901 "invalidate_file should clear reverse index"
2902 );
2903 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2905 assert!(
2906 !graph.data.contains_key(&canon),
2907 "invalidate_file should remove file from data cache"
2908 );
2909 assert!(
2911 graph.project_files.is_none(),
2912 "invalidate_file should clear project_files"
2913 );
2914 }
2915
2916 #[test]
2919 fn is_entry_point_exported_function() {
2920 assert!(is_entry_point(
2921 "handleRequest",
2922 &SymbolKind::Function,
2923 true,
2924 LangId::TypeScript
2925 ));
2926 }
2927
2928 #[test]
2929 fn is_entry_point_exported_method_is_not_entry() {
2930 assert!(!is_entry_point(
2932 "handleRequest",
2933 &SymbolKind::Method,
2934 true,
2935 LangId::TypeScript
2936 ));
2937 }
2938
2939 #[test]
2940 fn is_entry_point_main_init_patterns() {
2941 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
2942 assert!(
2943 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
2944 "{} should be an entry point",
2945 name
2946 );
2947 }
2948 }
2949
2950 #[test]
2951 fn is_entry_point_test_patterns_ts() {
2952 assert!(is_entry_point(
2953 "describe",
2954 &SymbolKind::Function,
2955 false,
2956 LangId::TypeScript
2957 ));
2958 assert!(is_entry_point(
2959 "it",
2960 &SymbolKind::Function,
2961 false,
2962 LangId::TypeScript
2963 ));
2964 assert!(is_entry_point(
2965 "test",
2966 &SymbolKind::Function,
2967 false,
2968 LangId::TypeScript
2969 ));
2970 assert!(is_entry_point(
2971 "testValidation",
2972 &SymbolKind::Function,
2973 false,
2974 LangId::TypeScript
2975 ));
2976 assert!(is_entry_point(
2977 "specHelper",
2978 &SymbolKind::Function,
2979 false,
2980 LangId::TypeScript
2981 ));
2982 }
2983
2984 #[test]
2985 fn is_entry_point_test_patterns_python() {
2986 assert!(is_entry_point(
2987 "test_login",
2988 &SymbolKind::Function,
2989 false,
2990 LangId::Python
2991 ));
2992 assert!(is_entry_point(
2993 "setUp",
2994 &SymbolKind::Function,
2995 false,
2996 LangId::Python
2997 ));
2998 assert!(is_entry_point(
2999 "tearDown",
3000 &SymbolKind::Function,
3001 false,
3002 LangId::Python
3003 ));
3004 assert!(!is_entry_point(
3006 "testSomething",
3007 &SymbolKind::Function,
3008 false,
3009 LangId::Python
3010 ));
3011 }
3012
3013 #[test]
3014 fn is_entry_point_test_patterns_rust() {
3015 assert!(is_entry_point(
3016 "test_parse",
3017 &SymbolKind::Function,
3018 false,
3019 LangId::Rust
3020 ));
3021 assert!(!is_entry_point(
3022 "TestSomething",
3023 &SymbolKind::Function,
3024 false,
3025 LangId::Rust
3026 ));
3027 }
3028
3029 #[test]
3030 fn is_entry_point_test_patterns_go() {
3031 assert!(is_entry_point(
3032 "TestParsing",
3033 &SymbolKind::Function,
3034 false,
3035 LangId::Go
3036 ));
3037 assert!(!is_entry_point(
3039 "testParsing",
3040 &SymbolKind::Function,
3041 false,
3042 LangId::Go
3043 ));
3044 }
3045
3046 #[test]
3047 fn is_entry_point_non_exported_non_main_is_not_entry() {
3048 assert!(!is_entry_point(
3049 "helperUtil",
3050 &SymbolKind::Function,
3051 false,
3052 LangId::TypeScript
3053 ));
3054 }
3055
3056 #[test]
3059 fn callgraph_symbol_metadata_populated() {
3060 let dir = setup_ts_project();
3061 let mut graph = CallGraph::new(dir.path().to_path_buf());
3062
3063 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3064 assert!(
3065 file_data.symbol_metadata.contains_key("helper"),
3066 "symbol_metadata should contain helper"
3067 );
3068 let meta = &file_data.symbol_metadata["helper"];
3069 assert_eq!(meta.kind, SymbolKind::Function);
3070 assert!(meta.exported, "helper should be exported");
3071 }
3072
3073 fn setup_trace_project() -> TempDir {
3089 let dir = TempDir::new().unwrap();
3090
3091 fs::write(
3092 dir.path().join("main.ts"),
3093 r#"import { processData } from './utils';
3094
3095export function main() {
3096 const result = processData("hello");
3097 return result;
3098}
3099"#,
3100 )
3101 .unwrap();
3102
3103 fs::write(
3104 dir.path().join("service.ts"),
3105 r#"import { processData } from './utils';
3106
3107export function handleRequest(input: string): string {
3108 return processData(input);
3109}
3110"#,
3111 )
3112 .unwrap();
3113
3114 fs::write(
3115 dir.path().join("utils.ts"),
3116 r#"import { validate } from './helpers';
3117
3118export function processData(input: string): string {
3119 const valid = validate(input);
3120 if (!valid) {
3121 throw new Error("invalid input");
3122 }
3123 return input.toUpperCase();
3124}
3125"#,
3126 )
3127 .unwrap();
3128
3129 fs::write(
3130 dir.path().join("helpers.ts"),
3131 r#"export function validate(input: string): boolean {
3132 return checkFormat(input);
3133}
3134
3135function checkFormat(input: string): boolean {
3136 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3137}
3138"#,
3139 )
3140 .unwrap();
3141
3142 fs::write(
3143 dir.path().join("test_helpers.ts"),
3144 r#"import { validate } from './helpers';
3145
3146function testValidation() {
3147 const result = validate("hello");
3148 console.log(result);
3149}
3150"#,
3151 )
3152 .unwrap();
3153
3154 std::process::Command::new("git")
3156 .args(["init"])
3157 .current_dir(dir.path())
3158 .output()
3159 .unwrap();
3160
3161 dir
3162 }
3163
3164 #[test]
3165 fn trace_to_multi_path() {
3166 let dir = setup_trace_project();
3167 let mut graph = CallGraph::new(dir.path().to_path_buf());
3168
3169 let result = graph
3170 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3171 .unwrap();
3172
3173 assert_eq!(result.target_symbol, "checkFormat");
3174 assert!(
3175 result.total_paths >= 2,
3176 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3177 result.total_paths,
3178 result
3179 .paths
3180 .iter()
3181 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3182 .collect::<Vec<_>>()
3183 );
3184
3185 for path in &result.paths {
3187 assert!(
3188 path.hops.first().unwrap().is_entry_point,
3189 "First hop should be an entry point, got: {}",
3190 path.hops.first().unwrap().symbol
3191 );
3192 assert_eq!(
3193 path.hops.last().unwrap().symbol,
3194 "checkFormat",
3195 "Last hop should be checkFormat"
3196 );
3197 }
3198
3199 assert!(
3201 result.entry_points_found >= 2,
3202 "should find at least 2 entry points, got {}",
3203 result.entry_points_found
3204 );
3205 }
3206
3207 #[test]
3208 fn trace_to_single_path() {
3209 let dir = setup_trace_project();
3210 let mut graph = CallGraph::new(dir.path().to_path_buf());
3211
3212 let result = graph
3216 .trace_to(&dir.path().join("helpers.ts"), "validate", 10)
3217 .unwrap();
3218
3219 assert_eq!(result.target_symbol, "validate");
3220 assert!(
3221 result.total_paths >= 2,
3222 "validate should have at least 2 paths, got {}",
3223 result.total_paths
3224 );
3225 }
3226
3227 #[test]
3228 fn trace_to_cycle_detection() {
3229 let dir = setup_cycle_project();
3230 let mut graph = CallGraph::new(dir.path().to_path_buf());
3231
3232 let result = graph
3234 .trace_to(&dir.path().join("a.ts"), "funcA", 10)
3235 .unwrap();
3236
3237 assert_eq!(result.target_symbol, "funcA");
3239 }
3240
3241 #[test]
3242 fn trace_to_depth_limit() {
3243 let dir = setup_trace_project();
3244 let mut graph = CallGraph::new(dir.path().to_path_buf());
3245
3246 let result = graph
3248 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1)
3249 .unwrap();
3250
3251 assert_eq!(result.target_symbol, "checkFormat");
3255
3256 let deep_result = graph
3258 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3259 .unwrap();
3260
3261 assert!(
3262 result.total_paths <= deep_result.total_paths,
3263 "shallow trace should find <= paths compared to deep: {} vs {}",
3264 result.total_paths,
3265 deep_result.total_paths
3266 );
3267 }
3268
3269 #[test]
3270 fn trace_to_entry_point_target() {
3271 let dir = setup_trace_project();
3272 let mut graph = CallGraph::new(dir.path().to_path_buf());
3273
3274 let result = graph
3276 .trace_to(&dir.path().join("main.ts"), "main", 10)
3277 .unwrap();
3278
3279 assert_eq!(result.target_symbol, "main");
3280 assert!(
3281 result.total_paths >= 1,
3282 "main should have at least 1 path (itself), got {}",
3283 result.total_paths
3284 );
3285 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3287 assert!(
3288 trivial.is_some(),
3289 "should have a trivial path with just the entry point itself"
3290 );
3291 }
3292
3293 #[test]
3296 fn extract_parameters_typescript() {
3297 let params = extract_parameters(
3298 "function processData(input: string, count: number): void",
3299 LangId::TypeScript,
3300 );
3301 assert_eq!(params, vec!["input", "count"]);
3302 }
3303
3304 #[test]
3305 fn extract_parameters_typescript_optional() {
3306 let params = extract_parameters(
3307 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3308 LangId::TypeScript,
3309 );
3310 assert_eq!(params, vec!["url", "options"]);
3311 }
3312
3313 #[test]
3314 fn extract_parameters_typescript_defaults() {
3315 let params = extract_parameters(
3316 "function greet(name: string, greeting: string = \"hello\"): string",
3317 LangId::TypeScript,
3318 );
3319 assert_eq!(params, vec!["name", "greeting"]);
3320 }
3321
3322 #[test]
3323 fn extract_parameters_typescript_rest() {
3324 let params = extract_parameters(
3325 "function sum(...numbers: number[]): number",
3326 LangId::TypeScript,
3327 );
3328 assert_eq!(params, vec!["numbers"]);
3329 }
3330
3331 #[test]
3332 fn extract_parameters_python_self_skipped() {
3333 let params = extract_parameters(
3334 "def process(self, data: str, count: int) -> bool",
3335 LangId::Python,
3336 );
3337 assert_eq!(params, vec!["data", "count"]);
3338 }
3339
3340 #[test]
3341 fn extract_parameters_python_no_self() {
3342 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3343 assert_eq!(params, vec!["input"]);
3344 }
3345
3346 #[test]
3347 fn extract_parameters_python_star_args() {
3348 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3349 assert_eq!(params, vec!["args", "kwargs"]);
3350 }
3351
3352 #[test]
3353 fn extract_parameters_rust_self_skipped() {
3354 let params = extract_parameters(
3355 "fn process(&self, data: &str, count: usize) -> bool",
3356 LangId::Rust,
3357 );
3358 assert_eq!(params, vec!["data", "count"]);
3359 }
3360
3361 #[test]
3362 fn extract_parameters_rust_mut_self_skipped() {
3363 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3364 assert_eq!(params, vec!["value"]);
3365 }
3366
3367 #[test]
3368 fn extract_parameters_rust_no_self() {
3369 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3370 assert_eq!(params, vec!["input"]);
3371 }
3372
3373 #[test]
3374 fn extract_parameters_rust_mut_param() {
3375 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3376 assert_eq!(params, vec!["buf", "len"]);
3377 }
3378
3379 #[test]
3380 fn extract_parameters_go() {
3381 let params = extract_parameters(
3382 "func ProcessData(input string, count int) error",
3383 LangId::Go,
3384 );
3385 assert_eq!(params, vec!["input", "count"]);
3386 }
3387
3388 #[test]
3389 fn extract_parameters_empty() {
3390 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3391 assert!(
3392 params.is_empty(),
3393 "no-arg function should return empty params"
3394 );
3395 }
3396
3397 #[test]
3398 fn extract_parameters_no_parens() {
3399 let params = extract_parameters("const x = 42", LangId::TypeScript);
3400 assert!(params.is_empty(), "no parens should return empty params");
3401 }
3402
3403 #[test]
3404 fn extract_parameters_javascript() {
3405 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3406 assert_eq!(params, vec!["event", "target"]);
3407 }
3408}