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