1use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use serde::Serialize;
14use serde_json::Value;
15use tree_sitter::{Node, Parser};
16
17#[cfg(test)]
18use crate::calls::{call_node_kinds, extract_callee_name, extract_full_callee};
19use crate::calls::{extract_calls_full, extract_rust_value_references};
20#[cfg(test)]
21use crate::edit::line_col_to_byte;
22use crate::error::AftError;
23use crate::imports::{self, ImportBlock};
24use crate::parser::{detect_language, grammar_for, LangId};
25use crate::symbols::{Range, Symbol, SymbolKind};
26
27type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
32type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
33type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36 LazyLock::new(|| RwLock::new(HashMap::new()));
37static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
38 LazyLock::new(|| RwLock::new(HashMap::new()));
39static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
40 LazyLock::new(|| RwLock::new(HashMap::new()));
41
42const MODULE_RESOLUTION_MEMO_MAX_ENTRIES: usize = 32_768;
46const MODULE_RESOLUTION_MEMO_MAX_RETAINED_BYTES: usize = 4 * 1024 * 1024;
47const JSON_VALUE_MEMO_MAX_ENTRIES: usize = 8_192;
48const JSON_VALUE_MEMO_MAX_RETAINED_BYTES: usize = 4 * 1024 * 1024;
49const WORKSPACE_PACKAGE_MEMO_MAX_ENTRIES: usize = 2_048;
50const WORKSPACE_PACKAGE_MEMO_MAX_RETAINED_BYTES: usize = 1024 * 1024;
51const MEMO_ENTRY_OVERHEAD_BYTES: usize = 128;
52
53type ModuleResolutionKey = (PathBuf, String);
54type WorkspacePackageKey = (PathBuf, String);
55
56struct BoundedMemo<K, V> {
57 entries: HashMap<K, V>,
58 retained_weight: usize,
59 max_entries: usize,
60 max_retained_weight: usize,
61}
62
63impl<K: Eq + std::hash::Hash, V> BoundedMemo<K, V> {
64 fn new(max_entries: usize, max_retained_weight: usize) -> Self {
65 Self {
66 entries: HashMap::new(),
67 retained_weight: 0,
68 max_entries,
69 max_retained_weight,
70 }
71 }
72
73 fn get<Q>(&self, key: &Q) -> Option<&V>
74 where
75 K: std::borrow::Borrow<Q>,
76 Q: Eq + std::hash::Hash + ?Sized,
77 {
78 self.entries.get(key)
79 }
80
81 fn insert(&mut self, key: K, value: V, retained_weight: usize) {
82 if self.entries.contains_key(&key)
83 || self.entries.len() >= self.max_entries
84 || self.retained_weight.saturating_add(retained_weight) > self.max_retained_weight
85 {
86 return;
87 }
88 self.retained_weight += retained_weight;
89 self.entries.insert(key, value);
90 }
91}
92
93pub(crate) struct ModuleResolutionMemo {
98 enabled: bool,
99 module_paths: RefCell<BoundedMemo<ModuleResolutionKey, Option<PathBuf>>>,
100 json_values: RefCell<BoundedMemo<PathBuf, Option<Arc<Value>>>>,
101 workspace_packages: RefCell<BoundedMemo<WorkspacePackageKey, Option<PathBuf>>>,
102 #[cfg(test)]
103 collect_metrics: bool,
104 #[cfg(test)]
105 module_computations: RefCell<HashMap<ModuleResolutionKey, usize>>,
106 #[cfg(test)]
107 json_probes: RefCell<HashMap<PathBuf, usize>>,
108}
109
110impl Default for ModuleResolutionMemo {
111 fn default() -> Self {
112 Self {
113 enabled: true,
114 module_paths: RefCell::new(BoundedMemo::new(
115 MODULE_RESOLUTION_MEMO_MAX_ENTRIES,
116 MODULE_RESOLUTION_MEMO_MAX_RETAINED_BYTES,
117 )),
118 json_values: RefCell::new(BoundedMemo::new(
119 JSON_VALUE_MEMO_MAX_ENTRIES,
120 JSON_VALUE_MEMO_MAX_RETAINED_BYTES,
121 )),
122 workspace_packages: RefCell::new(BoundedMemo::new(
123 WORKSPACE_PACKAGE_MEMO_MAX_ENTRIES,
124 WORKSPACE_PACKAGE_MEMO_MAX_RETAINED_BYTES,
125 )),
126 #[cfg(test)]
127 collect_metrics: false,
128 #[cfg(test)]
129 module_computations: RefCell::new(HashMap::new()),
130 #[cfg(test)]
131 json_probes: RefCell::new(HashMap::new()),
132 }
133 }
134}
135
136impl ModuleResolutionMemo {
137 fn resolve_module_path(&self, from_dir: &Path, module_path: &str) -> Option<PathBuf> {
138 let key = (from_dir.to_path_buf(), module_path.to_string());
139 if self.enabled {
140 if let Some(cached) = self.module_paths.borrow().get(&key) {
141 return cached.clone();
142 }
143 }
144
145 self.note_module_computation(&key);
146 let resolved = resolve_module_path_uncached(from_dir, module_path, Some(self));
147 if self.enabled {
148 let retained_weight = module_resolution_entry_weight(&key, resolved.as_deref());
149 self.module_paths
150 .borrow_mut()
151 .insert(key, resolved.clone(), retained_weight);
152 }
153 resolved
154 }
155
156 fn json_value(&self, path: &Path) -> Option<Arc<Value>> {
157 if self.enabled {
158 if let Some(cached) = self.json_values.borrow().get(path) {
159 return cached.clone();
160 }
161 }
162
163 self.note_json_probe(path);
164 let parsed = std::fs::read_to_string(path)
165 .ok()
166 .and_then(|source| serde_json::from_str(&source).ok())
167 .map(Arc::new);
168 if self.enabled {
169 let retained_weight = MEMO_ENTRY_OVERHEAD_BYTES
170 + path_retained_weight(path)
171 + parsed
172 .as_deref()
173 .map(json_retained_weight)
174 .unwrap_or_default();
175 self.json_values.borrow_mut().insert(
176 path.to_path_buf(),
177 parsed.clone(),
178 retained_weight,
179 );
180 }
181 parsed
182 }
183
184 fn workspace_package(&self, key: &WorkspacePackageKey) -> Option<Option<PathBuf>> {
185 self.enabled
186 .then(|| self.workspace_packages.borrow().get(key).cloned())
187 .flatten()
188 }
189
190 fn remember_workspace_package(&self, key: WorkspacePackageKey, resolved: Option<PathBuf>) {
191 if !self.enabled {
192 return;
193 }
194 let retained_weight = module_resolution_entry_weight(&key, resolved.as_deref());
195 self.workspace_packages
196 .borrow_mut()
197 .insert(key, resolved, retained_weight);
198 }
199
200 #[cfg(test)]
201 pub(crate) fn new_for_test(enabled: bool, collect_metrics: bool) -> Self {
202 Self {
203 enabled,
204 collect_metrics,
205 ..Self::default()
206 }
207 }
208
209 #[cfg(test)]
210 pub(crate) fn module_computations_for_test(&self) -> HashMap<ModuleResolutionKey, usize> {
211 self.module_computations.borrow().clone()
212 }
213
214 #[cfg(test)]
215 pub(crate) fn json_probes_for_test(&self) -> HashMap<PathBuf, usize> {
216 self.json_probes.borrow().clone()
217 }
218
219 #[cfg(test)]
220 fn note_module_computation(&self, key: &ModuleResolutionKey) {
221 if self.collect_metrics {
222 *self
223 .module_computations
224 .borrow_mut()
225 .entry(key.clone())
226 .or_default() += 1;
227 }
228 }
229
230 #[cfg(not(test))]
231 fn note_module_computation(&self, _key: &ModuleResolutionKey) {}
232
233 #[cfg(test)]
234 fn note_json_probe(&self, path: &Path) {
235 if self.collect_metrics {
236 *self
237 .json_probes
238 .borrow_mut()
239 .entry(path.to_path_buf())
240 .or_default() += 1;
241 }
242 }
243
244 #[cfg(not(test))]
245 fn note_json_probe(&self, _path: &Path) {}
246}
247
248fn module_resolution_entry_weight(key: &(PathBuf, String), resolved: Option<&Path>) -> usize {
249 MEMO_ENTRY_OVERHEAD_BYTES
250 + path_retained_weight(&key.0)
251 + key.1.len()
252 + resolved.map(path_retained_weight).unwrap_or_default()
253}
254
255fn path_retained_weight(path: &Path) -> usize {
256 path.to_string_lossy().len()
257}
258
259fn json_retained_weight(value: &Value) -> usize {
260 std::mem::size_of::<Value>()
261 + match value {
262 Value::Null | Value::Bool(_) | Value::Number(_) => 0,
263 Value::String(value) => value.len(),
264 Value::Array(values) => values.iter().map(json_retained_weight).sum(),
265 Value::Object(values) => values
266 .iter()
267 .map(|(key, value)| {
268 key.len() + MEMO_ENTRY_OVERHEAD_BYTES + json_retained_weight(value)
269 })
270 .sum(),
271 }
272}
273
274const TOP_LEVEL_SYMBOL: &str = "<top-level>";
275const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
276const JS_TS_INDEX_FILES: &[&str] = &[
277 "index.ts",
278 "index.tsx",
279 "index.mts",
280 "index.cts",
281 "index.js",
282 "index.jsx",
283 "index.mjs",
284 "index.cjs",
285];
286
287fn symbol_identity(symbol: &Symbol) -> String {
288 if symbol.scope_chain.is_empty() {
289 symbol.name.clone()
290 } else {
291 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
292 }
293}
294
295fn symbol_unqualified_name(symbol: &str) -> &str {
296 symbol.rsplit("::").next().unwrap_or(symbol)
297}
298
299pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
300 full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
301}
302
303fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
304 let mut seen = HashSet::new();
305 let mut candidates = Vec::new();
306 let qualified_query = symbol_name.contains("::");
307
308 let mut consider = |candidate: &str| {
309 let matches = if qualified_query {
310 candidate == symbol_name
311 } else {
312 candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
313 };
314
315 if matches && seen.insert(candidate.to_string()) {
316 candidates.push(candidate.to_string());
317 }
318 };
319
320 for candidate in file_data.symbol_metadata.keys() {
321 consider(candidate);
322 }
323 for candidate in file_data.calls_by_symbol.keys() {
324 consider(candidate);
325 }
326 for candidate in &file_data.exported_symbols {
327 consider(candidate);
328 }
329
330 candidates.sort();
331 candidates
332}
333
334pub(crate) fn resolve_symbol_query_in_data(
335 file_data: &FileCallData,
336 file: &Path,
337 symbol_name: &str,
338) -> Result<String, AftError> {
339 let candidates = symbol_query_candidates(file_data, symbol_name);
340 match candidates.as_slice() {
341 [candidate] => Ok(candidate.clone()),
342 [] => Err(AftError::SymbolNotFound {
343 name: symbol_name.to_string(),
344 file: file.display().to_string(),
345 }),
346 _ => Err(AftError::AmbiguousSymbol {
347 name: symbol_name.to_string(),
348 candidates,
349 }),
350 }
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct CallSite {
356 pub callee_name: String,
358 pub full_callee: String,
360 pub line: u32,
362 pub byte_start: usize,
364 pub byte_end: usize,
365}
366
367#[derive(Debug, Clone, Serialize)]
369pub struct SymbolMeta {
370 pub kind: SymbolKind,
372 pub exported: bool,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub signature: Option<String>,
377 pub line: u32,
379 pub range: Range,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub entry_point_attribute: Option<String>,
384}
385
386#[derive(Debug, Clone)]
389pub struct FileCallData {
390 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
392 pub value_refs_by_symbol: HashMap<String, Vec<CallSite>>,
395 pub exported_symbols: Vec<String>,
397 pub symbol_metadata: HashMap<String, SymbolMeta>,
399 pub default_export_symbol: Option<String>,
401 pub import_block: ImportBlock,
403 pub lang: LangId,
405}
406
407impl FileCallData {
408 pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
421 if let Some(meta) = self.symbol_metadata.get(name) {
422 return Some(meta);
423 }
424 self.symbol_metadata
425 .iter()
426 .find(|(key, _)| symbol_unqualified_name(key) == name)
427 .map(|(_, meta)| meta)
428 }
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
433pub enum EdgeResolution {
434 Resolved { file: PathBuf, symbol: String },
436 Unresolved { callee_name: String },
438}
439
440#[derive(Debug, Clone, PartialEq, Eq)]
441struct ResolvedSymbol {
442 file: PathBuf,
443 symbol: String,
444}
445
446#[derive(Debug, Clone)]
447struct RustCrateInfo {
448 lib_name: String,
449 lib_root: Option<PathBuf>,
450 main_root: Option<PathBuf>,
451}
452
453#[derive(Debug, Clone)]
454struct RustModuleBase {
455 src_dir: PathBuf,
456 root_file: PathBuf,
457}
458
459#[derive(Debug, Clone)]
460struct RustUseEntry {
461 module_path: String,
462 local_name: String,
463 kind: RustUseKind,
464}
465
466#[derive(Debug, Clone)]
467enum RustUseKind {
468 Item { imported_name: String },
469 Module,
470}
471
472#[derive(Debug, Clone, Serialize)]
474pub struct CallTreeNode {
475 pub name: String,
477 pub file: String,
479 pub line: u32,
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub signature: Option<String>,
484 pub resolved: bool,
486 pub children: Vec<CallTreeNode>,
488 pub depth_limited: bool,
490 pub truncated: usize,
492}
493
494const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
500
501pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
508 if exported && *kind == SymbolKind::Function {
510 return true;
511 }
512
513 let lower = name.to_lowercase();
515 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
516 return true;
517 }
518
519 match lang {
521 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
522 matches!(lower.as_str(), "describe" | "it" | "test")
524 || lower.starts_with("test")
525 || lower.starts_with("spec")
526 }
527 LangId::Python => {
528 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
530 }
531 LangId::Rust => {
532 lower.starts_with("test_")
534 }
535 LangId::Go => {
536 name.starts_with("Test")
538 }
539 LangId::C
540 | LangId::Cpp
541 | LangId::Zig
542 | LangId::CSharp
543 | LangId::Bash
544 | LangId::Solidity
545 | LangId::Scss
546 | LangId::Vue
547 | LangId::Json
548 | LangId::Scala
549 | LangId::Java
550 | LangId::Ruby
551 | LangId::Kotlin
552 | LangId::Swift
553 | LangId::Php
554 | LangId::Lua
555 | LangId::Perl
556 | LangId::Html
557 | LangId::Markdown
558 | LangId::Yaml
559 | LangId::Pascal
560 | LangId::R
561 | LangId::Groovy
562 | LangId::ObjC => false,
563 }
564}
565
566#[derive(Debug, Clone, Serialize)]
572pub struct TraceHop {
573 pub symbol: String,
575 pub file: String,
577 pub line: u32,
579 #[serde(skip_serializing_if = "Option::is_none")]
581 pub signature: Option<String>,
582 pub is_entry_point: bool,
584}
585
586#[derive(Debug, Clone, Serialize)]
588pub struct TracePath {
589 pub hops: Vec<TraceHop>,
591}
592
593#[derive(Debug, Clone, Serialize)]
595pub struct TraceToResult {
596 pub target_symbol: String,
598 pub target_file: String,
600 pub paths: Vec<TracePath>,
602 pub total_paths: usize,
604 pub entry_points_found: usize,
606 pub max_depth_reached: bool,
608 pub truncated_paths: usize,
610}
611
612#[derive(Debug, Clone, Serialize)]
614pub struct TraceToSymbolHop {
615 pub symbol: String,
617 pub file: String,
619 pub line: u32,
621}
622
623#[derive(Debug, Clone, Serialize)]
625pub struct TraceToSymbolCandidate {
626 pub file: String,
628 pub line: u32,
630}
631
632#[derive(Debug, Clone, Serialize)]
634pub struct TraceToSymbolResult {
635 pub path: Option<Vec<TraceToSymbolHop>>,
637 pub complete: bool,
639 #[serde(skip_serializing_if = "Option::is_none")]
641 pub reason: Option<String>,
642}
643
644#[derive(Debug, Clone, Serialize)]
650pub struct DataFlowHop {
651 pub file: String,
653 pub symbol: String,
655 pub variable: String,
657 pub line: u32,
659 pub flow_type: String,
661 pub approximate: bool,
663}
664
665#[derive(Debug, Clone, Serialize)]
668pub struct TraceDataResult {
669 pub expression: String,
671 pub origin_file: String,
673 pub origin_symbol: String,
675 pub hops: Vec<DataFlowHop>,
677 pub depth_limited: bool,
679}
680
681pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
687 let start = match signature.find('(') {
689 Some(i) => i + 1,
690 None => return Vec::new(),
691 };
692 let end = match signature[start..].find(')') {
693 Some(i) => start + i,
694 None => return Vec::new(),
695 };
696
697 let params_str = &signature[start..end].trim();
698 if params_str.is_empty() {
699 return Vec::new();
700 }
701
702 let parts = split_params(params_str);
704
705 let mut result = Vec::new();
706 for part in parts {
707 let trimmed = part.trim();
708 if trimmed.is_empty() {
709 continue;
710 }
711
712 match lang {
714 LangId::Rust => {
715 if trimmed == "self"
716 || trimmed == "mut self"
717 || trimmed.starts_with("&self")
718 || trimmed.starts_with("&mut self")
719 {
720 continue;
721 }
722 }
723 LangId::Python => {
724 if trimmed == "self" || trimmed.starts_with("self:") {
725 continue;
726 }
727 }
728 _ => {}
729 }
730
731 let name = extract_param_name(trimmed, lang);
733 if !name.is_empty() {
734 result.push(name);
735 }
736 }
737
738 result
739}
740
741fn split_params(s: &str) -> Vec<String> {
743 let mut parts = Vec::new();
744 let mut current = String::new();
745 let mut depth = 0i32;
746
747 for ch in s.chars() {
748 match ch {
749 '<' | '[' | '{' | '(' => {
750 depth += 1;
751 current.push(ch);
752 }
753 '>' | ']' | '}' | ')' => {
754 depth -= 1;
755 current.push(ch);
756 }
757 ',' if depth == 0 => {
758 parts.push(current.clone());
759 current.clear();
760 }
761 _ => {
762 current.push(ch);
763 }
764 }
765 }
766 if !current.is_empty() {
767 parts.push(current);
768 }
769 parts
770}
771
772fn extract_param_name(param: &str, lang: LangId) -> String {
780 let trimmed = param.trim();
781
782 let working = if trimmed.starts_with("...") {
784 &trimmed[3..]
785 } else if trimmed.starts_with("**") {
786 &trimmed[2..]
787 } else if trimmed.starts_with('*') && lang == LangId::Python {
788 &trimmed[1..]
789 } else {
790 trimmed
791 };
792
793 let working = if lang == LangId::Rust && working.starts_with("mut ") {
795 &working[4..]
796 } else {
797 working
798 };
799
800 let name = working
803 .split(|c: char| c == ':' || c == '=')
804 .next()
805 .unwrap_or("")
806 .trim();
807
808 let name = name.trim_end_matches('?');
810
811 if lang == LangId::Go && !name.contains(' ') {
813 return name.to_string();
814 }
815 if lang == LangId::Go {
816 return name.split_whitespace().next().unwrap_or("").to_string();
817 }
818
819 name.to_string()
820}
821
822pub struct CallGraph {
831 data: HashMap<PathBuf, FileCallData>,
833 project_root: PathBuf,
835}
836
837impl CallGraph {
838 pub fn new(project_root: PathBuf) -> Self {
840 clear_workspace_package_cache();
841 Self {
842 data: HashMap::new(),
843 project_root,
844 }
845 }
846
847 pub fn project_root(&self) -> &Path {
849 &self.project_root
850 }
851
852 fn resolve_cross_file_edge_with_exports<F, D>(
853 full_callee: &str,
854 short_name: &str,
855 caller_file: &Path,
856 import_block: &ImportBlock,
857 mut file_exports_symbol: F,
858 mut file_default_export_symbol: D,
859 ) -> EdgeResolution
860 where
861 F: FnMut(&Path, &str) -> bool,
862 D: FnMut(&Path) -> Option<String>,
863 {
864 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
865
866 if is_rust_source_file(caller_file) {
870 if let Some(target) = resolve_rust_cross_file_edge(
871 full_callee,
872 short_name,
873 caller_file,
874 import_block,
875 &mut file_exports_symbol,
876 ) {
877 return EdgeResolution::Resolved {
878 file: target.file,
879 symbol: target.symbol,
880 };
881 }
882 }
883
884 if full_callee.contains('.') {
886 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
887 if parts.len() == 2 {
888 let namespace = parts[0];
889 let member = parts[1];
890
891 for imp in &import_block.imports {
892 if imp.namespace_import.as_deref() == Some(namespace) {
893 if let Some(resolved_path) =
894 resolve_module_path(caller_dir, &imp.module_path)
895 {
896 if let Some(target) = resolve_reexported_symbol(
897 &resolved_path,
898 member,
899 &mut file_exports_symbol,
900 &mut file_default_export_symbol,
901 ) {
902 return EdgeResolution::Resolved {
903 file: target.file,
904 symbol: target.symbol,
905 };
906 }
907 }
908 }
909 }
910 }
911 }
912
913 for imp in &import_block.imports {
915 if imp.names.iter().any(|name| name == short_name) {
917 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
918 let target = resolve_reexported_symbol(
919 &resolved_path,
920 short_name,
921 &mut file_exports_symbol,
922 &mut file_default_export_symbol,
923 )
924 .unwrap_or(ResolvedSymbol {
925 file: resolved_path,
926 symbol: short_name.to_owned(),
927 });
928 return EdgeResolution::Resolved {
929 file: target.file,
930 symbol: target.symbol,
931 };
932 }
933 }
934
935 if imp.default_import.as_deref() == Some(short_name) {
937 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
938 let target = resolve_reexported_symbol(
939 &resolved_path,
940 "default",
941 &mut file_exports_symbol,
942 &mut file_default_export_symbol,
943 )
944 .unwrap_or_else(|| ResolvedSymbol {
945 symbol: file_default_export_symbol(&resolved_path)
946 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
947 file: resolved_path,
948 });
949 return EdgeResolution::Resolved {
950 file: target.file,
951 symbol: target.symbol,
952 };
953 }
954 }
955 }
956
957 if let Some((original_name, resolved_path)) =
962 resolve_aliased_import(short_name, import_block, caller_dir)
963 {
964 let target = resolve_reexported_symbol(
965 &resolved_path,
966 &original_name,
967 &mut file_exports_symbol,
968 &mut file_default_export_symbol,
969 )
970 .unwrap_or(ResolvedSymbol {
971 file: resolved_path,
972 symbol: original_name,
973 });
974 return EdgeResolution::Resolved {
975 file: target.file,
976 symbol: target.symbol,
977 };
978 }
979
980 for imp in &import_block.imports {
983 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
984 if resolved_path.is_dir() {
986 if let Some(index_path) = find_index_file(&resolved_path) {
987 if file_exports_symbol(&index_path, short_name) {
989 return EdgeResolution::Resolved {
990 file: index_path,
991 symbol: short_name.to_owned(),
992 };
993 }
994 }
995 } else if file_exports_symbol(&resolved_path, short_name) {
996 return EdgeResolution::Resolved {
997 file: resolved_path,
998 symbol: short_name.to_owned(),
999 };
1000 }
1001 }
1002 }
1003
1004 EdgeResolution::Unresolved {
1005 callee_name: short_name.to_owned(),
1006 }
1007 }
1008
1009 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
1011 let canon = self.canonicalize(path)?;
1012
1013 if !self.data.contains_key(&canon) {
1014 let file_data = build_file_data(&canon)?;
1015 self.data.insert(canon.clone(), file_data);
1016 }
1017
1018 Ok(&self.data[&canon])
1019 }
1020
1021 pub fn resolve_cross_file_edge(
1026 &mut self,
1027 full_callee: &str,
1028 short_name: &str,
1029 caller_file: &Path,
1030 import_block: &ImportBlock,
1031 ) -> EdgeResolution {
1032 let graph = RefCell::new(self);
1033 Self::resolve_cross_file_edge_with_exports(
1034 full_callee,
1035 short_name,
1036 caller_file,
1037 import_block,
1038 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
1039 |path| graph.borrow_mut().file_default_export_symbol(path),
1040 )
1041 }
1042
1043 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
1045 match self.build_file(path) {
1046 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
1047 Err(_) => false,
1048 }
1049 }
1050
1051 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
1052 self.build_file(path)
1053 .ok()
1054 .and_then(|data| data.default_export_symbol.clone())
1055 }
1056
1057 pub fn invalidate_file(&mut self, path: &Path) {
1059 self.data.remove(path);
1061 if let Ok(canon) = self.canonicalize(path) {
1062 self.data.remove(&canon);
1063 }
1064 clear_workspace_package_cache();
1065 }
1066
1067 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1069 let full_path = if path.is_relative() {
1071 self.project_root.join(path)
1072 } else {
1073 path.to_path_buf()
1074 };
1075
1076 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1078 }
1079}
1080
1081pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1087 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1088 message: format!("unsupported file for call graph: {}", path.display()),
1089 })?;
1090
1091 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1092 path: format!("{}: {}", path.display(), e),
1093 })?;
1094
1095 build_file_data_from_source_with_lang(path, &source, lang)
1096}
1097
1098pub(crate) fn build_file_data_from_source(
1099 path: &Path,
1100 source: &str,
1101) -> Result<FileCallData, AftError> {
1102 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1103 message: format!("unsupported file for call graph: {}", path.display()),
1104 })?;
1105 build_file_data_from_source_with_lang(path, source, lang)
1106}
1107
1108#[derive(Debug)]
1109struct SymbolCallRange {
1110 symbol_index: usize,
1111 byte_start: usize,
1112 byte_end: usize,
1113}
1114
1115struct SourceLineIndex {
1116 bounds: Vec<(usize, usize)>,
1117 source_len: usize,
1118}
1119
1120impl SourceLineIndex {
1121 fn new(source: &str) -> Self {
1122 let bytes = source.as_bytes();
1123 let mut bounds = Vec::new();
1124 let mut line_start = 0usize;
1125 let mut index = 0usize;
1126
1127 while index < bytes.len() {
1128 match bytes[index] {
1129 b'\r' => {
1130 bounds.push((line_start, index));
1131 index += if bytes.get(index + 1) == Some(&b'\n') {
1132 2
1133 } else {
1134 1
1135 };
1136 line_start = index;
1137 }
1138 b'\n' => {
1139 bounds.push((line_start, index));
1140 index += 1;
1141 line_start = index;
1142 }
1143 _ => index += 1,
1144 }
1145 }
1146 bounds.push((line_start, bytes.len()));
1147
1148 Self {
1149 bounds,
1150 source_len: bytes.len(),
1151 }
1152 }
1153
1154 fn byte_offset(&self, line: u32, column: u32) -> usize {
1155 let Some(&(line_start, line_end)) = self.bounds.get(line as usize) else {
1156 return self.source_len;
1157 };
1158 line_start + (column as usize).min(line_end.saturating_sub(line_start))
1159 }
1160}
1161
1162fn collect_calls_by_symbol(
1163 source: &str,
1164 root: Node<'_>,
1165 lang: LangId,
1166 symbols: &[Symbol],
1167) -> HashMap<String, Vec<CallSite>> {
1168 attribute_sites_to_symbols(
1169 source,
1170 symbols,
1171 extract_calls_full(source, root, 0, source.len(), lang),
1172 )
1173}
1174
1175fn collect_rust_value_refs_by_symbol(
1176 source: &str,
1177 root: Node<'_>,
1178 symbols: &[Symbol],
1179) -> HashMap<String, Vec<CallSite>> {
1180 attribute_sites_to_symbols(source, symbols, extract_rust_value_references(source, root))
1181}
1182
1183fn attribute_sites_to_symbols(
1184 source: &str,
1185 symbols: &[Symbol],
1186 raw_sites: Vec<(String, String, u32, usize, usize)>,
1187) -> HashMap<String, Vec<CallSite>> {
1188 let line_index = SourceLineIndex::new(source);
1189 let mut ranges = symbols
1190 .iter()
1191 .enumerate()
1192 .map(|(symbol_index, symbol)| SymbolCallRange {
1193 symbol_index,
1194 byte_start: line_index.byte_offset(symbol.range.start_line, symbol.range.start_col),
1195 byte_end: line_index.byte_offset(symbol.range.end_line, symbol.range.end_col),
1196 })
1197 .collect::<Vec<_>>();
1198 ranges.sort_by(|left, right| {
1199 left.byte_start
1200 .cmp(&right.byte_start)
1201 .then_with(|| left.symbol_index.cmp(&right.symbol_index))
1202 });
1203
1204 let mut sites_by_symbol = vec![Vec::new(); symbols.len()];
1205 let mut top_level_sites = Vec::new();
1206 let mut active_ranges = Vec::<usize>::new();
1207 let mut next_range = 0usize;
1208
1209 for (full, short, line, byte_start, byte_end) in raw_sites {
1210 active_ranges.retain(|range_index| ranges[*range_index].byte_end > byte_start);
1213 while next_range < ranges.len() && ranges[next_range].byte_start <= byte_start {
1214 if ranges[next_range].byte_end > byte_start {
1215 active_ranges.push(next_range);
1216 }
1217 next_range += 1;
1218 }
1219
1220 let site = CallSite {
1221 callee_name: short,
1222 full_callee: full,
1223 line,
1224 byte_start,
1225 byte_end,
1226 };
1227 let mut attributed = false;
1228 for range_index in &active_ranges {
1229 let range = &ranges[*range_index];
1230 if byte_end <= range.byte_end {
1231 sites_by_symbol[range.symbol_index].push(site.clone());
1232 attributed = true;
1233 }
1234 }
1235 if !attributed {
1236 top_level_sites.push(site);
1237 }
1238 }
1239
1240 let mut calls_by_symbol = HashMap::new();
1241 for (symbol, sites) in symbols.iter().zip(sites_by_symbol) {
1242 if !sites.is_empty() {
1243 calls_by_symbol.insert(symbol_identity(symbol), sites);
1244 }
1245 }
1246 if !top_level_sites.is_empty() {
1247 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
1248 }
1249 calls_by_symbol
1250}
1251
1252fn build_file_data_from_source_with_lang(
1253 path: &Path,
1254 source: &str,
1255 lang: LangId,
1256) -> Result<FileCallData, AftError> {
1257 let grammar = grammar_for(lang);
1258 let mut parser = Parser::new();
1259 parser
1260 .set_language(&grammar)
1261 .map_err(|e| AftError::ParseError {
1262 message: format!("grammar init failed for {:?}: {}", lang, e),
1263 })?;
1264
1265 let tree = parser
1266 .parse(&source, None)
1267 .ok_or_else(|| AftError::ParseError {
1268 message: format!("parse failed for {}", path.display()),
1269 })?;
1270
1271 let import_block = imports::parse_imports(&source, &tree, lang);
1273
1274 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
1276
1277 let root = tree.root_node();
1278 let mut calls_by_symbol = collect_calls_by_symbol(&source, root, lang, &symbols);
1279 let value_refs_by_symbol = if lang == LangId::Rust {
1280 collect_rust_value_refs_by_symbol(&source, root, &symbols)
1281 } else {
1282 HashMap::new()
1283 };
1284
1285 let default_export = find_default_export(&source, root, path, lang);
1286
1287 if let Some(default_export) = &default_export {
1288 if default_export.synthetic {
1289 let byte_start = default_export.node.byte_range().start;
1290 let byte_end = default_export.node.byte_range().end;
1291 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1292 let sites: Vec<CallSite> = raw_calls
1293 .into_iter()
1294 .filter(|(_, short, _, _, _)| *short != default_export.symbol)
1295 .map(
1296 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
1297 callee_name: short,
1298 full_callee: full,
1299 line,
1300 byte_start: call_byte_start,
1301 byte_end: call_byte_end,
1302 },
1303 )
1304 .collect();
1305 if !sites.is_empty() {
1306 calls_by_symbol.insert(default_export.symbol.clone(), sites);
1307 }
1308 }
1309 }
1310
1311 let mut exported_symbols: Vec<String> = symbols
1313 .iter()
1314 .filter(|s| s.exported)
1315 .map(|s| s.name.clone())
1316 .collect();
1317 if let Some(default_export) = &default_export {
1318 if !exported_symbols
1319 .iter()
1320 .any(|name| name == &default_export.symbol)
1321 {
1322 exported_symbols.push(default_export.symbol.clone());
1323 }
1324 }
1325
1326 let rust_attribute_entry_points = if lang == LangId::Rust {
1327 crate::parser::rust_attribute_entry_points(&source, root)
1328 .into_iter()
1329 .map(|entry| (entry.scoped_name, entry.attribute.to_string()))
1330 .collect::<HashMap<_, _>>()
1331 } else {
1332 HashMap::new()
1333 };
1334
1335 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
1337 .iter()
1338 .map(|s| {
1339 let identity = symbol_identity(s);
1340 (
1341 identity.clone(),
1342 SymbolMeta {
1343 kind: s.kind.clone(),
1344 exported: s.exported,
1345 signature: s.signature.clone(),
1346 line: s.range.start_line + 1,
1347 range: s.range.clone(),
1348 entry_point_attribute: rust_attribute_entry_points.get(&identity).cloned(),
1349 },
1350 )
1351 })
1352 .collect();
1353 if let Some(default_export) = &default_export {
1354 symbol_metadata
1355 .entry(default_export.symbol.clone())
1356 .or_insert_with(|| SymbolMeta {
1357 kind: default_export.kind.clone(),
1358 exported: true,
1359 signature: Some(first_line_signature(&source, &default_export.node)),
1360 line: default_export.node.start_position().row as u32 + 1,
1361 range: crate::parser::node_range(&default_export.node),
1362 entry_point_attribute: None,
1363 });
1364 }
1365 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL)
1366 || value_refs_by_symbol.contains_key(TOP_LEVEL_SYMBOL)
1367 {
1368 symbol_metadata
1369 .entry(TOP_LEVEL_SYMBOL.to_string())
1370 .or_insert(SymbolMeta {
1371 kind: SymbolKind::Function,
1372 exported: false,
1373 signature: None,
1374 line: 1,
1375 range: Range {
1376 start_line: 0,
1377 start_col: 0,
1378 end_line: 0,
1379 end_col: 0,
1380 },
1381 entry_point_attribute: None,
1382 });
1383 }
1384
1385 Ok(FileCallData {
1386 calls_by_symbol,
1387 value_refs_by_symbol,
1388 exported_symbols,
1389 symbol_metadata,
1390 default_export_symbol: default_export.map(|export| export.symbol),
1391 import_block,
1392 lang,
1393 })
1394}
1395
1396#[derive(Debug, Clone)]
1397struct DefaultExport<'tree> {
1398 symbol: String,
1399 synthetic: bool,
1400 kind: SymbolKind,
1401 node: Node<'tree>,
1402}
1403
1404fn find_default_export<'tree>(
1405 source: &str,
1406 root: Node<'tree>,
1407 path: &Path,
1408 lang: LangId,
1409) -> Option<DefaultExport<'tree>> {
1410 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
1411 return None;
1412 }
1413 find_default_export_inner(source, root, path)
1414}
1415
1416fn find_default_export_inner<'tree>(
1417 source: &str,
1418 node: Node<'tree>,
1419 path: &Path,
1420) -> Option<DefaultExport<'tree>> {
1421 if node.kind() == "export_statement" {
1422 if let Some(default_export) = default_export_from_statement(source, node, path) {
1423 return Some(default_export);
1424 }
1425 }
1426
1427 let mut cursor = node.walk();
1428 if !cursor.goto_first_child() {
1429 return None;
1430 }
1431
1432 loop {
1433 let child = cursor.node();
1434 if let Some(default_export) = find_default_export_inner(source, child, path) {
1435 return Some(default_export);
1436 }
1437 if !cursor.goto_next_sibling() {
1438 break;
1439 }
1440 }
1441
1442 None
1443}
1444
1445fn default_export_from_statement<'tree>(
1446 source: &str,
1447 node: Node<'tree>,
1448 path: &Path,
1449) -> Option<DefaultExport<'tree>> {
1450 let mut cursor = node.walk();
1451 if !cursor.goto_first_child() {
1452 return None;
1453 }
1454
1455 let mut saw_default = false;
1456 loop {
1457 let child = cursor.node();
1458 match child.kind() {
1459 "default" => saw_default = true,
1460 "function_declaration" | "generator_function_declaration" | "class_declaration"
1461 if saw_default =>
1462 {
1463 if let Some(name_node) = child.child_by_field_name("name") {
1464 return Some(DefaultExport {
1465 symbol: source[name_node.byte_range()].to_string(),
1466 synthetic: false,
1467 kind: default_export_kind(&child),
1468 node: child,
1469 });
1470 }
1471 return Some(DefaultExport {
1472 symbol: synthetic_default_symbol(path),
1473 synthetic: true,
1474 kind: default_export_kind(&child),
1475 node: child,
1476 });
1477 }
1478 "arrow_function"
1479 | "function"
1480 | "function_expression"
1481 | "class"
1482 | "class_expression"
1483 if saw_default =>
1484 {
1485 return Some(DefaultExport {
1486 symbol: synthetic_default_symbol(path),
1487 synthetic: true,
1488 kind: default_export_kind(&child),
1489 node: child,
1490 });
1491 }
1492 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
1493 return Some(DefaultExport {
1494 symbol: source[child.byte_range()].to_string(),
1495 synthetic: false,
1496 kind: SymbolKind::Function,
1497 node: child,
1498 });
1499 }
1500 _ => {}
1501 }
1502 if !cursor.goto_next_sibling() {
1503 break;
1504 }
1505 }
1506
1507 None
1508}
1509
1510fn default_export_kind(node: &Node) -> SymbolKind {
1511 if node.kind().contains("class") {
1512 SymbolKind::Class
1513 } else {
1514 SymbolKind::Function
1515 }
1516}
1517
1518fn synthetic_default_symbol(path: &Path) -> String {
1519 let file_name = path
1520 .file_name()
1521 .and_then(|name| name.to_str())
1522 .unwrap_or("unknown");
1523 format!("<default:{file_name}>")
1524}
1525
1526fn first_line_signature(source: &str, node: &Node) -> String {
1527 let text = &source[node.byte_range()];
1528 let first_line = text.lines().next().unwrap_or(text);
1529 first_line
1530 .trim_end()
1531 .trim_end_matches('{')
1532 .trim_end()
1533 .to_string()
1534}
1535
1536fn node_text(node: tree_sitter::Node, source: &str) -> String {
1537 source[node.start_byte()..node.end_byte()].to_string()
1538}
1539
1540fn find_child_by_kind<'a>(
1542 node: tree_sitter::Node<'a>,
1543 kind: &str,
1544) -> Option<tree_sitter::Node<'a>> {
1545 let mut cursor = node.walk();
1546 if cursor.goto_first_child() {
1547 loop {
1548 if cursor.node().kind() == kind {
1549 return Some(cursor.node());
1550 }
1551 if !cursor.goto_next_sibling() {
1552 break;
1553 }
1554 }
1555 }
1556 None
1557}
1558
1559#[cfg(test)]
1560#[derive(Debug, Clone)]
1561struct CallSiteWithRange {
1562 full: String,
1563 short: String,
1564 line: u32,
1565 byte_start: usize,
1566 byte_end: usize,
1567}
1568
1569#[cfg(test)]
1570fn collect_calls_full_with_ranges(
1571 root: tree_sitter::Node,
1572 source: &str,
1573 byte_start: usize,
1574 byte_end: usize,
1575 lang: LangId,
1576) -> Vec<CallSiteWithRange> {
1577 let mut results = Vec::new();
1578 let call_kinds = call_node_kinds(lang);
1579 collect_calls_full_with_ranges_inner(
1580 root,
1581 source,
1582 byte_start,
1583 byte_end,
1584 &call_kinds,
1585 &mut results,
1586 );
1587 results
1588}
1589
1590#[cfg(test)]
1591fn collect_calls_full_with_ranges_inner(
1592 node: tree_sitter::Node,
1593 source: &str,
1594 byte_start: usize,
1595 byte_end: usize,
1596 call_kinds: &[&str],
1597 results: &mut Vec<CallSiteWithRange>,
1598) {
1599 let node_start = node.start_byte();
1600 let node_end = node.end_byte();
1601
1602 if node_end <= byte_start || node_start >= byte_end {
1603 return;
1604 }
1605
1606 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
1607 if let (Some(full), Some(short)) = (
1608 extract_full_callee(&node, source),
1609 extract_callee_name(&node, source),
1610 ) {
1611 results.push(CallSiteWithRange {
1612 full,
1613 short,
1614 line: node.start_position().row as u32 + 1,
1615 byte_start: node_start,
1616 byte_end: node_end,
1617 });
1618 }
1619 }
1620
1621 let mut cursor = node.walk();
1622 if cursor.goto_first_child() {
1623 loop {
1624 collect_calls_full_with_ranges_inner(
1625 cursor.node(),
1626 source,
1627 byte_start,
1628 byte_end,
1629 call_kinds,
1630 results,
1631 );
1632 if !cursor.goto_next_sibling() {
1633 break;
1634 }
1635 }
1636 }
1637}
1638
1639pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1647 resolve_module_path_uncached(from_dir, module_path, None)
1648}
1649
1650pub(crate) fn resolve_module_path_with_memo(
1651 from_dir: &Path,
1652 module_path: &str,
1653 memo: &ModuleResolutionMemo,
1654) -> Option<PathBuf> {
1655 memo.resolve_module_path(from_dir, module_path)
1656}
1657
1658fn resolve_module_path_uncached(
1659 from_dir: &Path,
1660 module_path: &str,
1661 memo: Option<&ModuleResolutionMemo>,
1662) -> Option<PathBuf> {
1663 if module_path.starts_with('.') {
1664 return resolve_relative_module_path(from_dir, module_path);
1665 }
1666
1667 if module_path.starts_with('/') {
1668 return None;
1669 }
1670
1671 if let Some(path) = resolve_tsconfig_path(from_dir, module_path, memo) {
1672 return Some(path);
1673 }
1674
1675 resolve_workspace_module_path(from_dir, module_path, memo)
1676}
1677
1678fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1679 let base = from_dir.join(module_path);
1680 resolve_file_like_path(&base)
1681}
1682
1683fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
1684 let base = base.to_path_buf();
1685
1686 if base.is_file() {
1688 return Some(std::fs::canonicalize(&base).unwrap_or(base));
1689 }
1690
1691 for ext in JS_TS_EXTENSIONS {
1693 let with_ext = base.with_extension(ext);
1694 if with_ext.is_file() {
1695 return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
1696 }
1697 }
1698
1699 if base.is_dir() {
1701 if let Some(index) = find_index_file(&base) {
1702 return Some(index);
1703 }
1704 }
1705
1706 None
1707}
1708
1709fn resolve_workspace_module_path(
1710 from_dir: &Path,
1711 module_path: &str,
1712 memo: Option<&ModuleResolutionMemo>,
1713) -> Option<PathBuf> {
1714 let (package_name, subpath) = split_package_import(module_path)?;
1715 let package_root = find_package_root_for_import(from_dir, &package_name, memo)?;
1716 resolve_package_entry(&package_root, &subpath, memo)
1717}
1718
1719fn is_rust_source_file(path: &Path) -> bool {
1720 path.extension().and_then(|ext| ext.to_str()) == Some("rs")
1721}
1722
1723fn resolve_rust_cross_file_edge<F>(
1724 full_callee: &str,
1725 short_name: &str,
1726 caller_file: &Path,
1727 import_block: &ImportBlock,
1728 file_exports_symbol: &mut F,
1729) -> Option<ResolvedSymbol>
1730where
1731 F: FnMut(&Path, &str) -> bool,
1732{
1733 if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
1734 {
1735 return Some(target);
1736 }
1737
1738 resolve_rust_imported_call(
1739 caller_file,
1740 full_callee,
1741 short_name,
1742 import_block,
1743 file_exports_symbol,
1744 )
1745}
1746
1747fn resolve_rust_qualified_call<F>(
1748 caller_file: &Path,
1749 full_callee: &str,
1750 file_exports_symbol: &mut F,
1751) -> Option<ResolvedSymbol>
1752where
1753 F: FnMut(&Path, &str) -> bool,
1754{
1755 if !full_callee.contains("::") {
1756 return None;
1757 }
1758
1759 let segments = rust_path_segments(full_callee)?;
1760 resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
1761}
1762
1763fn resolve_rust_imported_call<F>(
1764 caller_file: &Path,
1765 full_callee: &str,
1766 short_name: &str,
1767 import_block: &ImportBlock,
1768 file_exports_symbol: &mut F,
1769) -> Option<ResolvedSymbol>
1770where
1771 F: FnMut(&Path, &str) -> bool,
1772{
1773 let call_segments = rust_path_segments(full_callee).unwrap_or_default();
1774 let bare_call_name = if call_segments.len() <= 1 {
1775 call_segments
1776 .first()
1777 .map(String::as_str)
1778 .unwrap_or(short_name)
1779 } else {
1780 short_name
1781 };
1782
1783 for imp in &import_block.imports {
1784 for entry in rust_use_entries(imp) {
1785 match &entry.kind {
1786 RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
1787 if entry.local_name != bare_call_name {
1788 continue;
1789 }
1790 let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
1791 else {
1792 continue;
1793 };
1794 if file_exports_symbol(&file, imported_name) {
1795 return Some(ResolvedSymbol {
1796 file,
1797 symbol: imported_name.clone(),
1798 });
1799 }
1800 }
1801 RustUseKind::Module if call_segments.len() >= 2 => {
1802 if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
1803 {
1804 continue;
1805 }
1806 let symbol = call_segments.last()?.clone();
1807 let mut module_path = entry.module_path.clone();
1808 for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
1809 module_path.push_str("::");
1810 module_path.push_str(segment);
1811 }
1812 let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
1813 continue;
1814 };
1815 if file_exports_symbol(&file, &symbol) {
1816 return Some(ResolvedSymbol { file, symbol });
1817 }
1818 }
1819 _ => {}
1820 }
1821 }
1822 }
1823
1824 None
1825}
1826
1827fn resolve_rust_call_segments<F>(
1828 caller_file: &Path,
1829 segments: &[String],
1830 file_exports_symbol: &mut F,
1831) -> Option<ResolvedSymbol>
1832where
1833 F: FnMut(&Path, &str) -> bool,
1834{
1835 if segments.len() < 2 {
1836 return None;
1837 }
1838
1839 let symbol = segments.last()?.clone();
1840 let module_path = segments[..segments.len() - 1].join("::");
1841 let file = resolve_rust_module_path(caller_file, &module_path)?;
1842 if file_exports_symbol(&file, &symbol) {
1843 Some(ResolvedSymbol { file, symbol })
1844 } else {
1845 None
1846 }
1847}
1848
1849fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
1850 let segments = rust_path_segments(module_path)?;
1851 let first = segments.first()?.as_str();
1852
1853 match first {
1854 "std" | "core" | "alloc" => None,
1855 "crate" => {
1856 let crate_root = find_rust_crate_root(caller_file)?;
1857 let crate_info = rust_crate_info(&crate_root)?;
1858 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1859 resolve_rust_module_segments(&base, &segments[1..])
1860 }
1861 "self" => {
1862 let crate_root = find_rust_crate_root(caller_file)?;
1863 let crate_info = rust_crate_info(&crate_root)?;
1864 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1865 if segments.len() == 1 {
1866 return Some(canonicalize_path(caller_file));
1867 }
1868 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1869 target_segments.extend(segments[1..].iter().cloned());
1870 resolve_rust_module_segments(&base, &target_segments)
1871 }
1872 "super" => {
1873 let crate_root = find_rust_crate_root(caller_file)?;
1874 let crate_info = rust_crate_info(&crate_root)?;
1875 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1876 let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1877 target_segments.pop();
1878 target_segments.extend(segments[1..].iter().cloned());
1879 resolve_rust_module_segments(&base, &target_segments)
1880 }
1881 crate_name => {
1882 let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
1883 let workspace_crates = rust_workspace_crates(caller_dir)?;
1884 let crate_info = workspace_crates.get(crate_name)?;
1885 let base = rust_lib_module_base(crate_info)?;
1886 resolve_rust_module_segments(&base, &segments[1..])
1887 }
1888 }
1889}
1890
1891fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
1892 let Some(body) = rust_use_body(&imp.raw_text) else {
1893 return Vec::new();
1894 };
1895 let mut entries = Vec::new();
1896 expand_rust_use_tree(body, &mut entries);
1897 entries
1898}
1899
1900fn rust_use_body(raw: &str) -> Option<&str> {
1901 let use_pos = raw.find("use ")?;
1902 let body = raw[use_pos + 4..].trim();
1903 let body = body.strip_suffix(';').unwrap_or(body).trim();
1904 (!body.is_empty()).then_some(body)
1905}
1906
1907fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
1908 let path = path.trim();
1909 if path.is_empty() {
1910 return;
1911 }
1912
1913 if let Some((prefix, inner)) = split_rust_use_braces(path) {
1914 let prefix = prefix.trim().trim_end_matches("::").trim();
1915 for part in split_top_level_commas(inner) {
1916 let part = part.trim();
1917 if part.is_empty() {
1918 continue;
1919 }
1920 if part == "self" {
1921 if let Some(local_name) = rust_last_path_segment(prefix) {
1922 entries.push(RustUseEntry {
1923 module_path: prefix.to_string(),
1924 local_name,
1925 kind: RustUseKind::Module,
1926 });
1927 }
1928 continue;
1929 }
1930 let combined = if prefix.is_empty() {
1931 part.to_string()
1932 } else {
1933 format!("{prefix}::{part}")
1934 };
1935 expand_rust_use_tree(&combined, entries);
1936 }
1937 return;
1938 }
1939
1940 add_rust_use_leaf(path, entries);
1941}
1942
1943fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
1944 let mut depth = 0usize;
1945 let mut start = None;
1946 for (idx, ch) in path.char_indices() {
1947 match ch {
1948 '{' => {
1949 if depth == 0 {
1950 start = Some(idx);
1951 }
1952 depth += 1;
1953 }
1954 '}' => {
1955 depth = depth.checked_sub(1)?;
1956 if depth == 0 {
1957 let start = start?;
1958 if !path[idx + ch.len_utf8()..].trim().is_empty() {
1959 return None;
1960 }
1961 return Some((&path[..start], &path[start + 1..idx]));
1962 }
1963 }
1964 _ => {}
1965 }
1966 }
1967 None
1968}
1969
1970fn split_top_level_commas(value: &str) -> Vec<&str> {
1971 let mut parts = Vec::new();
1972 let mut depth = 0usize;
1973 let mut start = 0usize;
1974 for (idx, ch) in value.char_indices() {
1975 match ch {
1976 '{' => depth += 1,
1977 '}' => depth = depth.saturating_sub(1),
1978 ',' if depth == 0 => {
1979 parts.push(&value[start..idx]);
1980 start = idx + ch.len_utf8();
1981 }
1982 _ => {}
1983 }
1984 }
1985 parts.push(&value[start..]);
1986 parts
1987}
1988
1989fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
1990 let (path, alias) = split_rust_alias(path);
1991 let Some(segments) = rust_path_segments(path) else {
1992 return;
1993 };
1994 if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
1995 return;
1996 }
1997
1998 let imported_name = segments.last().cloned().unwrap_or_default();
1999 let local_name = alias.unwrap_or(&imported_name).to_string();
2000 if segments.len() >= 2 {
2001 entries.push(RustUseEntry {
2002 module_path: segments[..segments.len() - 1].join("::"),
2003 local_name: local_name.clone(),
2004 kind: RustUseKind::Item {
2005 imported_name: imported_name.clone(),
2006 },
2007 });
2008 }
2009
2010 entries.push(RustUseEntry {
2011 module_path: segments.join("::"),
2012 local_name,
2013 kind: RustUseKind::Module,
2014 });
2015}
2016
2017fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
2018 if let Some(idx) = path.rfind(" as ") {
2019 let original = path[..idx].trim();
2020 let alias = path[idx + 4..].trim();
2021 if !original.is_empty() && !alias.is_empty() {
2022 return (original, Some(alias));
2023 }
2024 }
2025 (path.trim(), None)
2026}
2027
2028fn rust_path_segments(path: &str) -> Option<Vec<String>> {
2029 let path = path.trim().trim_end_matches(';').trim();
2030 if path.is_empty() || path.contains('{') || path.contains('}') {
2031 return None;
2032 }
2033
2034 let mut segments = Vec::new();
2035 for raw_segment in path.split("::") {
2036 let segment = raw_segment.trim();
2037 if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
2038 return None;
2039 }
2040 let segment = segment.strip_prefix("r#").unwrap_or(segment);
2041 if segment
2042 .chars()
2043 .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
2044 {
2045 return None;
2046 }
2047 segments.push(segment.to_string());
2048 }
2049
2050 (!segments.is_empty()).then_some(segments)
2051}
2052
2053fn rust_last_path_segment(path: &str) -> Option<String> {
2054 rust_path_segments(path)?.last().cloned()
2055}
2056
2057fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
2058 let mut current = if from.is_file() {
2059 from.parent()
2060 } else {
2061 Some(from)
2062 };
2063 while let Some(dir) = current {
2064 if dir.join("Cargo.toml").is_file() {
2065 return Some(canonicalize_path(dir));
2066 }
2067 current = dir.parent();
2068 }
2069 None
2070}
2071
2072fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
2073 let root = canonicalize_path(crate_root);
2074 if let Some(cached) = RUST_CRATE_INFO_CACHE
2075 .read()
2076 .ok()
2077 .and_then(|cache| cache.get(&root).cloned())
2078 {
2079 return cached;
2080 }
2081
2082 let resolved = read_rust_crate_info(&root);
2083 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2084 cache.insert(root, resolved.clone());
2085 }
2086 resolved
2087}
2088
2089fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
2090 let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
2091 let package = cargo.get("package")?;
2092 let package_name = package.get("name")?.as_str()?;
2093 let lib_name = cargo
2094 .get("lib")
2095 .and_then(|lib| lib.get("name"))
2096 .and_then(|name| name.as_str())
2097 .map(ToOwned::to_owned)
2098 .unwrap_or_else(|| package_name.replace('-', "_"));
2099
2100 let lib_root = cargo
2101 .get("lib")
2102 .and_then(|lib| lib.get("path"))
2103 .and_then(|path| path.as_str())
2104 .map(|path| crate_root.join(path))
2105 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
2106 let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
2107
2108 let main_root = crate_root.join("src/main.rs");
2109 let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
2110
2111 Some(RustCrateInfo {
2112 lib_name,
2113 lib_root,
2114 main_root,
2115 })
2116}
2117
2118fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
2119 let source = std::fs::read_to_string(path).ok()?;
2120 toml::from_str(&source).ok()
2121}
2122
2123fn rust_module_base_for_caller(
2124 crate_info: &RustCrateInfo,
2125 caller_file: &Path,
2126) -> Option<RustModuleBase> {
2127 let caller = canonicalize_path(caller_file);
2128 if crate_info.main_root.as_ref() == Some(&caller) {
2129 return rust_main_module_base(crate_info);
2130 }
2131 rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
2132}
2133
2134fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
2135 let root_file = crate_info.lib_root.clone()?;
2136 let src_dir = root_file.parent()?.to_path_buf();
2137 Some(RustModuleBase { src_dir, root_file })
2138}
2139
2140fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
2141 let root_file = crate_info.main_root.clone()?;
2142 let src_dir = root_file.parent()?.to_path_buf();
2143 Some(RustModuleBase { src_dir, root_file })
2144}
2145
2146fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
2147 if segments.is_empty() {
2148 return Some(base.root_file.clone());
2149 }
2150
2151 let module_base = segments
2152 .iter()
2153 .fold(base.src_dir.clone(), |path, segment| path.join(segment));
2154 let file_path = module_base.with_extension("rs");
2155 if file_path.is_file() {
2156 return Some(canonicalize_path(&file_path));
2157 }
2158
2159 let mod_path = module_base.join("mod.rs");
2160 if mod_path.is_file() {
2161 return Some(canonicalize_path(&mod_path));
2162 }
2163
2164 None
2165}
2166
2167fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
2168 let src_dir = canonicalize_path(src_dir);
2169 let file = canonicalize_path(file);
2170 let rel = file.strip_prefix(&src_dir).ok()?;
2171 let mut parts: Vec<String> = rel
2172 .components()
2173 .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
2174 .collect();
2175 if parts.is_empty() {
2176 return None;
2177 }
2178
2179 let last = parts.pop()?;
2180 if last == "lib.rs" || last == "main.rs" {
2181 return Some(Vec::new());
2182 }
2183 if last == "mod.rs" {
2184 return Some(parts);
2185 }
2186 let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
2187 parts.push(stem);
2188 Some(parts)
2189}
2190
2191fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
2192 let workspace_root =
2193 find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
2194 let workspace_root = canonicalize_path(&workspace_root);
2195
2196 if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
2197 .read()
2198 .ok()
2199 .and_then(|cache| cache.get(&workspace_root).cloned())
2200 {
2201 return Some(cached);
2202 }
2203
2204 let mut crates = HashMap::new();
2205 for member in rust_workspace_member_dirs(&workspace_root) {
2206 if let Some(info) = rust_crate_info(&member) {
2207 if info.lib_root.is_some() {
2208 crates.insert(info.lib_name.clone(), info);
2209 }
2210 }
2211 }
2212 if let Some(info) = rust_crate_info(&workspace_root) {
2213 if info.lib_root.is_some() {
2214 crates.insert(info.lib_name.clone(), info);
2215 }
2216 }
2217
2218 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2219 cache.insert(workspace_root, crates.clone());
2220 }
2221 Some(crates)
2222}
2223
2224fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2225 let mut current = Some(from_dir);
2226 while let Some(dir) = current {
2227 let cargo = dir.join("Cargo.toml");
2228 if rust_manifest_value(&cargo)
2229 .and_then(|value| value.get("workspace").cloned())
2230 .is_some()
2231 {
2232 return Some(canonicalize_path(dir));
2233 }
2234 current = dir.parent();
2235 }
2236 None
2237}
2238
2239fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2240 let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
2241 return Vec::new();
2242 };
2243 let Some(members) = cargo
2244 .get("workspace")
2245 .and_then(|workspace| workspace.get("members"))
2246 .and_then(|members| members.as_array())
2247 else {
2248 return Vec::new();
2249 };
2250
2251 let mut dirs = Vec::new();
2252 for member in members.iter().filter_map(|member| member.as_str()) {
2253 dirs.extend(expand_rust_workspace_member(workspace_root, member));
2254 }
2255 dirs.sort();
2256 dirs.dedup();
2257 dirs
2258}
2259
2260fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
2261 let member = member.trim();
2262 if member.is_empty() {
2263 return Vec::new();
2264 }
2265
2266 if member.contains('*') || member.contains('?') || member.contains('[') {
2267 let pattern = workspace_root.join(member).to_string_lossy().to_string();
2268 return crate::walk_boundary::expand_glob_same_file_system(&pattern)
2269 .unwrap_or_default()
2270 .into_iter()
2271 .filter(|path| path.join("Cargo.toml").is_file())
2272 .map(|path| canonicalize_path(&path))
2273 .collect();
2274 }
2275
2276 let path = workspace_root.join(member);
2277 if path.join("Cargo.toml").is_file() {
2278 vec![canonicalize_path(&path)]
2279 } else {
2280 Vec::new()
2281 }
2282}
2283
2284fn canonicalize_path(path: &Path) -> PathBuf {
2285 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2286}
2287
2288fn resolve_tsconfig_path(
2289 from_dir: &Path,
2290 module_path: &str,
2291 memo: Option<&ModuleResolutionMemo>,
2292) -> Option<PathBuf> {
2293 let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2294 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"), memo)?;
2295 let compiler_options = tsconfig.get("compilerOptions")?;
2296 let paths = compiler_options.get("paths")?.as_object()?;
2297 let base_url = compiler_options
2298 .get("baseUrl")
2299 .and_then(Value::as_str)
2300 .unwrap_or(".");
2301 let base_dir = tsconfig_dir.join(base_url);
2302
2303 for (alias, targets) in paths {
2304 let Some(capture) = ts_path_capture(alias, module_path) else {
2305 continue;
2306 };
2307 let Some(targets) = targets.as_array() else {
2308 continue;
2309 };
2310 for target in targets.iter().filter_map(Value::as_str) {
2311 let target = if target.contains('*') {
2312 target.replace('*', capture)
2313 } else {
2314 target.to_string()
2315 };
2316 if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2317 return Some(path);
2318 }
2319 }
2320 }
2321
2322 None
2323}
2324
2325fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2326 let mut current = Some(from_dir);
2327 while let Some(dir) = current {
2328 if dir.join("tsconfig.json").is_file() {
2329 return Some(dir.to_path_buf());
2330 }
2331 current = dir.parent();
2332 }
2333 None
2334}
2335
2336fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2337 if let Some(star_index) = alias.find('*') {
2338 let (prefix, suffix_with_star) = alias.split_at(star_index);
2339 let suffix = &suffix_with_star[1..];
2340 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2341 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2342 }
2343 return None;
2344 }
2345
2346 (alias == module_path).then_some("")
2347}
2348
2349fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2350 let mut parts = module_path.split('/');
2351 let first = parts.next()?;
2352 if first.is_empty() {
2353 return None;
2354 }
2355
2356 if first.starts_with('@') {
2357 let second = parts.next()?;
2358 if second.is_empty() {
2359 return None;
2360 }
2361 let package_name = format!("{first}/{second}");
2362 let subpath = parts.collect::<Vec<_>>().join("/");
2363 let subpath = (!subpath.is_empty()).then_some(subpath);
2364 Some((package_name, subpath))
2365 } else {
2366 let package_name = first.to_string();
2367 let subpath = parts.collect::<Vec<_>>().join("/");
2368 let subpath = (!subpath.is_empty()).then_some(subpath);
2369 Some((package_name, subpath))
2370 }
2371}
2372
2373fn find_package_root_for_import(
2374 from_dir: &Path,
2375 package_name: &str,
2376 memo: Option<&ModuleResolutionMemo>,
2377) -> Option<PathBuf> {
2378 let mut current = Some(from_dir);
2379 while let Some(dir) = current {
2380 if package_json_name(dir, memo).as_deref() == Some(package_name) {
2381 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2382 }
2383 current = dir.parent();
2384 }
2385
2386 find_workspace_root(from_dir, memo)
2387 .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name, memo))
2388}
2389
2390fn find_workspace_root(from_dir: &Path, memo: Option<&ModuleResolutionMemo>) -> Option<PathBuf> {
2391 let mut current = Some(from_dir);
2392 while let Some(dir) = current {
2393 if is_workspace_root(dir, memo) {
2394 return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2395 }
2396 current = dir.parent();
2397 }
2398 None
2399}
2400
2401fn is_workspace_root(dir: &Path, memo: Option<&ModuleResolutionMemo>) -> bool {
2402 package_json_value(dir, memo)
2403 .map(|value| !workspace_patterns(&value).is_empty())
2404 .unwrap_or(false)
2405 || !pnpm_workspace_patterns(dir).is_empty()
2406}
2407
2408pub(crate) fn clear_workspace_package_cache() {
2409 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2410 cache.clear();
2411 }
2412 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2413 cache.clear();
2414 }
2415 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2416 cache.clear();
2417 }
2418}
2419
2420fn resolve_workspace_package(
2421 workspace_root: &Path,
2422 package_name: &str,
2423 memo: Option<&ModuleResolutionMemo>,
2424) -> Option<PathBuf> {
2425 let workspace_root =
2426 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2427 let cache_key = (workspace_root.clone(), package_name.to_string());
2428
2429 if let Some(memo) = memo {
2430 if let Some(cached) = memo.workspace_package(&cache_key) {
2431 return cached;
2432 }
2433 } else if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2434 if let Some(cached) = cache.get(&cache_key) {
2435 return cached.clone();
2436 }
2437 }
2438
2439 let resolved = workspace_member_dirs(&workspace_root, memo)
2440 .into_iter()
2441 .find(|dir| package_json_name(dir, memo).as_deref() == Some(package_name))
2442 .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2443
2444 if let Some(memo) = memo {
2445 memo.remember_workspace_package(cache_key, resolved.clone());
2446 } else if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2447 cache.insert(cache_key, resolved.clone());
2448 }
2449
2450 resolved
2451}
2452
2453fn workspace_member_dirs(
2454 workspace_root: &Path,
2455 memo: Option<&ModuleResolutionMemo>,
2456) -> Vec<PathBuf> {
2457 let mut patterns = package_json_value(workspace_root, memo)
2458 .map(|package_json| workspace_patterns(&package_json))
2459 .unwrap_or_default();
2460 patterns.extend(pnpm_workspace_patterns(workspace_root));
2461
2462 expand_workspace_patterns(workspace_root, &patterns)
2463}
2464
2465fn workspace_patterns(package_json: &Value) -> Vec<String> {
2466 match package_json.get("workspaces") {
2467 Some(Value::Array(items)) => items
2468 .iter()
2469 .filter_map(non_empty_workspace_pattern)
2470 .collect(),
2471 Some(Value::Object(map)) => map
2472 .get("packages")
2473 .and_then(Value::as_array)
2474 .map(|items| {
2475 items
2476 .iter()
2477 .filter_map(non_empty_workspace_pattern)
2478 .collect()
2479 })
2480 .unwrap_or_default(),
2481 _ => Vec::new(),
2482 }
2483}
2484
2485fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2486 let pattern = value.as_str()?.trim();
2487 (!pattern.is_empty()).then(|| pattern.to_string())
2488}
2489
2490fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2491 let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2492 return Vec::new();
2493 };
2494
2495 let mut patterns = Vec::new();
2496 let mut in_packages = false;
2497 for line in source.lines() {
2498 let without_comment = line.split('#').next().unwrap_or("").trim_end();
2499 let trimmed = without_comment.trim();
2500 if trimmed.is_empty() {
2501 continue;
2502 }
2503 if trimmed == "packages:" {
2504 in_packages = true;
2505 continue;
2506 }
2507 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2508 in_packages = false;
2509 }
2510 if in_packages {
2511 if let Some(pattern) = trimmed.strip_prefix('-') {
2512 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2513 if !pattern.is_empty() {
2514 patterns.push(pattern.to_string());
2515 }
2516 }
2517 }
2518 }
2519 patterns
2520}
2521
2522fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2523 let positive_patterns: Vec<&str> = patterns
2524 .iter()
2525 .map(|pattern| pattern.trim())
2526 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2527 .collect();
2528 if positive_patterns.is_empty() {
2529 return Vec::new();
2530 }
2531
2532 let positives = build_glob_set(&positive_patterns);
2533 let negative_patterns: Vec<&str> = patterns
2534 .iter()
2535 .map(|pattern| pattern.trim())
2536 .filter_map(|pattern| pattern.strip_prefix('!'))
2537 .map(str::trim)
2538 .filter(|pattern| !pattern.is_empty())
2539 .collect();
2540 let negatives = build_glob_set(&negative_patterns);
2541
2542 let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(workspace_root) else {
2543 return Vec::new();
2544 };
2545 let mut members = Vec::new();
2546 collect_workspace_member_dirs(
2547 workspace_root,
2548 workspace_root,
2549 &boundary,
2550 &positives,
2551 &negatives,
2552 &mut members,
2553 );
2554 members
2555}
2556
2557fn build_glob_set(patterns: &[&str]) -> GlobSet {
2558 let mut builder = GlobSetBuilder::new();
2559 for pattern in patterns {
2560 if let Ok(glob) = Glob::new(pattern) {
2561 builder.add(glob);
2562 }
2563 }
2564 builder
2565 .build()
2566 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
2567}
2568
2569fn collect_workspace_member_dirs(
2570 workspace_root: &Path,
2571 dir: &Path,
2572 boundary: &crate::walk_boundary::DeviceBoundary,
2573 positives: &GlobSet,
2574 negatives: &GlobSet,
2575 members: &mut Vec<PathBuf>,
2576) {
2577 let Ok(entries) = std::fs::read_dir(dir) else {
2578 return;
2579 };
2580
2581 for entry in entries.filter_map(Result::ok) {
2582 let path = entry.path();
2583 let Ok(file_type) = entry.file_type() else {
2584 continue;
2585 };
2586 if !file_type.is_dir() {
2587 continue;
2588 }
2589 if !boundary.should_descend(&path).unwrap_or(false) {
2592 crate::slog_warn!(
2593 "callgraph workspace-member walk skipped foreign filesystem mount {}",
2594 path.display()
2595 );
2596 continue;
2597 }
2598 let name = entry.file_name();
2599 let name = name.to_string_lossy();
2600 if matches!(
2601 name.as_ref(),
2602 "node_modules" | ".git" | "target" | "dist" | "build"
2603 ) {
2604 continue;
2605 }
2606
2607 if path.join("package.json").is_file() {
2608 if let Ok(rel) = path.strip_prefix(workspace_root) {
2609 let rel = rel.to_string_lossy().replace('\\', "/");
2610 if positives.is_match(&rel) && !negatives.is_match(&rel) {
2611 members.push(path.clone());
2612 }
2613 }
2614 }
2615
2616 collect_workspace_member_dirs(
2617 workspace_root,
2618 &path,
2619 boundary,
2620 positives,
2621 negatives,
2622 members,
2623 );
2624 }
2625}
2626
2627fn package_json_value(dir: &Path, memo: Option<&ModuleResolutionMemo>) -> Option<Arc<Value>> {
2628 package_json_like_value(&dir.join("package.json"), memo)
2629}
2630
2631fn package_json_like_value(path: &Path, memo: Option<&ModuleResolutionMemo>) -> Option<Arc<Value>> {
2632 if let Some(memo) = memo {
2633 return memo.json_value(path);
2634 }
2635 let json = std::fs::read_to_string(path).ok()?;
2636 serde_json::from_str(&json).ok().map(Arc::new)
2637}
2638
2639fn package_json_name(dir: &Path, memo: Option<&ModuleResolutionMemo>) -> Option<String> {
2640 package_json_value(dir, memo)?
2641 .get("name")?
2642 .as_str()
2643 .map(ToOwned::to_owned)
2644}
2645
2646fn resolve_package_entry(
2647 package_root: &Path,
2648 subpath: &Option<String>,
2649 memo: Option<&ModuleResolutionMemo>,
2650) -> Option<PathBuf> {
2651 let package_json =
2652 package_json_value(package_root, memo).unwrap_or_else(|| Arc::new(Value::Null));
2653
2654 if let Some(exports) = package_json.get("exports") {
2655 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2656 if let Some(path) = resolve_package_target(package_root, &target) {
2657 return Some(path);
2658 }
2659 }
2660 }
2661
2662 if subpath.is_none() {
2663 for field in ["module", "main"] {
2664 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2665 if let Some(path) = resolve_package_target(package_root, target) {
2666 return Some(path);
2667 }
2668 }
2669 }
2670 }
2671
2672 resolve_package_fallback(package_root, subpath.as_deref())
2673}
2674
2675fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2676 let key = subpath
2677 .map(|value| format!("./{value}"))
2678 .unwrap_or_else(|| ".".to_string());
2679
2680 match exports {
2681 Value::String(target) if key == "." => Some(target.clone()),
2682 Value::Object(map) => {
2683 if let Some(target) = map.get(&key).and_then(export_condition_target) {
2684 return Some(target);
2685 }
2686
2687 if let Some(target) = wildcard_export_target(map, &key) {
2688 return Some(target);
2689 }
2690
2691 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2692 return export_condition_target(exports);
2693 }
2694
2695 None
2696 }
2697 _ => None,
2698 }
2699}
2700
2701fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2702 for (pattern, target) in map {
2703 let Some(star_index) = pattern.find('*') else {
2704 continue;
2705 };
2706 let (prefix, suffix_with_star) = pattern.split_at(star_index);
2707 let suffix = &suffix_with_star[1..];
2708 if !key.starts_with(prefix) || !key.ends_with(suffix) {
2709 continue;
2710 }
2711 let matched = &key[prefix.len()..key.len() - suffix.len()];
2712 if let Some(target_pattern) = export_condition_target(target) {
2713 return Some(target_pattern.replace('*', matched));
2714 }
2715 }
2716 None
2717}
2718
2719fn export_condition_target(value: &Value) -> Option<String> {
2720 match value {
2721 Value::String(target) => Some(target.clone()),
2722 Value::Object(map) => ["source", "import", "module", "default", "types"]
2723 .into_iter()
2724 .find_map(|field| map.get(field).and_then(export_condition_target)),
2725 _ => None,
2726 }
2727}
2728
2729fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2730 let target = target.strip_prefix("./").unwrap_or(target);
2731 if let Some(src_relative) = target.strip_prefix("dist/") {
2734 if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2735 return Some(path);
2736 }
2737 }
2738
2739 resolve_file_like_path(&package_root.join(target))
2740}
2741
2742fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2743 match subpath {
2744 Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2745 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2746 None => resolve_file_like_path(&package_root.join("src").join("index"))
2747 .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2748 }
2749}
2750
2751pub(crate) fn resolve_reexported_symbol_target<F, D>(
2752 file: &Path,
2753 symbol_name: &str,
2754 file_exports_symbol: &mut F,
2755 file_default_export_symbol: &mut D,
2756) -> Option<(PathBuf, String)>
2757where
2758 F: FnMut(&Path, &str) -> bool,
2759 D: FnMut(&Path) -> Option<String>,
2760{
2761 resolve_reexported_symbol(
2762 file,
2763 symbol_name,
2764 file_exports_symbol,
2765 file_default_export_symbol,
2766 )
2767 .map(|target| (target.file, target.symbol))
2768}
2769
2770fn resolve_reexported_symbol<F, D>(
2771 file: &Path,
2772 symbol_name: &str,
2773 file_exports_symbol: &mut F,
2774 file_default_export_symbol: &mut D,
2775) -> Option<ResolvedSymbol>
2776where
2777 F: FnMut(&Path, &str) -> bool,
2778 D: FnMut(&Path) -> Option<String>,
2779{
2780 let mut visited = HashSet::new();
2781 resolve_reexported_symbol_inner(
2782 file,
2783 symbol_name,
2784 file_exports_symbol,
2785 file_default_export_symbol,
2786 &mut visited,
2787 )
2788}
2789
2790fn resolve_reexported_symbol_inner<F, D>(
2791 file: &Path,
2792 symbol_name: &str,
2793 file_exports_symbol: &mut F,
2794 file_default_export_symbol: &mut D,
2795 visited: &mut HashSet<(PathBuf, String)>,
2796) -> Option<ResolvedSymbol>
2797where
2798 F: FnMut(&Path, &str) -> bool,
2799 D: FnMut(&Path) -> Option<String>,
2800{
2801 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2802 if !visited.insert((canon.clone(), symbol_name.to_string())) {
2803 return None;
2804 }
2805
2806 let source = std::fs::read_to_string(&canon).ok()?;
2807 let lang = detect_language(&canon)?;
2808 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2809 if symbol_name == "default" {
2810 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
2811 file: canon,
2812 symbol,
2813 });
2814 }
2815 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
2816 file: canon,
2817 symbol: symbol_name.to_string(),
2818 });
2819 }
2820
2821 let grammar = grammar_for(lang);
2822 let mut parser = Parser::new();
2823 parser.set_language(&grammar).ok()?;
2824 let tree = parser.parse(&source, None)?;
2825 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2826
2827 let mut cursor = tree.root_node().walk();
2828 if !cursor.goto_first_child() {
2829 return None;
2830 }
2831
2832 loop {
2833 let node = cursor.node();
2834 if node.kind() == "export_statement" {
2835 if let Some(target) = resolve_reexport_statement(
2836 &source,
2837 node,
2838 from_dir,
2839 symbol_name,
2840 file_exports_symbol,
2841 file_default_export_symbol,
2842 visited,
2843 ) {
2844 return Some(target);
2845 }
2846 }
2847
2848 if !cursor.goto_next_sibling() {
2849 break;
2850 }
2851 }
2852
2853 if symbol_name == "default" {
2854 if let Some(symbol) = file_default_export_symbol(&canon) {
2855 return Some(ResolvedSymbol {
2856 file: canon,
2857 symbol,
2858 });
2859 }
2860 }
2861
2862 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
2863 return Some(ResolvedSymbol {
2864 file: canon,
2865 symbol,
2866 });
2867 }
2868
2869 if file_exports_symbol(&canon, symbol_name) {
2870 let symbol = symbol_name.to_string();
2871 return Some(ResolvedSymbol {
2872 file: canon,
2873 symbol,
2874 });
2875 }
2876
2877 None
2878}
2879
2880fn resolve_reexport_statement<F, D>(
2881 source: &str,
2882 node: tree_sitter::Node,
2883 from_dir: &Path,
2884 symbol_name: &str,
2885 file_exports_symbol: &mut F,
2886 file_default_export_symbol: &mut D,
2887 visited: &mut HashSet<(PathBuf, String)>,
2888) -> Option<ResolvedSymbol>
2889where
2890 F: FnMut(&Path, &str) -> bool,
2891 D: FnMut(&Path) -> Option<String>,
2892{
2893 let source_node = node
2894 .child_by_field_name("source")
2895 .or_else(|| find_child_by_kind(node, "string"))?;
2896 let module_path = string_literal_content(source, source_node)?;
2897 let target_file = resolve_module_path(from_dir, &module_path)?;
2898 let raw_export = node_text(node, source);
2899
2900 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2901 return resolve_reexported_symbol_inner(
2902 &target_file,
2903 &source_symbol,
2904 file_exports_symbol,
2905 file_default_export_symbol,
2906 visited,
2907 )
2908 .or(Some(ResolvedSymbol {
2909 file: target_file,
2910 symbol: source_symbol,
2911 }));
2912 }
2913
2914 if raw_export.contains('*') {
2915 return resolve_reexported_symbol_inner(
2916 &target_file,
2917 symbol_name,
2918 file_exports_symbol,
2919 file_default_export_symbol,
2920 visited,
2921 );
2922 }
2923
2924 None
2925}
2926
2927fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
2928 let lang = detect_language(file)?;
2929 let grammar = grammar_for(lang);
2930 let mut parser = Parser::new();
2931 parser.set_language(&grammar).ok()?;
2932 let tree = parser.parse(source, None)?;
2933
2934 let mut cursor = tree.root_node().walk();
2935 if !cursor.goto_first_child() {
2936 return None;
2937 }
2938
2939 loop {
2940 let node = cursor.node();
2941 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
2942 let raw_export = node_text(node, source);
2943 if let Some(source_symbol) =
2944 reexport_clause_source_symbol(&raw_export, requested_export)
2945 {
2946 return Some(source_symbol);
2947 }
2948 }
2949
2950 if !cursor.goto_next_sibling() {
2951 break;
2952 }
2953 }
2954
2955 None
2956}
2957
2958fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
2959 let start = raw_export.find('{')? + 1;
2960 let end = raw_export[start..].find('}')? + start;
2961 for specifier in raw_export[start..end].split(',') {
2962 let specifier = specifier.trim();
2963 if specifier.is_empty() {
2964 continue;
2965 }
2966 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
2967 if let Some((imported, exported)) = specifier.split_once(" as ") {
2968 if exported.trim() == requested_export {
2969 return Some(imported.trim().to_string());
2970 }
2971 } else if specifier == requested_export {
2972 return Some(requested_export.to_string());
2973 }
2974 }
2975 None
2976}
2977
2978fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2979 let raw = source[node.byte_range()].trim();
2980 let quote = raw.chars().next()?;
2981 if quote != '\'' && quote != '"' {
2982 return None;
2983 }
2984 raw.strip_prefix(quote)
2985 .and_then(|value| value.strip_suffix(quote))
2986 .map(ToOwned::to_owned)
2987}
2988
2989fn find_index_file(dir: &Path) -> Option<PathBuf> {
2991 for name in JS_TS_INDEX_FILES {
2992 let p = dir.join(name);
2993 if p.is_file() {
2994 return Some(std::fs::canonicalize(&p).unwrap_or(p));
2995 }
2996 }
2997 None
2998}
2999
3000fn resolve_aliased_import(
3003 local_name: &str,
3004 import_block: &ImportBlock,
3005 caller_dir: &Path,
3006) -> Option<(String, PathBuf)> {
3007 for imp in &import_block.imports {
3008 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3011 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3012 return Some((original, resolved_path));
3013 }
3014 }
3015 }
3016 None
3017}
3018
3019fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3023 let search = format!(" as {}", local_name);
3026 if let Some(pos) = raw_import.find(&search) {
3027 let before = &raw_import[..pos];
3029 let original = before
3031 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3032 .find(|s| !s.is_empty())?;
3033 return Some(original.to_string());
3034 }
3035 None
3036}
3037
3038pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3046 use ignore::WalkBuilder;
3047
3048 let walker = WalkBuilder::new(root)
3051 .same_file_system(true)
3052 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .add_custom_ignore_filename(".aftignore") .filter_entry(|entry| {
3058 let name = entry.file_name().to_string_lossy();
3059 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3061 return !matches!(
3062 name.as_ref(),
3063 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3064 | ".tox" | "dist" | "build"
3065 );
3066 }
3067 true
3068 })
3069 .build();
3070
3071 walker
3072 .filter_map(|entry| entry.ok())
3073 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3074 .filter(|entry| detect_language(entry.path()).is_some())
3075 .map(|entry| entry.into_path())
3076}
3077
3078#[cfg(test)]
3083mod tests {
3084 use super::*;
3085 use std::fs;
3086 use tempfile::TempDir;
3087
3088 fn collect_calls_by_symbol_reference(
3089 source: &str,
3090 root: Node<'_>,
3091 lang: LangId,
3092 symbols: &[Symbol],
3093 ) -> HashMap<String, Vec<CallSite>> {
3094 let mut calls_by_symbol = HashMap::new();
3095 for symbol in symbols {
3096 let byte_start =
3097 line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col);
3098 let byte_end = line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col);
3099 let sites = extract_calls_full(source, root, byte_start, byte_end, lang)
3100 .into_iter()
3101 .map(
3102 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
3103 callee_name: short,
3104 full_callee: full,
3105 line,
3106 byte_start: call_byte_start,
3107 byte_end: call_byte_end,
3108 },
3109 )
3110 .collect::<Vec<_>>();
3111 if !sites.is_empty() {
3112 calls_by_symbol.insert(symbol_identity(symbol), sites);
3113 }
3114 }
3115
3116 let symbol_ranges = symbols
3117 .iter()
3118 .map(|symbol| {
3119 (
3120 line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col),
3121 line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col),
3122 )
3123 })
3124 .collect::<Vec<_>>();
3125 let top_level_sites = collect_calls_full_with_ranges(root, source, 0, source.len(), lang)
3126 .into_iter()
3127 .filter(|site| {
3128 !symbol_ranges
3129 .iter()
3130 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
3131 })
3132 .map(|site| CallSite {
3133 callee_name: site.short,
3134 full_callee: site.full,
3135 line: site.line,
3136 byte_start: site.byte_start,
3137 byte_end: site.byte_end,
3138 })
3139 .collect::<Vec<_>>();
3140 if !top_level_sites.is_empty() {
3141 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
3142 }
3143 calls_by_symbol
3144 }
3145
3146 fn parse_symbols(source: &str, lang: LangId) -> (tree_sitter::Tree, Vec<Symbol>) {
3147 let mut parser = Parser::new();
3148 parser.set_language(&grammar_for(lang)).unwrap();
3149 let tree = parser.parse(source, None).unwrap();
3150 let symbols = crate::parser::extract_symbols_from_tree(source, &tree, lang).unwrap();
3151 (tree, symbols)
3152 }
3153
3154 fn test_symbol(name: &str, start_col: u32, end_col: u32) -> Symbol {
3155 Symbol {
3156 name: name.to_string(),
3157 kind: SymbolKind::Function,
3158 range: Range {
3159 start_line: 0,
3160 start_col,
3161 end_line: 0,
3162 end_col,
3163 },
3164 signature: None,
3165 scope_chain: Vec::new(),
3166 exported: false,
3167 parent: None,
3168 }
3169 }
3170
3171 #[test]
3172 fn source_line_index_matches_shared_line_column_conversion() {
3173 let source = "a\r\nbb\rc\n";
3174 let index = SourceLineIndex::new(source);
3175 for line in 0..=5 {
3176 for column in 0..=5 {
3177 assert_eq!(
3178 index.byte_offset(line, column),
3179 line_col_to_byte(source, line, column),
3180 "line={line}, column={column}"
3181 );
3182 }
3183 }
3184 }
3185
3186 #[test]
3187 fn single_pass_call_attribution_matches_per_symbol_reference() {
3188 let corpora = [
3189 (
3190 "typescript",
3191 LangId::TypeScript,
3192 r#"bootstrap();
3193class Worker {
3194 run() {
3195 before();
3196 function nested() { nestedCall(); }
3197 nested();
3198 }
3199 next() { adjacentCall(); }
3200}
3201function left() { leftCall(); }
3202function right() { rightCall(); }
3203"#,
3204 ),
3205 (
3206 "python",
3207 LangId::Python,
3208 r#"bootstrap()
3209class Worker:
3210 def run(self):
3211 before()
3212 def nested():
3213 nested_call()
3214 nested()
3215
3216 def next(self):
3217 adjacent_call()
3218
3219def left():
3220 left_call()
3221
3222def right():
3223 right_call()
3224"#,
3225 ),
3226 ];
3227
3228 for (name, lang, source) in corpora {
3229 let (tree, symbols) = parse_symbols(source, lang);
3230 let reference =
3231 collect_calls_by_symbol_reference(source, tree.root_node(), lang, &symbols);
3232 let actual = collect_calls_by_symbol(source, tree.root_node(), lang, &symbols);
3233 assert_eq!(actual, reference, "call attribution changed for {name}");
3234
3235 let class_sites = actual.get("Worker").expect("class receives method calls");
3236 let method_sites = actual
3237 .get("Worker::run")
3238 .expect("method receives its own calls");
3239 assert!(class_sites.iter().any(|site| site.callee_name == "before"));
3240 assert!(method_sites.iter().any(|site| site.callee_name == "before"));
3241 let nested_sites = actual
3242 .iter()
3243 .find(|(symbol, _)| symbol.rsplit("::").next() == Some("nested"))
3244 .map(|(_, sites)| sites)
3245 .expect("nested function receives its own calls");
3246 assert!(nested_sites.iter().any(|site| {
3247 site.callee_name == "nested_call" || site.callee_name == "nestedCall"
3248 }));
3249 assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "bootstrap");
3250 }
3251
3252 let source = "first();second();third();";
3253 let (tree, _) = parse_symbols(source, LangId::TypeScript);
3254 let symbols = vec![
3255 test_symbol("outer", 0, 17),
3256 test_symbol("left", 0, 8),
3257 test_symbol("right", 8, 17),
3258 test_symbol("empty", 24, 24),
3259 ];
3260 let reference = collect_calls_by_symbol_reference(
3261 source,
3262 tree.root_node(),
3263 LangId::TypeScript,
3264 &symbols,
3265 );
3266 let actual =
3267 collect_calls_by_symbol(source, tree.root_node(), LangId::TypeScript, &symbols);
3268 assert_eq!(actual, reference, "overlapping and adjacent ranges changed");
3269 assert_eq!(
3270 actual["outer"]
3271 .iter()
3272 .map(|site| site.callee_name.as_str())
3273 .collect::<Vec<_>>(),
3274 ["first", "second"]
3275 );
3276 assert_eq!(actual["left"][0].callee_name, "first");
3277 assert_eq!(actual["right"][0].callee_name, "second");
3278 assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "third");
3279 assert!(!actual.contains_key("empty"));
3280 }
3281
3282 #[test]
3283 fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
3284 let mut symbol_metadata = HashMap::new();
3289 symbol_metadata.insert(
3290 "BackupStore::total_disk_bytes".to_string(),
3291 SymbolMeta {
3292 kind: SymbolKind::Method,
3293 exported: true,
3294 signature: None,
3295 line: 703,
3296 range: Range {
3297 start_line: 702,
3298 start_col: 0,
3299 end_line: 705,
3300 end_col: 0,
3301 },
3302 entry_point_attribute: None,
3303 },
3304 );
3305 let file_data = FileCallData {
3306 calls_by_symbol: HashMap::new(),
3307 value_refs_by_symbol: HashMap::new(),
3308 exported_symbols: vec!["total_disk_bytes".to_string()],
3309 symbol_metadata,
3310 default_export_symbol: None,
3311 import_block: ImportBlock::empty(),
3312 lang: LangId::Rust,
3313 };
3314
3315 let meta = file_data
3316 .symbol_metadata_for("total_disk_bytes")
3317 .expect("scoped method recovered by bare name");
3318 assert_eq!(meta.kind, SymbolKind::Method);
3319 assert_eq!(
3320 meta.line, 703,
3321 "real declaration line, not the line-1 fallback"
3322 );
3323
3324 assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
3326 }
3327
3328 fn setup_ts_project() -> TempDir {
3330 let dir = TempDir::new().unwrap();
3331
3332 fs::write(
3334 dir.path().join("main.ts"),
3335 r#"import { helper, compute } from './utils';
3336import * as math from './math';
3337
3338export function main() {
3339 const a = helper(1);
3340 const b = compute(a, 2);
3341 const c = math.add(a, b);
3342 return c;
3343}
3344"#,
3345 )
3346 .unwrap();
3347
3348 fs::write(
3350 dir.path().join("utils.ts"),
3351 r#"import { double } from './helpers';
3352
3353export function helper(x: number): number {
3354 return double(x);
3355}
3356
3357export function compute(a: number, b: number): number {
3358 return a + b;
3359}
3360"#,
3361 )
3362 .unwrap();
3363
3364 fs::write(
3366 dir.path().join("helpers.ts"),
3367 r#"export function double(x: number): number {
3368 return x * 2;
3369}
3370
3371export function triple(x: number): number {
3372 return x * 3;
3373}
3374"#,
3375 )
3376 .unwrap();
3377
3378 fs::write(
3380 dir.path().join("math.ts"),
3381 r#"export function add(a: number, b: number): number {
3382 return a + b;
3383}
3384
3385export function subtract(a: number, b: number): number {
3386 return a - b;
3387}
3388"#,
3389 )
3390 .unwrap();
3391
3392 dir
3393 }
3394
3395 fn setup_alias_project() -> TempDir {
3397 let dir = TempDir::new().unwrap();
3398
3399 fs::write(
3400 dir.path().join("main.ts"),
3401 r#"import { helper as h } from './utils';
3402
3403export function main() {
3404 return h(42);
3405}
3406"#,
3407 )
3408 .unwrap();
3409
3410 fs::write(
3411 dir.path().join("utils.ts"),
3412 r#"export function helper(x: number): number {
3413 return x + 1;
3414}
3415"#,
3416 )
3417 .unwrap();
3418
3419 dir
3420 }
3421
3422 #[test]
3425 fn callgraph_single_file_call_extraction() {
3426 let dir = setup_ts_project();
3427 let mut graph = CallGraph::new(dir.path().to_path_buf());
3428
3429 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3430 let main_calls = &file_data.calls_by_symbol["main"];
3431
3432 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3433 assert!(
3434 callee_names.contains(&"helper"),
3435 "main should call helper, got: {:?}",
3436 callee_names
3437 );
3438 assert!(
3439 callee_names.contains(&"compute"),
3440 "main should call compute, got: {:?}",
3441 callee_names
3442 );
3443 assert!(
3444 callee_names.contains(&"add"),
3445 "main should call math.add (short name: add), got: {:?}",
3446 callee_names
3447 );
3448 }
3449
3450 #[test]
3451 fn callgraph_file_data_has_exports() {
3452 let dir = setup_ts_project();
3453 let mut graph = CallGraph::new(dir.path().to_path_buf());
3454
3455 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3456 assert!(
3457 file_data.exported_symbols.contains(&"helper".to_string()),
3458 "utils.ts should export helper, got: {:?}",
3459 file_data.exported_symbols
3460 );
3461 assert!(
3462 file_data.exported_symbols.contains(&"compute".to_string()),
3463 "utils.ts should export compute, got: {:?}",
3464 file_data.exported_symbols
3465 );
3466 }
3467
3468 #[test]
3471 fn callgraph_resolve_direct_import() {
3472 let dir = setup_ts_project();
3473 let mut graph = CallGraph::new(dir.path().to_path_buf());
3474
3475 let main_path = dir.path().join("main.ts");
3476 let file_data = graph.build_file(&main_path).unwrap();
3477 let import_block = file_data.import_block.clone();
3478
3479 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3480 match edge {
3481 EdgeResolution::Resolved { file, symbol } => {
3482 assert!(
3483 file.ends_with("utils.ts"),
3484 "helper should resolve to utils.ts, got: {:?}",
3485 file
3486 );
3487 assert_eq!(symbol, "helper");
3488 }
3489 EdgeResolution::Unresolved { callee_name } => {
3490 panic!("Expected resolved, got unresolved: {}", callee_name);
3491 }
3492 }
3493 }
3494
3495 #[test]
3496 fn callgraph_resolve_namespace_import() {
3497 let dir = setup_ts_project();
3498 let mut graph = CallGraph::new(dir.path().to_path_buf());
3499
3500 let main_path = dir.path().join("main.ts");
3501 let file_data = graph.build_file(&main_path).unwrap();
3502 let import_block = file_data.import_block.clone();
3503
3504 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3505 match edge {
3506 EdgeResolution::Resolved { file, symbol } => {
3507 assert!(
3508 file.ends_with("math.ts"),
3509 "math.add should resolve to math.ts, got: {:?}",
3510 file
3511 );
3512 assert_eq!(symbol, "add");
3513 }
3514 EdgeResolution::Unresolved { callee_name } => {
3515 panic!("Expected resolved, got unresolved: {}", callee_name);
3516 }
3517 }
3518 }
3519
3520 #[test]
3521 fn callgraph_resolve_aliased_import() {
3522 let dir = setup_alias_project();
3523 let mut graph = CallGraph::new(dir.path().to_path_buf());
3524
3525 let main_path = dir.path().join("main.ts");
3526 let file_data = graph.build_file(&main_path).unwrap();
3527 let import_block = file_data.import_block.clone();
3528
3529 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3530 match edge {
3531 EdgeResolution::Resolved { file, symbol } => {
3532 assert!(
3533 file.ends_with("utils.ts"),
3534 "h (alias for helper) should resolve to utils.ts, got: {:?}",
3535 file
3536 );
3537 assert_eq!(symbol, "helper");
3538 }
3539 EdgeResolution::Unresolved { callee_name } => {
3540 panic!("Expected resolved, got unresolved: {}", callee_name);
3541 }
3542 }
3543 }
3544
3545 #[test]
3546 fn callgraph_unresolved_edge_marked() {
3547 let dir = setup_ts_project();
3548 let mut graph = CallGraph::new(dir.path().to_path_buf());
3549
3550 let main_path = dir.path().join("main.ts");
3551 let file_data = graph.build_file(&main_path).unwrap();
3552 let import_block = file_data.import_block.clone();
3553
3554 let edge =
3555 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3556 assert_eq!(
3557 edge,
3558 EdgeResolution::Unresolved {
3559 callee_name: "unknownFunc".to_string()
3560 },
3561 "Unknown callee should be unresolved"
3562 );
3563 }
3564
3565 #[test]
3568 fn callgraph_walker_excludes_gitignored() {
3569 let dir = TempDir::new().unwrap();
3570
3571 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3573
3574 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3576 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3577 fs::write(
3578 dir.path().join("ignored_dir").join("secret.ts"),
3579 "export function secret() {}",
3580 )
3581 .unwrap();
3582
3583 fs::create_dir(dir.path().join("node_modules")).unwrap();
3585 fs::write(
3586 dir.path().join("node_modules").join("dep.ts"),
3587 "export function dep() {}",
3588 )
3589 .unwrap();
3590
3591 let mut command = std::process::Command::new("git");
3593 crate::test_env::apply_hermetic_git_env(command.current_dir(dir.path()))
3594 .args(["init"])
3595 .output()
3596 .unwrap();
3597
3598 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3599 let file_names: Vec<String> = files
3600 .iter()
3601 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3602 .collect();
3603
3604 assert!(
3605 file_names.contains(&"main.ts".to_string()),
3606 "Should include main.ts, got: {:?}",
3607 file_names
3608 );
3609 assert!(
3610 !file_names.contains(&"secret.ts".to_string()),
3611 "Should exclude gitignored secret.ts, got: {:?}",
3612 file_names
3613 );
3614 assert!(
3615 !file_names.contains(&"dep.ts".to_string()),
3616 "Should exclude node_modules, got: {:?}",
3617 file_names
3618 );
3619 }
3620
3621 #[test]
3622 fn callgraph_walker_excludes_aftignored() {
3623 let dir = TempDir::new().unwrap();
3624
3625 fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
3627 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3628 fs::create_dir(dir.path().join("vendored")).unwrap();
3629 fs::write(
3630 dir.path().join("vendored").join("sub.ts"),
3631 "export function sub() {}",
3632 )
3633 .unwrap();
3634
3635 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3636 let file_names: Vec<String> = files
3637 .iter()
3638 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3639 .collect();
3640
3641 assert!(
3642 file_names.contains(&"main.ts".to_string()),
3643 "Should include main.ts, got: {:?}",
3644 file_names
3645 );
3646 assert!(
3647 !file_names.contains(&"sub.ts".to_string()),
3648 "Should exclude .aftignored sub.ts, got: {:?}",
3649 file_names
3650 );
3651 }
3652
3653 #[test]
3654 fn callgraph_walker_only_source_files() {
3655 let dir = TempDir::new().unwrap();
3656
3657 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3658 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
3659 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
3660 fs::write(
3661 dir.path().join("runtime.mjs"),
3662 "export function runtime() {}",
3663 )
3664 .unwrap();
3665 fs::write(
3666 dir.path().join("legacy.cjs"),
3667 "exports.legacy = function() {};",
3668 )
3669 .unwrap();
3670 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
3671 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3672 fs::write(dir.path().join("data.json"), "{}").unwrap();
3673
3674 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3675 let file_names: Vec<String> = files
3676 .iter()
3677 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3678 .collect();
3679
3680 assert!(file_names.contains(&"main.ts".to_string()));
3681 for modern_ext_file in [
3682 "module.mts",
3683 "common.cts",
3684 "runtime.mjs",
3685 "legacy.cjs",
3686 "types.pyi",
3687 ] {
3688 assert!(
3689 file_names.contains(&modern_ext_file.to_string()),
3690 "walker should include {modern_ext_file}, got: {:?}",
3691 file_names
3692 );
3693 }
3694 assert!(
3695 file_names.contains(&"readme.md".to_string()),
3696 "Markdown is now a supported source language"
3697 );
3698 assert!(
3699 file_names.contains(&"data.json".to_string()),
3700 "JSON is now a supported source language"
3701 );
3702 }
3703
3704 #[test]
3707 fn callgraph_find_alias_original_simple() {
3708 let raw = "import { foo as bar } from './utils';";
3709 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3710 }
3711
3712 #[test]
3713 fn callgraph_find_alias_original_multiple() {
3714 let raw = "import { foo as bar, baz as qux } from './utils';";
3715 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3716 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3717 }
3718
3719 #[test]
3720 fn callgraph_find_alias_no_match() {
3721 let raw = "import { foo } from './utils';";
3722 assert_eq!(find_alias_original(raw, "foo"), None);
3723 }
3724
3725 #[test]
3728 fn is_entry_point_exported_function() {
3729 assert!(is_entry_point(
3730 "handleRequest",
3731 &SymbolKind::Function,
3732 true,
3733 LangId::TypeScript
3734 ));
3735 }
3736
3737 #[test]
3738 fn is_entry_point_exported_method_is_not_entry() {
3739 assert!(!is_entry_point(
3741 "handleRequest",
3742 &SymbolKind::Method,
3743 true,
3744 LangId::TypeScript
3745 ));
3746 }
3747
3748 #[test]
3749 fn is_entry_point_main_init_patterns() {
3750 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3751 assert!(
3752 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3753 "{} should be an entry point",
3754 name
3755 );
3756 }
3757 }
3758
3759 #[test]
3760 fn is_entry_point_test_patterns_ts() {
3761 assert!(is_entry_point(
3762 "describe",
3763 &SymbolKind::Function,
3764 false,
3765 LangId::TypeScript
3766 ));
3767 assert!(is_entry_point(
3768 "it",
3769 &SymbolKind::Function,
3770 false,
3771 LangId::TypeScript
3772 ));
3773 assert!(is_entry_point(
3774 "test",
3775 &SymbolKind::Function,
3776 false,
3777 LangId::TypeScript
3778 ));
3779 assert!(is_entry_point(
3780 "testValidation",
3781 &SymbolKind::Function,
3782 false,
3783 LangId::TypeScript
3784 ));
3785 assert!(is_entry_point(
3786 "specHelper",
3787 &SymbolKind::Function,
3788 false,
3789 LangId::TypeScript
3790 ));
3791 }
3792
3793 #[test]
3794 fn is_entry_point_test_patterns_python() {
3795 assert!(is_entry_point(
3796 "test_login",
3797 &SymbolKind::Function,
3798 false,
3799 LangId::Python
3800 ));
3801 assert!(is_entry_point(
3802 "setUp",
3803 &SymbolKind::Function,
3804 false,
3805 LangId::Python
3806 ));
3807 assert!(is_entry_point(
3808 "tearDown",
3809 &SymbolKind::Function,
3810 false,
3811 LangId::Python
3812 ));
3813 assert!(!is_entry_point(
3815 "testSomething",
3816 &SymbolKind::Function,
3817 false,
3818 LangId::Python
3819 ));
3820 }
3821
3822 #[test]
3823 fn is_entry_point_test_patterns_rust() {
3824 assert!(is_entry_point(
3825 "test_parse",
3826 &SymbolKind::Function,
3827 false,
3828 LangId::Rust
3829 ));
3830 assert!(!is_entry_point(
3831 "TestSomething",
3832 &SymbolKind::Function,
3833 false,
3834 LangId::Rust
3835 ));
3836 }
3837
3838 #[test]
3839 fn is_entry_point_test_patterns_go() {
3840 assert!(is_entry_point(
3841 "TestParsing",
3842 &SymbolKind::Function,
3843 false,
3844 LangId::Go
3845 ));
3846 assert!(!is_entry_point(
3848 "testParsing",
3849 &SymbolKind::Function,
3850 false,
3851 LangId::Go
3852 ));
3853 }
3854
3855 #[test]
3856 fn is_entry_point_non_exported_non_main_is_not_entry() {
3857 assert!(!is_entry_point(
3858 "helperUtil",
3859 &SymbolKind::Function,
3860 false,
3861 LangId::TypeScript
3862 ));
3863 }
3864
3865 #[test]
3868 fn callgraph_symbol_metadata_populated() {
3869 let dir = setup_ts_project();
3870 let mut graph = CallGraph::new(dir.path().to_path_buf());
3871
3872 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3873 assert!(
3874 file_data.symbol_metadata.contains_key("helper"),
3875 "symbol_metadata should contain helper"
3876 );
3877 let meta = &file_data.symbol_metadata["helper"];
3878 assert_eq!(meta.kind, SymbolKind::Function);
3879 assert!(meta.exported, "helper should be exported");
3880 }
3881
3882 #[test]
3883 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
3884 let dir = TempDir::new().unwrap();
3885 fs::write(
3886 dir.path().join("main.ts"),
3887 r#"import * as lib from './index';
3888
3889export function main() {
3890 lib.helper();
3891 lib.hidden();
3892}
3893"#,
3894 )
3895 .unwrap();
3896 fs::write(
3897 dir.path().join("index.ts"),
3898 "export { helper } from './utils';\n",
3899 )
3900 .unwrap();
3901 fs::write(
3902 dir.path().join("utils.ts"),
3903 r#"export function helper() {}
3904function hidden() {}
3905"#,
3906 )
3907 .unwrap();
3908
3909 let mut graph = CallGraph::new(dir.path().to_path_buf());
3910 let main_path = dir.path().join("main.ts");
3911 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
3912
3913 let helper =
3914 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
3915 match helper {
3916 EdgeResolution::Resolved { file, symbol } => {
3917 assert!(
3918 file.ends_with("utils.ts"),
3919 "helper should resolve through barrel: {file:?}"
3920 );
3921 assert_eq!(symbol, "helper");
3922 }
3923 other => panic!("expected helper to resolve through barrel, got {other:?}"),
3924 }
3925
3926 let hidden =
3927 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
3928 assert_eq!(
3929 hidden,
3930 EdgeResolution::Unresolved {
3931 callee_name: "hidden".to_string()
3932 }
3933 );
3934 }
3935
3936 #[test]
3937 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
3938 let dir = TempDir::new().unwrap();
3939 fs::write(
3940 dir.path().join("package.json"),
3941 r#"{"workspaces":["packages/*"]}"#,
3942 )
3943 .unwrap();
3944 let package_dir = dir.path().join("packages/lib");
3945 fs::create_dir_all(package_dir.join("src")).unwrap();
3946 fs::create_dir_all(package_dir.join("dist")).unwrap();
3947 fs::write(
3948 package_dir.join("package.json"),
3949 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
3950 )
3951 .unwrap();
3952 fs::write(
3953 package_dir.join("src/index.mts"),
3954 "export function helper() {}\n",
3955 )
3956 .unwrap();
3957 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
3958
3959 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
3960 assert!(
3961 resolved.ends_with("src/index.mts"),
3962 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
3963 );
3964 }
3965
3966 #[test]
3967 fn same_named_methods_use_scoped_symbol_identity() {
3968 let dir = TempDir::new().unwrap();
3969 fs::write(
3970 dir.path().join("classes.ts"),
3971 r#"class A {
3972 run() { helperA(); }
3973}
3974
3975class B {
3976 run() { helperB(); }
3977}
3978
3979function helperA() {}
3980function helperB() {}
3981"#,
3982 )
3983 .unwrap();
3984
3985 let mut graph = CallGraph::new(dir.path().to_path_buf());
3986 let path = dir.path().join("classes.ts");
3987 let data = graph.build_file(&path).unwrap();
3988
3989 assert!(
3990 data.symbol_metadata.contains_key("A::run"),
3991 "A::run metadata missing"
3992 );
3993 assert!(
3994 data.symbol_metadata.contains_key("B::run"),
3995 "B::run metadata missing"
3996 );
3997 assert!(
3998 data.calls_by_symbol["A::run"]
3999 .iter()
4000 .any(|call| call.callee_name == "helperA"),
4001 "A::run calls should not be overwritten"
4002 );
4003 assert!(
4004 data.calls_by_symbol["B::run"]
4005 .iter()
4006 .any(|call| call.callee_name == "helperB"),
4007 "B::run calls should not be overwritten"
4008 );
4009 }
4010
4011 #[test]
4014 fn extract_parameters_typescript() {
4015 let params = extract_parameters(
4016 "function processData(input: string, count: number): void",
4017 LangId::TypeScript,
4018 );
4019 assert_eq!(params, vec!["input", "count"]);
4020 }
4021
4022 #[test]
4023 fn extract_parameters_typescript_optional() {
4024 let params = extract_parameters(
4025 "function fetch(url: string, options?: RequestInit): Promise<Response>",
4026 LangId::TypeScript,
4027 );
4028 assert_eq!(params, vec!["url", "options"]);
4029 }
4030
4031 #[test]
4032 fn extract_parameters_typescript_defaults() {
4033 let params = extract_parameters(
4034 "function greet(name: string, greeting: string = \"hello\"): string",
4035 LangId::TypeScript,
4036 );
4037 assert_eq!(params, vec!["name", "greeting"]);
4038 }
4039
4040 #[test]
4041 fn extract_parameters_typescript_rest() {
4042 let params = extract_parameters(
4043 "function sum(...numbers: number[]): number",
4044 LangId::TypeScript,
4045 );
4046 assert_eq!(params, vec!["numbers"]);
4047 }
4048
4049 #[test]
4050 fn extract_parameters_python_self_skipped() {
4051 let params = extract_parameters(
4052 "def process(self, data: str, count: int) -> bool",
4053 LangId::Python,
4054 );
4055 assert_eq!(params, vec!["data", "count"]);
4056 }
4057
4058 #[test]
4059 fn extract_parameters_python_no_self() {
4060 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4061 assert_eq!(params, vec!["input"]);
4062 }
4063
4064 #[test]
4065 fn extract_parameters_python_star_args() {
4066 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4067 assert_eq!(params, vec!["args", "kwargs"]);
4068 }
4069
4070 #[test]
4071 fn extract_parameters_rust_self_skipped() {
4072 let params = extract_parameters(
4073 "fn process(&self, data: &str, count: usize) -> bool",
4074 LangId::Rust,
4075 );
4076 assert_eq!(params, vec!["data", "count"]);
4077 }
4078
4079 #[test]
4080 fn extract_parameters_rust_mut_self_skipped() {
4081 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4082 assert_eq!(params, vec!["value"]);
4083 }
4084
4085 #[test]
4086 fn extract_parameters_rust_no_self() {
4087 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4088 assert_eq!(params, vec!["input"]);
4089 }
4090
4091 #[test]
4092 fn extract_parameters_rust_mut_param() {
4093 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4094 assert_eq!(params, vec!["buf", "len"]);
4095 }
4096
4097 #[test]
4098 fn extract_parameters_go() {
4099 let params = extract_parameters(
4100 "func ProcessData(input string, count int) error",
4101 LangId::Go,
4102 );
4103 assert_eq!(params, vec!["input", "count"]);
4104 }
4105
4106 #[test]
4107 fn extract_parameters_empty() {
4108 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4109 assert!(
4110 params.is_empty(),
4111 "no-arg function should return empty params"
4112 );
4113 }
4114
4115 #[test]
4116 fn extract_parameters_no_parens() {
4117 let params = extract_parameters("const x = 42", LangId::TypeScript);
4118 assert!(params.is_empty(), "no parens should return empty params");
4119 }
4120
4121 #[test]
4122 fn extract_parameters_javascript() {
4123 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4124 assert_eq!(params, vec!["event", "target"]);
4125 }
4126}