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)
2728 .unwrap_or(path)
2729 .display()
2730 .to_string()
2731 }
2732
2733 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2735 let full_path = if path.is_relative() {
2737 self.project_root.join(path)
2738 } else {
2739 path.to_path_buf()
2740 };
2741
2742 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2744 }
2745
2746 fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2750 if let Some(fd) = self.data.get(path) {
2751 return Some(fd);
2752 }
2753 let canon = std::fs::canonicalize(path).ok()?;
2755 self.data.get(&canon).or_else(|| {
2756 self.data.iter().find_map(|(k, v)| {
2758 if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2759 Some(v)
2760 } else {
2761 None
2762 }
2763 })
2764 })
2765 }
2766}
2767
2768fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2774 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2775 message: format!("unsupported file for call graph: {}", path.display()),
2776 })?;
2777
2778 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2779 path: format!("{}: {}", path.display(), e),
2780 })?;
2781
2782 let grammar = grammar_for(lang);
2783 let mut parser = Parser::new();
2784 parser
2785 .set_language(&grammar)
2786 .map_err(|e| AftError::ParseError {
2787 message: format!("grammar init failed for {:?}: {}", lang, e),
2788 })?;
2789
2790 let tree = parser
2791 .parse(&source, None)
2792 .ok_or_else(|| AftError::ParseError {
2793 message: format!("parse failed for {}", path.display()),
2794 })?;
2795
2796 let import_block = imports::parse_imports(&source, &tree, lang);
2798
2799 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2801
2802 let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2804 let root = tree.root_node();
2805
2806 for sym in &symbols {
2807 let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2808 let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2809
2810 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2811
2812 let sites: Vec<CallSite> = raw_calls
2813 .into_iter()
2814 .map(|(full, short, line)| CallSite {
2815 callee_name: short,
2816 full_callee: full,
2817 line,
2818 byte_start,
2819 byte_end,
2820 })
2821 .collect();
2822
2823 if !sites.is_empty() {
2824 calls_by_symbol.insert(symbol_identity(sym), sites);
2825 }
2826 }
2827
2828 let symbol_ranges: Vec<(usize, usize)> = symbols
2829 .iter()
2830 .map(|sym| {
2831 (
2832 line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2833 line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2834 )
2835 })
2836 .collect();
2837
2838 let top_level_sites: Vec<CallSite> =
2839 collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2840 .into_iter()
2841 .filter(|site| {
2842 !symbol_ranges
2843 .iter()
2844 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2845 })
2846 .map(|site| CallSite {
2847 callee_name: site.short,
2848 full_callee: site.full,
2849 line: site.line,
2850 byte_start: site.byte_start,
2851 byte_end: site.byte_end,
2852 })
2853 .collect();
2854
2855 if !top_level_sites.is_empty() {
2856 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2857 }
2858
2859 let default_export = find_default_export(&source, root, path, lang);
2860
2861 if let Some(default_export) = &default_export {
2862 if default_export.synthetic {
2863 let byte_start = default_export.node.byte_range().start;
2864 let byte_end = default_export.node.byte_range().end;
2865 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2866 let sites: Vec<CallSite> = raw_calls
2867 .into_iter()
2868 .filter(|(_, short, _)| *short != default_export.symbol)
2869 .map(|(full, short, line)| CallSite {
2870 callee_name: short,
2871 full_callee: full,
2872 line,
2873 byte_start,
2874 byte_end,
2875 })
2876 .collect();
2877 if !sites.is_empty() {
2878 calls_by_symbol.insert(default_export.symbol.clone(), sites);
2879 }
2880 }
2881 }
2882
2883 let mut exported_symbols: Vec<String> = symbols
2885 .iter()
2886 .filter(|s| s.exported)
2887 .map(|s| s.name.clone())
2888 .collect();
2889 if let Some(default_export) = &default_export {
2890 if !exported_symbols
2891 .iter()
2892 .any(|name| name == &default_export.symbol)
2893 {
2894 exported_symbols.push(default_export.symbol.clone());
2895 }
2896 }
2897
2898 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2900 .iter()
2901 .map(|s| {
2902 (
2903 symbol_identity(s),
2904 SymbolMeta {
2905 kind: s.kind.clone(),
2906 exported: s.exported,
2907 signature: s.signature.clone(),
2908 line: s.range.start_line + 1,
2909 range: s.range.clone(),
2910 },
2911 )
2912 })
2913 .collect();
2914 if let Some(default_export) = &default_export {
2915 symbol_metadata
2916 .entry(default_export.symbol.clone())
2917 .or_insert_with(|| SymbolMeta {
2918 kind: default_export.kind.clone(),
2919 exported: true,
2920 signature: Some(first_line_signature(&source, &default_export.node)),
2921 line: default_export.node.start_position().row as u32 + 1,
2922 range: crate::parser::node_range(&default_export.node),
2923 });
2924 }
2925 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2926 symbol_metadata
2927 .entry(TOP_LEVEL_SYMBOL.to_string())
2928 .or_insert(SymbolMeta {
2929 kind: SymbolKind::Function,
2930 exported: false,
2931 signature: None,
2932 line: 1,
2933 range: Range {
2934 start_line: 0,
2935 start_col: 0,
2936 end_line: 0,
2937 end_col: 0,
2938 },
2939 });
2940 }
2941
2942 Ok(FileCallData {
2943 calls_by_symbol,
2944 exported_symbols,
2945 symbol_metadata,
2946 default_export_symbol: default_export.map(|export| export.symbol),
2947 import_block,
2948 lang,
2949 })
2950}
2951
2952#[derive(Debug, Clone)]
2953struct DefaultExport<'tree> {
2954 symbol: String,
2955 synthetic: bool,
2956 kind: SymbolKind,
2957 node: Node<'tree>,
2958}
2959
2960fn find_default_export<'tree>(
2961 source: &str,
2962 root: Node<'tree>,
2963 path: &Path,
2964 lang: LangId,
2965) -> Option<DefaultExport<'tree>> {
2966 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2967 return None;
2968 }
2969 find_default_export_inner(source, root, path)
2970}
2971
2972fn find_default_export_inner<'tree>(
2973 source: &str,
2974 node: Node<'tree>,
2975 path: &Path,
2976) -> Option<DefaultExport<'tree>> {
2977 if node.kind() == "export_statement" {
2978 if let Some(default_export) = default_export_from_statement(source, node, path) {
2979 return Some(default_export);
2980 }
2981 }
2982
2983 let mut cursor = node.walk();
2984 if !cursor.goto_first_child() {
2985 return None;
2986 }
2987
2988 loop {
2989 let child = cursor.node();
2990 if let Some(default_export) = find_default_export_inner(source, child, path) {
2991 return Some(default_export);
2992 }
2993 if !cursor.goto_next_sibling() {
2994 break;
2995 }
2996 }
2997
2998 None
2999}
3000
3001fn default_export_from_statement<'tree>(
3002 source: &str,
3003 node: Node<'tree>,
3004 path: &Path,
3005) -> Option<DefaultExport<'tree>> {
3006 let mut cursor = node.walk();
3007 if !cursor.goto_first_child() {
3008 return None;
3009 }
3010
3011 let mut saw_default = false;
3012 loop {
3013 let child = cursor.node();
3014 match child.kind() {
3015 "default" => saw_default = true,
3016 "function_declaration" | "generator_function_declaration" | "class_declaration"
3017 if saw_default =>
3018 {
3019 if let Some(name_node) = child.child_by_field_name("name") {
3020 return Some(DefaultExport {
3021 symbol: source[name_node.byte_range()].to_string(),
3022 synthetic: false,
3023 kind: default_export_kind(&child),
3024 node: child,
3025 });
3026 }
3027 return Some(DefaultExport {
3028 symbol: synthetic_default_symbol(path),
3029 synthetic: true,
3030 kind: default_export_kind(&child),
3031 node: child,
3032 });
3033 }
3034 "arrow_function"
3035 | "function"
3036 | "function_expression"
3037 | "class"
3038 | "class_expression"
3039 if saw_default =>
3040 {
3041 return Some(DefaultExport {
3042 symbol: synthetic_default_symbol(path),
3043 synthetic: true,
3044 kind: default_export_kind(&child),
3045 node: child,
3046 });
3047 }
3048 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
3049 return Some(DefaultExport {
3050 symbol: source[child.byte_range()].to_string(),
3051 synthetic: false,
3052 kind: SymbolKind::Function,
3053 node: child,
3054 });
3055 }
3056 _ => {}
3057 }
3058 if !cursor.goto_next_sibling() {
3059 break;
3060 }
3061 }
3062
3063 None
3064}
3065
3066fn default_export_kind(node: &Node) -> SymbolKind {
3067 if node.kind().contains("class") {
3068 SymbolKind::Class
3069 } else {
3070 SymbolKind::Function
3071 }
3072}
3073
3074fn synthetic_default_symbol(path: &Path) -> String {
3075 let file_name = path
3076 .file_name()
3077 .and_then(|name| name.to_str())
3078 .unwrap_or("unknown");
3079 format!("<default:{file_name}>")
3080}
3081
3082fn first_line_signature(source: &str, node: &Node) -> String {
3083 let text = &source[node.byte_range()];
3084 let first_line = text.lines().next().unwrap_or(text);
3085 first_line
3086 .trim_end()
3087 .trim_end_matches('{')
3088 .trim_end()
3089 .to_string()
3090}
3091
3092fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
3093 file_data
3094 .symbol_metadata
3095 .get(symbol_name)
3096 .map(|meta| (meta.line, meta.signature.clone()))
3097 .unwrap_or((1, None))
3098}
3099
3100fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
3102 let provider = crate::parser::TreeSitterProvider::new();
3103 match provider.list_symbols(path) {
3104 Ok(symbols) => {
3105 for s in &symbols {
3106 if symbol_identity(s) == symbol_name || s.name == symbol_name {
3107 return (s.range.start_line + 1, s.signature.clone());
3108 }
3109 }
3110 (1, None)
3111 }
3112 Err(_) => (1, None),
3113 }
3114}
3115
3116fn node_text(node: tree_sitter::Node, source: &str) -> String {
3122 source[node.start_byte()..node.end_byte()].to_string()
3123}
3124
3125fn find_node_covering_range(
3127 root: tree_sitter::Node,
3128 start: usize,
3129 end: usize,
3130) -> Option<tree_sitter::Node> {
3131 let mut best = None;
3132 let mut cursor = root.walk();
3133
3134 fn walk_covering<'a>(
3135 cursor: &mut tree_sitter::TreeCursor<'a>,
3136 start: usize,
3137 end: usize,
3138 best: &mut Option<tree_sitter::Node<'a>>,
3139 ) {
3140 let node = cursor.node();
3141 if node.start_byte() <= start && node.end_byte() >= end {
3142 *best = Some(node);
3143 if cursor.goto_first_child() {
3144 loop {
3145 walk_covering(cursor, start, end, best);
3146 if !cursor.goto_next_sibling() {
3147 break;
3148 }
3149 }
3150 cursor.goto_parent();
3151 }
3152 }
3153 }
3154
3155 walk_covering(&mut cursor, start, end, &mut best);
3156 best
3157}
3158
3159fn find_child_by_kind<'a>(
3161 node: tree_sitter::Node<'a>,
3162 kind: &str,
3163) -> Option<tree_sitter::Node<'a>> {
3164 let mut cursor = node.walk();
3165 if cursor.goto_first_child() {
3166 loop {
3167 if cursor.node().kind() == kind {
3168 return Some(cursor.node());
3169 }
3170 if !cursor.goto_next_sibling() {
3171 break;
3172 }
3173 }
3174 }
3175 None
3176}
3177
3178#[derive(Debug, Clone)]
3179struct CallSiteWithRange {
3180 full: String,
3181 short: String,
3182 line: u32,
3183 byte_start: usize,
3184 byte_end: usize,
3185}
3186
3187fn collect_calls_full_with_ranges(
3188 root: tree_sitter::Node,
3189 source: &str,
3190 byte_start: usize,
3191 byte_end: usize,
3192 lang: LangId,
3193) -> Vec<CallSiteWithRange> {
3194 let mut results = Vec::new();
3195 let call_kinds = call_node_kinds(lang);
3196 collect_calls_full_with_ranges_inner(
3197 root,
3198 source,
3199 byte_start,
3200 byte_end,
3201 &call_kinds,
3202 &mut results,
3203 );
3204 results
3205}
3206
3207fn collect_calls_full_with_ranges_inner(
3208 node: tree_sitter::Node,
3209 source: &str,
3210 byte_start: usize,
3211 byte_end: usize,
3212 call_kinds: &[&str],
3213 results: &mut Vec<CallSiteWithRange>,
3214) {
3215 let node_start = node.start_byte();
3216 let node_end = node.end_byte();
3217
3218 if node_end <= byte_start || node_start >= byte_end {
3219 return;
3220 }
3221
3222 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
3223 if let (Some(full), Some(short)) = (
3224 extract_full_callee(&node, source),
3225 extract_callee_name(&node, source),
3226 ) {
3227 results.push(CallSiteWithRange {
3228 full,
3229 short,
3230 line: node.start_position().row as u32 + 1,
3231 byte_start: node_start,
3232 byte_end: node_end,
3233 });
3234 }
3235 }
3236
3237 let mut cursor = node.walk();
3238 if cursor.goto_first_child() {
3239 loop {
3240 collect_calls_full_with_ranges_inner(
3241 cursor.node(),
3242 source,
3243 byte_start,
3244 byte_end,
3245 call_kinds,
3246 results,
3247 );
3248 if !cursor.goto_next_sibling() {
3249 break;
3250 }
3251 }
3252 }
3253}
3254
3255fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
3257 let callee = match node.child_by_field_name("function") {
3259 Some(c) => c,
3260 None => return (None, None),
3261 };
3262
3263 let full = node_text(callee, source);
3264 let short = if full.contains('.') {
3265 full.rsplit('.').next().unwrap_or(&full).to_string()
3266 } else {
3267 full.clone()
3268 };
3269
3270 (Some(full), Some(short))
3271}
3272
3273pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3281 if module_path.starts_with('.') {
3282 return resolve_relative_module_path(from_dir, module_path);
3283 }
3284
3285 if module_path.starts_with('/') {
3286 return None;
3287 }
3288
3289 if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
3290 return Some(path);
3291 }
3292
3293 resolve_workspace_module_path(from_dir, module_path)
3294}
3295
3296fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3297 let base = from_dir.join(module_path);
3298 resolve_file_like_path(&base)
3299}
3300
3301fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
3302 let base = base.to_path_buf();
3303
3304 if base.is_file() {
3306 return Some(std::fs::canonicalize(&base).unwrap_or(base));
3307 }
3308
3309 for ext in JS_TS_EXTENSIONS {
3311 let with_ext = base.with_extension(ext);
3312 if with_ext.is_file() {
3313 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
3314 }
3315 }
3316
3317 if base.is_dir() {
3319 if let Some(index) = find_index_file(&base) {
3320 return Some(index);
3321 }
3322 }
3323
3324 None
3325}
3326
3327fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3328 let (package_name, subpath) = split_package_import(module_path)?;
3329 let package_root = find_package_root_for_import(from_dir, &package_name)?;
3330 resolve_package_entry(&package_root, &subpath)
3331}
3332
3333fn is_rust_source_file(path: &Path) -> bool {
3334 path.extension().and_then(|ext| ext.to_str()) == Some("rs")
3335}
3336
3337fn resolve_rust_cross_file_edge<F>(
3338 full_callee: &str,
3339 short_name: &str,
3340 caller_file: &Path,
3341 import_block: &ImportBlock,
3342 file_exports_symbol: &mut F,
3343) -> Option<ResolvedSymbol>
3344where
3345 F: FnMut(&Path, &str) -> bool,
3346{
3347 if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
3348 {
3349 return Some(target);
3350 }
3351
3352 resolve_rust_imported_call(
3353 caller_file,
3354 full_callee,
3355 short_name,
3356 import_block,
3357 file_exports_symbol,
3358 )
3359}
3360
3361fn resolve_rust_qualified_call<F>(
3362 caller_file: &Path,
3363 full_callee: &str,
3364 file_exports_symbol: &mut F,
3365) -> Option<ResolvedSymbol>
3366where
3367 F: FnMut(&Path, &str) -> bool,
3368{
3369 if !full_callee.contains("::") {
3370 return None;
3371 }
3372
3373 let segments = rust_path_segments(full_callee)?;
3374 resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
3375}
3376
3377fn resolve_rust_imported_call<F>(
3378 caller_file: &Path,
3379 full_callee: &str,
3380 short_name: &str,
3381 import_block: &ImportBlock,
3382 file_exports_symbol: &mut F,
3383) -> Option<ResolvedSymbol>
3384where
3385 F: FnMut(&Path, &str) -> bool,
3386{
3387 let call_segments = rust_path_segments(full_callee).unwrap_or_default();
3388 let bare_call_name = if call_segments.len() <= 1 {
3389 call_segments
3390 .first()
3391 .map(String::as_str)
3392 .unwrap_or(short_name)
3393 } else {
3394 short_name
3395 };
3396
3397 for imp in &import_block.imports {
3398 for entry in rust_use_entries(imp) {
3399 match &entry.kind {
3400 RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
3401 if entry.local_name != bare_call_name {
3402 continue;
3403 }
3404 let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
3405 else {
3406 continue;
3407 };
3408 if file_exports_symbol(&file, imported_name) {
3409 return Some(ResolvedSymbol {
3410 file,
3411 symbol: imported_name.clone(),
3412 });
3413 }
3414 }
3415 RustUseKind::Module if call_segments.len() >= 2 => {
3416 if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
3417 {
3418 continue;
3419 }
3420 let symbol = call_segments.last()?.clone();
3421 let mut module_path = entry.module_path.clone();
3422 for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
3423 module_path.push_str("::");
3424 module_path.push_str(segment);
3425 }
3426 let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
3427 continue;
3428 };
3429 if file_exports_symbol(&file, &symbol) {
3430 return Some(ResolvedSymbol { file, symbol });
3431 }
3432 }
3433 _ => {}
3434 }
3435 }
3436 }
3437
3438 None
3439}
3440
3441fn resolve_rust_call_segments<F>(
3442 caller_file: &Path,
3443 segments: &[String],
3444 file_exports_symbol: &mut F,
3445) -> Option<ResolvedSymbol>
3446where
3447 F: FnMut(&Path, &str) -> bool,
3448{
3449 if segments.len() < 2 {
3450 return None;
3451 }
3452
3453 let symbol = segments.last()?.clone();
3454 let module_path = segments[..segments.len() - 1].join("::");
3455 let file = resolve_rust_module_path(caller_file, &module_path)?;
3456 if file_exports_symbol(&file, &symbol) {
3457 Some(ResolvedSymbol { file, symbol })
3458 } else {
3459 None
3460 }
3461}
3462
3463fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
3464 let segments = rust_path_segments(module_path)?;
3465 let first = segments.first()?.as_str();
3466
3467 match first {
3468 "std" | "core" | "alloc" => None,
3469 "crate" => {
3470 let crate_root = find_rust_crate_root(caller_file)?;
3471 let crate_info = rust_crate_info(&crate_root)?;
3472 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3473 resolve_rust_module_segments(&base, &segments[1..])
3474 }
3475 "self" => {
3476 let crate_root = find_rust_crate_root(caller_file)?;
3477 let crate_info = rust_crate_info(&crate_root)?;
3478 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3479 if segments.len() == 1 {
3480 return Some(canonicalize_path(caller_file));
3481 }
3482 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3483 target_segments.extend(segments[1..].iter().cloned());
3484 resolve_rust_module_segments(&base, &target_segments)
3485 }
3486 "super" => {
3487 let crate_root = find_rust_crate_root(caller_file)?;
3488 let crate_info = rust_crate_info(&crate_root)?;
3489 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3490 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3491 target_segments.pop();
3492 target_segments.extend(segments[1..].iter().cloned());
3493 resolve_rust_module_segments(&base, &target_segments)
3494 }
3495 crate_name => {
3496 let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
3497 let workspace_crates = rust_workspace_crates(caller_dir)?;
3498 let crate_info = workspace_crates.get(crate_name)?;
3499 let base = rust_lib_module_base(crate_info)?;
3500 resolve_rust_module_segments(&base, &segments[1..])
3501 }
3502 }
3503}
3504
3505fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
3506 let Some(body) = rust_use_body(&imp.raw_text) else {
3507 return Vec::new();
3508 };
3509 let mut entries = Vec::new();
3510 expand_rust_use_tree(body, &mut entries);
3511 entries
3512}
3513
3514fn rust_use_body(raw: &str) -> Option<&str> {
3515 let use_pos = raw.find("use ")?;
3516 let body = raw[use_pos + 4..].trim();
3517 let body = body.strip_suffix(';').unwrap_or(body).trim();
3518 (!body.is_empty()).then_some(body)
3519}
3520
3521fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
3522 let path = path.trim();
3523 if path.is_empty() {
3524 return;
3525 }
3526
3527 if let Some((prefix, inner)) = split_rust_use_braces(path) {
3528 let prefix = prefix.trim().trim_end_matches("::").trim();
3529 for part in split_top_level_commas(inner) {
3530 let part = part.trim();
3531 if part.is_empty() {
3532 continue;
3533 }
3534 if part == "self" {
3535 if let Some(local_name) = rust_last_path_segment(prefix) {
3536 entries.push(RustUseEntry {
3537 module_path: prefix.to_string(),
3538 local_name,
3539 kind: RustUseKind::Module,
3540 });
3541 }
3542 continue;
3543 }
3544 let combined = if prefix.is_empty() {
3545 part.to_string()
3546 } else {
3547 format!("{prefix}::{part}")
3548 };
3549 expand_rust_use_tree(&combined, entries);
3550 }
3551 return;
3552 }
3553
3554 add_rust_use_leaf(path, entries);
3555}
3556
3557fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
3558 let mut depth = 0usize;
3559 let mut start = None;
3560 for (idx, ch) in path.char_indices() {
3561 match ch {
3562 '{' => {
3563 if depth == 0 {
3564 start = Some(idx);
3565 }
3566 depth += 1;
3567 }
3568 '}' => {
3569 depth = depth.checked_sub(1)?;
3570 if depth == 0 {
3571 let start = start?;
3572 if !path[idx + ch.len_utf8()..].trim().is_empty() {
3573 return None;
3574 }
3575 return Some((&path[..start], &path[start + 1..idx]));
3576 }
3577 }
3578 _ => {}
3579 }
3580 }
3581 None
3582}
3583
3584fn split_top_level_commas(value: &str) -> Vec<&str> {
3585 let mut parts = Vec::new();
3586 let mut depth = 0usize;
3587 let mut start = 0usize;
3588 for (idx, ch) in value.char_indices() {
3589 match ch {
3590 '{' => depth += 1,
3591 '}' => depth = depth.saturating_sub(1),
3592 ',' if depth == 0 => {
3593 parts.push(&value[start..idx]);
3594 start = idx + ch.len_utf8();
3595 }
3596 _ => {}
3597 }
3598 }
3599 parts.push(&value[start..]);
3600 parts
3601}
3602
3603fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
3604 let (path, alias) = split_rust_alias(path);
3605 let Some(segments) = rust_path_segments(path) else {
3606 return;
3607 };
3608 if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
3609 return;
3610 }
3611
3612 let imported_name = segments.last().cloned().unwrap_or_default();
3613 let local_name = alias.unwrap_or(&imported_name).to_string();
3614 if segments.len() >= 2 {
3615 entries.push(RustUseEntry {
3616 module_path: segments[..segments.len() - 1].join("::"),
3617 local_name: local_name.clone(),
3618 kind: RustUseKind::Item {
3619 imported_name: imported_name.clone(),
3620 },
3621 });
3622 }
3623
3624 entries.push(RustUseEntry {
3625 module_path: segments.join("::"),
3626 local_name,
3627 kind: RustUseKind::Module,
3628 });
3629}
3630
3631fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
3632 if let Some(idx) = path.rfind(" as ") {
3633 let original = path[..idx].trim();
3634 let alias = path[idx + 4..].trim();
3635 if !original.is_empty() && !alias.is_empty() {
3636 return (original, Some(alias));
3637 }
3638 }
3639 (path.trim(), None)
3640}
3641
3642fn rust_path_segments(path: &str) -> Option<Vec<String>> {
3643 let path = path.trim().trim_end_matches(';').trim();
3644 if path.is_empty() || path.contains('{') || path.contains('}') {
3645 return None;
3646 }
3647
3648 let mut segments = Vec::new();
3649 for raw_segment in path.split("::") {
3650 let segment = raw_segment.trim();
3651 if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
3652 return None;
3653 }
3654 let segment = segment.strip_prefix("r#").unwrap_or(segment);
3655 if segment
3656 .chars()
3657 .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
3658 {
3659 return None;
3660 }
3661 segments.push(segment.to_string());
3662 }
3663
3664 (!segments.is_empty()).then_some(segments)
3665}
3666
3667fn rust_last_path_segment(path: &str) -> Option<String> {
3668 rust_path_segments(path)?.last().cloned()
3669}
3670
3671fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
3672 let mut current = if from.is_file() {
3673 from.parent()
3674 } else {
3675 Some(from)
3676 };
3677 while let Some(dir) = current {
3678 if dir.join("Cargo.toml").is_file() {
3679 return Some(canonicalize_path(dir));
3680 }
3681 current = dir.parent();
3682 }
3683 None
3684}
3685
3686fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3687 let root = canonicalize_path(crate_root);
3688 if let Some(cached) = RUST_CRATE_INFO_CACHE
3689 .read()
3690 .ok()
3691 .and_then(|cache| cache.get(&root).cloned())
3692 {
3693 return cached;
3694 }
3695
3696 let resolved = read_rust_crate_info(&root);
3697 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
3698 cache.insert(root, resolved.clone());
3699 }
3700 resolved
3701}
3702
3703fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3704 let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
3705 let package = cargo.get("package")?;
3706 let package_name = package.get("name")?.as_str()?;
3707 let lib_name = cargo
3708 .get("lib")
3709 .and_then(|lib| lib.get("name"))
3710 .and_then(|name| name.as_str())
3711 .map(ToOwned::to_owned)
3712 .unwrap_or_else(|| package_name.replace('-', "_"));
3713
3714 let lib_root = cargo
3715 .get("lib")
3716 .and_then(|lib| lib.get("path"))
3717 .and_then(|path| path.as_str())
3718 .map(|path| crate_root.join(path))
3719 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
3720 let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
3721
3722 let main_root = crate_root.join("src/main.rs");
3723 let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
3724
3725 Some(RustCrateInfo {
3726 lib_name,
3727 lib_root,
3728 main_root,
3729 })
3730}
3731
3732fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
3733 let source = std::fs::read_to_string(path).ok()?;
3734 toml::from_str(&source).ok()
3735}
3736
3737fn rust_module_base_for_caller(
3738 crate_info: &RustCrateInfo,
3739 caller_file: &Path,
3740) -> Option<RustModuleBase> {
3741 let caller = canonicalize_path(caller_file);
3742 if crate_info.main_root.as_ref() == Some(&caller) {
3743 return rust_main_module_base(crate_info);
3744 }
3745 rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
3746}
3747
3748fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3749 let root_file = crate_info.lib_root.clone()?;
3750 let src_dir = root_file.parent()?.to_path_buf();
3751 Some(RustModuleBase { src_dir, root_file })
3752}
3753
3754fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3755 let root_file = crate_info.main_root.clone()?;
3756 let src_dir = root_file.parent()?.to_path_buf();
3757 Some(RustModuleBase { src_dir, root_file })
3758}
3759
3760fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
3761 if segments.is_empty() {
3762 return Some(base.root_file.clone());
3763 }
3764
3765 let module_base = segments
3766 .iter()
3767 .fold(base.src_dir.clone(), |path, segment| path.join(segment));
3768 let file_path = module_base.with_extension("rs");
3769 if file_path.is_file() {
3770 return Some(canonicalize_path(&file_path));
3771 }
3772
3773 let mod_path = module_base.join("mod.rs");
3774 if mod_path.is_file() {
3775 return Some(canonicalize_path(&mod_path));
3776 }
3777
3778 None
3779}
3780
3781fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
3782 let src_dir = canonicalize_path(src_dir);
3783 let file = canonicalize_path(file);
3784 let rel = file.strip_prefix(&src_dir).ok()?;
3785 let mut parts: Vec<String> = rel
3786 .components()
3787 .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
3788 .collect();
3789 if parts.is_empty() {
3790 return None;
3791 }
3792
3793 let last = parts.pop()?;
3794 if last == "lib.rs" || last == "main.rs" {
3795 return Some(Vec::new());
3796 }
3797 if last == "mod.rs" {
3798 return Some(parts);
3799 }
3800 let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
3801 parts.push(stem);
3802 Some(parts)
3803}
3804
3805fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
3806 let workspace_root =
3807 find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
3808 let workspace_root = canonicalize_path(&workspace_root);
3809
3810 if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
3811 .read()
3812 .ok()
3813 .and_then(|cache| cache.get(&workspace_root).cloned())
3814 {
3815 return Some(cached);
3816 }
3817
3818 let mut crates = HashMap::new();
3819 for member in rust_workspace_member_dirs(&workspace_root) {
3820 if let Some(info) = rust_crate_info(&member) {
3821 if info.lib_root.is_some() {
3822 crates.insert(info.lib_name.clone(), info);
3823 }
3824 }
3825 }
3826 if let Some(info) = rust_crate_info(&workspace_root) {
3827 if info.lib_root.is_some() {
3828 crates.insert(info.lib_name.clone(), info);
3829 }
3830 }
3831
3832 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
3833 cache.insert(workspace_root, crates.clone());
3834 }
3835 Some(crates)
3836}
3837
3838fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3839 let mut current = Some(from_dir);
3840 while let Some(dir) = current {
3841 let cargo = dir.join("Cargo.toml");
3842 if rust_manifest_value(&cargo)
3843 .and_then(|value| value.get("workspace").cloned())
3844 .is_some()
3845 {
3846 return Some(canonicalize_path(dir));
3847 }
3848 current = dir.parent();
3849 }
3850 None
3851}
3852
3853fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
3854 let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
3855 return Vec::new();
3856 };
3857 let Some(members) = cargo
3858 .get("workspace")
3859 .and_then(|workspace| workspace.get("members"))
3860 .and_then(|members| members.as_array())
3861 else {
3862 return Vec::new();
3863 };
3864
3865 let mut dirs = Vec::new();
3866 for member in members.iter().filter_map(|member| member.as_str()) {
3867 dirs.extend(expand_rust_workspace_member(workspace_root, member));
3868 }
3869 dirs.sort();
3870 dirs.dedup();
3871 dirs
3872}
3873
3874fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
3875 let member = member.trim();
3876 if member.is_empty() {
3877 return Vec::new();
3878 }
3879
3880 if member.contains('*') || member.contains('?') || member.contains('[') {
3881 let pattern = workspace_root.join(member).to_string_lossy().to_string();
3882 return glob::glob(&pattern)
3883 .ok()
3884 .into_iter()
3885 .flatten()
3886 .filter_map(Result::ok)
3887 .filter(|path| path.join("Cargo.toml").is_file())
3888 .map(|path| canonicalize_path(&path))
3889 .collect();
3890 }
3891
3892 let path = workspace_root.join(member);
3893 if path.join("Cargo.toml").is_file() {
3894 vec![canonicalize_path(&path)]
3895 } else {
3896 Vec::new()
3897 }
3898}
3899
3900fn canonicalize_path(path: &Path) -> PathBuf {
3901 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3902}
3903
3904fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3905 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
3906 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
3907 let compiler_options = tsconfig.get("compilerOptions")?;
3908 let paths = compiler_options.get("paths")?.as_object()?;
3909 let base_url = compiler_options
3910 .get("baseUrl")
3911 .and_then(Value::as_str)
3912 .unwrap_or(".");
3913 let base_dir = tsconfig_dir.join(base_url);
3914
3915 for (alias, targets) in paths {
3916 let Some(capture) = ts_path_capture(alias, module_path) else {
3917 continue;
3918 };
3919 let Some(targets) = targets.as_array() else {
3920 continue;
3921 };
3922 for target in targets.iter().filter_map(Value::as_str) {
3923 let target = if target.contains('*') {
3924 target.replace('*', capture)
3925 } else {
3926 target.to_string()
3927 };
3928 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
3929 return Some(path);
3930 }
3931 }
3932 }
3933
3934 None
3935}
3936
3937fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
3938 let mut current = Some(from_dir);
3939 while let Some(dir) = current {
3940 if dir.join("tsconfig.json").is_file() {
3941 return Some(dir.to_path_buf());
3942 }
3943 current = dir.parent();
3944 }
3945 None
3946}
3947
3948fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
3949 if let Some(star_index) = alias.find('*') {
3950 let (prefix, suffix_with_star) = alias.split_at(star_index);
3951 let suffix = &suffix_with_star[1..];
3952 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
3953 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
3954 }
3955 return None;
3956 }
3957
3958 (alias == module_path).then_some("")
3959}
3960
3961fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
3962 let mut parts = module_path.split('/');
3963 let first = parts.next()?;
3964 if first.is_empty() {
3965 return None;
3966 }
3967
3968 if first.starts_with('@') {
3969 let second = parts.next()?;
3970 if second.is_empty() {
3971 return None;
3972 }
3973 let package_name = format!("{first}/{second}");
3974 let subpath = parts.collect::<Vec<_>>().join("/");
3975 let subpath = (!subpath.is_empty()).then_some(subpath);
3976 Some((package_name, subpath))
3977 } else {
3978 let package_name = first.to_string();
3979 let subpath = parts.collect::<Vec<_>>().join("/");
3980 let subpath = (!subpath.is_empty()).then_some(subpath);
3981 Some((package_name, subpath))
3982 }
3983}
3984
3985fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
3986 let mut current = Some(from_dir);
3987 while let Some(dir) = current {
3988 if package_json_name(dir).as_deref() == Some(package_name) {
3989 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
3990 }
3991 current = dir.parent();
3992 }
3993
3994 find_workspace_root(from_dir)
3995 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
3996}
3997
3998fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3999 let mut current = Some(from_dir);
4000 while let Some(dir) = current {
4001 if is_workspace_root(dir) {
4002 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4003 }
4004 current = dir.parent();
4005 }
4006 None
4007}
4008
4009fn is_workspace_root(dir: &Path) -> bool {
4010 package_json_value(dir)
4011 .map(|value| !workspace_patterns(&value).is_empty())
4012 .unwrap_or(false)
4013 || !pnpm_workspace_patterns(dir).is_empty()
4014}
4015
4016fn clear_workspace_package_cache() {
4017 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4018 cache.clear();
4019 }
4020 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
4021 cache.clear();
4022 }
4023 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
4024 cache.clear();
4025 }
4026}
4027
4028fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
4029 let workspace_root =
4030 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
4031 let cache_key = (workspace_root.clone(), package_name.to_string());
4032
4033 if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
4034 if let Some(cached) = cache.get(&cache_key) {
4035 return cached.clone();
4036 }
4037 }
4038
4039 let resolved = workspace_member_dirs(&workspace_root)
4040 .into_iter()
4041 .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
4042 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
4043
4044 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4045 cache.insert(cache_key, resolved.clone());
4046 }
4047
4048 resolved
4049}
4050
4051fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
4052 let mut patterns = package_json_value(workspace_root)
4053 .map(|package_json| workspace_patterns(&package_json))
4054 .unwrap_or_default();
4055 patterns.extend(pnpm_workspace_patterns(workspace_root));
4056
4057 expand_workspace_patterns(workspace_root, &patterns)
4058}
4059
4060fn workspace_patterns(package_json: &Value) -> Vec<String> {
4061 match package_json.get("workspaces") {
4062 Some(Value::Array(items)) => items
4063 .iter()
4064 .filter_map(non_empty_workspace_pattern)
4065 .collect(),
4066 Some(Value::Object(map)) => map
4067 .get("packages")
4068 .and_then(Value::as_array)
4069 .map(|items| {
4070 items
4071 .iter()
4072 .filter_map(non_empty_workspace_pattern)
4073 .collect()
4074 })
4075 .unwrap_or_default(),
4076 _ => Vec::new(),
4077 }
4078}
4079
4080fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
4081 let pattern = value.as_str()?.trim();
4082 (!pattern.is_empty()).then(|| pattern.to_string())
4083}
4084
4085fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
4086 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
4087 return Vec::new();
4088 };
4089
4090 let mut patterns = Vec::new();
4091 let mut in_packages = false;
4092 for line in source.lines() {
4093 let without_comment = line.split('#').next().unwrap_or("").trim_end();
4094 let trimmed = without_comment.trim();
4095 if trimmed.is_empty() {
4096 continue;
4097 }
4098 if trimmed == "packages:" {
4099 in_packages = true;
4100 continue;
4101 }
4102 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
4103 in_packages = false;
4104 }
4105 if in_packages {
4106 if let Some(pattern) = trimmed.strip_prefix('-') {
4107 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
4108 if !pattern.is_empty() {
4109 patterns.push(pattern.to_string());
4110 }
4111 }
4112 }
4113 }
4114 patterns
4115}
4116
4117fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
4118 let positive_patterns: Vec<&str> = patterns
4119 .iter()
4120 .map(|pattern| pattern.trim())
4121 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
4122 .collect();
4123 if positive_patterns.is_empty() {
4124 return Vec::new();
4125 }
4126
4127 let positives = build_glob_set(&positive_patterns);
4128 let negative_patterns: Vec<&str> = patterns
4129 .iter()
4130 .map(|pattern| pattern.trim())
4131 .filter_map(|pattern| pattern.strip_prefix('!'))
4132 .map(str::trim)
4133 .filter(|pattern| !pattern.is_empty())
4134 .collect();
4135 let negatives = build_glob_set(&negative_patterns);
4136
4137 let mut members = Vec::new();
4138 collect_workspace_member_dirs(
4139 workspace_root,
4140 workspace_root,
4141 &positives,
4142 &negatives,
4143 &mut members,
4144 );
4145 members
4146}
4147
4148fn build_glob_set(patterns: &[&str]) -> GlobSet {
4149 let mut builder = GlobSetBuilder::new();
4150 for pattern in patterns {
4151 if let Ok(glob) = Glob::new(pattern) {
4152 builder.add(glob);
4153 }
4154 }
4155 builder
4156 .build()
4157 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
4158}
4159
4160fn collect_workspace_member_dirs(
4161 workspace_root: &Path,
4162 dir: &Path,
4163 positives: &GlobSet,
4164 negatives: &GlobSet,
4165 members: &mut Vec<PathBuf>,
4166) {
4167 let Ok(entries) = std::fs::read_dir(dir) else {
4168 return;
4169 };
4170
4171 for entry in entries.filter_map(Result::ok) {
4172 let path = entry.path();
4173 let Ok(file_type) = entry.file_type() else {
4174 continue;
4175 };
4176 if !file_type.is_dir() {
4177 continue;
4178 }
4179 let name = entry.file_name();
4180 let name = name.to_string_lossy();
4181 if matches!(
4182 name.as_ref(),
4183 "node_modules" | ".git" | "target" | "dist" | "build"
4184 ) {
4185 continue;
4186 }
4187
4188 if path.join("package.json").is_file() {
4189 if let Ok(rel) = path.strip_prefix(workspace_root) {
4190 let rel = rel.to_string_lossy().replace('\\', "/");
4191 if positives.is_match(&rel) && !negatives.is_match(&rel) {
4192 members.push(path.clone());
4193 }
4194 }
4195 }
4196
4197 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
4198 }
4199}
4200
4201fn package_json_value(dir: &Path) -> Option<Value> {
4202 package_json_like_value(&dir.join("package.json"))
4203}
4204
4205fn package_json_like_value(path: &Path) -> Option<Value> {
4206 let json = std::fs::read_to_string(path).ok()?;
4207 serde_json::from_str(&json).ok()
4208}
4209
4210fn package_json_name(dir: &Path) -> Option<String> {
4211 package_json_value(dir)?
4212 .get("name")?
4213 .as_str()
4214 .map(ToOwned::to_owned)
4215}
4216
4217fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
4218 let package_json = package_json_value(package_root).unwrap_or(Value::Null);
4219
4220 if let Some(exports) = package_json.get("exports") {
4221 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
4222 if let Some(path) = resolve_package_target(package_root, &target) {
4223 return Some(path);
4224 }
4225 }
4226 }
4227
4228 if subpath.is_none() {
4229 for field in ["module", "main"] {
4230 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
4231 if let Some(path) = resolve_package_target(package_root, target) {
4232 return Some(path);
4233 }
4234 }
4235 }
4236 }
4237
4238 resolve_package_fallback(package_root, subpath.as_deref())
4239}
4240
4241fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
4242 let key = subpath
4243 .map(|value| format!("./{value}"))
4244 .unwrap_or_else(|| ".".to_string());
4245
4246 match exports {
4247 Value::String(target) if key == "." => Some(target.clone()),
4248 Value::Object(map) => {
4249 if let Some(target) = map.get(&key).and_then(export_condition_target) {
4250 return Some(target);
4251 }
4252
4253 if let Some(target) = wildcard_export_target(map, &key) {
4254 return Some(target);
4255 }
4256
4257 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
4258 return export_condition_target(exports);
4259 }
4260
4261 None
4262 }
4263 _ => None,
4264 }
4265}
4266
4267fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
4268 for (pattern, target) in map {
4269 let Some(star_index) = pattern.find('*') else {
4270 continue;
4271 };
4272 let (prefix, suffix_with_star) = pattern.split_at(star_index);
4273 let suffix = &suffix_with_star[1..];
4274 if !key.starts_with(prefix) || !key.ends_with(suffix) {
4275 continue;
4276 }
4277 let matched = &key[prefix.len()..key.len() - suffix.len()];
4278 if let Some(target_pattern) = export_condition_target(target) {
4279 return Some(target_pattern.replace('*', matched));
4280 }
4281 }
4282 None
4283}
4284
4285fn export_condition_target(value: &Value) -> Option<String> {
4286 match value {
4287 Value::String(target) => Some(target.clone()),
4288 Value::Object(map) => ["source", "import", "module", "default", "types"]
4289 .into_iter()
4290 .find_map(|field| map.get(field).and_then(export_condition_target)),
4291 _ => None,
4292 }
4293}
4294
4295fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
4296 let target = target.strip_prefix("./").unwrap_or(target);
4297 if let Some(src_relative) = target.strip_prefix("dist/") {
4300 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
4301 return Some(path);
4302 }
4303 }
4304
4305 resolve_file_like_path(&package_root.join(target))
4306}
4307
4308fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
4309 match subpath {
4310 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
4311 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
4312 None => resolve_file_like_path(&package_root.join("src").join("index"))
4313 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
4314 }
4315}
4316
4317pub(crate) fn resolve_reexported_symbol_target<F, D>(
4318 file: &Path,
4319 symbol_name: &str,
4320 file_exports_symbol: &mut F,
4321 file_default_export_symbol: &mut D,
4322) -> Option<(PathBuf, String)>
4323where
4324 F: FnMut(&Path, &str) -> bool,
4325 D: FnMut(&Path) -> Option<String>,
4326{
4327 resolve_reexported_symbol(
4328 file,
4329 symbol_name,
4330 file_exports_symbol,
4331 file_default_export_symbol,
4332 )
4333 .map(|target| (target.file, target.symbol))
4334}
4335
4336fn resolve_reexported_symbol<F, D>(
4337 file: &Path,
4338 symbol_name: &str,
4339 file_exports_symbol: &mut F,
4340 file_default_export_symbol: &mut D,
4341) -> Option<ResolvedSymbol>
4342where
4343 F: FnMut(&Path, &str) -> bool,
4344 D: FnMut(&Path) -> Option<String>,
4345{
4346 let mut visited = HashSet::new();
4347 resolve_reexported_symbol_inner(
4348 file,
4349 symbol_name,
4350 file_exports_symbol,
4351 file_default_export_symbol,
4352 &mut visited,
4353 )
4354}
4355
4356fn resolve_reexported_symbol_inner<F, D>(
4357 file: &Path,
4358 symbol_name: &str,
4359 file_exports_symbol: &mut F,
4360 file_default_export_symbol: &mut D,
4361 visited: &mut HashSet<(PathBuf, String)>,
4362) -> Option<ResolvedSymbol>
4363where
4364 F: FnMut(&Path, &str) -> bool,
4365 D: FnMut(&Path) -> Option<String>,
4366{
4367 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
4368 if !visited.insert((canon.clone(), symbol_name.to_string())) {
4369 return None;
4370 }
4371
4372 let source = std::fs::read_to_string(&canon).ok()?;
4373 let lang = detect_language(&canon)?;
4374 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
4375 if symbol_name == "default" {
4376 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
4377 file: canon,
4378 symbol,
4379 });
4380 }
4381 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
4382 file: canon,
4383 symbol: symbol_name.to_string(),
4384 });
4385 }
4386
4387 let grammar = grammar_for(lang);
4388 let mut parser = Parser::new();
4389 parser.set_language(&grammar).ok()?;
4390 let tree = parser.parse(&source, None)?;
4391 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
4392
4393 let mut cursor = tree.root_node().walk();
4394 if !cursor.goto_first_child() {
4395 return None;
4396 }
4397
4398 loop {
4399 let node = cursor.node();
4400 if node.kind() == "export_statement" {
4401 if let Some(target) = resolve_reexport_statement(
4402 &source,
4403 node,
4404 from_dir,
4405 symbol_name,
4406 file_exports_symbol,
4407 file_default_export_symbol,
4408 visited,
4409 ) {
4410 return Some(target);
4411 }
4412 }
4413
4414 if !cursor.goto_next_sibling() {
4415 break;
4416 }
4417 }
4418
4419 if symbol_name == "default" {
4420 if let Some(symbol) = file_default_export_symbol(&canon) {
4421 return Some(ResolvedSymbol {
4422 file: canon,
4423 symbol,
4424 });
4425 }
4426 }
4427
4428 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
4429 return Some(ResolvedSymbol {
4430 file: canon,
4431 symbol,
4432 });
4433 }
4434
4435 if file_exports_symbol(&canon, symbol_name) {
4436 let symbol = symbol_name.to_string();
4437 return Some(ResolvedSymbol {
4438 file: canon,
4439 symbol,
4440 });
4441 }
4442
4443 None
4444}
4445
4446fn resolve_reexport_statement<F, D>(
4447 source: &str,
4448 node: tree_sitter::Node,
4449 from_dir: &Path,
4450 symbol_name: &str,
4451 file_exports_symbol: &mut F,
4452 file_default_export_symbol: &mut D,
4453 visited: &mut HashSet<(PathBuf, String)>,
4454) -> Option<ResolvedSymbol>
4455where
4456 F: FnMut(&Path, &str) -> bool,
4457 D: FnMut(&Path) -> Option<String>,
4458{
4459 let source_node = node
4460 .child_by_field_name("source")
4461 .or_else(|| find_child_by_kind(node, "string"))?;
4462 let module_path = string_literal_content(source, source_node)?;
4463 let target_file = resolve_module_path(from_dir, &module_path)?;
4464 let raw_export = node_text(node, source);
4465
4466 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
4467 return resolve_reexported_symbol_inner(
4468 &target_file,
4469 &source_symbol,
4470 file_exports_symbol,
4471 file_default_export_symbol,
4472 visited,
4473 )
4474 .or(Some(ResolvedSymbol {
4475 file: target_file,
4476 symbol: source_symbol,
4477 }));
4478 }
4479
4480 if raw_export.contains('*') {
4481 return resolve_reexported_symbol_inner(
4482 &target_file,
4483 symbol_name,
4484 file_exports_symbol,
4485 file_default_export_symbol,
4486 visited,
4487 );
4488 }
4489
4490 None
4491}
4492
4493fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
4494 let lang = detect_language(file)?;
4495 let grammar = grammar_for(lang);
4496 let mut parser = Parser::new();
4497 parser.set_language(&grammar).ok()?;
4498 let tree = parser.parse(source, None)?;
4499
4500 let mut cursor = tree.root_node().walk();
4501 if !cursor.goto_first_child() {
4502 return None;
4503 }
4504
4505 loop {
4506 let node = cursor.node();
4507 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
4508 let raw_export = node_text(node, source);
4509 if let Some(source_symbol) =
4510 reexport_clause_source_symbol(&raw_export, requested_export)
4511 {
4512 return Some(source_symbol);
4513 }
4514 }
4515
4516 if !cursor.goto_next_sibling() {
4517 break;
4518 }
4519 }
4520
4521 None
4522}
4523
4524fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
4525 let start = raw_export.find('{')? + 1;
4526 let end = raw_export[start..].find('}')? + start;
4527 for specifier in raw_export[start..end].split(',') {
4528 let specifier = specifier.trim();
4529 if specifier.is_empty() {
4530 continue;
4531 }
4532 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
4533 if let Some((imported, exported)) = specifier.split_once(" as ") {
4534 if exported.trim() == requested_export {
4535 return Some(imported.trim().to_string());
4536 }
4537 } else if specifier == requested_export {
4538 return Some(requested_export.to_string());
4539 }
4540 }
4541 None
4542}
4543
4544fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
4545 let raw = source[node.byte_range()].trim();
4546 let quote = raw.chars().next()?;
4547 if quote != '\'' && quote != '"' {
4548 return None;
4549 }
4550 raw.strip_prefix(quote)
4551 .and_then(|value| value.strip_suffix(quote))
4552 .map(ToOwned::to_owned)
4553}
4554
4555fn find_index_file(dir: &Path) -> Option<PathBuf> {
4557 for name in JS_TS_INDEX_FILES {
4558 let p = dir.join(name);
4559 if p.is_file() {
4560 return Some(std::fs::canonicalize(&p).unwrap_or(p));
4561 }
4562 }
4563 None
4564}
4565
4566fn resolve_aliased_import(
4569 local_name: &str,
4570 import_block: &ImportBlock,
4571 caller_dir: &Path,
4572) -> Option<(String, PathBuf)> {
4573 for imp in &import_block.imports {
4574 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
4577 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
4578 return Some((original, resolved_path));
4579 }
4580 }
4581 }
4582 None
4583}
4584
4585fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
4589 let search = format!(" as {}", local_name);
4592 if let Some(pos) = raw_import.find(&search) {
4593 let before = &raw_import[..pos];
4595 let original = before
4597 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
4598 .find(|s| !s.is_empty())?;
4599 return Some(original.to_string());
4600 }
4601 None
4602}
4603
4604pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
4612 use ignore::WalkBuilder;
4613
4614 let walker = WalkBuilder::new(root)
4615 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .add_custom_ignore_filename(".aftignore") .filter_entry(|entry| {
4621 let name = entry.file_name().to_string_lossy();
4622 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4624 return !matches!(
4625 name.as_ref(),
4626 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
4627 | ".tox" | "dist" | "build"
4628 );
4629 }
4630 true
4631 })
4632 .build();
4633
4634 walker
4635 .filter_map(|entry| entry.ok())
4636 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
4637 .filter(|entry| detect_language(entry.path()).is_some())
4638 .map(|entry| entry.into_path())
4639}
4640
4641#[cfg(test)]
4646mod tests {
4647 use super::*;
4648 use std::fs;
4649 use tempfile::TempDir;
4650
4651 #[test]
4652 fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
4653 let mut symbol_metadata = HashMap::new();
4658 symbol_metadata.insert(
4659 "BackupStore::total_disk_bytes".to_string(),
4660 SymbolMeta {
4661 kind: SymbolKind::Method,
4662 exported: true,
4663 signature: None,
4664 line: 703,
4665 range: Range {
4666 start_line: 702,
4667 start_col: 0,
4668 end_line: 705,
4669 end_col: 0,
4670 },
4671 },
4672 );
4673 let file_data = FileCallData {
4674 calls_by_symbol: HashMap::new(),
4675 exported_symbols: vec!["total_disk_bytes".to_string()],
4676 symbol_metadata,
4677 default_export_symbol: None,
4678 import_block: ImportBlock::empty(),
4679 lang: LangId::Rust,
4680 };
4681
4682 let meta = file_data
4683 .symbol_metadata_for("total_disk_bytes")
4684 .expect("scoped method recovered by bare name");
4685 assert_eq!(meta.kind, SymbolKind::Method);
4686 assert_eq!(
4687 meta.line, 703,
4688 "real declaration line, not the line-1 fallback"
4689 );
4690
4691 assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
4693 }
4694
4695 fn setup_ts_project() -> TempDir {
4697 let dir = TempDir::new().unwrap();
4698
4699 fs::write(
4701 dir.path().join("main.ts"),
4702 r#"import { helper, compute } from './utils';
4703import * as math from './math';
4704
4705export function main() {
4706 const a = helper(1);
4707 const b = compute(a, 2);
4708 const c = math.add(a, b);
4709 return c;
4710}
4711"#,
4712 )
4713 .unwrap();
4714
4715 fs::write(
4717 dir.path().join("utils.ts"),
4718 r#"import { double } from './helpers';
4719
4720export function helper(x: number): number {
4721 return double(x);
4722}
4723
4724export function compute(a: number, b: number): number {
4725 return a + b;
4726}
4727"#,
4728 )
4729 .unwrap();
4730
4731 fs::write(
4733 dir.path().join("helpers.ts"),
4734 r#"export function double(x: number): number {
4735 return x * 2;
4736}
4737
4738export function triple(x: number): number {
4739 return x * 3;
4740}
4741"#,
4742 )
4743 .unwrap();
4744
4745 fs::write(
4747 dir.path().join("math.ts"),
4748 r#"export function add(a: number, b: number): number {
4749 return a + b;
4750}
4751
4752export function subtract(a: number, b: number): number {
4753 return a - b;
4754}
4755"#,
4756 )
4757 .unwrap();
4758
4759 dir
4760 }
4761
4762 fn setup_alias_project() -> TempDir {
4764 let dir = TempDir::new().unwrap();
4765
4766 fs::write(
4767 dir.path().join("main.ts"),
4768 r#"import { helper as h } from './utils';
4769
4770export function main() {
4771 return h(42);
4772}
4773"#,
4774 )
4775 .unwrap();
4776
4777 fs::write(
4778 dir.path().join("utils.ts"),
4779 r#"export function helper(x: number): number {
4780 return x + 1;
4781}
4782"#,
4783 )
4784 .unwrap();
4785
4786 dir
4787 }
4788
4789 fn setup_cycle_project() -> TempDir {
4791 let dir = TempDir::new().unwrap();
4792
4793 fs::write(
4794 dir.path().join("a.ts"),
4795 r#"import { funcB } from './b';
4796
4797export function funcA() {
4798 return funcB();
4799}
4800"#,
4801 )
4802 .unwrap();
4803
4804 fs::write(
4805 dir.path().join("b.ts"),
4806 r#"import { funcA } from './a';
4807
4808export function funcB() {
4809 return funcA();
4810}
4811"#,
4812 )
4813 .unwrap();
4814
4815 dir
4816 }
4817
4818 #[test]
4821 fn callgraph_single_file_call_extraction() {
4822 let dir = setup_ts_project();
4823 let mut graph = CallGraph::new(dir.path().to_path_buf());
4824
4825 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
4826 let main_calls = &file_data.calls_by_symbol["main"];
4827
4828 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
4829 assert!(
4830 callee_names.contains(&"helper"),
4831 "main should call helper, got: {:?}",
4832 callee_names
4833 );
4834 assert!(
4835 callee_names.contains(&"compute"),
4836 "main should call compute, got: {:?}",
4837 callee_names
4838 );
4839 assert!(
4840 callee_names.contains(&"add"),
4841 "main should call math.add (short name: add), got: {:?}",
4842 callee_names
4843 );
4844 }
4845
4846 #[test]
4847 fn callgraph_file_data_has_exports() {
4848 let dir = setup_ts_project();
4849 let mut graph = CallGraph::new(dir.path().to_path_buf());
4850
4851 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4852 assert!(
4853 file_data.exported_symbols.contains(&"helper".to_string()),
4854 "utils.ts should export helper, got: {:?}",
4855 file_data.exported_symbols
4856 );
4857 assert!(
4858 file_data.exported_symbols.contains(&"compute".to_string()),
4859 "utils.ts should export compute, got: {:?}",
4860 file_data.exported_symbols
4861 );
4862 }
4863
4864 #[test]
4867 fn callgraph_resolve_direct_import() {
4868 let dir = setup_ts_project();
4869 let mut graph = CallGraph::new(dir.path().to_path_buf());
4870
4871 let main_path = dir.path().join("main.ts");
4872 let file_data = graph.build_file(&main_path).unwrap();
4873 let import_block = file_data.import_block.clone();
4874
4875 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4876 match edge {
4877 EdgeResolution::Resolved { file, symbol } => {
4878 assert!(
4879 file.ends_with("utils.ts"),
4880 "helper should resolve to utils.ts, got: {:?}",
4881 file
4882 );
4883 assert_eq!(symbol, "helper");
4884 }
4885 EdgeResolution::Unresolved { callee_name } => {
4886 panic!("Expected resolved, got unresolved: {}", callee_name);
4887 }
4888 }
4889 }
4890
4891 #[test]
4892 fn callgraph_resolve_namespace_import() {
4893 let dir = setup_ts_project();
4894 let mut graph = CallGraph::new(dir.path().to_path_buf());
4895
4896 let main_path = dir.path().join("main.ts");
4897 let file_data = graph.build_file(&main_path).unwrap();
4898 let import_block = file_data.import_block.clone();
4899
4900 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4901 match edge {
4902 EdgeResolution::Resolved { file, symbol } => {
4903 assert!(
4904 file.ends_with("math.ts"),
4905 "math.add should resolve to math.ts, got: {:?}",
4906 file
4907 );
4908 assert_eq!(symbol, "add");
4909 }
4910 EdgeResolution::Unresolved { callee_name } => {
4911 panic!("Expected resolved, got unresolved: {}", callee_name);
4912 }
4913 }
4914 }
4915
4916 #[test]
4917 fn callgraph_resolve_aliased_import() {
4918 let dir = setup_alias_project();
4919 let mut graph = CallGraph::new(dir.path().to_path_buf());
4920
4921 let main_path = dir.path().join("main.ts");
4922 let file_data = graph.build_file(&main_path).unwrap();
4923 let import_block = file_data.import_block.clone();
4924
4925 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
4926 match edge {
4927 EdgeResolution::Resolved { file, symbol } => {
4928 assert!(
4929 file.ends_with("utils.ts"),
4930 "h (alias for helper) should resolve to utils.ts, got: {:?}",
4931 file
4932 );
4933 assert_eq!(symbol, "helper");
4934 }
4935 EdgeResolution::Unresolved { callee_name } => {
4936 panic!("Expected resolved, got unresolved: {}", callee_name);
4937 }
4938 }
4939 }
4940
4941 #[test]
4942 fn callgraph_unresolved_edge_marked() {
4943 let dir = setup_ts_project();
4944 let mut graph = CallGraph::new(dir.path().to_path_buf());
4945
4946 let main_path = dir.path().join("main.ts");
4947 let file_data = graph.build_file(&main_path).unwrap();
4948 let import_block = file_data.import_block.clone();
4949
4950 let edge =
4951 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
4952 assert_eq!(
4953 edge,
4954 EdgeResolution::Unresolved {
4955 callee_name: "unknownFunc".to_string()
4956 },
4957 "Unknown callee should be unresolved"
4958 );
4959 }
4960
4961 #[test]
4964 fn callgraph_cycle_detection_stops() {
4965 let dir = setup_cycle_project();
4966 let mut graph = CallGraph::new(dir.path().to_path_buf());
4967
4968 let tree = graph
4970 .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
4971 .unwrap();
4972
4973 assert_eq!(tree.name, "funcA");
4974 assert!(tree.resolved);
4975
4976 fn count_depth(node: &CallTreeNode) -> usize {
4979 if node.children.is_empty() {
4980 1
4981 } else {
4982 1 + node.children.iter().map(count_depth).max().unwrap_or(0)
4983 }
4984 }
4985
4986 let depth = count_depth(&tree);
4987 assert!(
4988 depth <= 4,
4989 "Cycle should be detected and bounded, depth was: {}",
4990 depth
4991 );
4992 }
4993
4994 #[test]
4997 fn callgraph_depth_limit_truncates() {
4998 let dir = setup_ts_project();
4999 let mut graph = CallGraph::new(dir.path().to_path_buf());
5000
5001 let tree = graph
5004 .forward_tree(&dir.path().join("main.ts"), "main", 1)
5005 .unwrap();
5006
5007 assert_eq!(tree.name, "main");
5008 assert!(tree.depth_limited, "depth limit should be reported");
5009 assert!(
5010 tree.truncated > 0,
5011 "truncated edge count should be reported"
5012 );
5013
5014 for child in &tree.children {
5016 assert!(
5017 child.children.is_empty(),
5018 "At depth 1, child '{}' should have no children, got {:?}",
5019 child.name,
5020 child.children.len()
5021 );
5022 }
5023 }
5024
5025 #[test]
5026 fn callgraph_depth_zero_no_children() {
5027 let dir = setup_ts_project();
5028 let mut graph = CallGraph::new(dir.path().to_path_buf());
5029
5030 let tree = graph
5031 .forward_tree(&dir.path().join("main.ts"), "main", 0)
5032 .unwrap();
5033
5034 assert_eq!(tree.name, "main");
5035 assert!(
5036 tree.children.is_empty(),
5037 "At depth 0, should have no children"
5038 );
5039 }
5040
5041 #[test]
5044 fn callgraph_forward_tree_cross_file() {
5045 let dir = setup_ts_project();
5046 let mut graph = CallGraph::new(dir.path().to_path_buf());
5047
5048 let tree = graph
5050 .forward_tree(&dir.path().join("main.ts"), "main", 5)
5051 .unwrap();
5052
5053 assert_eq!(tree.name, "main");
5054 assert!(tree.resolved);
5055
5056 let helper_child = tree.children.iter().find(|c| c.name == "helper");
5058 assert!(
5059 helper_child.is_some(),
5060 "main should have helper as child, children: {:?}",
5061 tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5062 );
5063
5064 let helper = helper_child.unwrap();
5065 assert!(
5066 helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
5067 "helper should be in utils.ts, got: {}",
5068 helper.file
5069 );
5070
5071 let double_child = helper.children.iter().find(|c| c.name == "double");
5073 assert!(
5074 double_child.is_some(),
5075 "helper should call double, children: {:?}",
5076 helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5077 );
5078
5079 let double = double_child.unwrap();
5080 assert!(
5081 double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
5082 "double should be in helpers.ts, got: {}",
5083 double.file
5084 );
5085 }
5086
5087 #[test]
5090 fn callgraph_walker_excludes_gitignored() {
5091 let dir = TempDir::new().unwrap();
5092
5093 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
5095
5096 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5098 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
5099 fs::write(
5100 dir.path().join("ignored_dir").join("secret.ts"),
5101 "export function secret() {}",
5102 )
5103 .unwrap();
5104
5105 fs::create_dir(dir.path().join("node_modules")).unwrap();
5107 fs::write(
5108 dir.path().join("node_modules").join("dep.ts"),
5109 "export function dep() {}",
5110 )
5111 .unwrap();
5112
5113 std::process::Command::new("git")
5115 .args(["init"])
5116 .current_dir(dir.path())
5117 .output()
5118 .unwrap();
5119
5120 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5121 let file_names: Vec<String> = files
5122 .iter()
5123 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5124 .collect();
5125
5126 assert!(
5127 file_names.contains(&"main.ts".to_string()),
5128 "Should include main.ts, got: {:?}",
5129 file_names
5130 );
5131 assert!(
5132 !file_names.contains(&"secret.ts".to_string()),
5133 "Should exclude gitignored secret.ts, got: {:?}",
5134 file_names
5135 );
5136 assert!(
5137 !file_names.contains(&"dep.ts".to_string()),
5138 "Should exclude node_modules, got: {:?}",
5139 file_names
5140 );
5141 }
5142
5143 #[test]
5144 fn callgraph_walker_excludes_aftignored() {
5145 let dir = TempDir::new().unwrap();
5146
5147 fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
5149 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5150 fs::create_dir(dir.path().join("vendored")).unwrap();
5151 fs::write(
5152 dir.path().join("vendored").join("sub.ts"),
5153 "export function sub() {}",
5154 )
5155 .unwrap();
5156
5157 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5158 let file_names: Vec<String> = files
5159 .iter()
5160 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5161 .collect();
5162
5163 assert!(
5164 file_names.contains(&"main.ts".to_string()),
5165 "Should include main.ts, got: {:?}",
5166 file_names
5167 );
5168 assert!(
5169 !file_names.contains(&"sub.ts".to_string()),
5170 "Should exclude .aftignored sub.ts, got: {:?}",
5171 file_names
5172 );
5173 }
5174
5175 #[test]
5176 fn callgraph_walker_only_source_files() {
5177 let dir = TempDir::new().unwrap();
5178
5179 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5180 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
5181 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
5182 fs::write(
5183 dir.path().join("runtime.mjs"),
5184 "export function runtime() {}",
5185 )
5186 .unwrap();
5187 fs::write(
5188 dir.path().join("legacy.cjs"),
5189 "exports.legacy = function() {};",
5190 )
5191 .unwrap();
5192 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
5193 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
5194 fs::write(dir.path().join("data.json"), "{}").unwrap();
5195
5196 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5197 let file_names: Vec<String> = files
5198 .iter()
5199 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5200 .collect();
5201
5202 assert!(file_names.contains(&"main.ts".to_string()));
5203 for modern_ext_file in [
5204 "module.mts",
5205 "common.cts",
5206 "runtime.mjs",
5207 "legacy.cjs",
5208 "types.pyi",
5209 ] {
5210 assert!(
5211 file_names.contains(&modern_ext_file.to_string()),
5212 "walker should include {modern_ext_file}, got: {:?}",
5213 file_names
5214 );
5215 }
5216 assert!(
5217 file_names.contains(&"readme.md".to_string()),
5218 "Markdown is now a supported source language"
5219 );
5220 assert!(
5221 file_names.contains(&"data.json".to_string()),
5222 "JSON is now a supported source language"
5223 );
5224 }
5225
5226 #[test]
5229 fn callgraph_find_alias_original_simple() {
5230 let raw = "import { foo as bar } from './utils';";
5231 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5232 }
5233
5234 #[test]
5235 fn callgraph_find_alias_original_multiple() {
5236 let raw = "import { foo as bar, baz as qux } from './utils';";
5237 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5238 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
5239 }
5240
5241 #[test]
5242 fn callgraph_find_alias_no_match() {
5243 let raw = "import { foo } from './utils';";
5244 assert_eq!(find_alias_original(raw, "foo"), None);
5245 }
5246
5247 #[test]
5250 fn callgraph_callers_of_direct() {
5251 let dir = setup_ts_project();
5252 let mut graph = CallGraph::new(dir.path().to_path_buf());
5253
5254 let result = graph
5256 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5257 .unwrap();
5258
5259 assert_eq!(result.symbol, "double");
5260 assert!(result.total_callers > 0, "double should have callers");
5261 assert!(result.scanned_files > 0, "should have scanned files");
5262
5263 let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
5265 assert!(
5266 utils_group.is_some(),
5267 "double should be called from utils.ts, groups: {:?}",
5268 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5269 );
5270
5271 let group = utils_group.unwrap();
5272 let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
5273 assert!(
5274 helper_caller.is_some(),
5275 "double should be called by helper, callers: {:?}",
5276 group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
5277 );
5278 }
5279
5280 #[test]
5281 fn callgraph_callers_of_no_callers() {
5282 let dir = setup_ts_project();
5283 let mut graph = CallGraph::new(dir.path().to_path_buf());
5284
5285 let result = graph
5287 .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
5288 .unwrap();
5289
5290 assert_eq!(result.symbol, "main");
5291 assert_eq!(result.total_callers, 0, "main should have no callers");
5292 assert!(result.callers.is_empty());
5293 }
5294
5295 #[test]
5296 fn callgraph_callers_recursive_depth() {
5297 let dir = setup_ts_project();
5298 let mut graph = CallGraph::new(dir.path().to_path_buf());
5299
5300 let result = graph
5304 .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
5305 .unwrap();
5306
5307 assert!(
5308 result.total_callers >= 2,
5309 "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
5310 result.total_callers
5311 );
5312
5313 let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
5315 assert!(
5316 main_group.is_some(),
5317 "recursive callers should include main.ts, groups: {:?}",
5318 result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5319 );
5320 }
5321
5322 #[test]
5323 fn callgraph_invalidate_file_clears_reverse_index() {
5324 let dir = setup_ts_project();
5325 let mut graph = CallGraph::new(dir.path().to_path_buf());
5326
5327 let _ = graph
5329 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5330 .unwrap();
5331 assert!(
5332 graph.reverse_index.is_some(),
5333 "reverse index should be built"
5334 );
5335
5336 graph.invalidate_file(&dir.path().join("utils.ts"));
5338
5339 assert!(
5341 graph.reverse_index.is_none(),
5342 "invalidate_file should clear reverse index"
5343 );
5344 let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
5346 assert!(
5347 !graph.data.contains_key(&canon),
5348 "invalidate_file should remove file from data cache"
5349 );
5350 assert!(
5352 graph.project_files.is_none(),
5353 "invalidate_file should clear project_files"
5354 );
5355 }
5356
5357 #[test]
5360 fn is_entry_point_exported_function() {
5361 assert!(is_entry_point(
5362 "handleRequest",
5363 &SymbolKind::Function,
5364 true,
5365 LangId::TypeScript
5366 ));
5367 }
5368
5369 #[test]
5370 fn is_entry_point_exported_method_is_not_entry() {
5371 assert!(!is_entry_point(
5373 "handleRequest",
5374 &SymbolKind::Method,
5375 true,
5376 LangId::TypeScript
5377 ));
5378 }
5379
5380 #[test]
5381 fn is_entry_point_main_init_patterns() {
5382 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
5383 assert!(
5384 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
5385 "{} should be an entry point",
5386 name
5387 );
5388 }
5389 }
5390
5391 #[test]
5392 fn is_entry_point_test_patterns_ts() {
5393 assert!(is_entry_point(
5394 "describe",
5395 &SymbolKind::Function,
5396 false,
5397 LangId::TypeScript
5398 ));
5399 assert!(is_entry_point(
5400 "it",
5401 &SymbolKind::Function,
5402 false,
5403 LangId::TypeScript
5404 ));
5405 assert!(is_entry_point(
5406 "test",
5407 &SymbolKind::Function,
5408 false,
5409 LangId::TypeScript
5410 ));
5411 assert!(is_entry_point(
5412 "testValidation",
5413 &SymbolKind::Function,
5414 false,
5415 LangId::TypeScript
5416 ));
5417 assert!(is_entry_point(
5418 "specHelper",
5419 &SymbolKind::Function,
5420 false,
5421 LangId::TypeScript
5422 ));
5423 }
5424
5425 #[test]
5426 fn is_entry_point_test_patterns_python() {
5427 assert!(is_entry_point(
5428 "test_login",
5429 &SymbolKind::Function,
5430 false,
5431 LangId::Python
5432 ));
5433 assert!(is_entry_point(
5434 "setUp",
5435 &SymbolKind::Function,
5436 false,
5437 LangId::Python
5438 ));
5439 assert!(is_entry_point(
5440 "tearDown",
5441 &SymbolKind::Function,
5442 false,
5443 LangId::Python
5444 ));
5445 assert!(!is_entry_point(
5447 "testSomething",
5448 &SymbolKind::Function,
5449 false,
5450 LangId::Python
5451 ));
5452 }
5453
5454 #[test]
5455 fn is_entry_point_test_patterns_rust() {
5456 assert!(is_entry_point(
5457 "test_parse",
5458 &SymbolKind::Function,
5459 false,
5460 LangId::Rust
5461 ));
5462 assert!(!is_entry_point(
5463 "TestSomething",
5464 &SymbolKind::Function,
5465 false,
5466 LangId::Rust
5467 ));
5468 }
5469
5470 #[test]
5471 fn is_entry_point_test_patterns_go() {
5472 assert!(is_entry_point(
5473 "TestParsing",
5474 &SymbolKind::Function,
5475 false,
5476 LangId::Go
5477 ));
5478 assert!(!is_entry_point(
5480 "testParsing",
5481 &SymbolKind::Function,
5482 false,
5483 LangId::Go
5484 ));
5485 }
5486
5487 #[test]
5488 fn is_entry_point_non_exported_non_main_is_not_entry() {
5489 assert!(!is_entry_point(
5490 "helperUtil",
5491 &SymbolKind::Function,
5492 false,
5493 LangId::TypeScript
5494 ));
5495 }
5496
5497 #[test]
5500 fn callgraph_symbol_metadata_populated() {
5501 let dir = setup_ts_project();
5502 let mut graph = CallGraph::new(dir.path().to_path_buf());
5503
5504 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
5505 assert!(
5506 file_data.symbol_metadata.contains_key("helper"),
5507 "symbol_metadata should contain helper"
5508 );
5509 let meta = &file_data.symbol_metadata["helper"];
5510 assert_eq!(meta.kind, SymbolKind::Function);
5511 assert!(meta.exported, "helper should be exported");
5512 }
5513
5514 fn setup_trace_project() -> TempDir {
5530 let dir = TempDir::new().unwrap();
5531
5532 fs::write(
5533 dir.path().join("main.ts"),
5534 r#"import { processData } from './utils';
5535
5536export function main() {
5537 const result = processData("hello");
5538 return result;
5539}
5540"#,
5541 )
5542 .unwrap();
5543
5544 fs::write(
5545 dir.path().join("service.ts"),
5546 r#"import { processData } from './utils';
5547
5548export function handleRequest(input: string): string {
5549 return processData(input);
5550}
5551"#,
5552 )
5553 .unwrap();
5554
5555 fs::write(
5556 dir.path().join("utils.ts"),
5557 r#"import { validate } from './helpers';
5558
5559export function processData(input: string): string {
5560 const valid = validate(input);
5561 if (!valid) {
5562 throw new Error("invalid input");
5563 }
5564 return input.toUpperCase();
5565}
5566"#,
5567 )
5568 .unwrap();
5569
5570 fs::write(
5571 dir.path().join("helpers.ts"),
5572 r#"export function validate(input: string): boolean {
5573 return checkFormat(input);
5574}
5575
5576function checkFormat(input: string): boolean {
5577 return input.length > 0 && /^[a-zA-Z]+$/.test(input);
5578}
5579"#,
5580 )
5581 .unwrap();
5582
5583 fs::write(
5584 dir.path().join("test_helpers.ts"),
5585 r#"import { validate } from './helpers';
5586
5587function testValidation() {
5588 const result = validate("hello");
5589 console.log(result);
5590}
5591"#,
5592 )
5593 .unwrap();
5594
5595 std::process::Command::new("git")
5597 .args(["init"])
5598 .current_dir(dir.path())
5599 .output()
5600 .unwrap();
5601
5602 dir
5603 }
5604
5605 #[test]
5606 fn trace_to_multi_path() {
5607 let dir = setup_trace_project();
5608 let mut graph = CallGraph::new(dir.path().to_path_buf());
5609
5610 let result = graph
5611 .trace_to(
5612 &dir.path().join("helpers.ts"),
5613 "checkFormat",
5614 10,
5615 usize::MAX,
5616 )
5617 .unwrap();
5618
5619 assert_eq!(result.target_symbol, "checkFormat");
5620 assert!(
5621 result.total_paths >= 2,
5622 "checkFormat should have at least 2 paths, got {} (paths: {:?})",
5623 result.total_paths,
5624 result
5625 .paths
5626 .iter()
5627 .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
5628 .collect::<Vec<_>>()
5629 );
5630
5631 for path in &result.paths {
5633 assert!(
5634 path.hops.first().unwrap().is_entry_point,
5635 "First hop should be an entry point, got: {}",
5636 path.hops.first().unwrap().symbol
5637 );
5638 assert_eq!(
5639 path.hops.last().unwrap().symbol,
5640 "checkFormat",
5641 "Last hop should be checkFormat"
5642 );
5643 }
5644
5645 assert!(
5647 result.entry_points_found >= 2,
5648 "should find at least 2 entry points, got {}",
5649 result.entry_points_found
5650 );
5651 }
5652
5653 #[test]
5654 fn trace_to_single_path() {
5655 let dir = setup_trace_project();
5656 let mut graph = CallGraph::new(dir.path().to_path_buf());
5657
5658 let result = graph
5662 .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
5663 .unwrap();
5664
5665 assert_eq!(result.target_symbol, "validate");
5666 assert!(
5667 result.total_paths >= 2,
5668 "validate should have at least 2 paths, got {}",
5669 result.total_paths
5670 );
5671 }
5672
5673 #[test]
5674 fn trace_to_cycle_detection() {
5675 let dir = setup_cycle_project();
5676 let mut graph = CallGraph::new(dir.path().to_path_buf());
5677
5678 let result = graph
5680 .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
5681 .unwrap();
5682
5683 assert_eq!(result.target_symbol, "funcA");
5685 }
5686
5687 #[test]
5688 fn trace_to_depth_limit() {
5689 let dir = setup_trace_project();
5690 let mut graph = CallGraph::new(dir.path().to_path_buf());
5691
5692 let result = graph
5694 .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
5695 .unwrap();
5696
5697 assert_eq!(result.target_symbol, "checkFormat");
5701
5702 let deep_result = graph
5704 .trace_to(
5705 &dir.path().join("helpers.ts"),
5706 "checkFormat",
5707 10,
5708 usize::MAX,
5709 )
5710 .unwrap();
5711
5712 assert!(
5713 result.total_paths <= deep_result.total_paths,
5714 "shallow trace should find <= paths compared to deep: {} vs {}",
5715 result.total_paths,
5716 deep_result.total_paths
5717 );
5718 }
5719
5720 #[test]
5721 fn trace_to_entry_point_target() {
5722 let dir = setup_trace_project();
5723 let mut graph = CallGraph::new(dir.path().to_path_buf());
5724
5725 let result = graph
5727 .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
5728 .unwrap();
5729
5730 assert_eq!(result.target_symbol, "main");
5731 assert!(
5732 result.total_paths >= 1,
5733 "main should have at least 1 path (itself), got {}",
5734 result.total_paths
5735 );
5736 let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
5738 assert!(
5739 trivial.is_some(),
5740 "should have a trivial path with just the entry point itself"
5741 );
5742 }
5743
5744 #[test]
5745 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
5746 let dir = TempDir::new().unwrap();
5747 fs::write(
5748 dir.path().join("main.ts"),
5749 r#"import * as lib from './index';
5750
5751export function main() {
5752 lib.helper();
5753 lib.hidden();
5754}
5755"#,
5756 )
5757 .unwrap();
5758 fs::write(
5759 dir.path().join("index.ts"),
5760 "export { helper } from './utils';\n",
5761 )
5762 .unwrap();
5763 fs::write(
5764 dir.path().join("utils.ts"),
5765 r#"export function helper() {}
5766function hidden() {}
5767"#,
5768 )
5769 .unwrap();
5770
5771 let mut graph = CallGraph::new(dir.path().to_path_buf());
5772 let main_path = dir.path().join("main.ts");
5773 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
5774
5775 let helper =
5776 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
5777 match helper {
5778 EdgeResolution::Resolved { file, symbol } => {
5779 assert!(
5780 file.ends_with("utils.ts"),
5781 "helper should resolve through barrel: {file:?}"
5782 );
5783 assert_eq!(symbol, "helper");
5784 }
5785 other => panic!("expected helper to resolve through barrel, got {other:?}"),
5786 }
5787
5788 let hidden =
5789 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
5790 assert_eq!(
5791 hidden,
5792 EdgeResolution::Unresolved {
5793 callee_name: "hidden".to_string()
5794 }
5795 );
5796 }
5797
5798 #[test]
5799 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
5800 let dir = TempDir::new().unwrap();
5801 fs::write(
5802 dir.path().join("package.json"),
5803 r#"{"workspaces":["packages/*"]}"#,
5804 )
5805 .unwrap();
5806 let package_dir = dir.path().join("packages/lib");
5807 fs::create_dir_all(package_dir.join("src")).unwrap();
5808 fs::create_dir_all(package_dir.join("dist")).unwrap();
5809 fs::write(
5810 package_dir.join("package.json"),
5811 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
5812 )
5813 .unwrap();
5814 fs::write(
5815 package_dir.join("src/index.mts"),
5816 "export function helper() {}\n",
5817 )
5818 .unwrap();
5819 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
5820
5821 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
5822 assert!(
5823 resolved.ends_with("src/index.mts"),
5824 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
5825 );
5826 }
5827
5828 #[test]
5829 fn unresolved_member_calls_do_not_become_same_file_callers() {
5830 let dir = TempDir::new().unwrap();
5831 fs::write(
5832 dir.path().join("main.ts"),
5833 r#"function caller() {
5834 db.connect();
5835}
5836
5837function connect() {}
5838"#,
5839 )
5840 .unwrap();
5841
5842 let mut graph = CallGraph::new(dir.path().to_path_buf());
5843 let result = graph
5844 .callers_of(&dir.path().join("main.ts"), "connect", 1, usize::MAX)
5845 .unwrap();
5846
5847 assert_eq!(
5848 result.total_callers, 0,
5849 "db.connect() must not call local connect"
5850 );
5851 }
5852
5853 #[test]
5854 fn same_named_methods_use_scoped_symbol_identity() {
5855 let dir = TempDir::new().unwrap();
5856 fs::write(
5857 dir.path().join("classes.ts"),
5858 r#"class A {
5859 run() { helperA(); }
5860}
5861
5862class B {
5863 run() { helperB(); }
5864}
5865
5866function helperA() {}
5867function helperB() {}
5868"#,
5869 )
5870 .unwrap();
5871
5872 let mut graph = CallGraph::new(dir.path().to_path_buf());
5873 let path = dir.path().join("classes.ts");
5874 let data = graph.build_file(&path).unwrap();
5875
5876 assert!(
5877 data.symbol_metadata.contains_key("A::run"),
5878 "A::run metadata missing"
5879 );
5880 assert!(
5881 data.symbol_metadata.contains_key("B::run"),
5882 "B::run metadata missing"
5883 );
5884 assert!(
5885 data.calls_by_symbol["A::run"]
5886 .iter()
5887 .any(|call| call.callee_name == "helperA"),
5888 "A::run calls should not be overwritten"
5889 );
5890 assert!(
5891 data.calls_by_symbol["B::run"]
5892 .iter()
5893 .any(|call| call.callee_name == "helperB"),
5894 "B::run calls should not be overwritten"
5895 );
5896
5897 assert!(matches!(
5898 graph.resolve_symbol_query(&path, "run"),
5899 Err(AftError::AmbiguousSymbol { .. })
5900 ));
5901 assert_eq!(
5902 graph.resolve_symbol_query(&path, "A::run").unwrap(),
5903 "A::run"
5904 );
5905 }
5906
5907 #[test]
5908 fn trace_to_counts_same_named_entry_points_by_file_and_symbol() {
5909 let dir = TempDir::new().unwrap();
5910 fs::create_dir_all(dir.path().join("web")).unwrap();
5911 fs::create_dir_all(dir.path().join("cli")).unwrap();
5912 fs::write(
5913 dir.path().join("target.ts"),
5914 r#"export function target() {
5915 leaf();
5916}
5917
5918function leaf() {}
5919"#,
5920 )
5921 .unwrap();
5922 fs::write(
5923 dir.path().join("web/main.ts"),
5924 r#"import { target } from '../target';
5925
5926export function main() {
5927 target();
5928}
5929"#,
5930 )
5931 .unwrap();
5932 fs::write(
5933 dir.path().join("cli/main.ts"),
5934 r#"import { target } from '../target';
5935
5936export function main() {
5937 target();
5938}
5939"#,
5940 )
5941 .unwrap();
5942
5943 let mut graph = CallGraph::new(dir.path().to_path_buf());
5944 let result = graph
5945 .trace_to(&dir.path().join("target.ts"), "leaf", 10, usize::MAX)
5946 .unwrap();
5947
5948 assert_eq!(
5949 result.total_paths, 3,
5950 "target plus two main entry paths expected"
5951 );
5952 assert_eq!(
5953 result.entry_points_found, 3,
5954 "same-named main entry points in different files must both count"
5955 );
5956 }
5957
5958 #[test]
5959 fn callers_and_impact_report_depth_truncation() {
5960 let dir = setup_ts_project();
5961 let mut graph = CallGraph::new(dir.path().to_path_buf());
5962
5963 let callers = graph
5964 .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5965 .unwrap();
5966 assert!(
5967 callers.depth_limited,
5968 "callers should report omitted transitive callers"
5969 );
5970 assert!(
5971 callers.truncated > 0,
5972 "callers should report truncated edge count"
5973 );
5974
5975 let impact = graph
5976 .impact(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5977 .unwrap();
5978 assert!(
5979 impact.depth_limited,
5980 "impact should report omitted transitive callers"
5981 );
5982 assert!(
5983 impact.truncated > 0,
5984 "impact should report truncated edge count"
5985 );
5986 }
5987
5988 #[test]
5991 fn extract_parameters_typescript() {
5992 let params = extract_parameters(
5993 "function processData(input: string, count: number): void",
5994 LangId::TypeScript,
5995 );
5996 assert_eq!(params, vec!["input", "count"]);
5997 }
5998
5999 #[test]
6000 fn extract_parameters_typescript_optional() {
6001 let params = extract_parameters(
6002 "function fetch(url: string, options?: RequestInit): Promise<Response>",
6003 LangId::TypeScript,
6004 );
6005 assert_eq!(params, vec!["url", "options"]);
6006 }
6007
6008 #[test]
6009 fn extract_parameters_typescript_defaults() {
6010 let params = extract_parameters(
6011 "function greet(name: string, greeting: string = \"hello\"): string",
6012 LangId::TypeScript,
6013 );
6014 assert_eq!(params, vec!["name", "greeting"]);
6015 }
6016
6017 #[test]
6018 fn extract_parameters_typescript_rest() {
6019 let params = extract_parameters(
6020 "function sum(...numbers: number[]): number",
6021 LangId::TypeScript,
6022 );
6023 assert_eq!(params, vec!["numbers"]);
6024 }
6025
6026 #[test]
6027 fn extract_parameters_python_self_skipped() {
6028 let params = extract_parameters(
6029 "def process(self, data: str, count: int) -> bool",
6030 LangId::Python,
6031 );
6032 assert_eq!(params, vec!["data", "count"]);
6033 }
6034
6035 #[test]
6036 fn extract_parameters_python_no_self() {
6037 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
6038 assert_eq!(params, vec!["input"]);
6039 }
6040
6041 #[test]
6042 fn extract_parameters_python_star_args() {
6043 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
6044 assert_eq!(params, vec!["args", "kwargs"]);
6045 }
6046
6047 #[test]
6048 fn extract_parameters_rust_self_skipped() {
6049 let params = extract_parameters(
6050 "fn process(&self, data: &str, count: usize) -> bool",
6051 LangId::Rust,
6052 );
6053 assert_eq!(params, vec!["data", "count"]);
6054 }
6055
6056 #[test]
6057 fn extract_parameters_rust_mut_self_skipped() {
6058 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
6059 assert_eq!(params, vec!["value"]);
6060 }
6061
6062 #[test]
6063 fn extract_parameters_rust_no_self() {
6064 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
6065 assert_eq!(params, vec!["input"]);
6066 }
6067
6068 #[test]
6069 fn extract_parameters_rust_mut_param() {
6070 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
6071 assert_eq!(params, vec!["buf", "len"]);
6072 }
6073
6074 #[test]
6075 fn extract_parameters_go() {
6076 let params = extract_parameters(
6077 "func ProcessData(input string, count int) error",
6078 LangId::Go,
6079 );
6080 assert_eq!(params, vec!["input", "count"]);
6081 }
6082
6083 #[test]
6084 fn extract_parameters_empty() {
6085 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
6086 assert!(
6087 params.is_empty(),
6088 "no-arg function should return empty params"
6089 );
6090 }
6091
6092 #[test]
6093 fn extract_parameters_no_parens() {
6094 let params = extract_parameters("const x = 42", LangId::TypeScript);
6095 assert!(params.is_empty(), "no parens should return empty params");
6096 }
6097
6098 #[test]
6099 fn extract_parameters_javascript() {
6100 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
6101 assert_eq!(params, vec!["event", "target"]);
6102 }
6103}