1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11use std::time::Instant;
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use rayon::prelude::*;
15use serde::Serialize;
16use serde_json::Value;
17use tree_sitter::{Node, Parser};
18
19use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
20use crate::edit::line_col_to_byte;
21use crate::error::AftError;
22use crate::imports::{self, ImportBlock};
23use crate::language::LanguageProvider;
24use crate::parser::{detect_language, grammar_for, LangId};
25use crate::symbols::{Range, Symbol, SymbolKind};
26use crate::{slog_debug, slog_info};
27
28type SharedPath = Arc<PathBuf>;
33type SharedStr = Arc<str>;
34type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
35type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
36type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
37type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
38
39static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
40 LazyLock::new(|| RwLock::new(HashMap::new()));
41static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
42 LazyLock::new(|| RwLock::new(HashMap::new()));
43static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
44 LazyLock::new(|| RwLock::new(HashMap::new()));
45
46const TOP_LEVEL_SYMBOL: &str = "<top-level>";
47const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
48const JS_TS_INDEX_FILES: &[&str] = &[
49 "index.ts",
50 "index.tsx",
51 "index.mts",
52 "index.cts",
53 "index.js",
54 "index.jsx",
55 "index.mjs",
56 "index.cjs",
57];
58
59fn symbol_identity(symbol: &Symbol) -> String {
60 if symbol.scope_chain.is_empty() {
61 symbol.name.clone()
62 } else {
63 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
64 }
65}
66
67fn symbol_unqualified_name(symbol: &str) -> &str {
68 symbol.rsplit("::").next().unwrap_or(symbol)
69}
70
71fn symbol_query_matches(symbol: &str, query: &str) -> bool {
72 symbol == query || symbol_unqualified_name(symbol) == query
73}
74
75pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
76 full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
77}
78
79fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
80 let mut seen = HashSet::new();
81 let mut candidates = Vec::new();
82 let qualified_query = symbol_name.contains("::");
83
84 let mut consider = |candidate: &str| {
85 let matches = if qualified_query {
86 candidate == symbol_name
87 } else {
88 candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
89 };
90
91 if matches && seen.insert(candidate.to_string()) {
92 candidates.push(candidate.to_string());
93 }
94 };
95
96 for candidate in file_data.symbol_metadata.keys() {
97 consider(candidate);
98 }
99 for candidate in file_data.calls_by_symbol.keys() {
100 consider(candidate);
101 }
102 for candidate in &file_data.exported_symbols {
103 consider(candidate);
104 }
105
106 candidates.sort();
107 candidates
108}
109
110pub(crate) fn resolve_symbol_query_in_data(
111 file_data: &FileCallData,
112 file: &Path,
113 symbol_name: &str,
114) -> Result<String, AftError> {
115 let candidates = symbol_query_candidates(file_data, symbol_name);
116 match candidates.as_slice() {
117 [candidate] => Ok(candidate.clone()),
118 [] => Err(AftError::SymbolNotFound {
119 name: symbol_name.to_string(),
120 file: file.display().to_string(),
121 }),
122 _ => Err(AftError::AmbiguousSymbol {
123 name: symbol_name.to_string(),
124 candidates,
125 }),
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct CallSite {
132 pub callee_name: String,
134 pub full_callee: String,
136 pub line: u32,
138 pub byte_start: usize,
140 pub byte_end: usize,
141}
142
143#[derive(Debug, Clone, Serialize)]
145pub struct SymbolMeta {
146 pub kind: SymbolKind,
148 pub exported: bool,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub signature: Option<String>,
153 pub line: u32,
155 pub range: Range,
157}
158
159#[derive(Debug, Clone)]
162pub struct FileCallData {
163 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
165 pub exported_symbols: Vec<String>,
167 pub symbol_metadata: HashMap<String, SymbolMeta>,
169 pub default_export_symbol: Option<String>,
171 pub import_block: ImportBlock,
173 pub lang: LangId,
175}
176
177impl FileCallData {
178 pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
191 if let Some(meta) = self.symbol_metadata.get(name) {
192 return Some(meta);
193 }
194 self.symbol_metadata
195 .iter()
196 .find(|(key, _)| symbol_unqualified_name(key) == name)
197 .map(|(_, meta)| meta)
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum EdgeResolution {
204 Resolved { file: PathBuf, symbol: String },
206 Unresolved { callee_name: String },
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
211struct ResolvedSymbol {
212 file: PathBuf,
213 symbol: String,
214}
215
216#[derive(Debug, Clone)]
217struct RustCrateInfo {
218 lib_name: String,
219 lib_root: Option<PathBuf>,
220 main_root: Option<PathBuf>,
221}
222
223#[derive(Debug, Clone)]
224struct RustModuleBase {
225 src_dir: PathBuf,
226 root_file: PathBuf,
227}
228
229#[derive(Debug, Clone)]
230struct RustUseEntry {
231 module_path: String,
232 local_name: String,
233 kind: RustUseKind,
234}
235
236#[derive(Debug, Clone)]
237enum RustUseKind {
238 Item { imported_name: String },
239 Module,
240}
241
242#[derive(Debug, Clone, Serialize)]
244pub struct CallerSite {
245 pub caller_file: PathBuf,
247 pub caller_symbol: String,
249 pub line: u32,
251 pub col: u32,
253 pub resolved: bool,
255}
256
257#[derive(Debug, Clone)]
258struct IndexedCallerSite {
259 caller_file: SharedPath,
260 caller_symbol: SharedStr,
261 line: u32,
262 col: u32,
263 resolved: bool,
264}
265
266#[derive(Debug, Clone, Serialize)]
268pub struct CallerGroup {
269 pub file: String,
271 pub callers: Vec<CallerEntry>,
273}
274
275#[derive(Debug, Clone, Serialize)]
277pub struct CallerEntry {
278 pub symbol: String,
279 pub line: u32,
281}
282
283#[derive(Debug, Clone, Serialize)]
285pub struct CallersResult {
286 pub symbol: String,
288 pub file: String,
290 pub callers: Vec<CallerGroup>,
292 pub total_callers: usize,
294 pub scanned_files: usize,
296 pub depth_limited: bool,
298 pub truncated: usize,
300}
301
302#[derive(Debug, Clone, Serialize)]
304pub struct CallTreeNode {
305 pub name: String,
307 pub file: String,
309 pub line: u32,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub signature: Option<String>,
314 pub resolved: bool,
316 pub children: Vec<CallTreeNode>,
318 pub depth_limited: bool,
320 pub truncated: usize,
322}
323
324const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
330
331pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
338 if exported && *kind == SymbolKind::Function {
340 return true;
341 }
342
343 let lower = name.to_lowercase();
345 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
346 return true;
347 }
348
349 match lang {
351 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
352 matches!(lower.as_str(), "describe" | "it" | "test")
354 || lower.starts_with("test")
355 || lower.starts_with("spec")
356 }
357 LangId::Python => {
358 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
360 }
361 LangId::Rust => {
362 lower.starts_with("test_")
364 }
365 LangId::Go => {
366 name.starts_with("Test")
368 }
369 LangId::C
370 | LangId::Cpp
371 | LangId::Zig
372 | LangId::CSharp
373 | LangId::Bash
374 | LangId::Solidity
375 | LangId::Scss
376 | LangId::Vue
377 | LangId::Json
378 | LangId::Scala
379 | LangId::Java
380 | LangId::Ruby
381 | LangId::Kotlin
382 | LangId::Swift
383 | LangId::Php
384 | LangId::Lua
385 | LangId::Perl
386 | LangId::Html
387 | LangId::Markdown
388 | LangId::Yaml
389 | LangId::Pascal
390 | LangId::R => false,
391 }
392}
393
394#[derive(Debug, Clone, Serialize)]
400pub struct TraceHop {
401 pub symbol: String,
403 pub file: String,
405 pub line: u32,
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub signature: Option<String>,
410 pub is_entry_point: bool,
412}
413
414#[derive(Debug, Clone, Serialize)]
416pub struct TracePath {
417 pub hops: Vec<TraceHop>,
419}
420
421#[derive(Debug, Clone, Serialize)]
423pub struct TraceToResult {
424 pub target_symbol: String,
426 pub target_file: String,
428 pub paths: Vec<TracePath>,
430 pub total_paths: usize,
432 pub entry_points_found: usize,
434 pub max_depth_reached: bool,
436 pub truncated_paths: usize,
438}
439
440#[derive(Debug, Clone, Serialize)]
442pub struct TraceToSymbolHop {
443 pub symbol: String,
445 pub file: String,
447 pub line: u32,
449}
450
451#[derive(Debug, Clone, Serialize)]
453pub struct TraceToSymbolCandidate {
454 pub file: String,
456 pub line: u32,
458}
459
460#[derive(Debug, Clone, Serialize)]
462pub struct TraceToSymbolResult {
463 pub path: Option<Vec<TraceToSymbolHop>>,
465 pub complete: bool,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub reason: Option<String>,
470}
471
472#[derive(Debug, Clone, Serialize)]
478pub struct ImpactCaller {
479 pub caller_symbol: String,
481 pub caller_file: String,
483 pub line: u32,
485 #[serde(skip_serializing_if = "Option::is_none")]
487 pub signature: Option<String>,
488 pub is_entry_point: bool,
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub call_expression: Option<String>,
493 pub parameters: Vec<String>,
495}
496
497#[derive(Debug, Clone, Serialize)]
499pub struct ImpactResult {
500 pub symbol: String,
502 pub file: String,
504 #[serde(skip_serializing_if = "Option::is_none")]
506 pub signature: Option<String>,
507 pub parameters: Vec<String>,
509 pub total_affected: usize,
511 pub affected_files: usize,
513 pub callers: Vec<ImpactCaller>,
515 pub depth_limited: bool,
517 pub truncated: usize,
519}
520
521#[derive(Debug, Clone, Serialize)]
527pub struct DataFlowHop {
528 pub file: String,
530 pub symbol: String,
532 pub variable: String,
534 pub line: u32,
536 pub flow_type: String,
538 pub approximate: bool,
540}
541
542#[derive(Debug, Clone, Serialize)]
545pub struct TraceDataResult {
546 pub expression: String,
548 pub origin_file: String,
550 pub origin_symbol: String,
552 pub hops: Vec<DataFlowHop>,
554 pub depth_limited: bool,
556}
557
558pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
564 let start = match signature.find('(') {
566 Some(i) => i + 1,
567 None => return Vec::new(),
568 };
569 let end = match signature[start..].find(')') {
570 Some(i) => start + i,
571 None => return Vec::new(),
572 };
573
574 let params_str = &signature[start..end].trim();
575 if params_str.is_empty() {
576 return Vec::new();
577 }
578
579 let parts = split_params(params_str);
581
582 let mut result = Vec::new();
583 for part in parts {
584 let trimmed = part.trim();
585 if trimmed.is_empty() {
586 continue;
587 }
588
589 match lang {
591 LangId::Rust => {
592 if trimmed == "self"
593 || trimmed == "mut self"
594 || trimmed.starts_with("&self")
595 || trimmed.starts_with("&mut self")
596 {
597 continue;
598 }
599 }
600 LangId::Python => {
601 if trimmed == "self" || trimmed.starts_with("self:") {
602 continue;
603 }
604 }
605 _ => {}
606 }
607
608 let name = extract_param_name(trimmed, lang);
610 if !name.is_empty() {
611 result.push(name);
612 }
613 }
614
615 result
616}
617
618fn split_params(s: &str) -> Vec<String> {
620 let mut parts = Vec::new();
621 let mut current = String::new();
622 let mut depth = 0i32;
623
624 for ch in s.chars() {
625 match ch {
626 '<' | '[' | '{' | '(' => {
627 depth += 1;
628 current.push(ch);
629 }
630 '>' | ']' | '}' | ')' => {
631 depth -= 1;
632 current.push(ch);
633 }
634 ',' if depth == 0 => {
635 parts.push(current.clone());
636 current.clear();
637 }
638 _ => {
639 current.push(ch);
640 }
641 }
642 }
643 if !current.is_empty() {
644 parts.push(current);
645 }
646 parts
647}
648
649fn extract_param_name(param: &str, lang: LangId) -> String {
657 let trimmed = param.trim();
658
659 let working = if trimmed.starts_with("...") {
661 &trimmed[3..]
662 } else if trimmed.starts_with("**") {
663 &trimmed[2..]
664 } else if trimmed.starts_with('*') && lang == LangId::Python {
665 &trimmed[1..]
666 } else {
667 trimmed
668 };
669
670 let working = if lang == LangId::Rust && working.starts_with("mut ") {
672 &working[4..]
673 } else {
674 working
675 };
676
677 let name = working
680 .split(|c: char| c == ':' || c == '=')
681 .next()
682 .unwrap_or("")
683 .trim();
684
685 let name = name.trim_end_matches('?');
687
688 if lang == LangId::Go && !name.contains(' ') {
690 return name.to_string();
691 }
692 if lang == LangId::Go {
693 return name.split_whitespace().next().unwrap_or("").to_string();
694 }
695
696 name.to_string()
697}
698
699pub struct CallGraph {
708 data: HashMap<PathBuf, FileCallData>,
710 project_root: PathBuf,
712 project_files: Option<Vec<PathBuf>>,
714 reverse_index: Option<ReverseIndex>,
717 canon_cache: RefCell<HashMap<PathBuf, Arc<PathBuf>>>,
723}
724
725impl CallGraph {
726 pub fn new(project_root: PathBuf) -> Self {
728 clear_workspace_package_cache();
729 Self {
730 data: HashMap::new(),
731 project_root,
732 project_files: None,
733 reverse_index: None,
734 canon_cache: RefCell::new(HashMap::new()),
735 }
736 }
737
738 fn canonicalize_cached(&self, path: &Path) -> Arc<PathBuf> {
744 if let Some(hit) = self.canon_cache.borrow().get(path) {
745 return Arc::clone(hit);
746 }
747 match std::fs::canonicalize(path) {
748 Ok(canon) => {
749 let canon = Arc::new(canon);
750 self.canon_cache
751 .borrow_mut()
752 .insert(path.to_path_buf(), Arc::clone(&canon));
753 canon
754 }
755 Err(_) => Arc::new(path.to_path_buf()),
759 }
760 }
761
762 pub fn project_root(&self) -> &Path {
764 &self.project_root
765 }
766
767 fn resolve_cross_file_edge_with_exports<F, D>(
768 full_callee: &str,
769 short_name: &str,
770 caller_file: &Path,
771 import_block: &ImportBlock,
772 mut file_exports_symbol: F,
773 mut file_default_export_symbol: D,
774 ) -> EdgeResolution
775 where
776 F: FnMut(&Path, &str) -> bool,
777 D: FnMut(&Path) -> Option<String>,
778 {
779 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
780
781 if is_rust_source_file(caller_file) {
785 if let Some(target) = resolve_rust_cross_file_edge(
786 full_callee,
787 short_name,
788 caller_file,
789 import_block,
790 &mut file_exports_symbol,
791 ) {
792 return EdgeResolution::Resolved {
793 file: target.file,
794 symbol: target.symbol,
795 };
796 }
797 }
798
799 if full_callee.contains('.') {
801 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
802 if parts.len() == 2 {
803 let namespace = parts[0];
804 let member = parts[1];
805
806 for imp in &import_block.imports {
807 if imp.namespace_import.as_deref() == Some(namespace) {
808 if let Some(resolved_path) =
809 resolve_module_path(caller_dir, &imp.module_path)
810 {
811 if let Some(target) = resolve_reexported_symbol(
812 &resolved_path,
813 member,
814 &mut file_exports_symbol,
815 &mut file_default_export_symbol,
816 ) {
817 return EdgeResolution::Resolved {
818 file: target.file,
819 symbol: target.symbol,
820 };
821 }
822 }
823 }
824 }
825 }
826 }
827
828 for imp in &import_block.imports {
830 if imp.names.iter().any(|name| name == short_name) {
832 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
833 let target = resolve_reexported_symbol(
834 &resolved_path,
835 short_name,
836 &mut file_exports_symbol,
837 &mut file_default_export_symbol,
838 )
839 .unwrap_or(ResolvedSymbol {
840 file: resolved_path,
841 symbol: short_name.to_owned(),
842 });
843 return EdgeResolution::Resolved {
844 file: target.file,
845 symbol: target.symbol,
846 };
847 }
848 }
849
850 if imp.default_import.as_deref() == Some(short_name) {
852 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
853 let target = resolve_reexported_symbol(
854 &resolved_path,
855 "default",
856 &mut file_exports_symbol,
857 &mut file_default_export_symbol,
858 )
859 .unwrap_or_else(|| ResolvedSymbol {
860 symbol: file_default_export_symbol(&resolved_path)
861 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
862 file: resolved_path,
863 });
864 return EdgeResolution::Resolved {
865 file: target.file,
866 symbol: target.symbol,
867 };
868 }
869 }
870 }
871
872 if let Some((original_name, resolved_path)) =
877 resolve_aliased_import(short_name, import_block, caller_dir)
878 {
879 let target = resolve_reexported_symbol(
880 &resolved_path,
881 &original_name,
882 &mut file_exports_symbol,
883 &mut file_default_export_symbol,
884 )
885 .unwrap_or(ResolvedSymbol {
886 file: resolved_path,
887 symbol: original_name,
888 });
889 return EdgeResolution::Resolved {
890 file: target.file,
891 symbol: target.symbol,
892 };
893 }
894
895 for imp in &import_block.imports {
898 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
899 if resolved_path.is_dir() {
901 if let Some(index_path) = find_index_file(&resolved_path) {
902 if file_exports_symbol(&index_path, short_name) {
904 return EdgeResolution::Resolved {
905 file: index_path,
906 symbol: short_name.to_owned(),
907 };
908 }
909 }
910 } else if file_exports_symbol(&resolved_path, short_name) {
911 return EdgeResolution::Resolved {
912 file: resolved_path,
913 symbol: short_name.to_owned(),
914 };
915 }
916 }
917 }
918
919 EdgeResolution::Unresolved {
920 callee_name: short_name.to_owned(),
921 }
922 }
923
924 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
926 let canon = self.canonicalize(path)?;
927
928 if !self.data.contains_key(&canon) {
929 let file_data = build_file_data(&canon)?;
930 self.data.insert(canon.clone(), file_data);
931 }
932
933 Ok(&self.data[&canon])
934 }
935
936 pub fn resolve_symbol_query(&mut self, file: &Path, symbol: &str) -> Result<String, AftError> {
939 let canon = self.canonicalize(file)?;
940 let file_data = self.build_file(&canon)?;
941 resolve_symbol_query_in_data(file_data, &canon, symbol)
942 }
943
944 pub fn resolve_cross_file_edge(
949 &mut self,
950 full_callee: &str,
951 short_name: &str,
952 caller_file: &Path,
953 import_block: &ImportBlock,
954 ) -> EdgeResolution {
955 let graph = RefCell::new(self);
956 Self::resolve_cross_file_edge_with_exports(
957 full_callee,
958 short_name,
959 caller_file,
960 import_block,
961 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
962 |path| graph.borrow_mut().file_default_export_symbol(path),
963 )
964 }
965
966 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
968 match self.build_file(path) {
969 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
970 Err(_) => false,
971 }
972 }
973
974 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
975 self.build_file(path)
976 .ok()
977 .and_then(|data| data.default_export_symbol.clone())
978 }
979
980 fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
981 self.lookup_file_data(path)
982 .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
983 .unwrap_or(false)
984 }
985
986 fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
987 self.lookup_file_data(path)
988 .and_then(|data| data.default_export_symbol.clone())
989 }
990
991 pub fn forward_tree(
996 &mut self,
997 file: &Path,
998 symbol: &str,
999 max_depth: usize,
1000 ) -> Result<CallTreeNode, AftError> {
1001 let canon = self.canonicalize(file)?;
1002 let resolved_symbol = {
1003 let file_data = self.build_file(&canon)?;
1004 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1005 };
1006 let mut visited = HashSet::new();
1007 self.forward_tree_inner(&canon, &resolved_symbol, max_depth, 0, &mut visited)
1008 }
1009
1010 fn forward_tree_inner(
1011 &mut self,
1012 file: &Path,
1013 symbol: &str,
1014 max_depth: usize,
1015 current_depth: usize,
1016 visited: &mut HashSet<(PathBuf, String)>,
1017 ) -> Result<CallTreeNode, AftError> {
1018 let canon = self.canonicalize(file)?;
1019 let visit_key = (canon.clone(), symbol.to_string());
1020
1021 if visited.contains(&visit_key) {
1023 let (line, signature) = self
1024 .lookup_file_data(&canon)
1025 .map(|data| get_symbol_meta_from_data(data, symbol))
1026 .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1027 return Ok(CallTreeNode {
1028 name: symbol.to_string(),
1029 file: self.relative_path(&canon),
1030 line,
1031 signature,
1032 resolved: true,
1033 children: vec![], depth_limited: false,
1035 truncated: 0,
1036 });
1037 }
1038
1039 visited.insert(visit_key.clone());
1040
1041 let (import_block, call_sites, sym_line, sym_signature) = {
1042 let file_data = self.build_file(&canon)?;
1043 let meta = get_symbol_meta_from_data(file_data, symbol);
1044
1045 (
1046 file_data.import_block.clone(),
1047 file_data
1048 .calls_by_symbol
1049 .get(symbol)
1050 .cloned()
1051 .unwrap_or_default(),
1052 meta.0,
1053 meta.1,
1054 )
1055 };
1056
1057 let mut children = Vec::new();
1059 let mut depth_limited = false;
1060 let mut truncated = 0;
1061
1062 if current_depth < max_depth {
1063 for call_site in &call_sites {
1064 let edge = self.resolve_cross_file_edge(
1065 &call_site.full_callee,
1066 &call_site.callee_name,
1067 &canon,
1068 &import_block,
1069 );
1070
1071 match edge {
1072 EdgeResolution::Resolved {
1073 file: ref target_file,
1074 ref symbol,
1075 } => {
1076 match self.forward_tree_inner(
1077 target_file,
1078 symbol,
1079 max_depth,
1080 current_depth + 1,
1081 visited,
1082 ) {
1083 Ok(child) => {
1084 depth_limited |= child.depth_limited;
1085 truncated += child.truncated;
1086 children.push(child);
1087 }
1088 Err(_) => {
1089 children.push(CallTreeNode {
1091 name: call_site.callee_name.clone(),
1092 file: self.relative_path(target_file),
1093 line: call_site.line,
1094 signature: None,
1095 resolved: false,
1096 children: vec![],
1097 depth_limited: false,
1098 truncated: 0,
1099 });
1100 }
1101 }
1102 }
1103 EdgeResolution::Unresolved { callee_name } => {
1104 if let Some(local_child) = self.resolve_local_call_tree_child(
1105 &canon,
1106 symbol,
1107 call_site,
1108 &callee_name,
1109 max_depth,
1110 current_depth,
1111 visited,
1112 )? {
1113 depth_limited |= local_child.depth_limited;
1114 truncated += local_child.truncated;
1115 children.push(local_child);
1116 continue;
1117 }
1118 children.push(CallTreeNode {
1119 name: callee_name,
1120 file: self.relative_path(&canon),
1121 line: call_site.line,
1122 signature: None,
1123 resolved: false,
1124 children: vec![],
1125 depth_limited: false,
1126 truncated: 0,
1127 });
1128 }
1129 }
1130 }
1131 } else if !call_sites.is_empty() {
1132 depth_limited = true;
1133 truncated = call_sites.len();
1134 }
1135
1136 visited.remove(&visit_key);
1137
1138 Ok(CallTreeNode {
1139 name: symbol.to_string(),
1140 file: self.relative_path(&canon),
1141 line: sym_line,
1142 signature: sym_signature,
1143 resolved: true,
1144 children,
1145 depth_limited,
1146 truncated,
1147 })
1148 }
1149
1150 fn resolve_local_call_tree_child(
1151 &mut self,
1152 canon: &Path,
1153 current_symbol: &str,
1154 call_site: &CallSite,
1155 callee_name: &str,
1156 max_depth: usize,
1157 current_depth: usize,
1158 visited: &mut HashSet<(PathBuf, String)>,
1159 ) -> Result<Option<CallTreeNode>, AftError> {
1160 if !is_bare_callee(&call_site.full_callee, callee_name) {
1161 return Ok(None);
1162 }
1163
1164 let target_symbol = match self
1165 .lookup_file_data(canon)
1166 .and_then(|data| resolve_symbol_query_in_data(data, canon, callee_name).ok())
1167 {
1168 Some(symbol) => symbol,
1169 None => return Ok(None),
1170 };
1171
1172 if target_symbol == current_symbol {
1173 return Ok(None);
1174 }
1175
1176 match self.forward_tree_inner(canon, &target_symbol, max_depth, current_depth + 1, visited)
1177 {
1178 Ok(child) => Ok(Some(child)),
1179 Err(_) => Ok(Some(CallTreeNode {
1180 name: target_symbol,
1181 file: self.relative_path(canon),
1182 line: call_site.line,
1183 signature: None,
1184 resolved: false,
1185 children: vec![],
1186 depth_limited: false,
1187 truncated: 0,
1188 })),
1189 }
1190 }
1191
1192 pub fn project_files(&mut self) -> &[PathBuf] {
1194 if self.project_files.is_none() {
1195 let project_root = self.project_root.clone();
1196 self.project_files = Some(walk_project_files(&project_root).collect());
1197 }
1198 self.project_files.as_deref().unwrap_or(&[])
1199 }
1200
1201 pub fn project_file_count(&mut self) -> usize {
1207 self.project_files().len()
1208 }
1209
1210 pub fn project_file_count_bounded(&self, limit: usize) -> usize {
1221 if let Some(files) = self.project_files.as_deref() {
1222 return files.len();
1223 }
1224 walk_project_files(&self.project_root)
1225 .take(limit.saturating_add(1))
1226 .count()
1227 }
1228
1229 fn ensure_project_files_built(&mut self, max_files: usize) -> Result<(), AftError> {
1233 let count = self.project_file_count_bounded(max_files);
1238 if count > max_files {
1239 return Err(AftError::ProjectTooLarge {
1240 count,
1241 max: max_files,
1242 });
1243 }
1244
1245 let all_files = self.project_files().to_vec();
1249
1250 let uncached_files: Vec<PathBuf> = all_files
1252 .iter()
1253 .filter(|f| self.lookup_file_data(f).is_none())
1254 .cloned()
1255 .collect();
1256
1257 if !uncached_files.is_empty() {
1262 let started = Instant::now();
1263 let pool = callgraph_parse_pool();
1270 let computed: Vec<(PathBuf, FileCallData)> = pool.install(|| {
1271 uncached_files
1272 .par_iter()
1273 .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
1274 .collect()
1275 });
1276
1277 let parsed = computed.len();
1278 for (file, data) in computed {
1279 self.data.insert(file, data);
1280 }
1281 slog_info!(
1282 "perf callgraph: parsed {} uncached files ({} total project files) in {}ms (bounded {}-thread pool)",
1283 parsed,
1284 all_files.len(),
1285 started.elapsed().as_millis(),
1286 pool.current_num_threads(),
1287 );
1288 }
1289
1290 Ok(())
1291 }
1292
1293 fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
1299 self.ensure_project_files_built(max_files)?;
1300 let all_files = self.project_files().to_vec();
1301
1302 let reverse_started = Instant::now();
1306
1307 let mut reverse: ReverseIndex = HashMap::new();
1309
1310 for caller_file in &all_files {
1311 let canon_caller = self.canonicalize_cached(caller_file);
1315 let file_data = match self
1316 .data
1317 .get(caller_file)
1318 .or_else(|| self.data.get(canon_caller.as_ref()))
1319 {
1320 Some(d) => d,
1321 None => continue,
1322 };
1323
1324 for (symbol_name, call_sites) in &file_data.calls_by_symbol {
1325 let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
1326
1327 for call_site in call_sites {
1328 let edge = Self::resolve_cross_file_edge_with_exports(
1329 &call_site.full_callee,
1330 &call_site.callee_name,
1331 canon_caller.as_ref(),
1332 &file_data.import_block,
1333 |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1334 |path| self.file_default_export_symbol_cached(path),
1335 );
1336
1337 let (target_file, target_symbol, resolved) = match edge {
1338 EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1339 EdgeResolution::Unresolved { callee_name } => {
1340 if !is_bare_callee(&call_site.full_callee, &callee_name) {
1341 continue;
1342 }
1343
1344 let Ok(target_symbol) = resolve_symbol_query_in_data(
1345 file_data,
1346 canon_caller.as_ref(),
1347 &callee_name,
1348 ) else {
1349 continue;
1350 };
1351
1352 (canon_caller.as_ref().clone(), target_symbol, false)
1353 }
1354 };
1355
1356 if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1357 continue;
1358 }
1359
1360 reverse
1361 .entry(target_file)
1362 .or_default()
1363 .entry(target_symbol)
1364 .or_default()
1365 .push(IndexedCallerSite {
1366 caller_file: Arc::clone(&canon_caller),
1367 caller_symbol: Arc::clone(&caller_symbol),
1368 line: call_site.line,
1369 col: 0,
1370 resolved,
1371 });
1372 }
1373 }
1374 }
1375
1376 let edges: usize = reverse
1377 .values()
1378 .map(|m| m.values().map(Vec::len).sum::<usize>())
1379 .sum();
1380 self.reverse_index = Some(reverse);
1381 slog_debug!(
1382 "callgraph: built reverse index ({} edges over {} files) in {}ms",
1383 edges,
1384 all_files.len(),
1385 reverse_started.elapsed().as_millis()
1386 );
1387 Ok(())
1388 }
1389
1390 fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1391 self.reverse_index
1392 .as_ref()?
1393 .get(file)?
1394 .get(symbol)
1395 .map(Vec::as_slice)
1396 }
1397
1398 pub fn callers_of(
1404 &mut self,
1405 file: &Path,
1406 symbol: &str,
1407 depth: usize,
1408 max_files: usize,
1409 ) -> Result<CallersResult, AftError> {
1410 let canon = self.canonicalize(file)?;
1411
1412 let resolved_symbol = {
1414 let file_data = self.build_file(&canon)?;
1415 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1416 };
1417
1418 if self.reverse_index.is_none() {
1420 self.build_reverse_index(max_files)?;
1421 }
1422
1423 let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1424 let effective_depth = if depth == 0 { 1 } else { depth };
1425
1426 let mut visited = HashSet::new();
1427 let mut all_sites: Vec<CallerSite> = Vec::new();
1428 let mut depth_limited = false;
1429 let mut truncated = 0;
1430 self.collect_callers_recursive(
1431 &canon,
1432 &resolved_symbol,
1433 effective_depth,
1434 0,
1435 &mut visited,
1436 &mut all_sites,
1437 &mut depth_limited,
1438 &mut truncated,
1439 );
1440
1441 let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1444 let total_callers = all_sites.len();
1445 for site in all_sites {
1446 let caller_file: PathBuf = site.caller_file;
1447 let caller_symbol: String = site.caller_symbol;
1448 let line = site.line;
1449 let entry = CallerEntry {
1450 symbol: caller_symbol,
1451 line,
1452 };
1453
1454 if let Some(entries) = groups_map.get_mut(&caller_file) {
1455 entries.push(entry);
1456 } else {
1457 groups_map.insert(caller_file, vec![entry]);
1458 }
1459 }
1460
1461 let mut callers: Vec<CallerGroup> = groups_map
1462 .into_iter()
1463 .map(|(file_path, entries)| CallerGroup {
1464 file: self.relative_path(&file_path),
1465 callers: entries,
1466 })
1467 .collect();
1468
1469 callers.sort_by(|a, b| a.file.cmp(&b.file));
1471
1472 Ok(CallersResult {
1473 symbol: resolved_symbol,
1474 file: self.relative_path(&canon),
1475 callers,
1476 total_callers,
1477 scanned_files,
1478 depth_limited,
1479 truncated,
1480 })
1481 }
1482
1483 pub fn trace_to(
1489 &mut self,
1490 file: &Path,
1491 symbol: &str,
1492 max_depth: usize,
1493 max_files: usize,
1494 ) -> Result<TraceToResult, AftError> {
1495 let canon = self.canonicalize(file)?;
1496
1497 let resolved_symbol = {
1499 let file_data = self.build_file(&canon)?;
1500 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1501 };
1502
1503 if self.reverse_index.is_none() {
1505 self.build_reverse_index(max_files)?;
1506 }
1507
1508 let target_rel = self.relative_path(&canon);
1509 let effective_max = if max_depth == 0 { 10 } else { max_depth };
1510 if self.reverse_index.is_none() {
1511 return Err(AftError::ParseError {
1512 message: format!(
1513 "reverse index unavailable after building callers for {}",
1514 canon.display()
1515 ),
1516 });
1517 }
1518
1519 let (target_line, target_sig) = self
1521 .lookup_file_data(&canon)
1522 .map(|data| get_symbol_meta_from_data(data, &resolved_symbol))
1523 .unwrap_or_else(|| get_symbol_meta(&canon, &resolved_symbol));
1524
1525 let target_is_entry = self
1527 .lookup_file_data(&canon)
1528 .and_then(|fd| {
1529 let meta = fd.symbol_metadata.get(&resolved_symbol)?;
1530 Some(is_entry_point(
1531 &resolved_symbol,
1532 &meta.kind,
1533 meta.exported,
1534 fd.lang,
1535 ))
1536 })
1537 .unwrap_or(false);
1538
1539 type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1542 let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1543 let mut max_depth_reached = false;
1544 let mut truncated_paths: usize = 0;
1545
1546 let initial: Vec<PathElem> = vec![(
1548 Arc::new(canon.clone()),
1549 Arc::from(resolved_symbol.as_str()),
1550 target_line,
1551 target_sig,
1552 )];
1553
1554 if target_is_entry {
1556 complete_paths.push(initial.clone());
1557 }
1558
1559 let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1561
1562 while let Some((path, depth)) = queue.pop() {
1563 if depth >= effective_max {
1564 max_depth_reached = true;
1565 continue;
1566 }
1567
1568 let Some((current_file, current_symbol, _, _)) = path.last() else {
1569 continue;
1570 };
1571
1572 let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1574 Some(sites) => sites,
1575 None => {
1576 if path.len() > 1 {
1579 truncated_paths += 1;
1582 }
1583 continue;
1584 }
1585 };
1586
1587 let mut has_new_path = false;
1588 for site in callers {
1589 if path.iter().any(|(file_path, sym, _, _)| {
1591 file_path.as_ref() == site.caller_file.as_ref()
1592 && sym.as_ref() == site.caller_symbol.as_ref()
1593 }) {
1594 continue;
1595 }
1596
1597 has_new_path = true;
1598
1599 let (caller_line, caller_sig) = self
1601 .lookup_file_data(site.caller_file.as_ref())
1602 .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1603 .unwrap_or_else(|| {
1604 get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1605 });
1606
1607 let mut new_path = path.clone();
1608 new_path.push((
1609 Arc::clone(&site.caller_file),
1610 Arc::clone(&site.caller_symbol),
1611 caller_line,
1612 caller_sig,
1613 ));
1614
1615 let caller_is_entry = self
1619 .lookup_file_data(site.caller_file.as_ref())
1620 .and_then(|fd| {
1621 let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1622 Some(is_entry_point(
1623 site.caller_symbol.as_ref(),
1624 &meta.kind,
1625 meta.exported,
1626 fd.lang,
1627 ))
1628 })
1629 .unwrap_or(false);
1630
1631 if caller_is_entry {
1632 complete_paths.push(new_path.clone());
1633 }
1634 queue.push((new_path, depth + 1));
1637 }
1638
1639 if !has_new_path && path.len() > 1 {
1641 truncated_paths += 1;
1642 }
1643 }
1644
1645 let mut paths: Vec<TracePath> = complete_paths
1648 .into_iter()
1649 .map(|mut elems| {
1650 elems.reverse();
1651 let hops: Vec<TraceHop> = elems
1652 .iter()
1653 .enumerate()
1654 .map(|(i, (file_path, sym, line, sig))| {
1655 let is_ep = if i == 0 {
1656 self.lookup_file_data(file_path.as_ref())
1658 .and_then(|fd| {
1659 let meta = fd.symbol_metadata.get(sym.as_ref())?;
1660 Some(is_entry_point(
1661 sym.as_ref(),
1662 &meta.kind,
1663 meta.exported,
1664 fd.lang,
1665 ))
1666 })
1667 .unwrap_or(false)
1668 } else {
1669 false
1670 };
1671 TraceHop {
1672 symbol: sym.to_string(),
1673 file: self.relative_path(file_path.as_ref()),
1674 line: *line,
1675 signature: sig.clone(),
1676 is_entry_point: is_ep,
1677 }
1678 })
1679 .collect();
1680 TracePath { hops }
1681 })
1682 .collect();
1683
1684 paths.sort_by(|a, b| {
1686 let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1687 let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1688 a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1689 });
1690
1691 let mut entry_points: HashSet<(String, String)> = HashSet::new();
1693 for p in &paths {
1694 if let Some(first) = p.hops.first() {
1695 if first.is_entry_point {
1696 entry_points.insert((first.file.clone(), first.symbol.clone()));
1697 }
1698 }
1699 }
1700
1701 let total_paths = paths.len();
1702 let entry_points_found = entry_points.len();
1703
1704 Ok(TraceToResult {
1705 target_symbol: resolved_symbol,
1706 target_file: target_rel,
1707 paths,
1708 total_paths,
1709 entry_points_found,
1710 max_depth_reached,
1711 truncated_paths,
1712 })
1713 }
1714
1715 pub fn trace_to_symbol_candidates(
1720 &mut self,
1721 to_symbol: &str,
1722 max_files: usize,
1723 ) -> Result<Vec<TraceToSymbolCandidate>, AftError> {
1724 self.ensure_project_files_built(max_files)?;
1725
1726 let mut candidates_by_file: HashMap<PathBuf, u32> = HashMap::new();
1727 let all_files = self.project_files().to_vec();
1728
1729 for file in all_files {
1730 let canon = self.canonicalize(&file)?;
1731 let Some(file_data) = self
1732 .lookup_file_data(&canon)
1733 .or_else(|| self.lookup_file_data(&file))
1734 else {
1735 continue;
1736 };
1737
1738 let symbol_candidates = symbol_query_candidates(file_data, to_symbol);
1739 if symbol_candidates.is_empty() {
1740 continue;
1741 }
1742
1743 let line = symbol_candidates
1744 .iter()
1745 .filter_map(|symbol| file_data.symbol_metadata.get(symbol).map(|meta| meta.line))
1746 .min()
1747 .unwrap_or(1);
1748
1749 candidates_by_file
1750 .entry(canon)
1751 .and_modify(|existing| *existing = (*existing).min(line))
1752 .or_insert(line);
1753 }
1754
1755 let mut candidates: Vec<TraceToSymbolCandidate> = candidates_by_file
1756 .into_iter()
1757 .map(|(file, line)| TraceToSymbolCandidate {
1758 file: self.relative_path(&file),
1759 line,
1760 })
1761 .collect();
1762 candidates.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
1763 Ok(candidates)
1764 }
1765
1766 pub fn trace_to_symbol(
1772 &mut self,
1773 file: &Path,
1774 symbol: &str,
1775 to_symbol: &str,
1776 to_file: Option<&Path>,
1777 max_depth: usize,
1778 max_files: usize,
1779 ) -> Result<TraceToSymbolResult, AftError> {
1780 let canon = self.canonicalize(file)?;
1781
1782 let resolved_symbol = {
1784 let file_data = self.build_file(&canon)?;
1785 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1786 };
1787
1788 self.ensure_project_files_built(max_files)?;
1789
1790 let target_file = to_file.map(|path| self.canonicalize(path)).transpose()?;
1791 let effective_max = if max_depth == 0 {
1792 10
1793 } else {
1794 max_depth.min(16)
1795 };
1796
1797 let start_hop = self.trace_to_symbol_hop(&canon, &resolved_symbol);
1798 if Self::trace_to_symbol_matches_target(&canon, &resolved_symbol, to_symbol, &target_file) {
1799 return Ok(TraceToSymbolResult {
1800 path: Some(vec![start_hop]),
1801 complete: true,
1802 reason: None,
1803 });
1804 }
1805
1806 let mut queue: VecDeque<(PathBuf, String, Vec<TraceToSymbolHop>, usize)> = VecDeque::new();
1807 queue.push_back((canon.clone(), resolved_symbol.clone(), vec![start_hop], 0));
1808
1809 let mut visited: HashSet<(PathBuf, String)> = HashSet::new();
1810 visited.insert((canon, resolved_symbol));
1811 let mut max_depth_exhausted = false;
1812
1813 while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
1814 let callees = self.forward_resolved_callees(¤t_file, ¤t_symbol)?;
1815
1816 if depth >= effective_max {
1817 if callees
1818 .iter()
1819 .any(|(file, symbol)| !visited.contains(&(file.clone(), symbol.clone())))
1820 {
1821 max_depth_exhausted = true;
1822 }
1823 continue;
1824 }
1825
1826 for (callee_file, callee_symbol) in callees {
1827 let visit_key = (callee_file.clone(), callee_symbol.clone());
1828 if !visited.insert(visit_key) {
1829 continue;
1830 }
1831
1832 let mut next_path = path.clone();
1833 next_path.push(self.trace_to_symbol_hop(&callee_file, &callee_symbol));
1834
1835 if Self::trace_to_symbol_matches_target(
1836 &callee_file,
1837 &callee_symbol,
1838 to_symbol,
1839 &target_file,
1840 ) {
1841 return Ok(TraceToSymbolResult {
1842 path: Some(next_path),
1843 complete: true,
1844 reason: None,
1845 });
1846 }
1847
1848 queue.push_back((callee_file, callee_symbol, next_path, depth + 1));
1849 }
1850 }
1851
1852 if max_depth_exhausted {
1853 Ok(TraceToSymbolResult {
1854 path: None,
1855 complete: false,
1856 reason: Some("max_depth_exhausted".to_string()),
1857 })
1858 } else {
1859 Ok(TraceToSymbolResult {
1860 path: None,
1861 complete: true,
1862 reason: Some("no_path_found".to_string()),
1863 })
1864 }
1865 }
1866
1867 fn trace_to_symbol_matches_target(
1868 file: &Path,
1869 symbol: &str,
1870 to_symbol: &str,
1871 to_file: &Option<PathBuf>,
1872 ) -> bool {
1873 if !symbol_query_matches(symbol, to_symbol) {
1874 return false;
1875 }
1876
1877 if let Some(target_file) = to_file {
1878 file == target_file
1879 } else {
1880 true
1881 }
1882 }
1883
1884 fn trace_to_symbol_hop(&self, file: &Path, symbol: &str) -> TraceToSymbolHop {
1885 let (line, _) = self
1886 .lookup_file_data(file)
1887 .map(|data| get_symbol_meta_from_data(data, symbol))
1888 .unwrap_or_else(|| get_symbol_meta(file, symbol));
1889
1890 TraceToSymbolHop {
1891 symbol: symbol.to_string(),
1892 file: self.relative_path(file),
1893 line,
1894 }
1895 }
1896
1897 fn forward_resolved_callees(
1898 &mut self,
1899 file: &Path,
1900 symbol: &str,
1901 ) -> Result<Vec<(PathBuf, String)>, AftError> {
1902 let canon = self.canonicalize(file)?;
1903 let (import_block, call_sites) = {
1904 let file_data = self.build_file(&canon)?;
1905 (
1906 file_data.import_block.clone(),
1907 file_data
1908 .calls_by_symbol
1909 .get(symbol)
1910 .cloned()
1911 .unwrap_or_default(),
1912 )
1913 };
1914
1915 let mut callees = Vec::new();
1916 for call_site in call_sites {
1917 let edge = self.resolve_cross_file_edge(
1918 &call_site.full_callee,
1919 &call_site.callee_name,
1920 &canon,
1921 &import_block,
1922 );
1923
1924 match edge {
1925 EdgeResolution::Resolved {
1926 file: target_file,
1927 symbol: target_symbol,
1928 } => {
1929 let target_canon = self.canonicalize(&target_file)?;
1930 if self.build_file(&target_canon).is_err() {
1931 continue;
1932 }
1933
1934 let resolved_target_symbol = self
1935 .lookup_file_data(&target_canon)
1936 .and_then(|data| {
1937 resolve_symbol_query_in_data(data, &target_canon, &target_symbol).ok()
1938 })
1939 .unwrap_or(target_symbol);
1940
1941 callees.push((target_canon, resolved_target_symbol));
1942 }
1943 EdgeResolution::Unresolved { callee_name } => {
1944 if !is_bare_callee(&call_site.full_callee, &callee_name) {
1945 continue;
1946 }
1947
1948 let local_symbol = self.lookup_file_data(&canon).and_then(|data| {
1949 resolve_symbol_query_in_data(data, &canon, &callee_name).ok()
1950 });
1951
1952 if let Some(local_symbol) = local_symbol {
1953 callees.push((canon.clone(), local_symbol));
1954 }
1955 }
1956 }
1957 }
1958
1959 Ok(callees)
1960 }
1961
1962 pub fn impact(
1968 &mut self,
1969 file: &Path,
1970 symbol: &str,
1971 depth: usize,
1972 max_files: usize,
1973 ) -> Result<ImpactResult, AftError> {
1974 let canon = self.canonicalize(file)?;
1975
1976 let resolved_symbol = {
1978 let file_data = self.build_file(&canon)?;
1979 resolve_symbol_query_in_data(file_data, &canon, symbol)?
1980 };
1981
1982 if self.reverse_index.is_none() {
1984 self.build_reverse_index(max_files)?;
1985 }
1986
1987 let effective_depth = if depth == 0 { 1 } else { depth };
1988
1989 let (target_signature, target_parameters, target_lang) = {
1991 let file_data = match self.data.get(&canon) {
1992 Some(d) => d,
1993 None => {
1994 return Err(AftError::InvalidRequest {
1995 message: "file data missing after build".to_string(),
1996 })
1997 }
1998 };
1999 let meta = file_data.symbol_metadata.get(&resolved_symbol);
2000 let sig = meta.and_then(|m| m.signature.clone());
2001 let lang = file_data.lang;
2002 let params = sig
2003 .as_deref()
2004 .map(|s| extract_parameters(s, lang))
2005 .unwrap_or_default();
2006 (sig, params, lang)
2007 };
2008
2009 let mut visited = HashSet::new();
2011 let mut all_sites: Vec<CallerSite> = Vec::new();
2012 let mut depth_limited = false;
2013 let mut truncated = 0;
2014 self.collect_callers_recursive(
2015 &canon,
2016 &resolved_symbol,
2017 effective_depth,
2018 0,
2019 &mut visited,
2020 &mut all_sites,
2021 &mut depth_limited,
2022 &mut truncated,
2023 );
2024
2025 let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
2027 all_sites.retain(|site| {
2028 seen.insert((
2029 site.caller_file.clone(),
2030 site.caller_symbol.clone(),
2031 site.line,
2032 ))
2033 });
2034
2035 let mut callers = Vec::new();
2037 let mut affected_file_set = HashSet::new();
2038
2039 for site in &all_sites {
2040 if let Err(e) = self.build_file(site.caller_file.as_path()) {
2042 log::debug!(
2043 "callgraph: skipping caller file {}: {}",
2044 site.caller_file.display(),
2045 e
2046 );
2047 }
2048
2049 let (sig, is_ep, params, _lang) = {
2050 if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
2051 let meta = fd.symbol_metadata.get(&site.caller_symbol);
2052 let sig = meta.and_then(|m| m.signature.clone());
2053 let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
2054 let exported = meta.map(|m| m.exported).unwrap_or(false);
2055 let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
2056 let lang = fd.lang;
2057 let params = sig
2058 .as_deref()
2059 .map(|s| extract_parameters(s, lang))
2060 .unwrap_or_default();
2061 (sig, is_ep, params, lang)
2062 } else {
2063 (None, false, Vec::new(), target_lang)
2064 }
2065 };
2066
2067 let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
2069
2070 let rel_file = self.relative_path(site.caller_file.as_path());
2071 affected_file_set.insert(rel_file.clone());
2072
2073 callers.push(ImpactCaller {
2074 caller_symbol: site.caller_symbol.clone(),
2075 caller_file: rel_file,
2076 line: site.line,
2077 signature: sig,
2078 is_entry_point: is_ep,
2079 call_expression,
2080 parameters: params,
2081 });
2082 }
2083
2084 callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
2086
2087 let total_affected = callers.len();
2088 let affected_files = affected_file_set.len();
2089
2090 Ok(ImpactResult {
2091 symbol: resolved_symbol,
2092 file: self.relative_path(&canon),
2093 signature: target_signature,
2094 parameters: target_parameters,
2095 total_affected,
2096 affected_files,
2097 callers,
2098 depth_limited,
2099 truncated,
2100 })
2101 }
2102
2103 pub fn trace_data(
2114 &mut self,
2115 file: &Path,
2116 symbol: &str,
2117 expression: &str,
2118 max_depth: usize,
2119 max_files: usize,
2120 ) -> Result<TraceDataResult, AftError> {
2121 let canon = self.canonicalize(file)?;
2122 let rel_file = self.relative_path(&canon);
2123
2124 let resolved_symbol = {
2126 let file_data = self.build_file(&canon)?;
2127 resolve_symbol_query_in_data(file_data, &canon, symbol)?
2128 };
2129
2130 let count = self.project_file_count_bounded(max_files);
2134 if count > max_files {
2135 return Err(AftError::ProjectTooLarge {
2136 count,
2137 max: max_files,
2138 });
2139 }
2140
2141 let mut hops = Vec::new();
2142 let mut depth_limited = false;
2143
2144 self.trace_data_inner(
2145 &canon,
2146 &resolved_symbol,
2147 expression,
2148 max_depth,
2149 0,
2150 &mut hops,
2151 &mut depth_limited,
2152 &mut HashSet::new(),
2153 );
2154
2155 Ok(TraceDataResult {
2156 expression: expression.to_string(),
2157 origin_file: rel_file,
2158 origin_symbol: resolved_symbol,
2159 hops,
2160 depth_limited,
2161 })
2162 }
2163
2164 fn trace_data_inner(
2166 &mut self,
2167 file: &Path,
2168 symbol: &str,
2169 tracking_name: &str,
2170 max_depth: usize,
2171 current_depth: usize,
2172 hops: &mut Vec<DataFlowHop>,
2173 depth_limited: &mut bool,
2174 visited: &mut HashSet<(PathBuf, String, String)>,
2175 ) {
2176 let visit_key = (
2177 file.to_path_buf(),
2178 symbol.to_string(),
2179 tracking_name.to_string(),
2180 );
2181 if visited.contains(&visit_key) {
2182 return; }
2184 visited.insert(visit_key);
2185
2186 let source = match std::fs::read_to_string(file) {
2188 Ok(s) => s,
2189 Err(_) => return,
2190 };
2191
2192 let lang = match detect_language(file) {
2193 Some(l) => l,
2194 None => return,
2195 };
2196
2197 let grammar = grammar_for(lang);
2198 let mut parser = Parser::new();
2199 if parser.set_language(&grammar).is_err() {
2200 return;
2201 }
2202 let tree = match parser.parse(&source, None) {
2203 Some(t) => t,
2204 None => return,
2205 };
2206
2207 let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
2209 Ok(symbols) => symbols,
2210 Err(_) => return,
2211 };
2212 let sym_info = match symbols
2213 .iter()
2214 .find(|s| symbol_identity(s) == symbol || s.name == symbol)
2215 {
2216 Some(s) => s,
2217 None => return,
2218 };
2219
2220 let body_start =
2221 line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
2222 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
2223
2224 let root = tree.root_node();
2225
2226 let body_node = match find_node_covering_range(root, body_start, body_end) {
2228 Some(n) => n,
2229 None => return,
2230 };
2231
2232 let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
2234 let rel_file = self.relative_path(file);
2235
2236 self.walk_for_data_flow(
2238 body_node,
2239 &source,
2240 &mut tracked_names,
2241 file,
2242 symbol,
2243 &rel_file,
2244 lang,
2245 max_depth,
2246 current_depth,
2247 hops,
2248 depth_limited,
2249 visited,
2250 );
2251 }
2252
2253 #[allow(clippy::too_many_arguments)]
2256 fn walk_for_data_flow(
2257 &mut self,
2258 node: tree_sitter::Node,
2259 source: &str,
2260 tracked_names: &mut Vec<String>,
2261 file: &Path,
2262 symbol: &str,
2263 rel_file: &str,
2264 lang: LangId,
2265 max_depth: usize,
2266 current_depth: usize,
2267 hops: &mut Vec<DataFlowHop>,
2268 depth_limited: &mut bool,
2269 visited: &mut HashSet<(PathBuf, String, String)>,
2270 ) {
2271 let kind = node.kind();
2272
2273 let is_var_decl = matches!(
2275 kind,
2276 "variable_declarator"
2277 | "assignment_expression"
2278 | "augmented_assignment_expression"
2279 | "assignment"
2280 | "let_declaration"
2281 | "short_var_declaration"
2282 );
2283
2284 if is_var_decl {
2285 if let Some((new_name, init_text, line, is_approx)) =
2286 self.extract_assignment_info(node, source, lang, tracked_names)
2287 {
2288 if !is_approx {
2290 hops.push(DataFlowHop {
2291 file: rel_file.to_string(),
2292 symbol: symbol.to_string(),
2293 variable: new_name.clone(),
2294 line,
2295 flow_type: "assignment".to_string(),
2296 approximate: false,
2297 });
2298 tracked_names.push(new_name);
2299 } else {
2300 hops.push(DataFlowHop {
2302 file: rel_file.to_string(),
2303 symbol: symbol.to_string(),
2304 variable: init_text,
2305 line,
2306 flow_type: "assignment".to_string(),
2307 approximate: true,
2308 });
2309 return;
2311 }
2312 }
2313 }
2314
2315 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
2317 self.check_call_for_data_flow(
2318 node,
2319 source,
2320 tracked_names,
2321 file,
2322 symbol,
2323 rel_file,
2324 lang,
2325 max_depth,
2326 current_depth,
2327 hops,
2328 depth_limited,
2329 visited,
2330 );
2331 }
2332
2333 let mut cursor = node.walk();
2335 if cursor.goto_first_child() {
2336 loop {
2337 let child = cursor.node();
2338 self.walk_for_data_flow(
2340 child,
2341 source,
2342 tracked_names,
2343 file,
2344 symbol,
2345 rel_file,
2346 lang,
2347 max_depth,
2348 current_depth,
2349 hops,
2350 depth_limited,
2351 visited,
2352 );
2353 if !cursor.goto_next_sibling() {
2354 break;
2355 }
2356 }
2357 }
2358 }
2359
2360 fn extract_assignment_info(
2363 &self,
2364 node: tree_sitter::Node,
2365 source: &str,
2366 _lang: LangId,
2367 tracked_names: &[String],
2368 ) -> Option<(String, String, u32, bool)> {
2369 let kind = node.kind();
2370 let line = node.start_position().row as u32 + 1;
2371
2372 match kind {
2373 "variable_declarator" => {
2374 let name_node = node.child_by_field_name("name")?;
2376 let value_node = node.child_by_field_name("value")?;
2377 let name_text = node_text(name_node, source);
2378 let value_text = node_text(value_node, source);
2379
2380 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
2382 if tracked_names.iter().any(|t| value_text.contains(t)) {
2384 return Some((name_text.clone(), name_text, line, true));
2385 }
2386 return None;
2387 }
2388
2389 if tracked_names.iter().any(|t| {
2391 value_text == *t
2392 || value_text.starts_with(&format!("{}.", t))
2393 || value_text.starts_with(&format!("{}[", t))
2394 }) {
2395 return Some((name_text, value_text, line, false));
2396 }
2397 None
2398 }
2399 "assignment_expression" | "augmented_assignment_expression" => {
2400 let left = node.child_by_field_name("left")?;
2402 let right = node.child_by_field_name("right")?;
2403 let left_text = node_text(left, source);
2404 let right_text = node_text(right, source);
2405
2406 if tracked_names.iter().any(|t| right_text == *t) {
2407 return Some((left_text, right_text, line, false));
2408 }
2409 None
2410 }
2411 "assignment" => {
2412 let left = node.child_by_field_name("left")?;
2414 let right = node.child_by_field_name("right")?;
2415 let left_text = node_text(left, source);
2416 let right_text = node_text(right, source);
2417
2418 if tracked_names.iter().any(|t| right_text == *t) {
2419 return Some((left_text, right_text, line, false));
2420 }
2421 None
2422 }
2423 "let_declaration" | "short_var_declaration" => {
2424 let left = node
2426 .child_by_field_name("pattern")
2427 .or_else(|| node.child_by_field_name("left"))?;
2428 let right = node
2429 .child_by_field_name("value")
2430 .or_else(|| node.child_by_field_name("right"))?;
2431 let left_text = node_text(left, source);
2432 let right_text = node_text(right, source);
2433
2434 if tracked_names.iter().any(|t| right_text == *t) {
2435 return Some((left_text, right_text, line, false));
2436 }
2437 None
2438 }
2439 _ => None,
2440 }
2441 }
2442
2443 #[allow(clippy::too_many_arguments)]
2446 fn check_call_for_data_flow(
2447 &mut self,
2448 node: tree_sitter::Node,
2449 source: &str,
2450 tracked_names: &[String],
2451 file: &Path,
2452 _symbol: &str,
2453 rel_file: &str,
2454 _lang: LangId,
2455 max_depth: usize,
2456 current_depth: usize,
2457 hops: &mut Vec<DataFlowHop>,
2458 depth_limited: &mut bool,
2459 visited: &mut HashSet<(PathBuf, String, String)>,
2460 ) {
2461 let args_node = find_child_by_kind(node, "arguments")
2463 .or_else(|| find_child_by_kind(node, "argument_list"));
2464
2465 let args_node = match args_node {
2466 Some(n) => n,
2467 None => return,
2468 };
2469
2470 let mut arg_positions: Vec<(usize, String)> = Vec::new(); let mut arg_idx = 0;
2473
2474 let mut cursor = args_node.walk();
2475 if cursor.goto_first_child() {
2476 loop {
2477 let child = cursor.node();
2478 let child_kind = child.kind();
2479
2480 if child_kind == "(" || child_kind == ")" || child_kind == "," {
2482 if !cursor.goto_next_sibling() {
2483 break;
2484 }
2485 continue;
2486 }
2487
2488 let arg_text = node_text(child, source);
2489
2490 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
2492 if tracked_names.iter().any(|t| arg_text.contains(t)) {
2493 hops.push(DataFlowHop {
2494 file: rel_file.to_string(),
2495 symbol: _symbol.to_string(),
2496 variable: arg_text,
2497 line: child.start_position().row as u32 + 1,
2498 flow_type: "parameter".to_string(),
2499 approximate: true,
2500 });
2501 }
2502 if !cursor.goto_next_sibling() {
2503 break;
2504 }
2505 arg_idx += 1;
2506 continue;
2507 }
2508
2509 if tracked_names.iter().any(|t| arg_text == *t) {
2510 arg_positions.push((arg_idx, arg_text));
2511 }
2512
2513 arg_idx += 1;
2514 if !cursor.goto_next_sibling() {
2515 break;
2516 }
2517 }
2518 }
2519
2520 if arg_positions.is_empty() {
2521 return;
2522 }
2523
2524 let (full_callee, short_callee) = extract_callee_names(node, source);
2526 let full_callee = match full_callee {
2527 Some(f) => f,
2528 None => return,
2529 };
2530 let short_callee = match short_callee {
2531 Some(s) => s,
2532 None => return,
2533 };
2534
2535 let import_block = {
2537 match self.data.get(file) {
2538 Some(fd) => fd.import_block.clone(),
2539 None => return,
2540 }
2541 };
2542
2543 let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
2544
2545 match edge {
2546 EdgeResolution::Resolved {
2547 file: target_file,
2548 symbol: target_symbol,
2549 } => {
2550 if current_depth + 1 > max_depth {
2551 *depth_limited = true;
2552 return;
2553 }
2554
2555 if let Err(e) = self.build_file(&target_file) {
2557 log::debug!(
2558 "callgraph: skipping target file {}: {}",
2559 target_file.display(),
2560 e
2561 );
2562 }
2563 let (params, target_line) = {
2564 match self.lookup_file_data(&target_file) {
2565 Some(fd) => {
2566 let meta = fd.symbol_metadata.get(&target_symbol);
2567 let sig = meta.and_then(|m| m.signature.clone());
2568 let params = sig
2569 .as_deref()
2570 .map(|s| extract_parameters(s, fd.lang))
2571 .unwrap_or_default();
2572 let line = meta.map(|m| m.line).unwrap_or(1);
2573 (params, line)
2574 }
2575 None => return,
2576 }
2577 };
2578
2579 let target_rel = self.relative_path(&target_file);
2580
2581 for (pos, _tracked) in &arg_positions {
2582 if let Some(param_name) = params.get(*pos) {
2583 hops.push(DataFlowHop {
2585 file: target_rel.clone(),
2586 symbol: target_symbol.clone(),
2587 variable: param_name.clone(),
2588 line: target_line,
2589 flow_type: "parameter".to_string(),
2590 approximate: false,
2591 });
2592
2593 self.trace_data_inner(
2595 &target_file.clone(),
2596 &target_symbol.clone(),
2597 param_name,
2598 max_depth,
2599 current_depth + 1,
2600 hops,
2601 depth_limited,
2602 visited,
2603 );
2604 }
2605 }
2606 }
2607 EdgeResolution::Unresolved { callee_name } => {
2608 let local_symbol = if is_bare_callee(&full_callee, &callee_name) {
2609 self.data
2610 .get(file)
2611 .and_then(|fd| resolve_symbol_query_in_data(fd, file, &callee_name).ok())
2612 } else {
2613 None
2614 };
2615
2616 if let Some(local_symbol) = local_symbol {
2617 let (params, target_line) = {
2619 let Some(fd) = self.data.get(file) else {
2620 return;
2621 };
2622 let meta = fd.symbol_metadata.get(&local_symbol);
2623 let sig = meta.and_then(|m| m.signature.clone());
2624 let params = sig
2625 .as_deref()
2626 .map(|s| extract_parameters(s, fd.lang))
2627 .unwrap_or_default();
2628 let line = meta.map(|m| m.line).unwrap_or(1);
2629 (params, line)
2630 };
2631
2632 let file_rel = self.relative_path(file);
2633
2634 for (pos, _tracked) in &arg_positions {
2635 if let Some(param_name) = params.get(*pos) {
2636 hops.push(DataFlowHop {
2637 file: file_rel.clone(),
2638 symbol: local_symbol.clone(),
2639 variable: param_name.clone(),
2640 line: target_line,
2641 flow_type: "parameter".to_string(),
2642 approximate: false,
2643 });
2644
2645 self.trace_data_inner(
2647 file,
2648 &local_symbol,
2649 param_name,
2650 max_depth,
2651 current_depth + 1,
2652 hops,
2653 depth_limited,
2654 visited,
2655 );
2656 }
2657 }
2658 } else {
2659 for (_pos, tracked) in &arg_positions {
2661 hops.push(DataFlowHop {
2662 file: self.relative_path(file),
2663 symbol: callee_name.clone(),
2664 variable: tracked.clone(),
2665 line: node.start_position().row as u32 + 1,
2666 flow_type: "parameter".to_string(),
2667 approximate: true,
2668 });
2669 }
2670 }
2671 }
2672 }
2673 }
2674
2675 fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2677 let content = std::fs::read_to_string(path).ok()?;
2678 content
2679 .lines()
2680 .nth(line.saturating_sub(1) as usize)
2681 .map(|l| l.trim().to_string())
2682 }
2683
2684 fn collect_callers_recursive(
2686 &self,
2687 file: &Path,
2688 symbol: &str,
2689 max_depth: usize,
2690 current_depth: usize,
2691 visited: &mut HashSet<(PathBuf, SharedStr)>,
2692 result: &mut Vec<CallerSite>,
2693 depth_limited: &mut bool,
2694 truncated: &mut usize,
2695 ) {
2696 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2698 let key_symbol: SharedStr = Arc::from(symbol);
2699
2700 if current_depth >= max_depth {
2701 let omitted = self
2702 .reverse_sites(&canon, key_symbol.as_ref())
2703 .map(|sites| sites.len())
2704 .unwrap_or(0);
2705 if omitted > 0 {
2706 *depth_limited = true;
2707 *truncated += omitted;
2708 }
2709 return;
2710 }
2711
2712 if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2713 return; }
2715
2716 if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2717 for site in sites {
2718 result.push(CallerSite {
2719 caller_file: site.caller_file.as_ref().clone(),
2720 caller_symbol: site.caller_symbol.to_string(),
2721 line: site.line,
2722 col: site.col,
2723 resolved: site.resolved,
2724 });
2725 if current_depth + 1 < max_depth {
2727 self.collect_callers_recursive(
2728 site.caller_file.as_ref(),
2729 site.caller_symbol.as_ref(),
2730 max_depth,
2731 current_depth + 1,
2732 visited,
2733 result,
2734 depth_limited,
2735 truncated,
2736 );
2737 } else {
2738 let omitted = self
2739 .reverse_sites(site.caller_file.as_ref(), site.caller_symbol.as_ref())
2740 .map(|sites| sites.len())
2741 .unwrap_or(0);
2742 if omitted > 0 {
2743 *depth_limited = true;
2744 *truncated += omitted;
2745 }
2746 }
2747 }
2748 }
2749 }
2750
2751 pub fn invalidate_file(&mut self, path: &Path) {
2756 self.data.remove(path);
2758 if let Ok(canon) = self.canonicalize(path) {
2759 self.data.remove(&canon);
2760 }
2761 self.reverse_index = None;
2763 self.project_files = None;
2765 clear_workspace_package_cache();
2766 }
2767
2768 fn relative_path(&self, path: &Path) -> String {
2771 path.strip_prefix(&self.project_root)
2778 .unwrap_or(path)
2779 .to_string_lossy()
2780 .replace('\\', "/")
2781 }
2782
2783 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2785 let full_path = if path.is_relative() {
2787 self.project_root.join(path)
2788 } else {
2789 path.to_path_buf()
2790 };
2791
2792 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2794 }
2795
2796 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2800 if let Some(fd) = self.data.get(path) {
2801 return Some(fd);
2802 }
2803 let canon = std::fs::canonicalize(path).ok()?;
2805 self.data.get(&canon).or_else(|| {
2806 self.data.iter().find_map(|(k, v)| {
2808 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2809 Some(v)
2810 } else {
2811 None
2812 }
2813 })
2814 })
2815 }
2816}
2817
2818fn callgraph_parse_pool() -> rayon::ThreadPool {
2829 let threads = std::thread::available_parallelism()
2830 .map(|p| p.get())
2831 .unwrap_or(1)
2832 .div_ceil(2)
2833 .clamp(1, 8);
2834 rayon::ThreadPoolBuilder::new()
2835 .num_threads(threads)
2836 .thread_name(|i| format!("aft-callgraph-{i}"))
2837 .stack_size(8 * 1024 * 1024)
2838 .build()
2839 .unwrap_or_else(|_| {
2840 rayon::ThreadPoolBuilder::new()
2842 .num_threads(1)
2843 .build()
2844 .expect("single-thread rayon pool must build")
2845 })
2846}
2847
2848pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2849 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2850 message: format!("unsupported file for call graph: {}", path.display()),
2851 })?;
2852
2853 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2854 path: format!("{}: {}", path.display(), e),
2855 })?;
2856
2857 build_file_data_from_source_with_lang(path, &source, lang)
2858}
2859
2860pub(crate) fn build_file_data_from_source(
2861 path: &Path,
2862 source: &str,
2863) -> Result<FileCallData, AftError> {
2864 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2865 message: format!("unsupported file for call graph: {}", path.display()),
2866 })?;
2867 build_file_data_from_source_with_lang(path, source, lang)
2868}
2869
2870fn build_file_data_from_source_with_lang(
2871 path: &Path,
2872 source: &str,
2873 lang: LangId,
2874) -> Result<FileCallData, AftError> {
2875 let grammar = grammar_for(lang);
2876 let mut parser = Parser::new();
2877 parser
2878 .set_language(&grammar)
2879 .map_err(|e| AftError::ParseError {
2880 message: format!("grammar init failed for {:?}: {}", lang, e),
2881 })?;
2882
2883 let tree = parser
2884 .parse(&source, None)
2885 .ok_or_else(|| AftError::ParseError {
2886 message: format!("parse failed for {}", path.display()),
2887 })?;
2888
2889 let import_block = imports::parse_imports(&source, &tree, lang);
2891
2892 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2894
2895 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2897 let root = tree.root_node();
2898
2899 for sym in &symbols {
2900 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2901 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2902
2903 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2904
2905 let sites: Vec<CallSite> = raw_calls
2906 .into_iter()
2907 .map(
2908 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2909 callee_name: short,
2910 full_callee: full,
2911 line,
2912 byte_start: call_byte_start,
2913 byte_end: call_byte_end,
2914 },
2915 )
2916 .collect();
2917
2918 if !sites.is_empty() {
2919 calls_by_symbol.insert(symbol_identity(sym), sites);
2920 }
2921 }
2922
2923 let symbol_ranges: Vec<(usize, usize)> = symbols
2924 .iter()
2925 .map(|sym| {
2926 (
2927 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2928 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2929 )
2930 })
2931 .collect();
2932
2933 let top_level_sites: Vec<CallSite> =
2934 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2935 .into_iter()
2936 .filter(|site| {
2937 !symbol_ranges
2938 .iter()
2939 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2940 })
2941 .map(|site| CallSite {
2942 callee_name: site.short,
2943 full_callee: site.full,
2944 line: site.line,
2945 byte_start: site.byte_start,
2946 byte_end: site.byte_end,
2947 })
2948 .collect();
2949
2950 if !top_level_sites.is_empty() {
2951 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2952 }
2953
2954 let default_export = find_default_export(&source, root, path, lang);
2955
2956 if let Some(default_export) = &default_export {
2957 if default_export.synthetic {
2958 let byte_start = default_export.node.byte_range().start;
2959 let byte_end = default_export.node.byte_range().end;
2960 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2961 let sites: Vec<CallSite> = raw_calls
2962 .into_iter()
2963 .filter(|(_, short, _, _, _)| *short != default_export.symbol)
2964 .map(
2965 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2966 callee_name: short,
2967 full_callee: full,
2968 line,
2969 byte_start: call_byte_start,
2970 byte_end: call_byte_end,
2971 },
2972 )
2973 .collect();
2974 if !sites.is_empty() {
2975 calls_by_symbol.insert(default_export.symbol.clone(), sites);
2976 }
2977 }
2978 }
2979
2980 let mut exported_symbols: Vec<String> = symbols
2982 .iter()
2983 .filter(|s| s.exported)
2984 .map(|s| s.name.clone())
2985 .collect();
2986 if let Some(default_export) = &default_export {
2987 if !exported_symbols
2988 .iter()
2989 .any(|name| name == &default_export.symbol)
2990 {
2991 exported_symbols.push(default_export.symbol.clone());
2992 }
2993 }
2994
2995 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2997 .iter()
2998 .map(|s| {
2999 (
3000 symbol_identity(s),
3001 SymbolMeta {
3002 kind: s.kind.clone(),
3003 exported: s.exported,
3004 signature: s.signature.clone(),
3005 line: s.range.start_line + 1,
3006 range: s.range.clone(),
3007 },
3008 )
3009 })
3010 .collect();
3011 if let Some(default_export) = &default_export {
3012 symbol_metadata
3013 .entry(default_export.symbol.clone())
3014 .or_insert_with(|| SymbolMeta {
3015 kind: default_export.kind.clone(),
3016 exported: true,
3017 signature: Some(first_line_signature(&source, &default_export.node)),
3018 line: default_export.node.start_position().row as u32 + 1,
3019 range: crate::parser::node_range(&default_export.node),
3020 });
3021 }
3022 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
3023 symbol_metadata
3024 .entry(TOP_LEVEL_SYMBOL.to_string())
3025 .or_insert(SymbolMeta {
3026 kind: SymbolKind::Function,
3027 exported: false,
3028 signature: None,
3029 line: 1,
3030 range: Range {
3031 start_line: 0,
3032 start_col: 0,
3033 end_line: 0,
3034 end_col: 0,
3035 },
3036 });
3037 }
3038
3039 Ok(FileCallData {
3040 calls_by_symbol,
3041 exported_symbols,
3042 symbol_metadata,
3043 default_export_symbol: default_export.map(|export| export.symbol),
3044 import_block,
3045 lang,
3046 })
3047}
3048
3049#[derive(Debug, Clone)]
3050struct DefaultExport<'tree> {
3051 symbol: String,
3052 synthetic: bool,
3053 kind: SymbolKind,
3054 node: Node<'tree>,
3055}
3056
3057fn find_default_export<'tree>(
3058 source: &str,
3059 root: Node<'tree>,
3060 path: &Path,
3061 lang: LangId,
3062) -> Option<DefaultExport<'tree>> {
3063 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3064 return None;
3065 }
3066 find_default_export_inner(source, root, path)
3067}
3068
3069fn find_default_export_inner<'tree>(
3070 source: &str,
3071 node: Node<'tree>,
3072 path: &Path,
3073) -> Option<DefaultExport<'tree>> {
3074 if node.kind() == "export_statement" {
3075 if let Some(default_export) = default_export_from_statement(source, node, path) {
3076 return Some(default_export);
3077 }
3078 }
3079
3080 let mut cursor = node.walk();
3081 if !cursor.goto_first_child() {
3082 return None;
3083 }
3084
3085 loop {
3086 let child = cursor.node();
3087 if let Some(default_export) = find_default_export_inner(source, child, path) {
3088 return Some(default_export);
3089 }
3090 if !cursor.goto_next_sibling() {
3091 break;
3092 }
3093 }
3094
3095 None
3096}
3097
3098fn default_export_from_statement<'tree>(
3099 source: &str,
3100 node: Node<'tree>,
3101 path: &Path,
3102) -> Option<DefaultExport<'tree>> {
3103 let mut cursor = node.walk();
3104 if !cursor.goto_first_child() {
3105 return None;
3106 }
3107
3108 let mut saw_default = false;
3109 loop {
3110 let child = cursor.node();
3111 match child.kind() {
3112 "default" => saw_default = true,
3113 "function_declaration" | "generator_function_declaration" | "class_declaration"
3114 if saw_default =>
3115 {
3116 if let Some(name_node) = child.child_by_field_name("name") {
3117 return Some(DefaultExport {
3118 symbol: source[name_node.byte_range()].to_string(),
3119 synthetic: false,
3120 kind: default_export_kind(&child),
3121 node: child,
3122 });
3123 }
3124 return Some(DefaultExport {
3125 symbol: synthetic_default_symbol(path),
3126 synthetic: true,
3127 kind: default_export_kind(&child),
3128 node: child,
3129 });
3130 }
3131 "arrow_function"
3132 | "function"
3133 | "function_expression"
3134 | "class"
3135 | "class_expression"
3136 if saw_default =>
3137 {
3138 return Some(DefaultExport {
3139 symbol: synthetic_default_symbol(path),
3140 synthetic: true,
3141 kind: default_export_kind(&child),
3142 node: child,
3143 });
3144 }
3145 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
3146 return Some(DefaultExport {
3147 symbol: source[child.byte_range()].to_string(),
3148 synthetic: false,
3149 kind: SymbolKind::Function,
3150 node: child,
3151 });
3152 }
3153 _ => {}
3154 }
3155 if !cursor.goto_next_sibling() {
3156 break;
3157 }
3158 }
3159
3160 None
3161}
3162
3163fn default_export_kind(node: &Node) -> SymbolKind {
3164 if node.kind().contains("class") {
3165 SymbolKind::Class
3166 } else {
3167 SymbolKind::Function
3168 }
3169}
3170
3171fn synthetic_default_symbol(path: &Path) -> String {
3172 let file_name = path
3173 .file_name()
3174 .and_then(|name| name.to_str())
3175 .unwrap_or("unknown");
3176 format!("<default:{file_name}>")
3177}
3178
3179fn first_line_signature(source: &str, node: &Node) -> String {
3180 let text = &source[node.byte_range()];
3181 let first_line = text.lines().next().unwrap_or(text);
3182 first_line
3183 .trim_end()
3184 .trim_end_matches('{')
3185 .trim_end()
3186 .to_string()
3187}
3188
3189fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
3190 file_data
3191 .symbol_metadata
3192 .get(symbol_name)
3193 .map(|meta| (meta.line, meta.signature.clone()))
3194 .unwrap_or((1, None))
3195}
3196
3197fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
3199 let provider = crate::parser::TreeSitterProvider::new();
3200 match provider.list_symbols(path) {
3201 Ok(symbols) => {
3202 for s in &symbols {
3203 if symbol_identity(s) == symbol_name || s.name == symbol_name {
3204 return (s.range.start_line + 1, s.signature.clone());
3205 }
3206 }
3207 (1, None)
3208 }
3209 Err(_) => (1, None),
3210 }
3211}
3212
3213fn node_text(node: tree_sitter::Node, source: &str) -> String {
3219 source[node.start_byte()..node.end_byte()].to_string()
3220}
3221
3222fn find_node_covering_range(
3224 root: tree_sitter::Node,
3225 start: usize,
3226 end: usize,
3227) -> Option<tree_sitter::Node> {
3228 let mut best = None;
3229 let mut cursor = root.walk();
3230
3231 fn walk_covering<'a>(
3232 cursor: &mut tree_sitter::TreeCursor<'a>,
3233 start: usize,
3234 end: usize,
3235 best: &mut Option<tree_sitter::Node<'a>>,
3236 ) {
3237 let node = cursor.node();
3238 if node.start_byte() <= start && node.end_byte() >= end {
3239 *best = Some(node);
3240 if cursor.goto_first_child() {
3241 loop {
3242 walk_covering(cursor, start, end, best);
3243 if !cursor.goto_next_sibling() {
3244 break;
3245 }
3246 }
3247 cursor.goto_parent();
3248 }
3249 }
3250 }
3251
3252 walk_covering(&mut cursor, start, end, &mut best);
3253 best
3254}
3255
3256fn find_child_by_kind<'a>(
3258 node: tree_sitter::Node<'a>,
3259 kind: &str,
3260) -> Option<tree_sitter::Node<'a>> {
3261 let mut cursor = node.walk();
3262 if cursor.goto_first_child() {
3263 loop {
3264 if cursor.node().kind() == kind {
3265 return Some(cursor.node());
3266 }
3267 if !cursor.goto_next_sibling() {
3268 break;
3269 }
3270 }
3271 }
3272 None
3273}
3274
3275#[derive(Debug, Clone)]
3276struct CallSiteWithRange {
3277 full: String,
3278 short: String,
3279 line: u32,
3280 byte_start: usize,
3281 byte_end: usize,
3282}
3283
3284fn collect_calls_full_with_ranges(
3285 root: tree_sitter::Node,
3286 source: &str,
3287 byte_start: usize,
3288 byte_end: usize,
3289 lang: LangId,
3290) -> Vec<CallSiteWithRange> {
3291 let mut results = Vec::new();
3292 let call_kinds = call_node_kinds(lang);
3293 collect_calls_full_with_ranges_inner(
3294 root,
3295 source,
3296 byte_start,
3297 byte_end,
3298 &call_kinds,
3299 &mut results,
3300 );
3301 results
3302}
3303
3304fn collect_calls_full_with_ranges_inner(
3305 node: tree_sitter::Node,
3306 source: &str,
3307 byte_start: usize,
3308 byte_end: usize,
3309 call_kinds: &[&str],
3310 results: &mut Vec<CallSiteWithRange>,
3311) {
3312 let node_start = node.start_byte();
3313 let node_end = node.end_byte();
3314
3315 if node_end <= byte_start || node_start >= byte_end {
3316 return;
3317 }
3318
3319 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
3320 if let (Some(full), Some(short)) = (
3321 extract_full_callee(&node, source),
3322 extract_callee_name(&node, source),
3323 ) {
3324 results.push(CallSiteWithRange {
3325 full,
3326 short,
3327 line: node.start_position().row as u32 + 1,
3328 byte_start: node_start,
3329 byte_end: node_end,
3330 });
3331 }
3332 }
3333
3334 let mut cursor = node.walk();
3335 if cursor.goto_first_child() {
3336 loop {
3337 collect_calls_full_with_ranges_inner(
3338 cursor.node(),
3339 source,
3340 byte_start,
3341 byte_end,
3342 call_kinds,
3343 results,
3344 );
3345 if !cursor.goto_next_sibling() {
3346 break;
3347 }
3348 }
3349 }
3350}
3351
3352fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
3354 let callee = match node.child_by_field_name("function") {
3356 Some(c) => c,
3357 None => return (None, None),
3358 };
3359
3360 let full = node_text(callee, source);
3361 let short = if full.contains('.') {
3362 full.rsplit('.').next().unwrap_or(&full).to_string()
3363 } else {
3364 full.clone()
3365 };
3366
3367 (Some(full), Some(short))
3368}
3369
3370pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3378 if module_path.starts_with('.') {
3379 return resolve_relative_module_path(from_dir, module_path);
3380 }
3381
3382 if module_path.starts_with('/') {
3383 return None;
3384 }
3385
3386 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
3387 return Some(path);
3388 }
3389
3390 resolve_workspace_module_path(from_dir, module_path)
3391}
3392
3393fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3394 let base = from_dir.join(module_path);
3395 resolve_file_like_path(&base)
3396}
3397
3398fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
3399 let base = base.to_path_buf();
3400
3401 if base.is_file() {
3403 return Some(std::fs::canonicalize(&base).unwrap_or(base));
3404 }
3405
3406 for ext in JS_TS_EXTENSIONS {
3408 let with_ext = base.with_extension(ext);
3409 if with_ext.is_file() {
3410 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
3411 }
3412 }
3413
3414 if base.is_dir() {
3416 if let Some(index) = find_index_file(&base) {
3417 return Some(index);
3418 }
3419 }
3420
3421 None
3422}
3423
3424fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3425 let (package_name, subpath) = split_package_import(module_path)?;
3426 let package_root = find_package_root_for_import(from_dir, &package_name)?;
3427 resolve_package_entry(&package_root, &subpath)
3428}
3429
3430fn is_rust_source_file(path: &Path) -> bool {
3431 path.extension().and_then(|ext| ext.to_str()) == Some("rs")
3432}
3433
3434fn resolve_rust_cross_file_edge<F>(
3435 full_callee: &str,
3436 short_name: &str,
3437 caller_file: &Path,
3438 import_block: &ImportBlock,
3439 file_exports_symbol: &mut F,
3440) -> Option<ResolvedSymbol>
3441where
3442 F: FnMut(&Path, &str) -> bool,
3443{
3444 if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
3445 {
3446 return Some(target);
3447 }
3448
3449 resolve_rust_imported_call(
3450 caller_file,
3451 full_callee,
3452 short_name,
3453 import_block,
3454 file_exports_symbol,
3455 )
3456}
3457
3458fn resolve_rust_qualified_call<F>(
3459 caller_file: &Path,
3460 full_callee: &str,
3461 file_exports_symbol: &mut F,
3462) -> Option<ResolvedSymbol>
3463where
3464 F: FnMut(&Path, &str) -> bool,
3465{
3466 if !full_callee.contains("::") {
3467 return None;
3468 }
3469
3470 let segments = rust_path_segments(full_callee)?;
3471 resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
3472}
3473
3474fn resolve_rust_imported_call<F>(
3475 caller_file: &Path,
3476 full_callee: &str,
3477 short_name: &str,
3478 import_block: &ImportBlock,
3479 file_exports_symbol: &mut F,
3480) -> Option<ResolvedSymbol>
3481where
3482 F: FnMut(&Path, &str) -> bool,
3483{
3484 let call_segments = rust_path_segments(full_callee).unwrap_or_default();
3485 let bare_call_name = if call_segments.len() <= 1 {
3486 call_segments
3487 .first()
3488 .map(String::as_str)
3489 .unwrap_or(short_name)
3490 } else {
3491 short_name
3492 };
3493
3494 for imp in &import_block.imports {
3495 for entry in rust_use_entries(imp) {
3496 match &entry.kind {
3497 RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
3498 if entry.local_name != bare_call_name {
3499 continue;
3500 }
3501 let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
3502 else {
3503 continue;
3504 };
3505 if file_exports_symbol(&file, imported_name) {
3506 return Some(ResolvedSymbol {
3507 file,
3508 symbol: imported_name.clone(),
3509 });
3510 }
3511 }
3512 RustUseKind::Module if call_segments.len() >= 2 => {
3513 if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
3514 {
3515 continue;
3516 }
3517 let symbol = call_segments.last()?.clone();
3518 let mut module_path = entry.module_path.clone();
3519 for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
3520 module_path.push_str("::");
3521 module_path.push_str(segment);
3522 }
3523 let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
3524 continue;
3525 };
3526 if file_exports_symbol(&file, &symbol) {
3527 return Some(ResolvedSymbol { file, symbol });
3528 }
3529 }
3530 _ => {}
3531 }
3532 }
3533 }
3534
3535 None
3536}
3537
3538fn resolve_rust_call_segments<F>(
3539 caller_file: &Path,
3540 segments: &[String],
3541 file_exports_symbol: &mut F,
3542) -> Option<ResolvedSymbol>
3543where
3544 F: FnMut(&Path, &str) -> bool,
3545{
3546 if segments.len() < 2 {
3547 return None;
3548 }
3549
3550 let symbol = segments.last()?.clone();
3551 let module_path = segments[..segments.len() - 1].join("::");
3552 let file = resolve_rust_module_path(caller_file, &module_path)?;
3553 if file_exports_symbol(&file, &symbol) {
3554 Some(ResolvedSymbol { file, symbol })
3555 } else {
3556 None
3557 }
3558}
3559
3560fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
3561 let segments = rust_path_segments(module_path)?;
3562 let first = segments.first()?.as_str();
3563
3564 match first {
3565 "std" | "core" | "alloc" => None,
3566 "crate" => {
3567 let crate_root = find_rust_crate_root(caller_file)?;
3568 let crate_info = rust_crate_info(&crate_root)?;
3569 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3570 resolve_rust_module_segments(&base, &segments[1..])
3571 }
3572 "self" => {
3573 let crate_root = find_rust_crate_root(caller_file)?;
3574 let crate_info = rust_crate_info(&crate_root)?;
3575 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3576 if segments.len() == 1 {
3577 return Some(canonicalize_path(caller_file));
3578 }
3579 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3580 target_segments.extend(segments[1..].iter().cloned());
3581 resolve_rust_module_segments(&base, &target_segments)
3582 }
3583 "super" => {
3584 let crate_root = find_rust_crate_root(caller_file)?;
3585 let crate_info = rust_crate_info(&crate_root)?;
3586 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3587 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3588 target_segments.pop();
3589 target_segments.extend(segments[1..].iter().cloned());
3590 resolve_rust_module_segments(&base, &target_segments)
3591 }
3592 crate_name => {
3593 let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
3594 let workspace_crates = rust_workspace_crates(caller_dir)?;
3595 let crate_info = workspace_crates.get(crate_name)?;
3596 let base = rust_lib_module_base(crate_info)?;
3597 resolve_rust_module_segments(&base, &segments[1..])
3598 }
3599 }
3600}
3601
3602fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
3603 let Some(body) = rust_use_body(&imp.raw_text) else {
3604 return Vec::new();
3605 };
3606 let mut entries = Vec::new();
3607 expand_rust_use_tree(body, &mut entries);
3608 entries
3609}
3610
3611fn rust_use_body(raw: &str) -> Option<&str> {
3612 let use_pos = raw.find("use ")?;
3613 let body = raw[use_pos + 4..].trim();
3614 let body = body.strip_suffix(';').unwrap_or(body).trim();
3615 (!body.is_empty()).then_some(body)
3616}
3617
3618fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
3619 let path = path.trim();
3620 if path.is_empty() {
3621 return;
3622 }
3623
3624 if let Some((prefix, inner)) = split_rust_use_braces(path) {
3625 let prefix = prefix.trim().trim_end_matches("::").trim();
3626 for part in split_top_level_commas(inner) {
3627 let part = part.trim();
3628 if part.is_empty() {
3629 continue;
3630 }
3631 if part == "self" {
3632 if let Some(local_name) = rust_last_path_segment(prefix) {
3633 entries.push(RustUseEntry {
3634 module_path: prefix.to_string(),
3635 local_name,
3636 kind: RustUseKind::Module,
3637 });
3638 }
3639 continue;
3640 }
3641 let combined = if prefix.is_empty() {
3642 part.to_string()
3643 } else {
3644 format!("{prefix}::{part}")
3645 };
3646 expand_rust_use_tree(&combined, entries);
3647 }
3648 return;
3649 }
3650
3651 add_rust_use_leaf(path, entries);
3652}
3653
3654fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
3655 let mut depth = 0usize;
3656 let mut start = None;
3657 for (idx, ch) in path.char_indices() {
3658 match ch {
3659 '{' => {
3660 if depth == 0 {
3661 start = Some(idx);
3662 }
3663 depth += 1;
3664 }
3665 '}' => {
3666 depth = depth.checked_sub(1)?;
3667 if depth == 0 {
3668 let start = start?;
3669 if !path[idx + ch.len_utf8()..].trim().is_empty() {
3670 return None;
3671 }
3672 return Some((&path[..start], &path[start + 1..idx]));
3673 }
3674 }
3675 _ => {}
3676 }
3677 }
3678 None
3679}
3680
3681fn split_top_level_commas(value: &str) -> Vec<&str> {
3682 let mut parts = Vec::new();
3683 let mut depth = 0usize;
3684 let mut start = 0usize;
3685 for (idx, ch) in value.char_indices() {
3686 match ch {
3687 '{' => depth += 1,
3688 '}' => depth = depth.saturating_sub(1),
3689 ',' if depth == 0 => {
3690 parts.push(&value[start..idx]);
3691 start = idx + ch.len_utf8();
3692 }
3693 _ => {}
3694 }
3695 }
3696 parts.push(&value[start..]);
3697 parts
3698}
3699
3700fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
3701 let (path, alias) = split_rust_alias(path);
3702 let Some(segments) = rust_path_segments(path) else {
3703 return;
3704 };
3705 if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
3706 return;
3707 }
3708
3709 let imported_name = segments.last().cloned().unwrap_or_default();
3710 let local_name = alias.unwrap_or(&imported_name).to_string();
3711 if segments.len() >= 2 {
3712 entries.push(RustUseEntry {
3713 module_path: segments[..segments.len() - 1].join("::"),
3714 local_name: local_name.clone(),
3715 kind: RustUseKind::Item {
3716 imported_name: imported_name.clone(),
3717 },
3718 });
3719 }
3720
3721 entries.push(RustUseEntry {
3722 module_path: segments.join("::"),
3723 local_name,
3724 kind: RustUseKind::Module,
3725 });
3726}
3727
3728fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
3729 if let Some(idx) = path.rfind(" as ") {
3730 let original = path[..idx].trim();
3731 let alias = path[idx + 4..].trim();
3732 if !original.is_empty() && !alias.is_empty() {
3733 return (original, Some(alias));
3734 }
3735 }
3736 (path.trim(), None)
3737}
3738
3739fn rust_path_segments(path: &str) -> Option<Vec<String>> {
3740 let path = path.trim().trim_end_matches(';').trim();
3741 if path.is_empty() || path.contains('{') || path.contains('}') {
3742 return None;
3743 }
3744
3745 let mut segments = Vec::new();
3746 for raw_segment in path.split("::") {
3747 let segment = raw_segment.trim();
3748 if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
3749 return None;
3750 }
3751 let segment = segment.strip_prefix("r#").unwrap_or(segment);
3752 if segment
3753 .chars()
3754 .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
3755 {
3756 return None;
3757 }
3758 segments.push(segment.to_string());
3759 }
3760
3761 (!segments.is_empty()).then_some(segments)
3762}
3763
3764fn rust_last_path_segment(path: &str) -> Option<String> {
3765 rust_path_segments(path)?.last().cloned()
3766}
3767
3768fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
3769 let mut current = if from.is_file() {
3770 from.parent()
3771 } else {
3772 Some(from)
3773 };
3774 while let Some(dir) = current {
3775 if dir.join("Cargo.toml").is_file() {
3776 return Some(canonicalize_path(dir));
3777 }
3778 current = dir.parent();
3779 }
3780 None
3781}
3782
3783fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3784 let root = canonicalize_path(crate_root);
3785 if let Some(cached) = RUST_CRATE_INFO_CACHE
3786 .read()
3787 .ok()
3788 .and_then(|cache| cache.get(&root).cloned())
3789 {
3790 return cached;
3791 }
3792
3793 let resolved = read_rust_crate_info(&root);
3794 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
3795 cache.insert(root, resolved.clone());
3796 }
3797 resolved
3798}
3799
3800fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3801 let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
3802 let package = cargo.get("package")?;
3803 let package_name = package.get("name")?.as_str()?;
3804 let lib_name = cargo
3805 .get("lib")
3806 .and_then(|lib| lib.get("name"))
3807 .and_then(|name| name.as_str())
3808 .map(ToOwned::to_owned)
3809 .unwrap_or_else(|| package_name.replace('-', "_"));
3810
3811 let lib_root = cargo
3812 .get("lib")
3813 .and_then(|lib| lib.get("path"))
3814 .and_then(|path| path.as_str())
3815 .map(|path| crate_root.join(path))
3816 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
3817 let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
3818
3819 let main_root = crate_root.join("src/main.rs");
3820 let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
3821
3822 Some(RustCrateInfo {
3823 lib_name,
3824 lib_root,
3825 main_root,
3826 })
3827}
3828
3829fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
3830 let source = std::fs::read_to_string(path).ok()?;
3831 toml::from_str(&source).ok()
3832}
3833
3834fn rust_module_base_for_caller(
3835 crate_info: &RustCrateInfo,
3836 caller_file: &Path,
3837) -> Option<RustModuleBase> {
3838 let caller = canonicalize_path(caller_file);
3839 if crate_info.main_root.as_ref() == Some(&caller) {
3840 return rust_main_module_base(crate_info);
3841 }
3842 rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
3843}
3844
3845fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3846 let root_file = crate_info.lib_root.clone()?;
3847 let src_dir = root_file.parent()?.to_path_buf();
3848 Some(RustModuleBase { src_dir, root_file })
3849}
3850
3851fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3852 let root_file = crate_info.main_root.clone()?;
3853 let src_dir = root_file.parent()?.to_path_buf();
3854 Some(RustModuleBase { src_dir, root_file })
3855}
3856
3857fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
3858 if segments.is_empty() {
3859 return Some(base.root_file.clone());
3860 }
3861
3862 let module_base = segments
3863 .iter()
3864 .fold(base.src_dir.clone(), |path, segment| path.join(segment));
3865 let file_path = module_base.with_extension("rs");
3866 if file_path.is_file() {
3867 return Some(canonicalize_path(&file_path));
3868 }
3869
3870 let mod_path = module_base.join("mod.rs");
3871 if mod_path.is_file() {
3872 return Some(canonicalize_path(&mod_path));
3873 }
3874
3875 None
3876}
3877
3878fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
3879 let src_dir = canonicalize_path(src_dir);
3880 let file = canonicalize_path(file);
3881 let rel = file.strip_prefix(&src_dir).ok()?;
3882 let mut parts: Vec<String> = rel
3883 .components()
3884 .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
3885 .collect();
3886 if parts.is_empty() {
3887 return None;
3888 }
3889
3890 let last = parts.pop()?;
3891 if last == "lib.rs" || last == "main.rs" {
3892 return Some(Vec::new());
3893 }
3894 if last == "mod.rs" {
3895 return Some(parts);
3896 }
3897 let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
3898 parts.push(stem);
3899 Some(parts)
3900}
3901
3902fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
3903 let workspace_root =
3904 find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
3905 let workspace_root = canonicalize_path(&workspace_root);
3906
3907 if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
3908 .read()
3909 .ok()
3910 .and_then(|cache| cache.get(&workspace_root).cloned())
3911 {
3912 return Some(cached);
3913 }
3914
3915 let mut crates = HashMap::new();
3916 for member in rust_workspace_member_dirs(&workspace_root) {
3917 if let Some(info) = rust_crate_info(&member) {
3918 if info.lib_root.is_some() {
3919 crates.insert(info.lib_name.clone(), info);
3920 }
3921 }
3922 }
3923 if let Some(info) = rust_crate_info(&workspace_root) {
3924 if info.lib_root.is_some() {
3925 crates.insert(info.lib_name.clone(), info);
3926 }
3927 }
3928
3929 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
3930 cache.insert(workspace_root, crates.clone());
3931 }
3932 Some(crates)
3933}
3934
3935fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3936 let mut current = Some(from_dir);
3937 while let Some(dir) = current {
3938 let cargo = dir.join("Cargo.toml");
3939 if rust_manifest_value(&cargo)
3940 .and_then(|value| value.get("workspace").cloned())
3941 .is_some()
3942 {
3943 return Some(canonicalize_path(dir));
3944 }
3945 current = dir.parent();
3946 }
3947 None
3948}
3949
3950fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
3951 let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
3952 return Vec::new();
3953 };
3954 let Some(members) = cargo
3955 .get("workspace")
3956 .and_then(|workspace| workspace.get("members"))
3957 .and_then(|members| members.as_array())
3958 else {
3959 return Vec::new();
3960 };
3961
3962 let mut dirs = Vec::new();
3963 for member in members.iter().filter_map(|member| member.as_str()) {
3964 dirs.extend(expand_rust_workspace_member(workspace_root, member));
3965 }
3966 dirs.sort();
3967 dirs.dedup();
3968 dirs
3969}
3970
3971fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
3972 let member = member.trim();
3973 if member.is_empty() {
3974 return Vec::new();
3975 }
3976
3977 if member.contains('*') || member.contains('?') || member.contains('[') {
3978 let pattern = workspace_root.join(member).to_string_lossy().to_string();
3979 return glob::glob(&pattern)
3980 .ok()
3981 .into_iter()
3982 .flatten()
3983 .filter_map(Result::ok)
3984 .filter(|path| path.join("Cargo.toml").is_file())
3985 .map(|path| canonicalize_path(&path))
3986 .collect();
3987 }
3988
3989 let path = workspace_root.join(member);
3990 if path.join("Cargo.toml").is_file() {
3991 vec![canonicalize_path(&path)]
3992 } else {
3993 Vec::new()
3994 }
3995}
3996
3997fn canonicalize_path(path: &Path) -> PathBuf {
3998 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3999}
4000
4001fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
4002 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
4003 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
4004 let compiler_options = tsconfig.get("compilerOptions")?;
4005 let paths = compiler_options.get("paths")?.as_object()?;
4006 let base_url = compiler_options
4007 .get("baseUrl")
4008 .and_then(Value::as_str)
4009 .unwrap_or(".");
4010 let base_dir = tsconfig_dir.join(base_url);
4011
4012 for (alias, targets) in paths {
4013 let Some(capture) = ts_path_capture(alias, module_path) else {
4014 continue;
4015 };
4016 let Some(targets) = targets.as_array() else {
4017 continue;
4018 };
4019 for target in targets.iter().filter_map(Value::as_str) {
4020 let target = if target.contains('*') {
4021 target.replace('*', capture)
4022 } else {
4023 target.to_string()
4024 };
4025 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
4026 return Some(path);
4027 }
4028 }
4029 }
4030
4031 None
4032}
4033
4034fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
4035 let mut current = Some(from_dir);
4036 while let Some(dir) = current {
4037 if dir.join("tsconfig.json").is_file() {
4038 return Some(dir.to_path_buf());
4039 }
4040 current = dir.parent();
4041 }
4042 None
4043}
4044
4045fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
4046 if let Some(star_index) = alias.find('*') {
4047 let (prefix, suffix_with_star) = alias.split_at(star_index);
4048 let suffix = &suffix_with_star[1..];
4049 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
4050 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
4051 }
4052 return None;
4053 }
4054
4055 (alias == module_path).then_some("")
4056}
4057
4058fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
4059 let mut parts = module_path.split('/');
4060 let first = parts.next()?;
4061 if first.is_empty() {
4062 return None;
4063 }
4064
4065 if first.starts_with('@') {
4066 let second = parts.next()?;
4067 if second.is_empty() {
4068 return None;
4069 }
4070 let package_name = format!("{first}/{second}");
4071 let subpath = parts.collect::<Vec<_>>().join("/");
4072 let subpath = (!subpath.is_empty()).then_some(subpath);
4073 Some((package_name, subpath))
4074 } else {
4075 let package_name = first.to_string();
4076 let subpath = parts.collect::<Vec<_>>().join("/");
4077 let subpath = (!subpath.is_empty()).then_some(subpath);
4078 Some((package_name, subpath))
4079 }
4080}
4081
4082fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
4083 let mut current = Some(from_dir);
4084 while let Some(dir) = current {
4085 if package_json_name(dir).as_deref() == Some(package_name) {
4086 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4087 }
4088 current = dir.parent();
4089 }
4090
4091 find_workspace_root(from_dir)
4092 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
4093}
4094
4095fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
4096 let mut current = Some(from_dir);
4097 while let Some(dir) = current {
4098 if is_workspace_root(dir) {
4099 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4100 }
4101 current = dir.parent();
4102 }
4103 None
4104}
4105
4106fn is_workspace_root(dir: &Path) -> bool {
4107 package_json_value(dir)
4108 .map(|value| !workspace_patterns(&value).is_empty())
4109 .unwrap_or(false)
4110 || !pnpm_workspace_patterns(dir).is_empty()
4111}
4112
4113fn clear_workspace_package_cache() {
4114 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4115 cache.clear();
4116 }
4117 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
4118 cache.clear();
4119 }
4120 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
4121 cache.clear();
4122 }
4123}
4124
4125fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
4126 let workspace_root =
4127 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
4128 let cache_key = (workspace_root.clone(), package_name.to_string());
4129
4130 if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
4131 if let Some(cached) = cache.get(&cache_key) {
4132 return cached.clone();
4133 }
4134 }
4135
4136 let resolved = workspace_member_dirs(&workspace_root)
4137 .into_iter()
4138 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
4139 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
4140
4141 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4142 cache.insert(cache_key, resolved.clone());
4143 }
4144
4145 resolved
4146}
4147
4148fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
4149 let mut patterns = package_json_value(workspace_root)
4150 .map(|package_json| workspace_patterns(&package_json))
4151 .unwrap_or_default();
4152 patterns.extend(pnpm_workspace_patterns(workspace_root));
4153
4154 expand_workspace_patterns(workspace_root, &patterns)
4155}
4156
4157fn workspace_patterns(package_json: &Value) -> Vec<String> {
4158 match package_json.get("workspaces") {
4159 Some(Value::Array(items)) => items
4160 .iter()
4161 .filter_map(non_empty_workspace_pattern)
4162 .collect(),
4163 Some(Value::Object(map)) => map
4164 .get("packages")
4165 .and_then(Value::as_array)
4166 .map(|items| {
4167 items
4168 .iter()
4169 .filter_map(non_empty_workspace_pattern)
4170 .collect()
4171 })
4172 .unwrap_or_default(),
4173 _ => Vec::new(),
4174 }
4175}
4176
4177fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
4178 let pattern = value.as_str()?.trim();
4179 (!pattern.is_empty()).then(|| pattern.to_string())
4180}
4181
4182fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
4183 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
4184 return Vec::new();
4185 };
4186
4187 let mut patterns = Vec::new();
4188 let mut in_packages = false;
4189 for line in source.lines() {
4190 let without_comment = line.split('#').next().unwrap_or("").trim_end();
4191 let trimmed = without_comment.trim();
4192 if trimmed.is_empty() {
4193 continue;
4194 }
4195 if trimmed == "packages:" {
4196 in_packages = true;
4197 continue;
4198 }
4199 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
4200 in_packages = false;
4201 }
4202 if in_packages {
4203 if let Some(pattern) = trimmed.strip_prefix('-') {
4204 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
4205 if !pattern.is_empty() {
4206 patterns.push(pattern.to_string());
4207 }
4208 }
4209 }
4210 }
4211 patterns
4212}
4213
4214fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
4215 let positive_patterns: Vec<&str> = patterns
4216 .iter()
4217 .map(|pattern| pattern.trim())
4218 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
4219 .collect();
4220 if positive_patterns.is_empty() {
4221 return Vec::new();
4222 }
4223
4224 let positives = build_glob_set(&positive_patterns);
4225 let negative_patterns: Vec<&str> = patterns
4226 .iter()
4227 .map(|pattern| pattern.trim())
4228 .filter_map(|pattern| pattern.strip_prefix('!'))
4229 .map(str::trim)
4230 .filter(|pattern| !pattern.is_empty())
4231 .collect();
4232 let negatives = build_glob_set(&negative_patterns);
4233
4234 let mut members = Vec::new();
4235 collect_workspace_member_dirs(
4236 workspace_root,
4237 workspace_root,
4238 &positives,
4239 &negatives,
4240 &mut members,
4241 );
4242 members
4243}
4244
4245fn build_glob_set(patterns: &[&str]) -> GlobSet {
4246 let mut builder = GlobSetBuilder::new();
4247 for pattern in patterns {
4248 if let Ok(glob) = Glob::new(pattern) {
4249 builder.add(glob);
4250 }
4251 }
4252 builder
4253 .build()
4254 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
4255}
4256
4257fn collect_workspace_member_dirs(
4258 workspace_root: &Path,
4259 dir: &Path,
4260 positives: &GlobSet,
4261 negatives: &GlobSet,
4262 members: &mut Vec<PathBuf>,
4263) {
4264 let Ok(entries) = std::fs::read_dir(dir) else {
4265 return;
4266 };
4267
4268 for entry in entries.filter_map(Result::ok) {
4269 let path = entry.path();
4270 let Ok(file_type) = entry.file_type() else {
4271 continue;
4272 };
4273 if !file_type.is_dir() {
4274 continue;
4275 }
4276 let name = entry.file_name();
4277 let name = name.to_string_lossy();
4278 if matches!(
4279 name.as_ref(),
4280 "node_modules" | ".git" | "target" | "dist" | "build"
4281 ) {
4282 continue;
4283 }
4284
4285 if path.join("package.json").is_file() {
4286 if let Ok(rel) = path.strip_prefix(workspace_root) {
4287 let rel = rel.to_string_lossy().replace('\\', "/");
4288 if positives.is_match(&rel) && !negatives.is_match(&rel) {
4289 members.push(path.clone());
4290 }
4291 }
4292 }
4293
4294 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
4295 }
4296}
4297
4298fn package_json_value(dir: &Path) -> Option<Value> {
4299 package_json_like_value(&dir.join("package.json"))
4300}
4301
4302fn package_json_like_value(path: &Path) -> Option<Value> {
4303 let json = std::fs::read_to_string(path).ok()?;
4304 serde_json::from_str(&json).ok()
4305}
4306
4307fn package_json_name(dir: &Path) -> Option<String> {
4308 package_json_value(dir)?
4309 .get("name")?
4310 .as_str()
4311 .map(ToOwned::to_owned)
4312}
4313
4314fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
4315 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
4316
4317 if let Some(exports) = package_json.get("exports") {
4318 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
4319 if let Some(path) = resolve_package_target(package_root, &target) {
4320 return Some(path);
4321 }
4322 }
4323 }
4324
4325 if subpath.is_none() {
4326 for field in ["module", "main"] {
4327 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
4328 if let Some(path) = resolve_package_target(package_root, target) {
4329 return Some(path);
4330 }
4331 }
4332 }
4333 }
4334
4335 resolve_package_fallback(package_root, subpath.as_deref())
4336}
4337
4338fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
4339 let key = subpath
4340 .map(|value| format!("./{value}"))
4341 .unwrap_or_else(|| ".".to_string());
4342
4343 match exports {
4344 Value::String(target) if key == "." => Some(target.clone()),
4345 Value::Object(map) => {
4346 if let Some(target) = map.get(&key).and_then(export_condition_target) {
4347 return Some(target);
4348 }
4349
4350 if let Some(target) = wildcard_export_target(map, &key) {
4351 return Some(target);
4352 }
4353
4354 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
4355 return export_condition_target(exports);
4356 }
4357
4358 None
4359 }
4360 _ => None,
4361 }
4362}
4363
4364fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
4365 for (pattern, target) in map {
4366 let Some(star_index) = pattern.find('*') else {
4367 continue;
4368 };
4369 let (prefix, suffix_with_star) = pattern.split_at(star_index);
4370 let suffix = &suffix_with_star[1..];
4371 if !key.starts_with(prefix) || !key.ends_with(suffix) {
4372 continue;
4373 }
4374 let matched = &key[prefix.len()..key.len() - suffix.len()];
4375 if let Some(target_pattern) = export_condition_target(target) {
4376 return Some(target_pattern.replace('*', matched));
4377 }
4378 }
4379 None
4380}
4381
4382fn export_condition_target(value: &Value) -> Option<String> {
4383 match value {
4384 Value::String(target) => Some(target.clone()),
4385 Value::Object(map) => ["source", "import", "module", "default", "types"]
4386 .into_iter()
4387 .find_map(|field| map.get(field).and_then(export_condition_target)),
4388 _ => None,
4389 }
4390}
4391
4392fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
4393 let target = target.strip_prefix("./").unwrap_or(target);
4394 if let Some(src_relative) = target.strip_prefix("dist/") {
4397 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
4398 return Some(path);
4399 }
4400 }
4401
4402 resolve_file_like_path(&package_root.join(target))
4403}
4404
4405fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
4406 match subpath {
4407 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
4408 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
4409 None => resolve_file_like_path(&package_root.join("src").join("index"))
4410 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
4411 }
4412}
4413
4414pub(crate) fn resolve_reexported_symbol_target<F, D>(
4415 file: &Path,
4416 symbol_name: &str,
4417 file_exports_symbol: &mut F,
4418 file_default_export_symbol: &mut D,
4419) -> Option<(PathBuf, String)>
4420where
4421 F: FnMut(&Path, &str) -> bool,
4422 D: FnMut(&Path) -> Option<String>,
4423{
4424 resolve_reexported_symbol(
4425 file,
4426 symbol_name,
4427 file_exports_symbol,
4428 file_default_export_symbol,
4429 )
4430 .map(|target| (target.file, target.symbol))
4431}
4432
4433fn resolve_reexported_symbol<F, D>(
4434 file: &Path,
4435 symbol_name: &str,
4436 file_exports_symbol: &mut F,
4437 file_default_export_symbol: &mut D,
4438) -> Option<ResolvedSymbol>
4439where
4440 F: FnMut(&Path, &str) -> bool,
4441 D: FnMut(&Path) -> Option<String>,
4442{
4443 let mut visited = HashSet::new();
4444 resolve_reexported_symbol_inner(
4445 file,
4446 symbol_name,
4447 file_exports_symbol,
4448 file_default_export_symbol,
4449 &mut visited,
4450 )
4451}
4452
4453fn resolve_reexported_symbol_inner<F, D>(
4454 file: &Path,
4455 symbol_name: &str,
4456 file_exports_symbol: &mut F,
4457 file_default_export_symbol: &mut D,
4458 visited: &mut HashSet<(PathBuf, String)>,
4459) -> Option<ResolvedSymbol>
4460where
4461 F: FnMut(&Path, &str) -> bool,
4462 D: FnMut(&Path) -> Option<String>,
4463{
4464 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
4465 if !visited.insert((canon.clone(), symbol_name.to_string())) {
4466 return None;
4467 }
4468
4469 let source = std::fs::read_to_string(&canon).ok()?;
4470 let lang = detect_language(&canon)?;
4471 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
4472 if symbol_name == "default" {
4473 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
4474 file: canon,
4475 symbol,
4476 });
4477 }
4478 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
4479 file: canon,
4480 symbol: symbol_name.to_string(),
4481 });
4482 }
4483
4484 let grammar = grammar_for(lang);
4485 let mut parser = Parser::new();
4486 parser.set_language(&grammar).ok()?;
4487 let tree = parser.parse(&source, None)?;
4488 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
4489
4490 let mut cursor = tree.root_node().walk();
4491 if !cursor.goto_first_child() {
4492 return None;
4493 }
4494
4495 loop {
4496 let node = cursor.node();
4497 if node.kind() == "export_statement" {
4498 if let Some(target) = resolve_reexport_statement(
4499 &source,
4500 node,
4501 from_dir,
4502 symbol_name,
4503 file_exports_symbol,
4504 file_default_export_symbol,
4505 visited,
4506 ) {
4507 return Some(target);
4508 }
4509 }
4510
4511 if !cursor.goto_next_sibling() {
4512 break;
4513 }
4514 }
4515
4516 if symbol_name == "default" {
4517 if let Some(symbol) = file_default_export_symbol(&canon) {
4518 return Some(ResolvedSymbol {
4519 file: canon,
4520 symbol,
4521 });
4522 }
4523 }
4524
4525 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
4526 return Some(ResolvedSymbol {
4527 file: canon,
4528 symbol,
4529 });
4530 }
4531
4532 if file_exports_symbol(&canon, symbol_name) {
4533 let symbol = symbol_name.to_string();
4534 return Some(ResolvedSymbol {
4535 file: canon,
4536 symbol,
4537 });
4538 }
4539
4540 None
4541}
4542
4543fn resolve_reexport_statement<F, D>(
4544 source: &str,
4545 node: tree_sitter::Node,
4546 from_dir: &Path,
4547 symbol_name: &str,
4548 file_exports_symbol: &mut F,
4549 file_default_export_symbol: &mut D,
4550 visited: &mut HashSet<(PathBuf, String)>,
4551) -> Option<ResolvedSymbol>
4552where
4553 F: FnMut(&Path, &str) -> bool,
4554 D: FnMut(&Path) -> Option<String>,
4555{
4556 let source_node = node
4557 .child_by_field_name("source")
4558 .or_else(|| find_child_by_kind(node, "string"))?;
4559 let module_path = string_literal_content(source, source_node)?;
4560 let target_file = resolve_module_path(from_dir, &module_path)?;
4561 let raw_export = node_text(node, source);
4562
4563 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
4564 return resolve_reexported_symbol_inner(
4565 &target_file,
4566 &source_symbol,
4567 file_exports_symbol,
4568 file_default_export_symbol,
4569 visited,
4570 )
4571 .or(Some(ResolvedSymbol {
4572 file: target_file,
4573 symbol: source_symbol,
4574 }));
4575 }
4576
4577 if raw_export.contains('*') {
4578 return resolve_reexported_symbol_inner(
4579 &target_file,
4580 symbol_name,
4581 file_exports_symbol,
4582 file_default_export_symbol,
4583 visited,
4584 );
4585 }
4586
4587 None
4588}
4589
4590fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
4591 let lang = detect_language(file)?;
4592 let grammar = grammar_for(lang);
4593 let mut parser = Parser::new();
4594 parser.set_language(&grammar).ok()?;
4595 let tree = parser.parse(source, None)?;
4596
4597 let mut cursor = tree.root_node().walk();
4598 if !cursor.goto_first_child() {
4599 return None;
4600 }
4601
4602 loop {
4603 let node = cursor.node();
4604 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
4605 let raw_export = node_text(node, source);
4606 if let Some(source_symbol) =
4607 reexport_clause_source_symbol(&raw_export, requested_export)
4608 {
4609 return Some(source_symbol);
4610 }
4611 }
4612
4613 if !cursor.goto_next_sibling() {
4614 break;
4615 }
4616 }
4617
4618 None
4619}
4620
4621fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
4622 let start = raw_export.find('{')? + 1;
4623 let end = raw_export[start..].find('}')? + start;
4624 for specifier in raw_export[start..end].split(',') {
4625 let specifier = specifier.trim();
4626 if specifier.is_empty() {
4627 continue;
4628 }
4629 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
4630 if let Some((imported, exported)) = specifier.split_once(" as ") {
4631 if exported.trim() == requested_export {
4632 return Some(imported.trim().to_string());
4633 }
4634 } else if specifier == requested_export {
4635 return Some(requested_export.to_string());
4636 }
4637 }
4638 None
4639}
4640
4641fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
4642 let raw = source[node.byte_range()].trim();
4643 let quote = raw.chars().next()?;
4644 if quote != '\'' && quote != '"' {
4645 return None;
4646 }
4647 raw.strip_prefix(quote)
4648 .and_then(|value| value.strip_suffix(quote))
4649 .map(ToOwned::to_owned)
4650}
4651
4652fn find_index_file(dir: &Path) -> Option<PathBuf> {
4654 for name in JS_TS_INDEX_FILES {
4655 let p = dir.join(name);
4656 if p.is_file() {
4657 return Some(std::fs::canonicalize(&p).unwrap_or(p));
4658 }
4659 }
4660 None
4661}
4662
4663fn resolve_aliased_import(
4666 local_name: &str,
4667 import_block: &ImportBlock,
4668 caller_dir: &Path,
4669) -> Option<(String, PathBuf)> {
4670 for imp in &import_block.imports {
4671 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
4674 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
4675 return Some((original, resolved_path));
4676 }
4677 }
4678 }
4679 None
4680}
4681
4682fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
4686 let search = format!(" as {}", local_name);
4689 if let Some(pos) = raw_import.find(&search) {
4690 let before = &raw_import[..pos];
4692 let original = before
4694 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
4695 .find(|s| !s.is_empty())?;
4696 return Some(original.to_string());
4697 }
4698 None
4699}
4700
4701pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
4709 use ignore::WalkBuilder;
4710
4711 let walker = WalkBuilder::new(root)
4712 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .add_custom_ignore_filename(".aftignore") .filter_entry(|entry| {
4718 let name = entry.file_name().to_string_lossy();
4719 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4721 return !matches!(
4722 name.as_ref(),
4723 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
4724 | ".tox" | "dist" | "build"
4725 );
4726 }
4727 true
4728 })
4729 .build();
4730
4731 walker
4732 .filter_map(|entry| entry.ok())
4733 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
4734 .filter(|entry| detect_language(entry.path()).is_some())
4735 .map(|entry| entry.into_path())
4736}
4737
4738#[cfg(test)]
4743mod tests {
4744 use super::*;
4745 use std::fs;
4746 use tempfile::TempDir;
4747
4748 #[test]
4749 fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
4750 let mut symbol_metadata = HashMap::new();
4755 symbol_metadata.insert(
4756 "BackupStore::total_disk_bytes".to_string(),
4757 SymbolMeta {
4758 kind: SymbolKind::Method,
4759 exported: true,
4760 signature: None,
4761 line: 703,
4762 range: Range {
4763 start_line: 702,
4764 start_col: 0,
4765 end_line: 705,
4766 end_col: 0,
4767 },
4768 },
4769 );
4770 let file_data = FileCallData {
4771 calls_by_symbol: HashMap::new(),
4772 exported_symbols: vec!["total_disk_bytes".to_string()],
4773 symbol_metadata,
4774 default_export_symbol: None,
4775 import_block: ImportBlock::empty(),
4776 lang: LangId::Rust,
4777 };
4778
4779 let meta = file_data
4780 .symbol_metadata_for("total_disk_bytes")
4781 .expect("scoped method recovered by bare name");
4782 assert_eq!(meta.kind, SymbolKind::Method);
4783 assert_eq!(
4784 meta.line, 703,
4785 "real declaration line, not the line-1 fallback"
4786 );
4787
4788 assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
4790 }
4791
4792 fn setup_ts_project() -> TempDir {
4794 let dir = TempDir::new().unwrap();
4795
4796 fs::write(
4798 dir.path().join("main.ts"),
4799 r#"import { helper, compute } from './utils';
4800import * as math from './math';
4801
4802export function main() {
4803 const a = helper(1);
4804 const b = compute(a, 2);
4805 const c = math.add(a, b);
4806 return c;
4807}
4808"#,
4809 )
4810 .unwrap();
4811
4812 fs::write(
4814 dir.path().join("utils.ts"),
4815 r#"import { double } from './helpers';
4816
4817export function helper(x: number): number {
4818 return double(x);
4819}
4820
4821export function compute(a: number, b: number): number {
4822 return a + b;
4823}
4824"#,
4825 )
4826 .unwrap();
4827
4828 fs::write(
4830 dir.path().join("helpers.ts"),
4831 r#"export function double(x: number): number {
4832 return x * 2;
4833}
4834
4835export function triple(x: number): number {
4836 return x * 3;
4837}
4838"#,
4839 )
4840 .unwrap();
4841
4842 fs::write(
4844 dir.path().join("math.ts"),
4845 r#"export function add(a: number, b: number): number {
4846 return a + b;
4847}
4848
4849export function subtract(a: number, b: number): number {
4850 return a - b;
4851}
4852"#,
4853 )
4854 .unwrap();
4855
4856 dir
4857 }
4858
4859 fn setup_alias_project() -> TempDir {
4861 let dir = TempDir::new().unwrap();
4862
4863 fs::write(
4864 dir.path().join("main.ts"),
4865 r#"import { helper as h } from './utils';
4866
4867export function main() {
4868 return h(42);
4869}
4870"#,
4871 )
4872 .unwrap();
4873
4874 fs::write(
4875 dir.path().join("utils.ts"),
4876 r#"export function helper(x: number): number {
4877 return x + 1;
4878}
4879"#,
4880 )
4881 .unwrap();
4882
4883 dir
4884 }
4885
4886 fn setup_cycle_project() -> TempDir {
4888 let dir = TempDir::new().unwrap();
4889
4890 fs::write(
4891 dir.path().join("a.ts"),
4892 r#"import { funcB } from './b';
4893
4894export function funcA() {
4895 return funcB();
4896}
4897"#,
4898 )
4899 .unwrap();
4900
4901 fs::write(
4902 dir.path().join("b.ts"),
4903 r#"import { funcA } from './a';
4904
4905export function funcB() {
4906 return funcA();
4907}
4908"#,
4909 )
4910 .unwrap();
4911
4912 dir
4913 }
4914
4915 #[test]
4918 fn callgraph_single_file_call_extraction() {
4919 let dir = setup_ts_project();
4920 let mut graph = CallGraph::new(dir.path().to_path_buf());
4921
4922 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
4923 let main_calls = &file_data.calls_by_symbol["main"];
4924
4925 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
4926 assert!(
4927 callee_names.contains(&"helper"),
4928 "main should call helper, got: {:?}",
4929 callee_names
4930 );
4931 assert!(
4932 callee_names.contains(&"compute"),
4933 "main should call compute, got: {:?}",
4934 callee_names
4935 );
4936 assert!(
4937 callee_names.contains(&"add"),
4938 "main should call math.add (short name: add), got: {:?}",
4939 callee_names
4940 );
4941 }
4942
4943 #[test]
4944 fn callgraph_file_data_has_exports() {
4945 let dir = setup_ts_project();
4946 let mut graph = CallGraph::new(dir.path().to_path_buf());
4947
4948 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4949 assert!(
4950 file_data.exported_symbols.contains(&"helper".to_string()),
4951 "utils.ts should export helper, got: {:?}",
4952 file_data.exported_symbols
4953 );
4954 assert!(
4955 file_data.exported_symbols.contains(&"compute".to_string()),
4956 "utils.ts should export compute, got: {:?}",
4957 file_data.exported_symbols
4958 );
4959 }
4960
4961 #[test]
4964 fn callgraph_resolve_direct_import() {
4965 let dir = setup_ts_project();
4966 let mut graph = CallGraph::new(dir.path().to_path_buf());
4967
4968 let main_path = dir.path().join("main.ts");
4969 let file_data = graph.build_file(&main_path).unwrap();
4970 let import_block = file_data.import_block.clone();
4971
4972 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4973 match edge {
4974 EdgeResolution::Resolved { file, symbol } => {
4975 assert!(
4976 file.ends_with("utils.ts"),
4977 "helper should resolve to utils.ts, got: {:?}",
4978 file
4979 );
4980 assert_eq!(symbol, "helper");
4981 }
4982 EdgeResolution::Unresolved { callee_name } => {
4983 panic!("Expected resolved, got unresolved: {}", callee_name);
4984 }
4985 }
4986 }
4987
4988 #[test]
4989 fn callgraph_resolve_namespace_import() {
4990 let dir = setup_ts_project();
4991 let mut graph = CallGraph::new(dir.path().to_path_buf());
4992
4993 let main_path = dir.path().join("main.ts");
4994 let file_data = graph.build_file(&main_path).unwrap();
4995 let import_block = file_data.import_block.clone();
4996
4997 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4998 match edge {
4999 EdgeResolution::Resolved { file, symbol } => {
5000 assert!(
5001 file.ends_with("math.ts"),
5002 "math.add should resolve to math.ts, got: {:?}",
5003 file
5004 );
5005 assert_eq!(symbol, "add");
5006 }
5007 EdgeResolution::Unresolved { callee_name } => {
5008 panic!("Expected resolved, got unresolved: {}", callee_name);
5009 }
5010 }
5011 }
5012
5013 #[test]
5014 fn callgraph_resolve_aliased_import() {
5015 let dir = setup_alias_project();
5016 let mut graph = CallGraph::new(dir.path().to_path_buf());
5017
5018 let main_path = dir.path().join("main.ts");
5019 let file_data = graph.build_file(&main_path).unwrap();
5020 let import_block = file_data.import_block.clone();
5021
5022 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
5023 match edge {
5024 EdgeResolution::Resolved { file, symbol } => {
5025 assert!(
5026 file.ends_with("utils.ts"),
5027 "h (alias for helper) should resolve to utils.ts, got: {:?}",
5028 file
5029 );
5030 assert_eq!(symbol, "helper");
5031 }
5032 EdgeResolution::Unresolved { callee_name } => {
5033 panic!("Expected resolved, got unresolved: {}", callee_name);
5034 }
5035 }
5036 }
5037
5038 #[test]
5039 fn callgraph_unresolved_edge_marked() {
5040 let dir = setup_ts_project();
5041 let mut graph = CallGraph::new(dir.path().to_path_buf());
5042
5043 let main_path = dir.path().join("main.ts");
5044 let file_data = graph.build_file(&main_path).unwrap();
5045 let import_block = file_data.import_block.clone();
5046
5047 let edge =
5048 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
5049 assert_eq!(
5050 edge,
5051 EdgeResolution::Unresolved {
5052 callee_name: "unknownFunc".to_string()
5053 },
5054 "Unknown callee should be unresolved"
5055 );
5056 }
5057
5058 #[test]
5061 fn callgraph_cycle_detection_stops() {
5062 let dir = setup_cycle_project();
5063 let mut graph = CallGraph::new(dir.path().to_path_buf());
5064
5065 let tree = graph
5067 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
5068 .unwrap();
5069
5070 assert_eq!(tree.name, "funcA");
5071 assert!(tree.resolved);
5072
5073 fn count_depth(node: &CallTreeNode) -> usize {
5076 if node.children.is_empty() {
5077 1
5078 } else {
5079 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
5080 }
5081 }
5082
5083 let depth = count_depth(&tree);
5084 assert!(
5085 depth <= 4,
5086 "Cycle should be detected and bounded, depth was: {}",
5087 depth
5088 );
5089 }
5090
5091 #[test]
5094 fn callgraph_depth_limit_truncates() {
5095 let dir = setup_ts_project();
5096 let mut graph = CallGraph::new(dir.path().to_path_buf());
5097
5098 let tree = graph
5101 .forward_tree(&dir.path().join("main.ts"), "main", 1)
5102 .unwrap();
5103
5104 assert_eq!(tree.name, "main");
5105 assert!(tree.depth_limited, "depth limit should be reported");
5106 assert!(
5107 tree.truncated > 0,
5108 "truncated edge count should be reported"
5109 );
5110
5111 for child in &tree.children {
5113 assert!(
5114 child.children.is_empty(),
5115 "At depth 1, child '{}' should have no children, got {:?}",
5116 child.name,
5117 child.children.len()
5118 );
5119 }
5120 }
5121
5122 #[test]
5123 fn callgraph_depth_zero_no_children() {
5124 let dir = setup_ts_project();
5125 let mut graph = CallGraph::new(dir.path().to_path_buf());
5126
5127 let tree = graph
5128 .forward_tree(&dir.path().join("main.ts"), "main", 0)
5129 .unwrap();
5130
5131 assert_eq!(tree.name, "main");
5132 assert!(
5133 tree.children.is_empty(),
5134 "At depth 0, should have no children"
5135 );
5136 }
5137
5138 #[test]
5141 fn callgraph_forward_tree_cross_file() {
5142 let dir = setup_ts_project();
5143 let mut graph = CallGraph::new(dir.path().to_path_buf());
5144
5145 let tree = graph
5147 .forward_tree(&dir.path().join("main.ts"), "main", 5)
5148 .unwrap();
5149
5150 assert_eq!(tree.name, "main");
5151 assert!(tree.resolved);
5152
5153 let helper_child = tree.children.iter().find(|c| c.name == "helper");
5155 assert!(
5156 helper_child.is_some(),
5157 "main should have helper as child, children: {:?}",
5158 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5159 );
5160
5161 let helper = helper_child.unwrap();
5162 assert!(
5163 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
5164 "helper should be in utils.ts, got: {}",
5165 helper.file
5166 );
5167
5168 let double_child = helper.children.iter().find(|c| c.name == "double");
5170 assert!(
5171 double_child.is_some(),
5172 "helper should call double, children: {:?}",
5173 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5174 );
5175
5176 let double = double_child.unwrap();
5177 assert!(
5178 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
5179 "double should be in helpers.ts, got: {}",
5180 double.file
5181 );
5182 }
5183
5184 #[test]
5187 fn callgraph_walker_excludes_gitignored() {
5188 let dir = TempDir::new().unwrap();
5189
5190 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
5192
5193 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5195 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
5196 fs::write(
5197 dir.path().join("ignored_dir").join("secret.ts"),
5198 "export function secret() {}",
5199 )
5200 .unwrap();
5201
5202 fs::create_dir(dir.path().join("node_modules")).unwrap();
5204 fs::write(
5205 dir.path().join("node_modules").join("dep.ts"),
5206 "export function dep() {}",
5207 )
5208 .unwrap();
5209
5210 std::process::Command::new("git")
5212 .args(["init"])
5213 .current_dir(dir.path())
5214 .output()
5215 .unwrap();
5216
5217 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5218 let file_names: Vec<String> = files
5219 .iter()
5220 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5221 .collect();
5222
5223 assert!(
5224 file_names.contains(&"main.ts".to_string()),
5225 "Should include main.ts, got: {:?}",
5226 file_names
5227 );
5228 assert!(
5229 !file_names.contains(&"secret.ts".to_string()),
5230 "Should exclude gitignored secret.ts, got: {:?}",
5231 file_names
5232 );
5233 assert!(
5234 !file_names.contains(&"dep.ts".to_string()),
5235 "Should exclude node_modules, got: {:?}",
5236 file_names
5237 );
5238 }
5239
5240 #[test]
5241 fn callgraph_walker_excludes_aftignored() {
5242 let dir = TempDir::new().unwrap();
5243
5244 fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
5246 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5247 fs::create_dir(dir.path().join("vendored")).unwrap();
5248 fs::write(
5249 dir.path().join("vendored").join("sub.ts"),
5250 "export function sub() {}",
5251 )
5252 .unwrap();
5253
5254 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5255 let file_names: Vec<String> = files
5256 .iter()
5257 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5258 .collect();
5259
5260 assert!(
5261 file_names.contains(&"main.ts".to_string()),
5262 "Should include main.ts, got: {:?}",
5263 file_names
5264 );
5265 assert!(
5266 !file_names.contains(&"sub.ts".to_string()),
5267 "Should exclude .aftignored sub.ts, got: {:?}",
5268 file_names
5269 );
5270 }
5271
5272 #[test]
5273 fn callgraph_walker_only_source_files() {
5274 let dir = TempDir::new().unwrap();
5275
5276 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5277 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
5278 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
5279 fs::write(
5280 dir.path().join("runtime.mjs"),
5281 "export function runtime() {}",
5282 )
5283 .unwrap();
5284 fs::write(
5285 dir.path().join("legacy.cjs"),
5286 "exports.legacy = function() {};",
5287 )
5288 .unwrap();
5289 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
5290 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
5291 fs::write(dir.path().join("data.json"), "{}").unwrap();
5292
5293 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5294 let file_names: Vec<String> = files
5295 .iter()
5296 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5297 .collect();
5298
5299 assert!(file_names.contains(&"main.ts".to_string()));
5300 for modern_ext_file in [
5301 "module.mts",
5302 "common.cts",
5303 "runtime.mjs",
5304 "legacy.cjs",
5305 "types.pyi",
5306 ] {
5307 assert!(
5308 file_names.contains(&modern_ext_file.to_string()),
5309 "walker should include {modern_ext_file}, got: {:?}",
5310 file_names
5311 );
5312 }
5313 assert!(
5314 file_names.contains(&"readme.md".to_string()),
5315 "Markdown is now a supported source language"
5316 );
5317 assert!(
5318 file_names.contains(&"data.json".to_string()),
5319 "JSON is now a supported source language"
5320 );
5321 }
5322
5323 #[test]
5326 fn callgraph_find_alias_original_simple() {
5327 let raw = "import { foo as bar } from './utils';";
5328 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5329 }
5330
5331 #[test]
5332 fn callgraph_find_alias_original_multiple() {
5333 let raw = "import { foo as bar, baz as qux } from './utils';";
5334 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5335 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
5336 }
5337
5338 #[test]
5339 fn callgraph_find_alias_no_match() {
5340 let raw = "import { foo } from './utils';";
5341 assert_eq!(find_alias_original(raw, "foo"), None);
5342 }
5343
5344 #[test]
5347 fn callgraph_callers_of_direct() {
5348 let dir = setup_ts_project();
5349 let mut graph = CallGraph::new(dir.path().to_path_buf());
5350
5351 let result = graph
5353 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5354 .unwrap();
5355
5356 assert_eq!(result.symbol, "double");
5357 assert!(result.total_callers > 0, "double should have callers");
5358 assert!(result.scanned_files > 0, "should have scanned files");
5359
5360 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
5362 assert!(
5363 utils_group.is_some(),
5364 "double should be called from utils.ts, groups: {:?}",
5365 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5366 );
5367
5368 let group = utils_group.unwrap();
5369 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
5370 assert!(
5371 helper_caller.is_some(),
5372 "double should be called by helper, callers: {:?}",
5373 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
5374 );
5375 }
5376
5377 #[test]
5378 fn callgraph_callers_of_no_callers() {
5379 let dir = setup_ts_project();
5380 let mut graph = CallGraph::new(dir.path().to_path_buf());
5381
5382 let result = graph
5384 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
5385 .unwrap();
5386
5387 assert_eq!(result.symbol, "main");
5388 assert_eq!(result.total_callers, 0, "main should have no callers");
5389 assert!(result.callers.is_empty());
5390 }
5391
5392 #[test]
5393 fn callgraph_callers_recursive_depth() {
5394 let dir = setup_ts_project();
5395 let mut graph = CallGraph::new(dir.path().to_path_buf());
5396
5397 let result = graph
5401 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
5402 .unwrap();
5403
5404 assert!(
5405 result.total_callers >= 2,
5406 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
5407 result.total_callers
5408 );
5409
5410 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
5412 assert!(
5413 main_group.is_some(),
5414 "recursive callers should include main.ts, groups: {:?}",
5415 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5416 );
5417 }
5418
5419 #[test]
5420 fn callgraph_invalidate_file_clears_reverse_index() {
5421 let dir = setup_ts_project();
5422 let mut graph = CallGraph::new(dir.path().to_path_buf());
5423
5424 let _ = graph
5426 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5427 .unwrap();
5428 assert!(
5429 graph.reverse_index.is_some(),
5430 "reverse index should be built"
5431 );
5432
5433 graph.invalidate_file(&dir.path().join("utils.ts"));
5435
5436 assert!(
5438 graph.reverse_index.is_none(),
5439 "invalidate_file should clear reverse index"
5440 );
5441 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
5443 assert!(
5444 !graph.data.contains_key(&canon),
5445 "invalidate_file should remove file from data cache"
5446 );
5447 assert!(
5449 graph.project_files.is_none(),
5450 "invalidate_file should clear project_files"
5451 );
5452 }
5453
5454 #[test]
5457 fn is_entry_point_exported_function() {
5458 assert!(is_entry_point(
5459 "handleRequest",
5460 &SymbolKind::Function,
5461 true,
5462 LangId::TypeScript
5463 ));
5464 }
5465
5466 #[test]
5467 fn is_entry_point_exported_method_is_not_entry() {
5468 assert!(!is_entry_point(
5470 "handleRequest",
5471 &SymbolKind::Method,
5472 true,
5473 LangId::TypeScript
5474 ));
5475 }
5476
5477 #[test]
5478 fn is_entry_point_main_init_patterns() {
5479 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
5480 assert!(
5481 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
5482 "{} should be an entry point",
5483 name
5484 );
5485 }
5486 }
5487
5488 #[test]
5489 fn is_entry_point_test_patterns_ts() {
5490 assert!(is_entry_point(
5491 "describe",
5492 &SymbolKind::Function,
5493 false,
5494 LangId::TypeScript
5495 ));
5496 assert!(is_entry_point(
5497 "it",
5498 &SymbolKind::Function,
5499 false,
5500 LangId::TypeScript
5501 ));
5502 assert!(is_entry_point(
5503 "test",
5504 &SymbolKind::Function,
5505 false,
5506 LangId::TypeScript
5507 ));
5508 assert!(is_entry_point(
5509 "testValidation",
5510 &SymbolKind::Function,
5511 false,
5512 LangId::TypeScript
5513 ));
5514 assert!(is_entry_point(
5515 "specHelper",
5516 &SymbolKind::Function,
5517 false,
5518 LangId::TypeScript
5519 ));
5520 }
5521
5522 #[test]
5523 fn is_entry_point_test_patterns_python() {
5524 assert!(is_entry_point(
5525 "test_login",
5526 &SymbolKind::Function,
5527 false,
5528 LangId::Python
5529 ));
5530 assert!(is_entry_point(
5531 "setUp",
5532 &SymbolKind::Function,
5533 false,
5534 LangId::Python
5535 ));
5536 assert!(is_entry_point(
5537 "tearDown",
5538 &SymbolKind::Function,
5539 false,
5540 LangId::Python
5541 ));
5542 assert!(!is_entry_point(
5544 "testSomething",
5545 &SymbolKind::Function,
5546 false,
5547 LangId::Python
5548 ));
5549 }
5550
5551 #[test]
5552 fn is_entry_point_test_patterns_rust() {
5553 assert!(is_entry_point(
5554 "test_parse",
5555 &SymbolKind::Function,
5556 false,
5557 LangId::Rust
5558 ));
5559 assert!(!is_entry_point(
5560 "TestSomething",
5561 &SymbolKind::Function,
5562 false,
5563 LangId::Rust
5564 ));
5565 }
5566
5567 #[test]
5568 fn is_entry_point_test_patterns_go() {
5569 assert!(is_entry_point(
5570 "TestParsing",
5571 &SymbolKind::Function,
5572 false,
5573 LangId::Go
5574 ));
5575 assert!(!is_entry_point(
5577 "testParsing",
5578 &SymbolKind::Function,
5579 false,
5580 LangId::Go
5581 ));
5582 }
5583
5584 #[test]
5585 fn is_entry_point_non_exported_non_main_is_not_entry() {
5586 assert!(!is_entry_point(
5587 "helperUtil",
5588 &SymbolKind::Function,
5589 false,
5590 LangId::TypeScript
5591 ));
5592 }
5593
5594 #[test]
5597 fn callgraph_symbol_metadata_populated() {
5598 let dir = setup_ts_project();
5599 let mut graph = CallGraph::new(dir.path().to_path_buf());
5600
5601 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
5602 assert!(
5603 file_data.symbol_metadata.contains_key("helper"),
5604 "symbol_metadata should contain helper"
5605 );
5606 let meta = &file_data.symbol_metadata["helper"];
5607 assert_eq!(meta.kind, SymbolKind::Function);
5608 assert!(meta.exported, "helper should be exported");
5609 }
5610
5611 fn setup_trace_project() -> TempDir {
5627 let dir = TempDir::new().unwrap();
5628
5629 fs::write(
5630 dir.path().join("main.ts"),
5631 r#"import { processData } from './utils';
5632
5633export function main() {
5634 const result = processData("hello");
5635 return result;
5636}
5637"#,
5638 )
5639 .unwrap();
5640
5641 fs::write(
5642 dir.path().join("service.ts"),
5643 r#"import { processData } from './utils';
5644
5645export function handleRequest(input: string): string {
5646 return processData(input);
5647}
5648"#,
5649 )
5650 .unwrap();
5651
5652 fs::write(
5653 dir.path().join("utils.ts"),
5654 r#"import { validate } from './helpers';
5655
5656export function processData(input: string): string {
5657 const valid = validate(input);
5658 if (!valid) {
5659 throw new Error("invalid input");
5660 }
5661 return input.toUpperCase();
5662}
5663"#,
5664 )
5665 .unwrap();
5666
5667 fs::write(
5668 dir.path().join("helpers.ts"),
5669 r#"export function validate(input: string): boolean {
5670 return checkFormat(input);
5671}
5672
5673function checkFormat(input: string): boolean {
5674 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
5675}
5676"#,
5677 )
5678 .unwrap();
5679
5680 fs::write(
5681 dir.path().join("test_helpers.ts"),
5682 r#"import { validate } from './helpers';
5683
5684function testValidation() {
5685 const result = validate("hello");
5686 console.log(result);
5687}
5688"#,
5689 )
5690 .unwrap();
5691
5692 std::process::Command::new("git")
5694 .args(["init"])
5695 .current_dir(dir.path())
5696 .output()
5697 .unwrap();
5698
5699 dir
5700 }
5701
5702 #[test]
5703 fn trace_to_multi_path() {
5704 let dir = setup_trace_project();
5705 let mut graph = CallGraph::new(dir.path().to_path_buf());
5706
5707 let result = graph
5708 .trace_to(
5709 &dir.path().join("helpers.ts"),
5710 "checkFormat",
5711 10,
5712 usize::MAX,
5713 )
5714 .unwrap();
5715
5716 assert_eq!(result.target_symbol, "checkFormat");
5717 assert!(
5718 result.total_paths >= 2,
5719 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
5720 result.total_paths,
5721 result
5722 .paths
5723 .iter()
5724 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
5725 .collect::<Vec<_>>()
5726 );
5727
5728 for path in &result.paths {
5730 assert!(
5731 path.hops.first().unwrap().is_entry_point,
5732 "First hop should be an entry point, got: {}",
5733 path.hops.first().unwrap().symbol
5734 );
5735 assert_eq!(
5736 path.hops.last().unwrap().symbol,
5737 "checkFormat",
5738 "Last hop should be checkFormat"
5739 );
5740 }
5741
5742 assert!(
5744 result.entry_points_found >= 2,
5745 "should find at least 2 entry points, got {}",
5746 result.entry_points_found
5747 );
5748 }
5749
5750 #[test]
5751 fn trace_to_single_path() {
5752 let dir = setup_trace_project();
5753 let mut graph = CallGraph::new(dir.path().to_path_buf());
5754
5755 let result = graph
5759 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
5760 .unwrap();
5761
5762 assert_eq!(result.target_symbol, "validate");
5763 assert!(
5764 result.total_paths >= 2,
5765 "validate should have at least 2 paths, got {}",
5766 result.total_paths
5767 );
5768 }
5769
5770 #[test]
5771 fn trace_to_cycle_detection() {
5772 let dir = setup_cycle_project();
5773 let mut graph = CallGraph::new(dir.path().to_path_buf());
5774
5775 let result = graph
5777 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
5778 .unwrap();
5779
5780 assert_eq!(result.target_symbol, "funcA");
5782 }
5783
5784 #[test]
5785 fn trace_to_depth_limit() {
5786 let dir = setup_trace_project();
5787 let mut graph = CallGraph::new(dir.path().to_path_buf());
5788
5789 let result = graph
5791 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
5792 .unwrap();
5793
5794 assert_eq!(result.target_symbol, "checkFormat");
5798
5799 let deep_result = graph
5801 .trace_to(
5802 &dir.path().join("helpers.ts"),
5803 "checkFormat",
5804 10,
5805 usize::MAX,
5806 )
5807 .unwrap();
5808
5809 assert!(
5810 result.total_paths <= deep_result.total_paths,
5811 "shallow trace should find <= paths compared to deep: {} vs {}",
5812 result.total_paths,
5813 deep_result.total_paths
5814 );
5815 }
5816
5817 #[test]
5818 fn trace_to_entry_point_target() {
5819 let dir = setup_trace_project();
5820 let mut graph = CallGraph::new(dir.path().to_path_buf());
5821
5822 let result = graph
5824 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
5825 .unwrap();
5826
5827 assert_eq!(result.target_symbol, "main");
5828 assert!(
5829 result.total_paths >= 1,
5830 "main should have at least 1 path (itself), got {}",
5831 result.total_paths
5832 );
5833 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
5835 assert!(
5836 trivial.is_some(),
5837 "should have a trivial path with just the entry point itself"
5838 );
5839 }
5840
5841 #[test]
5842 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
5843 let dir = TempDir::new().unwrap();
5844 fs::write(
5845 dir.path().join("main.ts"),
5846 r#"import * as lib from './index';
5847
5848export function main() {
5849 lib.helper();
5850 lib.hidden();
5851}
5852"#,
5853 )
5854 .unwrap();
5855 fs::write(
5856 dir.path().join("index.ts"),
5857 "export { helper } from './utils';\n",
5858 )
5859 .unwrap();
5860 fs::write(
5861 dir.path().join("utils.ts"),
5862 r#"export function helper() {}
5863function hidden() {}
5864"#,
5865 )
5866 .unwrap();
5867
5868 let mut graph = CallGraph::new(dir.path().to_path_buf());
5869 let main_path = dir.path().join("main.ts");
5870 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
5871
5872 let helper =
5873 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
5874 match helper {
5875 EdgeResolution::Resolved { file, symbol } => {
5876 assert!(
5877 file.ends_with("utils.ts"),
5878 "helper should resolve through barrel: {file:?}"
5879 );
5880 assert_eq!(symbol, "helper");
5881 }
5882 other => panic!("expected helper to resolve through barrel, got {other:?}"),
5883 }
5884
5885 let hidden =
5886 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
5887 assert_eq!(
5888 hidden,
5889 EdgeResolution::Unresolved {
5890 callee_name: "hidden".to_string()
5891 }
5892 );
5893 }
5894
5895 #[test]
5896 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
5897 let dir = TempDir::new().unwrap();
5898 fs::write(
5899 dir.path().join("package.json"),
5900 r#"{"workspaces":["packages/*"]}"#,
5901 )
5902 .unwrap();
5903 let package_dir = dir.path().join("packages/lib");
5904 fs::create_dir_all(package_dir.join("src")).unwrap();
5905 fs::create_dir_all(package_dir.join("dist")).unwrap();
5906 fs::write(
5907 package_dir.join("package.json"),
5908 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
5909 )
5910 .unwrap();
5911 fs::write(
5912 package_dir.join("src/index.mts"),
5913 "export function helper() {}\n",
5914 )
5915 .unwrap();
5916 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
5917
5918 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
5919 assert!(
5920 resolved.ends_with("src/index.mts"),
5921 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
5922 );
5923 }
5924
5925 #[test]
5926 fn unresolved_member_calls_do_not_become_same_file_callers() {
5927 let dir = TempDir::new().unwrap();
5928 fs::write(
5929 dir.path().join("main.ts"),
5930 r#"function caller() {
5931 db.connect();
5932}
5933
5934function connect() {}
5935"#,
5936 )
5937 .unwrap();
5938
5939 let mut graph = CallGraph::new(dir.path().to_path_buf());
5940 let result = graph
5941 .callers_of(&dir.path().join("main.ts"), "connect", 1, usize::MAX)
5942 .unwrap();
5943
5944 assert_eq!(
5945 result.total_callers, 0,
5946 "db.connect() must not call local connect"
5947 );
5948 }
5949
5950 #[test]
5951 fn same_named_methods_use_scoped_symbol_identity() {
5952 let dir = TempDir::new().unwrap();
5953 fs::write(
5954 dir.path().join("classes.ts"),
5955 r#"class A {
5956 run() { helperA(); }
5957}
5958
5959class B {
5960 run() { helperB(); }
5961}
5962
5963function helperA() {}
5964function helperB() {}
5965"#,
5966 )
5967 .unwrap();
5968
5969 let mut graph = CallGraph::new(dir.path().to_path_buf());
5970 let path = dir.path().join("classes.ts");
5971 let data = graph.build_file(&path).unwrap();
5972
5973 assert!(
5974 data.symbol_metadata.contains_key("A::run"),
5975 "A::run metadata missing"
5976 );
5977 assert!(
5978 data.symbol_metadata.contains_key("B::run"),
5979 "B::run metadata missing"
5980 );
5981 assert!(
5982 data.calls_by_symbol["A::run"]
5983 .iter()
5984 .any(|call| call.callee_name == "helperA"),
5985 "A::run calls should not be overwritten"
5986 );
5987 assert!(
5988 data.calls_by_symbol["B::run"]
5989 .iter()
5990 .any(|call| call.callee_name == "helperB"),
5991 "B::run calls should not be overwritten"
5992 );
5993
5994 assert!(matches!(
5995 graph.resolve_symbol_query(&path, "run"),
5996 Err(AftError::AmbiguousSymbol { .. })
5997 ));
5998 assert_eq!(
5999 graph.resolve_symbol_query(&path, "A::run").unwrap(),
6000 "A::run"
6001 );
6002 }
6003
6004 #[test]
6005 fn trace_to_counts_same_named_entry_points_by_file_and_symbol() {
6006 let dir = TempDir::new().unwrap();
6007 fs::create_dir_all(dir.path().join("web")).unwrap();
6008 fs::create_dir_all(dir.path().join("cli")).unwrap();
6009 fs::write(
6010 dir.path().join("target.ts"),
6011 r#"export function target() {
6012 leaf();
6013}
6014
6015function leaf() {}
6016"#,
6017 )
6018 .unwrap();
6019 fs::write(
6020 dir.path().join("web/main.ts"),
6021 r#"import { target } from '../target';
6022
6023export function main() {
6024 target();
6025}
6026"#,
6027 )
6028 .unwrap();
6029 fs::write(
6030 dir.path().join("cli/main.ts"),
6031 r#"import { target } from '../target';
6032
6033export function main() {
6034 target();
6035}
6036"#,
6037 )
6038 .unwrap();
6039
6040 let mut graph = CallGraph::new(dir.path().to_path_buf());
6041 let result = graph
6042 .trace_to(&dir.path().join("target.ts"), "leaf", 10, usize::MAX)
6043 .unwrap();
6044
6045 assert_eq!(
6046 result.total_paths, 3,
6047 "target plus two main entry paths expected"
6048 );
6049 assert_eq!(
6050 result.entry_points_found, 3,
6051 "same-named main entry points in different files must both count"
6052 );
6053 }
6054
6055 #[test]
6056 fn callers_and_impact_report_depth_truncation() {
6057 let dir = setup_ts_project();
6058 let mut graph = CallGraph::new(dir.path().to_path_buf());
6059
6060 let callers = graph
6061 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
6062 .unwrap();
6063 assert!(
6064 callers.depth_limited,
6065 "callers should report omitted transitive callers"
6066 );
6067 assert!(
6068 callers.truncated > 0,
6069 "callers should report truncated edge count"
6070 );
6071
6072 let impact = graph
6073 .impact(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
6074 .unwrap();
6075 assert!(
6076 impact.depth_limited,
6077 "impact should report omitted transitive callers"
6078 );
6079 assert!(
6080 impact.truncated > 0,
6081 "impact should report truncated edge count"
6082 );
6083 }
6084
6085 #[test]
6088 fn extract_parameters_typescript() {
6089 let params = extract_parameters(
6090 "function processData(input: string, count: number): void",
6091 LangId::TypeScript,
6092 );
6093 assert_eq!(params, vec!["input", "count"]);
6094 }
6095
6096 #[test]
6097 fn extract_parameters_typescript_optional() {
6098 let params = extract_parameters(
6099 "function fetch(url: string, options?: RequestInit): Promise<Response>",
6100 LangId::TypeScript,
6101 );
6102 assert_eq!(params, vec!["url", "options"]);
6103 }
6104
6105 #[test]
6106 fn extract_parameters_typescript_defaults() {
6107 let params = extract_parameters(
6108 "function greet(name: string, greeting: string = \"hello\"): string",
6109 LangId::TypeScript,
6110 );
6111 assert_eq!(params, vec!["name", "greeting"]);
6112 }
6113
6114 #[test]
6115 fn extract_parameters_typescript_rest() {
6116 let params = extract_parameters(
6117 "function sum(...numbers: number[]): number",
6118 LangId::TypeScript,
6119 );
6120 assert_eq!(params, vec!["numbers"]);
6121 }
6122
6123 #[test]
6124 fn extract_parameters_python_self_skipped() {
6125 let params = extract_parameters(
6126 "def process(self, data: str, count: int) -> bool",
6127 LangId::Python,
6128 );
6129 assert_eq!(params, vec!["data", "count"]);
6130 }
6131
6132 #[test]
6133 fn extract_parameters_python_no_self() {
6134 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
6135 assert_eq!(params, vec!["input"]);
6136 }
6137
6138 #[test]
6139 fn extract_parameters_python_star_args() {
6140 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
6141 assert_eq!(params, vec!["args", "kwargs"]);
6142 }
6143
6144 #[test]
6145 fn extract_parameters_rust_self_skipped() {
6146 let params = extract_parameters(
6147 "fn process(&self, data: &str, count: usize) -> bool",
6148 LangId::Rust,
6149 );
6150 assert_eq!(params, vec!["data", "count"]);
6151 }
6152
6153 #[test]
6154 fn extract_parameters_rust_mut_self_skipped() {
6155 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
6156 assert_eq!(params, vec!["value"]);
6157 }
6158
6159 #[test]
6160 fn extract_parameters_rust_no_self() {
6161 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
6162 assert_eq!(params, vec!["input"]);
6163 }
6164
6165 #[test]
6166 fn extract_parameters_rust_mut_param() {
6167 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
6168 assert_eq!(params, vec!["buf", "len"]);
6169 }
6170
6171 #[test]
6172 fn extract_parameters_go() {
6173 let params = extract_parameters(
6174 "func ProcessData(input string, count int) error",
6175 LangId::Go,
6176 );
6177 assert_eq!(params, vec!["input", "count"]);
6178 }
6179
6180 #[test]
6181 fn extract_parameters_empty() {
6182 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
6183 assert!(
6184 params.is_empty(),
6185 "no-arg function should return empty params"
6186 );
6187 }
6188
6189 #[test]
6190 fn extract_parameters_no_parens() {
6191 let params = extract_parameters("const x = 42", LangId::TypeScript);
6192 assert!(params.is_empty(), "no parens should return empty params");
6193 }
6194
6195 #[test]
6196 fn extract_parameters_javascript() {
6197 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
6198 assert_eq!(params, vec!["event", "target"]);
6199 }
6200}