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