1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11
12use rayon::prelude::*;
13use serde::Serialize;
14use serde_json::Value;
15use tree_sitter::{Node, Parser};
16
17use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
18use crate::edit::line_col_to_byte;
19use crate::error::AftError;
20use crate::imports::{self, ImportBlock};
21use crate::language::LanguageProvider;
22use crate::parser::{detect_language, grammar_for, LangId};
23use crate::symbols::{Range, SymbolKind};
24
25type SharedPath = Arc<PathBuf>;
30type SharedStr = Arc<str>;
31type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
32type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
33
34static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
35 LazyLock::new(|| RwLock::new(HashMap::new()));
36
37const TOP_LEVEL_SYMBOL: &str = "<top-level>";
38
39#[derive(Debug, Clone)]
41pub struct CallSite {
42 pub callee_name: String,
44 pub full_callee: String,
46 pub line: u32,
48 pub byte_start: usize,
50 pub byte_end: usize,
51}
52
53#[derive(Debug, Clone, Serialize)]
55pub struct SymbolMeta {
56 pub kind: SymbolKind,
58 pub exported: bool,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub signature: Option<String>,
63 pub line: u32,
65 pub range: Range,
67}
68
69#[derive(Debug, Clone)]
72pub struct FileCallData {
73 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
75 pub exported_symbols: Vec<String>,
77 pub symbol_metadata: HashMap<String, SymbolMeta>,
79 pub default_export_symbol: Option<String>,
81 pub import_block: ImportBlock,
83 pub lang: LangId,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum EdgeResolution {
90 Resolved { file: PathBuf, symbol: String },
92 Unresolved { callee_name: String },
94}
95
96#[derive(Debug, Clone, Serialize)]
98pub struct CallerSite {
99 pub caller_file: PathBuf,
101 pub caller_symbol: String,
103 pub line: u32,
105 pub col: u32,
107 pub resolved: bool,
109}
110
111#[derive(Debug, Clone)]
112struct IndexedCallerSite {
113 caller_file: SharedPath,
114 caller_symbol: SharedStr,
115 line: u32,
116 col: u32,
117 resolved: bool,
118}
119
120#[derive(Debug, Clone, Serialize)]
122pub struct CallerGroup {
123 pub file: String,
125 pub callers: Vec<CallerEntry>,
127}
128
129#[derive(Debug, Clone, Serialize)]
131pub struct CallerEntry {
132 pub symbol: String,
133 pub line: u32,
135}
136
137#[derive(Debug, Clone, Serialize)]
139pub struct CallersResult {
140 pub symbol: String,
142 pub file: String,
144 pub callers: Vec<CallerGroup>,
146 pub total_callers: usize,
148 pub scanned_files: usize,
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct CallTreeNode {
155 pub name: String,
157 pub file: String,
159 pub line: u32,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub signature: Option<String>,
164 pub resolved: bool,
166 pub children: Vec<CallTreeNode>,
168}
169
170const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
176
177pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
184 if exported && *kind == SymbolKind::Function {
186 return true;
187 }
188
189 let lower = name.to_lowercase();
191 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
192 return true;
193 }
194
195 match lang {
197 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
198 matches!(lower.as_str(), "describe" | "it" | "test")
200 || lower.starts_with("test")
201 || lower.starts_with("spec")
202 }
203 LangId::Python => {
204 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
206 }
207 LangId::Rust => {
208 lower.starts_with("test_")
210 }
211 LangId::Go => {
212 name.starts_with("Test")
214 }
215 LangId::C
216 | LangId::Cpp
217 | LangId::Zig
218 | LangId::CSharp
219 | LangId::Bash
220 | LangId::Solidity
221 | LangId::Vue
222 | LangId::Json
223 | LangId::Scala
224 | LangId::Html
225 | LangId::Markdown => false,
226 }
227}
228
229#[derive(Debug, Clone, Serialize)]
235pub struct TraceHop {
236 pub symbol: String,
238 pub file: String,
240 pub line: u32,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub signature: Option<String>,
245 pub is_entry_point: bool,
247}
248
249#[derive(Debug, Clone, Serialize)]
251pub struct TracePath {
252 pub hops: Vec<TraceHop>,
254}
255
256#[derive(Debug, Clone, Serialize)]
258pub struct TraceToResult {
259 pub target_symbol: String,
261 pub target_file: String,
263 pub paths: Vec<TracePath>,
265 pub total_paths: usize,
267 pub entry_points_found: usize,
269 pub max_depth_reached: bool,
271 pub truncated_paths: usize,
273}
274
275#[derive(Debug, Clone, Serialize)]
281pub struct ImpactCaller {
282 pub caller_symbol: String,
284 pub caller_file: String,
286 pub line: u32,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub signature: Option<String>,
291 pub is_entry_point: bool,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub call_expression: Option<String>,
296 pub parameters: Vec<String>,
298}
299
300#[derive(Debug, Clone, Serialize)]
302pub struct ImpactResult {
303 pub symbol: String,
305 pub file: String,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub signature: Option<String>,
310 pub parameters: Vec<String>,
312 pub total_affected: usize,
314 pub affected_files: usize,
316 pub callers: Vec<ImpactCaller>,
318}
319
320#[derive(Debug, Clone, Serialize)]
326pub struct DataFlowHop {
327 pub file: String,
329 pub symbol: String,
331 pub variable: String,
333 pub line: u32,
335 pub flow_type: String,
337 pub approximate: bool,
339}
340
341#[derive(Debug, Clone, Serialize)]
344pub struct TraceDataResult {
345 pub expression: String,
347 pub origin_file: String,
349 pub origin_symbol: String,
351 pub hops: Vec<DataFlowHop>,
353 pub depth_limited: bool,
355}
356
357pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
363 let start = match signature.find('(') {
365 Some(i) => i + 1,
366 None => return Vec::new(),
367 };
368 let end = match signature[start..].find(')') {
369 Some(i) => start + i,
370 None => return Vec::new(),
371 };
372
373 let params_str = &signature[start..end].trim();
374 if params_str.is_empty() {
375 return Vec::new();
376 }
377
378 let parts = split_params(params_str);
380
381 let mut result = Vec::new();
382 for part in parts {
383 let trimmed = part.trim();
384 if trimmed.is_empty() {
385 continue;
386 }
387
388 match lang {
390 LangId::Rust => {
391 if trimmed == "self"
392 || trimmed == "mut self"
393 || trimmed.starts_with("&self")
394 || trimmed.starts_with("&mut self")
395 {
396 continue;
397 }
398 }
399 LangId::Python => {
400 if trimmed == "self" || trimmed.starts_with("self:") {
401 continue;
402 }
403 }
404 _ => {}
405 }
406
407 let name = extract_param_name(trimmed, lang);
409 if !name.is_empty() {
410 result.push(name);
411 }
412 }
413
414 result
415}
416
417fn split_params(s: &str) -> Vec<String> {
419 let mut parts = Vec::new();
420 let mut current = String::new();
421 let mut depth = 0i32;
422
423 for ch in s.chars() {
424 match ch {
425 '<' | '[' | '{' | '(' => {
426 depth += 1;
427 current.push(ch);
428 }
429 '>' | ']' | '}' | ')' => {
430 depth -= 1;
431 current.push(ch);
432 }
433 ',' if depth == 0 => {
434 parts.push(current.clone());
435 current.clear();
436 }
437 _ => {
438 current.push(ch);
439 }
440 }
441 }
442 if !current.is_empty() {
443 parts.push(current);
444 }
445 parts
446}
447
448fn extract_param_name(param: &str, lang: LangId) -> String {
456 let trimmed = param.trim();
457
458 let working = if trimmed.starts_with("...") {
460 &trimmed[3..]
461 } else if trimmed.starts_with("**") {
462 &trimmed[2..]
463 } else if trimmed.starts_with('*') && lang == LangId::Python {
464 &trimmed[1..]
465 } else {
466 trimmed
467 };
468
469 let working = if lang == LangId::Rust && working.starts_with("mut ") {
471 &working[4..]
472 } else {
473 working
474 };
475
476 let name = working
479 .split(|c: char| c == ':' || c == '=')
480 .next()
481 .unwrap_or("")
482 .trim();
483
484 let name = name.trim_end_matches('?');
486
487 if lang == LangId::Go && !name.contains(' ') {
489 return name.to_string();
490 }
491 if lang == LangId::Go {
492 return name.split_whitespace().next().unwrap_or("").to_string();
493 }
494
495 name.to_string()
496}
497
498pub struct CallGraph {
507 data: HashMap<PathBuf, FileCallData>,
509 project_root: PathBuf,
511 project_files: Option<Vec<PathBuf>>,
513 reverse_index: Option<ReverseIndex>,
516}
517
518impl CallGraph {
519 pub fn new(project_root: PathBuf) -> Self {
521 Self {
522 data: HashMap::new(),
523 project_root,
524 project_files: None,
525 reverse_index: None,
526 }
527 }
528
529 pub fn project_root(&self) -> &Path {
531 &self.project_root
532 }
533
534 fn resolve_cross_file_edge_with_exports<F, D>(
535 full_callee: &str,
536 short_name: &str,
537 caller_file: &Path,
538 import_block: &ImportBlock,
539 mut file_exports_symbol: F,
540 mut file_default_export_symbol: D,
541 ) -> EdgeResolution
542 where
543 F: FnMut(&Path, &str) -> bool,
544 D: FnMut(&Path) -> Option<String>,
545 {
546 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
547
548 if full_callee.contains('.') {
550 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
551 if parts.len() == 2 {
552 let namespace = parts[0];
553 let member = parts[1];
554
555 for imp in &import_block.imports {
556 if imp.namespace_import.as_deref() == Some(namespace) {
557 if let Some(resolved_path) =
558 resolve_module_path(caller_dir, &imp.module_path)
559 {
560 return EdgeResolution::Resolved {
561 file: resolved_path,
562 symbol: member.to_owned(),
563 };
564 }
565 }
566 }
567 }
568 }
569
570 for imp in &import_block.imports {
572 if imp.names.iter().any(|name| name == short_name) {
574 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
575 let target_file = resolve_reexported_symbol_path(
576 &resolved_path,
577 short_name,
578 &mut file_exports_symbol,
579 )
580 .unwrap_or(resolved_path);
581 return EdgeResolution::Resolved {
583 file: target_file,
584 symbol: short_name.to_owned(),
585 };
586 }
587 }
588
589 if imp.default_import.as_deref() == Some(short_name) {
591 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
592 let symbol = file_default_export_symbol(&resolved_path)
593 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path));
594 return EdgeResolution::Resolved {
595 file: resolved_path,
596 symbol,
597 };
598 }
599 }
600 }
601
602 if let Some((original_name, resolved_path)) =
607 resolve_aliased_import(short_name, import_block, caller_dir)
608 {
609 return EdgeResolution::Resolved {
610 file: resolved_path,
611 symbol: original_name,
612 };
613 }
614
615 for imp in &import_block.imports {
618 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
619 if resolved_path.is_dir() {
621 if let Some(index_path) = find_index_file(&resolved_path) {
622 if file_exports_symbol(&index_path, short_name) {
624 return EdgeResolution::Resolved {
625 file: index_path,
626 symbol: short_name.to_owned(),
627 };
628 }
629 }
630 } else if file_exports_symbol(&resolved_path, short_name) {
631 return EdgeResolution::Resolved {
632 file: resolved_path,
633 symbol: short_name.to_owned(),
634 };
635 }
636 }
637 }
638
639 EdgeResolution::Unresolved {
640 callee_name: short_name.to_owned(),
641 }
642 }
643
644 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
646 let canon = self.canonicalize(path)?;
647
648 if !self.data.contains_key(&canon) {
649 let file_data = build_file_data(&canon)?;
650 self.data.insert(canon.clone(), file_data);
651 }
652
653 Ok(&self.data[&canon])
654 }
655
656 pub fn resolve_cross_file_edge(
661 &mut self,
662 full_callee: &str,
663 short_name: &str,
664 caller_file: &Path,
665 import_block: &ImportBlock,
666 ) -> EdgeResolution {
667 let graph = RefCell::new(self);
668 Self::resolve_cross_file_edge_with_exports(
669 full_callee,
670 short_name,
671 caller_file,
672 import_block,
673 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
674 |path| graph.borrow_mut().file_default_export_symbol(path),
675 )
676 }
677
678 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
680 match self.build_file(path) {
681 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
682 Err(_) => false,
683 }
684 }
685
686 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
687 self.build_file(path)
688 .ok()
689 .and_then(|data| data.default_export_symbol.clone())
690 }
691
692 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
693 self.lookup_file_data(path)
694 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
695 .unwrap_or(false)
696 }
697
698 fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
699 self.lookup_file_data(path)
700 .and_then(|data| data.default_export_symbol.clone())
701 }
702
703 pub fn forward_tree(
708 &mut self,
709 file: &Path,
710 symbol: &str,
711 max_depth: usize,
712 ) -> Result<CallTreeNode, AftError> {
713 let mut visited = HashSet::new();
714 self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
715 }
716
717 fn forward_tree_inner(
718 &mut self,
719 file: &Path,
720 symbol: &str,
721 max_depth: usize,
722 current_depth: usize,
723 visited: &mut HashSet<(PathBuf, String)>,
724 ) -> Result<CallTreeNode, AftError> {
725 let canon = self.canonicalize(file)?;
726 let visit_key = (canon.clone(), symbol.to_string());
727
728 if visited.contains(&visit_key) {
730 let (line, signature) = self
731 .lookup_file_data(&canon)
732 .map(|data| get_symbol_meta_from_data(data, symbol))
733 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
734 return Ok(CallTreeNode {
735 name: symbol.to_string(),
736 file: self.relative_path(&canon),
737 line,
738 signature,
739 resolved: true,
740 children: vec![], });
742 }
743
744 visited.insert(visit_key.clone());
745
746 let (import_block, call_sites, sym_line, sym_signature) = {
747 let file_data = self.build_file(&canon)?;
748 let meta = get_symbol_meta_from_data(file_data, symbol);
749
750 (
751 file_data.import_block.clone(),
752 file_data
753 .calls_by_symbol
754 .get(symbol)
755 .cloned()
756 .unwrap_or_default(),
757 meta.0,
758 meta.1,
759 )
760 };
761
762 let mut children = Vec::new();
764
765 if current_depth < max_depth {
766 for call_site in &call_sites {
767 let edge = self.resolve_cross_file_edge(
768 &call_site.full_callee,
769 &call_site.callee_name,
770 &canon,
771 &import_block,
772 );
773
774 match edge {
775 EdgeResolution::Resolved {
776 file: ref target_file,
777 ref symbol,
778 } => {
779 match self.forward_tree_inner(
780 target_file,
781 symbol,
782 max_depth,
783 current_depth + 1,
784 visited,
785 ) {
786 Ok(child) => children.push(child),
787 Err(_) => {
788 children.push(CallTreeNode {
790 name: call_site.callee_name.clone(),
791 file: self.relative_path(target_file),
792 line: call_site.line,
793 signature: None,
794 resolved: false,
795 children: vec![],
796 });
797 }
798 }
799 }
800 EdgeResolution::Unresolved { callee_name } => {
801 children.push(CallTreeNode {
802 name: callee_name,
803 file: self.relative_path(&canon),
804 line: call_site.line,
805 signature: None,
806 resolved: false,
807 children: vec![],
808 });
809 }
810 }
811 }
812 }
813
814 visited.remove(&visit_key);
815
816 Ok(CallTreeNode {
817 name: symbol.to_string(),
818 file: self.relative_path(&canon),
819 line: sym_line,
820 signature: sym_signature,
821 resolved: true,
822 children,
823 })
824 }
825
826 pub fn project_files(&mut self) -> &[PathBuf] {
828 if self.project_files.is_none() {
829 let project_root = self.project_root.clone();
830 self.project_files = Some(walk_project_files(&project_root).collect());
831 }
832 self.project_files.as_deref().unwrap_or(&[])
833 }
834
835 pub fn project_file_count(&mut self) -> usize {
841 self.project_files().len()
842 }
843
844 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
855 if let Some(files) = self.project_files.as_deref() {
856 return files.len();
857 }
858 walk_project_files(&self.project_root)
859 .take(limit.saturating_add(1))
860 .count()
861 }
862
863 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
869 let count = self.project_file_count_bounded(max_files);
874 if count > max_files {
875 return Err(AftError::ProjectTooLarge {
876 count,
877 max: max_files,
878 });
879 }
880
881 let all_files = self.project_files().to_vec();
885
886 let uncached_files: Vec<PathBuf> = all_files
888 .iter()
889 .filter(|f| self.lookup_file_data(f).is_none())
890 .cloned()
891 .collect();
892
893 let computed: Vec<(PathBuf, FileCallData)> = uncached_files
894 .par_iter()
895 .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
896 .collect();
897
898 for (file, data) in computed {
899 self.data.insert(file, data);
900 }
901
902 let mut reverse: ReverseIndex = HashMap::new();
904
905 for caller_file in &all_files {
906 let canon_caller = Arc::new(
908 std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
909 );
910 let file_data = match self
911 .data
912 .get(caller_file)
913 .or_else(|| self.data.get(canon_caller.as_ref()))
914 {
915 Some(d) => d,
916 None => continue,
917 };
918
919 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
920 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
921
922 for call_site in call_sites {
923 let edge = Self::resolve_cross_file_edge_with_exports(
924 &call_site.full_callee,
925 &call_site.callee_name,
926 canon_caller.as_ref(),
927 &file_data.import_block,
928 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
929 |path| self.file_default_export_symbol_cached(path),
930 );
931
932 let (target_file, target_symbol, resolved) = match edge {
933 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
934 EdgeResolution::Unresolved { callee_name } => {
935 (canon_caller.as_ref().clone(), callee_name, false)
936 }
937 };
938
939 reverse
940 .entry(target_file)
941 .or_default()
942 .entry(target_symbol)
943 .or_default()
944 .push(IndexedCallerSite {
945 caller_file: Arc::clone(&canon_caller),
946 caller_symbol: Arc::clone(&caller_symbol),
947 line: call_site.line,
948 col: 0,
949 resolved,
950 });
951 }
952 }
953 }
954
955 self.reverse_index = Some(reverse);
956 Ok(())
957 }
958
959 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
960 self.reverse_index
961 .as_ref()?
962 .get(file)?
963 .get(symbol)
964 .map(Vec::as_slice)
965 }
966
967 pub fn callers_of(
973 &mut self,
974 file: &Path,
975 symbol: &str,
976 depth: usize,
977 max_files: usize,
978 ) -> Result<CallersResult, AftError> {
979 let canon = self.canonicalize(file)?;
980
981 self.build_file(&canon)?;
983
984 if self.reverse_index.is_none() {
986 self.build_reverse_index(max_files)?;
987 }
988
989 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
990 let effective_depth = if depth == 0 { 1 } else { depth };
991
992 let mut visited = HashSet::new();
993 let mut all_sites: Vec<CallerSite> = Vec::new();
994 self.collect_callers_recursive(
995 &canon,
996 symbol,
997 effective_depth,
998 0,
999 &mut visited,
1000 &mut all_sites,
1001 );
1002
1003 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1006 let total_callers = all_sites.len();
1007 for site in all_sites {
1008 let caller_file: PathBuf = site.caller_file;
1009 let caller_symbol: String = site.caller_symbol;
1010 let line = site.line;
1011 let entry = CallerEntry {
1012 symbol: caller_symbol,
1013 line,
1014 };
1015
1016 if let Some(entries) = groups_map.get_mut(&caller_file) {
1017 entries.push(entry);
1018 } else {
1019 groups_map.insert(caller_file, vec![entry]);
1020 }
1021 }
1022
1023 let mut callers: Vec<CallerGroup> = groups_map
1024 .into_iter()
1025 .map(|(file_path, entries)| CallerGroup {
1026 file: self.relative_path(&file_path),
1027 callers: entries,
1028 })
1029 .collect();
1030
1031 callers.sort_by(|a, b| a.file.cmp(&b.file));
1033
1034 Ok(CallersResult {
1035 symbol: symbol.to_string(),
1036 file: self.relative_path(&canon),
1037 callers,
1038 total_callers,
1039 scanned_files,
1040 })
1041 }
1042
1043 pub fn trace_to(
1049 &mut self,
1050 file: &Path,
1051 symbol: &str,
1052 max_depth: usize,
1053 max_files: usize,
1054 ) -> Result<TraceToResult, AftError> {
1055 let canon = self.canonicalize(file)?;
1056
1057 self.build_file(&canon)?;
1059
1060 if self.reverse_index.is_none() {
1062 self.build_reverse_index(max_files)?;
1063 }
1064
1065 let target_rel = self.relative_path(&canon);
1066 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1067 if self.reverse_index.is_none() {
1068 return Err(AftError::ParseError {
1069 message: format!(
1070 "reverse index unavailable after building callers for {}",
1071 canon.display()
1072 ),
1073 });
1074 }
1075
1076 let (target_line, target_sig) = self
1078 .lookup_file_data(&canon)
1079 .map(|data| get_symbol_meta_from_data(data, symbol))
1080 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1081
1082 let target_is_entry = self
1084 .lookup_file_data(&canon)
1085 .and_then(|fd| {
1086 let meta = fd.symbol_metadata.get(symbol)?;
1087 Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1088 })
1089 .unwrap_or(false);
1090
1091 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1094 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1095 let mut max_depth_reached = false;
1096 let mut truncated_paths: usize = 0;
1097
1098 let initial: Vec<PathElem> = vec![(
1100 Arc::new(canon.clone()),
1101 Arc::from(symbol),
1102 target_line,
1103 target_sig,
1104 )];
1105
1106 if target_is_entry {
1108 complete_paths.push(initial.clone());
1109 }
1110
1111 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1113
1114 while let Some((path, depth)) = queue.pop() {
1115 if depth >= effective_max {
1116 max_depth_reached = true;
1117 continue;
1118 }
1119
1120 let Some((current_file, current_symbol, _, _)) = path.last() else {
1121 continue;
1122 };
1123
1124 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1126 Some(sites) => sites,
1127 None => {
1128 if path.len() > 1 {
1131 truncated_paths += 1;
1134 }
1135 continue;
1136 }
1137 };
1138
1139 let mut has_new_path = false;
1140 for site in callers {
1141 if path.iter().any(|(file_path, sym, _, _)| {
1143 file_path.as_ref() == site.caller_file.as_ref()
1144 && sym.as_ref() == site.caller_symbol.as_ref()
1145 }) {
1146 continue;
1147 }
1148
1149 has_new_path = true;
1150
1151 let (caller_line, caller_sig) = self
1153 .lookup_file_data(site.caller_file.as_ref())
1154 .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1155 .unwrap_or_else(|| {
1156 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1157 });
1158
1159 let mut new_path = path.clone();
1160 new_path.push((
1161 Arc::clone(&site.caller_file),
1162 Arc::clone(&site.caller_symbol),
1163 caller_line,
1164 caller_sig,
1165 ));
1166
1167 let caller_is_entry = self
1171 .lookup_file_data(site.caller_file.as_ref())
1172 .and_then(|fd| {
1173 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1174 Some(is_entry_point(
1175 site.caller_symbol.as_ref(),
1176 &meta.kind,
1177 meta.exported,
1178 fd.lang,
1179 ))
1180 })
1181 .unwrap_or(false);
1182
1183 if caller_is_entry {
1184 complete_paths.push(new_path.clone());
1185 }
1186 queue.push((new_path, depth + 1));
1189 }
1190
1191 if !has_new_path && path.len() > 1 {
1193 truncated_paths += 1;
1194 }
1195 }
1196
1197 let mut paths: Vec<TracePath> = complete_paths
1200 .into_iter()
1201 .map(|mut elems| {
1202 elems.reverse();
1203 let hops: Vec<TraceHop> = elems
1204 .iter()
1205 .enumerate()
1206 .map(|(i, (file_path, sym, line, sig))| {
1207 let is_ep = if i == 0 {
1208 self.lookup_file_data(file_path.as_ref())
1210 .and_then(|fd| {
1211 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1212 Some(is_entry_point(
1213 sym.as_ref(),
1214 &meta.kind,
1215 meta.exported,
1216 fd.lang,
1217 ))
1218 })
1219 .unwrap_or(false)
1220 } else {
1221 false
1222 };
1223 TraceHop {
1224 symbol: sym.to_string(),
1225 file: self.relative_path(file_path.as_ref()),
1226 line: *line,
1227 signature: sig.clone(),
1228 is_entry_point: is_ep,
1229 }
1230 })
1231 .collect();
1232 TracePath { hops }
1233 })
1234 .collect();
1235
1236 paths.sort_by(|a, b| {
1238 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1239 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1240 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1241 });
1242
1243 let mut entry_point_names: HashSet<String> = HashSet::new();
1245 for p in &paths {
1246 if let Some(first) = p.hops.first() {
1247 if first.is_entry_point {
1248 entry_point_names.insert(first.symbol.clone());
1249 }
1250 }
1251 }
1252
1253 let total_paths = paths.len();
1254 let entry_points_found = entry_point_names.len();
1255
1256 Ok(TraceToResult {
1257 target_symbol: symbol.to_string(),
1258 target_file: target_rel,
1259 paths,
1260 total_paths,
1261 entry_points_found,
1262 max_depth_reached,
1263 truncated_paths,
1264 })
1265 }
1266
1267 pub fn impact(
1273 &mut self,
1274 file: &Path,
1275 symbol: &str,
1276 depth: usize,
1277 max_files: usize,
1278 ) -> Result<ImpactResult, AftError> {
1279 let canon = self.canonicalize(file)?;
1280
1281 self.build_file(&canon)?;
1283
1284 if self.reverse_index.is_none() {
1286 self.build_reverse_index(max_files)?;
1287 }
1288
1289 let effective_depth = if depth == 0 { 1 } else { depth };
1290
1291 let (target_signature, target_parameters, target_lang) = {
1293 let file_data = match self.data.get(&canon) {
1294 Some(d) => d,
1295 None => {
1296 return Err(AftError::InvalidRequest {
1297 message: "file data missing after build".to_string(),
1298 })
1299 }
1300 };
1301 let meta = file_data.symbol_metadata.get(symbol);
1302 let sig = meta.and_then(|m| m.signature.clone());
1303 let lang = file_data.lang;
1304 let params = sig
1305 .as_deref()
1306 .map(|s| extract_parameters(s, lang))
1307 .unwrap_or_default();
1308 (sig, params, lang)
1309 };
1310
1311 let mut visited = HashSet::new();
1313 let mut all_sites: Vec<CallerSite> = Vec::new();
1314 self.collect_callers_recursive(
1315 &canon,
1316 symbol,
1317 effective_depth,
1318 0,
1319 &mut visited,
1320 &mut all_sites,
1321 );
1322
1323 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1325 all_sites.retain(|site| {
1326 seen.insert((
1327 site.caller_file.clone(),
1328 site.caller_symbol.clone(),
1329 site.line,
1330 ))
1331 });
1332
1333 let mut callers = Vec::new();
1335 let mut affected_file_set = HashSet::new();
1336
1337 for site in &all_sites {
1338 if let Err(e) = self.build_file(site.caller_file.as_path()) {
1340 log::debug!(
1341 "callgraph: skipping caller file {}: {}",
1342 site.caller_file.display(),
1343 e
1344 );
1345 }
1346
1347 let (sig, is_ep, params, _lang) = {
1348 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1349 let meta = fd.symbol_metadata.get(&site.caller_symbol);
1350 let sig = meta.and_then(|m| m.signature.clone());
1351 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1352 let exported = meta.map(|m| m.exported).unwrap_or(false);
1353 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1354 let lang = fd.lang;
1355 let params = sig
1356 .as_deref()
1357 .map(|s| extract_parameters(s, lang))
1358 .unwrap_or_default();
1359 (sig, is_ep, params, lang)
1360 } else {
1361 (None, false, Vec::new(), target_lang)
1362 }
1363 };
1364
1365 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1367
1368 let rel_file = self.relative_path(site.caller_file.as_path());
1369 affected_file_set.insert(rel_file.clone());
1370
1371 callers.push(ImpactCaller {
1372 caller_symbol: site.caller_symbol.clone(),
1373 caller_file: rel_file,
1374 line: site.line,
1375 signature: sig,
1376 is_entry_point: is_ep,
1377 call_expression,
1378 parameters: params,
1379 });
1380 }
1381
1382 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1384
1385 let total_affected = callers.len();
1386 let affected_files = affected_file_set.len();
1387
1388 Ok(ImpactResult {
1389 symbol: symbol.to_string(),
1390 file: self.relative_path(&canon),
1391 signature: target_signature,
1392 parameters: target_parameters,
1393 total_affected,
1394 affected_files,
1395 callers,
1396 })
1397 }
1398
1399 pub fn trace_data(
1410 &mut self,
1411 file: &Path,
1412 symbol: &str,
1413 expression: &str,
1414 max_depth: usize,
1415 max_files: usize,
1416 ) -> Result<TraceDataResult, AftError> {
1417 let canon = self.canonicalize(file)?;
1418 let rel_file = self.relative_path(&canon);
1419
1420 self.build_file(&canon)?;
1422
1423 {
1425 let fd = match self.data.get(&canon) {
1426 Some(d) => d,
1427 None => {
1428 return Err(AftError::InvalidRequest {
1429 message: "file data missing after build".to_string(),
1430 })
1431 }
1432 };
1433 let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1434 || fd.exported_symbols.iter().any(|name| name == symbol)
1435 || fd.symbol_metadata.contains_key(symbol);
1436 if !has_symbol {
1437 return Err(AftError::InvalidRequest {
1438 message: format!(
1439 "trace_data: symbol '{}' not found in {}",
1440 symbol,
1441 file.display()
1442 ),
1443 });
1444 }
1445 }
1446
1447 let count = self.project_file_count_bounded(max_files);
1451 if count > max_files {
1452 return Err(AftError::ProjectTooLarge {
1453 count,
1454 max: max_files,
1455 });
1456 }
1457
1458 let mut hops = Vec::new();
1459 let mut depth_limited = false;
1460
1461 self.trace_data_inner(
1462 &canon,
1463 symbol,
1464 expression,
1465 max_depth,
1466 0,
1467 &mut hops,
1468 &mut depth_limited,
1469 &mut HashSet::new(),
1470 );
1471
1472 Ok(TraceDataResult {
1473 expression: expression.to_string(),
1474 origin_file: rel_file,
1475 origin_symbol: symbol.to_string(),
1476 hops,
1477 depth_limited,
1478 })
1479 }
1480
1481 fn trace_data_inner(
1483 &mut self,
1484 file: &Path,
1485 symbol: &str,
1486 tracking_name: &str,
1487 max_depth: usize,
1488 current_depth: usize,
1489 hops: &mut Vec<DataFlowHop>,
1490 depth_limited: &mut bool,
1491 visited: &mut HashSet<(PathBuf, String, String)>,
1492 ) {
1493 let visit_key = (
1494 file.to_path_buf(),
1495 symbol.to_string(),
1496 tracking_name.to_string(),
1497 );
1498 if visited.contains(&visit_key) {
1499 return; }
1501 visited.insert(visit_key);
1502
1503 let source = match std::fs::read_to_string(file) {
1505 Ok(s) => s,
1506 Err(_) => return,
1507 };
1508
1509 let lang = match detect_language(file) {
1510 Some(l) => l,
1511 None => return,
1512 };
1513
1514 let grammar = grammar_for(lang);
1515 let mut parser = Parser::new();
1516 if parser.set_language(&grammar).is_err() {
1517 return;
1518 }
1519 let tree = match parser.parse(&source, None) {
1520 Some(t) => t,
1521 None => return,
1522 };
1523
1524 let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1526 Ok(symbols) => symbols,
1527 Err(_) => return,
1528 };
1529 let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1530 Some(s) => s,
1531 None => return,
1532 };
1533
1534 let body_start =
1535 line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1536 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1537
1538 let root = tree.root_node();
1539
1540 let body_node = match find_node_covering_range(root, body_start, body_end) {
1542 Some(n) => n,
1543 None => return,
1544 };
1545
1546 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1548 let rel_file = self.relative_path(file);
1549
1550 self.walk_for_data_flow(
1552 body_node,
1553 &source,
1554 &mut tracked_names,
1555 file,
1556 symbol,
1557 &rel_file,
1558 lang,
1559 max_depth,
1560 current_depth,
1561 hops,
1562 depth_limited,
1563 visited,
1564 );
1565 }
1566
1567 #[allow(clippy::too_many_arguments)]
1570 fn walk_for_data_flow(
1571 &mut self,
1572 node: tree_sitter::Node,
1573 source: &str,
1574 tracked_names: &mut Vec<String>,
1575 file: &Path,
1576 symbol: &str,
1577 rel_file: &str,
1578 lang: LangId,
1579 max_depth: usize,
1580 current_depth: usize,
1581 hops: &mut Vec<DataFlowHop>,
1582 depth_limited: &mut bool,
1583 visited: &mut HashSet<(PathBuf, String, String)>,
1584 ) {
1585 let kind = node.kind();
1586
1587 let is_var_decl = matches!(
1589 kind,
1590 "variable_declarator"
1591 | "assignment_expression"
1592 | "augmented_assignment_expression"
1593 | "assignment"
1594 | "let_declaration"
1595 | "short_var_declaration"
1596 );
1597
1598 if is_var_decl {
1599 if let Some((new_name, init_text, line, is_approx)) =
1600 self.extract_assignment_info(node, source, lang, tracked_names)
1601 {
1602 if !is_approx {
1604 hops.push(DataFlowHop {
1605 file: rel_file.to_string(),
1606 symbol: symbol.to_string(),
1607 variable: new_name.clone(),
1608 line,
1609 flow_type: "assignment".to_string(),
1610 approximate: false,
1611 });
1612 tracked_names.push(new_name);
1613 } else {
1614 hops.push(DataFlowHop {
1616 file: rel_file.to_string(),
1617 symbol: symbol.to_string(),
1618 variable: init_text,
1619 line,
1620 flow_type: "assignment".to_string(),
1621 approximate: true,
1622 });
1623 return;
1625 }
1626 }
1627 }
1628
1629 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1631 self.check_call_for_data_flow(
1632 node,
1633 source,
1634 tracked_names,
1635 file,
1636 symbol,
1637 rel_file,
1638 lang,
1639 max_depth,
1640 current_depth,
1641 hops,
1642 depth_limited,
1643 visited,
1644 );
1645 }
1646
1647 let mut cursor = node.walk();
1649 if cursor.goto_first_child() {
1650 loop {
1651 let child = cursor.node();
1652 self.walk_for_data_flow(
1654 child,
1655 source,
1656 tracked_names,
1657 file,
1658 symbol,
1659 rel_file,
1660 lang,
1661 max_depth,
1662 current_depth,
1663 hops,
1664 depth_limited,
1665 visited,
1666 );
1667 if !cursor.goto_next_sibling() {
1668 break;
1669 }
1670 }
1671 }
1672 }
1673
1674 fn extract_assignment_info(
1677 &self,
1678 node: tree_sitter::Node,
1679 source: &str,
1680 _lang: LangId,
1681 tracked_names: &[String],
1682 ) -> Option<(String, String, u32, bool)> {
1683 let kind = node.kind();
1684 let line = node.start_position().row as u32 + 1;
1685
1686 match kind {
1687 "variable_declarator" => {
1688 let name_node = node.child_by_field_name("name")?;
1690 let value_node = node.child_by_field_name("value")?;
1691 let name_text = node_text(name_node, source);
1692 let value_text = node_text(value_node, source);
1693
1694 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1696 if tracked_names.iter().any(|t| value_text.contains(t)) {
1698 return Some((name_text.clone(), name_text, line, true));
1699 }
1700 return None;
1701 }
1702
1703 if tracked_names.iter().any(|t| {
1705 value_text == *t
1706 || value_text.starts_with(&format!("{}.", t))
1707 || value_text.starts_with(&format!("{}[", t))
1708 }) {
1709 return Some((name_text, value_text, line, false));
1710 }
1711 None
1712 }
1713 "assignment_expression" | "augmented_assignment_expression" => {
1714 let left = node.child_by_field_name("left")?;
1716 let right = node.child_by_field_name("right")?;
1717 let left_text = node_text(left, source);
1718 let right_text = node_text(right, source);
1719
1720 if tracked_names.iter().any(|t| right_text == *t) {
1721 return Some((left_text, right_text, line, false));
1722 }
1723 None
1724 }
1725 "assignment" => {
1726 let left = node.child_by_field_name("left")?;
1728 let right = node.child_by_field_name("right")?;
1729 let left_text = node_text(left, source);
1730 let right_text = node_text(right, source);
1731
1732 if tracked_names.iter().any(|t| right_text == *t) {
1733 return Some((left_text, right_text, line, false));
1734 }
1735 None
1736 }
1737 "let_declaration" | "short_var_declaration" => {
1738 let left = node
1740 .child_by_field_name("pattern")
1741 .or_else(|| node.child_by_field_name("left"))?;
1742 let right = node
1743 .child_by_field_name("value")
1744 .or_else(|| node.child_by_field_name("right"))?;
1745 let left_text = node_text(left, source);
1746 let right_text = node_text(right, source);
1747
1748 if tracked_names.iter().any(|t| right_text == *t) {
1749 return Some((left_text, right_text, line, false));
1750 }
1751 None
1752 }
1753 _ => None,
1754 }
1755 }
1756
1757 #[allow(clippy::too_many_arguments)]
1760 fn check_call_for_data_flow(
1761 &mut self,
1762 node: tree_sitter::Node,
1763 source: &str,
1764 tracked_names: &[String],
1765 file: &Path,
1766 _symbol: &str,
1767 rel_file: &str,
1768 _lang: LangId,
1769 max_depth: usize,
1770 current_depth: usize,
1771 hops: &mut Vec<DataFlowHop>,
1772 depth_limited: &mut bool,
1773 visited: &mut HashSet<(PathBuf, String, String)>,
1774 ) {
1775 let args_node = find_child_by_kind(node, "arguments")
1777 .or_else(|| find_child_by_kind(node, "argument_list"));
1778
1779 let args_node = match args_node {
1780 Some(n) => n,
1781 None => return,
1782 };
1783
1784 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
1787
1788 let mut cursor = args_node.walk();
1789 if cursor.goto_first_child() {
1790 loop {
1791 let child = cursor.node();
1792 let child_kind = child.kind();
1793
1794 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1796 if !cursor.goto_next_sibling() {
1797 break;
1798 }
1799 continue;
1800 }
1801
1802 let arg_text = node_text(child, source);
1803
1804 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1806 if tracked_names.iter().any(|t| arg_text.contains(t)) {
1807 hops.push(DataFlowHop {
1808 file: rel_file.to_string(),
1809 symbol: _symbol.to_string(),
1810 variable: arg_text,
1811 line: child.start_position().row as u32 + 1,
1812 flow_type: "parameter".to_string(),
1813 approximate: true,
1814 });
1815 }
1816 if !cursor.goto_next_sibling() {
1817 break;
1818 }
1819 arg_idx += 1;
1820 continue;
1821 }
1822
1823 if tracked_names.iter().any(|t| arg_text == *t) {
1824 arg_positions.push((arg_idx, arg_text));
1825 }
1826
1827 arg_idx += 1;
1828 if !cursor.goto_next_sibling() {
1829 break;
1830 }
1831 }
1832 }
1833
1834 if arg_positions.is_empty() {
1835 return;
1836 }
1837
1838 let (full_callee, short_callee) = extract_callee_names(node, source);
1840 let full_callee = match full_callee {
1841 Some(f) => f,
1842 None => return,
1843 };
1844 let short_callee = match short_callee {
1845 Some(s) => s,
1846 None => return,
1847 };
1848
1849 let import_block = {
1851 match self.data.get(file) {
1852 Some(fd) => fd.import_block.clone(),
1853 None => return,
1854 }
1855 };
1856
1857 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1858
1859 match edge {
1860 EdgeResolution::Resolved {
1861 file: target_file,
1862 symbol: target_symbol,
1863 } => {
1864 if current_depth + 1 > max_depth {
1865 *depth_limited = true;
1866 return;
1867 }
1868
1869 if let Err(e) = self.build_file(&target_file) {
1871 log::debug!(
1872 "callgraph: skipping target file {}: {}",
1873 target_file.display(),
1874 e
1875 );
1876 }
1877 let (params, target_line) = {
1878 match self.lookup_file_data(&target_file) {
1879 Some(fd) => {
1880 let meta = fd.symbol_metadata.get(&target_symbol);
1881 let sig = meta.and_then(|m| m.signature.clone());
1882 let params = sig
1883 .as_deref()
1884 .map(|s| extract_parameters(s, fd.lang))
1885 .unwrap_or_default();
1886 let line = meta.map(|m| m.line).unwrap_or(1);
1887 (params, line)
1888 }
1889 None => return,
1890 }
1891 };
1892
1893 let target_rel = self.relative_path(&target_file);
1894
1895 for (pos, _tracked) in &arg_positions {
1896 if let Some(param_name) = params.get(*pos) {
1897 hops.push(DataFlowHop {
1899 file: target_rel.clone(),
1900 symbol: target_symbol.clone(),
1901 variable: param_name.clone(),
1902 line: target_line,
1903 flow_type: "parameter".to_string(),
1904 approximate: false,
1905 });
1906
1907 self.trace_data_inner(
1909 &target_file.clone(),
1910 &target_symbol.clone(),
1911 param_name,
1912 max_depth,
1913 current_depth + 1,
1914 hops,
1915 depth_limited,
1916 visited,
1917 );
1918 }
1919 }
1920 }
1921 EdgeResolution::Unresolved { callee_name } => {
1922 let has_local = self
1924 .data
1925 .get(file)
1926 .map(|fd| {
1927 fd.calls_by_symbol.contains_key(&callee_name)
1928 || fd.symbol_metadata.contains_key(&callee_name)
1929 })
1930 .unwrap_or(false);
1931
1932 if has_local {
1933 let (params, target_line) = {
1935 let Some(fd) = self.data.get(file) else {
1936 return;
1937 };
1938 let meta = fd.symbol_metadata.get(&callee_name);
1939 let sig = meta.and_then(|m| m.signature.clone());
1940 let params = sig
1941 .as_deref()
1942 .map(|s| extract_parameters(s, fd.lang))
1943 .unwrap_or_default();
1944 let line = meta.map(|m| m.line).unwrap_or(1);
1945 (params, line)
1946 };
1947
1948 let file_rel = self.relative_path(file);
1949
1950 for (pos, _tracked) in &arg_positions {
1951 if let Some(param_name) = params.get(*pos) {
1952 hops.push(DataFlowHop {
1953 file: file_rel.clone(),
1954 symbol: callee_name.clone(),
1955 variable: param_name.clone(),
1956 line: target_line,
1957 flow_type: "parameter".to_string(),
1958 approximate: false,
1959 });
1960
1961 self.trace_data_inner(
1963 file,
1964 &callee_name.clone(),
1965 param_name,
1966 max_depth,
1967 current_depth + 1,
1968 hops,
1969 depth_limited,
1970 visited,
1971 );
1972 }
1973 }
1974 } else {
1975 for (_pos, tracked) in &arg_positions {
1977 hops.push(DataFlowHop {
1978 file: self.relative_path(file),
1979 symbol: callee_name.clone(),
1980 variable: tracked.clone(),
1981 line: node.start_position().row as u32 + 1,
1982 flow_type: "parameter".to_string(),
1983 approximate: true,
1984 });
1985 }
1986 }
1987 }
1988 }
1989 }
1990
1991 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1993 let content = std::fs::read_to_string(path).ok()?;
1994 content
1995 .lines()
1996 .nth(line.saturating_sub(1) as usize)
1997 .map(|l| l.trim().to_string())
1998 }
1999
2000 fn collect_callers_recursive(
2002 &self,
2003 file: &Path,
2004 symbol: &str,
2005 max_depth: usize,
2006 current_depth: usize,
2007 visited: &mut HashSet<(PathBuf, SharedStr)>,
2008 result: &mut Vec<CallerSite>,
2009 ) {
2010 if current_depth >= max_depth {
2011 return;
2012 }
2013
2014 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2016 let key_symbol: SharedStr = Arc::from(symbol);
2017 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2018 return; }
2020
2021 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2022 for site in sites {
2023 result.push(CallerSite {
2024 caller_file: site.caller_file.as_ref().clone(),
2025 caller_symbol: site.caller_symbol.to_string(),
2026 line: site.line,
2027 col: site.col,
2028 resolved: site.resolved,
2029 });
2030 if current_depth + 1 < max_depth {
2032 self.collect_callers_recursive(
2033 site.caller_file.as_ref(),
2034 site.caller_symbol.as_ref(),
2035 max_depth,
2036 current_depth + 1,
2037 visited,
2038 result,
2039 );
2040 }
2041 }
2042 }
2043 }
2044
2045 pub fn invalidate_file(&mut self, path: &Path) {
2050 self.data.remove(path);
2052 if let Ok(canon) = self.canonicalize(path) {
2053 self.data.remove(&canon);
2054 }
2055 self.reverse_index = None;
2057 self.project_files = None;
2059 }
2060
2061 fn relative_path(&self, path: &Path) -> String {
2064 path.strip_prefix(&self.project_root)
2065 .unwrap_or(path)
2066 .display()
2067 .to_string()
2068 }
2069
2070 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2072 let full_path = if path.is_relative() {
2074 self.project_root.join(path)
2075 } else {
2076 path.to_path_buf()
2077 };
2078
2079 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2081 }
2082
2083 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2087 if let Some(fd) = self.data.get(path) {
2088 return Some(fd);
2089 }
2090 let canon = std::fs::canonicalize(path).ok()?;
2092 self.data.get(&canon).or_else(|| {
2093 self.data.iter().find_map(|(k, v)| {
2095 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2096 Some(v)
2097 } else {
2098 None
2099 }
2100 })
2101 })
2102 }
2103}
2104
2105fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2111 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2112 message: format!("unsupported file for call graph: {}", path.display()),
2113 })?;
2114
2115 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2116 path: format!("{}: {}", path.display(), e),
2117 })?;
2118
2119 let grammar = grammar_for(lang);
2120 let mut parser = Parser::new();
2121 parser
2122 .set_language(&grammar)
2123 .map_err(|e| AftError::ParseError {
2124 message: format!("grammar init failed for {:?}: {}", lang, e),
2125 })?;
2126
2127 let tree = parser
2128 .parse(&source, None)
2129 .ok_or_else(|| AftError::ParseError {
2130 message: format!("parse failed for {}", path.display()),
2131 })?;
2132
2133 let import_block = imports::parse_imports(&source, &tree, lang);
2135
2136 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2138
2139 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2141 let root = tree.root_node();
2142
2143 for sym in &symbols {
2144 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2145 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2146
2147 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2148
2149 let sites: Vec<CallSite> = raw_calls
2150 .into_iter()
2151 .filter(|(_, short, _)| *short != sym.name) .map(|(full, short, line)| CallSite {
2153 callee_name: short,
2154 full_callee: full,
2155 line,
2156 byte_start,
2157 byte_end,
2158 })
2159 .collect();
2160
2161 if !sites.is_empty() {
2162 calls_by_symbol.insert(sym.name.clone(), sites);
2163 }
2164 }
2165
2166 let symbol_ranges: Vec<(usize, usize)> = symbols
2167 .iter()
2168 .map(|sym| {
2169 (
2170 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2171 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2172 )
2173 })
2174 .collect();
2175
2176 let top_level_sites: Vec<CallSite> =
2177 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2178 .into_iter()
2179 .filter(|site| {
2180 !symbol_ranges
2181 .iter()
2182 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2183 })
2184 .map(|site| CallSite {
2185 callee_name: site.short,
2186 full_callee: site.full,
2187 line: site.line,
2188 byte_start: site.byte_start,
2189 byte_end: site.byte_end,
2190 })
2191 .collect();
2192
2193 if !top_level_sites.is_empty() {
2194 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2195 }
2196
2197 let default_export = find_default_export(&source, root, path, lang);
2198
2199 if let Some(default_export) = &default_export {
2200 if default_export.synthetic {
2201 let byte_start = default_export.node.byte_range().start;
2202 let byte_end = default_export.node.byte_range().end;
2203 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2204 let sites: Vec<CallSite> = raw_calls
2205 .into_iter()
2206 .filter(|(_, short, _)| *short != default_export.symbol)
2207 .map(|(full, short, line)| CallSite {
2208 callee_name: short,
2209 full_callee: full,
2210 line,
2211 byte_start,
2212 byte_end,
2213 })
2214 .collect();
2215 if !sites.is_empty() {
2216 calls_by_symbol.insert(default_export.symbol.clone(), sites);
2217 }
2218 }
2219 }
2220
2221 let mut exported_symbols: Vec<String> = symbols
2223 .iter()
2224 .filter(|s| s.exported)
2225 .map(|s| s.name.clone())
2226 .collect();
2227 if let Some(default_export) = &default_export {
2228 if !exported_symbols
2229 .iter()
2230 .any(|name| name == &default_export.symbol)
2231 {
2232 exported_symbols.push(default_export.symbol.clone());
2233 }
2234 }
2235
2236 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2238 .iter()
2239 .map(|s| {
2240 (
2241 s.name.clone(),
2242 SymbolMeta {
2243 kind: s.kind.clone(),
2244 exported: s.exported,
2245 signature: s.signature.clone(),
2246 line: s.range.start_line + 1,
2247 range: s.range.clone(),
2248 },
2249 )
2250 })
2251 .collect();
2252 if let Some(default_export) = &default_export {
2253 symbol_metadata
2254 .entry(default_export.symbol.clone())
2255 .or_insert_with(|| SymbolMeta {
2256 kind: default_export.kind.clone(),
2257 exported: true,
2258 signature: Some(first_line_signature(&source, &default_export.node)),
2259 line: default_export.node.start_position().row as u32 + 1,
2260 range: crate::parser::node_range(&default_export.node),
2261 });
2262 }
2263 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2264 symbol_metadata
2265 .entry(TOP_LEVEL_SYMBOL.to_string())
2266 .or_insert(SymbolMeta {
2267 kind: SymbolKind::Function,
2268 exported: false,
2269 signature: None,
2270 line: 1,
2271 range: Range {
2272 start_line: 0,
2273 start_col: 0,
2274 end_line: 0,
2275 end_col: 0,
2276 },
2277 });
2278 }
2279
2280 Ok(FileCallData {
2281 calls_by_symbol,
2282 exported_symbols,
2283 symbol_metadata,
2284 default_export_symbol: default_export.map(|export| export.symbol),
2285 import_block,
2286 lang,
2287 })
2288}
2289
2290#[derive(Debug, Clone)]
2291struct DefaultExport<'tree> {
2292 symbol: String,
2293 synthetic: bool,
2294 kind: SymbolKind,
2295 node: Node<'tree>,
2296}
2297
2298fn find_default_export<'tree>(
2299 source: &str,
2300 root: Node<'tree>,
2301 path: &Path,
2302 lang: LangId,
2303) -> Option<DefaultExport<'tree>> {
2304 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2305 return None;
2306 }
2307 find_default_export_inner(source, root, path)
2308}
2309
2310fn find_default_export_inner<'tree>(
2311 source: &str,
2312 node: Node<'tree>,
2313 path: &Path,
2314) -> Option<DefaultExport<'tree>> {
2315 if node.kind() == "export_statement" {
2316 if let Some(default_export) = default_export_from_statement(source, node, path) {
2317 return Some(default_export);
2318 }
2319 }
2320
2321 let mut cursor = node.walk();
2322 if !cursor.goto_first_child() {
2323 return None;
2324 }
2325
2326 loop {
2327 let child = cursor.node();
2328 if let Some(default_export) = find_default_export_inner(source, child, path) {
2329 return Some(default_export);
2330 }
2331 if !cursor.goto_next_sibling() {
2332 break;
2333 }
2334 }
2335
2336 None
2337}
2338
2339fn default_export_from_statement<'tree>(
2340 source: &str,
2341 node: Node<'tree>,
2342 path: &Path,
2343) -> Option<DefaultExport<'tree>> {
2344 let mut cursor = node.walk();
2345 if !cursor.goto_first_child() {
2346 return None;
2347 }
2348
2349 let mut saw_default = false;
2350 loop {
2351 let child = cursor.node();
2352 match child.kind() {
2353 "default" => saw_default = true,
2354 "function_declaration" | "generator_function_declaration" | "class_declaration"
2355 if saw_default =>
2356 {
2357 if let Some(name_node) = child.child_by_field_name("name") {
2358 return Some(DefaultExport {
2359 symbol: source[name_node.byte_range()].to_string(),
2360 synthetic: false,
2361 kind: default_export_kind(&child),
2362 node: child,
2363 });
2364 }
2365 return Some(DefaultExport {
2366 symbol: synthetic_default_symbol(path),
2367 synthetic: true,
2368 kind: default_export_kind(&child),
2369 node: child,
2370 });
2371 }
2372 "arrow_function"
2373 | "function"
2374 | "function_expression"
2375 | "class"
2376 | "class_expression"
2377 if saw_default =>
2378 {
2379 return Some(DefaultExport {
2380 symbol: synthetic_default_symbol(path),
2381 synthetic: true,
2382 kind: default_export_kind(&child),
2383 node: child,
2384 });
2385 }
2386 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
2387 return Some(DefaultExport {
2388 symbol: source[child.byte_range()].to_string(),
2389 synthetic: false,
2390 kind: SymbolKind::Function,
2391 node: child,
2392 });
2393 }
2394 _ => {}
2395 }
2396 if !cursor.goto_next_sibling() {
2397 break;
2398 }
2399 }
2400
2401 None
2402}
2403
2404fn default_export_kind(node: &Node) -> SymbolKind {
2405 if node.kind().contains("class") {
2406 SymbolKind::Class
2407 } else {
2408 SymbolKind::Function
2409 }
2410}
2411
2412fn synthetic_default_symbol(path: &Path) -> String {
2413 let file_name = path
2414 .file_name()
2415 .and_then(|name| name.to_str())
2416 .unwrap_or("unknown");
2417 format!("<default:{file_name}>")
2418}
2419
2420fn first_line_signature(source: &str, node: &Node) -> String {
2421 let text = &source[node.byte_range()];
2422 let first_line = text.lines().next().unwrap_or(text);
2423 first_line
2424 .trim_end()
2425 .trim_end_matches('{')
2426 .trim_end()
2427 .to_string()
2428}
2429
2430fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2431 file_data
2432 .symbol_metadata
2433 .get(symbol_name)
2434 .map(|meta| (meta.line, meta.signature.clone()))
2435 .unwrap_or((1, None))
2436}
2437
2438fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2440 let provider = crate::parser::TreeSitterProvider::new();
2441 match provider.list_symbols(path) {
2442 Ok(symbols) => {
2443 for s in &symbols {
2444 if s.name == symbol_name {
2445 return (s.range.start_line + 1, s.signature.clone());
2446 }
2447 }
2448 (1, None)
2449 }
2450 Err(_) => (1, None),
2451 }
2452}
2453
2454fn node_text(node: tree_sitter::Node, source: &str) -> String {
2460 source[node.start_byte()..node.end_byte()].to_string()
2461}
2462
2463fn find_node_covering_range(
2465 root: tree_sitter::Node,
2466 start: usize,
2467 end: usize,
2468) -> Option<tree_sitter::Node> {
2469 let mut best = None;
2470 let mut cursor = root.walk();
2471
2472 fn walk_covering<'a>(
2473 cursor: &mut tree_sitter::TreeCursor<'a>,
2474 start: usize,
2475 end: usize,
2476 best: &mut Option<tree_sitter::Node<'a>>,
2477 ) {
2478 let node = cursor.node();
2479 if node.start_byte() <= start && node.end_byte() >= end {
2480 *best = Some(node);
2481 if cursor.goto_first_child() {
2482 loop {
2483 walk_covering(cursor, start, end, best);
2484 if !cursor.goto_next_sibling() {
2485 break;
2486 }
2487 }
2488 cursor.goto_parent();
2489 }
2490 }
2491 }
2492
2493 walk_covering(&mut cursor, start, end, &mut best);
2494 best
2495}
2496
2497fn find_child_by_kind<'a>(
2499 node: tree_sitter::Node<'a>,
2500 kind: &str,
2501) -> Option<tree_sitter::Node<'a>> {
2502 let mut cursor = node.walk();
2503 if cursor.goto_first_child() {
2504 loop {
2505 if cursor.node().kind() == kind {
2506 return Some(cursor.node());
2507 }
2508 if !cursor.goto_next_sibling() {
2509 break;
2510 }
2511 }
2512 }
2513 None
2514}
2515
2516#[derive(Debug, Clone)]
2517struct CallSiteWithRange {
2518 full: String,
2519 short: String,
2520 line: u32,
2521 byte_start: usize,
2522 byte_end: usize,
2523}
2524
2525fn collect_calls_full_with_ranges(
2526 root: tree_sitter::Node,
2527 source: &str,
2528 byte_start: usize,
2529 byte_end: usize,
2530 lang: LangId,
2531) -> Vec<CallSiteWithRange> {
2532 let mut results = Vec::new();
2533 let call_kinds = call_node_kinds(lang);
2534 collect_calls_full_with_ranges_inner(
2535 root,
2536 source,
2537 byte_start,
2538 byte_end,
2539 &call_kinds,
2540 &mut results,
2541 );
2542 results
2543}
2544
2545fn collect_calls_full_with_ranges_inner(
2546 node: tree_sitter::Node,
2547 source: &str,
2548 byte_start: usize,
2549 byte_end: usize,
2550 call_kinds: &[&str],
2551 results: &mut Vec<CallSiteWithRange>,
2552) {
2553 let node_start = node.start_byte();
2554 let node_end = node.end_byte();
2555
2556 if node_end <= byte_start || node_start >= byte_end {
2557 return;
2558 }
2559
2560 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
2561 if let (Some(full), Some(short)) = (
2562 extract_full_callee(&node, source),
2563 extract_callee_name(&node, source),
2564 ) {
2565 results.push(CallSiteWithRange {
2566 full,
2567 short,
2568 line: node.start_position().row as u32 + 1,
2569 byte_start: node_start,
2570 byte_end: node_end,
2571 });
2572 }
2573 }
2574
2575 let mut cursor = node.walk();
2576 if cursor.goto_first_child() {
2577 loop {
2578 collect_calls_full_with_ranges_inner(
2579 cursor.node(),
2580 source,
2581 byte_start,
2582 byte_end,
2583 call_kinds,
2584 results,
2585 );
2586 if !cursor.goto_next_sibling() {
2587 break;
2588 }
2589 }
2590 }
2591}
2592
2593fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2595 let callee = match node.child_by_field_name("function") {
2597 Some(c) => c,
2598 None => return (None, None),
2599 };
2600
2601 let full = node_text(callee, source);
2602 let short = if full.contains('.') {
2603 full.rsplit('.').next().unwrap_or(&full).to_string()
2604 } else {
2605 full.clone()
2606 };
2607
2608 (Some(full), Some(short))
2609}
2610
2611pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2619 if module_path.starts_with('.') {
2620 return resolve_relative_module_path(from_dir, module_path);
2621 }
2622
2623 if module_path.starts_with('/') {
2624 return None;
2625 }
2626
2627 resolve_workspace_module_path(from_dir, module_path)
2628}
2629
2630fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2631 let base = from_dir.join(module_path);
2632 resolve_file_like_path(&base)
2633}
2634
2635fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
2636 let base = base.to_path_buf();
2637
2638 if base.is_file() {
2640 return Some(std::fs::canonicalize(&base).unwrap_or(base));
2641 }
2642
2643 let extensions = [".ts", ".tsx", ".js", ".jsx"];
2645 for ext in &extensions {
2646 let with_ext = base.with_extension(ext.trim_start_matches('.'));
2647 if with_ext.is_file() {
2648 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2649 }
2650 }
2651
2652 if base.is_dir() {
2654 if let Some(index) = find_index_file(&base) {
2655 return Some(index);
2656 }
2657 }
2658
2659 None
2660}
2661
2662fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2663 let (package_name, subpath) = split_package_import(module_path)?;
2664 let package_root = find_package_root_for_import(from_dir, &package_name)?;
2665 resolve_package_entry(&package_root, &subpath)
2666}
2667
2668fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2669 let mut parts = module_path.split('/');
2670 let first = parts.next()?;
2671 if first.is_empty() {
2672 return None;
2673 }
2674
2675 if first.starts_with('@') {
2676 let second = parts.next()?;
2677 if second.is_empty() {
2678 return None;
2679 }
2680 let package_name = format!("{first}/{second}");
2681 let subpath = parts.collect::<Vec<_>>().join("/");
2682 let subpath = (!subpath.is_empty()).then_some(subpath);
2683 Some((package_name, subpath))
2684 } else {
2685 let package_name = first.to_string();
2686 let subpath = parts.collect::<Vec<_>>().join("/");
2687 let subpath = (!subpath.is_empty()).then_some(subpath);
2688 Some((package_name, subpath))
2689 }
2690}
2691
2692fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2693 let mut current = Some(from_dir);
2694 while let Some(dir) = current {
2695 if package_json_name(dir).as_deref() == Some(package_name) {
2696 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2697 }
2698 current = dir.parent();
2699 }
2700
2701 find_workspace_root(from_dir)
2702 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2703}
2704
2705fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2706 let mut current = Some(from_dir);
2707 while let Some(dir) = current {
2708 if is_workspace_root(dir) {
2709 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2710 }
2711 current = dir.parent();
2712 }
2713 None
2714}
2715
2716fn is_workspace_root(dir: &Path) -> bool {
2717 package_json_value(dir)
2718 .and_then(|value| value.get("workspaces").cloned())
2719 .is_some()
2720 || dir.join("bun.lock").is_file()
2721 || dir.join("package-lock.json").is_file()
2722}
2723
2724fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2725 let workspace_root =
2726 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2727 let cache_key = (workspace_root.clone(), package_name.to_string());
2728
2729 if let Some(cached) = WORKSPACE_PACKAGE_CACHE
2730 .read()
2731 .ok()
2732 .and_then(|cache| cache.get(&cache_key).cloned())
2733 {
2734 return cached;
2735 }
2736
2737 let resolved = workspace_member_dirs(&workspace_root)
2738 .into_iter()
2739 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2740 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2741
2742 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2743 cache.insert(cache_key, resolved.clone());
2744 }
2745
2746 resolved
2747}
2748
2749fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2750 let Some(package_json) = package_json_value(workspace_root) else {
2751 return Vec::new();
2752 };
2753
2754 workspace_patterns(&package_json)
2755 .into_iter()
2756 .flat_map(|pattern| expand_workspace_pattern(workspace_root, &pattern))
2757 .collect()
2758}
2759
2760fn workspace_patterns(package_json: &Value) -> Vec<String> {
2761 match package_json.get("workspaces") {
2762 Some(Value::Array(items)) => items
2763 .iter()
2764 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
2765 .collect(),
2766 Some(Value::Object(map)) => map
2767 .get("packages")
2768 .and_then(Value::as_array)
2769 .map(|items| {
2770 items
2771 .iter()
2772 .filter_map(|item| item.as_str().map(ToOwned::to_owned))
2773 .collect()
2774 })
2775 .unwrap_or_default(),
2776 _ => Vec::new(),
2777 }
2778}
2779
2780fn expand_workspace_pattern(workspace_root: &Path, pattern: &str) -> Vec<PathBuf> {
2781 if let Some(prefix) = pattern.strip_suffix("/*") {
2782 let parent = workspace_root.join(prefix);
2783 return std::fs::read_dir(parent)
2784 .ok()
2785 .into_iter()
2786 .flat_map(|entries| entries.filter_map(Result::ok))
2787 .map(|entry| entry.path())
2788 .filter(|path| path.join("package.json").is_file())
2789 .collect();
2790 }
2791
2792 let dir = workspace_root.join(pattern);
2793 if dir.join("package.json").is_file() {
2794 vec![dir]
2795 } else {
2796 Vec::new()
2797 }
2798}
2799
2800fn package_json_value(dir: &Path) -> Option<Value> {
2801 let package_json = std::fs::read_to_string(dir.join("package.json")).ok()?;
2802 serde_json::from_str(&package_json).ok()
2803}
2804
2805fn package_json_name(dir: &Path) -> Option<String> {
2806 package_json_value(dir)?
2807 .get("name")?
2808 .as_str()
2809 .map(ToOwned::to_owned)
2810}
2811
2812fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
2813 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
2814
2815 if let Some(exports) = package_json.get("exports") {
2816 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2817 if let Some(path) = resolve_package_target(package_root, &target) {
2818 return Some(path);
2819 }
2820 }
2821 }
2822
2823 if subpath.is_none() {
2824 for field in ["module", "main"] {
2825 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2826 if let Some(path) = resolve_package_target(package_root, target) {
2827 return Some(path);
2828 }
2829 }
2830 }
2831 }
2832
2833 resolve_package_fallback(package_root, subpath.as_deref())
2834}
2835
2836fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2837 let key = subpath
2838 .map(|value| format!("./{value}"))
2839 .unwrap_or_else(|| ".".to_string());
2840
2841 match exports {
2842 Value::String(target) if key == "." => Some(target.clone()),
2843 Value::Object(map) => {
2844 if let Some(target) = map.get(&key).and_then(export_condition_target) {
2845 return Some(target);
2846 }
2847
2848 if let Some(target) = wildcard_export_target(map, &key) {
2849 return Some(target);
2850 }
2851
2852 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2853 return export_condition_target(exports);
2854 }
2855
2856 None
2857 }
2858 _ => None,
2859 }
2860}
2861
2862fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2863 for (pattern, target) in map {
2864 let Some(star_index) = pattern.find('*') else {
2865 continue;
2866 };
2867 let (prefix, suffix_with_star) = pattern.split_at(star_index);
2868 let suffix = &suffix_with_star[1..];
2869 if !key.starts_with(prefix) || !key.ends_with(suffix) {
2870 continue;
2871 }
2872 let matched = &key[prefix.len()..key.len() - suffix.len()];
2873 if let Some(target_pattern) = export_condition_target(target) {
2874 return Some(target_pattern.replace('*', matched));
2875 }
2876 }
2877 None
2878}
2879
2880fn export_condition_target(value: &Value) -> Option<String> {
2881 match value {
2882 Value::String(target) => Some(target.clone()),
2883 Value::Object(map) => ["source", "import", "module", "default", "types"]
2884 .into_iter()
2885 .find_map(|field| map.get(field).and_then(export_condition_target)),
2886 _ => None,
2887 }
2888}
2889
2890fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2891 let target = target.strip_prefix("./").unwrap_or(target);
2892 if let Some(src_relative) = target.strip_prefix("dist/") {
2895 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2896 return Some(path);
2897 }
2898 }
2899
2900 resolve_file_like_path(&package_root.join(target))
2901}
2902
2903fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2904 match subpath {
2905 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2906 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2907 None => resolve_file_like_path(&package_root.join("src").join("index"))
2908 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2909 }
2910}
2911
2912fn resolve_reexported_symbol_path<F>(
2913 file: &Path,
2914 symbol_name: &str,
2915 file_exports_symbol: &mut F,
2916) -> Option<PathBuf>
2917where
2918 F: FnMut(&Path, &str) -> bool,
2919{
2920 let mut visited = HashSet::new();
2921 resolve_reexported_symbol_path_inner(file, symbol_name, file_exports_symbol, &mut visited)
2922}
2923
2924fn resolve_reexported_symbol_path_inner<F>(
2925 file: &Path,
2926 symbol_name: &str,
2927 file_exports_symbol: &mut F,
2928 visited: &mut HashSet<(PathBuf, String)>,
2929) -> Option<PathBuf>
2930where
2931 F: FnMut(&Path, &str) -> bool,
2932{
2933 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2934 if !visited.insert((canon.clone(), symbol_name.to_string())) {
2935 return None;
2936 }
2937
2938 if file_exports_symbol(&canon, symbol_name) {
2939 return Some(canon);
2940 }
2941
2942 let source = std::fs::read_to_string(&canon).ok()?;
2943 let lang = detect_language(&canon)?;
2944 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2945 return None;
2946 }
2947
2948 let grammar = grammar_for(lang);
2949 let mut parser = Parser::new();
2950 parser.set_language(&grammar).ok()?;
2951 let tree = parser.parse(&source, None)?;
2952 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2953
2954 let mut cursor = tree.root_node().walk();
2955 if !cursor.goto_first_child() {
2956 return None;
2957 }
2958
2959 loop {
2960 let node = cursor.node();
2961 if node.kind() == "export_statement" {
2962 if let Some(target) = resolve_reexport_statement(
2963 &source,
2964 node,
2965 from_dir,
2966 symbol_name,
2967 file_exports_symbol,
2968 visited,
2969 ) {
2970 return Some(target);
2971 }
2972 }
2973
2974 if !cursor.goto_next_sibling() {
2975 break;
2976 }
2977 }
2978
2979 None
2980}
2981
2982fn resolve_reexport_statement<F>(
2983 source: &str,
2984 node: tree_sitter::Node,
2985 from_dir: &Path,
2986 symbol_name: &str,
2987 file_exports_symbol: &mut F,
2988 visited: &mut HashSet<(PathBuf, String)>,
2989) -> Option<PathBuf>
2990where
2991 F: FnMut(&Path, &str) -> bool,
2992{
2993 let source_node = node.child_by_field_name("source")?;
2994 let module_path = string_literal_content(source, source_node)?;
2995 let target_file = resolve_module_path(from_dir, &module_path)?;
2996 let raw_export = node_text(node, source);
2997
2998 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2999 return resolve_reexported_symbol_path_inner(
3000 &target_file,
3001 &source_symbol,
3002 file_exports_symbol,
3003 visited,
3004 )
3005 .or(Some(target_file));
3006 }
3007
3008 if raw_export.contains('*') {
3009 return resolve_reexported_symbol_path_inner(
3010 &target_file,
3011 symbol_name,
3012 file_exports_symbol,
3013 visited,
3014 );
3015 }
3016
3017 None
3018}
3019
3020fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3021 let start = raw_export.find('{')? + 1;
3022 let end = raw_export[start..].find('}')? + start;
3023 for specifier in raw_export[start..end].split(',') {
3024 let specifier = specifier.trim();
3025 if specifier.is_empty() {
3026 continue;
3027 }
3028 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3029 if let Some((imported, exported)) = specifier.split_once(" as ") {
3030 if exported.trim() == requested_export {
3031 return Some(imported.trim().to_string());
3032 }
3033 } else if specifier == requested_export {
3034 return Some(requested_export.to_string());
3035 }
3036 }
3037 None
3038}
3039
3040fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3041 let raw = source[node.byte_range()].trim();
3042 let quote = raw.chars().next()?;
3043 if quote != '\'' && quote != '"' {
3044 return None;
3045 }
3046 raw.strip_prefix(quote)
3047 .and_then(|value| value.strip_suffix(quote))
3048 .map(ToOwned::to_owned)
3049}
3050
3051fn find_index_file(dir: &Path) -> Option<PathBuf> {
3053 let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
3054 for name in &candidates {
3055 let p = dir.join(name);
3056 if p.is_file() {
3057 return Some(std::fs::canonicalize(&p).unwrap_or(p));
3058 }
3059 }
3060 None
3061}
3062
3063fn resolve_aliased_import(
3066 local_name: &str,
3067 import_block: &ImportBlock,
3068 caller_dir: &Path,
3069) -> Option<(String, PathBuf)> {
3070 for imp in &import_block.imports {
3071 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3074 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3075 return Some((original, resolved_path));
3076 }
3077 }
3078 }
3079 None
3080}
3081
3082fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3086 let search = format!(" as {}", local_name);
3089 if let Some(pos) = raw_import.find(&search) {
3090 let before = &raw_import[..pos];
3092 let original = before
3094 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3095 .find(|s| !s.is_empty())?;
3096 return Some(original.to_string());
3097 }
3098 None
3099}
3100
3101pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3109 use ignore::WalkBuilder;
3110
3111 let walker = WalkBuilder::new(root)
3112 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .filter_entry(|entry| {
3117 let name = entry.file_name().to_string_lossy();
3118 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3120 return !matches!(
3121 name.as_ref(),
3122 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3123 | ".tox" | "dist" | "build"
3124 );
3125 }
3126 true
3127 })
3128 .build();
3129
3130 walker
3131 .filter_map(|entry| entry.ok())
3132 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3133 .filter(|entry| detect_language(entry.path()).is_some())
3134 .map(|entry| entry.into_path())
3135}
3136
3137#[cfg(test)]
3142mod tests {
3143 use super::*;
3144 use std::fs;
3145 use tempfile::TempDir;
3146
3147 fn setup_ts_project() -> TempDir {
3149 let dir = TempDir::new().unwrap();
3150
3151 fs::write(
3153 dir.path().join("main.ts"),
3154 r#"import { helper, compute } from './utils';
3155import * as math from './math';
3156
3157export function main() {
3158 const a = helper(1);
3159 const b = compute(a, 2);
3160 const c = math.add(a, b);
3161 return c;
3162}
3163"#,
3164 )
3165 .unwrap();
3166
3167 fs::write(
3169 dir.path().join("utils.ts"),
3170 r#"import { double } from './helpers';
3171
3172export function helper(x: number): number {
3173 return double(x);
3174}
3175
3176export function compute(a: number, b: number): number {
3177 return a + b;
3178}
3179"#,
3180 )
3181 .unwrap();
3182
3183 fs::write(
3185 dir.path().join("helpers.ts"),
3186 r#"export function double(x: number): number {
3187 return x * 2;
3188}
3189
3190export function triple(x: number): number {
3191 return x * 3;
3192}
3193"#,
3194 )
3195 .unwrap();
3196
3197 fs::write(
3199 dir.path().join("math.ts"),
3200 r#"export function add(a: number, b: number): number {
3201 return a + b;
3202}
3203
3204export function subtract(a: number, b: number): number {
3205 return a - b;
3206}
3207"#,
3208 )
3209 .unwrap();
3210
3211 dir
3212 }
3213
3214 fn setup_alias_project() -> TempDir {
3216 let dir = TempDir::new().unwrap();
3217
3218 fs::write(
3219 dir.path().join("main.ts"),
3220 r#"import { helper as h } from './utils';
3221
3222export function main() {
3223 return h(42);
3224}
3225"#,
3226 )
3227 .unwrap();
3228
3229 fs::write(
3230 dir.path().join("utils.ts"),
3231 r#"export function helper(x: number): number {
3232 return x + 1;
3233}
3234"#,
3235 )
3236 .unwrap();
3237
3238 dir
3239 }
3240
3241 fn setup_cycle_project() -> TempDir {
3243 let dir = TempDir::new().unwrap();
3244
3245 fs::write(
3246 dir.path().join("a.ts"),
3247 r#"import { funcB } from './b';
3248
3249export function funcA() {
3250 return funcB();
3251}
3252"#,
3253 )
3254 .unwrap();
3255
3256 fs::write(
3257 dir.path().join("b.ts"),
3258 r#"import { funcA } from './a';
3259
3260export function funcB() {
3261 return funcA();
3262}
3263"#,
3264 )
3265 .unwrap();
3266
3267 dir
3268 }
3269
3270 #[test]
3273 fn callgraph_single_file_call_extraction() {
3274 let dir = setup_ts_project();
3275 let mut graph = CallGraph::new(dir.path().to_path_buf());
3276
3277 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3278 let main_calls = &file_data.calls_by_symbol["main"];
3279
3280 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3281 assert!(
3282 callee_names.contains(&"helper"),
3283 "main should call helper, got: {:?}",
3284 callee_names
3285 );
3286 assert!(
3287 callee_names.contains(&"compute"),
3288 "main should call compute, got: {:?}",
3289 callee_names
3290 );
3291 assert!(
3292 callee_names.contains(&"add"),
3293 "main should call math.add (short name: add), got: {:?}",
3294 callee_names
3295 );
3296 }
3297
3298 #[test]
3299 fn callgraph_file_data_has_exports() {
3300 let dir = setup_ts_project();
3301 let mut graph = CallGraph::new(dir.path().to_path_buf());
3302
3303 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3304 assert!(
3305 file_data.exported_symbols.contains(&"helper".to_string()),
3306 "utils.ts should export helper, got: {:?}",
3307 file_data.exported_symbols
3308 );
3309 assert!(
3310 file_data.exported_symbols.contains(&"compute".to_string()),
3311 "utils.ts should export compute, got: {:?}",
3312 file_data.exported_symbols
3313 );
3314 }
3315
3316 #[test]
3319 fn callgraph_resolve_direct_import() {
3320 let dir = setup_ts_project();
3321 let mut graph = CallGraph::new(dir.path().to_path_buf());
3322
3323 let main_path = dir.path().join("main.ts");
3324 let file_data = graph.build_file(&main_path).unwrap();
3325 let import_block = file_data.import_block.clone();
3326
3327 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3328 match edge {
3329 EdgeResolution::Resolved { file, symbol } => {
3330 assert!(
3331 file.ends_with("utils.ts"),
3332 "helper should resolve to utils.ts, got: {:?}",
3333 file
3334 );
3335 assert_eq!(symbol, "helper");
3336 }
3337 EdgeResolution::Unresolved { callee_name } => {
3338 panic!("Expected resolved, got unresolved: {}", callee_name);
3339 }
3340 }
3341 }
3342
3343 #[test]
3344 fn callgraph_resolve_namespace_import() {
3345 let dir = setup_ts_project();
3346 let mut graph = CallGraph::new(dir.path().to_path_buf());
3347
3348 let main_path = dir.path().join("main.ts");
3349 let file_data = graph.build_file(&main_path).unwrap();
3350 let import_block = file_data.import_block.clone();
3351
3352 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3353 match edge {
3354 EdgeResolution::Resolved { file, symbol } => {
3355 assert!(
3356 file.ends_with("math.ts"),
3357 "math.add should resolve to math.ts, got: {:?}",
3358 file
3359 );
3360 assert_eq!(symbol, "add");
3361 }
3362 EdgeResolution::Unresolved { callee_name } => {
3363 panic!("Expected resolved, got unresolved: {}", callee_name);
3364 }
3365 }
3366 }
3367
3368 #[test]
3369 fn callgraph_resolve_aliased_import() {
3370 let dir = setup_alias_project();
3371 let mut graph = CallGraph::new(dir.path().to_path_buf());
3372
3373 let main_path = dir.path().join("main.ts");
3374 let file_data = graph.build_file(&main_path).unwrap();
3375 let import_block = file_data.import_block.clone();
3376
3377 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3378 match edge {
3379 EdgeResolution::Resolved { file, symbol } => {
3380 assert!(
3381 file.ends_with("utils.ts"),
3382 "h (alias for helper) should resolve to utils.ts, got: {:?}",
3383 file
3384 );
3385 assert_eq!(symbol, "helper");
3386 }
3387 EdgeResolution::Unresolved { callee_name } => {
3388 panic!("Expected resolved, got unresolved: {}", callee_name);
3389 }
3390 }
3391 }
3392
3393 #[test]
3394 fn callgraph_unresolved_edge_marked() {
3395 let dir = setup_ts_project();
3396 let mut graph = CallGraph::new(dir.path().to_path_buf());
3397
3398 let main_path = dir.path().join("main.ts");
3399 let file_data = graph.build_file(&main_path).unwrap();
3400 let import_block = file_data.import_block.clone();
3401
3402 let edge =
3403 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3404 assert_eq!(
3405 edge,
3406 EdgeResolution::Unresolved {
3407 callee_name: "unknownFunc".to_string()
3408 },
3409 "Unknown callee should be unresolved"
3410 );
3411 }
3412
3413 #[test]
3416 fn callgraph_cycle_detection_stops() {
3417 let dir = setup_cycle_project();
3418 let mut graph = CallGraph::new(dir.path().to_path_buf());
3419
3420 let tree = graph
3422 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
3423 .unwrap();
3424
3425 assert_eq!(tree.name, "funcA");
3426 assert!(tree.resolved);
3427
3428 fn count_depth(node: &CallTreeNode) -> usize {
3431 if node.children.is_empty() {
3432 1
3433 } else {
3434 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
3435 }
3436 }
3437
3438 let depth = count_depth(&tree);
3439 assert!(
3440 depth <= 4,
3441 "Cycle should be detected and bounded, depth was: {}",
3442 depth
3443 );
3444 }
3445
3446 #[test]
3449 fn callgraph_depth_limit_truncates() {
3450 let dir = setup_ts_project();
3451 let mut graph = CallGraph::new(dir.path().to_path_buf());
3452
3453 let tree = graph
3456 .forward_tree(&dir.path().join("main.ts"), "main", 1)
3457 .unwrap();
3458
3459 assert_eq!(tree.name, "main");
3460
3461 for child in &tree.children {
3463 assert!(
3464 child.children.is_empty(),
3465 "At depth 1, child '{}' should have no children, got {:?}",
3466 child.name,
3467 child.children.len()
3468 );
3469 }
3470 }
3471
3472 #[test]
3473 fn callgraph_depth_zero_no_children() {
3474 let dir = setup_ts_project();
3475 let mut graph = CallGraph::new(dir.path().to_path_buf());
3476
3477 let tree = graph
3478 .forward_tree(&dir.path().join("main.ts"), "main", 0)
3479 .unwrap();
3480
3481 assert_eq!(tree.name, "main");
3482 assert!(
3483 tree.children.is_empty(),
3484 "At depth 0, should have no children"
3485 );
3486 }
3487
3488 #[test]
3491 fn callgraph_forward_tree_cross_file() {
3492 let dir = setup_ts_project();
3493 let mut graph = CallGraph::new(dir.path().to_path_buf());
3494
3495 let tree = graph
3497 .forward_tree(&dir.path().join("main.ts"), "main", 5)
3498 .unwrap();
3499
3500 assert_eq!(tree.name, "main");
3501 assert!(tree.resolved);
3502
3503 let helper_child = tree.children.iter().find(|c| c.name == "helper");
3505 assert!(
3506 helper_child.is_some(),
3507 "main should have helper as child, children: {:?}",
3508 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3509 );
3510
3511 let helper = helper_child.unwrap();
3512 assert!(
3513 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
3514 "helper should be in utils.ts, got: {}",
3515 helper.file
3516 );
3517
3518 let double_child = helper.children.iter().find(|c| c.name == "double");
3520 assert!(
3521 double_child.is_some(),
3522 "helper should call double, children: {:?}",
3523 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3524 );
3525
3526 let double = double_child.unwrap();
3527 assert!(
3528 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
3529 "double should be in helpers.ts, got: {}",
3530 double.file
3531 );
3532 }
3533
3534 #[test]
3537 fn callgraph_walker_excludes_gitignored() {
3538 let dir = TempDir::new().unwrap();
3539
3540 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3542
3543 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3545 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3546 fs::write(
3547 dir.path().join("ignored_dir").join("secret.ts"),
3548 "export function secret() {}",
3549 )
3550 .unwrap();
3551
3552 fs::create_dir(dir.path().join("node_modules")).unwrap();
3554 fs::write(
3555 dir.path().join("node_modules").join("dep.ts"),
3556 "export function dep() {}",
3557 )
3558 .unwrap();
3559
3560 std::process::Command::new("git")
3562 .args(["init"])
3563 .current_dir(dir.path())
3564 .output()
3565 .unwrap();
3566
3567 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3568 let file_names: Vec<String> = files
3569 .iter()
3570 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3571 .collect();
3572
3573 assert!(
3574 file_names.contains(&"main.ts".to_string()),
3575 "Should include main.ts, got: {:?}",
3576 file_names
3577 );
3578 assert!(
3579 !file_names.contains(&"secret.ts".to_string()),
3580 "Should exclude gitignored secret.ts, got: {:?}",
3581 file_names
3582 );
3583 assert!(
3584 !file_names.contains(&"dep.ts".to_string()),
3585 "Should exclude node_modules, got: {:?}",
3586 file_names
3587 );
3588 }
3589
3590 #[test]
3591 fn callgraph_walker_only_source_files() {
3592 let dir = TempDir::new().unwrap();
3593
3594 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3595 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3596 fs::write(dir.path().join("data.json"), "{}").unwrap();
3597
3598 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3599 let file_names: Vec<String> = files
3600 .iter()
3601 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3602 .collect();
3603
3604 assert!(file_names.contains(&"main.ts".to_string()));
3605 assert!(
3606 file_names.contains(&"readme.md".to_string()),
3607 "Markdown is now a supported source language"
3608 );
3609 assert!(
3610 file_names.contains(&"data.json".to_string()),
3611 "JSON is now a supported source language"
3612 );
3613 }
3614
3615 #[test]
3618 fn callgraph_find_alias_original_simple() {
3619 let raw = "import { foo as bar } from './utils';";
3620 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3621 }
3622
3623 #[test]
3624 fn callgraph_find_alias_original_multiple() {
3625 let raw = "import { foo as bar, baz as qux } from './utils';";
3626 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3627 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3628 }
3629
3630 #[test]
3631 fn callgraph_find_alias_no_match() {
3632 let raw = "import { foo } from './utils';";
3633 assert_eq!(find_alias_original(raw, "foo"), None);
3634 }
3635
3636 #[test]
3639 fn callgraph_callers_of_direct() {
3640 let dir = setup_ts_project();
3641 let mut graph = CallGraph::new(dir.path().to_path_buf());
3642
3643 let result = graph
3645 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
3646 .unwrap();
3647
3648 assert_eq!(result.symbol, "double");
3649 assert!(result.total_callers > 0, "double should have callers");
3650 assert!(result.scanned_files > 0, "should have scanned files");
3651
3652 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
3654 assert!(
3655 utils_group.is_some(),
3656 "double should be called from utils.ts, groups: {:?}",
3657 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
3658 );
3659
3660 let group = utils_group.unwrap();
3661 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
3662 assert!(
3663 helper_caller.is_some(),
3664 "double should be called by helper, callers: {:?}",
3665 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
3666 );
3667 }
3668
3669 #[test]
3670 fn callgraph_callers_of_no_callers() {
3671 let dir = setup_ts_project();
3672 let mut graph = CallGraph::new(dir.path().to_path_buf());
3673
3674 let result = graph
3676 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
3677 .unwrap();
3678
3679 assert_eq!(result.symbol, "main");
3680 assert_eq!(result.total_callers, 0, "main should have no callers");
3681 assert!(result.callers.is_empty());
3682 }
3683
3684 #[test]
3685 fn callgraph_callers_recursive_depth() {
3686 let dir = setup_ts_project();
3687 let mut graph = CallGraph::new(dir.path().to_path_buf());
3688
3689 let result = graph
3693 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
3694 .unwrap();
3695
3696 assert!(
3697 result.total_callers >= 2,
3698 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
3699 result.total_callers
3700 );
3701
3702 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
3704 assert!(
3705 main_group.is_some(),
3706 "recursive callers should include main.ts, groups: {:?}",
3707 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
3708 );
3709 }
3710
3711 #[test]
3712 fn callgraph_invalidate_file_clears_reverse_index() {
3713 let dir = setup_ts_project();
3714 let mut graph = CallGraph::new(dir.path().to_path_buf());
3715
3716 let _ = graph
3718 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
3719 .unwrap();
3720 assert!(
3721 graph.reverse_index.is_some(),
3722 "reverse index should be built"
3723 );
3724
3725 graph.invalidate_file(&dir.path().join("utils.ts"));
3727
3728 assert!(
3730 graph.reverse_index.is_none(),
3731 "invalidate_file should clear reverse index"
3732 );
3733 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
3735 assert!(
3736 !graph.data.contains_key(&canon),
3737 "invalidate_file should remove file from data cache"
3738 );
3739 assert!(
3741 graph.project_files.is_none(),
3742 "invalidate_file should clear project_files"
3743 );
3744 }
3745
3746 #[test]
3749 fn is_entry_point_exported_function() {
3750 assert!(is_entry_point(
3751 "handleRequest",
3752 &SymbolKind::Function,
3753 true,
3754 LangId::TypeScript
3755 ));
3756 }
3757
3758 #[test]
3759 fn is_entry_point_exported_method_is_not_entry() {
3760 assert!(!is_entry_point(
3762 "handleRequest",
3763 &SymbolKind::Method,
3764 true,
3765 LangId::TypeScript
3766 ));
3767 }
3768
3769 #[test]
3770 fn is_entry_point_main_init_patterns() {
3771 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3772 assert!(
3773 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3774 "{} should be an entry point",
3775 name
3776 );
3777 }
3778 }
3779
3780 #[test]
3781 fn is_entry_point_test_patterns_ts() {
3782 assert!(is_entry_point(
3783 "describe",
3784 &SymbolKind::Function,
3785 false,
3786 LangId::TypeScript
3787 ));
3788 assert!(is_entry_point(
3789 "it",
3790 &SymbolKind::Function,
3791 false,
3792 LangId::TypeScript
3793 ));
3794 assert!(is_entry_point(
3795 "test",
3796 &SymbolKind::Function,
3797 false,
3798 LangId::TypeScript
3799 ));
3800 assert!(is_entry_point(
3801 "testValidation",
3802 &SymbolKind::Function,
3803 false,
3804 LangId::TypeScript
3805 ));
3806 assert!(is_entry_point(
3807 "specHelper",
3808 &SymbolKind::Function,
3809 false,
3810 LangId::TypeScript
3811 ));
3812 }
3813
3814 #[test]
3815 fn is_entry_point_test_patterns_python() {
3816 assert!(is_entry_point(
3817 "test_login",
3818 &SymbolKind::Function,
3819 false,
3820 LangId::Python
3821 ));
3822 assert!(is_entry_point(
3823 "setUp",
3824 &SymbolKind::Function,
3825 false,
3826 LangId::Python
3827 ));
3828 assert!(is_entry_point(
3829 "tearDown",
3830 &SymbolKind::Function,
3831 false,
3832 LangId::Python
3833 ));
3834 assert!(!is_entry_point(
3836 "testSomething",
3837 &SymbolKind::Function,
3838 false,
3839 LangId::Python
3840 ));
3841 }
3842
3843 #[test]
3844 fn is_entry_point_test_patterns_rust() {
3845 assert!(is_entry_point(
3846 "test_parse",
3847 &SymbolKind::Function,
3848 false,
3849 LangId::Rust
3850 ));
3851 assert!(!is_entry_point(
3852 "TestSomething",
3853 &SymbolKind::Function,
3854 false,
3855 LangId::Rust
3856 ));
3857 }
3858
3859 #[test]
3860 fn is_entry_point_test_patterns_go() {
3861 assert!(is_entry_point(
3862 "TestParsing",
3863 &SymbolKind::Function,
3864 false,
3865 LangId::Go
3866 ));
3867 assert!(!is_entry_point(
3869 "testParsing",
3870 &SymbolKind::Function,
3871 false,
3872 LangId::Go
3873 ));
3874 }
3875
3876 #[test]
3877 fn is_entry_point_non_exported_non_main_is_not_entry() {
3878 assert!(!is_entry_point(
3879 "helperUtil",
3880 &SymbolKind::Function,
3881 false,
3882 LangId::TypeScript
3883 ));
3884 }
3885
3886 #[test]
3889 fn callgraph_symbol_metadata_populated() {
3890 let dir = setup_ts_project();
3891 let mut graph = CallGraph::new(dir.path().to_path_buf());
3892
3893 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3894 assert!(
3895 file_data.symbol_metadata.contains_key("helper"),
3896 "symbol_metadata should contain helper"
3897 );
3898 let meta = &file_data.symbol_metadata["helper"];
3899 assert_eq!(meta.kind, SymbolKind::Function);
3900 assert!(meta.exported, "helper should be exported");
3901 }
3902
3903 fn setup_trace_project() -> TempDir {
3919 let dir = TempDir::new().unwrap();
3920
3921 fs::write(
3922 dir.path().join("main.ts"),
3923 r#"import { processData } from './utils';
3924
3925export function main() {
3926 const result = processData("hello");
3927 return result;
3928}
3929"#,
3930 )
3931 .unwrap();
3932
3933 fs::write(
3934 dir.path().join("service.ts"),
3935 r#"import { processData } from './utils';
3936
3937export function handleRequest(input: string): string {
3938 return processData(input);
3939}
3940"#,
3941 )
3942 .unwrap();
3943
3944 fs::write(
3945 dir.path().join("utils.ts"),
3946 r#"import { validate } from './helpers';
3947
3948export function processData(input: string): string {
3949 const valid = validate(input);
3950 if (!valid) {
3951 throw new Error("invalid input");
3952 }
3953 return input.toUpperCase();
3954}
3955"#,
3956 )
3957 .unwrap();
3958
3959 fs::write(
3960 dir.path().join("helpers.ts"),
3961 r#"export function validate(input: string): boolean {
3962 return checkFormat(input);
3963}
3964
3965function checkFormat(input: string): boolean {
3966 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3967}
3968"#,
3969 )
3970 .unwrap();
3971
3972 fs::write(
3973 dir.path().join("test_helpers.ts"),
3974 r#"import { validate } from './helpers';
3975
3976function testValidation() {
3977 const result = validate("hello");
3978 console.log(result);
3979}
3980"#,
3981 )
3982 .unwrap();
3983
3984 std::process::Command::new("git")
3986 .args(["init"])
3987 .current_dir(dir.path())
3988 .output()
3989 .unwrap();
3990
3991 dir
3992 }
3993
3994 #[test]
3995 fn trace_to_multi_path() {
3996 let dir = setup_trace_project();
3997 let mut graph = CallGraph::new(dir.path().to_path_buf());
3998
3999 let result = graph
4000 .trace_to(
4001 &dir.path().join("helpers.ts"),
4002 "checkFormat",
4003 10,
4004 usize::MAX,
4005 )
4006 .unwrap();
4007
4008 assert_eq!(result.target_symbol, "checkFormat");
4009 assert!(
4010 result.total_paths >= 2,
4011 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
4012 result.total_paths,
4013 result
4014 .paths
4015 .iter()
4016 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
4017 .collect::<Vec<_>>()
4018 );
4019
4020 for path in &result.paths {
4022 assert!(
4023 path.hops.first().unwrap().is_entry_point,
4024 "First hop should be an entry point, got: {}",
4025 path.hops.first().unwrap().symbol
4026 );
4027 assert_eq!(
4028 path.hops.last().unwrap().symbol,
4029 "checkFormat",
4030 "Last hop should be checkFormat"
4031 );
4032 }
4033
4034 assert!(
4036 result.entry_points_found >= 2,
4037 "should find at least 2 entry points, got {}",
4038 result.entry_points_found
4039 );
4040 }
4041
4042 #[test]
4043 fn trace_to_single_path() {
4044 let dir = setup_trace_project();
4045 let mut graph = CallGraph::new(dir.path().to_path_buf());
4046
4047 let result = graph
4051 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
4052 .unwrap();
4053
4054 assert_eq!(result.target_symbol, "validate");
4055 assert!(
4056 result.total_paths >= 2,
4057 "validate should have at least 2 paths, got {}",
4058 result.total_paths
4059 );
4060 }
4061
4062 #[test]
4063 fn trace_to_cycle_detection() {
4064 let dir = setup_cycle_project();
4065 let mut graph = CallGraph::new(dir.path().to_path_buf());
4066
4067 let result = graph
4069 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
4070 .unwrap();
4071
4072 assert_eq!(result.target_symbol, "funcA");
4074 }
4075
4076 #[test]
4077 fn trace_to_depth_limit() {
4078 let dir = setup_trace_project();
4079 let mut graph = CallGraph::new(dir.path().to_path_buf());
4080
4081 let result = graph
4083 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
4084 .unwrap();
4085
4086 assert_eq!(result.target_symbol, "checkFormat");
4090
4091 let deep_result = graph
4093 .trace_to(
4094 &dir.path().join("helpers.ts"),
4095 "checkFormat",
4096 10,
4097 usize::MAX,
4098 )
4099 .unwrap();
4100
4101 assert!(
4102 result.total_paths <= deep_result.total_paths,
4103 "shallow trace should find <= paths compared to deep: {} vs {}",
4104 result.total_paths,
4105 deep_result.total_paths
4106 );
4107 }
4108
4109 #[test]
4110 fn trace_to_entry_point_target() {
4111 let dir = setup_trace_project();
4112 let mut graph = CallGraph::new(dir.path().to_path_buf());
4113
4114 let result = graph
4116 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
4117 .unwrap();
4118
4119 assert_eq!(result.target_symbol, "main");
4120 assert!(
4121 result.total_paths >= 1,
4122 "main should have at least 1 path (itself), got {}",
4123 result.total_paths
4124 );
4125 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
4127 assert!(
4128 trivial.is_some(),
4129 "should have a trivial path with just the entry point itself"
4130 );
4131 }
4132
4133 #[test]
4136 fn extract_parameters_typescript() {
4137 let params = extract_parameters(
4138 "function processData(input: string, count: number): void",
4139 LangId::TypeScript,
4140 );
4141 assert_eq!(params, vec!["input", "count"]);
4142 }
4143
4144 #[test]
4145 fn extract_parameters_typescript_optional() {
4146 let params = extract_parameters(
4147 "function fetch(url: string, options?: RequestInit): Promise<Response>",
4148 LangId::TypeScript,
4149 );
4150 assert_eq!(params, vec!["url", "options"]);
4151 }
4152
4153 #[test]
4154 fn extract_parameters_typescript_defaults() {
4155 let params = extract_parameters(
4156 "function greet(name: string, greeting: string = \"hello\"): string",
4157 LangId::TypeScript,
4158 );
4159 assert_eq!(params, vec!["name", "greeting"]);
4160 }
4161
4162 #[test]
4163 fn extract_parameters_typescript_rest() {
4164 let params = extract_parameters(
4165 "function sum(...numbers: number[]): number",
4166 LangId::TypeScript,
4167 );
4168 assert_eq!(params, vec!["numbers"]);
4169 }
4170
4171 #[test]
4172 fn extract_parameters_python_self_skipped() {
4173 let params = extract_parameters(
4174 "def process(self, data: str, count: int) -> bool",
4175 LangId::Python,
4176 );
4177 assert_eq!(params, vec!["data", "count"]);
4178 }
4179
4180 #[test]
4181 fn extract_parameters_python_no_self() {
4182 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4183 assert_eq!(params, vec!["input"]);
4184 }
4185
4186 #[test]
4187 fn extract_parameters_python_star_args() {
4188 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4189 assert_eq!(params, vec!["args", "kwargs"]);
4190 }
4191
4192 #[test]
4193 fn extract_parameters_rust_self_skipped() {
4194 let params = extract_parameters(
4195 "fn process(&self, data: &str, count: usize) -> bool",
4196 LangId::Rust,
4197 );
4198 assert_eq!(params, vec!["data", "count"]);
4199 }
4200
4201 #[test]
4202 fn extract_parameters_rust_mut_self_skipped() {
4203 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4204 assert_eq!(params, vec!["value"]);
4205 }
4206
4207 #[test]
4208 fn extract_parameters_rust_no_self() {
4209 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4210 assert_eq!(params, vec!["input"]);
4211 }
4212
4213 #[test]
4214 fn extract_parameters_rust_mut_param() {
4215 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4216 assert_eq!(params, vec!["buf", "len"]);
4217 }
4218
4219 #[test]
4220 fn extract_parameters_go() {
4221 let params = extract_parameters(
4222 "func ProcessData(input string, count int) error",
4223 LangId::Go,
4224 );
4225 assert_eq!(params, vec!["input", "count"]);
4226 }
4227
4228 #[test]
4229 fn extract_parameters_empty() {
4230 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4231 assert!(
4232 params.is_empty(),
4233 "no-arg function should return empty params"
4234 );
4235 }
4236
4237 #[test]
4238 fn extract_parameters_no_parens() {
4239 let params = extract_parameters("const x = 42", LangId::TypeScript);
4240 assert!(params.is_empty(), "no parens should return empty params");
4241 }
4242
4243 #[test]
4244 fn extract_parameters_javascript() {
4245 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4246 assert_eq!(params, vec!["event", "target"]);
4247 }
4248}