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