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