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