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