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