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