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