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
17use crate::callgraph_store::disk_facts::DiskFacts;
18use crate::callgraph_store::facts::{byte_path, EntryKind, FactPaths};
19#[cfg(test)]
20use crate::calls::{call_node_kinds, extract_callee_name, extract_full_callee};
21use crate::calls::{extract_calls_full, extract_rust_value_references};
22#[cfg(test)]
23use crate::edit::line_col_to_byte;
24use crate::error::AftError;
25use crate::imports::{self, ImportBlock};
26use crate::parser::{detect_language, grammar_for, LangId};
27use crate::symbols::{Range, Symbol, SymbolKind};
28
29type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
34type WorkspaceMemberDirsCache = HashMap<PathBuf, Arc<Vec<PathBuf>>>;
39type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
40type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
41
42static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
43 LazyLock::new(|| RwLock::new(HashMap::new()));
44static WORKSPACE_MEMBER_DIRS_CACHE: LazyLock<RwLock<WorkspaceMemberDirsCache>> =
45 LazyLock::new(|| RwLock::new(HashMap::new()));
46static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
47 LazyLock::new(|| RwLock::new(HashMap::new()));
48static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
49 LazyLock::new(|| RwLock::new(HashMap::new()));
50
51const MODULE_RESOLUTION_MEMO_MAX_ENTRIES: usize = 32_768;
55const MODULE_RESOLUTION_MEMO_MAX_RETAINED_BYTES: usize = 3 * 1024 * 1024;
56const JSON_VALUE_MEMO_MAX_ENTRIES: usize = 8_192;
57const JSON_VALUE_MEMO_MAX_RETAINED_BYTES: usize = 3 * 1024 * 1024;
58const WORKSPACE_PACKAGE_MEMO_MAX_ENTRIES: usize = 2_048;
59const WORKSPACE_PACKAGE_MEMO_MAX_RETAINED_BYTES: usize = 1024 * 1024;
60const RUST_DECLARED_MODULE_MEMO_MAX_ENTRIES: usize = 8_192;
61const RUST_DECLARED_MODULE_MEMO_MAX_RETAINED_BYTES: usize = 2 * 1024 * 1024;
62const MEMO_ENTRY_OVERHEAD_BYTES: usize = 128;
63
64type ModuleResolutionKey = (PathBuf, String);
65type WorkspacePackageKey = (PathBuf, String);
66type RustDeclaredModuleMap = HashMap<String, Option<String>>;
67
68#[derive(Clone, Debug)]
69struct RustCrateTargets {
70 lib_root: Option<PathBuf>,
71 target_roots: Vec<PathBuf>,
72}
73
74#[derive(Clone, Default)]
75pub(crate) struct RustCrateRootMemo {
76 caller_roots: std::rc::Rc<RefCell<HashMap<PathBuf, Option<PathBuf>>>>,
77 crate_targets: std::rc::Rc<RefCell<HashMap<PathBuf, Option<RustCrateTargets>>>>,
78}
79
80struct BoundedMemo<K, V> {
81 entries: HashMap<K, V>,
82 retained_weight: usize,
83 max_entries: usize,
84 max_retained_weight: usize,
85}
86
87impl<K: Eq + std::hash::Hash, V> BoundedMemo<K, V> {
88 fn new(max_entries: usize, max_retained_weight: usize) -> Self {
89 Self {
90 entries: HashMap::new(),
91 retained_weight: 0,
92 max_entries,
93 max_retained_weight,
94 }
95 }
96
97 fn get<Q>(&self, key: &Q) -> Option<&V>
98 where
99 K: std::borrow::Borrow<Q>,
100 Q: Eq + std::hash::Hash + ?Sized,
101 {
102 self.entries.get(key)
103 }
104
105 fn insert(&mut self, key: K, value: V, retained_weight: usize) {
106 if self.entries.contains_key(&key)
107 || self.entries.len() >= self.max_entries
108 || self.retained_weight.saturating_add(retained_weight) > self.max_retained_weight
109 {
110 return;
111 }
112 self.retained_weight += retained_weight;
113 self.entries.insert(key, value);
114 }
115}
116
117pub(crate) struct ModuleResolutionMemo {
122 enabled: bool,
123 module_paths: RefCell<BoundedMemo<ModuleResolutionKey, Option<PathBuf>>>,
124 json_values: RefCell<BoundedMemo<PathBuf, Option<Arc<Value>>>>,
125 workspace_packages: RefCell<BoundedMemo<WorkspacePackageKey, Option<PathBuf>>>,
126 rust_declared_modules: RefCell<BoundedMemo<String, Arc<RustDeclaredModuleMap>>>,
127 rust_crate_roots: RustCrateRootMemo,
128 #[cfg(test)]
129 collect_metrics: bool,
130 #[cfg(test)]
131 module_computations: RefCell<HashMap<ModuleResolutionKey, usize>>,
132 #[cfg(test)]
133 json_probes: RefCell<HashMap<PathBuf, usize>>,
134 #[cfg(test)]
135 rust_declaration_parses: RefCell<HashMap<String, usize>>,
136}
137
138impl Default for ModuleResolutionMemo {
139 fn default() -> Self {
140 Self {
141 enabled: true,
142 module_paths: RefCell::new(BoundedMemo::new(
143 MODULE_RESOLUTION_MEMO_MAX_ENTRIES,
144 MODULE_RESOLUTION_MEMO_MAX_RETAINED_BYTES,
145 )),
146 json_values: RefCell::new(BoundedMemo::new(
147 JSON_VALUE_MEMO_MAX_ENTRIES,
148 JSON_VALUE_MEMO_MAX_RETAINED_BYTES,
149 )),
150 workspace_packages: RefCell::new(BoundedMemo::new(
151 WORKSPACE_PACKAGE_MEMO_MAX_ENTRIES,
152 WORKSPACE_PACKAGE_MEMO_MAX_RETAINED_BYTES,
153 )),
154 rust_declared_modules: RefCell::new(BoundedMemo::new(
155 RUST_DECLARED_MODULE_MEMO_MAX_ENTRIES,
156 RUST_DECLARED_MODULE_MEMO_MAX_RETAINED_BYTES,
157 )),
158 rust_crate_roots: RustCrateRootMemo::default(),
159 #[cfg(test)]
160 collect_metrics: false,
161 #[cfg(test)]
162 module_computations: RefCell::new(HashMap::new()),
163 #[cfg(test)]
164 json_probes: RefCell::new(HashMap::new()),
165 #[cfg(test)]
166 rust_declaration_parses: RefCell::new(HashMap::new()),
167 }
168 }
169}
170
171impl ModuleResolutionMemo {
172 fn resolve_module_path(
173 &self,
174 from_dir: &Path,
175 module_path: &str,
176 facts: &FactPaths<'_>,
177 ) -> Option<PathBuf> {
178 let key = (from_dir.to_path_buf(), module_path.to_string());
179 if self.enabled {
180 if let Some(cached) = self.module_paths.borrow().get(&key) {
181 facts.facts.memo_replay(from_dir, "module", module_path);
182 return cached.clone();
183 }
184 }
185
186 self.note_module_computation(&key);
187 facts.facts.memo_start(from_dir, "module", module_path);
188 let resolved = resolve_module_path_uncached(from_dir, module_path, Some(self), facts);
189 facts.facts.memo_finish(from_dir, "module", module_path);
190 if self.enabled {
191 let retained_weight = module_resolution_entry_weight(&key, resolved.as_deref());
192 self.module_paths
193 .borrow_mut()
194 .insert(key, resolved.clone(), retained_weight);
195 }
196 resolved
197 }
198
199 fn json_value(&self, path: &Path, facts: &FactPaths<'_>) -> Option<Arc<Value>> {
200 if self.enabled {
201 if let Some(cached) = self.json_values.borrow().get(path) {
202 return cached.clone();
203 }
204 }
205
206 self.note_json_probe(path);
207 let parsed = facts
208 .attributed_bytes(path)
209 .and_then(|source| serde_json::from_slice(&source).ok())
210 .map(Arc::new);
211 if self.enabled {
212 let retained_weight = MEMO_ENTRY_OVERHEAD_BYTES
213 + path_retained_weight(path)
214 + parsed
215 .as_deref()
216 .map(json_retained_weight)
217 .unwrap_or_default();
218 self.json_values.borrow_mut().insert(
219 path.to_path_buf(),
220 parsed.clone(),
221 retained_weight,
222 );
223 }
224 parsed
225 }
226
227 pub(crate) fn rust_crate_root_file(
228 &self,
229 project_root: &Path,
230 caller_file: &Path,
231 facts: &FactPaths<'_>,
232 ) -> Option<PathBuf> {
233 self.rust_crate_roots
234 .root_file(project_root, caller_file, facts)
235 }
236
237 pub(crate) fn rust_declared_module_target(
238 &self,
239 caller_file: &str,
240 module_name: &str,
241 populate: impl FnOnce() -> RustDeclaredModuleMap,
242 ) -> Option<String> {
243 if self.enabled {
244 if let Some(cached) = self.rust_declared_modules.borrow().get(caller_file) {
245 return cached.get(module_name).cloned().flatten();
246 }
247 }
248
249 self.note_rust_declaration_parse(caller_file);
250 let declared_modules = Arc::new(populate());
251 let resolved = declared_modules.get(module_name).cloned().flatten();
252 if self.enabled {
253 let retained_weight = rust_declared_module_entry_weight(caller_file, &declared_modules);
254 self.rust_declared_modules.borrow_mut().insert(
255 caller_file.to_string(),
256 declared_modules,
257 retained_weight,
258 );
259 }
260 resolved
261 }
262
263 fn workspace_package(&self, key: &WorkspacePackageKey) -> Option<Option<PathBuf>> {
264 self.enabled
265 .then(|| self.workspace_packages.borrow().get(key).cloned())
266 .flatten()
267 }
268
269 fn remember_workspace_package(&self, key: WorkspacePackageKey, resolved: Option<PathBuf>) {
270 if !self.enabled {
271 return;
272 }
273 let retained_weight = module_resolution_entry_weight(&key, resolved.as_deref());
274 self.workspace_packages
275 .borrow_mut()
276 .insert(key, resolved, retained_weight);
277 }
278
279 #[cfg(test)]
280 pub(crate) fn new_for_test(enabled: bool, collect_metrics: bool) -> Self {
281 Self {
282 enabled,
283 collect_metrics,
284 ..Self::default()
285 }
286 }
287
288 #[cfg(test)]
289 pub(crate) fn module_computations_for_test(&self) -> HashMap<ModuleResolutionKey, usize> {
290 self.module_computations.borrow().clone()
291 }
292
293 #[cfg(test)]
294 pub(crate) fn json_probes_for_test(&self) -> HashMap<PathBuf, usize> {
295 self.json_probes.borrow().clone()
296 }
297
298 #[cfg(test)]
299 pub(crate) fn rust_declaration_parses_for_test(&self) -> HashMap<String, usize> {
300 self.rust_declaration_parses.borrow().clone()
301 }
302
303 #[cfg(test)]
304 fn note_module_computation(&self, key: &ModuleResolutionKey) {
305 if self.collect_metrics {
306 *self
307 .module_computations
308 .borrow_mut()
309 .entry(key.clone())
310 .or_default() += 1;
311 }
312 }
313
314 #[cfg(not(test))]
315 fn note_module_computation(&self, _key: &ModuleResolutionKey) {}
316
317 #[cfg(test)]
318 fn note_json_probe(&self, path: &Path) {
319 if self.collect_metrics {
320 *self
321 .json_probes
322 .borrow_mut()
323 .entry(path.to_path_buf())
324 .or_default() += 1;
325 }
326 }
327
328 #[cfg(not(test))]
329 fn note_json_probe(&self, _path: &Path) {}
330
331 #[cfg(test)]
332 fn note_rust_declaration_parse(&self, caller_file: &str) {
333 if self.collect_metrics {
334 *self
335 .rust_declaration_parses
336 .borrow_mut()
337 .entry(caller_file.to_string())
338 .or_default() += 1;
339 }
340 }
341
342 #[cfg(not(test))]
343 fn note_rust_declaration_parse(&self, _caller_file: &str) {}
344}
345
346fn module_resolution_entry_weight(key: &(PathBuf, String), resolved: Option<&Path>) -> usize {
347 MEMO_ENTRY_OVERHEAD_BYTES
348 + path_retained_weight(&key.0)
349 + key.1.len()
350 + resolved.map(path_retained_weight).unwrap_or_default()
351}
352
353fn path_retained_weight(path: &Path) -> usize {
354 path.to_string_lossy().len()
355}
356
357fn rust_declared_module_entry_weight(
358 caller_file: &str,
359 declared_modules: &RustDeclaredModuleMap,
360) -> usize {
361 MEMO_ENTRY_OVERHEAD_BYTES
362 + caller_file.len()
363 + declared_modules
364 .iter()
365 .map(|(module_name, target)| {
366 MEMO_ENTRY_OVERHEAD_BYTES
367 + module_name.len()
368 + target.as_deref().map(str::len).unwrap_or_default()
369 })
370 .sum::<usize>()
371}
372
373fn json_retained_weight(value: &Value) -> usize {
374 std::mem::size_of::<Value>()
375 + match value {
376 Value::Null | Value::Bool(_) | Value::Number(_) => 0,
377 Value::String(value) => value.len(),
378 Value::Array(values) => values.iter().map(json_retained_weight).sum(),
379 Value::Object(values) => values
380 .iter()
381 .map(|(key, value)| {
382 key.len() + MEMO_ENTRY_OVERHEAD_BYTES + json_retained_weight(value)
383 })
384 .sum(),
385 }
386}
387
388const TOP_LEVEL_SYMBOL: &str = "<top-level>";
389const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
390const JS_TS_INDEX_FILES: &[&str] = &[
391 "index.ts",
392 "index.tsx",
393 "index.mts",
394 "index.cts",
395 "index.js",
396 "index.jsx",
397 "index.mjs",
398 "index.cjs",
399];
400
401fn symbol_identity(symbol: &Symbol) -> String {
402 if symbol.scope_chain.is_empty() {
403 symbol.name.clone()
404 } else {
405 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
406 }
407}
408
409fn symbol_unqualified_name(symbol: &str) -> &str {
410 symbol.rsplit("::").next().unwrap_or(symbol)
411}
412
413pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
414 full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
415}
416
417fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
418 let mut seen = HashSet::new();
419 let mut candidates = Vec::new();
420 let qualified_query = symbol_name.contains("::");
421
422 let mut consider = |candidate: &str| {
423 let matches = if qualified_query {
424 candidate == symbol_name
425 } else {
426 candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
427 };
428
429 if matches && seen.insert(candidate.to_string()) {
430 candidates.push(candidate.to_string());
431 }
432 };
433
434 for candidate in file_data.symbol_metadata.keys() {
435 consider(candidate);
436 }
437 for candidate in file_data.calls_by_symbol.keys() {
438 consider(candidate);
439 }
440 for candidate in &file_data.exported_symbols {
441 consider(candidate);
442 }
443
444 candidates.sort();
445 candidates
446}
447
448pub(crate) fn resolve_symbol_query_in_data(
449 file_data: &FileCallData,
450 file: &Path,
451 symbol_name: &str,
452) -> Result<String, AftError> {
453 let candidates = symbol_query_candidates(file_data, symbol_name);
454 match candidates.as_slice() {
455 [candidate] => Ok(candidate.clone()),
456 [] => Err(AftError::SymbolNotFound {
457 name: symbol_name.to_string(),
458 file: file.display().to_string(),
459 }),
460 _ => Err(AftError::AmbiguousSymbol {
461 name: symbol_name.to_string(),
462 candidates,
463 }),
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct CallSite {
470 pub callee_name: String,
472 pub full_callee: String,
474 pub line: u32,
476 pub byte_start: usize,
478 pub byte_end: usize,
479}
480
481#[derive(Debug, Clone, Serialize)]
483pub struct SymbolMeta {
484 pub kind: SymbolKind,
486 pub exported: bool,
488 #[serde(skip_serializing_if = "Option::is_none")]
490 pub signature: Option<String>,
491 pub line: u32,
493 pub range: Range,
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub entry_point_attribute: Option<String>,
498}
499
500#[derive(Debug, Clone)]
503pub struct FileCallData {
504 pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
506 pub value_refs_by_symbol: HashMap<String, Vec<CallSite>>,
509 pub exported_symbols: Vec<String>,
511 pub symbol_metadata: HashMap<String, SymbolMeta>,
513 pub default_export_symbol: Option<String>,
515 pub import_block: ImportBlock,
517 pub lang: LangId,
519}
520
521impl FileCallData {
522 pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
535 if let Some(meta) = self.symbol_metadata.get(name) {
536 return Some(meta);
537 }
538 self.symbol_metadata
539 .iter()
540 .find(|(key, _)| symbol_unqualified_name(key) == name)
541 .map(|(_, meta)| meta)
542 }
543}
544
545#[derive(Debug, Clone, PartialEq, Eq)]
547pub enum EdgeResolution {
548 Resolved { file: PathBuf, symbol: String },
550 Unresolved { callee_name: String },
552}
553
554#[derive(Debug, Clone, PartialEq, Eq)]
555struct ResolvedSymbol {
556 file: PathBuf,
557 symbol: String,
558}
559
560#[derive(Debug, Clone)]
561struct RustCrateInfo {
562 package_root: PathBuf,
563 lib_name: String,
564 lib_root: Option<PathBuf>,
565 target_roots: Vec<PathBuf>,
566}
567
568#[derive(Debug, Clone)]
569struct RustModuleBase {
570 src_dir: PathBuf,
571 root_file: PathBuf,
572}
573
574#[derive(Debug, Clone)]
575struct RustUseEntry {
576 module_path: String,
577 local_name: String,
578 kind: RustUseKind,
579}
580
581#[derive(Debug, Clone)]
582enum RustUseKind {
583 Item { imported_name: String },
584 Module,
585}
586
587#[derive(Debug, Clone, Serialize)]
589pub struct CallTreeNode {
590 pub name: String,
592 pub file: String,
594 pub line: u32,
596 #[serde(skip_serializing_if = "Option::is_none")]
598 pub signature: Option<String>,
599 pub resolved: bool,
601 pub children: Vec<CallTreeNode>,
603 pub depth_limited: bool,
605 pub truncated: usize,
607}
608
609const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
615
616pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
623 if exported && *kind == SymbolKind::Function {
625 return true;
626 }
627
628 let lower = name.to_lowercase();
630 if MAIN_INIT_NAMES.contains(&lower.as_str()) {
631 return true;
632 }
633
634 match lang {
636 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
637 matches!(lower.as_str(), "describe" | "it" | "test")
639 || lower.starts_with("test")
640 || lower.starts_with("spec")
641 }
642 LangId::Python => {
643 lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
645 }
646 LangId::Rust => {
647 lower.starts_with("test_")
649 }
650 LangId::Go => {
651 name.starts_with("Test")
653 }
654 LangId::C
655 | LangId::Cpp
656 | LangId::Cuda
657 | LangId::Metal
658 | LangId::Zig
659 | LangId::CSharp
660 | LangId::Bash
661 | LangId::Solidity
662 | LangId::Scss
663 | LangId::Vue
664 | LangId::Json
665 | LangId::Scala
666 | LangId::Java
667 | LangId::Ruby
668 | LangId::Kotlin
669 | LangId::Swift
670 | LangId::Php
671 | LangId::Lua
672 | LangId::Perl
673 | LangId::Html
674 | LangId::Markdown
675 | LangId::Yaml
676 | LangId::Pascal
677 | LangId::R
678 | LangId::Groovy
679 | LangId::ObjC
680 | LangId::Toml => false,
681 }
682}
683
684#[derive(Debug, Clone, Serialize)]
690pub struct TraceHop {
691 pub symbol: String,
693 pub file: String,
695 pub line: u32,
697 #[serde(skip_serializing_if = "Option::is_none")]
699 pub signature: Option<String>,
700 pub is_entry_point: bool,
702}
703
704#[derive(Debug, Clone, Serialize)]
706pub struct TracePath {
707 pub hops: Vec<TraceHop>,
709}
710
711#[derive(Debug, Clone, Serialize)]
713pub struct TraceToResult {
714 pub target_symbol: String,
716 pub target_file: String,
718 pub paths: Vec<TracePath>,
720 pub total_paths: usize,
722 pub entry_points_found: usize,
724 pub max_depth_reached: bool,
726 pub truncated_paths: usize,
728}
729
730#[derive(Debug, Clone, Serialize)]
732pub struct TraceToSymbolHop {
733 pub symbol: String,
735 pub file: String,
737 pub line: u32,
739}
740
741#[derive(Debug, Clone, Serialize)]
743pub struct TraceToSymbolCandidate {
744 pub file: String,
746 pub line: u32,
748}
749
750#[derive(Debug, Clone, Serialize)]
752pub struct TraceToSymbolResult {
753 pub path: Option<Vec<TraceToSymbolHop>>,
755 pub complete: bool,
757 #[serde(skip_serializing_if = "Option::is_none")]
759 pub reason: Option<String>,
760}
761
762#[derive(Debug, Clone, Serialize)]
768pub struct DataFlowHop {
769 pub file: String,
771 pub symbol: String,
773 pub variable: String,
775 pub line: u32,
777 pub flow_type: String,
779 pub approximate: bool,
781}
782
783#[derive(Debug, Clone, Serialize)]
786pub struct TraceDataResult {
787 pub expression: String,
789 pub origin_file: String,
791 pub origin_symbol: String,
793 pub hops: Vec<DataFlowHop>,
795 pub depth_limited: bool,
797}
798
799pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
805 let start = match signature.find('(') {
807 Some(i) => i + 1,
808 None => return Vec::new(),
809 };
810 let end = match signature[start..].find(')') {
811 Some(i) => start + i,
812 None => return Vec::new(),
813 };
814
815 let params_str = &signature[start..end].trim();
816 if params_str.is_empty() {
817 return Vec::new();
818 }
819
820 let parts = split_params(params_str);
822
823 let mut result = Vec::new();
824 for part in parts {
825 let trimmed = part.trim();
826 if trimmed.is_empty() {
827 continue;
828 }
829
830 match lang {
832 LangId::Rust => {
833 if trimmed == "self"
834 || trimmed == "mut self"
835 || trimmed.starts_with("&self")
836 || trimmed.starts_with("&mut self")
837 {
838 continue;
839 }
840 }
841 LangId::Python => {
842 if trimmed == "self" || trimmed.starts_with("self:") {
843 continue;
844 }
845 }
846 _ => {}
847 }
848
849 let name = extract_param_name(trimmed, lang);
851 if !name.is_empty() {
852 result.push(name);
853 }
854 }
855
856 result
857}
858
859fn split_params(s: &str) -> Vec<String> {
861 let mut parts = Vec::new();
862 let mut current = String::new();
863 let mut depth = 0i32;
864
865 for ch in s.chars() {
866 match ch {
867 '<' | '[' | '{' | '(' => {
868 depth += 1;
869 current.push(ch);
870 }
871 '>' | ']' | '}' | ')' => {
872 depth -= 1;
873 current.push(ch);
874 }
875 ',' if depth == 0 => {
876 parts.push(current.clone());
877 current.clear();
878 }
879 _ => {
880 current.push(ch);
881 }
882 }
883 }
884 if !current.is_empty() {
885 parts.push(current);
886 }
887 parts
888}
889
890fn extract_param_name(param: &str, lang: LangId) -> String {
898 let trimmed = param.trim();
899
900 let working = if trimmed.starts_with("...") {
902 &trimmed[3..]
903 } else if trimmed.starts_with("**") {
904 &trimmed[2..]
905 } else if trimmed.starts_with('*') && lang == LangId::Python {
906 &trimmed[1..]
907 } else {
908 trimmed
909 };
910
911 let working = if lang == LangId::Rust && working.starts_with("mut ") {
913 &working[4..]
914 } else {
915 working
916 };
917
918 let name = working
921 .split(|c: char| c == ':' || c == '=')
922 .next()
923 .unwrap_or("")
924 .trim();
925
926 let name = name.trim_end_matches('?');
928
929 if lang == LangId::Go && !name.contains(' ') {
931 return name.to_string();
932 }
933 if lang == LangId::Go {
934 return name.split_whitespace().next().unwrap_or("").to_string();
935 }
936
937 name.to_string()
938}
939
940pub struct CallGraph {
949 data: HashMap<PathBuf, FileCallData>,
951 project_root: PathBuf,
953}
954
955impl CallGraph {
956 pub fn new(project_root: PathBuf) -> Self {
958 clear_workspace_package_cache();
959 Self {
960 data: HashMap::new(),
961 project_root,
962 }
963 }
964
965 pub fn project_root(&self) -> &Path {
967 &self.project_root
968 }
969
970 fn resolve_cross_file_edge_with_exports<F, D>(
971 full_callee: &str,
972 short_name: &str,
973 caller_file: &Path,
974 import_block: &ImportBlock,
975 mut file_exports_symbol: F,
976 mut file_default_export_symbol: D,
977 ) -> EdgeResolution
978 where
979 F: FnMut(&Path, &str) -> bool,
980 D: FnMut(&Path) -> Option<String>,
981 {
982 let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
983 let disk = DiskFacts::new(caller_dir.ancestors().last().unwrap_or(caller_dir));
984 let facts = &FactPaths {
985 root: &disk.project_root,
986 facts: &disk,
987 };
988
989 if is_rust_source_file(caller_file) {
993 if let Some(target) = resolve_rust_cross_file_edge(
994 full_callee,
995 short_name,
996 caller_file,
997 import_block,
998 &mut file_exports_symbol,
999 ) {
1000 return EdgeResolution::Resolved {
1001 file: target.file,
1002 symbol: target.symbol,
1003 };
1004 }
1005 }
1006
1007 if full_callee.contains('.') {
1009 let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
1010 if parts.len() == 2 {
1011 let namespace = parts[0];
1012 let member = parts[1];
1013
1014 for imp in &import_block.imports {
1015 if imp.namespace_import.as_deref() == Some(namespace) {
1016 if let Some(resolved_path) =
1017 resolve_module_path(caller_dir, &imp.module_path)
1018 {
1019 if let Some(target) = resolve_reexported_symbol(
1020 &resolved_path,
1021 member,
1022 &mut file_exports_symbol,
1023 &mut file_default_export_symbol,
1024 ) {
1025 return EdgeResolution::Resolved {
1026 file: target.file,
1027 symbol: target.symbol,
1028 };
1029 }
1030 }
1031 }
1032 }
1033 }
1034 }
1035
1036 for imp in &import_block.imports {
1038 if imp.names.iter().any(|name| name == short_name) {
1040 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
1041 let target = resolve_reexported_symbol(
1042 &resolved_path,
1043 short_name,
1044 &mut file_exports_symbol,
1045 &mut file_default_export_symbol,
1046 )
1047 .unwrap_or(ResolvedSymbol {
1048 file: resolved_path,
1049 symbol: short_name.to_owned(),
1050 });
1051 return EdgeResolution::Resolved {
1052 file: target.file,
1053 symbol: target.symbol,
1054 };
1055 }
1056 }
1057
1058 if imp.default_import.as_deref() == Some(short_name) {
1060 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
1061 let target = resolve_reexported_symbol(
1062 &resolved_path,
1063 "default",
1064 &mut file_exports_symbol,
1065 &mut file_default_export_symbol,
1066 )
1067 .unwrap_or_else(|| ResolvedSymbol {
1068 symbol: file_default_export_symbol(&resolved_path)
1069 .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
1070 file: resolved_path,
1071 });
1072 return EdgeResolution::Resolved {
1073 file: target.file,
1074 symbol: target.symbol,
1075 };
1076 }
1077 }
1078 }
1079
1080 if let Some((original_name, resolved_path)) =
1085 resolve_aliased_import(short_name, import_block, caller_dir)
1086 {
1087 let target = resolve_reexported_symbol(
1088 &resolved_path,
1089 &original_name,
1090 &mut file_exports_symbol,
1091 &mut file_default_export_symbol,
1092 )
1093 .unwrap_or(ResolvedSymbol {
1094 file: resolved_path,
1095 symbol: original_name,
1096 });
1097 return EdgeResolution::Resolved {
1098 file: target.file,
1099 symbol: target.symbol,
1100 };
1101 }
1102
1103 for imp in &import_block.imports {
1106 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
1107 if resolved_path.is_dir() {
1109 if let Some(index_path) = find_index_file(&resolved_path, facts) {
1110 if file_exports_symbol(&index_path, short_name) {
1112 return EdgeResolution::Resolved {
1113 file: index_path,
1114 symbol: short_name.to_owned(),
1115 };
1116 }
1117 }
1118 } else if file_exports_symbol(&resolved_path, short_name) {
1119 return EdgeResolution::Resolved {
1120 file: resolved_path,
1121 symbol: short_name.to_owned(),
1122 };
1123 }
1124 }
1125 }
1126
1127 EdgeResolution::Unresolved {
1128 callee_name: short_name.to_owned(),
1129 }
1130 }
1131
1132 pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
1134 let canon = self.canonicalize(path)?;
1135
1136 if !self.data.contains_key(&canon) {
1137 let file_data = build_file_data(&canon)?;
1138 self.data.insert(canon.clone(), file_data);
1139 }
1140
1141 Ok(&self.data[&canon])
1142 }
1143
1144 pub fn resolve_cross_file_edge(
1149 &mut self,
1150 full_callee: &str,
1151 short_name: &str,
1152 caller_file: &Path,
1153 import_block: &ImportBlock,
1154 ) -> EdgeResolution {
1155 let graph = RefCell::new(self);
1156 Self::resolve_cross_file_edge_with_exports(
1157 full_callee,
1158 short_name,
1159 caller_file,
1160 import_block,
1161 |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
1162 |path| graph.borrow_mut().file_default_export_symbol(path),
1163 )
1164 }
1165
1166 fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
1168 match self.build_file(path) {
1169 Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
1170 Err(_) => false,
1171 }
1172 }
1173
1174 fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
1175 self.build_file(path)
1176 .ok()
1177 .and_then(|data| data.default_export_symbol.clone())
1178 }
1179
1180 pub fn invalidate_file(&mut self, path: &Path) {
1182 self.data.remove(path);
1184 if let Ok(canon) = self.canonicalize(path) {
1185 self.data.remove(&canon);
1186 }
1187 clear_workspace_package_cache();
1188 }
1189
1190 fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
1192 let full_path = if path.is_relative() {
1194 self.project_root.join(path)
1195 } else {
1196 path.to_path_buf()
1197 };
1198
1199 Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
1201 }
1202}
1203
1204pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
1210 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1211 message: format!("unsupported file for call graph: {}", path.display()),
1212 })?;
1213
1214 let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
1215 path: format!("{}: {}", path.display(), e),
1216 })?;
1217
1218 build_file_data_from_source_with_lang(path, &source, lang)
1219}
1220
1221pub(crate) fn build_file_data_from_source(
1222 path: &Path,
1223 source: &str,
1224) -> Result<FileCallData, AftError> {
1225 let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
1226 message: format!("unsupported file for call graph: {}", path.display()),
1227 })?;
1228 build_file_data_from_source_with_lang(path, source, lang)
1229}
1230
1231#[derive(Debug)]
1232struct SymbolCallRange {
1233 symbol_index: usize,
1234 byte_start: usize,
1235 byte_end: usize,
1236}
1237
1238struct SourceLineIndex {
1239 bounds: Vec<(usize, usize)>,
1240 source_len: usize,
1241}
1242
1243impl SourceLineIndex {
1244 fn new(source: &str) -> Self {
1245 let bytes = source.as_bytes();
1246 let mut bounds = Vec::new();
1247 let mut line_start = 0usize;
1248 let mut index = 0usize;
1249
1250 while index < bytes.len() {
1251 match bytes[index] {
1252 b'\r' => {
1253 bounds.push((line_start, index));
1254 index += if bytes.get(index + 1) == Some(&b'\n') {
1255 2
1256 } else {
1257 1
1258 };
1259 line_start = index;
1260 }
1261 b'\n' => {
1262 bounds.push((line_start, index));
1263 index += 1;
1264 line_start = index;
1265 }
1266 _ => index += 1,
1267 }
1268 }
1269 bounds.push((line_start, bytes.len()));
1270
1271 Self {
1272 bounds,
1273 source_len: bytes.len(),
1274 }
1275 }
1276
1277 fn byte_offset(&self, line: u32, column: u32) -> usize {
1278 let Some(&(line_start, line_end)) = self.bounds.get(line as usize) else {
1279 return self.source_len;
1280 };
1281 line_start + (column as usize).min(line_end.saturating_sub(line_start))
1282 }
1283}
1284
1285fn collect_calls_by_symbol(
1286 source: &str,
1287 root: Node<'_>,
1288 lang: LangId,
1289 symbols: &[Symbol],
1290) -> HashMap<String, Vec<CallSite>> {
1291 attribute_sites_to_symbols(
1292 source,
1293 symbols,
1294 extract_calls_full(source, root, 0, source.len(), lang),
1295 )
1296}
1297
1298fn collect_rust_value_refs_by_symbol(
1299 source: &str,
1300 root: Node<'_>,
1301 symbols: &[Symbol],
1302) -> HashMap<String, Vec<CallSite>> {
1303 attribute_sites_to_symbols(source, symbols, extract_rust_value_references(source, root))
1304}
1305
1306fn attribute_sites_to_symbols(
1307 source: &str,
1308 symbols: &[Symbol],
1309 raw_sites: Vec<(String, String, u32, usize, usize)>,
1310) -> HashMap<String, Vec<CallSite>> {
1311 let line_index = SourceLineIndex::new(source);
1312 let mut ranges = symbols
1313 .iter()
1314 .enumerate()
1315 .map(|(symbol_index, symbol)| SymbolCallRange {
1316 symbol_index,
1317 byte_start: line_index.byte_offset(symbol.range.start_line, symbol.range.start_col),
1318 byte_end: line_index.byte_offset(symbol.range.end_line, symbol.range.end_col),
1319 })
1320 .collect::<Vec<_>>();
1321 ranges.sort_by(|left, right| {
1322 left.byte_start
1323 .cmp(&right.byte_start)
1324 .then_with(|| left.symbol_index.cmp(&right.symbol_index))
1325 });
1326
1327 let mut sites_by_symbol = vec![Vec::new(); symbols.len()];
1328 let mut top_level_sites = Vec::new();
1329 let mut active_ranges = Vec::<usize>::new();
1330 let mut next_range = 0usize;
1331
1332 for (full, short, line, byte_start, byte_end) in raw_sites {
1333 active_ranges.retain(|range_index| ranges[*range_index].byte_end > byte_start);
1336 while next_range < ranges.len() && ranges[next_range].byte_start <= byte_start {
1337 if ranges[next_range].byte_end > byte_start {
1338 active_ranges.push(next_range);
1339 }
1340 next_range += 1;
1341 }
1342
1343 let site = CallSite {
1344 callee_name: short,
1345 full_callee: full,
1346 line,
1347 byte_start,
1348 byte_end,
1349 };
1350 let mut attributed = false;
1351 for range_index in &active_ranges {
1352 let range = &ranges[*range_index];
1353 if byte_end <= range.byte_end {
1354 sites_by_symbol[range.symbol_index].push(site.clone());
1355 attributed = true;
1356 }
1357 }
1358 if !attributed {
1359 top_level_sites.push(site);
1360 }
1361 }
1362
1363 let mut calls_by_symbol = HashMap::new();
1364 for (symbol, sites) in symbols.iter().zip(sites_by_symbol) {
1365 if !sites.is_empty() {
1366 calls_by_symbol.insert(symbol_identity(symbol), sites);
1367 }
1368 }
1369 if !top_level_sites.is_empty() {
1370 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
1371 }
1372 calls_by_symbol
1373}
1374
1375pub(crate) fn build_file_data_from_source_with_lang(
1376 path: &Path,
1377 source: &str,
1378 lang: LangId,
1379) -> Result<FileCallData, AftError> {
1380 let grammar = grammar_for(lang);
1381 let mut parser = Parser::new();
1382 parser
1383 .set_language(&grammar)
1384 .map_err(|e| AftError::ParseError {
1385 message: format!("grammar init failed for {:?}: {}", lang, e),
1386 })?;
1387
1388 let tree = parser
1389 .parse(&source, None)
1390 .ok_or_else(|| AftError::ParseError {
1391 message: format!("parse failed for {}", path.display()),
1392 })?;
1393
1394 let import_block = imports::parse_imports(&source, &tree, lang);
1396
1397 let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
1399
1400 let root = tree.root_node();
1401 let mut calls_by_symbol = collect_calls_by_symbol(&source, root, lang, &symbols);
1402 let value_refs_by_symbol = if lang == LangId::Rust {
1403 collect_rust_value_refs_by_symbol(&source, root, &symbols)
1404 } else {
1405 HashMap::new()
1406 };
1407
1408 let default_export = find_default_export(&source, root, path, lang);
1409
1410 if let Some(default_export) = &default_export {
1411 if default_export.synthetic {
1412 let byte_start = default_export.node.byte_range().start;
1413 let byte_end = default_export.node.byte_range().end;
1414 let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1415 let sites: Vec<CallSite> = raw_calls
1416 .into_iter()
1417 .filter(|(_, short, _, _, _)| *short != default_export.symbol)
1418 .map(
1419 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
1420 callee_name: short,
1421 full_callee: full,
1422 line,
1423 byte_start: call_byte_start,
1424 byte_end: call_byte_end,
1425 },
1426 )
1427 .collect();
1428 if !sites.is_empty() {
1429 calls_by_symbol.insert(default_export.symbol.clone(), sites);
1430 }
1431 }
1432 }
1433
1434 let mut exported_symbols: Vec<String> = symbols
1436 .iter()
1437 .filter(|s| s.exported)
1438 .map(|s| s.name.clone())
1439 .collect();
1440 if let Some(default_export) = &default_export {
1441 if !exported_symbols
1442 .iter()
1443 .any(|name| name == &default_export.symbol)
1444 {
1445 exported_symbols.push(default_export.symbol.clone());
1446 }
1447 }
1448
1449 let rust_attribute_entry_points = if lang == LangId::Rust {
1450 crate::parser::rust_attribute_entry_points(&source, root)
1451 .into_iter()
1452 .map(|entry| (entry.scoped_name, entry.attribute.to_string()))
1453 .collect::<HashMap<_, _>>()
1454 } else {
1455 HashMap::new()
1456 };
1457
1458 let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
1460 .iter()
1461 .map(|s| {
1462 let identity = symbol_identity(s);
1463 (
1464 identity.clone(),
1465 SymbolMeta {
1466 kind: s.kind.clone(),
1467 exported: s.exported,
1468 signature: s.signature.clone(),
1469 line: s.range.start_line + 1,
1470 range: s.range.clone(),
1471 entry_point_attribute: rust_attribute_entry_points.get(&identity).cloned(),
1472 },
1473 )
1474 })
1475 .collect();
1476 if let Some(default_export) = &default_export {
1477 symbol_metadata
1478 .entry(default_export.symbol.clone())
1479 .or_insert_with(|| SymbolMeta {
1480 kind: default_export.kind.clone(),
1481 exported: true,
1482 signature: Some(first_line_signature(&source, &default_export.node)),
1483 line: default_export.node.start_position().row as u32 + 1,
1484 range: crate::parser::node_range(&default_export.node),
1485 entry_point_attribute: None,
1486 });
1487 }
1488 if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL)
1489 || value_refs_by_symbol.contains_key(TOP_LEVEL_SYMBOL)
1490 {
1491 symbol_metadata
1492 .entry(TOP_LEVEL_SYMBOL.to_string())
1493 .or_insert(SymbolMeta {
1494 kind: SymbolKind::Function,
1495 exported: false,
1496 signature: None,
1497 line: 1,
1498 range: Range {
1499 start_line: 0,
1500 start_col: 0,
1501 end_line: 0,
1502 end_col: 0,
1503 },
1504 entry_point_attribute: None,
1505 });
1506 }
1507
1508 Ok(FileCallData {
1509 calls_by_symbol,
1510 value_refs_by_symbol,
1511 exported_symbols,
1512 symbol_metadata,
1513 default_export_symbol: default_export.map(|export| export.symbol),
1514 import_block,
1515 lang,
1516 })
1517}
1518
1519#[derive(Debug, Clone)]
1520struct DefaultExport<'tree> {
1521 symbol: String,
1522 synthetic: bool,
1523 kind: SymbolKind,
1524 node: Node<'tree>,
1525}
1526
1527fn find_default_export<'tree>(
1528 source: &str,
1529 root: Node<'tree>,
1530 path: &Path,
1531 lang: LangId,
1532) -> Option<DefaultExport<'tree>> {
1533 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
1534 return None;
1535 }
1536 find_default_export_inner(source, root, path)
1537}
1538
1539fn find_default_export_inner<'tree>(
1540 source: &str,
1541 node: Node<'tree>,
1542 path: &Path,
1543) -> Option<DefaultExport<'tree>> {
1544 if node.kind() == "export_statement" {
1545 if let Some(default_export) = default_export_from_statement(source, node, path) {
1546 return Some(default_export);
1547 }
1548 }
1549
1550 let mut cursor = node.walk();
1551 if !cursor.goto_first_child() {
1552 return None;
1553 }
1554
1555 loop {
1556 let child = cursor.node();
1557 if let Some(default_export) = find_default_export_inner(source, child, path) {
1558 return Some(default_export);
1559 }
1560 if !cursor.goto_next_sibling() {
1561 break;
1562 }
1563 }
1564
1565 None
1566}
1567
1568fn default_export_from_statement<'tree>(
1569 source: &str,
1570 node: Node<'tree>,
1571 path: &Path,
1572) -> Option<DefaultExport<'tree>> {
1573 let mut cursor = node.walk();
1574 if !cursor.goto_first_child() {
1575 return None;
1576 }
1577
1578 let mut saw_default = false;
1579 loop {
1580 let child = cursor.node();
1581 match child.kind() {
1582 "default" => saw_default = true,
1583 "function_declaration" | "generator_function_declaration" | "class_declaration"
1584 if saw_default =>
1585 {
1586 if let Some(name_node) = child.child_by_field_name("name") {
1587 return Some(DefaultExport {
1588 symbol: source[name_node.byte_range()].to_string(),
1589 synthetic: false,
1590 kind: default_export_kind(&child),
1591 node: child,
1592 });
1593 }
1594 return Some(DefaultExport {
1595 symbol: synthetic_default_symbol(path),
1596 synthetic: true,
1597 kind: default_export_kind(&child),
1598 node: child,
1599 });
1600 }
1601 "arrow_function"
1602 | "function"
1603 | "function_expression"
1604 | "class"
1605 | "class_expression"
1606 if saw_default =>
1607 {
1608 return Some(DefaultExport {
1609 symbol: synthetic_default_symbol(path),
1610 synthetic: true,
1611 kind: default_export_kind(&child),
1612 node: child,
1613 });
1614 }
1615 "identifier" | "type_identifier" | "property_identifier" if saw_default => {
1616 return Some(DefaultExport {
1617 symbol: source[child.byte_range()].to_string(),
1618 synthetic: false,
1619 kind: SymbolKind::Function,
1620 node: child,
1621 });
1622 }
1623 _ => {}
1624 }
1625 if !cursor.goto_next_sibling() {
1626 break;
1627 }
1628 }
1629
1630 None
1631}
1632
1633fn default_export_kind(node: &Node) -> SymbolKind {
1634 if node.kind().contains("class") {
1635 SymbolKind::Class
1636 } else {
1637 SymbolKind::Function
1638 }
1639}
1640
1641fn synthetic_default_symbol(path: &Path) -> String {
1642 let file_name = path
1643 .file_name()
1644 .and_then(|name| name.to_str())
1645 .unwrap_or("unknown");
1646 format!("<default:{file_name}>")
1647}
1648
1649fn first_line_signature(source: &str, node: &Node) -> String {
1650 let text = &source[node.byte_range()];
1651 let first_line = text.lines().next().unwrap_or(text);
1652 first_line
1653 .trim_end()
1654 .trim_end_matches('{')
1655 .trim_end()
1656 .to_string()
1657}
1658
1659fn node_text(node: tree_sitter::Node, source: &str) -> String {
1660 source[node.start_byte()..node.end_byte()].to_string()
1661}
1662
1663fn find_child_by_kind<'a>(
1665 node: tree_sitter::Node<'a>,
1666 kind: &str,
1667) -> Option<tree_sitter::Node<'a>> {
1668 let mut cursor = node.walk();
1669 if cursor.goto_first_child() {
1670 loop {
1671 if cursor.node().kind() == kind {
1672 return Some(cursor.node());
1673 }
1674 if !cursor.goto_next_sibling() {
1675 break;
1676 }
1677 }
1678 }
1679 None
1680}
1681
1682#[cfg(test)]
1683#[derive(Debug, Clone)]
1684struct CallSiteWithRange {
1685 full: String,
1686 short: String,
1687 line: u32,
1688 byte_start: usize,
1689 byte_end: usize,
1690}
1691
1692#[cfg(test)]
1693fn collect_calls_full_with_ranges(
1694 root: tree_sitter::Node,
1695 source: &str,
1696 byte_start: usize,
1697 byte_end: usize,
1698 lang: LangId,
1699) -> Vec<CallSiteWithRange> {
1700 let mut results = Vec::new();
1701 let call_kinds = call_node_kinds(lang);
1702 collect_calls_full_with_ranges_inner(
1703 root,
1704 source,
1705 byte_start,
1706 byte_end,
1707 &call_kinds,
1708 &mut results,
1709 );
1710 results
1711}
1712
1713#[cfg(test)]
1714fn collect_calls_full_with_ranges_inner(
1715 node: tree_sitter::Node,
1716 source: &str,
1717 byte_start: usize,
1718 byte_end: usize,
1719 call_kinds: &[&str],
1720 results: &mut Vec<CallSiteWithRange>,
1721) {
1722 let node_start = node.start_byte();
1723 let node_end = node.end_byte();
1724
1725 if node_end <= byte_start || node_start >= byte_end {
1726 return;
1727 }
1728
1729 if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
1730 if let (Some(full), Some(short)) = (
1731 extract_full_callee(&node, source),
1732 extract_callee_name(&node, source),
1733 ) {
1734 results.push(CallSiteWithRange {
1735 full,
1736 short,
1737 line: node.start_position().row as u32 + 1,
1738 byte_start: node_start,
1739 byte_end: node_end,
1740 });
1741 }
1742 }
1743
1744 let mut cursor = node.walk();
1745 if cursor.goto_first_child() {
1746 loop {
1747 collect_calls_full_with_ranges_inner(
1748 cursor.node(),
1749 source,
1750 byte_start,
1751 byte_end,
1752 call_kinds,
1753 results,
1754 );
1755 if !cursor.goto_next_sibling() {
1756 break;
1757 }
1758 }
1759 }
1760}
1761
1762pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1770 let disk = DiskFacts::new(from_dir.ancestors().last().unwrap_or(from_dir));
1771 let facts = &FactPaths {
1772 root: &disk.project_root,
1773 facts: &disk,
1774 };
1775 resolve_module_path_uncached(from_dir, module_path, None, facts)
1776}
1777
1778pub(crate) fn resolve_module_path_with_memo(
1779 from_dir: &Path,
1780 module_path: &str,
1781 memo: &ModuleResolutionMemo,
1782 facts: &FactPaths<'_>,
1783) -> Option<PathBuf> {
1784 memo.resolve_module_path(from_dir, module_path, facts)
1785}
1786
1787fn resolve_module_path_uncached(
1788 from_dir: &Path,
1789 module_path: &str,
1790 memo: Option<&ModuleResolutionMemo>,
1791 facts: &FactPaths<'_>,
1792) -> Option<PathBuf> {
1793 if module_path.starts_with('.') {
1794 return resolve_relative_module_path(from_dir, module_path, facts);
1795 }
1796
1797 if module_path.starts_with('/') {
1798 return None;
1799 }
1800
1801 if let Some(path) = resolve_tsconfig_path(from_dir, module_path, memo, facts) {
1802 return Some(path);
1803 }
1804
1805 resolve_workspace_module_path(from_dir, module_path, memo, facts)
1806}
1807
1808fn resolve_relative_module_path(
1809 from_dir: &Path,
1810 module_path: &str,
1811 facts: &FactPaths<'_>,
1812) -> Option<PathBuf> {
1813 let base = from_dir.join(module_path);
1814 resolve_file_like_path(&base, facts)
1815}
1816
1817fn resolve_file_like_path(base: &Path, facts: &FactPaths<'_>) -> Option<PathBuf> {
1818 let base = base.to_path_buf();
1819
1820 if facts.is_file(&base) {
1822 return Some(facts.canonical(&base).unwrap_or(base));
1823 }
1824
1825 for ext in JS_TS_EXTENSIONS {
1827 let with_ext = base.with_extension(ext);
1828 if facts.is_file(&with_ext) {
1829 return Some(facts.canonical(&with_ext).unwrap_or(with_ext));
1830 }
1831 }
1832
1833 if facts.is_dir(&base) {
1835 if let Some(index) = find_index_file(&base, facts) {
1836 return Some(index);
1837 }
1838 }
1839
1840 None
1841}
1842
1843fn resolve_workspace_module_path(
1844 from_dir: &Path,
1845 module_path: &str,
1846 memo: Option<&ModuleResolutionMemo>,
1847 facts: &FactPaths<'_>,
1848) -> Option<PathBuf> {
1849 let (package_name, subpath) = split_package_import(module_path)?;
1850 let package_root = find_package_root_for_import(from_dir, &package_name, memo, facts)?;
1851 resolve_package_entry(&package_root, &subpath, memo, facts)
1852}
1853
1854fn is_rust_source_file(path: &Path) -> bool {
1855 path.extension().and_then(|ext| ext.to_str()) == Some("rs")
1856}
1857
1858fn resolve_rust_cross_file_edge<F>(
1859 full_callee: &str,
1860 short_name: &str,
1861 caller_file: &Path,
1862 import_block: &ImportBlock,
1863 file_exports_symbol: &mut F,
1864) -> Option<ResolvedSymbol>
1865where
1866 F: FnMut(&Path, &str) -> bool,
1867{
1868 if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
1869 {
1870 return Some(target);
1871 }
1872
1873 resolve_rust_imported_call(
1874 caller_file,
1875 full_callee,
1876 short_name,
1877 import_block,
1878 file_exports_symbol,
1879 )
1880}
1881
1882fn resolve_rust_qualified_call<F>(
1883 caller_file: &Path,
1884 full_callee: &str,
1885 file_exports_symbol: &mut F,
1886) -> Option<ResolvedSymbol>
1887where
1888 F: FnMut(&Path, &str) -> bool,
1889{
1890 if !full_callee.contains("::") {
1891 return None;
1892 }
1893
1894 let segments = rust_path_segments(full_callee)?;
1895 resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
1896}
1897
1898fn resolve_rust_imported_call<F>(
1899 caller_file: &Path,
1900 full_callee: &str,
1901 short_name: &str,
1902 import_block: &ImportBlock,
1903 file_exports_symbol: &mut F,
1904) -> Option<ResolvedSymbol>
1905where
1906 F: FnMut(&Path, &str) -> bool,
1907{
1908 let call_segments = rust_path_segments(full_callee).unwrap_or_default();
1909 let bare_call_name = if call_segments.len() <= 1 {
1910 call_segments
1911 .first()
1912 .map(String::as_str)
1913 .unwrap_or(short_name)
1914 } else {
1915 short_name
1916 };
1917
1918 for imp in &import_block.imports {
1919 for entry in rust_use_entries(imp) {
1920 match &entry.kind {
1921 RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
1922 if entry.local_name != bare_call_name {
1923 continue;
1924 }
1925 let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
1926 else {
1927 continue;
1928 };
1929 if file_exports_symbol(&file, imported_name) {
1930 return Some(ResolvedSymbol {
1931 file,
1932 symbol: imported_name.clone(),
1933 });
1934 }
1935 }
1936 RustUseKind::Module if call_segments.len() >= 2 => {
1937 if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
1938 {
1939 continue;
1940 }
1941 let symbol = call_segments.last()?.clone();
1942 let mut module_path = entry.module_path.clone();
1943 for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
1944 module_path.push_str("::");
1945 module_path.push_str(segment);
1946 }
1947 let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
1948 continue;
1949 };
1950 if file_exports_symbol(&file, &symbol) {
1951 return Some(ResolvedSymbol { file, symbol });
1952 }
1953 }
1954 _ => {}
1955 }
1956 }
1957 }
1958
1959 None
1960}
1961
1962fn resolve_rust_call_segments<F>(
1963 caller_file: &Path,
1964 segments: &[String],
1965 file_exports_symbol: &mut F,
1966) -> Option<ResolvedSymbol>
1967where
1968 F: FnMut(&Path, &str) -> bool,
1969{
1970 if segments.len() < 2 {
1971 return None;
1972 }
1973
1974 let symbol = segments.last()?.clone();
1975 let module_path = segments[..segments.len() - 1].join("::");
1976 let file = resolve_rust_module_path(caller_file, &module_path)?;
1977 if file_exports_symbol(&file, &symbol) {
1978 Some(ResolvedSymbol { file, symbol })
1979 } else {
1980 None
1981 }
1982}
1983
1984fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
1985 let segments = rust_path_segments(module_path)?;
1986 let first = segments.first()?.as_str();
1987
1988 match first {
1989 "std" | "core" | "alloc" => None,
1990 "crate" => {
1991 let crate_root = find_rust_crate_root(caller_file)?;
1992 let crate_info = rust_crate_info(&crate_root)?;
1993 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1994 resolve_rust_module_segments(&base, &segments[1..])
1995 }
1996 "self" => {
1997 let crate_root = find_rust_crate_root(caller_file)?;
1998 let crate_info = rust_crate_info(&crate_root)?;
1999 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
2000 if segments.len() == 1 {
2001 return Some(canonicalize_path(caller_file));
2002 }
2003 let mut target_segments = rust_module_segments_for_file(&base, caller_file)?;
2004 target_segments.extend(segments[1..].iter().cloned());
2005 resolve_rust_module_segments(&base, &target_segments)
2006 }
2007 "super" => {
2008 let crate_root = find_rust_crate_root(caller_file)?;
2009 let crate_info = rust_crate_info(&crate_root)?;
2010 let base = rust_module_base_for_caller(&crate_info, caller_file)?;
2011 let mut target_segments = rust_module_segments_for_file(&base, caller_file)?;
2012 target_segments.pop();
2013 target_segments.extend(segments[1..].iter().cloned());
2014 resolve_rust_module_segments(&base, &target_segments)
2015 }
2016 crate_name => {
2017 let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
2018 let workspace_crates = rust_workspace_crates(caller_dir)?;
2019 let crate_info = workspace_crates.get(crate_name)?;
2020 let base = rust_lib_module_base(crate_info)?;
2021 resolve_rust_module_segments(&base, &segments[1..])
2022 }
2023 }
2024}
2025
2026fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
2027 let Some(body) = rust_use_body(&imp.raw_text) else {
2028 return Vec::new();
2029 };
2030 let mut entries = Vec::new();
2031 expand_rust_use_tree(body, &mut entries);
2032 entries
2033}
2034
2035fn rust_use_body(raw: &str) -> Option<&str> {
2036 let use_pos = raw.find("use ")?;
2037 let body = raw[use_pos + 4..].trim();
2038 let body = body.strip_suffix(';').unwrap_or(body).trim();
2039 (!body.is_empty()).then_some(body)
2040}
2041
2042fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
2043 let path = path.trim();
2044 if path.is_empty() {
2045 return;
2046 }
2047
2048 if let Some((prefix, inner)) = split_rust_use_braces(path) {
2049 let prefix = prefix.trim().trim_end_matches("::").trim();
2050 for part in split_top_level_commas(inner) {
2051 let part = part.trim();
2052 if part.is_empty() {
2053 continue;
2054 }
2055 if part == "self" {
2056 if let Some(local_name) = rust_last_path_segment(prefix) {
2057 entries.push(RustUseEntry {
2058 module_path: prefix.to_string(),
2059 local_name,
2060 kind: RustUseKind::Module,
2061 });
2062 }
2063 continue;
2064 }
2065 let combined = if prefix.is_empty() {
2066 part.to_string()
2067 } else {
2068 format!("{prefix}::{part}")
2069 };
2070 expand_rust_use_tree(&combined, entries);
2071 }
2072 return;
2073 }
2074
2075 add_rust_use_leaf(path, entries);
2076}
2077
2078fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
2079 let mut depth = 0usize;
2080 let mut start = None;
2081 for (idx, ch) in path.char_indices() {
2082 match ch {
2083 '{' => {
2084 if depth == 0 {
2085 start = Some(idx);
2086 }
2087 depth += 1;
2088 }
2089 '}' => {
2090 depth = depth.checked_sub(1)?;
2091 if depth == 0 {
2092 let start = start?;
2093 if !path[idx + ch.len_utf8()..].trim().is_empty() {
2094 return None;
2095 }
2096 return Some((&path[..start], &path[start + 1..idx]));
2097 }
2098 }
2099 _ => {}
2100 }
2101 }
2102 None
2103}
2104
2105fn split_top_level_commas(value: &str) -> Vec<&str> {
2106 let mut parts = Vec::new();
2107 let mut depth = 0usize;
2108 let mut start = 0usize;
2109 for (idx, ch) in value.char_indices() {
2110 match ch {
2111 '{' => depth += 1,
2112 '}' => depth = depth.saturating_sub(1),
2113 ',' if depth == 0 => {
2114 parts.push(&value[start..idx]);
2115 start = idx + ch.len_utf8();
2116 }
2117 _ => {}
2118 }
2119 }
2120 parts.push(&value[start..]);
2121 parts
2122}
2123
2124fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
2125 let (path, alias) = split_rust_alias(path);
2126 let Some(segments) = rust_path_segments(path) else {
2127 return;
2128 };
2129 if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
2130 return;
2131 }
2132
2133 let imported_name = segments.last().cloned().unwrap_or_default();
2134 let local_name = alias.unwrap_or(&imported_name).to_string();
2135 if segments.len() >= 2 {
2136 entries.push(RustUseEntry {
2137 module_path: segments[..segments.len() - 1].join("::"),
2138 local_name: local_name.clone(),
2139 kind: RustUseKind::Item {
2140 imported_name: imported_name.clone(),
2141 },
2142 });
2143 }
2144
2145 entries.push(RustUseEntry {
2146 module_path: segments.join("::"),
2147 local_name,
2148 kind: RustUseKind::Module,
2149 });
2150}
2151
2152fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
2153 if let Some(idx) = path.rfind(" as ") {
2154 let original = path[..idx].trim();
2155 let alias = path[idx + 4..].trim();
2156 if !original.is_empty() && !alias.is_empty() {
2157 return (original, Some(alias));
2158 }
2159 }
2160 (path.trim(), None)
2161}
2162
2163fn rust_path_segments(path: &str) -> Option<Vec<String>> {
2164 let path = path.trim().trim_end_matches(';').trim();
2165 if path.is_empty() || path.contains('{') || path.contains('}') {
2166 return None;
2167 }
2168
2169 let mut segments = Vec::new();
2170 for raw_segment in path.split("::") {
2171 let segment = raw_segment.trim();
2172 if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
2173 return None;
2174 }
2175 let segment = segment.strip_prefix("r#").unwrap_or(segment);
2176 if segment
2177 .chars()
2178 .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
2179 {
2180 return None;
2181 }
2182 segments.push(segment.to_string());
2183 }
2184
2185 (!segments.is_empty()).then_some(segments)
2186}
2187
2188fn rust_last_path_segment(path: &str) -> Option<String> {
2189 rust_path_segments(path)?.last().cloned()
2190}
2191
2192fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
2193 let mut current = if from.is_file() {
2194 from.parent()
2195 } else {
2196 Some(from)
2197 };
2198 while let Some(dir) = current {
2199 if dir.join("Cargo.toml").is_file() {
2200 return Some(canonicalize_path(dir));
2201 }
2202 current = dir.parent();
2203 }
2204 None
2205}
2206
2207fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
2208 let root = canonicalize_path(crate_root);
2209 if let Some(cached) = RUST_CRATE_INFO_CACHE
2210 .read()
2211 .ok()
2212 .and_then(|cache| cache.get(&root).cloned())
2213 {
2214 return cached;
2215 }
2216
2217 let resolved = read_rust_crate_info(&root);
2218 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2219 cache.insert(root, resolved.clone());
2220 }
2221 resolved
2222}
2223
2224fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
2225 let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
2226 let package = cargo.get("package")?;
2227 let package_name = package.get("name")?.as_str()?;
2228 let lib_name = cargo
2229 .get("lib")
2230 .and_then(|lib| lib.get("name"))
2231 .and_then(|name| name.as_str())
2232 .map(ToOwned::to_owned)
2233 .unwrap_or_else(|| package_name.replace('-', "_"));
2234
2235 let lib_root = cargo
2236 .get("lib")
2237 .and_then(|lib| lib.get("path"))
2238 .and_then(|path| path.as_str())
2239 .map(|path| crate_root.join(path))
2240 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
2241 let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
2242
2243 Some(RustCrateInfo {
2244 package_root: canonicalize_path(crate_root),
2245 lib_name,
2246 lib_root,
2247 target_roots: rust_target_roots(
2248 crate_root,
2249 &cargo,
2250 &FactPaths {
2251 root: crate_root,
2252 facts: &DiskFacts::new(crate_root),
2253 },
2254 ),
2255 })
2256}
2257
2258fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
2259 let source = std::fs::read_to_string(path).ok()?;
2260 toml::from_str(&source).ok()
2261}
2262
2263fn rust_target_roots(
2264 crate_root: &Path,
2265 cargo: &toml::Value,
2266 facts: &FactPaths<'_>,
2267) -> Vec<PathBuf> {
2268 let mut roots = Vec::new();
2269 for (table, directory, auto_key) in [
2270 ("bin", "src/bin", "autobins"),
2271 ("example", "examples", "autoexamples"),
2272 ("test", "tests", "autotests"),
2273 ("bench", "benches", "autobenches"),
2274 ] {
2275 if let Some(targets) = cargo.get(table).and_then(toml::Value::as_array) {
2276 for path in targets.iter().filter_map(|target| {
2277 target
2278 .get("path")
2279 .and_then(toml::Value::as_str)
2280 .map(|path| crate_root.join(path))
2281 }) {
2282 if facts.is_file(&path) {
2283 roots.push(facts.canonical(&path).unwrap_or(path));
2284 }
2285 }
2286 }
2287 let enabled = cargo
2288 .get("package")
2289 .and_then(|package| package.get(auto_key))
2290 .and_then(toml::Value::as_bool)
2291 .unwrap_or(true);
2292 if !enabled {
2293 continue;
2294 }
2295 let directory = crate_root.join(directory);
2296 for entry in facts.list_dir(&directory) {
2297 let path = directory.join(byte_path(&entry.name));
2298 let root = match entry.kind {
2299 EntryKind::Regular
2300 if path.extension().and_then(|ext| ext.to_str()) == Some("rs") =>
2301 {
2302 Some(path)
2303 }
2304 EntryKind::Directory => Some(path.join("main.rs")),
2305 _ => None,
2306 };
2307 if let Some(root) = root.filter(|root| facts.is_file(root)) {
2308 roots.push(facts.canonical(&root).unwrap_or(root));
2309 }
2310 }
2311 }
2312 if cargo
2313 .get("package")
2314 .and_then(|package| package.get("autobins"))
2315 .and_then(toml::Value::as_bool)
2316 .unwrap_or(true)
2317 {
2318 let main = crate_root.join("src/main.rs");
2319 if facts.is_file(&main) {
2320 roots.push(facts.canonical(&main).unwrap_or(main));
2321 }
2322 }
2323 roots.sort();
2324 roots.dedup();
2325 roots
2326}
2327
2328impl RustCrateRootMemo {
2329 pub(crate) fn root_file(
2330 &self,
2331 project_root: &Path,
2332 caller_file: &Path,
2333 facts: &FactPaths<'_>,
2334 ) -> Option<PathBuf> {
2335 let caller = facts
2336 .canonical(caller_file)
2337 .unwrap_or_else(|| caller_file.to_path_buf());
2338 if let Some(cached) = self.caller_roots.borrow().get(&caller).cloned() {
2339 facts.facts.memo_replay(&caller, "rust-crate-root", "");
2340 return cached;
2341 }
2342
2343 facts.facts.memo_start(&caller, "rust-crate-root", "");
2344 let resolved = self.resolve_root_file(project_root, &caller, facts);
2345 facts.facts.memo_finish(&caller, "rust-crate-root", "");
2346 self.caller_roots
2347 .borrow_mut()
2348 .insert(caller, resolved.clone());
2349 resolved
2350 }
2351
2352 fn resolve_root_file(
2353 &self,
2354 project_root: &Path,
2355 caller: &Path,
2356 facts: &FactPaths<'_>,
2357 ) -> Option<PathBuf> {
2358 let mut current = caller.parent();
2359 let crate_root = loop {
2360 let dir = current?;
2361 if facts.is_file(&dir.join("Cargo.toml")) {
2362 break dir.to_path_buf();
2363 }
2364 if dir == project_root {
2365 return None;
2366 }
2367 current = dir.parent();
2368 };
2369 let cached_targets = { self.crate_targets.borrow().get(&crate_root).cloned() };
2370 let targets = if let Some(cached) = cached_targets {
2371 facts
2372 .facts
2373 .memo_replay(&crate_root, "rust-target-roots", "");
2374 cached
2375 } else {
2376 facts.facts.memo_start(&crate_root, "rust-target-roots", "");
2377 let resolved = read_rust_crate_targets(&crate_root, facts);
2378 facts
2379 .facts
2380 .memo_finish(&crate_root, "rust-target-roots", "");
2381 self.crate_targets
2382 .borrow_mut()
2383 .insert(crate_root.clone(), resolved.clone());
2384 resolved
2385 }?;
2386 if targets.lib_root.as_ref() == Some(&caller.to_path_buf()) {
2387 return targets.lib_root;
2388 }
2389 targets
2390 .target_roots
2391 .into_iter()
2392 .filter(|root| {
2393 let default_main = *root == crate_root.join("src/main.rs");
2394 root.parent()
2395 .is_some_and(|dir| caller == root || (!default_main && caller.starts_with(dir)))
2396 })
2397 .max_by_key(|root| {
2398 (
2399 caller == root,
2400 root.parent()
2401 .map(|dir| dir.components().count())
2402 .unwrap_or(0),
2403 )
2404 })
2405 .or(targets.lib_root)
2406 }
2407}
2408
2409fn read_rust_crate_targets(crate_root: &Path, facts: &FactPaths<'_>) -> Option<RustCrateTargets> {
2410 let bytes = facts.attributed_bytes(&crate_root.join("Cargo.toml"))?;
2411 let cargo: toml::Value = toml::from_str(std::str::from_utf8(&bytes).ok()?).ok()?;
2412 let lib = cargo
2413 .get("lib")
2414 .and_then(|lib| lib.get("path"))
2415 .and_then(toml::Value::as_str)
2416 .map(|path| crate_root.join(path))
2417 .unwrap_or_else(|| crate_root.join("src/lib.rs"));
2418 let lib_root = facts
2419 .is_file(&lib)
2420 .then(|| facts.canonical(&lib).unwrap_or(lib));
2421 Some(RustCrateTargets {
2422 lib_root,
2423 target_roots: rust_target_roots(crate_root, &cargo, facts),
2424 })
2425}
2426
2427pub(crate) fn rust_crate_root_file_for_caller(
2428 project_root: &Path,
2429 caller_file: &Path,
2430 facts: &FactPaths<'_>,
2431 memo: &RustCrateRootMemo,
2432) -> Option<PathBuf> {
2433 memo.root_file(project_root, caller_file, facts)
2434}
2435
2436fn rust_module_base_for_caller(
2437 crate_info: &RustCrateInfo,
2438 caller_file: &Path,
2439) -> Option<RustModuleBase> {
2440 let caller = canonicalize_path(caller_file);
2441 if crate_info.lib_root.as_ref() == Some(&caller) {
2442 return rust_lib_module_base(crate_info);
2443 }
2444 crate_info
2445 .target_roots
2446 .iter()
2447 .filter_map(|root_file| {
2448 let src_dir = root_file.parent()?;
2449 let default_main = *root_file == crate_info.package_root.join("src/main.rs");
2450 (caller == *root_file || (!default_main && caller.starts_with(src_dir))).then(|| {
2451 RustModuleBase {
2452 src_dir: src_dir.to_path_buf(),
2453 root_file: root_file.clone(),
2454 }
2455 })
2456 })
2457 .max_by_key(|base| (caller == base.root_file, base.src_dir.components().count()))
2458 .or_else(|| rust_lib_module_base(crate_info))
2459}
2460
2461fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
2462 let root_file = crate_info.lib_root.clone()?;
2463 let src_dir = root_file.parent()?.to_path_buf();
2464 Some(RustModuleBase { src_dir, root_file })
2465}
2466
2467fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
2468 if segments.is_empty() {
2469 return Some(base.root_file.clone());
2470 }
2471
2472 let module_base = segments
2473 .iter()
2474 .fold(base.src_dir.clone(), |path, segment| path.join(segment));
2475 let file_path = module_base.with_extension("rs");
2476 if file_path.is_file() {
2477 return Some(canonicalize_path(&file_path));
2478 }
2479
2480 let mod_path = module_base.join("mod.rs");
2481 if mod_path.is_file() {
2482 return Some(canonicalize_path(&mod_path));
2483 }
2484
2485 None
2486}
2487
2488fn rust_module_segments_for_file(base: &RustModuleBase, file: &Path) -> Option<Vec<String>> {
2489 let src_dir = canonicalize_path(&base.src_dir);
2490 let file = canonicalize_path(file);
2491 if file == canonicalize_path(&base.root_file) {
2492 return Some(Vec::new());
2493 }
2494 let rel = file.strip_prefix(&src_dir).ok()?;
2495 let mut parts: Vec<String> = rel
2496 .components()
2497 .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
2498 .collect();
2499 if parts.is_empty() {
2500 return None;
2501 }
2502
2503 let last = parts.pop()?;
2504 if last == "lib.rs" || last == "main.rs" {
2505 return Some(Vec::new());
2506 }
2507 if last == "mod.rs" {
2508 return Some(parts);
2509 }
2510 let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
2511 parts.push(stem);
2512 Some(parts)
2513}
2514
2515fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
2516 let workspace_root =
2517 find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
2518 let workspace_root = canonicalize_path(&workspace_root);
2519
2520 if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
2521 .read()
2522 .ok()
2523 .and_then(|cache| cache.get(&workspace_root).cloned())
2524 {
2525 return Some(cached);
2526 }
2527
2528 let mut crates = HashMap::new();
2529 for member in rust_workspace_member_dirs(&workspace_root) {
2530 if let Some(info) = rust_crate_info(&member) {
2531 if info.lib_root.is_some() {
2532 crates.insert(info.lib_name.clone(), info);
2533 }
2534 }
2535 }
2536 if let Some(info) = rust_crate_info(&workspace_root) {
2537 if info.lib_root.is_some() {
2538 crates.insert(info.lib_name.clone(), info);
2539 }
2540 }
2541
2542 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2543 cache.insert(workspace_root, crates.clone());
2544 }
2545 Some(crates)
2546}
2547
2548fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2549 let mut current = Some(from_dir);
2550 while let Some(dir) = current {
2551 let cargo = dir.join("Cargo.toml");
2552 if rust_manifest_value(&cargo)
2553 .and_then(|value| value.get("workspace").cloned())
2554 .is_some()
2555 {
2556 return Some(canonicalize_path(dir));
2557 }
2558 current = dir.parent();
2559 }
2560 None
2561}
2562
2563fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2564 let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
2565 return Vec::new();
2566 };
2567 let Some(members) = cargo
2568 .get("workspace")
2569 .and_then(|workspace| workspace.get("members"))
2570 .and_then(|members| members.as_array())
2571 else {
2572 return Vec::new();
2573 };
2574
2575 let mut dirs = Vec::new();
2576 for member in members.iter().filter_map(|member| member.as_str()) {
2577 dirs.extend(expand_rust_workspace_member(workspace_root, member));
2578 }
2579 dirs.sort();
2580 dirs.dedup();
2581 dirs
2582}
2583
2584fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
2585 let member = member.trim();
2586 if member.is_empty() {
2587 return Vec::new();
2588 }
2589
2590 if member.contains('*') || member.contains('?') || member.contains('[') {
2591 let pattern = workspace_root.join(member).to_string_lossy().to_string();
2592 return crate::walk_boundary::expand_glob_same_file_system(&pattern)
2593 .unwrap_or_default()
2594 .into_iter()
2595 .filter(|path| path.join("Cargo.toml").is_file())
2596 .map(|path| canonicalize_path(&path))
2597 .collect();
2598 }
2599
2600 let path = workspace_root.join(member);
2601 if path.join("Cargo.toml").is_file() {
2602 vec![canonicalize_path(&path)]
2603 } else {
2604 Vec::new()
2605 }
2606}
2607
2608fn canonicalize_path(path: &Path) -> PathBuf {
2609 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2610}
2611
2612fn resolve_tsconfig_path(
2613 from_dir: &Path,
2614 module_path: &str,
2615 memo: Option<&ModuleResolutionMemo>,
2616 facts: &FactPaths<'_>,
2617) -> Option<PathBuf> {
2618 let tsconfig_dir = find_tsconfig_dir(from_dir, facts)?;
2619 let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"), memo, facts);
2620 facts.config_field(&tsconfig_dir, "tsconfig.json", "compilerOptions.paths");
2621 let tsconfig = tsconfig?;
2622 let compiler_options = tsconfig.get("compilerOptions")?;
2623 let paths = compiler_options.get("paths")?.as_object()?;
2624 facts.config_field(&tsconfig_dir, "tsconfig.json", "baseUrl");
2625 let base_url = compiler_options
2626 .get("baseUrl")
2627 .and_then(Value::as_str)
2628 .unwrap_or(".");
2629 let base_dir = tsconfig_dir.join(base_url);
2630
2631 for (alias, targets) in paths {
2632 let Some(capture) = ts_path_capture(alias, module_path) else {
2633 continue;
2634 };
2635 let Some(targets) = targets.as_array() else {
2636 continue;
2637 };
2638 for target in targets.iter().filter_map(Value::as_str) {
2639 let target = if target.contains('*') {
2640 target.replace('*', capture)
2641 } else {
2642 target.to_string()
2643 };
2644 if let Some(path) = resolve_file_like_path(&base_dir.join(target), facts) {
2645 return Some(path);
2646 }
2647 }
2648 }
2649
2650 None
2651}
2652
2653fn find_tsconfig_dir(from_dir: &Path, facts: &FactPaths<'_>) -> Option<PathBuf> {
2654 let mut current = Some(from_dir);
2655 while let Some(dir) = current {
2656 if facts.is_file(&dir.join("tsconfig.json")) {
2657 return Some(dir.to_path_buf());
2658 }
2659 current = dir.parent();
2660 }
2661 None
2662}
2663
2664fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2665 if let Some(star_index) = alias.find('*') {
2666 let (prefix, suffix_with_star) = alias.split_at(star_index);
2667 let suffix = &suffix_with_star[1..];
2668 if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2669 return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2670 }
2671 return None;
2672 }
2673
2674 (alias == module_path).then_some("")
2675}
2676
2677fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2678 let mut parts = module_path.split('/');
2679 let first = parts.next()?;
2680 if first.is_empty() {
2681 return None;
2682 }
2683
2684 if first.starts_with('@') {
2685 let second = parts.next()?;
2686 if second.is_empty() {
2687 return None;
2688 }
2689 let package_name = format!("{first}/{second}");
2690 let subpath = parts.collect::<Vec<_>>().join("/");
2691 let subpath = (!subpath.is_empty()).then_some(subpath);
2692 Some((package_name, subpath))
2693 } else {
2694 let package_name = first.to_string();
2695 let subpath = parts.collect::<Vec<_>>().join("/");
2696 let subpath = (!subpath.is_empty()).then_some(subpath);
2697 Some((package_name, subpath))
2698 }
2699}
2700
2701fn find_package_root_for_import(
2702 from_dir: &Path,
2703 package_name: &str,
2704 memo: Option<&ModuleResolutionMemo>,
2705 facts: &FactPaths<'_>,
2706) -> Option<PathBuf> {
2707 let mut current = Some(from_dir);
2708 while let Some(dir) = current {
2709 if package_json_name(dir, memo, facts).as_deref() == Some(package_name) {
2710 return Some(facts.canonical(dir).unwrap_or_else(|| dir.to_path_buf()));
2711 }
2712 current = dir.parent();
2713 }
2714
2715 find_workspace_root(from_dir, memo, facts).and_then(|workspace_root| {
2716 resolve_workspace_package(&workspace_root, package_name, memo, facts)
2717 })
2718}
2719
2720fn find_workspace_root(
2721 from_dir: &Path,
2722 memo: Option<&ModuleResolutionMemo>,
2723 facts: &FactPaths<'_>,
2724) -> Option<PathBuf> {
2725 let mut current = Some(from_dir);
2726 while let Some(dir) = current {
2727 if is_workspace_root(dir, memo, facts) {
2728 return Some(facts.canonical(dir).unwrap_or_else(|| dir.to_path_buf()));
2729 }
2730 current = dir.parent();
2731 }
2732 None
2733}
2734
2735fn is_workspace_root(
2736 dir: &Path,
2737 memo: Option<&ModuleResolutionMemo>,
2738 facts: &FactPaths<'_>,
2739) -> bool {
2740 facts.config_field(dir, "package.json", "workspaces");
2741 package_json_value(dir, memo, facts)
2742 .map(|value| !workspace_patterns(&value).is_empty())
2743 .unwrap_or(false)
2744 || !pnpm_workspace_patterns(dir, facts).is_empty()
2745}
2746
2747pub(crate) fn clear_workspace_package_cache_under(root: &Path) {
2754 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
2755 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2756 cache.retain(|(workspace, _), _| !workspace.starts_with(&root));
2757 }
2758 if let Ok(mut cache) = WORKSPACE_MEMBER_DIRS_CACHE.write() {
2759 cache.retain(|workspace, _| !workspace.starts_with(&root));
2760 }
2761 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2762 cache.retain(|dir, _| !dir.starts_with(&root));
2763 }
2764 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2765 cache.retain(|workspace, _| !workspace.starts_with(&root));
2766 }
2767}
2768
2769pub(crate) fn clear_workspace_package_cache() {
2770 if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2771 cache.clear();
2772 }
2773 if let Ok(mut cache) = WORKSPACE_MEMBER_DIRS_CACHE.write() {
2774 cache.clear();
2775 }
2776 if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2777 cache.clear();
2778 }
2779 if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2780 cache.clear();
2781 }
2782}
2783
2784fn resolve_workspace_package(
2785 workspace_root: &Path,
2786 package_name: &str,
2787 memo: Option<&ModuleResolutionMemo>,
2788 facts: &FactPaths<'_>,
2789) -> Option<PathBuf> {
2790 let workspace_root = facts
2791 .canonical(workspace_root)
2792 .unwrap_or_else(|| workspace_root.to_path_buf());
2793 let cache_key = (workspace_root.clone(), package_name.to_string());
2794
2795 if let Some(memo) = memo {
2803 if let Some(cached) = memo.workspace_package(&cache_key) {
2804 facts
2805 .facts
2806 .memo_replay(&workspace_root, "package", package_name);
2807 return cached;
2808 }
2809 }
2810 if facts.facts.records_config_facts() {
2811 if let Some(cached) = facts.facts.workspace_package(&workspace_root, package_name) {
2812 facts
2813 .facts
2814 .memo_replay(&workspace_root, "package", package_name);
2815 return cached;
2816 }
2817 } else if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2818 if let Some(cached) = cache.get(&cache_key) {
2819 if let Some(memo) = memo {
2820 memo.remember_workspace_package(cache_key, cached.clone());
2821 }
2822 facts
2823 .facts
2824 .memo_replay(&workspace_root, "package", package_name);
2825 return cached.clone();
2826 }
2827 }
2828
2829 facts
2830 .facts
2831 .memo_start(&workspace_root, "package", package_name);
2832 let resolved = cached_workspace_member_dirs(&workspace_root, memo, facts)
2833 .iter()
2834 .find(|dir| package_json_name(dir, memo, facts).as_deref() == Some(package_name))
2835 .map(|dir| facts.canonical(dir).unwrap_or_else(|| dir.clone()));
2836
2837 facts
2838 .facts
2839 .memo_finish(&workspace_root, "package", package_name);
2840 if let Some(memo) = memo {
2841 memo.remember_workspace_package(cache_key.clone(), resolved.clone());
2842 }
2843 if facts.facts.records_config_facts() {
2844 facts
2845 .facts
2846 .remember_workspace_package(&workspace_root, package_name, resolved.clone());
2847 } else if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2848 cache.insert(cache_key, resolved.clone());
2849 }
2850
2851 resolved
2852}
2853
2854fn cached_workspace_member_dirs(
2858 workspace_root: &Path,
2859 memo: Option<&ModuleResolutionMemo>,
2860 facts: &FactPaths<'_>,
2861) -> Arc<Vec<PathBuf>> {
2862 if facts.facts.records_config_facts() {
2863 if let Some(members) = facts.facts.workspace_members(workspace_root) {
2864 facts.facts.memo_replay(workspace_root, "members", "");
2865 return members;
2866 }
2867 } else if let Ok(cache) = WORKSPACE_MEMBER_DIRS_CACHE.read() {
2868 if let Some(members) = cache.get(workspace_root) {
2869 facts.facts.memo_replay(workspace_root, "members", "");
2870 return Arc::clone(members);
2871 }
2872 }
2873 facts.facts.memo_start(workspace_root, "members", "");
2874 let members = Arc::new(workspace_member_dirs(workspace_root, memo, facts));
2875 facts.facts.memo_finish(workspace_root, "members", "");
2876 if facts.facts.records_config_facts() {
2877 facts
2878 .facts
2879 .remember_workspace_members(workspace_root, Arc::clone(&members));
2880 } else if let Ok(mut cache) = WORKSPACE_MEMBER_DIRS_CACHE.write() {
2881 cache.insert(workspace_root.to_path_buf(), Arc::clone(&members));
2882 }
2883 members
2884}
2885
2886fn workspace_member_dirs(
2887 workspace_root: &Path,
2888 memo: Option<&ModuleResolutionMemo>,
2889 facts: &FactPaths<'_>,
2890) -> Vec<PathBuf> {
2891 facts.config_field(workspace_root, "package.json", "workspaces");
2892 let mut patterns = package_json_value(workspace_root, memo, facts)
2893 .map(|package_json| workspace_patterns(&package_json))
2894 .unwrap_or_default();
2895 patterns.extend(pnpm_workspace_patterns(workspace_root, facts));
2896
2897 expand_workspace_patterns(workspace_root, &patterns, facts)
2898}
2899
2900pub(crate) fn workspace_patterns(package_json: &Value) -> Vec<String> {
2901 match package_json.get("workspaces") {
2902 Some(Value::Array(items)) => items
2903 .iter()
2904 .filter_map(non_empty_workspace_pattern)
2905 .collect(),
2906 Some(Value::Object(map)) => map
2907 .get("packages")
2908 .and_then(Value::as_array)
2909 .map(|items| {
2910 items
2911 .iter()
2912 .filter_map(non_empty_workspace_pattern)
2913 .collect()
2914 })
2915 .unwrap_or_default(),
2916 _ => Vec::new(),
2917 }
2918}
2919
2920fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2921 let pattern = value.as_str()?.trim();
2922 (!pattern.is_empty()).then(|| pattern.to_string())
2923}
2924
2925fn pnpm_workspace_patterns(workspace_root: &Path, facts: &FactPaths<'_>) -> Vec<String> {
2926 facts.config_field(workspace_root, "pnpm-workspace.yaml", "packages");
2927 let Some(bytes) = facts.attributed_bytes(&workspace_root.join("pnpm-workspace.yaml")) else {
2928 return Vec::new();
2929 };
2930
2931 parse_pnpm_workspace_patterns(&bytes)
2932}
2933
2934pub(crate) fn parse_pnpm_workspace_patterns(bytes: &[u8]) -> Vec<String> {
2935 let Ok(source) = std::str::from_utf8(bytes) else {
2936 return Vec::new();
2937 };
2938 let mut patterns = Vec::new();
2939 let mut in_packages = false;
2940 for line in source.lines() {
2941 let without_comment = line.split('#').next().unwrap_or("").trim_end();
2942 let trimmed = without_comment.trim();
2943 if trimmed.is_empty() {
2944 continue;
2945 }
2946 if trimmed == "packages:" {
2947 in_packages = true;
2948 continue;
2949 }
2950 if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2951 in_packages = false;
2952 }
2953 if in_packages {
2954 if let Some(pattern) = trimmed.strip_prefix('-') {
2955 let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2956 if !pattern.is_empty() {
2957 patterns.push(pattern.to_string());
2958 }
2959 }
2960 }
2961 }
2962 patterns
2963}
2964
2965fn expand_workspace_patterns(
2966 workspace_root: &Path,
2967 patterns: &[String],
2968 facts: &FactPaths<'_>,
2969) -> Vec<PathBuf> {
2970 let positive_patterns: Vec<&str> = patterns
2971 .iter()
2972 .map(|pattern| pattern.trim())
2973 .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2974 .collect();
2975 if positive_patterns.is_empty() {
2976 return Vec::new();
2977 }
2978
2979 let positives = build_glob_set(&positive_patterns);
2980 let negative_patterns: Vec<&str> = patterns
2981 .iter()
2982 .map(|pattern| pattern.trim())
2983 .filter_map(|pattern| pattern.strip_prefix('!'))
2984 .map(str::trim)
2985 .filter(|pattern| !pattern.is_empty())
2986 .collect();
2987 let negatives = build_glob_set(&negative_patterns);
2988
2989 let mut members = Vec::new();
2990 collect_workspace_member_dirs(
2991 workspace_root,
2992 workspace_root,
2993 &positives,
2994 &negatives,
2995 &mut members,
2996 facts,
2997 );
2998 members
2999}
3000
3001fn build_glob_set(patterns: &[&str]) -> GlobSet {
3002 let mut builder = GlobSetBuilder::new();
3003 for pattern in patterns {
3004 if let Ok(glob) = Glob::new(pattern) {
3005 builder.add(glob);
3006 }
3007 }
3008 builder
3009 .build()
3010 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
3011}
3012
3013fn collect_workspace_member_dirs(
3014 workspace_root: &Path,
3015 dir: &Path,
3016 positives: &GlobSet,
3017 negatives: &GlobSet,
3018 members: &mut Vec<PathBuf>,
3019 facts: &FactPaths<'_>,
3020) {
3021 for entry in facts.list_dir(dir) {
3022 if entry.kind != EntryKind::Directory {
3023 continue;
3024 }
3025 let path = dir.join(byte_path(&entry.name));
3026 let name = String::from_utf8_lossy(&entry.name);
3027 if matches!(
3028 name.as_ref(),
3029 "node_modules" | ".git" | "target" | "dist" | "build"
3030 ) {
3031 continue;
3032 }
3033
3034 if facts.is_file(&path.join("package.json")) {
3035 if let Ok(rel) = path.strip_prefix(workspace_root) {
3036 let rel = rel.to_string_lossy().replace('\\', "/");
3037 if positives.is_match(&rel) && !negatives.is_match(&rel) {
3038 members.push(path.clone());
3039 }
3040 }
3041 }
3042
3043 collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members, facts);
3044 }
3045}
3046
3047fn package_json_value(
3048 dir: &Path,
3049 memo: Option<&ModuleResolutionMemo>,
3050 facts: &FactPaths<'_>,
3051) -> Option<Arc<Value>> {
3052 package_json_like_value(&dir.join("package.json"), memo, facts)
3053}
3054
3055fn package_json_like_value(
3056 path: &Path,
3057 memo: Option<&ModuleResolutionMemo>,
3058 facts: &FactPaths<'_>,
3059) -> Option<Arc<Value>> {
3060 if let Some(memo) = memo {
3061 return memo.json_value(path, facts);
3062 }
3063 let json = facts.attributed_bytes(path)?;
3064 serde_json::from_slice(&json).ok().map(Arc::new)
3065}
3066
3067fn package_json_name(
3068 dir: &Path,
3069 memo: Option<&ModuleResolutionMemo>,
3070 facts: &FactPaths<'_>,
3071) -> Option<String> {
3072 facts.config_field(dir, "package.json", "name");
3073 package_json_value(dir, memo, facts)?
3074 .get("name")?
3075 .as_str()
3076 .map(ToOwned::to_owned)
3077}
3078
3079fn resolve_package_entry(
3080 package_root: &Path,
3081 subpath: &Option<String>,
3082 memo: Option<&ModuleResolutionMemo>,
3083 facts: &FactPaths<'_>,
3084) -> Option<PathBuf> {
3085 let package_json =
3086 package_json_value(package_root, memo, facts).unwrap_or_else(|| Arc::new(Value::Null));
3087
3088 facts.config_field(package_root, "package.json", "exports");
3089 if let Some(exports) = package_json.get("exports") {
3090 if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
3091 if let Some(path) = resolve_package_target(package_root, &target, facts) {
3092 return Some(path);
3093 }
3094 }
3095 }
3096
3097 if subpath.is_none() {
3098 for field in ["module", "main"] {
3099 facts.config_field(package_root, "package.json", field);
3100 if let Some(target) = package_json.get(field).and_then(Value::as_str) {
3101 if let Some(path) = resolve_package_target(package_root, target, facts) {
3102 return Some(path);
3103 }
3104 }
3105 }
3106 }
3107
3108 resolve_package_fallback(package_root, subpath.as_deref(), facts)
3109}
3110
3111fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
3112 let key = subpath
3113 .map(|value| format!("./{value}"))
3114 .unwrap_or_else(|| ".".to_string());
3115
3116 match exports {
3117 Value::String(target) if key == "." => Some(target.clone()),
3118 Value::Object(map) => {
3119 if let Some(target) = map.get(&key).and_then(export_condition_target) {
3120 return Some(target);
3121 }
3122
3123 if let Some(target) = wildcard_export_target(map, &key) {
3124 return Some(target);
3125 }
3126
3127 if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
3128 return export_condition_target(exports);
3129 }
3130
3131 None
3132 }
3133 _ => None,
3134 }
3135}
3136
3137fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
3138 for (pattern, target) in map {
3139 let Some(star_index) = pattern.find('*') else {
3140 continue;
3141 };
3142 let (prefix, suffix_with_star) = pattern.split_at(star_index);
3143 let suffix = &suffix_with_star[1..];
3144 if !key.starts_with(prefix) || !key.ends_with(suffix) {
3145 continue;
3146 }
3147 let matched = &key[prefix.len()..key.len() - suffix.len()];
3148 if let Some(target_pattern) = export_condition_target(target) {
3149 return Some(target_pattern.replace('*', matched));
3150 }
3151 }
3152 None
3153}
3154
3155fn export_condition_target(value: &Value) -> Option<String> {
3156 match value {
3157 Value::String(target) => Some(target.clone()),
3158 Value::Object(map) => ["source", "import", "module", "default", "types"]
3159 .into_iter()
3160 .find_map(|field| map.get(field).and_then(export_condition_target)),
3161 _ => None,
3162 }
3163}
3164
3165fn resolve_package_target(
3166 package_root: &Path,
3167 target: &str,
3168 facts: &FactPaths<'_>,
3169) -> Option<PathBuf> {
3170 let target = target.strip_prefix("./").unwrap_or(target);
3171 if let Some(src_relative) = target.strip_prefix("dist/") {
3174 if let Some(path) =
3175 resolve_file_like_path(&package_root.join("src").join(src_relative), facts)
3176 {
3177 return Some(path);
3178 }
3179 }
3180
3181 resolve_file_like_path(&package_root.join(target), facts)
3182}
3183
3184fn resolve_package_fallback(
3185 package_root: &Path,
3186 subpath: Option<&str>,
3187 facts: &FactPaths<'_>,
3188) -> Option<PathBuf> {
3189 match subpath {
3190 Some(subpath) => resolve_file_like_path(&package_root.join(subpath), facts)
3191 .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath), facts)),
3192 None => resolve_file_like_path(&package_root.join("src").join("index"), facts)
3193 .or_else(|| resolve_file_like_path(&package_root.join("index"), facts)),
3194 }
3195}
3196
3197pub(crate) fn resolve_reexported_symbol_target<F, D>(
3198 file: &Path,
3199 symbol_name: &str,
3200 file_exports_symbol: &mut F,
3201 file_default_export_symbol: &mut D,
3202) -> Option<(PathBuf, String)>
3203where
3204 F: FnMut(&Path, &str) -> bool,
3205 D: FnMut(&Path) -> Option<String>,
3206{
3207 resolve_reexported_symbol(
3208 file,
3209 symbol_name,
3210 file_exports_symbol,
3211 file_default_export_symbol,
3212 )
3213 .map(|target| (target.file, target.symbol))
3214}
3215
3216fn resolve_reexported_symbol<F, D>(
3217 file: &Path,
3218 symbol_name: &str,
3219 file_exports_symbol: &mut F,
3220 file_default_export_symbol: &mut D,
3221) -> Option<ResolvedSymbol>
3222where
3223 F: FnMut(&Path, &str) -> bool,
3224 D: FnMut(&Path) -> Option<String>,
3225{
3226 let mut visited = HashSet::new();
3227 resolve_reexported_symbol_inner(
3228 file,
3229 symbol_name,
3230 file_exports_symbol,
3231 file_default_export_symbol,
3232 &mut visited,
3233 )
3234}
3235
3236fn resolve_reexported_symbol_inner<F, D>(
3237 file: &Path,
3238 symbol_name: &str,
3239 file_exports_symbol: &mut F,
3240 file_default_export_symbol: &mut D,
3241 visited: &mut HashSet<(PathBuf, String)>,
3242) -> Option<ResolvedSymbol>
3243where
3244 F: FnMut(&Path, &str) -> bool,
3245 D: FnMut(&Path) -> Option<String>,
3246{
3247 let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
3248 if !visited.insert((canon.clone(), symbol_name.to_string())) {
3249 return None;
3250 }
3251
3252 let source = std::fs::read_to_string(&canon).ok()?;
3253 let lang = detect_language(&canon)?;
3254 if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3255 if symbol_name == "default" {
3256 return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
3257 file: canon,
3258 symbol,
3259 });
3260 }
3261 return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
3262 file: canon,
3263 symbol: symbol_name.to_string(),
3264 });
3265 }
3266
3267 let grammar = grammar_for(lang);
3268 let mut parser = Parser::new();
3269 parser.set_language(&grammar).ok()?;
3270 let tree = parser.parse(&source, None)?;
3271 let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
3272
3273 let mut cursor = tree.root_node().walk();
3274 if !cursor.goto_first_child() {
3275 return None;
3276 }
3277
3278 loop {
3279 let node = cursor.node();
3280 if node.kind() == "export_statement" {
3281 if let Some(target) = resolve_reexport_statement(
3282 &source,
3283 node,
3284 from_dir,
3285 symbol_name,
3286 file_exports_symbol,
3287 file_default_export_symbol,
3288 visited,
3289 ) {
3290 return Some(target);
3291 }
3292 }
3293
3294 if !cursor.goto_next_sibling() {
3295 break;
3296 }
3297 }
3298
3299 if symbol_name == "default" {
3300 if let Some(symbol) = file_default_export_symbol(&canon) {
3301 return Some(ResolvedSymbol {
3302 file: canon,
3303 symbol,
3304 });
3305 }
3306 }
3307
3308 if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
3309 return Some(ResolvedSymbol {
3310 file: canon,
3311 symbol,
3312 });
3313 }
3314
3315 if file_exports_symbol(&canon, symbol_name) {
3316 let symbol = symbol_name.to_string();
3317 return Some(ResolvedSymbol {
3318 file: canon,
3319 symbol,
3320 });
3321 }
3322
3323 None
3324}
3325
3326fn resolve_reexport_statement<F, D>(
3327 source: &str,
3328 node: tree_sitter::Node,
3329 from_dir: &Path,
3330 symbol_name: &str,
3331 file_exports_symbol: &mut F,
3332 file_default_export_symbol: &mut D,
3333 visited: &mut HashSet<(PathBuf, String)>,
3334) -> Option<ResolvedSymbol>
3335where
3336 F: FnMut(&Path, &str) -> bool,
3337 D: FnMut(&Path) -> Option<String>,
3338{
3339 let source_node = node
3340 .child_by_field_name("source")
3341 .or_else(|| find_child_by_kind(node, "string"))?;
3342 let module_path = string_literal_content(source, source_node)?;
3343 let target_file = resolve_module_path(from_dir, &module_path)?;
3344 let raw_export = node_text(node, source);
3345
3346 if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
3347 return resolve_reexported_symbol_inner(
3348 &target_file,
3349 &source_symbol,
3350 file_exports_symbol,
3351 file_default_export_symbol,
3352 visited,
3353 )
3354 .or(Some(ResolvedSymbol {
3355 file: target_file,
3356 symbol: source_symbol,
3357 }));
3358 }
3359
3360 if raw_export.contains('*') {
3361 return resolve_reexported_symbol_inner(
3362 &target_file,
3363 symbol_name,
3364 file_exports_symbol,
3365 file_default_export_symbol,
3366 visited,
3367 );
3368 }
3369
3370 None
3371}
3372
3373fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
3374 let lang = detect_language(file)?;
3375 let grammar = grammar_for(lang);
3376 let mut parser = Parser::new();
3377 parser.set_language(&grammar).ok()?;
3378 let tree = parser.parse(source, None)?;
3379
3380 let mut cursor = tree.root_node().walk();
3381 if !cursor.goto_first_child() {
3382 return None;
3383 }
3384
3385 loop {
3386 let node = cursor.node();
3387 if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
3388 let raw_export = node_text(node, source);
3389 if let Some(source_symbol) =
3390 reexport_clause_source_symbol(&raw_export, requested_export)
3391 {
3392 return Some(source_symbol);
3393 }
3394 }
3395
3396 if !cursor.goto_next_sibling() {
3397 break;
3398 }
3399 }
3400
3401 None
3402}
3403
3404fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3405 let start = raw_export.find('{')? + 1;
3406 let end = raw_export[start..].find('}')? + start;
3407 for specifier in raw_export[start..end].split(',') {
3408 let specifier = specifier.trim();
3409 if specifier.is_empty() {
3410 continue;
3411 }
3412 let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3413 if let Some((imported, exported)) = specifier.split_once(" as ") {
3414 if exported.trim() == requested_export {
3415 return Some(imported.trim().to_string());
3416 }
3417 } else if specifier == requested_export {
3418 return Some(requested_export.to_string());
3419 }
3420 }
3421 None
3422}
3423
3424fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3425 let raw = source[node.byte_range()].trim();
3426 let quote = raw.chars().next()?;
3427 if quote != '\'' && quote != '"' {
3428 return None;
3429 }
3430 raw.strip_prefix(quote)
3431 .and_then(|value| value.strip_suffix(quote))
3432 .map(ToOwned::to_owned)
3433}
3434
3435fn find_index_file(dir: &Path, facts: &FactPaths<'_>) -> Option<PathBuf> {
3437 for name in JS_TS_INDEX_FILES {
3438 let p = dir.join(name);
3439 if facts.is_file(&p) {
3440 return Some(facts.canonical(&p).unwrap_or(p));
3441 }
3442 }
3443 None
3444}
3445
3446fn resolve_aliased_import(
3449 local_name: &str,
3450 import_block: &ImportBlock,
3451 caller_dir: &Path,
3452) -> Option<(String, PathBuf)> {
3453 for imp in &import_block.imports {
3454 if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3457 if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3458 return Some((original, resolved_path));
3459 }
3460 }
3461 }
3462 None
3463}
3464
3465fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3469 let search = format!(" as {}", local_name);
3472 if let Some(pos) = raw_import.find(&search) {
3473 let before = &raw_import[..pos];
3475 let original = before
3477 .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3478 .find(|s| !s.is_empty())?;
3479 return Some(original.to_string());
3480 }
3481 None
3482}
3483
3484pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3492 use ignore::WalkBuilder;
3493
3494 let walker = WalkBuilder::new(root)
3497 .same_file_system(true)
3498 .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .add_custom_ignore_filename(".aftignore") .filter_entry(|entry| {
3504 let name = entry.file_name().to_string_lossy();
3505 if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3507 return !matches!(
3508 name.as_ref(),
3509 "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3510 | ".tox" | "dist" | "build"
3511 );
3512 }
3513 true
3514 })
3515 .build();
3516
3517 walker
3518 .filter_map(|entry| entry.ok())
3519 .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3520 .filter(|entry| detect_language(entry.path()).is_some())
3521 .map(|entry| entry.into_path())
3522}
3523
3524#[cfg(test)]
3529mod tests {
3530 use super::*;
3531 use crate::callgraph_store::facts::{DirEntry, ProjectFacts};
3532 use std::cell::Cell;
3533 use std::fs;
3534 use tempfile::TempDir;
3535
3536 struct CountingFacts {
3537 inner: DiskFacts,
3538 cargo_reads: Cell<usize>,
3539 bin_listings: Cell<usize>,
3540 }
3541
3542 impl CountingFacts {
3543 fn new(root: &Path) -> Self {
3544 Self {
3545 inner: DiskFacts::new(root),
3546 cargo_reads: Cell::new(0),
3547 bin_listings: Cell::new(0),
3548 }
3549 }
3550 }
3551
3552 impl ProjectFacts for CountingFacts {
3553 fn is_file(&self, rel: &[u8]) -> bool {
3554 self.inner.is_file(rel)
3555 }
3556
3557 fn is_dir(&self, rel: &[u8]) -> bool {
3558 self.inner.is_dir(rel)
3559 }
3560
3561 fn config_bytes(&self, rel: &[u8]) -> Option<Arc<[u8]>> {
3562 self.inner.config_bytes(rel)
3563 }
3564
3565 fn attributed_config_bytes(&self, rel: &[u8]) -> Option<Arc<[u8]>> {
3566 if rel == b"Cargo.toml" {
3567 self.cargo_reads.set(self.cargo_reads.get() + 1);
3568 }
3569 self.inner.attributed_config_bytes(rel)
3570 }
3571
3572 fn symlink_target(&self, rel: &[u8]) -> Option<&[u8]> {
3573 self.inner.symlink_target(rel)
3574 }
3575
3576 fn canonical(&self, rel: &[u8]) -> Option<Vec<u8>> {
3577 self.inner.canonical(rel)
3578 }
3579
3580 fn list_dir(&self, rel: &[u8]) -> Vec<DirEntry> {
3581 if rel == b"src/bin" {
3582 self.bin_listings.set(self.bin_listings.get() + 1);
3583 }
3584 self.inner.list_dir(rel)
3585 }
3586 }
3587
3588 #[test]
3589 fn rust_crate_root_memo_bounds_one_thousand_per_reference_lookups() {
3590 let temp = TempDir::new().unwrap();
3591 let root = canonicalize_path(temp.path());
3592 let root = root.as_path();
3593 fs::create_dir_all(root.join("src/bin/support")).unwrap();
3594 fs::write(
3595 root.join("Cargo.toml"),
3596 "[package]\nname = \"memo-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
3597 )
3598 .unwrap();
3599 fs::write(root.join("src/bin/tool.rs"), "mod support;\n").unwrap();
3600 let callers = [
3601 root.join("src/bin/support/admin_client.rs"),
3602 root.join("src/bin/support/route_client.rs"),
3603 ];
3604 for caller in &callers {
3605 fs::write(caller, "pub fn caller() {}\n").unwrap();
3606 }
3607
3608 let counting = CountingFacts::new(root);
3609 let paths = FactPaths {
3610 root,
3611 facts: &counting,
3612 };
3613 let memo = RustCrateRootMemo::default();
3614 for index in 0..1_000 {
3615 assert_eq!(
3616 memo.root_file(root, &callers[index % callers.len()], &paths),
3617 Some(canonicalize_path(&root.join("src/bin/tool.rs")))
3618 );
3619 }
3620
3621 assert_eq!(counting.cargo_reads.get(), 1);
3622 assert_eq!(counting.bin_listings.get(), 1);
3623 }
3624
3625 fn collect_calls_by_symbol_reference(
3626 source: &str,
3627 root: Node<'_>,
3628 lang: LangId,
3629 symbols: &[Symbol],
3630 ) -> HashMap<String, Vec<CallSite>> {
3631 let mut calls_by_symbol = HashMap::new();
3632 for symbol in symbols {
3633 let byte_start =
3634 line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col);
3635 let byte_end = line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col);
3636 let sites = extract_calls_full(source, root, byte_start, byte_end, lang)
3637 .into_iter()
3638 .map(
3639 |(full, short, line, call_byte_start, call_byte_end)| CallSite {
3640 callee_name: short,
3641 full_callee: full,
3642 line,
3643 byte_start: call_byte_start,
3644 byte_end: call_byte_end,
3645 },
3646 )
3647 .collect::<Vec<_>>();
3648 if !sites.is_empty() {
3649 calls_by_symbol.insert(symbol_identity(symbol), sites);
3650 }
3651 }
3652
3653 let symbol_ranges = symbols
3654 .iter()
3655 .map(|symbol| {
3656 (
3657 line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col),
3658 line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col),
3659 )
3660 })
3661 .collect::<Vec<_>>();
3662 let top_level_sites = collect_calls_full_with_ranges(root, source, 0, source.len(), lang)
3663 .into_iter()
3664 .filter(|site| {
3665 !symbol_ranges
3666 .iter()
3667 .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
3668 })
3669 .map(|site| CallSite {
3670 callee_name: site.short,
3671 full_callee: site.full,
3672 line: site.line,
3673 byte_start: site.byte_start,
3674 byte_end: site.byte_end,
3675 })
3676 .collect::<Vec<_>>();
3677 if !top_level_sites.is_empty() {
3678 calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
3679 }
3680 calls_by_symbol
3681 }
3682
3683 fn parse_symbols(source: &str, lang: LangId) -> (tree_sitter::Tree, Vec<Symbol>) {
3684 let mut parser = Parser::new();
3685 parser.set_language(&grammar_for(lang)).unwrap();
3686 let tree = parser.parse(source, None).unwrap();
3687 let symbols = crate::parser::extract_symbols_from_tree(source, &tree, lang).unwrap();
3688 (tree, symbols)
3689 }
3690
3691 fn test_symbol(name: &str, start_col: u32, end_col: u32) -> Symbol {
3692 Symbol {
3693 name: name.to_string(),
3694 kind: SymbolKind::Function,
3695 range: Range {
3696 start_line: 0,
3697 start_col,
3698 end_line: 0,
3699 end_col,
3700 },
3701 signature: None,
3702 scope_chain: Vec::new(),
3703 exported: false,
3704 parent: None,
3705 }
3706 }
3707
3708 #[test]
3709 fn source_line_index_matches_shared_line_column_conversion() {
3710 let source = "a\r\nbb\rc\n";
3711 let index = SourceLineIndex::new(source);
3712 for line in 0..=5 {
3713 for column in 0..=5 {
3714 assert_eq!(
3715 index.byte_offset(line, column),
3716 line_col_to_byte(source, line, column),
3717 "line={line}, column={column}"
3718 );
3719 }
3720 }
3721 }
3722
3723 #[test]
3724 fn single_pass_call_attribution_matches_per_symbol_reference() {
3725 let corpora = [
3726 (
3727 "typescript",
3728 LangId::TypeScript,
3729 r#"bootstrap();
3730class Worker {
3731 run() {
3732 before();
3733 function nested() { nestedCall(); }
3734 nested();
3735 }
3736 next() { adjacentCall(); }
3737}
3738function left() { leftCall(); }
3739function right() { rightCall(); }
3740"#,
3741 ),
3742 (
3743 "python",
3744 LangId::Python,
3745 r#"bootstrap()
3746class Worker:
3747 def run(self):
3748 before()
3749 def nested():
3750 nested_call()
3751 nested()
3752
3753 def next(self):
3754 adjacent_call()
3755
3756def left():
3757 left_call()
3758
3759def right():
3760 right_call()
3761"#,
3762 ),
3763 ];
3764
3765 for (name, lang, source) in corpora {
3766 let (tree, symbols) = parse_symbols(source, lang);
3767 let reference =
3768 collect_calls_by_symbol_reference(source, tree.root_node(), lang, &symbols);
3769 let actual = collect_calls_by_symbol(source, tree.root_node(), lang, &symbols);
3770 assert_eq!(actual, reference, "call attribution changed for {name}");
3771
3772 let class_sites = actual.get("Worker").expect("class receives method calls");
3773 let method_sites = actual
3774 .get("Worker::run")
3775 .expect("method receives its own calls");
3776 assert!(class_sites.iter().any(|site| site.callee_name == "before"));
3777 assert!(method_sites.iter().any(|site| site.callee_name == "before"));
3778 let nested_sites = actual
3779 .iter()
3780 .find(|(symbol, _)| symbol.rsplit("::").next() == Some("nested"))
3781 .map(|(_, sites)| sites)
3782 .expect("nested function receives its own calls");
3783 assert!(nested_sites.iter().any(|site| {
3784 site.callee_name == "nested_call" || site.callee_name == "nestedCall"
3785 }));
3786 assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "bootstrap");
3787 }
3788
3789 let source = "first();second();third();";
3790 let (tree, _) = parse_symbols(source, LangId::TypeScript);
3791 let symbols = vec![
3792 test_symbol("outer", 0, 17),
3793 test_symbol("left", 0, 8),
3794 test_symbol("right", 8, 17),
3795 test_symbol("empty", 24, 24),
3796 ];
3797 let reference = collect_calls_by_symbol_reference(
3798 source,
3799 tree.root_node(),
3800 LangId::TypeScript,
3801 &symbols,
3802 );
3803 let actual =
3804 collect_calls_by_symbol(source, tree.root_node(), LangId::TypeScript, &symbols);
3805 assert_eq!(actual, reference, "overlapping and adjacent ranges changed");
3806 assert_eq!(
3807 actual["outer"]
3808 .iter()
3809 .map(|site| site.callee_name.as_str())
3810 .collect::<Vec<_>>(),
3811 ["first", "second"]
3812 );
3813 assert_eq!(actual["left"][0].callee_name, "first");
3814 assert_eq!(actual["right"][0].callee_name, "second");
3815 assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "third");
3816 assert!(!actual.contains_key("empty"));
3817 }
3818
3819 #[test]
3820 fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
3821 let mut symbol_metadata = HashMap::new();
3826 symbol_metadata.insert(
3827 "BackupStore::total_disk_bytes".to_string(),
3828 SymbolMeta {
3829 kind: SymbolKind::Method,
3830 exported: true,
3831 signature: None,
3832 line: 703,
3833 range: Range {
3834 start_line: 702,
3835 start_col: 0,
3836 end_line: 705,
3837 end_col: 0,
3838 },
3839 entry_point_attribute: None,
3840 },
3841 );
3842 let file_data = FileCallData {
3843 calls_by_symbol: HashMap::new(),
3844 value_refs_by_symbol: HashMap::new(),
3845 exported_symbols: vec!["total_disk_bytes".to_string()],
3846 symbol_metadata,
3847 default_export_symbol: None,
3848 import_block: ImportBlock::empty(),
3849 lang: LangId::Rust,
3850 };
3851
3852 let meta = file_data
3853 .symbol_metadata_for("total_disk_bytes")
3854 .expect("scoped method recovered by bare name");
3855 assert_eq!(meta.kind, SymbolKind::Method);
3856 assert_eq!(
3857 meta.line, 703,
3858 "real declaration line, not the line-1 fallback"
3859 );
3860
3861 assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
3863 }
3864
3865 fn setup_ts_project() -> TempDir {
3867 let dir = TempDir::new().unwrap();
3868
3869 fs::write(
3871 dir.path().join("main.ts"),
3872 r#"import { helper, compute } from './utils';
3873import * as math from './math';
3874
3875export function main() {
3876 const a = helper(1);
3877 const b = compute(a, 2);
3878 const c = math.add(a, b);
3879 return c;
3880}
3881"#,
3882 )
3883 .unwrap();
3884
3885 fs::write(
3887 dir.path().join("utils.ts"),
3888 r#"import { double } from './helpers';
3889
3890export function helper(x: number): number {
3891 return double(x);
3892}
3893
3894export function compute(a: number, b: number): number {
3895 return a + b;
3896}
3897"#,
3898 )
3899 .unwrap();
3900
3901 fs::write(
3903 dir.path().join("helpers.ts"),
3904 r#"export function double(x: number): number {
3905 return x * 2;
3906}
3907
3908export function triple(x: number): number {
3909 return x * 3;
3910}
3911"#,
3912 )
3913 .unwrap();
3914
3915 fs::write(
3917 dir.path().join("math.ts"),
3918 r#"export function add(a: number, b: number): number {
3919 return a + b;
3920}
3921
3922export function subtract(a: number, b: number): number {
3923 return a - b;
3924}
3925"#,
3926 )
3927 .unwrap();
3928
3929 dir
3930 }
3931
3932 fn setup_alias_project() -> TempDir {
3934 let dir = TempDir::new().unwrap();
3935
3936 fs::write(
3937 dir.path().join("main.ts"),
3938 r#"import { helper as h } from './utils';
3939
3940export function main() {
3941 return h(42);
3942}
3943"#,
3944 )
3945 .unwrap();
3946
3947 fs::write(
3948 dir.path().join("utils.ts"),
3949 r#"export function helper(x: number): number {
3950 return x + 1;
3951}
3952"#,
3953 )
3954 .unwrap();
3955
3956 dir
3957 }
3958
3959 #[test]
3962 fn callgraph_single_file_call_extraction() {
3963 let dir = setup_ts_project();
3964 let mut graph = CallGraph::new(dir.path().to_path_buf());
3965
3966 let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3967 let main_calls = &file_data.calls_by_symbol["main"];
3968
3969 let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3970 assert!(
3971 callee_names.contains(&"helper"),
3972 "main should call helper, got: {:?}",
3973 callee_names
3974 );
3975 assert!(
3976 callee_names.contains(&"compute"),
3977 "main should call compute, got: {:?}",
3978 callee_names
3979 );
3980 assert!(
3981 callee_names.contains(&"add"),
3982 "main should call math.add (short name: add), got: {:?}",
3983 callee_names
3984 );
3985 }
3986
3987 #[test]
3988 fn callgraph_file_data_has_exports() {
3989 let dir = setup_ts_project();
3990 let mut graph = CallGraph::new(dir.path().to_path_buf());
3991
3992 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3993 assert!(
3994 file_data.exported_symbols.contains(&"helper".to_string()),
3995 "utils.ts should export helper, got: {:?}",
3996 file_data.exported_symbols
3997 );
3998 assert!(
3999 file_data.exported_symbols.contains(&"compute".to_string()),
4000 "utils.ts should export compute, got: {:?}",
4001 file_data.exported_symbols
4002 );
4003 }
4004
4005 #[test]
4008 fn callgraph_resolve_direct_import() {
4009 let dir = setup_ts_project();
4010 let mut graph = CallGraph::new(dir.path().to_path_buf());
4011
4012 let main_path = dir.path().join("main.ts");
4013 let file_data = graph.build_file(&main_path).unwrap();
4014 let import_block = file_data.import_block.clone();
4015
4016 let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4017 match edge {
4018 EdgeResolution::Resolved { file, symbol } => {
4019 assert!(
4020 file.ends_with("utils.ts"),
4021 "helper should resolve to utils.ts, got: {:?}",
4022 file
4023 );
4024 assert_eq!(symbol, "helper");
4025 }
4026 EdgeResolution::Unresolved { callee_name } => {
4027 panic!("Expected resolved, got unresolved: {}", callee_name);
4028 }
4029 }
4030 }
4031
4032 #[test]
4033 fn callgraph_resolve_namespace_import() {
4034 let dir = setup_ts_project();
4035 let mut graph = CallGraph::new(dir.path().to_path_buf());
4036
4037 let main_path = dir.path().join("main.ts");
4038 let file_data = graph.build_file(&main_path).unwrap();
4039 let import_block = file_data.import_block.clone();
4040
4041 let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4042 match edge {
4043 EdgeResolution::Resolved { file, symbol } => {
4044 assert!(
4045 file.ends_with("math.ts"),
4046 "math.add should resolve to math.ts, got: {:?}",
4047 file
4048 );
4049 assert_eq!(symbol, "add");
4050 }
4051 EdgeResolution::Unresolved { callee_name } => {
4052 panic!("Expected resolved, got unresolved: {}", callee_name);
4053 }
4054 }
4055 }
4056
4057 #[test]
4058 fn callgraph_resolve_aliased_import() {
4059 let dir = setup_alias_project();
4060 let mut graph = CallGraph::new(dir.path().to_path_buf());
4061
4062 let main_path = dir.path().join("main.ts");
4063 let file_data = graph.build_file(&main_path).unwrap();
4064 let import_block = file_data.import_block.clone();
4065
4066 let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
4067 match edge {
4068 EdgeResolution::Resolved { file, symbol } => {
4069 assert!(
4070 file.ends_with("utils.ts"),
4071 "h (alias for helper) should resolve to utils.ts, got: {:?}",
4072 file
4073 );
4074 assert_eq!(symbol, "helper");
4075 }
4076 EdgeResolution::Unresolved { callee_name } => {
4077 panic!("Expected resolved, got unresolved: {}", callee_name);
4078 }
4079 }
4080 }
4081
4082 #[test]
4083 fn callgraph_unresolved_edge_marked() {
4084 let dir = setup_ts_project();
4085 let mut graph = CallGraph::new(dir.path().to_path_buf());
4086
4087 let main_path = dir.path().join("main.ts");
4088 let file_data = graph.build_file(&main_path).unwrap();
4089 let import_block = file_data.import_block.clone();
4090
4091 let edge =
4092 graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
4093 assert_eq!(
4094 edge,
4095 EdgeResolution::Unresolved {
4096 callee_name: "unknownFunc".to_string()
4097 },
4098 "Unknown callee should be unresolved"
4099 );
4100 }
4101
4102 #[test]
4105 fn callgraph_walker_excludes_gitignored() {
4106 let dir = TempDir::new().unwrap();
4107
4108 fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
4110
4111 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
4113 fs::create_dir(dir.path().join("ignored_dir")).unwrap();
4114 fs::write(
4115 dir.path().join("ignored_dir").join("secret.ts"),
4116 "export function secret() {}",
4117 )
4118 .unwrap();
4119
4120 fs::create_dir(dir.path().join("node_modules")).unwrap();
4122 fs::write(
4123 dir.path().join("node_modules").join("dep.ts"),
4124 "export function dep() {}",
4125 )
4126 .unwrap();
4127
4128 let mut command = std::process::Command::new("git");
4130 crate::test_env::apply_hermetic_git_env(command.current_dir(dir.path()))
4131 .args(["init"])
4132 .output()
4133 .unwrap();
4134
4135 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
4136 let file_names: Vec<String> = files
4137 .iter()
4138 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
4139 .collect();
4140
4141 assert!(
4142 file_names.contains(&"main.ts".to_string()),
4143 "Should include main.ts, got: {:?}",
4144 file_names
4145 );
4146 assert!(
4147 !file_names.contains(&"secret.ts".to_string()),
4148 "Should exclude gitignored secret.ts, got: {:?}",
4149 file_names
4150 );
4151 assert!(
4152 !file_names.contains(&"dep.ts".to_string()),
4153 "Should exclude node_modules, got: {:?}",
4154 file_names
4155 );
4156 }
4157
4158 #[test]
4159 fn callgraph_walker_excludes_aftignored() {
4160 let dir = TempDir::new().unwrap();
4161
4162 fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
4164 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
4165 fs::create_dir(dir.path().join("vendored")).unwrap();
4166 fs::write(
4167 dir.path().join("vendored").join("sub.ts"),
4168 "export function sub() {}",
4169 )
4170 .unwrap();
4171
4172 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
4173 let file_names: Vec<String> = files
4174 .iter()
4175 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
4176 .collect();
4177
4178 assert!(
4179 file_names.contains(&"main.ts".to_string()),
4180 "Should include main.ts, got: {:?}",
4181 file_names
4182 );
4183 assert!(
4184 !file_names.contains(&"sub.ts".to_string()),
4185 "Should exclude .aftignored sub.ts, got: {:?}",
4186 file_names
4187 );
4188 }
4189
4190 #[test]
4191 fn callgraph_walker_only_source_files() {
4192 let dir = TempDir::new().unwrap();
4193
4194 fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
4195 fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
4196 fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
4197 fs::write(
4198 dir.path().join("runtime.mjs"),
4199 "export function runtime() {}",
4200 )
4201 .unwrap();
4202 fs::write(
4203 dir.path().join("legacy.cjs"),
4204 "exports.legacy = function() {};",
4205 )
4206 .unwrap();
4207 fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
4208 fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
4209 fs::write(dir.path().join("data.json"), "{}").unwrap();
4210
4211 let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
4212 let file_names: Vec<String> = files
4213 .iter()
4214 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
4215 .collect();
4216
4217 assert!(file_names.contains(&"main.ts".to_string()));
4218 for modern_ext_file in [
4219 "module.mts",
4220 "common.cts",
4221 "runtime.mjs",
4222 "legacy.cjs",
4223 "types.pyi",
4224 ] {
4225 assert!(
4226 file_names.contains(&modern_ext_file.to_string()),
4227 "walker should include {modern_ext_file}, got: {:?}",
4228 file_names
4229 );
4230 }
4231 assert!(
4232 file_names.contains(&"readme.md".to_string()),
4233 "Markdown is now a supported source language"
4234 );
4235 assert!(
4236 file_names.contains(&"data.json".to_string()),
4237 "JSON is now a supported source language"
4238 );
4239 }
4240
4241 #[test]
4244 fn callgraph_find_alias_original_simple() {
4245 let raw = "import { foo as bar } from './utils';";
4246 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
4247 }
4248
4249 #[test]
4250 fn callgraph_find_alias_original_multiple() {
4251 let raw = "import { foo as bar, baz as qux } from './utils';";
4252 assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
4253 assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
4254 }
4255
4256 #[test]
4257 fn callgraph_find_alias_no_match() {
4258 let raw = "import { foo } from './utils';";
4259 assert_eq!(find_alias_original(raw, "foo"), None);
4260 }
4261
4262 #[test]
4265 fn is_entry_point_exported_function() {
4266 assert!(is_entry_point(
4267 "handleRequest",
4268 &SymbolKind::Function,
4269 true,
4270 LangId::TypeScript
4271 ));
4272 }
4273
4274 #[test]
4275 fn is_entry_point_exported_method_is_not_entry() {
4276 assert!(!is_entry_point(
4278 "handleRequest",
4279 &SymbolKind::Method,
4280 true,
4281 LangId::TypeScript
4282 ));
4283 }
4284
4285 #[test]
4286 fn is_entry_point_main_init_patterns() {
4287 for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
4288 assert!(
4289 is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
4290 "{} should be an entry point",
4291 name
4292 );
4293 }
4294 }
4295
4296 #[test]
4297 fn is_entry_point_test_patterns_ts() {
4298 assert!(is_entry_point(
4299 "describe",
4300 &SymbolKind::Function,
4301 false,
4302 LangId::TypeScript
4303 ));
4304 assert!(is_entry_point(
4305 "it",
4306 &SymbolKind::Function,
4307 false,
4308 LangId::TypeScript
4309 ));
4310 assert!(is_entry_point(
4311 "test",
4312 &SymbolKind::Function,
4313 false,
4314 LangId::TypeScript
4315 ));
4316 assert!(is_entry_point(
4317 "testValidation",
4318 &SymbolKind::Function,
4319 false,
4320 LangId::TypeScript
4321 ));
4322 assert!(is_entry_point(
4323 "specHelper",
4324 &SymbolKind::Function,
4325 false,
4326 LangId::TypeScript
4327 ));
4328 }
4329
4330 #[test]
4331 fn is_entry_point_test_patterns_python() {
4332 assert!(is_entry_point(
4333 "test_login",
4334 &SymbolKind::Function,
4335 false,
4336 LangId::Python
4337 ));
4338 assert!(is_entry_point(
4339 "setUp",
4340 &SymbolKind::Function,
4341 false,
4342 LangId::Python
4343 ));
4344 assert!(is_entry_point(
4345 "tearDown",
4346 &SymbolKind::Function,
4347 false,
4348 LangId::Python
4349 ));
4350 assert!(!is_entry_point(
4352 "testSomething",
4353 &SymbolKind::Function,
4354 false,
4355 LangId::Python
4356 ));
4357 }
4358
4359 #[test]
4360 fn is_entry_point_test_patterns_rust() {
4361 assert!(is_entry_point(
4362 "test_parse",
4363 &SymbolKind::Function,
4364 false,
4365 LangId::Rust
4366 ));
4367 assert!(!is_entry_point(
4368 "TestSomething",
4369 &SymbolKind::Function,
4370 false,
4371 LangId::Rust
4372 ));
4373 }
4374
4375 #[test]
4376 fn is_entry_point_test_patterns_go() {
4377 assert!(is_entry_point(
4378 "TestParsing",
4379 &SymbolKind::Function,
4380 false,
4381 LangId::Go
4382 ));
4383 assert!(!is_entry_point(
4385 "testParsing",
4386 &SymbolKind::Function,
4387 false,
4388 LangId::Go
4389 ));
4390 }
4391
4392 #[test]
4393 fn is_entry_point_non_exported_non_main_is_not_entry() {
4394 assert!(!is_entry_point(
4395 "helperUtil",
4396 &SymbolKind::Function,
4397 false,
4398 LangId::TypeScript
4399 ));
4400 }
4401
4402 #[test]
4405 fn callgraph_symbol_metadata_populated() {
4406 let dir = setup_ts_project();
4407 let mut graph = CallGraph::new(dir.path().to_path_buf());
4408
4409 let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4410 assert!(
4411 file_data.symbol_metadata.contains_key("helper"),
4412 "symbol_metadata should contain helper"
4413 );
4414 let meta = &file_data.symbol_metadata["helper"];
4415 assert_eq!(meta.kind, SymbolKind::Function);
4416 assert!(meta.exported, "helper should be exported");
4417 }
4418
4419 #[test]
4420 fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
4421 let dir = TempDir::new().unwrap();
4422 fs::write(
4423 dir.path().join("main.ts"),
4424 r#"import * as lib from './index';
4425
4426export function main() {
4427 lib.helper();
4428 lib.hidden();
4429}
4430"#,
4431 )
4432 .unwrap();
4433 fs::write(
4434 dir.path().join("index.ts"),
4435 "export { helper } from './utils';\n",
4436 )
4437 .unwrap();
4438 fs::write(
4439 dir.path().join("utils.ts"),
4440 r#"export function helper() {}
4441function hidden() {}
4442"#,
4443 )
4444 .unwrap();
4445
4446 let mut graph = CallGraph::new(dir.path().to_path_buf());
4447 let main_path = dir.path().join("main.ts");
4448 let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
4449
4450 let helper =
4451 graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
4452 match helper {
4453 EdgeResolution::Resolved { file, symbol } => {
4454 assert!(
4455 file.ends_with("utils.ts"),
4456 "helper should resolve through barrel: {file:?}"
4457 );
4458 assert_eq!(symbol, "helper");
4459 }
4460 other => panic!("expected helper to resolve through barrel, got {other:?}"),
4461 }
4462
4463 let hidden =
4464 graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
4465 assert_eq!(
4466 hidden,
4467 EdgeResolution::Unresolved {
4468 callee_name: "hidden".to_string()
4469 }
4470 );
4471 }
4472
4473 #[test]
4474 fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
4475 let dir = TempDir::new().unwrap();
4476 fs::write(
4477 dir.path().join("package.json"),
4478 r#"{"workspaces":["packages/*"]}"#,
4479 )
4480 .unwrap();
4481 let package_dir = dir.path().join("packages/lib");
4482 fs::create_dir_all(package_dir.join("src")).unwrap();
4483 fs::create_dir_all(package_dir.join("dist")).unwrap();
4484 fs::write(
4485 package_dir.join("package.json"),
4486 r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
4487 )
4488 .unwrap();
4489 fs::write(
4490 package_dir.join("src/index.mts"),
4491 "export function helper() {}\n",
4492 )
4493 .unwrap();
4494 fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
4495
4496 let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
4497 assert!(
4498 resolved.ends_with("src/index.mts"),
4499 "dist/index.mjs should map to src/index.mts, got {resolved:?}"
4500 );
4501 }
4502
4503 #[test]
4509 fn workspace_package_miss_is_walked_once_across_throwaway_memos() {
4510 use crate::callgraph_store::facts::{DirEntry, ProjectFacts};
4511 use std::cell::Cell;
4512 use std::sync::Arc;
4513
4514 struct CountingFacts<'a> {
4515 inner: &'a DiskFacts,
4516 list_dir_calls: Cell<usize>,
4517 }
4518 impl ProjectFacts for CountingFacts<'_> {
4519 fn is_file(&self, rel: &[u8]) -> bool {
4520 self.inner.is_file(rel)
4521 }
4522 fn is_dir(&self, rel: &[u8]) -> bool {
4523 self.inner.is_dir(rel)
4524 }
4525 fn config_bytes(&self, rel: &[u8]) -> Option<Arc<[u8]>> {
4526 self.inner.config_bytes(rel)
4527 }
4528 fn symlink_target(&self, rel: &[u8]) -> Option<&[u8]> {
4529 self.inner.symlink_target(rel)
4530 }
4531 fn canonical(&self, rel: &[u8]) -> Option<Vec<u8>> {
4532 self.inner.canonical(rel)
4533 }
4534 fn list_dir(&self, rel: &[u8]) -> Vec<DirEntry> {
4535 self.list_dir_calls.set(self.list_dir_calls.get() + 1);
4536 self.inner.list_dir(rel)
4537 }
4538 }
4539
4540 let dir = TempDir::new().unwrap();
4541 let root = fs::canonicalize(dir.path()).unwrap();
4542 fs::write(
4543 root.join("package.json"),
4544 r#"{"workspaces":["packages/*"]}"#,
4545 )
4546 .unwrap();
4547 for member in ["a", "b", "c"] {
4548 let member_dir = root.join("packages").join(member);
4549 fs::create_dir_all(member_dir.join("src")).unwrap();
4550 fs::write(
4551 member_dir.join("package.json"),
4552 format!(r#"{{"name":"@ws/{member}"}}"#),
4553 )
4554 .unwrap();
4555 }
4556 let disk = DiskFacts::new(&root);
4557 let counting = CountingFacts {
4558 inner: &disk,
4559 list_dir_calls: Cell::new(0),
4560 };
4561 let facts = FactPaths {
4562 root: &root,
4563 facts: &counting,
4564 };
4565 clear_workspace_package_cache();
4566
4567 let first = resolve_workspace_package(
4568 &root,
4569 "react",
4570 Some(&ModuleResolutionMemo::default()),
4571 &facts,
4572 );
4573 assert_eq!(first, None, "react is not a member");
4574 let walked = counting.list_dir_calls.get();
4575 assert!(
4576 walked > 0,
4577 "the first miss must walk the member directories"
4578 );
4579
4580 let second = resolve_workspace_package(
4581 &root,
4582 "react",
4583 Some(&ModuleResolutionMemo::default()),
4584 &facts,
4585 );
4586 assert_eq!(second, None);
4587 assert_eq!(
4588 counting.list_dir_calls.get(),
4589 walked,
4590 "a second resolution with a fresh memo must not walk again"
4591 );
4592
4593 let third = resolve_workspace_package(
4596 &root,
4597 "effect",
4598 Some(&ModuleResolutionMemo::default()),
4599 &facts,
4600 );
4601 assert_eq!(third, None, "effect is not a member either");
4602 assert_eq!(
4603 counting.list_dir_calls.get(),
4604 walked,
4605 "a second package on the same workspace must reuse the member walk"
4606 );
4607 let member = resolve_workspace_package(
4609 &root,
4610 "@ws/b",
4611 Some(&ModuleResolutionMemo::default()),
4612 &facts,
4613 )
4614 .expect("@ws/b is a member");
4615 assert!(member.ends_with("packages/b"), "{member:?}");
4616 assert_eq!(counting.list_dir_calls.get(), walked);
4617
4618 let other = TempDir::new().unwrap();
4621 clear_workspace_package_cache_under(other.path());
4622 resolve_workspace_package(
4623 &root,
4624 "react",
4625 Some(&ModuleResolutionMemo::default()),
4626 &facts,
4627 );
4628 assert_eq!(
4629 counting.list_dir_calls.get(),
4630 walked,
4631 "clearing under another root must not drop this workspace's entries"
4632 );
4633 clear_workspace_package_cache_under(&root);
4635 resolve_workspace_package(
4636 &root,
4637 "react",
4638 Some(&ModuleResolutionMemo::default()),
4639 &facts,
4640 );
4641 let rewalked = counting.list_dir_calls.get();
4642 assert!(rewalked > walked, "clearing under this root re-walks");
4643
4644 clear_workspace_package_cache();
4646 resolve_workspace_package(
4647 &root,
4648 "react",
4649 Some(&ModuleResolutionMemo::default()),
4650 &facts,
4651 );
4652 assert!(
4653 counting.list_dir_calls.get() > rewalked,
4654 "after the cache is dropped the next miss walks again"
4655 );
4656 }
4657
4658 #[test]
4659 fn same_named_methods_use_scoped_symbol_identity() {
4660 let dir = TempDir::new().unwrap();
4661 fs::write(
4662 dir.path().join("classes.ts"),
4663 r#"class A {
4664 run() { helperA(); }
4665}
4666
4667class B {
4668 run() { helperB(); }
4669}
4670
4671function helperA() {}
4672function helperB() {}
4673"#,
4674 )
4675 .unwrap();
4676
4677 let mut graph = CallGraph::new(dir.path().to_path_buf());
4678 let path = dir.path().join("classes.ts");
4679 let data = graph.build_file(&path).unwrap();
4680
4681 assert!(
4682 data.symbol_metadata.contains_key("A::run"),
4683 "A::run metadata missing"
4684 );
4685 assert!(
4686 data.symbol_metadata.contains_key("B::run"),
4687 "B::run metadata missing"
4688 );
4689 assert!(
4690 data.calls_by_symbol["A::run"]
4691 .iter()
4692 .any(|call| call.callee_name == "helperA"),
4693 "A::run calls should not be overwritten"
4694 );
4695 assert!(
4696 data.calls_by_symbol["B::run"]
4697 .iter()
4698 .any(|call| call.callee_name == "helperB"),
4699 "B::run calls should not be overwritten"
4700 );
4701 }
4702
4703 #[test]
4706 fn extract_parameters_typescript() {
4707 let params = extract_parameters(
4708 "function processData(input: string, count: number): void",
4709 LangId::TypeScript,
4710 );
4711 assert_eq!(params, vec!["input", "count"]);
4712 }
4713
4714 #[test]
4715 fn extract_parameters_typescript_optional() {
4716 let params = extract_parameters(
4717 "function fetch(url: string, options?: RequestInit): Promise<Response>",
4718 LangId::TypeScript,
4719 );
4720 assert_eq!(params, vec!["url", "options"]);
4721 }
4722
4723 #[test]
4724 fn extract_parameters_typescript_defaults() {
4725 let params = extract_parameters(
4726 "function greet(name: string, greeting: string = \"hello\"): string",
4727 LangId::TypeScript,
4728 );
4729 assert_eq!(params, vec!["name", "greeting"]);
4730 }
4731
4732 #[test]
4733 fn extract_parameters_typescript_rest() {
4734 let params = extract_parameters(
4735 "function sum(...numbers: number[]): number",
4736 LangId::TypeScript,
4737 );
4738 assert_eq!(params, vec!["numbers"]);
4739 }
4740
4741 #[test]
4742 fn extract_parameters_python_self_skipped() {
4743 let params = extract_parameters(
4744 "def process(self, data: str, count: int) -> bool",
4745 LangId::Python,
4746 );
4747 assert_eq!(params, vec!["data", "count"]);
4748 }
4749
4750 #[test]
4751 fn extract_parameters_python_no_self() {
4752 let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4753 assert_eq!(params, vec!["input"]);
4754 }
4755
4756 #[test]
4757 fn extract_parameters_python_star_args() {
4758 let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4759 assert_eq!(params, vec!["args", "kwargs"]);
4760 }
4761
4762 #[test]
4763 fn extract_parameters_rust_self_skipped() {
4764 let params = extract_parameters(
4765 "fn process(&self, data: &str, count: usize) -> bool",
4766 LangId::Rust,
4767 );
4768 assert_eq!(params, vec!["data", "count"]);
4769 }
4770
4771 #[test]
4772 fn extract_parameters_rust_mut_self_skipped() {
4773 let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4774 assert_eq!(params, vec!["value"]);
4775 }
4776
4777 #[test]
4778 fn extract_parameters_rust_no_self() {
4779 let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4780 assert_eq!(params, vec!["input"]);
4781 }
4782
4783 #[test]
4784 fn extract_parameters_rust_mut_param() {
4785 let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4786 assert_eq!(params, vec!["buf", "len"]);
4787 }
4788
4789 #[test]
4790 fn extract_parameters_go() {
4791 let params = extract_parameters(
4792 "func ProcessData(input string, count int) error",
4793 LangId::Go,
4794 );
4795 assert_eq!(params, vec!["input", "count"]);
4796 }
4797
4798 #[test]
4799 fn extract_parameters_empty() {
4800 let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4801 assert!(
4802 params.is_empty(),
4803 "no-arg function should return empty params"
4804 );
4805 }
4806
4807 #[test]
4808 fn extract_parameters_no_parens() {
4809 let params = extract_parameters("const x = 42", LangId::TypeScript);
4810 assert!(params.is_empty(), "no parens should return empty params");
4811 }
4812
4813 #[test]
4814 fn extract_parameters_javascript() {
4815 let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4816 assert_eq!(params, vec!["event", "target"]);
4817 }
4818}