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