Skip to main content

aft/
callgraph.rs

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