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