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