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