1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use tree_sitter::{Parser, Tree};
12
13use crate::calls::extract_calls_full;
14use crate::error::AftError;
15use crate::imports::{self, ImportBlock};
16use crate::language::LanguageProvider;
17use crate::parser::{detect_language, grammar_for, LangId};
18use crate::symbols::SymbolKind;
19
20#[derive(Debug, Clone)]
26pub struct CallSite {
27 pub callee_name: String,
29 pub full_callee: String,
31 pub line: u32,
33 pub byte_start: usize,
35 pub byte_end: usize,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct SymbolMeta {
41 pub kind: SymbolKind,
43 pub exported: bool,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub signature: Option<String>,
48}
49
50#[derive(Debug, Clone)]
53pub struct FileCallData {
54 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
56 pub exported_symbols: Vec<String>,
58 pub symbol_metadata: HashMap<String, SymbolMeta>,
60 pub import_block: ImportBlock,
62 pub lang: LangId,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum EdgeResolution {
69 Resolved { file: PathBuf, symbol: String },
71 Unresolved { callee_name: String },
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct CallerSite {
78 pub caller_file: PathBuf,
80 pub caller_symbol: String,
82 pub line: u32,
84 pub col: u32,
86 pub resolved: bool,
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct CallerGroup {
93 pub file: String,
95 pub callers: Vec<CallerEntry>,
97}
98
99#[derive(Debug, Clone, Serialize)]
101pub struct CallerEntry {
102 pub symbol: String,
103 pub line: u32,
104}
105
106#[derive(Debug, Clone, Serialize)]
108pub struct CallersResult {
109 pub symbol: String,
111 pub file: String,
113 pub callers: Vec<CallerGroup>,
115 pub total_callers: usize,
117 pub scanned_files: usize,
119}
120
121#[derive(Debug, Clone, Serialize)]
123pub struct CallTreeNode {
124 pub name: String,
126 pub file: String,
128 pub line: u32,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub signature: Option<String>,
133 pub resolved: bool,
135 pub children: Vec<CallTreeNode>,
137}
138
139const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
145
146pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
153 if exported && *kind == SymbolKind::Function {
155 return true;
156 }
157
158 let lower = name.to_lowercase();
160 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
161 return true;
162 }
163
164 match lang {
166 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
167 matches!(lower.as_str(), "describe" | "it" | "test")
169 || lower.starts_with("test")
170 || lower.starts_with("spec")
171 }
172 LangId::Python => {
173 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
175 }
176 LangId::Rust => {
177 lower.starts_with("test_")
179 }
180 LangId::Go => {
181 name.starts_with("Test")
183 }
184 LangId::Markdown => false,
185 }
186}
187
188#[derive(Debug, Clone, Serialize)]
194pub struct TraceHop {
195 pub symbol: String,
197 pub file: String,
199 pub line: u32,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub signature: Option<String>,
204 pub is_entry_point: bool,
206}
207
208#[derive(Debug, Clone, Serialize)]
210pub struct TracePath {
211 pub hops: Vec<TraceHop>,
213}
214
215#[derive(Debug, Clone, Serialize)]
217pub struct TraceToResult {
218 pub target_symbol: String,
220 pub target_file: String,
222 pub paths: Vec<TracePath>,
224 pub total_paths: usize,
226 pub entry_points_found: usize,
228 pub max_depth_reached: bool,
230 pub truncated_paths: usize,
232}
233
234#[derive(Debug, Clone, Serialize)]
240pub struct ImpactCaller {
241 pub caller_symbol: String,
243 pub caller_file: String,
245 pub line: u32,
247 #[serde(skip_serializing_if = "Option::is_none")]
249 pub signature: Option<String>,
250 pub is_entry_point: bool,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub call_expression: Option<String>,
255 pub parameters: Vec<String>,
257}
258
259#[derive(Debug, Clone, Serialize)]
261pub struct ImpactResult {
262 pub symbol: String,
264 pub file: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub signature: Option<String>,
269 pub parameters: Vec<String>,
271 pub total_affected: usize,
273 pub affected_files: usize,
275 pub callers: Vec<ImpactCaller>,
277}
278
279#[derive(Debug, Clone, Serialize)]
285pub struct DataFlowHop {
286 pub file: String,
288 pub symbol: String,
290 pub variable: String,
292 pub line: u32,
294 pub flow_type: String,
296 pub approximate: bool,
298}
299
300#[derive(Debug, Clone, Serialize)]
303pub struct TraceDataResult {
304 pub expression: String,
306 pub origin_file: String,
308 pub origin_symbol: String,
310 pub hops: Vec<DataFlowHop>,
312 pub depth_limited: bool,
314}
315
316pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
322 let start = match signature.find('(') {
324 Some(i) => i + 1,
325 None => return Vec::new(),
326 };
327 let end = match signature[start..].find(')') {
328 Some(i) => start + i,
329 None => return Vec::new(),
330 };
331
332 let params_str = &signature[start..end].trim();
333 if params_str.is_empty() {
334 return Vec::new();
335 }
336
337 let parts = split_params(params_str);
339
340 let mut result = Vec::new();
341 for part in parts {
342 let trimmed = part.trim();
343 if trimmed.is_empty() {
344 continue;
345 }
346
347 match lang {
349 LangId::Rust => {
350 let normalized = trimmed.replace(' ', "");
351 if normalized == "self"
352 || normalized == "&self"
353 || normalized == "&mutself"
354 || normalized == "mutself"
355 {
356 continue;
357 }
358 }
359 LangId::Python => {
360 if trimmed == "self" || trimmed.starts_with("self:") {
361 continue;
362 }
363 }
364 _ => {}
365 }
366
367 let name = extract_param_name(trimmed, lang);
369 if !name.is_empty() {
370 result.push(name);
371 }
372 }
373
374 result
375}
376
377fn split_params(s: &str) -> Vec<String> {
379 let mut parts = Vec::new();
380 let mut current = String::new();
381 let mut depth = 0i32;
382
383 for ch in s.chars() {
384 match ch {
385 '<' | '[' | '{' | '(' => {
386 depth += 1;
387 current.push(ch);
388 }
389 '>' | ']' | '}' | ')' => {
390 depth -= 1;
391 current.push(ch);
392 }
393 ',' if depth == 0 => {
394 parts.push(current.clone());
395 current.clear();
396 }
397 _ => {
398 current.push(ch);
399 }
400 }
401 }
402 if !current.is_empty() {
403 parts.push(current);
404 }
405 parts
406}
407
408fn extract_param_name(param: &str, lang: LangId) -> String {
416 let trimmed = param.trim();
417
418 let working = if trimmed.starts_with("...") {
420 &trimmed[3..]
421 } else if trimmed.starts_with("**") {
422 &trimmed[2..]
423 } else if trimmed.starts_with('*') && lang == LangId::Python {
424 &trimmed[1..]
425 } else {
426 trimmed
427 };
428
429 let working = if lang == LangId::Rust && working.starts_with("mut ") {
431 &working[4..]
432 } else {
433 working
434 };
435
436 let name = working
439 .split(|c: char| c == ':' || c == '=')
440 .next()
441 .unwrap_or("")
442 .trim();
443
444 let name = name.trim_end_matches('?');
446
447 if lang == LangId::Go && !name.contains(' ') {
449 return name.to_string();
450 }
451 if lang == LangId::Go {
452 return name.split_whitespace().next().unwrap_or("").to_string();
453 }
454
455 name.to_string()
456}
457
458pub struct CallGraph {
467 data: HashMap<PathBuf, FileCallData>,
469 project_root: PathBuf,
471 project_files: Option<Vec<PathBuf>>,
473 reverse_index: Option<HashMap<(PathBuf, String), Vec<CallerSite>>>,
476}
477
478impl CallGraph {
479 pub fn new(project_root: PathBuf) -> Self {
481 Self {
482 data: HashMap::new(),
483 project_root,
484 project_files: None,
485 reverse_index: None,
486 }
487 }
488
489 pub fn project_root(&self) -> &Path {
491 &self.project_root
492 }
493
494 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
496 let canon = self.canonicalize(path)?;
497
498 if !self.data.contains_key(&canon) {
499 let file_data = build_file_data(&canon)?;
500 self.data.insert(canon.clone(), file_data);
501 }
502
503 Ok(&self.data[&canon])
504 }
505
506 pub fn resolve_cross_file_edge(
511 &mut self,
512 full_callee: &str,
513 short_name: &str,
514 caller_file: &Path,
515 import_block: &ImportBlock,
516 ) -> EdgeResolution {
517 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
524
525 if full_callee.contains('.') {
527 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
528 if parts.len() == 2 {
529 let namespace = parts[0];
530 let member = parts[1];
531
532 for imp in &import_block.imports {
533 if imp.namespace_import.as_deref() == Some(namespace) {
534 if let Some(resolved_path) =
535 resolve_module_path(caller_dir, &imp.module_path)
536 {
537 return EdgeResolution::Resolved {
538 file: resolved_path,
539 symbol: member.to_string(),
540 };
541 }
542 }
543 }
544 }
545 }
546
547 for imp in &import_block.imports {
549 if imp.names.contains(&short_name.to_string()) {
551 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
552 return EdgeResolution::Resolved {
554 file: resolved_path,
555 symbol: short_name.to_string(),
556 };
557 }
558 }
559
560 if imp.default_import.as_deref() == Some(short_name) {
562 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
563 return EdgeResolution::Resolved {
564 file: resolved_path,
565 symbol: "default".to_string(),
566 };
567 }
568 }
569 }
570
571 if let Some((original_name, resolved_path)) =
576 resolve_aliased_import(short_name, import_block, caller_dir)
577 {
578 return EdgeResolution::Resolved {
579 file: resolved_path,
580 symbol: original_name,
581 };
582 }
583
584 for imp in &import_block.imports {
587 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
588 if resolved_path.is_dir() {
590 if let Some(index_path) = find_index_file(&resolved_path) {
591 if self.file_exports_symbol(&index_path, short_name) {
593 return EdgeResolution::Resolved {
594 file: index_path,
595 symbol: short_name.to_string(),
596 };
597 }
598 }
599 } else if self.file_exports_symbol(&resolved_path, short_name) {
600 return EdgeResolution::Resolved {
601 file: resolved_path,
602 symbol: short_name.to_string(),
603 };
604 }
605 }
606 }
607
608 EdgeResolution::Unresolved {
609 callee_name: short_name.to_string(),
610 }
611 }
612
613 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
615 match self.build_file(path) {
616 Ok(data) => data.exported_symbols.contains(&symbol_name.to_string()),
617 Err(_) => false,
618 }
619 }
620
621 pub fn forward_tree(
626 &mut self,
627 file: &Path,
628 symbol: &str,
629 max_depth: usize,
630 ) -> Result<CallTreeNode, AftError> {
631 let mut visited = HashSet::new();
632 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
633 }
634
635 fn forward_tree_inner(
636 &mut self,
637 file: &Path,
638 symbol: &str,
639 max_depth: usize,
640 current_depth: usize,
641 visited: &mut HashSet<(PathBuf, String)>,
642 ) -> Result<CallTreeNode, AftError> {
643 let canon = self.canonicalize(file)?;
644 let visit_key = (canon.clone(), symbol.to_string());
645
646 if visited.contains(&visit_key) {
648 return Ok(CallTreeNode {
649 name: symbol.to_string(),
650 file: self.relative_path(&canon),
651 line: 0,
652 signature: None,
653 resolved: true,
654 children: vec![], });
656 }
657
658 visited.insert(visit_key.clone());
659
660 let file_data = build_file_data(&canon)?;
662 let import_block = file_data.import_block.clone();
663 let _lang = file_data.lang;
664
665 let call_sites = file_data
667 .calls_by_symbol
668 .get(symbol)
669 .cloned()
670 .unwrap_or_default();
671
672 let (sym_line, sym_signature) = get_symbol_meta(&canon, symbol);
674
675 self.data.insert(canon.clone(), file_data);
677
678 let mut children = Vec::new();
680
681 if current_depth < max_depth {
682 for call_site in &call_sites {
683 let edge = self.resolve_cross_file_edge(
684 &call_site.full_callee,
685 &call_site.callee_name,
686 &canon,
687 &import_block,
688 );
689
690 match edge {
691 EdgeResolution::Resolved {
692 file: ref target_file,
693 ref symbol,
694 } => {
695 match self.forward_tree_inner(
696 target_file,
697 symbol,
698 max_depth,
699 current_depth + 1,
700 visited,
701 ) {
702 Ok(child) => children.push(child),
703 Err(_) => {
704 children.push(CallTreeNode {
706 name: call_site.callee_name.clone(),
707 file: self.relative_path(target_file),
708 line: call_site.line,
709 signature: None,
710 resolved: false,
711 children: vec![],
712 });
713 }
714 }
715 }
716 EdgeResolution::Unresolved { callee_name } => {
717 children.push(CallTreeNode {
718 name: callee_name,
719 file: self.relative_path(&canon),
720 line: call_site.line,
721 signature: None,
722 resolved: false,
723 children: vec![],
724 });
725 }
726 }
727 }
728 }
729
730 visited.remove(&visit_key);
731
732 Ok(CallTreeNode {
733 name: symbol.to_string(),
734 file: self.relative_path(&canon),
735 line: sym_line,
736 signature: sym_signature,
737 resolved: true,
738 children,
739 })
740 }
741
742 pub fn project_files(&mut self) -> &[PathBuf] {
744 if self.project_files.is_none() {
745 self.project_files = Some(walk_project_files(&self.project_root).collect());
746 }
747 self.project_files.as_ref().unwrap()
748 }
749
750 fn build_reverse_index(&mut self) {
756 if self.project_files.is_none() {
758 self.project_files = Some(walk_project_files(&self.project_root).collect());
759 }
760 let all_files: Vec<PathBuf> = self.project_files.as_ref().unwrap().clone();
761
762 for f in &all_files {
764 if !self.data.contains_key(f) {
765 if let Ok(fd) = build_file_data(f) {
766 self.data.insert(f.clone(), fd);
767 }
768 }
769 }
770
771 let mut reverse: HashMap<(PathBuf, String), Vec<CallerSite>> = HashMap::new();
773
774 for caller_file in &all_files {
775 let canon_caller =
777 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone());
778 let file_data = match self
779 .data
780 .get(caller_file)
781 .or_else(|| self.data.get(&canon_caller))
782 {
783 Some(d) => d.clone(),
784 None => continue,
785 };
786
787 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
788 for call_site in call_sites {
789 let edge = self.resolve_cross_file_edge(
790 &call_site.full_callee,
791 &call_site.callee_name,
792 caller_file,
793 &file_data.import_block,
794 );
795
796 match edge {
797 EdgeResolution::Resolved {
798 file: ref target_file,
799 ref symbol,
800 } => {
801 let key = (target_file.clone(), symbol.clone());
802 reverse.entry(key).or_default().push(CallerSite {
803 caller_file: canon_caller.clone(),
804 caller_symbol: symbol_name.clone(),
805 line: call_site.line,
806 col: 0,
807 resolved: true,
808 });
809 }
810 EdgeResolution::Unresolved { ref callee_name } => {
811 let key = (canon_caller.clone(), callee_name.clone());
814 reverse.entry(key).or_default().push(CallerSite {
815 caller_file: canon_caller.clone(),
816 caller_symbol: symbol_name.clone(),
817 line: call_site.line,
818 col: 0,
819 resolved: false,
820 });
821 }
822 }
823 }
824 }
825 }
826
827 self.reverse_index = Some(reverse);
828 }
829
830 pub fn callers_of(
836 &mut self,
837 file: &Path,
838 symbol: &str,
839 depth: usize,
840 ) -> Result<CallersResult, AftError> {
841 let canon = self.canonicalize(file)?;
842
843 self.build_file(&canon)?;
845
846 if self.reverse_index.is_none() {
848 self.build_reverse_index();
849 }
850
851 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
852 let effective_depth = if depth == 0 { 1 } else { depth };
853
854 let mut visited = HashSet::new();
855 let mut all_sites: Vec<CallerSite> = Vec::new();
856 self.collect_callers_recursive(
857 &canon,
858 symbol,
859 effective_depth,
860 0,
861 &mut visited,
862 &mut all_sites,
863 );
864
865 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
867 for site in &all_sites {
868 groups_map
869 .entry(site.caller_file.clone())
870 .or_default()
871 .push(CallerEntry {
872 symbol: site.caller_symbol.clone(),
873 line: site.line,
874 });
875 }
876
877 let total_callers = all_sites.len();
878 let mut callers: Vec<CallerGroup> = groups_map
879 .into_iter()
880 .map(|(file_path, entries)| CallerGroup {
881 file: self.relative_path(&file_path),
882 callers: entries,
883 })
884 .collect();
885
886 callers.sort_by(|a, b| a.file.cmp(&b.file));
888
889 Ok(CallersResult {
890 symbol: symbol.to_string(),
891 file: self.relative_path(&canon),
892 callers,
893 total_callers,
894 scanned_files,
895 })
896 }
897
898 pub fn trace_to(
904 &mut self,
905 file: &Path,
906 symbol: &str,
907 max_depth: usize,
908 ) -> Result<TraceToResult, AftError> {
909 let canon = self.canonicalize(file)?;
910
911 self.build_file(&canon)?;
913
914 if self.reverse_index.is_none() {
916 self.build_reverse_index();
917 }
918
919 let target_rel = self.relative_path(&canon);
920 let effective_max = if max_depth == 0 { 10 } else { max_depth };
921
922 let (target_line, target_sig) = get_symbol_meta(&canon, symbol);
924
925 let target_is_entry = self
927 .lookup_file_data(&canon)
928 .and_then(|fd| {
929 let meta = fd.symbol_metadata.get(symbol)?;
930 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
931 })
932 .unwrap_or(false);
933
934 type PathElem = (PathBuf, String, u32, Option<String>);
937 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
938 let mut max_depth_reached = false;
939 let mut truncated_paths: usize = 0;
940
941 let initial: Vec<PathElem> =
943 vec![(canon.clone(), symbol.to_string(), target_line, target_sig)];
944
945 if target_is_entry {
947 complete_paths.push(initial.clone());
948 }
949
950 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
952
953 while let Some((path, depth)) = queue.pop() {
954 if depth >= effective_max {
955 max_depth_reached = true;
956 continue;
957 }
958
959 let (ref current_file, ref current_symbol, _, _) = path.last().unwrap().clone();
960
961 let path_visited: HashSet<(PathBuf, String)> = path
963 .iter()
964 .map(|(f, s, _, _)| (f.clone(), s.clone()))
965 .collect();
966
967 let reverse_index = self.reverse_index.as_ref().unwrap();
969 let lookup_key = (current_file.clone(), current_symbol.clone());
970 let callers = match reverse_index.get(&lookup_key) {
971 Some(sites) => sites.clone(),
972 None => {
973 if path.len() > 1 {
976 truncated_paths += 1;
979 }
980 continue;
981 }
982 };
983
984 let mut has_new_path = false;
985 for site in &callers {
986 let caller_key = (site.caller_file.clone(), site.caller_symbol.clone());
987
988 if path_visited.contains(&caller_key) {
990 continue;
991 }
992
993 has_new_path = true;
994
995 let (caller_line, caller_sig) =
997 get_symbol_meta(&site.caller_file, &site.caller_symbol);
998
999 let mut new_path = path.clone();
1000 new_path.push((
1001 site.caller_file.clone(),
1002 site.caller_symbol.clone(),
1003 caller_line,
1004 caller_sig,
1005 ));
1006
1007 let caller_is_entry = self
1011 .lookup_file_data(&site.caller_file)
1012 .and_then(|fd| {
1013 let meta = fd.symbol_metadata.get(&site.caller_symbol)?;
1014 Some(is_entry_point(
1015 &site.caller_symbol,
1016 &meta.kind,
1017 meta.exported,
1018 fd.lang,
1019 ))
1020 })
1021 .unwrap_or(false);
1022
1023 if caller_is_entry {
1024 complete_paths.push(new_path.clone());
1025 }
1026 queue.push((new_path, depth + 1));
1029 }
1030
1031 if !has_new_path && path.len() > 1 {
1033 truncated_paths += 1;
1034 }
1035 }
1036
1037 let mut paths: Vec<TracePath> = complete_paths
1040 .into_iter()
1041 .map(|mut elems| {
1042 elems.reverse();
1043 let hops: Vec<TraceHop> = elems
1044 .iter()
1045 .enumerate()
1046 .map(|(i, (file_path, sym, line, sig))| {
1047 let is_ep = if i == 0 {
1048 self.lookup_file_data(file_path)
1050 .and_then(|fd| {
1051 let meta = fd.symbol_metadata.get(sym)?;
1052 Some(is_entry_point(sym, &meta.kind, meta.exported, fd.lang))
1053 })
1054 .unwrap_or(false)
1055 } else {
1056 false
1057 };
1058 TraceHop {
1059 symbol: sym.clone(),
1060 file: self.relative_path(file_path),
1061 line: *line,
1062 signature: sig.clone(),
1063 is_entry_point: is_ep,
1064 }
1065 })
1066 .collect();
1067 TracePath { hops }
1068 })
1069 .collect();
1070
1071 paths.sort_by(|a, b| {
1073 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1074 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1075 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1076 });
1077
1078 let mut entry_point_names: HashSet<String> = HashSet::new();
1080 for p in &paths {
1081 if let Some(first) = p.hops.first() {
1082 if first.is_entry_point {
1083 entry_point_names.insert(first.symbol.clone());
1084 }
1085 }
1086 }
1087
1088 let total_paths = paths.len();
1089 let entry_points_found = entry_point_names.len();
1090
1091 Ok(TraceToResult {
1092 target_symbol: symbol.to_string(),
1093 target_file: target_rel,
1094 paths,
1095 total_paths,
1096 entry_points_found,
1097 max_depth_reached,
1098 truncated_paths,
1099 })
1100 }
1101
1102 pub fn impact(
1108 &mut self,
1109 file: &Path,
1110 symbol: &str,
1111 depth: usize,
1112 ) -> Result<ImpactResult, AftError> {
1113 let canon = self.canonicalize(file)?;
1114
1115 self.build_file(&canon)?;
1117
1118 if self.reverse_index.is_none() {
1120 self.build_reverse_index();
1121 }
1122
1123 let effective_depth = if depth == 0 { 1 } else { depth };
1124
1125 let (target_signature, target_parameters, target_lang) = {
1127 let file_data = self.data.get(&canon).expect("file just built");
1128 let meta = file_data.symbol_metadata.get(symbol);
1129 let sig = meta.and_then(|m| m.signature.clone());
1130 let lang = file_data.lang;
1131 let params = sig
1132 .as_deref()
1133 .map(|s| extract_parameters(s, lang))
1134 .unwrap_or_default();
1135 (sig, params, lang)
1136 };
1137
1138 let mut visited = HashSet::new();
1140 let mut all_sites: Vec<CallerSite> = Vec::new();
1141 self.collect_callers_recursive(
1142 &canon,
1143 symbol,
1144 effective_depth,
1145 0,
1146 &mut visited,
1147 &mut all_sites,
1148 );
1149
1150 let mut seen = HashSet::new();
1152 all_sites.retain(|s| seen.insert((s.caller_file.clone(), s.caller_symbol.clone(), s.line)));
1153
1154 let mut callers = Vec::new();
1156 let mut affected_file_set = HashSet::new();
1157
1158 for site in &all_sites {
1159 let caller_canon = std::fs::canonicalize(&site.caller_file)
1161 .unwrap_or_else(|_| site.caller_file.clone());
1162 let _ = self.build_file(&caller_canon);
1163
1164 let (sig, is_ep, params, _lang) = {
1165 if let Some(fd) = self.data.get(&caller_canon) {
1166 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1167 let sig = meta.and_then(|m| m.signature.clone());
1168 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1169 let exported = meta.map(|m| m.exported).unwrap_or(false);
1170 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1171 let lang = fd.lang;
1172 let params = sig
1173 .as_deref()
1174 .map(|s| extract_parameters(s, lang))
1175 .unwrap_or_default();
1176 (sig, is_ep, params, lang)
1177 } else {
1178 (None, false, Vec::new(), target_lang)
1179 }
1180 };
1181
1182 let call_expression = self.read_source_line(&caller_canon, site.line);
1184
1185 let rel_file = self.relative_path(&site.caller_file);
1186 affected_file_set.insert(rel_file.clone());
1187
1188 callers.push(ImpactCaller {
1189 caller_symbol: site.caller_symbol.clone(),
1190 caller_file: rel_file,
1191 line: site.line,
1192 signature: sig,
1193 is_entry_point: is_ep,
1194 call_expression,
1195 parameters: params,
1196 });
1197 }
1198
1199 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1201
1202 let total_affected = callers.len();
1203 let affected_files = affected_file_set.len();
1204
1205 Ok(ImpactResult {
1206 symbol: symbol.to_string(),
1207 file: self.relative_path(&canon),
1208 signature: target_signature,
1209 parameters: target_parameters,
1210 total_affected,
1211 affected_files,
1212 callers,
1213 })
1214 }
1215
1216 pub fn trace_data(
1227 &mut self,
1228 file: &Path,
1229 symbol: &str,
1230 expression: &str,
1231 max_depth: usize,
1232 ) -> Result<TraceDataResult, AftError> {
1233 let canon = self.canonicalize(file)?;
1234 let rel_file = self.relative_path(&canon);
1235
1236 self.build_file(&canon)?;
1238
1239 {
1241 let fd = self.data.get(&canon).expect("file just built");
1242 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1243 || fd.exported_symbols.contains(&symbol.to_string())
1244 || fd.symbol_metadata.contains_key(symbol);
1245 if !has_symbol {
1246 return Err(AftError::InvalidRequest {
1247 message: format!(
1248 "trace_data: symbol '{}' not found in {}",
1249 symbol,
1250 file.display()
1251 ),
1252 });
1253 }
1254 }
1255
1256 let mut hops = Vec::new();
1257 let mut depth_limited = false;
1258
1259 self.trace_data_inner(
1260 &canon,
1261 symbol,
1262 expression,
1263 max_depth,
1264 0,
1265 &mut hops,
1266 &mut depth_limited,
1267 &mut HashSet::new(),
1268 );
1269
1270 Ok(TraceDataResult {
1271 expression: expression.to_string(),
1272 origin_file: rel_file,
1273 origin_symbol: symbol.to_string(),
1274 hops,
1275 depth_limited,
1276 })
1277 }
1278
1279 fn trace_data_inner(
1281 &mut self,
1282 file: &Path,
1283 symbol: &str,
1284 tracking_name: &str,
1285 max_depth: usize,
1286 current_depth: usize,
1287 hops: &mut Vec<DataFlowHop>,
1288 depth_limited: &mut bool,
1289 visited: &mut HashSet<(PathBuf, String, String)>,
1290 ) {
1291 let visit_key = (
1292 file.to_path_buf(),
1293 symbol.to_string(),
1294 tracking_name.to_string(),
1295 );
1296 if visited.contains(&visit_key) {
1297 return; }
1299 visited.insert(visit_key);
1300
1301 let source = match std::fs::read_to_string(file) {
1303 Ok(s) => s,
1304 Err(_) => return,
1305 };
1306
1307 let lang = match detect_language(file) {
1308 Some(l) => l,
1309 None => return,
1310 };
1311
1312 let grammar = grammar_for(lang);
1313 let mut parser = Parser::new();
1314 if parser.set_language(&grammar).is_err() {
1315 return;
1316 }
1317 let tree = match parser.parse(&source, None) {
1318 Some(t) => t,
1319 None => return,
1320 };
1321
1322 let symbols = list_symbols_from_tree(&source, &tree, lang, file);
1324 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1325 Some(s) => s,
1326 None => return,
1327 };
1328
1329 let body_start = line_col_to_byte(&source, sym_info.start_line, sym_info.start_col);
1330 let body_end = line_col_to_byte(&source, sym_info.end_line, sym_info.end_col);
1331
1332 let root = tree.root_node();
1333
1334 let body_node = match find_node_covering_range(root, body_start, body_end) {
1336 Some(n) => n,
1337 None => return,
1338 };
1339
1340 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1342 let rel_file = self.relative_path(file);
1343
1344 self.walk_for_data_flow(
1346 body_node,
1347 &source,
1348 &mut tracked_names,
1349 file,
1350 symbol,
1351 &rel_file,
1352 lang,
1353 max_depth,
1354 current_depth,
1355 hops,
1356 depth_limited,
1357 visited,
1358 );
1359 }
1360
1361 #[allow(clippy::too_many_arguments)]
1364 fn walk_for_data_flow(
1365 &mut self,
1366 node: tree_sitter::Node,
1367 source: &str,
1368 tracked_names: &mut Vec<String>,
1369 file: &Path,
1370 symbol: &str,
1371 rel_file: &str,
1372 lang: LangId,
1373 max_depth: usize,
1374 current_depth: usize,
1375 hops: &mut Vec<DataFlowHop>,
1376 depth_limited: &mut bool,
1377 visited: &mut HashSet<(PathBuf, String, String)>,
1378 ) {
1379 let kind = node.kind();
1380
1381 let is_var_decl = matches!(
1383 kind,
1384 "variable_declarator"
1385 | "assignment_expression"
1386 | "augmented_assignment_expression"
1387 | "assignment"
1388 | "let_declaration"
1389 | "short_var_declaration"
1390 );
1391
1392 if is_var_decl {
1393 if let Some((new_name, init_text, line, is_approx)) =
1394 self.extract_assignment_info(node, source, lang, tracked_names)
1395 {
1396 if !is_approx {
1398 hops.push(DataFlowHop {
1399 file: rel_file.to_string(),
1400 symbol: symbol.to_string(),
1401 variable: new_name.clone(),
1402 line,
1403 flow_type: "assignment".to_string(),
1404 approximate: false,
1405 });
1406 tracked_names.push(new_name);
1407 } else {
1408 hops.push(DataFlowHop {
1410 file: rel_file.to_string(),
1411 symbol: symbol.to_string(),
1412 variable: init_text,
1413 line,
1414 flow_type: "assignment".to_string(),
1415 approximate: true,
1416 });
1417 return;
1419 }
1420 }
1421 }
1422
1423 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1425 self.check_call_for_data_flow(
1426 node,
1427 source,
1428 tracked_names,
1429 file,
1430 symbol,
1431 rel_file,
1432 lang,
1433 max_depth,
1434 current_depth,
1435 hops,
1436 depth_limited,
1437 visited,
1438 );
1439 }
1440
1441 let mut cursor = node.walk();
1443 if cursor.goto_first_child() {
1444 loop {
1445 let child = cursor.node();
1446 self.walk_for_data_flow(
1448 child,
1449 source,
1450 tracked_names,
1451 file,
1452 symbol,
1453 rel_file,
1454 lang,
1455 max_depth,
1456 current_depth,
1457 hops,
1458 depth_limited,
1459 visited,
1460 );
1461 if !cursor.goto_next_sibling() {
1462 break;
1463 }
1464 }
1465 }
1466 }
1467
1468 fn extract_assignment_info(
1471 &self,
1472 node: tree_sitter::Node,
1473 source: &str,
1474 _lang: LangId,
1475 tracked_names: &[String],
1476 ) -> Option<(String, String, u32, bool)> {
1477 let kind = node.kind();
1478 let line = node.start_position().row as u32;
1479
1480 match kind {
1481 "variable_declarator" => {
1482 let name_node = node.child_by_field_name("name")?;
1484 let value_node = node.child_by_field_name("value")?;
1485 let name_text = node_text(name_node, source);
1486 let value_text = node_text(value_node, source);
1487
1488 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1490 if tracked_names.iter().any(|t| value_text.contains(t)) {
1492 return Some((name_text.clone(), name_text, line, true));
1493 }
1494 return None;
1495 }
1496
1497 if tracked_names.iter().any(|t| {
1499 value_text == *t
1500 || value_text.starts_with(&format!("{}.", t))
1501 || value_text.starts_with(&format!("{}[", t))
1502 }) {
1503 return Some((name_text, value_text, line, false));
1504 }
1505 None
1506 }
1507 "assignment_expression" | "augmented_assignment_expression" => {
1508 let left = node.child_by_field_name("left")?;
1510 let right = node.child_by_field_name("right")?;
1511 let left_text = node_text(left, source);
1512 let right_text = node_text(right, source);
1513
1514 if tracked_names.iter().any(|t| right_text == *t) {
1515 return Some((left_text, right_text, line, false));
1516 }
1517 None
1518 }
1519 "assignment" => {
1520 let left = node.child_by_field_name("left")?;
1522 let right = node.child_by_field_name("right")?;
1523 let left_text = node_text(left, source);
1524 let right_text = node_text(right, source);
1525
1526 if tracked_names.iter().any(|t| right_text == *t) {
1527 return Some((left_text, right_text, line, false));
1528 }
1529 None
1530 }
1531 "let_declaration" | "short_var_declaration" => {
1532 let left = node
1534 .child_by_field_name("pattern")
1535 .or_else(|| node.child_by_field_name("left"))?;
1536 let right = node
1537 .child_by_field_name("value")
1538 .or_else(|| node.child_by_field_name("right"))?;
1539 let left_text = node_text(left, source);
1540 let right_text = node_text(right, source);
1541
1542 if tracked_names.iter().any(|t| right_text == *t) {
1543 return Some((left_text, right_text, line, false));
1544 }
1545 None
1546 }
1547 _ => None,
1548 }
1549 }
1550
1551 #[allow(clippy::too_many_arguments)]
1554 fn check_call_for_data_flow(
1555 &mut self,
1556 node: tree_sitter::Node,
1557 source: &str,
1558 tracked_names: &[String],
1559 file: &Path,
1560 _symbol: &str,
1561 rel_file: &str,
1562 _lang: LangId,
1563 max_depth: usize,
1564 current_depth: usize,
1565 hops: &mut Vec<DataFlowHop>,
1566 depth_limited: &mut bool,
1567 visited: &mut HashSet<(PathBuf, String, String)>,
1568 ) {
1569 let args_node = find_child_by_kind(node, "arguments")
1571 .or_else(|| find_child_by_kind(node, "argument_list"));
1572
1573 let args_node = match args_node {
1574 Some(n) => n,
1575 None => return,
1576 };
1577
1578 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1581
1582 let mut cursor = args_node.walk();
1583 if cursor.goto_first_child() {
1584 loop {
1585 let child = cursor.node();
1586 let child_kind = child.kind();
1587
1588 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1590 if !cursor.goto_next_sibling() {
1591 break;
1592 }
1593 continue;
1594 }
1595
1596 let arg_text = node_text(child, source);
1597
1598 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1600 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1601 hops.push(DataFlowHop {
1602 file: rel_file.to_string(),
1603 symbol: _symbol.to_string(),
1604 variable: arg_text,
1605 line: child.start_position().row as u32,
1606 flow_type: "parameter".to_string(),
1607 approximate: true,
1608 });
1609 }
1610 if !cursor.goto_next_sibling() {
1611 break;
1612 }
1613 arg_idx += 1;
1614 continue;
1615 }
1616
1617 if tracked_names.iter().any(|t| arg_text == *t) {
1618 arg_positions.push((arg_idx, arg_text));
1619 }
1620
1621 arg_idx += 1;
1622 if !cursor.goto_next_sibling() {
1623 break;
1624 }
1625 }
1626 }
1627
1628 if arg_positions.is_empty() {
1629 return;
1630 }
1631
1632 let (full_callee, short_callee) = extract_callee_names(node, source);
1634 let full_callee = match full_callee {
1635 Some(f) => f,
1636 None => return,
1637 };
1638 let short_callee = match short_callee {
1639 Some(s) => s,
1640 None => return,
1641 };
1642
1643 let import_block = {
1645 match self.data.get(file) {
1646 Some(fd) => fd.import_block.clone(),
1647 None => return,
1648 }
1649 };
1650
1651 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1652
1653 match edge {
1654 EdgeResolution::Resolved {
1655 file: target_file,
1656 symbol: target_symbol,
1657 } => {
1658 if current_depth + 1 > max_depth {
1659 *depth_limited = true;
1660 return;
1661 }
1662
1663 let _ = self.build_file(&target_file);
1665 let (params, _target_lang) = {
1666 match self.data.get(&target_file) {
1667 Some(fd) => {
1668 let meta = fd.symbol_metadata.get(&target_symbol);
1669 let sig = meta.and_then(|m| m.signature.clone());
1670 let params = sig
1671 .as_deref()
1672 .map(|s| extract_parameters(s, fd.lang))
1673 .unwrap_or_default();
1674 (params, fd.lang)
1675 }
1676 None => return,
1677 }
1678 };
1679
1680 let target_rel = self.relative_path(&target_file);
1681
1682 for (pos, _tracked) in &arg_positions {
1683 if let Some(param_name) = params.get(*pos) {
1684 hops.push(DataFlowHop {
1686 file: target_rel.clone(),
1687 symbol: target_symbol.clone(),
1688 variable: param_name.clone(),
1689 line: get_symbol_meta(&target_file, &target_symbol).0,
1690 flow_type: "parameter".to_string(),
1691 approximate: false,
1692 });
1693
1694 self.trace_data_inner(
1696 &target_file.clone(),
1697 &target_symbol.clone(),
1698 param_name,
1699 max_depth,
1700 current_depth + 1,
1701 hops,
1702 depth_limited,
1703 visited,
1704 );
1705 }
1706 }
1707 }
1708 EdgeResolution::Unresolved { callee_name } => {
1709 let has_local = self
1711 .data
1712 .get(file)
1713 .map(|fd| {
1714 fd.calls_by_symbol.contains_key(&callee_name)
1715 || fd.symbol_metadata.contains_key(&callee_name)
1716 })
1717 .unwrap_or(false);
1718
1719 if has_local {
1720 let (params, _target_lang) = {
1722 let fd = self.data.get(file).unwrap();
1723 let meta = fd.symbol_metadata.get(&callee_name);
1724 let sig = meta.and_then(|m| m.signature.clone());
1725 let params = sig
1726 .as_deref()
1727 .map(|s| extract_parameters(s, fd.lang))
1728 .unwrap_or_default();
1729 (params, fd.lang)
1730 };
1731
1732 let file_rel = self.relative_path(file);
1733
1734 for (pos, _tracked) in &arg_positions {
1735 if let Some(param_name) = params.get(*pos) {
1736 hops.push(DataFlowHop {
1737 file: file_rel.clone(),
1738 symbol: callee_name.clone(),
1739 variable: param_name.clone(),
1740 line: get_symbol_meta(file, &callee_name).0,
1741 flow_type: "parameter".to_string(),
1742 approximate: false,
1743 });
1744
1745 self.trace_data_inner(
1747 file,
1748 &callee_name.clone(),
1749 param_name,
1750 max_depth,
1751 current_depth + 1,
1752 hops,
1753 depth_limited,
1754 visited,
1755 );
1756 }
1757 }
1758 } else {
1759 for (_pos, tracked) in &arg_positions {
1761 hops.push(DataFlowHop {
1762 file: self.relative_path(file),
1763 symbol: callee_name.clone(),
1764 variable: tracked.clone(),
1765 line: node.start_position().row as u32,
1766 flow_type: "parameter".to_string(),
1767 approximate: true,
1768 });
1769 }
1770 }
1771 }
1772 }
1773 }
1774
1775 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1777 let content = std::fs::read_to_string(path).ok()?;
1778 content
1779 .lines()
1780 .nth(line as usize)
1781 .map(|l| l.trim().to_string())
1782 }
1783
1784 fn collect_callers_recursive(
1786 &self,
1787 file: &Path,
1788 symbol: &str,
1789 max_depth: usize,
1790 current_depth: usize,
1791 visited: &mut HashSet<(PathBuf, String)>,
1792 result: &mut Vec<CallerSite>,
1793 ) {
1794 if current_depth >= max_depth {
1795 return;
1796 }
1797
1798 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1800 let key = (canon.clone(), symbol.to_string());
1801 if visited.contains(&key) {
1802 return; }
1804 visited.insert(key);
1805
1806 let reverse_index = match &self.reverse_index {
1807 Some(ri) => ri,
1808 None => return,
1809 };
1810
1811 let lookup_key = (canon, symbol.to_string());
1812 if let Some(sites) = reverse_index.get(&lookup_key) {
1813 for site in sites {
1814 result.push(site.clone());
1815 if current_depth + 1 < max_depth {
1817 self.collect_callers_recursive(
1818 &site.caller_file,
1819 &site.caller_symbol,
1820 max_depth,
1821 current_depth + 1,
1822 visited,
1823 result,
1824 );
1825 }
1826 }
1827 }
1828 }
1829
1830 pub fn invalidate_file(&mut self, path: &Path) {
1835 self.data.remove(path);
1837 if let Ok(canon) = self.canonicalize(path) {
1838 self.data.remove(&canon);
1839 }
1840 self.reverse_index = None;
1842 self.project_files = None;
1844 }
1845
1846 fn relative_path(&self, path: &Path) -> String {
1849 path.strip_prefix(&self.project_root)
1850 .unwrap_or(path)
1851 .display()
1852 .to_string()
1853 }
1854
1855 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1857 let full_path = if path.is_relative() {
1859 self.project_root.join(path)
1860 } else {
1861 path.to_path_buf()
1862 };
1863
1864 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1866 }
1867
1868 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
1872 if let Some(fd) = self.data.get(path) {
1873 return Some(fd);
1874 }
1875 let canon = std::fs::canonicalize(path).ok()?;
1877 self.data.get(&canon).or_else(|| {
1878 self.data.iter().find_map(|(k, v)| {
1880 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
1881 Some(v)
1882 } else {
1883 None
1884 }
1885 })
1886 })
1887 }
1888}
1889
1890fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1896 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1897 message: format!("unsupported file for call graph: {}", path.display()),
1898 })?;
1899
1900 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1901 path: format!("{}: {}", path.display(), e),
1902 })?;
1903
1904 let grammar = grammar_for(lang);
1905 let mut parser = Parser::new();
1906 parser
1907 .set_language(&grammar)
1908 .map_err(|e| AftError::ParseError {
1909 message: format!("grammar init failed for {:?}: {}", lang, e),
1910 })?;
1911
1912 let tree = parser
1913 .parse(&source, None)
1914 .ok_or_else(|| AftError::ParseError {
1915 message: format!("parse failed for {}", path.display()),
1916 })?;
1917
1918 let import_block = imports::parse_imports(&source, &tree, lang);
1920
1921 let symbols = list_symbols_from_tree(&source, &tree, lang, path);
1923
1924 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
1926 let root = tree.root_node();
1927
1928 for sym in &symbols {
1929 let byte_start = line_col_to_byte(&source, sym.start_line, sym.start_col);
1930 let byte_end = line_col_to_byte(&source, sym.end_line, sym.end_col);
1931
1932 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1933
1934 let sites: Vec<CallSite> = raw_calls
1935 .into_iter()
1936 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
1938 callee_name: short,
1939 full_callee: full,
1940 line,
1941 byte_start,
1942 byte_end,
1943 })
1944 .collect();
1945
1946 if !sites.is_empty() {
1947 calls_by_symbol.insert(sym.name.clone(), sites);
1948 }
1949 }
1950
1951 let exported_symbols: Vec<String> = symbols
1953 .iter()
1954 .filter(|s| s.exported)
1955 .map(|s| s.name.clone())
1956 .collect();
1957
1958 let symbol_metadata: HashMap<String, SymbolMeta> = symbols
1960 .iter()
1961 .map(|s| {
1962 (
1963 s.name.clone(),
1964 SymbolMeta {
1965 kind: s.kind.clone(),
1966 exported: s.exported,
1967 signature: s.signature.clone(),
1968 },
1969 )
1970 })
1971 .collect();
1972
1973 Ok(FileCallData {
1974 calls_by_symbol,
1975 exported_symbols,
1976 symbol_metadata,
1977 import_block,
1978 lang,
1979 })
1980}
1981
1982#[derive(Debug)]
1984#[allow(dead_code)]
1985struct SymbolInfo {
1986 name: String,
1987 kind: SymbolKind,
1988 start_line: u32,
1989 start_col: u32,
1990 end_line: u32,
1991 end_col: u32,
1992 exported: bool,
1993 signature: Option<String>,
1994}
1995
1996fn list_symbols_from_tree(
1999 _source: &str,
2000 _tree: &Tree,
2001 _lang: LangId,
2002 path: &Path,
2003) -> Vec<SymbolInfo> {
2004 let mut file_parser = crate::parser::FileParser::new();
2006 match file_parser.parse(path) {
2007 Ok(_) => {}
2008 Err(_) => return vec![],
2009 }
2010
2011 let provider = crate::parser::TreeSitterProvider::new();
2013 match provider.list_symbols(path) {
2014 Ok(symbols) => symbols
2015 .into_iter()
2016 .map(|s| SymbolInfo {
2017 name: s.name,
2018 kind: s.kind,
2019 start_line: s.range.start_line,
2020 start_col: s.range.start_col,
2021 end_line: s.range.end_line,
2022 end_col: s.range.end_col,
2023 exported: s.exported,
2024 signature: s.signature,
2025 })
2026 .collect(),
2027 Err(_) => vec![],
2028 }
2029}
2030
2031fn line_col_to_byte(source: &str, line: u32, col: u32) -> usize {
2033 let mut byte = 0;
2034 for (i, l) in source.lines().enumerate() {
2035 if i == line as usize {
2036 return byte + (col as usize).min(l.len());
2037 }
2038 byte += l.len() + 1;
2039 }
2040 source.len()
2041}
2042
2043fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2045 let provider = crate::parser::TreeSitterProvider::new();
2046 match provider.list_symbols(path) {
2047 Ok(symbols) => {
2048 for s in &symbols {
2049 if s.name == symbol_name {
2050 return (s.range.start_line, s.signature.clone());
2051 }
2052 }
2053 (0, None)
2054 }
2055 Err(_) => (0, None),
2056 }
2057}
2058
2059fn node_text(node: tree_sitter::Node, source: &str) -> String {
2065 source[node.start_byte()..node.end_byte()].to_string()
2066}
2067
2068fn find_node_covering_range(
2070 root: tree_sitter::Node,
2071 start: usize,
2072 end: usize,
2073) -> Option<tree_sitter::Node> {
2074 let mut best = None;
2075 let mut cursor = root.walk();
2076
2077 fn walk_covering<'a>(
2078 cursor: &mut tree_sitter::TreeCursor<'a>,
2079 start: usize,
2080 end: usize,
2081 best: &mut Option<tree_sitter::Node<'a>>,
2082 ) {
2083 let node = cursor.node();
2084 if node.start_byte() <= start && node.end_byte() >= end {
2085 *best = Some(node);
2086 if cursor.goto_first_child() {
2087 loop {
2088 walk_covering(cursor, start, end, best);
2089 if !cursor.goto_next_sibling() {
2090 break;
2091 }
2092 }
2093 cursor.goto_parent();
2094 }
2095 }
2096 }
2097
2098 walk_covering(&mut cursor, start, end, &mut best);
2099 best
2100}
2101
2102fn find_child_by_kind<'a>(
2104 node: tree_sitter::Node<'a>,
2105 kind: &str,
2106) -> Option<tree_sitter::Node<'a>> {
2107 let mut cursor = node.walk();
2108 if cursor.goto_first_child() {
2109 loop {
2110 if cursor.node().kind() == kind {
2111 return Some(cursor.node());
2112 }
2113 if !cursor.goto_next_sibling() {
2114 break;
2115 }
2116 }
2117 }
2118 None
2119}
2120
2121fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2123 let callee = match node.child_by_field_name("function") {
2125 Some(c) => c,
2126 None => return (None, None),
2127 };
2128
2129 let full = node_text(callee, source);
2130 let short = if full.contains('.') {
2131 full.rsplit('.').next().unwrap_or(&full).to_string()
2132 } else {
2133 full.clone()
2134 };
2135
2136 (Some(full), Some(short))
2137}
2138
2139fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2147 if !module_path.starts_with('.') {
2149 return None;
2150 }
2151
2152 let base = from_dir.join(module_path);
2153
2154 if base.is_file() {
2156 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2157 }
2158
2159 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2161 for ext in &extensions {
2162 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2163 if with_ext.is_file() {
2164 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2165 }
2166 }
2167
2168 if base.is_dir() {
2170 if let Some(index) = find_index_file(&base) {
2171 return Some(index);
2172 }
2173 }
2174
2175 None
2176}
2177
2178fn find_index_file(dir: &Path) -> Option<PathBuf> {
2180 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2181 for name in &candidates {
2182 let p = dir.join(name);
2183 if p.is_file() {
2184 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2185 }
2186 }
2187 None
2188}
2189
2190fn resolve_aliased_import(
2193 local_name: &str,
2194 import_block: &ImportBlock,
2195 caller_dir: &Path,
2196) -> Option<(String, PathBuf)> {
2197 for imp in &import_block.imports {
2198 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2201 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2202 return Some((original, resolved_path));
2203 }
2204 }
2205 }
2206 None
2207}
2208
2209fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2213 let search = format!(" as {}", local_name);
2216 if let Some(pos) = raw_import.find(&search) {
2217 let before = &raw_import[..pos];
2219 let original = before
2221 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2222 .find(|s| !s.is_empty())?;
2223 return Some(original.to_string());
2224 }
2225 None
2226}
2227
2228pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2236 use ignore::WalkBuilder;
2237
2238 let walker = WalkBuilder::new(root)
2239 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
2244 let name = entry.file_name().to_string_lossy();
2245 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2247 return !matches!(
2248 name.as_ref(),
2249 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2250 | ".tox" | "dist" | "build"
2251 );
2252 }
2253 true
2254 })
2255 .build();
2256
2257 walker
2258 .filter_map(|entry| entry.ok())
2259 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2260 .filter(|entry| detect_language(entry.path()).is_some())
2261 .map(|entry| entry.into_path())
2262}
2263
2264#[cfg(test)]
2269mod tests {
2270 use super::*;
2271 use std::fs;
2272 use tempfile::TempDir;
2273
2274 fn setup_ts_project() -> TempDir {
2276 let dir = TempDir::new().unwrap();
2277
2278 fs::write(
2280 dir.path().join("main.ts"),
2281 r#"import { helper, compute } from './utils';
2282import * as math from './math';
2283
2284export function main() {
2285 const a = helper(1);
2286 const b = compute(a, 2);
2287 const c = math.add(a, b);
2288 return c;
2289}
2290"#,
2291 )
2292 .unwrap();
2293
2294 fs::write(
2296 dir.path().join("utils.ts"),
2297 r#"import { double } from './helpers';
2298
2299export function helper(x: number): number {
2300 return double(x);
2301}
2302
2303export function compute(a: number, b: number): number {
2304 return a + b;
2305}
2306"#,
2307 )
2308 .unwrap();
2309
2310 fs::write(
2312 dir.path().join("helpers.ts"),
2313 r#"export function double(x: number): number {
2314 return x * 2;
2315}
2316
2317export function triple(x: number): number {
2318 return x * 3;
2319}
2320"#,
2321 )
2322 .unwrap();
2323
2324 fs::write(
2326 dir.path().join("math.ts"),
2327 r#"export function add(a: number, b: number): number {
2328 return a + b;
2329}
2330
2331export function subtract(a: number, b: number): number {
2332 return a - b;
2333}
2334"#,
2335 )
2336 .unwrap();
2337
2338 dir
2339 }
2340
2341 fn setup_alias_project() -> TempDir {
2343 let dir = TempDir::new().unwrap();
2344
2345 fs::write(
2346 dir.path().join("main.ts"),
2347 r#"import { helper as h } from './utils';
2348
2349export function main() {
2350 return h(42);
2351}
2352"#,
2353 )
2354 .unwrap();
2355
2356 fs::write(
2357 dir.path().join("utils.ts"),
2358 r#"export function helper(x: number): number {
2359 return x + 1;
2360}
2361"#,
2362 )
2363 .unwrap();
2364
2365 dir
2366 }
2367
2368 fn setup_cycle_project() -> TempDir {
2370 let dir = TempDir::new().unwrap();
2371
2372 fs::write(
2373 dir.path().join("a.ts"),
2374 r#"import { funcB } from './b';
2375
2376export function funcA() {
2377 return funcB();
2378}
2379"#,
2380 )
2381 .unwrap();
2382
2383 fs::write(
2384 dir.path().join("b.ts"),
2385 r#"import { funcA } from './a';
2386
2387export function funcB() {
2388 return funcA();
2389}
2390"#,
2391 )
2392 .unwrap();
2393
2394 dir
2395 }
2396
2397 #[test]
2400 fn callgraph_single_file_call_extraction() {
2401 let dir = setup_ts_project();
2402 let mut graph = CallGraph::new(dir.path().to_path_buf());
2403
2404 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2405 let main_calls = &file_data.calls_by_symbol["main"];
2406
2407 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2408 assert!(
2409 callee_names.contains(&"helper"),
2410 "main should call helper, got: {:?}",
2411 callee_names
2412 );
2413 assert!(
2414 callee_names.contains(&"compute"),
2415 "main should call compute, got: {:?}",
2416 callee_names
2417 );
2418 assert!(
2419 callee_names.contains(&"add"),
2420 "main should call math.add (short name: add), got: {:?}",
2421 callee_names
2422 );
2423 }
2424
2425 #[test]
2426 fn callgraph_file_data_has_exports() {
2427 let dir = setup_ts_project();
2428 let mut graph = CallGraph::new(dir.path().to_path_buf());
2429
2430 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2431 assert!(
2432 file_data.exported_symbols.contains(&"helper".to_string()),
2433 "utils.ts should export helper, got: {:?}",
2434 file_data.exported_symbols
2435 );
2436 assert!(
2437 file_data.exported_symbols.contains(&"compute".to_string()),
2438 "utils.ts should export compute, got: {:?}",
2439 file_data.exported_symbols
2440 );
2441 }
2442
2443 #[test]
2446 fn callgraph_resolve_direct_import() {
2447 let dir = setup_ts_project();
2448 let mut graph = CallGraph::new(dir.path().to_path_buf());
2449
2450 let main_path = dir.path().join("main.ts");
2451 let file_data = graph.build_file(&main_path).unwrap();
2452 let import_block = file_data.import_block.clone();
2453
2454 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2455 match edge {
2456 EdgeResolution::Resolved { file, symbol } => {
2457 assert!(
2458 file.ends_with("utils.ts"),
2459 "helper should resolve to utils.ts, got: {:?}",
2460 file
2461 );
2462 assert_eq!(symbol, "helper");
2463 }
2464 EdgeResolution::Unresolved { callee_name } => {
2465 panic!("Expected resolved, got unresolved: {}", callee_name);
2466 }
2467 }
2468 }
2469
2470 #[test]
2471 fn callgraph_resolve_namespace_import() {
2472 let dir = setup_ts_project();
2473 let mut graph = CallGraph::new(dir.path().to_path_buf());
2474
2475 let main_path = dir.path().join("main.ts");
2476 let file_data = graph.build_file(&main_path).unwrap();
2477 let import_block = file_data.import_block.clone();
2478
2479 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2480 match edge {
2481 EdgeResolution::Resolved { file, symbol } => {
2482 assert!(
2483 file.ends_with("math.ts"),
2484 "math.add should resolve to math.ts, got: {:?}",
2485 file
2486 );
2487 assert_eq!(symbol, "add");
2488 }
2489 EdgeResolution::Unresolved { callee_name } => {
2490 panic!("Expected resolved, got unresolved: {}", callee_name);
2491 }
2492 }
2493 }
2494
2495 #[test]
2496 fn callgraph_resolve_aliased_import() {
2497 let dir = setup_alias_project();
2498 let mut graph = CallGraph::new(dir.path().to_path_buf());
2499
2500 let main_path = dir.path().join("main.ts");
2501 let file_data = graph.build_file(&main_path).unwrap();
2502 let import_block = file_data.import_block.clone();
2503
2504 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2505 match edge {
2506 EdgeResolution::Resolved { file, symbol } => {
2507 assert!(
2508 file.ends_with("utils.ts"),
2509 "h (alias for helper) should resolve to utils.ts, got: {:?}",
2510 file
2511 );
2512 assert_eq!(symbol, "helper");
2513 }
2514 EdgeResolution::Unresolved { callee_name } => {
2515 panic!("Expected resolved, got unresolved: {}", callee_name);
2516 }
2517 }
2518 }
2519
2520 #[test]
2521 fn callgraph_unresolved_edge_marked() {
2522 let dir = setup_ts_project();
2523 let mut graph = CallGraph::new(dir.path().to_path_buf());
2524
2525 let main_path = dir.path().join("main.ts");
2526 let file_data = graph.build_file(&main_path).unwrap();
2527 let import_block = file_data.import_block.clone();
2528
2529 let edge =
2530 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2531 assert_eq!(
2532 edge,
2533 EdgeResolution::Unresolved {
2534 callee_name: "unknownFunc".to_string()
2535 },
2536 "Unknown callee should be unresolved"
2537 );
2538 }
2539
2540 #[test]
2543 fn callgraph_cycle_detection_stops() {
2544 let dir = setup_cycle_project();
2545 let mut graph = CallGraph::new(dir.path().to_path_buf());
2546
2547 let tree = graph
2549 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2550 .unwrap();
2551
2552 assert_eq!(tree.name, "funcA");
2553 assert!(tree.resolved);
2554
2555 fn count_depth(node: &CallTreeNode) -> usize {
2558 if node.children.is_empty() {
2559 1
2560 } else {
2561 1 + node
2562 .children
2563 .iter()
2564 .map(|c| count_depth(c))
2565 .max()
2566 .unwrap_or(0)
2567 }
2568 }
2569
2570 let depth = count_depth(&tree);
2571 assert!(
2572 depth <= 4,
2573 "Cycle should be detected and bounded, depth was: {}",
2574 depth
2575 );
2576 }
2577
2578 #[test]
2581 fn callgraph_depth_limit_truncates() {
2582 let dir = setup_ts_project();
2583 let mut graph = CallGraph::new(dir.path().to_path_buf());
2584
2585 let tree = graph
2588 .forward_tree(&dir.path().join("main.ts"), "main", 1)
2589 .unwrap();
2590
2591 assert_eq!(tree.name, "main");
2592
2593 for child in &tree.children {
2595 assert!(
2596 child.children.is_empty(),
2597 "At depth 1, child '{}' should have no children, got {:?}",
2598 child.name,
2599 child.children.len()
2600 );
2601 }
2602 }
2603
2604 #[test]
2605 fn callgraph_depth_zero_no_children() {
2606 let dir = setup_ts_project();
2607 let mut graph = CallGraph::new(dir.path().to_path_buf());
2608
2609 let tree = graph
2610 .forward_tree(&dir.path().join("main.ts"), "main", 0)
2611 .unwrap();
2612
2613 assert_eq!(tree.name, "main");
2614 assert!(
2615 tree.children.is_empty(),
2616 "At depth 0, should have no children"
2617 );
2618 }
2619
2620 #[test]
2623 fn callgraph_forward_tree_cross_file() {
2624 let dir = setup_ts_project();
2625 let mut graph = CallGraph::new(dir.path().to_path_buf());
2626
2627 let tree = graph
2629 .forward_tree(&dir.path().join("main.ts"), "main", 5)
2630 .unwrap();
2631
2632 assert_eq!(tree.name, "main");
2633 assert!(tree.resolved);
2634
2635 let helper_child = tree.children.iter().find(|c| c.name == "helper");
2637 assert!(
2638 helper_child.is_some(),
2639 "main should have helper as child, children: {:?}",
2640 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2641 );
2642
2643 let helper = helper_child.unwrap();
2644 assert!(
2645 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2646 "helper should be in utils.ts, got: {}",
2647 helper.file
2648 );
2649
2650 let double_child = helper.children.iter().find(|c| c.name == "double");
2652 assert!(
2653 double_child.is_some(),
2654 "helper should call double, children: {:?}",
2655 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2656 );
2657
2658 let double = double_child.unwrap();
2659 assert!(
2660 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2661 "double should be in helpers.ts, got: {}",
2662 double.file
2663 );
2664 }
2665
2666 #[test]
2669 fn callgraph_walker_excludes_gitignored() {
2670 let dir = TempDir::new().unwrap();
2671
2672 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2674
2675 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2677 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2678 fs::write(
2679 dir.path().join("ignored_dir").join("secret.ts"),
2680 "export function secret() {}",
2681 )
2682 .unwrap();
2683
2684 fs::create_dir(dir.path().join("node_modules")).unwrap();
2686 fs::write(
2687 dir.path().join("node_modules").join("dep.ts"),
2688 "export function dep() {}",
2689 )
2690 .unwrap();
2691
2692 std::process::Command::new("git")
2694 .args(["init"])
2695 .current_dir(dir.path())
2696 .output()
2697 .unwrap();
2698
2699 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2700 let file_names: Vec<String> = files
2701 .iter()
2702 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2703 .collect();
2704
2705 assert!(
2706 file_names.contains(&"main.ts".to_string()),
2707 "Should include main.ts, got: {:?}",
2708 file_names
2709 );
2710 assert!(
2711 !file_names.contains(&"secret.ts".to_string()),
2712 "Should exclude gitignored secret.ts, got: {:?}",
2713 file_names
2714 );
2715 assert!(
2716 !file_names.contains(&"dep.ts".to_string()),
2717 "Should exclude node_modules, got: {:?}",
2718 file_names
2719 );
2720 }
2721
2722 #[test]
2723 fn callgraph_walker_only_source_files() {
2724 let dir = TempDir::new().unwrap();
2725
2726 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2727 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2728 fs::write(dir.path().join("data.json"), "{}").unwrap();
2729
2730 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2731 let file_names: Vec<String> = files
2732 .iter()
2733 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2734 .collect();
2735
2736 assert!(file_names.contains(&"main.ts".to_string()));
2737 assert!(
2738 file_names.contains(&"readme.md".to_string()),
2739 "Markdown is now a supported source language"
2740 );
2741 assert!(
2742 !file_names.contains(&"data.json".to_string()),
2743 "Should not include non-source files"
2744 );
2745 }
2746
2747 #[test]
2750 fn callgraph_find_alias_original_simple() {
2751 let raw = "import { foo as bar } from './utils';";
2752 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2753 }
2754
2755 #[test]
2756 fn callgraph_find_alias_original_multiple() {
2757 let raw = "import { foo as bar, baz as qux } from './utils';";
2758 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2759 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2760 }
2761
2762 #[test]
2763 fn callgraph_find_alias_no_match() {
2764 let raw = "import { foo } from './utils';";
2765 assert_eq!(find_alias_original(raw, "foo"), None);
2766 }
2767
2768 #[test]
2771 fn callgraph_callers_of_direct() {
2772 let dir = setup_ts_project();
2773 let mut graph = CallGraph::new(dir.path().to_path_buf());
2774
2775 let result = graph
2777 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2778 .unwrap();
2779
2780 assert_eq!(result.symbol, "double");
2781 assert!(result.total_callers > 0, "double should have callers");
2782 assert!(result.scanned_files > 0, "should have scanned files");
2783
2784 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2786 assert!(
2787 utils_group.is_some(),
2788 "double should be called from utils.ts, groups: {:?}",
2789 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2790 );
2791
2792 let group = utils_group.unwrap();
2793 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2794 assert!(
2795 helper_caller.is_some(),
2796 "double should be called by helper, callers: {:?}",
2797 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2798 );
2799 }
2800
2801 #[test]
2802 fn callgraph_callers_of_no_callers() {
2803 let dir = setup_ts_project();
2804 let mut graph = CallGraph::new(dir.path().to_path_buf());
2805
2806 let result = graph
2808 .callers_of(&dir.path().join("main.ts"), "main", 1)
2809 .unwrap();
2810
2811 assert_eq!(result.symbol, "main");
2812 assert_eq!(result.total_callers, 0, "main should have no callers");
2813 assert!(result.callers.is_empty());
2814 }
2815
2816 #[test]
2817 fn callgraph_callers_recursive_depth() {
2818 let dir = setup_ts_project();
2819 let mut graph = CallGraph::new(dir.path().to_path_buf());
2820
2821 let result = graph
2825 .callers_of(&dir.path().join("helpers.ts"), "double", 2)
2826 .unwrap();
2827
2828 assert!(
2829 result.total_callers >= 2,
2830 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2831 result.total_callers
2832 );
2833
2834 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2836 assert!(
2837 main_group.is_some(),
2838 "recursive callers should include main.ts, groups: {:?}",
2839 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2840 );
2841 }
2842
2843 #[test]
2844 fn callgraph_invalidate_file_clears_reverse_index() {
2845 let dir = setup_ts_project();
2846 let mut graph = CallGraph::new(dir.path().to_path_buf());
2847
2848 let _ = graph
2850 .callers_of(&dir.path().join("helpers.ts"), "double", 1)
2851 .unwrap();
2852 assert!(
2853 graph.reverse_index.is_some(),
2854 "reverse index should be built"
2855 );
2856
2857 graph.invalidate_file(&dir.path().join("utils.ts"));
2859
2860 assert!(
2862 graph.reverse_index.is_none(),
2863 "invalidate_file should clear reverse index"
2864 );
2865 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2867 assert!(
2868 !graph.data.contains_key(&canon),
2869 "invalidate_file should remove file from data cache"
2870 );
2871 assert!(
2873 graph.project_files.is_none(),
2874 "invalidate_file should clear project_files"
2875 );
2876 }
2877
2878 #[test]
2881 fn is_entry_point_exported_function() {
2882 assert!(is_entry_point(
2883 "handleRequest",
2884 &SymbolKind::Function,
2885 true,
2886 LangId::TypeScript
2887 ));
2888 }
2889
2890 #[test]
2891 fn is_entry_point_exported_method_is_not_entry() {
2892 assert!(!is_entry_point(
2894 "handleRequest",
2895 &SymbolKind::Method,
2896 true,
2897 LangId::TypeScript
2898 ));
2899 }
2900
2901 #[test]
2902 fn is_entry_point_main_init_patterns() {
2903 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
2904 assert!(
2905 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
2906 "{} should be an entry point",
2907 name
2908 );
2909 }
2910 }
2911
2912 #[test]
2913 fn is_entry_point_test_patterns_ts() {
2914 assert!(is_entry_point(
2915 "describe",
2916 &SymbolKind::Function,
2917 false,
2918 LangId::TypeScript
2919 ));
2920 assert!(is_entry_point(
2921 "it",
2922 &SymbolKind::Function,
2923 false,
2924 LangId::TypeScript
2925 ));
2926 assert!(is_entry_point(
2927 "test",
2928 &SymbolKind::Function,
2929 false,
2930 LangId::TypeScript
2931 ));
2932 assert!(is_entry_point(
2933 "testValidation",
2934 &SymbolKind::Function,
2935 false,
2936 LangId::TypeScript
2937 ));
2938 assert!(is_entry_point(
2939 "specHelper",
2940 &SymbolKind::Function,
2941 false,
2942 LangId::TypeScript
2943 ));
2944 }
2945
2946 #[test]
2947 fn is_entry_point_test_patterns_python() {
2948 assert!(is_entry_point(
2949 "test_login",
2950 &SymbolKind::Function,
2951 false,
2952 LangId::Python
2953 ));
2954 assert!(is_entry_point(
2955 "setUp",
2956 &SymbolKind::Function,
2957 false,
2958 LangId::Python
2959 ));
2960 assert!(is_entry_point(
2961 "tearDown",
2962 &SymbolKind::Function,
2963 false,
2964 LangId::Python
2965 ));
2966 assert!(!is_entry_point(
2968 "testSomething",
2969 &SymbolKind::Function,
2970 false,
2971 LangId::Python
2972 ));
2973 }
2974
2975 #[test]
2976 fn is_entry_point_test_patterns_rust() {
2977 assert!(is_entry_point(
2978 "test_parse",
2979 &SymbolKind::Function,
2980 false,
2981 LangId::Rust
2982 ));
2983 assert!(!is_entry_point(
2984 "TestSomething",
2985 &SymbolKind::Function,
2986 false,
2987 LangId::Rust
2988 ));
2989 }
2990
2991 #[test]
2992 fn is_entry_point_test_patterns_go() {
2993 assert!(is_entry_point(
2994 "TestParsing",
2995 &SymbolKind::Function,
2996 false,
2997 LangId::Go
2998 ));
2999 assert!(!is_entry_point(
3001 "testParsing",
3002 &SymbolKind::Function,
3003 false,
3004 LangId::Go
3005 ));
3006 }
3007
3008 #[test]
3009 fn is_entry_point_non_exported_non_main_is_not_entry() {
3010 assert!(!is_entry_point(
3011 "helperUtil",
3012 &SymbolKind::Function,
3013 false,
3014 LangId::TypeScript
3015 ));
3016 }
3017
3018 #[test]
3021 fn callgraph_symbol_metadata_populated() {
3022 let dir = setup_ts_project();
3023 let mut graph = CallGraph::new(dir.path().to_path_buf());
3024
3025 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3026 assert!(
3027 file_data.symbol_metadata.contains_key("helper"),
3028 "symbol_metadata should contain helper"
3029 );
3030 let meta = &file_data.symbol_metadata["helper"];
3031 assert_eq!(meta.kind, SymbolKind::Function);
3032 assert!(meta.exported, "helper should be exported");
3033 }
3034
3035 fn setup_trace_project() -> TempDir {
3051 let dir = TempDir::new().unwrap();
3052
3053 fs::write(
3054 dir.path().join("main.ts"),
3055 r#"import { processData } from './utils';
3056
3057export function main() {
3058 const result = processData("hello");
3059 return result;
3060}
3061"#,
3062 )
3063 .unwrap();
3064
3065 fs::write(
3066 dir.path().join("service.ts"),
3067 r#"import { processData } from './utils';
3068
3069export function handleRequest(input: string): string {
3070 return processData(input);
3071}
3072"#,
3073 )
3074 .unwrap();
3075
3076 fs::write(
3077 dir.path().join("utils.ts"),
3078 r#"import { validate } from './helpers';
3079
3080export function processData(input: string): string {
3081 const valid = validate(input);
3082 if (!valid) {
3083 throw new Error("invalid input");
3084 }
3085 return input.toUpperCase();
3086}
3087"#,
3088 )
3089 .unwrap();
3090
3091 fs::write(
3092 dir.path().join("helpers.ts"),
3093 r#"export function validate(input: string): boolean {
3094 return checkFormat(input);
3095}
3096
3097function checkFormat(input: string): boolean {
3098 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3099}
3100"#,
3101 )
3102 .unwrap();
3103
3104 fs::write(
3105 dir.path().join("test_helpers.ts"),
3106 r#"import { validate } from './helpers';
3107
3108function testValidation() {
3109 const result = validate("hello");
3110 console.log(result);
3111}
3112"#,
3113 )
3114 .unwrap();
3115
3116 std::process::Command::new("git")
3118 .args(["init"])
3119 .current_dir(dir.path())
3120 .output()
3121 .unwrap();
3122
3123 dir
3124 }
3125
3126 #[test]
3127 fn trace_to_multi_path() {
3128 let dir = setup_trace_project();
3129 let mut graph = CallGraph::new(dir.path().to_path_buf());
3130
3131 let result = graph
3132 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3133 .unwrap();
3134
3135 assert_eq!(result.target_symbol, "checkFormat");
3136 assert!(
3137 result.total_paths >= 2,
3138 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3139 result.total_paths,
3140 result
3141 .paths
3142 .iter()
3143 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3144 .collect::<Vec<_>>()
3145 );
3146
3147 for path in &result.paths {
3149 assert!(
3150 path.hops.first().unwrap().is_entry_point,
3151 "First hop should be an entry point, got: {}",
3152 path.hops.first().unwrap().symbol
3153 );
3154 assert_eq!(
3155 path.hops.last().unwrap().symbol,
3156 "checkFormat",
3157 "Last hop should be checkFormat"
3158 );
3159 }
3160
3161 assert!(
3163 result.entry_points_found >= 2,
3164 "should find at least 2 entry points, got {}",
3165 result.entry_points_found
3166 );
3167 }
3168
3169 #[test]
3170 fn trace_to_single_path() {
3171 let dir = setup_trace_project();
3172 let mut graph = CallGraph::new(dir.path().to_path_buf());
3173
3174 let result = graph
3178 .trace_to(&dir.path().join("helpers.ts"), "validate", 10)
3179 .unwrap();
3180
3181 assert_eq!(result.target_symbol, "validate");
3182 assert!(
3183 result.total_paths >= 2,
3184 "validate should have at least 2 paths, got {}",
3185 result.total_paths
3186 );
3187 }
3188
3189 #[test]
3190 fn trace_to_cycle_detection() {
3191 let dir = setup_cycle_project();
3192 let mut graph = CallGraph::new(dir.path().to_path_buf());
3193
3194 let result = graph
3196 .trace_to(&dir.path().join("a.ts"), "funcA", 10)
3197 .unwrap();
3198
3199 assert_eq!(result.target_symbol, "funcA");
3201 }
3202
3203 #[test]
3204 fn trace_to_depth_limit() {
3205 let dir = setup_trace_project();
3206 let mut graph = CallGraph::new(dir.path().to_path_buf());
3207
3208 let result = graph
3210 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1)
3211 .unwrap();
3212
3213 assert_eq!(result.target_symbol, "checkFormat");
3217
3218 let deep_result = graph
3220 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 10)
3221 .unwrap();
3222
3223 assert!(
3224 result.total_paths <= deep_result.total_paths,
3225 "shallow trace should find <= paths compared to deep: {} vs {}",
3226 result.total_paths,
3227 deep_result.total_paths
3228 );
3229 }
3230
3231 #[test]
3232 fn trace_to_entry_point_target() {
3233 let dir = setup_trace_project();
3234 let mut graph = CallGraph::new(dir.path().to_path_buf());
3235
3236 let result = graph
3238 .trace_to(&dir.path().join("main.ts"), "main", 10)
3239 .unwrap();
3240
3241 assert_eq!(result.target_symbol, "main");
3242 assert!(
3243 result.total_paths >= 1,
3244 "main should have at least 1 path (itself), got {}",
3245 result.total_paths
3246 );
3247 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3249 assert!(
3250 trivial.is_some(),
3251 "should have a trivial path with just the entry point itself"
3252 );
3253 }
3254
3255 #[test]
3258 fn extract_parameters_typescript() {
3259 let params = extract_parameters(
3260 "function processData(input: string, count: number): void",
3261 LangId::TypeScript,
3262 );
3263 assert_eq!(params, vec!["input", "count"]);
3264 }
3265
3266 #[test]
3267 fn extract_parameters_typescript_optional() {
3268 let params = extract_parameters(
3269 "function fetch(url: string, options?: RequestInit): Promise<Response>",
3270 LangId::TypeScript,
3271 );
3272 assert_eq!(params, vec!["url", "options"]);
3273 }
3274
3275 #[test]
3276 fn extract_parameters_typescript_defaults() {
3277 let params = extract_parameters(
3278 "function greet(name: string, greeting: string = \"hello\"): string",
3279 LangId::TypeScript,
3280 );
3281 assert_eq!(params, vec!["name", "greeting"]);
3282 }
3283
3284 #[test]
3285 fn extract_parameters_typescript_rest() {
3286 let params = extract_parameters(
3287 "function sum(...numbers: number[]): number",
3288 LangId::TypeScript,
3289 );
3290 assert_eq!(params, vec!["numbers"]);
3291 }
3292
3293 #[test]
3294 fn extract_parameters_python_self_skipped() {
3295 let params = extract_parameters(
3296 "def process(self, data: str, count: int) -> bool",
3297 LangId::Python,
3298 );
3299 assert_eq!(params, vec!["data", "count"]);
3300 }
3301
3302 #[test]
3303 fn extract_parameters_python_no_self() {
3304 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3305 assert_eq!(params, vec!["input"]);
3306 }
3307
3308 #[test]
3309 fn extract_parameters_python_star_args() {
3310 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3311 assert_eq!(params, vec!["args", "kwargs"]);
3312 }
3313
3314 #[test]
3315 fn extract_parameters_rust_self_skipped() {
3316 let params = extract_parameters(
3317 "fn process(&self, data: &str, count: usize) -> bool",
3318 LangId::Rust,
3319 );
3320 assert_eq!(params, vec!["data", "count"]);
3321 }
3322
3323 #[test]
3324 fn extract_parameters_rust_mut_self_skipped() {
3325 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3326 assert_eq!(params, vec!["value"]);
3327 }
3328
3329 #[test]
3330 fn extract_parameters_rust_no_self() {
3331 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3332 assert_eq!(params, vec!["input"]);
3333 }
3334
3335 #[test]
3336 fn extract_parameters_rust_mut_param() {
3337 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3338 assert_eq!(params, vec!["buf", "len"]);
3339 }
3340
3341 #[test]
3342 fn extract_parameters_go() {
3343 let params = extract_parameters(
3344 "func ProcessData(input string, count int) error",
3345 LangId::Go,
3346 );
3347 assert_eq!(params, vec!["input", "count"]);
3348 }
3349
3350 #[test]
3351 fn extract_parameters_empty() {
3352 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3353 assert!(
3354 params.is_empty(),
3355 "no-arg function should return empty params"
3356 );
3357 }
3358
3359 #[test]
3360 fn extract_parameters_no_parens() {
3361 let params = extract_parameters("const x = 42", LangId::TypeScript);
3362 assert!(params.is_empty(), "no parens should return empty params");
3363 }
3364
3365 #[test]
3366 fn extract_parameters_javascript() {
3367 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3368 assert_eq!(params, vec!["event", "target"]);
3369 }
3370}