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::edit::line_col_to_byte;
15use crate::error::AftError;
16use crate::imports::{self, ImportBlock};
17use crate::language::LanguageProvider;
18use crate::parser::{detect_language, grammar_for, LangId};
19use crate::symbols::SymbolKind;
20
21#[derive(Debug, Clone)]
27pub struct CallSite {
28 pub callee_name: String,
30 pub full_callee: String,
32 pub line: u32,
34 pub byte_start: usize,
36 pub byte_end: usize,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct SymbolMeta {
42 pub kind: SymbolKind,
44 pub exported: bool,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub signature: Option<String>,
49}
50
51#[derive(Debug, Clone)]
54pub struct FileCallData {
55 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
57 pub exported_symbols: Vec<String>,
59 pub symbol_metadata: HashMap<String, SymbolMeta>,
61 pub import_block: ImportBlock,
63 pub lang: LangId,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum EdgeResolution {
70 Resolved { file: PathBuf, symbol: String },
72 Unresolved { callee_name: String },
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct CallerSite {
79 pub caller_file: PathBuf,
81 pub caller_symbol: String,
83 pub line: u32,
85 pub col: u32,
87 pub resolved: bool,
89}
90
91#[derive(Debug, Clone, Serialize)]
93pub struct CallerGroup {
94 pub file: String,
96 pub callers: Vec<CallerEntry>,
98}
99
100#[derive(Debug, Clone, Serialize)]
102pub struct CallerEntry {
103 pub symbol: String,
104 pub line: u32,
106}
107
108#[derive(Debug, Clone, Serialize)]
110pub struct CallersResult {
111 pub symbol: String,
113 pub file: String,
115 pub callers: Vec<CallerGroup>,
117 pub total_callers: usize,
119 pub scanned_files: usize,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct CallTreeNode {
126 pub name: String,
128 pub file: String,
130 pub line: u32,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub signature: Option<String>,
135 pub resolved: bool,
137 pub children: Vec<CallTreeNode>,
139}
140
141const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
147
148pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
155 if exported && *kind == SymbolKind::Function {
157 return true;
158 }
159
160 let lower = name.to_lowercase();
162 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
163 return true;
164 }
165
166 match lang {
168 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
169 matches!(lower.as_str(), "describe" | "it" | "test")
171 || lower.starts_with("test")
172 || lower.starts_with("spec")
173 }
174 LangId::Python => {
175 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
177 }
178 LangId::Rust => {
179 lower.starts_with("test_")
181 }
182 LangId::Go => {
183 name.starts_with("Test")
185 }
186 LangId::Markdown => false,
187 }
188}
189
190#[derive(Debug, Clone, Serialize)]
196pub struct TraceHop {
197 pub symbol: String,
199 pub file: String,
201 pub line: u32,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub signature: Option<String>,
206 pub is_entry_point: bool,
208}
209
210#[derive(Debug, Clone, Serialize)]
212pub struct TracePath {
213 pub hops: Vec<TraceHop>,
215}
216
217#[derive(Debug, Clone, Serialize)]
219pub struct TraceToResult {
220 pub target_symbol: String,
222 pub target_file: String,
224 pub paths: Vec<TracePath>,
226 pub total_paths: usize,
228 pub entry_points_found: usize,
230 pub max_depth_reached: bool,
232 pub truncated_paths: usize,
234}
235
236#[derive(Debug, Clone, Serialize)]
242pub struct ImpactCaller {
243 pub caller_symbol: String,
245 pub caller_file: String,
247 pub line: u32,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub signature: Option<String>,
252 pub is_entry_point: bool,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub call_expression: Option<String>,
257 pub parameters: Vec<String>,
259}
260
261#[derive(Debug, Clone, Serialize)]
263pub struct ImpactResult {
264 pub symbol: String,
266 pub file: String,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub signature: Option<String>,
271 pub parameters: Vec<String>,
273 pub total_affected: usize,
275 pub affected_files: usize,
277 pub callers: Vec<ImpactCaller>,
279}
280
281#[derive(Debug, Clone, Serialize)]
287pub struct DataFlowHop {
288 pub file: String,
290 pub symbol: String,
292 pub variable: String,
294 pub line: u32,
296 pub flow_type: String,
298 pub approximate: bool,
300}
301
302#[derive(Debug, Clone, Serialize)]
305pub struct TraceDataResult {
306 pub expression: String,
308 pub origin_file: String,
310 pub origin_symbol: String,
312 pub hops: Vec<DataFlowHop>,
314 pub depth_limited: bool,
316}
317
318pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
324 let start = match signature.find('(') {
326 Some(i) => i + 1,
327 None => return Vec::new(),
328 };
329 let end = match signature[start..].find(')') {
330 Some(i) => start + i,
331 None => return Vec::new(),
332 };
333
334 let params_str = &signature[start..end].trim();
335 if params_str.is_empty() {
336 return Vec::new();
337 }
338
339 let parts = split_params(params_str);
341
342 let mut result = Vec::new();
343 for part in parts {
344 let trimmed = part.trim();
345 if trimmed.is_empty() {
346 continue;
347 }
348
349 match lang {
351 LangId::Rust => {
352 let normalized = trimmed.replace(' ', "");
353 if normalized == "self"
354 || normalized == "&self"
355 || normalized == "&mutself"
356 || normalized == "mutself"
357 {
358 continue;
359 }
360 }
361 LangId::Python => {
362 if trimmed == "self" || trimmed.starts_with("self:") {
363 continue;
364 }
365 }
366 _ => {}
367 }
368
369 let name = extract_param_name(trimmed, lang);
371 if !name.is_empty() {
372 result.push(name);
373 }
374 }
375
376 result
377}
378
379fn split_params(s: &str) -> Vec<String> {
381 let mut parts = Vec::new();
382 let mut current = String::new();
383 let mut depth = 0i32;
384
385 for ch in s.chars() {
386 match ch {
387 '<' | '[' | '{' | '(' => {
388 depth += 1;
389 current.push(ch);
390 }
391 '>' | ']' | '}' | ')' => {
392 depth -= 1;
393 current.push(ch);
394 }
395 ',' if depth == 0 => {
396 parts.push(current.clone());
397 current.clear();
398 }
399 _ => {
400 current.push(ch);
401 }
402 }
403 }
404 if !current.is_empty() {
405 parts.push(current);
406 }
407 parts
408}
409
410fn extract_param_name(param: &str, lang: LangId) -> String {
418 let trimmed = param.trim();
419
420 let working = if trimmed.starts_with("...") {
422 &trimmed[3..]
423 } else if trimmed.starts_with("**") {
424 &trimmed[2..]
425 } else if trimmed.starts_with('*') && lang == LangId::Python {
426 &trimmed[1..]
427 } else {
428 trimmed
429 };
430
431 let working = if lang == LangId::Rust && working.starts_with("mut ") {
433 &working[4..]
434 } else {
435 working
436 };
437
438 let name = working
441 .split(|c: char| c == ':' || c == '=')
442 .next()
443 .unwrap_or("")
444 .trim();
445
446 let name = name.trim_end_matches('?');
448
449 if lang == LangId::Go && !name.contains(' ') {
451 return name.to_string();
452 }
453 if lang == LangId::Go {
454 return name.split_whitespace().next().unwrap_or("").to_string();
455 }
456
457 name.to_string()
458}
459
460pub struct CallGraph {
469 data: HashMap<PathBuf, FileCallData>,
471 project_root: PathBuf,
473 project_files: Option<Vec<PathBuf>>,
475 reverse_index: Option<HashMap<(PathBuf, String), Vec<CallerSite>>>,
478}
479
480impl CallGraph {
481 pub fn new(project_root: PathBuf) -> Self {
483 Self {
484 data: HashMap::new(),
485 project_root,
486 project_files: None,
487 reverse_index: None,
488 }
489 }
490
491 pub fn project_root(&self) -> &Path {
493 &self.project_root
494 }
495
496 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
498 let canon = self.canonicalize(path)?;
499
500 if !self.data.contains_key(&canon) {
501 let file_data = build_file_data(&canon)?;
502 self.data.insert(canon.clone(), file_data);
503 }
504
505 Ok(&self.data[&canon])
506 }
507
508 pub fn resolve_cross_file_edge(
513 &mut self,
514 full_callee: &str,
515 short_name: &str,
516 caller_file: &Path,
517 import_block: &ImportBlock,
518 ) -> EdgeResolution {
519 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_string(),
542 };
543 }
544 }
545 }
546 }
547 }
548
549 for imp in &import_block.imports {
551 if imp.names.contains(&short_name.to_string()) {
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_string(),
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_string(),
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 self.file_exports_symbol(&index_path, short_name) {
595 return EdgeResolution::Resolved {
596 file: index_path,
597 symbol: short_name.to_string(),
598 };
599 }
600 }
601 } else if self.file_exports_symbol(&resolved_path, short_name) {
602 return EdgeResolution::Resolved {
603 file: resolved_path,
604 symbol: short_name.to_string(),
605 };
606 }
607 }
608 }
609
610 EdgeResolution::Unresolved {
611 callee_name: short_name.to_string(),
612 }
613 }
614
615 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
617 match self.build_file(path) {
618 Ok(data) => data.exported_symbols.contains(&symbol_name.to_string()),
619 Err(_) => false,
620 }
621 }
622
623 pub fn forward_tree(
628 &mut self,
629 file: &Path,
630 symbol: &str,
631 max_depth: usize,
632 ) -> Result<CallTreeNode, AftError> {
633 let mut visited = HashSet::new();
634 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
635 }
636
637 fn forward_tree_inner(
638 &mut self,
639 file: &Path,
640 symbol: &str,
641 max_depth: usize,
642 current_depth: usize,
643 visited: &mut HashSet<(PathBuf, String)>,
644 ) -> Result<CallTreeNode, AftError> {
645 let canon = self.canonicalize(file)?;
646 let visit_key = (canon.clone(), symbol.to_string());
647
648 if visited.contains(&visit_key) {
650 let (line, signature) = get_symbol_meta(&canon, symbol);
651 return Ok(CallTreeNode {
652 name: symbol.to_string(),
653 file: self.relative_path(&canon),
654 line,
655 signature,
656 resolved: true,
657 children: vec![], });
659 }
660
661 visited.insert(visit_key.clone());
662
663 let file_data = build_file_data(&canon)?;
665 let import_block = file_data.import_block.clone();
666 let _lang = file_data.lang;
667
668 let call_sites = file_data
670 .calls_by_symbol
671 .get(symbol)
672 .cloned()
673 .unwrap_or_default();
674
675 let (sym_line, sym_signature) = get_symbol_meta(&canon, symbol);
677
678 self.data.insert(canon.clone(), file_data);
680
681 let mut children = Vec::new();
683
684 if current_depth < max_depth {
685 for call_site in &call_sites {
686 let edge = self.resolve_cross_file_edge(
687 &call_site.full_callee,
688 &call_site.callee_name,
689 &canon,
690 &import_block,
691 );
692
693 match edge {
694 EdgeResolution::Resolved {
695 file: ref target_file,
696 ref symbol,
697 } => {
698 match self.forward_tree_inner(
699 target_file,
700 symbol,
701 max_depth,
702 current_depth + 1,
703 visited,
704 ) {
705 Ok(child) => children.push(child),
706 Err(_) => {
707 children.push(CallTreeNode {
709 name: call_site.callee_name.clone(),
710 file: self.relative_path(target_file),
711 line: call_site.line,
712 signature: None,
713 resolved: false,
714 children: vec![],
715 });
716 }
717 }
718 }
719 EdgeResolution::Unresolved { callee_name } => {
720 children.push(CallTreeNode {
721 name: callee_name,
722 file: self.relative_path(&canon),
723 line: call_site.line,
724 signature: None,
725 resolved: false,
726 children: vec![],
727 });
728 }
729 }
730 }
731 }
732
733 visited.remove(&visit_key);
734
735 Ok(CallTreeNode {
736 name: symbol.to_string(),
737 file: self.relative_path(&canon),
738 line: sym_line,
739 signature: sym_signature,
740 resolved: true,
741 children,
742 })
743 }
744
745 pub fn project_files(&mut self) -> &[PathBuf] {
747 if self.project_files.is_none() {
748 let project_root = self.project_root.clone();
749 self.project_files = Some(walk_project_files(&project_root).collect());
750 }
751 self.project_files.as_deref().unwrap_or(&[])
752 }
753
754 fn build_reverse_index(&mut self) {
760 let all_files = self.project_files().to_vec();
762
763 for f in &all_files {
765 if !self.data.contains_key(f) {
766 if let Ok(fd) = build_file_data(f) {
767 self.data.insert(f.clone(), fd);
768 }
769 }
770 }
771
772 let mut reverse: HashMap<(PathBuf, String), Vec<CallerSite>> = HashMap::new();
774
775 for caller_file in &all_files {
776 let canon_caller =
778 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone());
779 let file_data = match self
780 .data
781 .get(caller_file)
782 .or_else(|| self.data.get(&canon_caller))
783 {
784 Some(d) => d.clone(),
785 None => continue,
786 };
787
788 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
789 for call_site in call_sites {
790 let edge = self.resolve_cross_file_edge(
791 &call_site.full_callee,
792 &call_site.callee_name,
793 caller_file,
794 &file_data.import_block,
795 );
796
797 match edge {
798 EdgeResolution::Resolved {
799 file: ref target_file,
800 ref symbol,
801 } => {
802 let key = (target_file.clone(), symbol.clone());
803 reverse.entry(key).or_default().push(CallerSite {
804 caller_file: canon_caller.clone(),
805 caller_symbol: symbol_name.clone(),
806 line: call_site.line,
807 col: 0,
808 resolved: true,
809 });
810 }
811 EdgeResolution::Unresolved { ref callee_name } => {
812 let key = (canon_caller.clone(), callee_name.clone());
815 reverse.entry(key).or_default().push(CallerSite {
816 caller_file: canon_caller.clone(),
817 caller_symbol: symbol_name.clone(),
818 line: call_site.line,
819 col: 0,
820 resolved: false,
821 });
822 }
823 }
824 }
825 }
826 }
827
828 self.reverse_index = Some(reverse);
829 }
830
831 pub fn callers_of(
837 &mut self,
838 file: &Path,
839 symbol: &str,
840 depth: usize,
841 ) -> Result<CallersResult, AftError> {
842 let canon = self.canonicalize(file)?;
843
844 self.build_file(&canon)?;
846
847 if self.reverse_index.is_none() {
849 self.build_reverse_index();
850 }
851
852 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
853 let effective_depth = if depth == 0 { 1 } else { depth };
854
855 let mut visited = HashSet::new();
856 let mut all_sites: Vec<CallerSite> = Vec::new();
857 self.collect_callers_recursive(
858 &canon,
859 symbol,
860 effective_depth,
861 0,
862 &mut visited,
863 &mut all_sites,
864 );
865
866 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
868 for site in &all_sites {
869 groups_map
870 .entry(site.caller_file.clone())
871 .or_default()
872 .push(CallerEntry {
873 symbol: site.caller_symbol.clone(),
874 line: site.line,
875 });
876 }
877
878 let total_callers = all_sites.len();
879 let mut callers: Vec<CallerGroup> = groups_map
880 .into_iter()
881 .map(|(file_path, entries)| CallerGroup {
882 file: self.relative_path(&file_path),
883 callers: entries,
884 })
885 .collect();
886
887 callers.sort_by(|a, b| a.file.cmp(&b.file));
889
890 Ok(CallersResult {
891 symbol: symbol.to_string(),
892 file: self.relative_path(&canon),
893 callers,
894 total_callers,
895 scanned_files,
896 })
897 }
898
899 pub fn trace_to(
905 &mut self,
906 file: &Path,
907 symbol: &str,
908 max_depth: usize,
909 ) -> Result<TraceToResult, AftError> {
910 let canon = self.canonicalize(file)?;
911
912 self.build_file(&canon)?;
914
915 if self.reverse_index.is_none() {
917 self.build_reverse_index();
918 }
919
920 let target_rel = self.relative_path(&canon);
921 let effective_max = if max_depth == 0 { 10 } else { max_depth };
922 let reverse_index = self
923 .reverse_index
924 .as_ref()
925 .ok_or_else(|| AftError::ParseError {
926 message: format!(
927 "reverse index unavailable after building callers for {}",
928 canon.display()
929 ),
930 })?;
931
932 let (target_line, target_sig) = get_symbol_meta(&canon, symbol);
934
935 let target_is_entry = self
937 .lookup_file_data(&canon)
938 .and_then(|fd| {
939 let meta = fd.symbol_metadata.get(symbol)?;
940 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
941 })
942 .unwrap_or(false);
943
944 type PathElem = (PathBuf, String, u32, Option<String>);
947 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
948 let mut max_depth_reached = false;
949 let mut truncated_paths: usize = 0;
950
951 let initial: Vec<PathElem> =
953 vec![(canon.clone(), symbol.to_string(), target_line, target_sig)];
954
955 if target_is_entry {
957 complete_paths.push(initial.clone());
958 }
959
960 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
962
963 while let Some((path, depth)) = queue.pop() {
964 if depth >= effective_max {
965 max_depth_reached = true;
966 continue;
967 }
968
969 let Some((current_file, current_symbol, _, _)) = path.last().cloned() else {
970 continue;
971 };
972
973 let path_visited: HashSet<(PathBuf, String)> = path
975 .iter()
976 .map(|(f, s, _, _)| (f.clone(), s.clone()))
977 .collect();
978
979 let lookup_key = (current_file.clone(), current_symbol.clone());
981 let callers = match reverse_index.get(&lookup_key) {
982 Some(sites) => sites.clone(),
983 None => {
984 if path.len() > 1 {
987 truncated_paths += 1;
990 }
991 continue;
992 }
993 };
994
995 let mut has_new_path = false;
996 for site in &callers {
997 let caller_key = (site.caller_file.clone(), site.caller_symbol.clone());
998
999 if path_visited.contains(&caller_key) {
1001 continue;
1002 }
1003
1004 has_new_path = true;
1005
1006 let (caller_line, caller_sig) =
1008 get_symbol_meta(&site.caller_file, &site.caller_symbol);
1009
1010 let mut new_path = path.clone();
1011 new_path.push((
1012 site.caller_file.clone(),
1013 site.caller_symbol.clone(),
1014 caller_line,
1015 caller_sig,
1016 ));
1017
1018 let caller_is_entry = self
1022 .lookup_file_data(&site.caller_file)
1023 .and_then(|fd| {
1024 let meta = fd.symbol_metadata.get(&site.caller_symbol)?;
1025 Some(is_entry_point(
1026 &site.caller_symbol,
1027 &meta.kind,
1028 meta.exported,
1029 fd.lang,
1030 ))
1031 })
1032 .unwrap_or(false);
1033
1034 if caller_is_entry {
1035 complete_paths.push(new_path.clone());
1036 }
1037 queue.push((new_path, depth + 1));
1040 }
1041
1042 if !has_new_path && path.len() > 1 {
1044 truncated_paths += 1;
1045 }
1046 }
1047
1048 let mut paths: Vec<TracePath> = complete_paths
1051 .into_iter()
1052 .map(|mut elems| {
1053 elems.reverse();
1054 let hops: Vec<TraceHop> = elems
1055 .iter()
1056 .enumerate()
1057 .map(|(i, (file_path, sym, line, sig))| {
1058 let is_ep = if i == 0 {
1059 self.lookup_file_data(file_path)
1061 .and_then(|fd| {
1062 let meta = fd.symbol_metadata.get(sym)?;
1063 Some(is_entry_point(sym, &meta.kind, meta.exported, fd.lang))
1064 })
1065 .unwrap_or(false)
1066 } else {
1067 false
1068 };
1069 TraceHop {
1070 symbol: sym.clone(),
1071 file: self.relative_path(file_path),
1072 line: *line,
1073 signature: sig.clone(),
1074 is_entry_point: is_ep,
1075 }
1076 })
1077 .collect();
1078 TracePath { hops }
1079 })
1080 .collect();
1081
1082 paths.sort_by(|a, b| {
1084 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1085 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1086 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1087 });
1088
1089 let mut entry_point_names: HashSet<String> = HashSet::new();
1091 for p in &paths {
1092 if let Some(first) = p.hops.first() {
1093 if first.is_entry_point {
1094 entry_point_names.insert(first.symbol.clone());
1095 }
1096 }
1097 }
1098
1099 let total_paths = paths.len();
1100 let entry_points_found = entry_point_names.len();
1101
1102 Ok(TraceToResult {
1103 target_symbol: symbol.to_string(),
1104 target_file: target_rel,
1105 paths,
1106 total_paths,
1107 entry_points_found,
1108 max_depth_reached,
1109 truncated_paths,
1110 })
1111 }
1112
1113 pub fn impact(
1119 &mut self,
1120 file: &Path,
1121 symbol: &str,
1122 depth: usize,
1123 ) -> Result<ImpactResult, AftError> {
1124 let canon = self.canonicalize(file)?;
1125
1126 self.build_file(&canon)?;
1128
1129 if self.reverse_index.is_none() {
1131 self.build_reverse_index();
1132 }
1133
1134 let effective_depth = if depth == 0 { 1 } else { depth };
1135
1136 let (target_signature, target_parameters, target_lang) = {
1138 let file_data = match self.data.get(&canon) {
1139 Some(d) => d,
1140 None => {
1141 return Err(AftError::InvalidRequest {
1142 message: "file data missing after build".to_string(),
1143 })
1144 }
1145 };
1146 let meta = file_data.symbol_metadata.get(symbol);
1147 let sig = meta.and_then(|m| m.signature.clone());
1148 let lang = file_data.lang;
1149 let params = sig
1150 .as_deref()
1151 .map(|s| extract_parameters(s, lang))
1152 .unwrap_or_default();
1153 (sig, params, lang)
1154 };
1155
1156 let mut visited = HashSet::new();
1158 let mut all_sites: Vec<CallerSite> = Vec::new();
1159 self.collect_callers_recursive(
1160 &canon,
1161 symbol,
1162 effective_depth,
1163 0,
1164 &mut visited,
1165 &mut all_sites,
1166 );
1167
1168 let mut seen = HashSet::new();
1170 all_sites.retain(|s| seen.insert((s.caller_file.clone(), s.caller_symbol.clone(), s.line)));
1171
1172 let mut callers = Vec::new();
1174 let mut affected_file_set = HashSet::new();
1175
1176 for site in &all_sites {
1177 let caller_canon = std::fs::canonicalize(&site.caller_file)
1179 .unwrap_or_else(|_| site.caller_file.clone());
1180 if let Err(e) = self.build_file(&caller_canon) {
1181 log::debug!(
1182 "callgraph: skipping caller file {}: {}",
1183 caller_canon.display(),
1184 e
1185 );
1186 }
1187
1188 let (sig, is_ep, params, _lang) = {
1189 if let Some(fd) = self.data.get(&caller_canon) {
1190 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1191 let sig = meta.and_then(|m| m.signature.clone());
1192 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1193 let exported = meta.map(|m| m.exported).unwrap_or(false);
1194 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1195 let lang = fd.lang;
1196 let params = sig
1197 .as_deref()
1198 .map(|s| extract_parameters(s, lang))
1199 .unwrap_or_default();
1200 (sig, is_ep, params, lang)
1201 } else {
1202 (None, false, Vec::new(), target_lang)
1203 }
1204 };
1205
1206 let call_expression = self.read_source_line(&caller_canon, site.line);
1208
1209 let rel_file = self.relative_path(&site.caller_file);
1210 affected_file_set.insert(rel_file.clone());
1211
1212 callers.push(ImpactCaller {
1213 caller_symbol: site.caller_symbol.clone(),
1214 caller_file: rel_file,
1215 line: site.line,
1216 signature: sig,
1217 is_entry_point: is_ep,
1218 call_expression,
1219 parameters: params,
1220 });
1221 }
1222
1223 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1225
1226 let total_affected = callers.len();
1227 let affected_files = affected_file_set.len();
1228
1229 Ok(ImpactResult {
1230 symbol: symbol.to_string(),
1231 file: self.relative_path(&canon),
1232 signature: target_signature,
1233 parameters: target_parameters,
1234 total_affected,
1235 affected_files,
1236 callers,
1237 })
1238 }
1239
1240 pub fn trace_data(
1251 &mut self,
1252 file: &Path,
1253 symbol: &str,
1254 expression: &str,
1255 max_depth: usize,
1256 ) -> Result<TraceDataResult, AftError> {
1257 let canon = self.canonicalize(file)?;
1258 let rel_file = self.relative_path(&canon);
1259
1260 self.build_file(&canon)?;
1262
1263 {
1265 let fd = match self.data.get(&canon) {
1266 Some(d) => d,
1267 None => {
1268 return Err(AftError::InvalidRequest {
1269 message: "file data missing after build".to_string(),
1270 })
1271 }
1272 };
1273 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1274 || fd.exported_symbols.contains(&symbol.to_string())
1275 || fd.symbol_metadata.contains_key(symbol);
1276 if !has_symbol {
1277 return Err(AftError::InvalidRequest {
1278 message: format!(
1279 "trace_data: symbol '{}' not found in {}",
1280 symbol,
1281 file.display()
1282 ),
1283 });
1284 }
1285 }
1286
1287 let mut hops = Vec::new();
1288 let mut depth_limited = false;
1289
1290 self.trace_data_inner(
1291 &canon,
1292 symbol,
1293 expression,
1294 max_depth,
1295 0,
1296 &mut hops,
1297 &mut depth_limited,
1298 &mut HashSet::new(),
1299 );
1300
1301 Ok(TraceDataResult {
1302 expression: expression.to_string(),
1303 origin_file: rel_file,
1304 origin_symbol: symbol.to_string(),
1305 hops,
1306 depth_limited,
1307 })
1308 }
1309
1310 fn trace_data_inner(
1312 &mut self,
1313 file: &Path,
1314 symbol: &str,
1315 tracking_name: &str,
1316 max_depth: usize,
1317 current_depth: usize,
1318 hops: &mut Vec<DataFlowHop>,
1319 depth_limited: &mut bool,
1320 visited: &mut HashSet<(PathBuf, String, String)>,
1321 ) {
1322 let visit_key = (
1323 file.to_path_buf(),
1324 symbol.to_string(),
1325 tracking_name.to_string(),
1326 );
1327 if visited.contains(&visit_key) {
1328 return; }
1330 visited.insert(visit_key);
1331
1332 let source = match std::fs::read_to_string(file) {
1334 Ok(s) => s,
1335 Err(_) => return,
1336 };
1337
1338 let lang = match detect_language(file) {
1339 Some(l) => l,
1340 None => return,
1341 };
1342
1343 let grammar = grammar_for(lang);
1344 let mut parser = Parser::new();
1345 if parser.set_language(&grammar).is_err() {
1346 return;
1347 }
1348 let tree = match parser.parse(&source, None) {
1349 Some(t) => t,
1350 None => return,
1351 };
1352
1353 let symbols = list_symbols_from_tree(&source, &tree, lang, file);
1355 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1356 Some(s) => s,
1357 None => return,
1358 };
1359
1360 let body_start = line_col_to_byte(&source, sym_info.start_line, sym_info.start_col);
1361 let body_end = line_col_to_byte(&source, sym_info.end_line, sym_info.end_col);
1362
1363 let root = tree.root_node();
1364
1365 let body_node = match find_node_covering_range(root, body_start, body_end) {
1367 Some(n) => n,
1368 None => return,
1369 };
1370
1371 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1373 let rel_file = self.relative_path(file);
1374
1375 self.walk_for_data_flow(
1377 body_node,
1378 &source,
1379 &mut tracked_names,
1380 file,
1381 symbol,
1382 &rel_file,
1383 lang,
1384 max_depth,
1385 current_depth,
1386 hops,
1387 depth_limited,
1388 visited,
1389 );
1390 }
1391
1392 #[allow(clippy::too_many_arguments)]
1395 fn walk_for_data_flow(
1396 &mut self,
1397 node: tree_sitter::Node,
1398 source: &str,
1399 tracked_names: &mut Vec<String>,
1400 file: &Path,
1401 symbol: &str,
1402 rel_file: &str,
1403 lang: LangId,
1404 max_depth: usize,
1405 current_depth: usize,
1406 hops: &mut Vec<DataFlowHop>,
1407 depth_limited: &mut bool,
1408 visited: &mut HashSet<(PathBuf, String, String)>,
1409 ) {
1410 let kind = node.kind();
1411
1412 let is_var_decl = matches!(
1414 kind,
1415 "variable_declarator"
1416 | "assignment_expression"
1417 | "augmented_assignment_expression"
1418 | "assignment"
1419 | "let_declaration"
1420 | "short_var_declaration"
1421 );
1422
1423 if is_var_decl {
1424 if let Some((new_name, init_text, line, is_approx)) =
1425 self.extract_assignment_info(node, source, lang, tracked_names)
1426 {
1427 if !is_approx {
1429 hops.push(DataFlowHop {
1430 file: rel_file.to_string(),
1431 symbol: symbol.to_string(),
1432 variable: new_name.clone(),
1433 line,
1434 flow_type: "assignment".to_string(),
1435 approximate: false,
1436 });
1437 tracked_names.push(new_name);
1438 } else {
1439 hops.push(DataFlowHop {
1441 file: rel_file.to_string(),
1442 symbol: symbol.to_string(),
1443 variable: init_text,
1444 line,
1445 flow_type: "assignment".to_string(),
1446 approximate: true,
1447 });
1448 return;
1450 }
1451 }
1452 }
1453
1454 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1456 self.check_call_for_data_flow(
1457 node,
1458 source,
1459 tracked_names,
1460 file,
1461 symbol,
1462 rel_file,
1463 lang,
1464 max_depth,
1465 current_depth,
1466 hops,
1467 depth_limited,
1468 visited,
1469 );
1470 }
1471
1472 let mut cursor = node.walk();
1474 if cursor.goto_first_child() {
1475 loop {
1476 let child = cursor.node();
1477 self.walk_for_data_flow(
1479 child,
1480 source,
1481 tracked_names,
1482 file,
1483 symbol,
1484 rel_file,
1485 lang,
1486 max_depth,
1487 current_depth,
1488 hops,
1489 depth_limited,
1490 visited,
1491 );
1492 if !cursor.goto_next_sibling() {
1493 break;
1494 }
1495 }
1496 }
1497 }
1498
1499 fn extract_assignment_info(
1502 &self,
1503 node: tree_sitter::Node,
1504 source: &str,
1505 _lang: LangId,
1506 tracked_names: &[String],
1507 ) -> Option<(String, String, u32, bool)> {
1508 let kind = node.kind();
1509 let line = node.start_position().row as u32 + 1;
1510
1511 match kind {
1512 "variable_declarator" => {
1513 let name_node = node.child_by_field_name("name")?;
1515 let value_node = node.child_by_field_name("value")?;
1516 let name_text = node_text(name_node, source);
1517 let value_text = node_text(value_node, source);
1518
1519 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1521 if tracked_names.iter().any(|t| value_text.contains(t)) {
1523 return Some((name_text.clone(), name_text, line, true));
1524 }
1525 return None;
1526 }
1527
1528 if tracked_names.iter().any(|t| {
1530 value_text == *t
1531 || value_text.starts_with(&format!("{}.", t))
1532 || value_text.starts_with(&format!("{}[", t))
1533 }) {
1534 return Some((name_text, value_text, line, false));
1535 }
1536 None
1537 }
1538 "assignment_expression" | "augmented_assignment_expression" => {
1539 let left = node.child_by_field_name("left")?;
1541 let right = node.child_by_field_name("right")?;
1542 let left_text = node_text(left, source);
1543 let right_text = node_text(right, source);
1544
1545 if tracked_names.iter().any(|t| right_text == *t) {
1546 return Some((left_text, right_text, line, false));
1547 }
1548 None
1549 }
1550 "assignment" => {
1551 let left = node.child_by_field_name("left")?;
1553 let right = node.child_by_field_name("right")?;
1554 let left_text = node_text(left, source);
1555 let right_text = node_text(right, source);
1556
1557 if tracked_names.iter().any(|t| right_text == *t) {
1558 return Some((left_text, right_text, line, false));
1559 }
1560 None
1561 }
1562 "let_declaration" | "short_var_declaration" => {
1563 let left = node
1565 .child_by_field_name("pattern")
1566 .or_else(|| node.child_by_field_name("left"))?;
1567 let right = node
1568 .child_by_field_name("value")
1569 .or_else(|| node.child_by_field_name("right"))?;
1570 let left_text = node_text(left, source);
1571 let right_text = node_text(right, source);
1572
1573 if tracked_names.iter().any(|t| right_text == *t) {
1574 return Some((left_text, right_text, line, false));
1575 }
1576 None
1577 }
1578 _ => None,
1579 }
1580 }
1581
1582 #[allow(clippy::too_many_arguments)]
1585 fn check_call_for_data_flow(
1586 &mut self,
1587 node: tree_sitter::Node,
1588 source: &str,
1589 tracked_names: &[String],
1590 file: &Path,
1591 _symbol: &str,
1592 rel_file: &str,
1593 _lang: LangId,
1594 max_depth: usize,
1595 current_depth: usize,
1596 hops: &mut Vec<DataFlowHop>,
1597 depth_limited: &mut bool,
1598 visited: &mut HashSet<(PathBuf, String, String)>,
1599 ) {
1600 let args_node = find_child_by_kind(node, "arguments")
1602 .or_else(|| find_child_by_kind(node, "argument_list"));
1603
1604 let args_node = match args_node {
1605 Some(n) => n,
1606 None => return,
1607 };
1608
1609 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1612
1613 let mut cursor = args_node.walk();
1614 if cursor.goto_first_child() {
1615 loop {
1616 let child = cursor.node();
1617 let child_kind = child.kind();
1618
1619 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1621 if !cursor.goto_next_sibling() {
1622 break;
1623 }
1624 continue;
1625 }
1626
1627 let arg_text = node_text(child, source);
1628
1629 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1631 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1632 hops.push(DataFlowHop {
1633 file: rel_file.to_string(),
1634 symbol: _symbol.to_string(),
1635 variable: arg_text,
1636 line: child.start_position().row as u32 + 1,
1637 flow_type: "parameter".to_string(),
1638 approximate: true,
1639 });
1640 }
1641 if !cursor.goto_next_sibling() {
1642 break;
1643 }
1644 arg_idx += 1;
1645 continue;
1646 }
1647
1648 if tracked_names.iter().any(|t| arg_text == *t) {
1649 arg_positions.push((arg_idx, arg_text));
1650 }
1651
1652 arg_idx += 1;
1653 if !cursor.goto_next_sibling() {
1654 break;
1655 }
1656 }
1657 }
1658
1659 if arg_positions.is_empty() {
1660 return;
1661 }
1662
1663 let (full_callee, short_callee) = extract_callee_names(node, source);
1665 let full_callee = match full_callee {
1666 Some(f) => f,
1667 None => return,
1668 };
1669 let short_callee = match short_callee {
1670 Some(s) => s,
1671 None => return,
1672 };
1673
1674 let import_block = {
1676 match self.data.get(file) {
1677 Some(fd) => fd.import_block.clone(),
1678 None => return,
1679 }
1680 };
1681
1682 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1683
1684 match edge {
1685 EdgeResolution::Resolved {
1686 file: target_file,
1687 symbol: target_symbol,
1688 } => {
1689 if current_depth + 1 > max_depth {
1690 *depth_limited = true;
1691 return;
1692 }
1693
1694 if let Err(e) = self.build_file(&target_file) {
1696 log::debug!(
1697 "callgraph: skipping target file {}: {}",
1698 target_file.display(),
1699 e
1700 );
1701 }
1702 let (params, _target_lang) = {
1703 match self.data.get(&target_file) {
1704 Some(fd) => {
1705 let meta = fd.symbol_metadata.get(&target_symbol);
1706 let sig = meta.and_then(|m| m.signature.clone());
1707 let params = sig
1708 .as_deref()
1709 .map(|s| extract_parameters(s, fd.lang))
1710 .unwrap_or_default();
1711 (params, fd.lang)
1712 }
1713 None => return,
1714 }
1715 };
1716
1717 let target_rel = self.relative_path(&target_file);
1718
1719 for (pos, _tracked) in &arg_positions {
1720 if let Some(param_name) = params.get(*pos) {
1721 hops.push(DataFlowHop {
1723 file: target_rel.clone(),
1724 symbol: target_symbol.clone(),
1725 variable: param_name.clone(),
1726 line: get_symbol_meta(&target_file, &target_symbol).0,
1727 flow_type: "parameter".to_string(),
1728 approximate: false,
1729 });
1730
1731 self.trace_data_inner(
1733 &target_file.clone(),
1734 &target_symbol.clone(),
1735 param_name,
1736 max_depth,
1737 current_depth + 1,
1738 hops,
1739 depth_limited,
1740 visited,
1741 );
1742 }
1743 }
1744 }
1745 EdgeResolution::Unresolved { callee_name } => {
1746 let has_local = self
1748 .data
1749 .get(file)
1750 .map(|fd| {
1751 fd.calls_by_symbol.contains_key(&callee_name)
1752 || fd.symbol_metadata.contains_key(&callee_name)
1753 })
1754 .unwrap_or(false);
1755
1756 if has_local {
1757 let (params, _target_lang) = {
1759 let Some(fd) = self.data.get(file) else {
1760 return;
1761 };
1762 let meta = fd.symbol_metadata.get(&callee_name);
1763 let sig = meta.and_then(|m| m.signature.clone());
1764 let params = sig
1765 .as_deref()
1766 .map(|s| extract_parameters(s, fd.lang))
1767 .unwrap_or_default();
1768 (params, fd.lang)
1769 };
1770
1771 let file_rel = self.relative_path(file);
1772
1773 for (pos, _tracked) in &arg_positions {
1774 if let Some(param_name) = params.get(*pos) {
1775 hops.push(DataFlowHop {
1776 file: file_rel.clone(),
1777 symbol: callee_name.clone(),
1778 variable: param_name.clone(),
1779 line: get_symbol_meta(file, &callee_name).0,
1780 flow_type: "parameter".to_string(),
1781 approximate: false,
1782 });
1783
1784 self.trace_data_inner(
1786 file,
1787 &callee_name.clone(),
1788 param_name,
1789 max_depth,
1790 current_depth + 1,
1791 hops,
1792 depth_limited,
1793 visited,
1794 );
1795 }
1796 }
1797 } else {
1798 for (_pos, tracked) in &arg_positions {
1800 hops.push(DataFlowHop {
1801 file: self.relative_path(file),
1802 symbol: callee_name.clone(),
1803 variable: tracked.clone(),
1804 line: node.start_position().row as u32 + 1,
1805 flow_type: "parameter".to_string(),
1806 approximate: true,
1807 });
1808 }
1809 }
1810 }
1811 }
1812 }
1813
1814 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1816 let content = std::fs::read_to_string(path).ok()?;
1817 content
1818 .lines()
1819 .nth(line.saturating_sub(1) as usize)
1820 .map(|l| l.trim().to_string())
1821 }
1822
1823 fn collect_callers_recursive(
1825 &self,
1826 file: &Path,
1827 symbol: &str,
1828 max_depth: usize,
1829 current_depth: usize,
1830 visited: &mut HashSet<(PathBuf, String)>,
1831 result: &mut Vec<CallerSite>,
1832 ) {
1833 if current_depth >= max_depth {
1834 return;
1835 }
1836
1837 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1839 let key = (canon.clone(), symbol.to_string());
1840 if visited.contains(&key) {
1841 return; }
1843 visited.insert(key);
1844
1845 let reverse_index = match &self.reverse_index {
1846 Some(ri) => ri,
1847 None => return,
1848 };
1849
1850 let lookup_key = (canon, symbol.to_string());
1851 if let Some(sites) = reverse_index.get(&lookup_key) {
1852 for site in sites {
1853 result.push(site.clone());
1854 if current_depth + 1 < max_depth {
1856 self.collect_callers_recursive(
1857 &site.caller_file,
1858 &site.caller_symbol,
1859 max_depth,
1860 current_depth + 1,
1861 visited,
1862 result,
1863 );
1864 }
1865 }
1866 }
1867 }
1868
1869 pub fn invalidate_file(&mut self, path: &Path) {
1874 self.data.remove(path);
1876 if let Ok(canon) = self.canonicalize(path) {
1877 self.data.remove(&canon);
1878 }
1879 self.reverse_index = None;
1881 self.project_files = None;
1883 }
1884
1885 fn relative_path(&self, path: &Path) -> String {
1888 path.strip_prefix(&self.project_root)
1889 .unwrap_or(path)
1890 .display()
1891 .to_string()
1892 }
1893
1894 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1896 let full_path = if path.is_relative() {
1898 self.project_root.join(path)
1899 } else {
1900 path.to_path_buf()
1901 };
1902
1903 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1905 }
1906
1907 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
1911 if let Some(fd) = self.data.get(path) {
1912 return Some(fd);
1913 }
1914 let canon = std::fs::canonicalize(path).ok()?;
1916 self.data.get(&canon).or_else(|| {
1917 self.data.iter().find_map(|(k, v)| {
1919 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
1920 Some(v)
1921 } else {
1922 None
1923 }
1924 })
1925 })
1926 }
1927}
1928
1929fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1935 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1936 message: format!("unsupported file for call graph: {}", path.display()),
1937 })?;
1938
1939 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1940 path: format!("{}: {}", path.display(), e),
1941 })?;
1942
1943 let grammar = grammar_for(lang);
1944 let mut parser = Parser::new();
1945 parser
1946 .set_language(&grammar)
1947 .map_err(|e| AftError::ParseError {
1948 message: format!("grammar init failed for {:?}: {}", lang, e),
1949 })?;
1950
1951 let tree = parser
1952 .parse(&source, None)
1953 .ok_or_else(|| AftError::ParseError {
1954 message: format!("parse failed for {}", path.display()),
1955 })?;
1956
1957 let import_block = imports::parse_imports(&source, &tree, lang);
1959
1960 let symbols = list_symbols_from_tree(&source, &tree, lang, path);
1962
1963 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
1965 let root = tree.root_node();
1966
1967 for sym in &symbols {
1968 let byte_start = line_col_to_byte(&source, sym.start_line, sym.start_col);
1969 let byte_end = line_col_to_byte(&source, sym.end_line, sym.end_col);
1970
1971 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1972
1973 let sites: Vec<CallSite> = raw_calls
1974 .into_iter()
1975 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
1977 callee_name: short,
1978 full_callee: full,
1979 line,
1980 byte_start,
1981 byte_end,
1982 })
1983 .collect();
1984
1985 if !sites.is_empty() {
1986 calls_by_symbol.insert(sym.name.clone(), sites);
1987 }
1988 }
1989
1990 let exported_symbols: Vec<String> = symbols
1992 .iter()
1993 .filter(|s| s.exported)
1994 .map(|s| s.name.clone())
1995 .collect();
1996
1997 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
1999 .iter()
2000 .map(|s| {
2001 (
2002 s.name.clone(),
2003 SymbolMeta {
2004 kind: s.kind.clone(),
2005 exported: s.exported,
2006 signature: s.signature.clone(),
2007 },
2008 )
2009 })
2010 .collect();
2011
2012 Ok(FileCallData {
2013 calls_by_symbol,
2014 exported_symbols,
2015 symbol_metadata,
2016 import_block,
2017 lang,
2018 })
2019}
2020
2021#[derive(Debug)]
2023#[allow(dead_code)]
2024struct SymbolInfo {
2025 name: String,
2026 kind: SymbolKind,
2027 start_line: u32,
2028 start_col: u32,
2029 end_line: u32,
2030 end_col: u32,
2031 exported: bool,
2032 signature: Option<String>,
2033}
2034
2035fn list_symbols_from_tree(
2038 _source: &str,
2039 _tree: &Tree,
2040 _lang: LangId,
2041 path: &Path,
2042) -> Vec<SymbolInfo> {
2043 let mut file_parser = crate::parser::FileParser::new();
2045 match file_parser.parse(path) {
2046 Ok(_) => {}
2047 Err(_) => return vec![],
2048 }
2049
2050 let provider = crate::parser::TreeSitterProvider::new();
2052 match provider.list_symbols(path) {
2053 Ok(symbols) => symbols
2054 .into_iter()
2055 .map(|s| SymbolInfo {
2056 name: s.name,
2057 kind: s.kind,
2058 start_line: s.range.start_line,
2059 start_col: s.range.start_col,
2060 end_line: s.range.end_line,
2061 end_col: s.range.end_col,
2062 exported: s.exported,
2063 signature: s.signature,
2064 })
2065 .collect(),
2066 Err(_) => vec![],
2067 }
2068}
2069
2070fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2072 let provider = crate::parser::TreeSitterProvider::new();
2073 match provider.list_symbols(path) {
2074 Ok(symbols) => {
2075 for s in &symbols {
2076 if s.name == symbol_name {
2077 return (s.range.start_line + 1, s.signature.clone());
2078 }
2079 }
2080 (1, None)
2081 }
2082 Err(_) => (1, None),
2083 }
2084}
2085
2086fn node_text(node: tree_sitter::Node, source: &str) -> String {
2092 source[node.start_byte()..node.end_byte()].to_string()
2093}
2094
2095fn find_node_covering_range(
2097 root: tree_sitter::Node,
2098 start: usize,
2099 end: usize,
2100) -> Option<tree_sitter::Node> {
2101 let mut best = None;
2102 let mut cursor = root.walk();
2103
2104 fn walk_covering<'a>(
2105 cursor: &mut tree_sitter::TreeCursor<'a>,
2106 start: usize,
2107 end: usize,
2108 best: &mut Option<tree_sitter::Node<'a>>,
2109 ) {
2110 let node = cursor.node();
2111 if node.start_byte() <= start && node.end_byte() >= end {
2112 *best = Some(node);
2113 if cursor.goto_first_child() {
2114 loop {
2115 walk_covering(cursor, start, end, best);
2116 if !cursor.goto_next_sibling() {
2117 break;
2118 }
2119 }
2120 cursor.goto_parent();
2121 }
2122 }
2123 }
2124
2125 walk_covering(&mut cursor, start, end, &mut best);
2126 best
2127}
2128
2129fn find_child_by_kind<'a>(
2131 node: tree_sitter::Node<'a>,
2132 kind: &str,
2133) -> Option<tree_sitter::Node<'a>> {
2134 let mut cursor = node.walk();
2135 if cursor.goto_first_child() {
2136 loop {
2137 if cursor.node().kind() == kind {
2138 return Some(cursor.node());
2139 }
2140 if !cursor.goto_next_sibling() {
2141 break;
2142 }
2143 }
2144 }
2145 None
2146}
2147
2148fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2150 let callee = match node.child_by_field_name("function") {
2152 Some(c) => c,
2153 None => return (None, None),
2154 };
2155
2156 let full = node_text(callee, source);
2157 let short = if full.contains('.') {
2158 full.rsplit('.').next().unwrap_or(&full).to_string()
2159 } else {
2160 full.clone()
2161 };
2162
2163 (Some(full), Some(short))
2164}
2165
2166pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2174 if !module_path.starts_with('.') {
2176 return None;
2177 }
2178
2179 let base = from_dir.join(module_path);
2180
2181 if base.is_file() {
2183 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2184 }
2185
2186 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2188 for ext in &extensions {
2189 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2190 if with_ext.is_file() {
2191 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2192 }
2193 }
2194
2195 if base.is_dir() {
2197 if let Some(index) = find_index_file(&base) {
2198 return Some(index);
2199 }
2200 }
2201
2202 None
2203}
2204
2205fn find_index_file(dir: &Path) -> Option<PathBuf> {
2207 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2208 for name in &candidates {
2209 let p = dir.join(name);
2210 if p.is_file() {
2211 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2212 }
2213 }
2214 None
2215}
2216
2217fn resolve_aliased_import(
2220 local_name: &str,
2221 import_block: &ImportBlock,
2222 caller_dir: &Path,
2223) -> Option<(String, PathBuf)> {
2224 for imp in &import_block.imports {
2225 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2228 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2229 return Some((original, resolved_path));
2230 }
2231 }
2232 }
2233 None
2234}
2235
2236fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2240 let search = format!(" as {}", local_name);
2243 if let Some(pos) = raw_import.find(&search) {
2244 let before = &raw_import[..pos];
2246 let original = before
2248 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2249 .find(|s| !s.is_empty())?;
2250 return Some(original.to_string());
2251 }
2252 None
2253}
2254
2255pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2263 use ignore::WalkBuilder;
2264
2265 let walker = WalkBuilder::new(root)
2266 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2271 let name = entry.file_name().to_string_lossy();
2272 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2274 return !matches!(
2275 name.as_ref(),
2276 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2277 | ".tox" | "dist" | "build"
2278 );
2279 }
2280 true
2281 })
2282 .build();
2283
2284 walker
2285 .filter_map(|entry| entry.ok())
2286 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2287 .filter(|entry| detect_language(entry.path()).is_some())
2288 .map(|entry| entry.into_path())
2289}
2290
2291#[cfg(test)]
2296mod tests {
2297 use super::*;
2298 use std::fs;
2299 use tempfile::TempDir;
2300
2301 fn setup_ts_project() -> TempDir {
2303 let dir = TempDir::new().unwrap();
2304
2305 fs::write(
2307 dir.path().join("main.ts"),
2308 r#"import { helper, compute } from './utils';
2309import * as math from './math';
2310
2311export function main() {
2312 const a = helper(1);
2313 const b = compute(a, 2);
2314 const c = math.add(a, b);
2315 return c;
2316}
2317"#,
2318 )
2319 .unwrap();
2320
2321 fs::write(
2323 dir.path().join("utils.ts"),
2324 r#"import { double } from './helpers';
2325
2326export function helper(x: number): number {
2327 return double(x);
2328}
2329
2330export function compute(a: number, b: number): number {
2331 return a + b;
2332}
2333"#,
2334 )
2335 .unwrap();
2336
2337 fs::write(
2339 dir.path().join("helpers.ts"),
2340 r#"export function double(x: number): number {
2341 return x * 2;
2342}
2343
2344export function triple(x: number): number {
2345 return x * 3;
2346}
2347"#,
2348 )
2349 .unwrap();
2350
2351 fs::write(
2353 dir.path().join("math.ts"),
2354 r#"export function add(a: number, b: number): number {
2355 return a + b;
2356}
2357
2358export function subtract(a: number, b: number): number {
2359 return a - b;
2360}
2361"#,
2362 )
2363 .unwrap();
2364
2365 dir
2366 }
2367
2368 fn setup_alias_project() -> TempDir {
2370 let dir = TempDir::new().unwrap();
2371
2372 fs::write(
2373 dir.path().join("main.ts"),
2374 r#"import { helper as h } from './utils';
2375
2376export function main() {
2377 return h(42);
2378}
2379"#,
2380 )
2381 .unwrap();
2382
2383 fs::write(
2384 dir.path().join("utils.ts"),
2385 r#"export function helper(x: number): number {
2386 return x + 1;
2387}
2388"#,
2389 )
2390 .unwrap();
2391
2392 dir
2393 }
2394
2395 fn setup_cycle_project() -> TempDir {
2397 let dir = TempDir::new().unwrap();
2398
2399 fs::write(
2400 dir.path().join("a.ts"),
2401 r#"import { funcB } from './b';
2402
2403export function funcA() {
2404 return funcB();
2405}
2406"#,
2407 )
2408 .unwrap();
2409
2410 fs::write(
2411 dir.path().join("b.ts"),
2412 r#"import { funcA } from './a';
2413
2414export function funcB() {
2415 return funcA();
2416}
2417"#,
2418 )
2419 .unwrap();
2420
2421 dir
2422 }
2423
2424 #[test]
2427 fn callgraph_single_file_call_extraction() {
2428 let dir = setup_ts_project();
2429 let mut graph = CallGraph::new(dir.path().to_path_buf());
2430
2431 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2432 let main_calls = &file_data.calls_by_symbol["main"];
2433
2434 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2435 assert!(
2436 callee_names.contains(&"helper"),
2437 "main should call helper, got: {:?}",
2438 callee_names
2439 );
2440 assert!(
2441 callee_names.contains(&"compute"),
2442 "main should call compute, got: {:?}",
2443 callee_names
2444 );
2445 assert!(
2446 callee_names.contains(&"add"),
2447 "main should call math.add (short name: add), got: {:?}",
2448 callee_names
2449 );
2450 }
2451
2452 #[test]
2453 fn callgraph_file_data_has_exports() {
2454 let dir = setup_ts_project();
2455 let mut graph = CallGraph::new(dir.path().to_path_buf());
2456
2457 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2458 assert!(
2459 file_data.exported_symbols.contains(&"helper".to_string()),
2460 "utils.ts should export helper, got: {:?}",
2461 file_data.exported_symbols
2462 );
2463 assert!(
2464 file_data.exported_symbols.contains(&"compute".to_string()),
2465 "utils.ts should export compute, got: {:?}",
2466 file_data.exported_symbols
2467 );
2468 }
2469
2470 #[test]
2473 fn callgraph_resolve_direct_import() {
2474 let dir = setup_ts_project();
2475 let mut graph = CallGraph::new(dir.path().to_path_buf());
2476
2477 let main_path = dir.path().join("main.ts");
2478 let file_data = graph.build_file(&main_path).unwrap();
2479 let import_block = file_data.import_block.clone();
2480
2481 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2482 match edge {
2483 EdgeResolution::Resolved { file, symbol } => {
2484 assert!(
2485 file.ends_with("utils.ts"),
2486 "helper should resolve to utils.ts, got: {:?}",
2487 file
2488 );
2489 assert_eq!(symbol, "helper");
2490 }
2491 EdgeResolution::Unresolved { callee_name } => {
2492 panic!("Expected resolved, got unresolved: {}", callee_name);
2493 }
2494 }
2495 }
2496
2497 #[test]
2498 fn callgraph_resolve_namespace_import() {
2499 let dir = setup_ts_project();
2500 let mut graph = CallGraph::new(dir.path().to_path_buf());
2501
2502 let main_path = dir.path().join("main.ts");
2503 let file_data = graph.build_file(&main_path).unwrap();
2504 let import_block = file_data.import_block.clone();
2505
2506 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2507 match edge {
2508 EdgeResolution::Resolved { file, symbol } => {
2509 assert!(
2510 file.ends_with("math.ts"),
2511 "math.add should resolve to math.ts, got: {:?}",
2512 file
2513 );
2514 assert_eq!(symbol, "add");
2515 }
2516 EdgeResolution::Unresolved { callee_name } => {
2517 panic!("Expected resolved, got unresolved: {}", callee_name);
2518 }
2519 }
2520 }
2521
2522 #[test]
2523 fn callgraph_resolve_aliased_import() {
2524 let dir = setup_alias_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("h", "h", &main_path, &import_block);
2532 match edge {
2533 EdgeResolution::Resolved { file, symbol } => {
2534 assert!(
2535 file.ends_with("utils.ts"),
2536 "h (alias for 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_unresolved_edge_marked() {
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 =
2557 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2558 assert_eq!(
2559 edge,
2560 EdgeResolution::Unresolved {
2561 callee_name: "unknownFunc".to_string()
2562 },
2563 "Unknown callee should be unresolved"
2564 );
2565 }
2566
2567 #[test]
2570 fn callgraph_cycle_detection_stops() {
2571 let dir = setup_cycle_project();
2572 let mut graph = CallGraph::new(dir.path().to_path_buf());
2573
2574 let tree = graph
2576 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2577 .unwrap();
2578
2579 assert_eq!(tree.name, "funcA");
2580 assert!(tree.resolved);
2581
2582 fn count_depth(node: &CallTreeNode) -> usize {
2585 if node.children.is_empty() {
2586 1
2587 } else {
2588 1 + node
2589 .children
2590 .iter()
2591 .map(|c| count_depth(c))
2592 .max()
2593 .unwrap_or(0)
2594 }
2595 }
2596
2597 let depth = count_depth(&tree);
2598 assert!(
2599 depth <= 4,
2600 "Cycle should be detected and bounded, depth was: {}",
2601 depth
2602 );
2603 }
2604
2605 #[test]
2608 fn callgraph_depth_limit_truncates() {
2609 let dir = setup_ts_project();
2610 let mut graph = CallGraph::new(dir.path().to_path_buf());
2611
2612 let tree = graph
2615 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2616 .unwrap();
2617
2618 assert_eq!(tree.name, "main");
2619
2620 for child in &tree.children {
2622 assert!(
2623 child.children.is_empty(),
2624 "At depth 1, child '{}' should have no children, got {:?}",
2625 child.name,
2626 child.children.len()
2627 );
2628 }
2629 }
2630
2631 #[test]
2632 fn callgraph_depth_zero_no_children() {
2633 let dir = setup_ts_project();
2634 let mut graph = CallGraph::new(dir.path().to_path_buf());
2635
2636 let tree = graph
2637 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2638 .unwrap();
2639
2640 assert_eq!(tree.name, "main");
2641 assert!(
2642 tree.children.is_empty(),
2643 "At depth 0, should have no children"
2644 );
2645 }
2646
2647 #[test]
2650 fn callgraph_forward_tree_cross_file() {
2651 let dir = setup_ts_project();
2652 let mut graph = CallGraph::new(dir.path().to_path_buf());
2653
2654 let tree = graph
2656 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2657 .unwrap();
2658
2659 assert_eq!(tree.name, "main");
2660 assert!(tree.resolved);
2661
2662 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2664 assert!(
2665 helper_child.is_some(),
2666 "main should have helper as child, children: {:?}",
2667 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2668 );
2669
2670 let helper = helper_child.unwrap();
2671 assert!(
2672 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2673 "helper should be in utils.ts, got: {}",
2674 helper.file
2675 );
2676
2677 let double_child = helper.children.iter().find(|c| c.name == "double");
2679 assert!(
2680 double_child.is_some(),
2681 "helper should call double, children: {:?}",
2682 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2683 );
2684
2685 let double = double_child.unwrap();
2686 assert!(
2687 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2688 "double should be in helpers.ts, got: {}",
2689 double.file
2690 );
2691 }
2692
2693 #[test]
2696 fn callgraph_walker_excludes_gitignored() {
2697 let dir = TempDir::new().unwrap();
2698
2699 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2701
2702 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2704 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2705 fs::write(
2706 dir.path().join("ignored_dir").join("secret.ts"),
2707 "export function secret() {}",
2708 )
2709 .unwrap();
2710
2711 fs::create_dir(dir.path().join("node_modules")).unwrap();
2713 fs::write(
2714 dir.path().join("node_modules").join("dep.ts"),
2715 "export function dep() {}",
2716 )
2717 .unwrap();
2718
2719 std::process::Command::new("git")
2721 .args(["init"])
2722 .current_dir(dir.path())
2723 .output()
2724 .unwrap();
2725
2726 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2727 let file_names: Vec<String> = files
2728 .iter()
2729 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2730 .collect();
2731
2732 assert!(
2733 file_names.contains(&"main.ts".to_string()),
2734 "Should include main.ts, got: {:?}",
2735 file_names
2736 );
2737 assert!(
2738 !file_names.contains(&"secret.ts".to_string()),
2739 "Should exclude gitignored secret.ts, got: {:?}",
2740 file_names
2741 );
2742 assert!(
2743 !file_names.contains(&"dep.ts".to_string()),
2744 "Should exclude node_modules, got: {:?}",
2745 file_names
2746 );
2747 }
2748
2749 #[test]
2750 fn callgraph_walker_only_source_files() {
2751 let dir = TempDir::new().unwrap();
2752
2753 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2754 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2755 fs::write(dir.path().join("data.json"), "{}").unwrap();
2756
2757 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2758 let file_names: Vec<String> = files
2759 .iter()
2760 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2761 .collect();
2762
2763 assert!(file_names.contains(&"main.ts".to_string()));
2764 assert!(
2765 file_names.contains(&"readme.md".to_string()),
2766 "Markdown is now a supported source language"
2767 );
2768 assert!(
2769 !file_names.contains(&"data.json".to_string()),
2770 "Should not include non-source files"
2771 );
2772 }
2773
2774 #[test]
2777 fn callgraph_find_alias_original_simple() {
2778 let raw = "import { foo as bar } from './utils';";
2779 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2780 }
2781
2782 #[test]
2783 fn callgraph_find_alias_original_multiple() {
2784 let raw = "import { foo as bar, baz as qux } from './utils';";
2785 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2786 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2787 }
2788
2789 #[test]
2790 fn callgraph_find_alias_no_match() {
2791 let raw = "import { foo } from './utils';";
2792 assert_eq!(find_alias_original(raw, "foo"), None);
2793 }
2794
2795 #[test]
2798 fn callgraph_callers_of_direct() {
2799 let dir = setup_ts_project();
2800 let mut graph = CallGraph::new(dir.path().to_path_buf());
2801
2802 let result = graph
2804 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2805 .unwrap();
2806
2807 assert_eq!(result.symbol, "double");
2808 assert!(result.total_callers > 0, "double should have callers");
2809 assert!(result.scanned_files > 0, "should have scanned files");
2810
2811 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2813 assert!(
2814 utils_group.is_some(),
2815 "double should be called from utils.ts, groups: {:?}",
2816 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2817 );
2818
2819 let group = utils_group.unwrap();
2820 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2821 assert!(
2822 helper_caller.is_some(),
2823 "double should be called by helper, callers: {:?}",
2824 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2825 );
2826 }
2827
2828 #[test]
2829 fn callgraph_callers_of_no_callers() {
2830 let dir = setup_ts_project();
2831 let mut graph = CallGraph::new(dir.path().to_path_buf());
2832
2833 let result = graph
2835 .callers_of(&dir.path().join("main.ts"), "main", 1)
2836 .unwrap();
2837
2838 assert_eq!(result.symbol, "main");
2839 assert_eq!(result.total_callers, 0, "main should have no callers");
2840 assert!(result.callers.is_empty());
2841 }
2842
2843 #[test]
2844 fn callgraph_callers_recursive_depth() {
2845 let dir = setup_ts_project();
2846 let mut graph = CallGraph::new(dir.path().to_path_buf());
2847
2848 let result = graph
2852 .callers_of(&dir.path().join("helpers.ts"), "double", 2)
2853 .unwrap();
2854
2855 assert!(
2856 result.total_callers >= 2,
2857 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2858 result.total_callers
2859 );
2860
2861 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2863 assert!(
2864 main_group.is_some(),
2865 "recursive callers should include main.ts, groups: {:?}",
2866 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2867 );
2868 }
2869
2870 #[test]
2871 fn callgraph_invalidate_file_clears_reverse_index() {
2872 let dir = setup_ts_project();
2873 let mut graph = CallGraph::new(dir.path().to_path_buf());
2874
2875 let _ = graph
2877 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2878 .unwrap();
2879 assert!(
2880 graph.reverse_index.is_some(),
2881 "reverse index should be built"
2882 );
2883
2884 graph.invalidate_file(&dir.path().join("utils.ts"));
2886
2887 assert!(
2889 graph.reverse_index.is_none(),
2890 "invalidate_file should clear reverse index"
2891 );
2892 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2894 assert!(
2895 !graph.data.contains_key(&canon),
2896 "invalidate_file should remove file from data cache"
2897 );
2898 assert!(
2900 graph.project_files.is_none(),
2901 "invalidate_file should clear project_files"
2902 );
2903 }
2904
2905 #[test]
2908 fn is_entry_point_exported_function() {
2909 assert!(is_entry_point(
2910 "handleRequest",
2911 &SymbolKind::Function,
2912 true,
2913 LangId::TypeScript
2914 ));
2915 }
2916
2917 #[test]
2918 fn is_entry_point_exported_method_is_not_entry() {
2919 assert!(!is_entry_point(
2921 "handleRequest",
2922 &SymbolKind::Method,
2923 true,
2924 LangId::TypeScript
2925 ));
2926 }
2927
2928 #[test]
2929 fn is_entry_point_main_init_patterns() {
2930 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
2931 assert!(
2932 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
2933 "{} should be an entry point",
2934 name
2935 );
2936 }
2937 }
2938
2939 #[test]
2940 fn is_entry_point_test_patterns_ts() {
2941 assert!(is_entry_point(
2942 "describe",
2943 &SymbolKind::Function,
2944 false,
2945 LangId::TypeScript
2946 ));
2947 assert!(is_entry_point(
2948 "it",
2949 &SymbolKind::Function,
2950 false,
2951 LangId::TypeScript
2952 ));
2953 assert!(is_entry_point(
2954 "test",
2955 &SymbolKind::Function,
2956 false,
2957 LangId::TypeScript
2958 ));
2959 assert!(is_entry_point(
2960 "testValidation",
2961 &SymbolKind::Function,
2962 false,
2963 LangId::TypeScript
2964 ));
2965 assert!(is_entry_point(
2966 "specHelper",
2967 &SymbolKind::Function,
2968 false,
2969 LangId::TypeScript
2970 ));
2971 }
2972
2973 #[test]
2974 fn is_entry_point_test_patterns_python() {
2975 assert!(is_entry_point(
2976 "test_login",
2977 &SymbolKind::Function,
2978 false,
2979 LangId::Python
2980 ));
2981 assert!(is_entry_point(
2982 "setUp",
2983 &SymbolKind::Function,
2984 false,
2985 LangId::Python
2986 ));
2987 assert!(is_entry_point(
2988 "tearDown",
2989 &SymbolKind::Function,
2990 false,
2991 LangId::Python
2992 ));
2993 assert!(!is_entry_point(
2995 "testSomething",
2996 &SymbolKind::Function,
2997 false,
2998 LangId::Python
2999 ));
3000 }
3001
3002 #[test]
3003 fn is_entry_point_test_patterns_rust() {
3004 assert!(is_entry_point(
3005 "test_parse",
3006 &SymbolKind::Function,
3007 false,
3008 LangId::Rust
3009 ));
3010 assert!(!is_entry_point(
3011 "TestSomething",
3012 &SymbolKind::Function,
3013 false,
3014 LangId::Rust
3015 ));
3016 }
3017
3018 #[test]
3019 fn is_entry_point_test_patterns_go() {
3020 assert!(is_entry_point(
3021 "TestParsing",
3022 &SymbolKind::Function,
3023 false,
3024 LangId::Go
3025 ));
3026 assert!(!is_entry_point(
3028 "testParsing",
3029 &SymbolKind::Function,
3030 false,
3031 LangId::Go
3032 ));
3033 }
3034
3035 #[test]
3036 fn is_entry_point_non_exported_non_main_is_not_entry() {
3037 assert!(!is_entry_point(
3038 "helperUtil",
3039 &SymbolKind::Function,
3040 false,
3041 LangId::TypeScript
3042 ));
3043 }
3044
3045 #[test]
3048 fn callgraph_symbol_metadata_populated() {
3049 let dir = setup_ts_project();
3050 let mut graph = CallGraph::new(dir.path().to_path_buf());
3051
3052 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3053 assert!(
3054 file_data.symbol_metadata.contains_key("helper"),
3055 "symbol_metadata should contain helper"
3056 );
3057 let meta = &file_data.symbol_metadata["helper"];
3058 assert_eq!(meta.kind, SymbolKind::Function);
3059 assert!(meta.exported, "helper should be exported");
3060 }
3061
3062 fn setup_trace_project() -> TempDir {
3078 let dir = TempDir::new().unwrap();
3079
3080 fs::write(
3081 dir.path().join("main.ts"),
3082 r#"import { processData } from './utils';
3083
3084export function main() {
3085 const result = processData("hello");
3086 return result;
3087}
3088"#,
3089 )
3090 .unwrap();
3091
3092 fs::write(
3093 dir.path().join("service.ts"),
3094 r#"import { processData } from './utils';
3095
3096export function handleRequest(input: string): string {
3097 return processData(input);
3098}
3099"#,
3100 )
3101 .unwrap();
3102
3103 fs::write(
3104 dir.path().join("utils.ts"),
3105 r#"import { validate } from './helpers';
3106
3107export function processData(input: string): string {
3108 const valid = validate(input);
3109 if (!valid) {
3110 throw new Error("invalid input");
3111 }
3112 return input.toUpperCase();
3113}
3114"#,
3115 )
3116 .unwrap();
3117
3118 fs::write(
3119 dir.path().join("helpers.ts"),
3120 r#"export function validate(input: string): boolean {
3121 return checkFormat(input);
3122}
3123
3124function checkFormat(input: string): boolean {
3125 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3126}
3127"#,
3128 )
3129 .unwrap();
3130
3131 fs::write(
3132 dir.path().join("test_helpers.ts"),
3133 r#"import { validate } from './helpers';
3134
3135function testValidation() {
3136 const result = validate("hello");
3137 console.log(result);
3138}
3139"#,
3140 )
3141 .unwrap();
3142
3143 std::process::Command::new("git")
3145 .args(["init"])
3146 .current_dir(dir.path())
3147 .output()
3148 .unwrap();
3149
3150 dir
3151 }
3152
3153 #[test]
3154 fn trace_to_multi_path() {
3155 let dir = setup_trace_project();
3156 let mut graph = CallGraph::new(dir.path().to_path_buf());
3157
3158 let result = graph
3159 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3160 .unwrap();
3161
3162 assert_eq!(result.target_symbol, "checkFormat");
3163 assert!(
3164 result.total_paths >= 2,
3165 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3166 result.total_paths,
3167 result
3168 .paths
3169 .iter()
3170 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3171 .collect::<Vec<_>>()
3172 );
3173
3174 for path in &result.paths {
3176 assert!(
3177 path.hops.first().unwrap().is_entry_point,
3178 "First hop should be an entry point, got: {}",
3179 path.hops.first().unwrap().symbol
3180 );
3181 assert_eq!(
3182 path.hops.last().unwrap().symbol,
3183 "checkFormat",
3184 "Last hop should be checkFormat"
3185 );
3186 }
3187
3188 assert!(
3190 result.entry_points_found >= 2,
3191 "should find at least 2 entry points, got {}",
3192 result.entry_points_found
3193 );
3194 }
3195
3196 #[test]
3197 fn trace_to_single_path() {
3198 let dir = setup_trace_project();
3199 let mut graph = CallGraph::new(dir.path().to_path_buf());
3200
3201 let result = graph
3205 .trace_to(&dir.path().join("helpers.ts"), "validate", 10)
3206 .unwrap();
3207
3208 assert_eq!(result.target_symbol, "validate");
3209 assert!(
3210 result.total_paths >= 2,
3211 "validate should have at least 2 paths, got {}",
3212 result.total_paths
3213 );
3214 }
3215
3216 #[test]
3217 fn trace_to_cycle_detection() {
3218 let dir = setup_cycle_project();
3219 let mut graph = CallGraph::new(dir.path().to_path_buf());
3220
3221 let result = graph
3223 .trace_to(&dir.path().join("a.ts"), "funcA", 10)
3224 .unwrap();
3225
3226 assert_eq!(result.target_symbol, "funcA");
3228 }
3229
3230 #[test]
3231 fn trace_to_depth_limit() {
3232 let dir = setup_trace_project();
3233 let mut graph = CallGraph::new(dir.path().to_path_buf());
3234
3235 let result = graph
3237 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1)
3238 .unwrap();
3239
3240 assert_eq!(result.target_symbol, "checkFormat");
3244
3245 let deep_result = graph
3247 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3248 .unwrap();
3249
3250 assert!(
3251 result.total_paths <= deep_result.total_paths,
3252 "shallow trace should find <= paths compared to deep: {} vs {}",
3253 result.total_paths,
3254 deep_result.total_paths
3255 );
3256 }
3257
3258 #[test]
3259 fn trace_to_entry_point_target() {
3260 let dir = setup_trace_project();
3261 let mut graph = CallGraph::new(dir.path().to_path_buf());
3262
3263 let result = graph
3265 .trace_to(&dir.path().join("main.ts"), "main", 10)
3266 .unwrap();
3267
3268 assert_eq!(result.target_symbol, "main");
3269 assert!(
3270 result.total_paths >= 1,
3271 "main should have at least 1 path (itself), got {}",
3272 result.total_paths
3273 );
3274 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3276 assert!(
3277 trivial.is_some(),
3278 "should have a trivial path with just the entry point itself"
3279 );
3280 }
3281
3282 #[test]
3285 fn extract_parameters_typescript() {
3286 let params = extract_parameters(
3287 "function processData(input: string, count: number): void",
3288 LangId::TypeScript,
3289 );
3290 assert_eq!(params, vec!["input", "count"]);
3291 }
3292
3293 #[test]
3294 fn extract_parameters_typescript_optional() {
3295 let params = extract_parameters(
3296 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3297 LangId::TypeScript,
3298 );
3299 assert_eq!(params, vec!["url", "options"]);
3300 }
3301
3302 #[test]
3303 fn extract_parameters_typescript_defaults() {
3304 let params = extract_parameters(
3305 "function greet(name: string, greeting: string = \"hello\"): string",
3306 LangId::TypeScript,
3307 );
3308 assert_eq!(params, vec!["name", "greeting"]);
3309 }
3310
3311 #[test]
3312 fn extract_parameters_typescript_rest() {
3313 let params = extract_parameters(
3314 "function sum(...numbers: number[]): number",
3315 LangId::TypeScript,
3316 );
3317 assert_eq!(params, vec!["numbers"]);
3318 }
3319
3320 #[test]
3321 fn extract_parameters_python_self_skipped() {
3322 let params = extract_parameters(
3323 "def process(self, data: str, count: int) -> bool",
3324 LangId::Python,
3325 );
3326 assert_eq!(params, vec!["data", "count"]);
3327 }
3328
3329 #[test]
3330 fn extract_parameters_python_no_self() {
3331 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3332 assert_eq!(params, vec!["input"]);
3333 }
3334
3335 #[test]
3336 fn extract_parameters_python_star_args() {
3337 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3338 assert_eq!(params, vec!["args", "kwargs"]);
3339 }
3340
3341 #[test]
3342 fn extract_parameters_rust_self_skipped() {
3343 let params = extract_parameters(
3344 "fn process(&self, data: &str, count: usize) -> bool",
3345 LangId::Rust,
3346 );
3347 assert_eq!(params, vec!["data", "count"]);
3348 }
3349
3350 #[test]
3351 fn extract_parameters_rust_mut_self_skipped() {
3352 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3353 assert_eq!(params, vec!["value"]);
3354 }
3355
3356 #[test]
3357 fn extract_parameters_rust_no_self() {
3358 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3359 assert_eq!(params, vec!["input"]);
3360 }
3361
3362 #[test]
3363 fn extract_parameters_rust_mut_param() {
3364 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3365 assert_eq!(params, vec!["buf", "len"]);
3366 }
3367
3368 #[test]
3369 fn extract_parameters_go() {
3370 let params = extract_parameters(
3371 "func ProcessData(input string, count int) error",
3372 LangId::Go,
3373 );
3374 assert_eq!(params, vec!["input", "count"]);
3375 }
3376
3377 #[test]
3378 fn extract_parameters_empty() {
3379 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3380 assert!(
3381 params.is_empty(),
3382 "no-arg function should return empty params"
3383 );
3384 }
3385
3386 #[test]
3387 fn extract_parameters_no_parens() {
3388 let params = extract_parameters("const x = 42", LangId::TypeScript);
3389 assert!(params.is_empty(), "no parens should return empty params");
3390 }
3391
3392 #[test]
3393 fn extract_parameters_javascript() {
3394 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3395 assert_eq!(params, vec!["event", "target"]);
3396 }
3397}