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, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11use std::time::Instant;
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use rayon::prelude::*;
15use serde::Serialize;
16use serde_json::Value;
17use tree_sitter::{Node, Parser};
18
19use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
20use crate::edit::line_col_to_byte;
21use crate::error::AftError;
22use crate::imports::{self, ImportBlock};
23use crate::language::LanguageProvider;
24use crate::parser::{detect_language, grammar_for, LangId};
25use crate::symbols::{Range, Symbol, SymbolKind};
26use crate::{slog_debug, slog_info};
27
28// ---------------------------------------------------------------------------
29// Core types
30// ---------------------------------------------------------------------------
31
32type SharedPath = Arc<PathBuf>;
33type SharedStr = Arc<str>;
34type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
35type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
36type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
37type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
38
39static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
40    LazyLock::new(|| RwLock::new(HashMap::new()));
41static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
42    LazyLock::new(|| RwLock::new(HashMap::new()));
43static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
44    LazyLock::new(|| RwLock::new(HashMap::new()));
45
46const TOP_LEVEL_SYMBOL: &str = "<top-level>";
47const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
48const JS_TS_INDEX_FILES: &[&str] = &[
49    "index.ts",
50    "index.tsx",
51    "index.mts",
52    "index.cts",
53    "index.js",
54    "index.jsx",
55    "index.mjs",
56    "index.cjs",
57];
58
59fn symbol_identity(symbol: &Symbol) -> String {
60    if symbol.scope_chain.is_empty() {
61        symbol.name.clone()
62    } else {
63        format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
64    }
65}
66
67fn symbol_unqualified_name(symbol: &str) -> &str {
68    symbol.rsplit("::").next().unwrap_or(symbol)
69}
70
71fn symbol_query_matches(symbol: &str, query: &str) -> bool {
72    symbol == query || symbol_unqualified_name(symbol) == query
73}
74
75pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
76    full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
77}
78
79fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
80    let mut seen = HashSet::new();
81    let mut candidates = Vec::new();
82    let qualified_query = symbol_name.contains("::");
83
84    let mut consider = |candidate: &str| {
85        let matches = if qualified_query {
86            candidate == symbol_name
87        } else {
88            candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
89        };
90
91        if matches && seen.insert(candidate.to_string()) {
92            candidates.push(candidate.to_string());
93        }
94    };
95
96    for candidate in file_data.symbol_metadata.keys() {
97        consider(candidate);
98    }
99    for candidate in file_data.calls_by_symbol.keys() {
100        consider(candidate);
101    }
102    for candidate in &file_data.exported_symbols {
103        consider(candidate);
104    }
105
106    candidates.sort();
107    candidates
108}
109
110pub(crate) fn resolve_symbol_query_in_data(
111    file_data: &FileCallData,
112    file: &Path,
113    symbol_name: &str,
114) -> Result<String, AftError> {
115    let candidates = symbol_query_candidates(file_data, symbol_name);
116    match candidates.as_slice() {
117        [candidate] => Ok(candidate.clone()),
118        [] => Err(AftError::SymbolNotFound {
119            name: symbol_name.to_string(),
120            file: file.display().to_string(),
121        }),
122        _ => Err(AftError::AmbiguousSymbol {
123            name: symbol_name.to_string(),
124            candidates,
125        }),
126    }
127}
128
129/// A single call site within a function body.
130#[derive(Debug, Clone)]
131pub struct CallSite {
132    /// The short callee name (last segment, e.g. "foo" for `utils.foo()`).
133    pub callee_name: String,
134    /// The full callee expression (e.g. "utils.foo" for `utils.foo()`).
135    pub full_callee: String,
136    /// 1-based line number of the call.
137    pub line: u32,
138    /// Byte range of the call expression in the source.
139    pub byte_start: usize,
140    pub byte_end: usize,
141}
142
143/// Per-symbol metadata for entry point detection (avoids re-parsing).
144#[derive(Debug, Clone, Serialize)]
145pub struct SymbolMeta {
146    /// The kind of symbol (function, class, method, etc).
147    pub kind: SymbolKind,
148    /// Whether this symbol is exported.
149    pub exported: bool,
150    /// Function/method signature if available.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub signature: Option<String>,
153    /// 1-based start line of the symbol.
154    pub line: u32,
155    /// 0-based source range of the symbol.
156    pub range: Range,
157}
158
159/// Per-file call data: call sites grouped by containing symbol, plus
160/// exported symbol names and parsed imports.
161#[derive(Debug, Clone)]
162pub struct FileCallData {
163    /// Map from symbol name → list of call sites within that symbol's body.
164    pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
165    /// Names of exported symbols in this file.
166    pub exported_symbols: Vec<String>,
167    /// Per-symbol metadata (kind, exported, signature).
168    pub symbol_metadata: HashMap<String, SymbolMeta>,
169    /// Real or synthetic symbol name for this file's default export.
170    pub default_export_symbol: Option<String>,
171    /// Parsed import block for cross-file resolution.
172    pub import_block: ImportBlock,
173    /// Language of the file.
174    pub lang: LangId,
175}
176
177impl FileCallData {
178    /// Look up metadata for an exported symbol name.
179    ///
180    /// `exported_symbols` stores bare names (e.g. `total_disk_bytes`), but
181    /// `symbol_metadata` is keyed by scoped identity (e.g.
182    /// `BackupStore::total_disk_bytes` for impl methods, via
183    /// [`symbol_identity`]). A bare-name `.get()` therefore misses scoped
184    /// symbols and forces callers into degraded `unknown`/line-1 fallbacks.
185    /// This resolves an exact key first, then falls back to the first entry
186    /// whose unqualified name matches — recovering correct kind and line for
187    /// methods. (Bare-name exports are already ambiguous across scopes, so
188    /// first-match is the best available signal; this only affects displayed
189    /// metadata, never liveness, which keys on the symbol name.)
190    pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
191        if let Some(meta) = self.symbol_metadata.get(name) {
192            return Some(meta);
193        }
194        self.symbol_metadata
195            .iter()
196            .find(|(key, _)| symbol_unqualified_name(key) == name)
197            .map(|(_, meta)| meta)
198    }
199}
200
201/// Result of resolving a cross-file call edge.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum EdgeResolution {
204    /// Successfully resolved to a specific file and symbol.
205    Resolved { file: PathBuf, symbol: String },
206    /// Could not resolve — callee name preserved for diagnostics.
207    Unresolved { callee_name: String },
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
211struct ResolvedSymbol {
212    file: PathBuf,
213    symbol: String,
214}
215
216#[derive(Debug, Clone)]
217struct RustCrateInfo {
218    lib_name: String,
219    lib_root: Option<PathBuf>,
220    main_root: Option<PathBuf>,
221}
222
223#[derive(Debug, Clone)]
224struct RustModuleBase {
225    src_dir: PathBuf,
226    root_file: PathBuf,
227}
228
229#[derive(Debug, Clone)]
230struct RustUseEntry {
231    module_path: String,
232    local_name: String,
233    kind: RustUseKind,
234}
235
236#[derive(Debug, Clone)]
237enum RustUseKind {
238    Item { imported_name: String },
239    Module,
240}
241
242/// A single caller site: who calls a given symbol and from where.
243#[derive(Debug, Clone, Serialize)]
244pub struct CallerSite {
245    /// File containing the caller.
246    pub caller_file: PathBuf,
247    /// Symbol that makes the call.
248    pub caller_symbol: String,
249    /// 1-based line number of the call.
250    pub line: u32,
251    /// 0-based column (byte start within file, kept for future use).
252    pub col: u32,
253    /// Whether the edge was resolved via import chain.
254    pub resolved: bool,
255}
256
257#[derive(Debug, Clone)]
258struct IndexedCallerSite {
259    caller_file: SharedPath,
260    caller_symbol: SharedStr,
261    line: u32,
262    col: u32,
263    resolved: bool,
264}
265
266/// A group of callers from a single file.
267#[derive(Debug, Clone, Serialize)]
268pub struct CallerGroup {
269    /// File path (relative to project root).
270    pub file: String,
271    /// Individual call sites in this file.
272    pub callers: Vec<CallerEntry>,
273}
274
275/// A single caller entry within a CallerGroup.
276#[derive(Debug, Clone, Serialize)]
277pub struct CallerEntry {
278    pub symbol: String,
279    /// 1-based line number of the call.
280    pub line: u32,
281}
282
283/// Result of a `callers_of` query.
284#[derive(Debug, Clone, Serialize)]
285pub struct CallersResult {
286    /// Target symbol queried.
287    pub symbol: String,
288    /// Target file queried.
289    pub file: String,
290    /// Caller groups, one per calling file.
291    pub callers: Vec<CallerGroup>,
292    /// Total number of call sites found.
293    pub total_callers: usize,
294    /// Number of files scanned to build the reverse index.
295    pub scanned_files: usize,
296    /// Whether recursive caller expansion stopped at the requested depth.
297    pub depth_limited: bool,
298    /// Number of caller edges omitted because of the depth limit.
299    pub truncated: usize,
300}
301
302/// A node in the forward call tree.
303#[derive(Debug, Clone, Serialize)]
304pub struct CallTreeNode {
305    /// Symbol name.
306    pub name: String,
307    /// File path (relative to project root when possible).
308    pub file: String,
309    /// 1-based line number.
310    pub line: u32,
311    /// Function signature if available.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub signature: Option<String>,
314    /// Whether this edge was resolved cross-file.
315    pub resolved: bool,
316    /// Child calls (recursive).
317    pub children: Vec<CallTreeNode>,
318    /// Whether traversal below this node stopped at the requested depth.
319    pub depth_limited: bool,
320    /// Number of child call edges omitted because of the depth limit.
321    pub truncated: usize,
322}
323
324// ---------------------------------------------------------------------------
325// Entry point detection
326// ---------------------------------------------------------------------------
327
328/// Well-known main/init function names (case-insensitive exact match).
329const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
330
331/// Determine whether a symbol is an entry point.
332///
333/// Entry points are:
334/// - Exported standalone functions (not methods — methods are class members)
335/// - Functions matching well-known main/init patterns (any language)
336/// - Test functions matching language-specific patterns
337pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
338    // Exported standalone functions
339    if exported && *kind == SymbolKind::Function {
340        return true;
341    }
342
343    // Main/init patterns (case-insensitive exact match, any kind)
344    let lower = name.to_lowercase();
345    if MAIN_INIT_NAMES.contains(&lower.as_str()) {
346        return true;
347    }
348
349    // Test patterns by language
350    match lang {
351        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
352            // describe, it, test (exact), or starts with test/spec
353            matches!(lower.as_str(), "describe" | "it" | "test")
354                || lower.starts_with("test")
355                || lower.starts_with("spec")
356        }
357        LangId::Python => {
358            // starts with test_ or matches setUp/tearDown
359            lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
360        }
361        LangId::Rust => {
362            // starts with test_
363            lower.starts_with("test_")
364        }
365        LangId::Go => {
366            // starts with Test (case-sensitive)
367            name.starts_with("Test")
368        }
369        LangId::C
370        | LangId::Cpp
371        | LangId::Zig
372        | LangId::CSharp
373        | LangId::Bash
374        | LangId::Solidity
375        | LangId::Scss
376        | LangId::Vue
377        | LangId::Json
378        | LangId::Scala
379        | LangId::Java
380        | LangId::Ruby
381        | LangId::Kotlin
382        | LangId::Swift
383        | LangId::Php
384        | LangId::Lua
385        | LangId::Perl
386        | LangId::Html
387        | LangId::Markdown
388        | LangId::Yaml => false,
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Trace-to types
394// ---------------------------------------------------------------------------
395
396/// A single hop in a trace path.
397#[derive(Debug, Clone, Serialize)]
398pub struct TraceHop {
399    /// Symbol name at this hop.
400    pub symbol: String,
401    /// File path (relative to project root).
402    pub file: String,
403    /// 1-based line number.
404    pub line: u32,
405    /// Function signature if available.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub signature: Option<String>,
408    /// Whether this hop is an entry point.
409    pub is_entry_point: bool,
410}
411
412/// A complete path from an entry point to the target symbol (top-down).
413#[derive(Debug, Clone, Serialize)]
414pub struct TracePath {
415    /// Hops from entry point (first) to target (last).
416    pub hops: Vec<TraceHop>,
417}
418
419/// Result of a `trace_to` query.
420#[derive(Debug, Clone, Serialize)]
421pub struct TraceToResult {
422    /// The target symbol that was traced.
423    pub target_symbol: String,
424    /// The target file (relative to project root).
425    pub target_file: String,
426    /// Complete paths from entry points to the target.
427    pub paths: Vec<TracePath>,
428    /// Total number of complete paths found.
429    pub total_paths: usize,
430    /// Number of distinct entry points found across all paths.
431    pub entry_points_found: usize,
432    /// Whether any path was cut short by the depth limit.
433    pub max_depth_reached: bool,
434    /// Number of paths that reached a dead end (no callers, not entry point).
435    pub truncated_paths: usize,
436}
437
438/// A single hop in a `trace_to_symbol` path.
439#[derive(Debug, Clone, Serialize)]
440pub struct TraceToSymbolHop {
441    /// Symbol name at this hop.
442    pub symbol: String,
443    /// File path (relative to project root).
444    pub file: String,
445    /// 1-based definition line number.
446    pub line: u32,
447}
448
449/// Candidate target location for an ambiguous `trace_to_symbol` request.
450#[derive(Debug, Clone, Serialize)]
451pub struct TraceToSymbolCandidate {
452    /// File path (relative to project root).
453    pub file: String,
454    /// 1-based definition line number.
455    pub line: u32,
456}
457
458/// Result of a `trace_to_symbol` query.
459#[derive(Debug, Clone, Serialize)]
460pub struct TraceToSymbolResult {
461    /// Shortest path from the origin symbol to the target symbol, if found.
462    pub path: Option<Vec<TraceToSymbolHop>>,
463    /// Whether traversal was complete within the requested depth.
464    pub complete: bool,
465    /// Machine-readable explanation when `path` is null.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub reason: Option<String>,
468}
469
470// ---------------------------------------------------------------------------
471// Impact analysis types
472// ---------------------------------------------------------------------------
473
474/// A single caller in an impact analysis result.
475#[derive(Debug, Clone, Serialize)]
476pub struct ImpactCaller {
477    /// Symbol that calls the target.
478    pub caller_symbol: String,
479    /// File containing the caller (relative to project root).
480    pub caller_file: String,
481    /// 1-based line number of the call site.
482    pub line: u32,
483    /// Caller's function/method signature, if available.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub signature: Option<String>,
486    /// Whether the caller is an entry point.
487    pub is_entry_point: bool,
488    /// Source line at the call site (trimmed).
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub call_expression: Option<String>,
491    /// Parameter names extracted from the caller's signature.
492    pub parameters: Vec<String>,
493}
494
495/// Result of an `impact` query — enriched callers analysis.
496#[derive(Debug, Clone, Serialize)]
497pub struct ImpactResult {
498    /// The target symbol being analyzed.
499    pub symbol: String,
500    /// The target file (relative to project root).
501    pub file: String,
502    /// Target symbol's signature, if available.
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub signature: Option<String>,
505    /// Parameter names extracted from the target's signature.
506    pub parameters: Vec<String>,
507    /// Total number of affected call sites.
508    pub total_affected: usize,
509    /// Number of distinct files containing callers.
510    pub affected_files: usize,
511    /// Enriched caller details.
512    pub callers: Vec<ImpactCaller>,
513    /// Whether transitive impact expansion stopped at the requested depth.
514    pub depth_limited: bool,
515    /// Number of caller edges omitted because of the depth limit.
516    pub truncated: usize,
517}
518
519// ---------------------------------------------------------------------------
520// Data flow tracking types
521// ---------------------------------------------------------------------------
522
523/// A single hop in a data flow trace.
524#[derive(Debug, Clone, Serialize)]
525pub struct DataFlowHop {
526    /// File path (relative to project root).
527    pub file: String,
528    /// Symbol (function/method) containing this hop.
529    pub symbol: String,
530    /// Variable or parameter name being tracked at this hop.
531    pub variable: String,
532    /// 1-based line number.
533    pub line: u32,
534    /// Type of data flow: "assignment", "parameter", or "return".
535    pub flow_type: String,
536    /// Whether this hop is an approximation (destructuring, spread, unresolved).
537    pub approximate: bool,
538}
539
540/// Result of a `trace_data` query — tracks how an expression flows through
541/// variable assignments and function parameters.
542#[derive(Debug, Clone, Serialize)]
543pub struct TraceDataResult {
544    /// The expression being tracked.
545    pub expression: String,
546    /// The file where tracking started.
547    pub origin_file: String,
548    /// The symbol where tracking started.
549    pub origin_symbol: String,
550    /// Hops through assignments and parameters.
551    pub hops: Vec<DataFlowHop>,
552    /// Whether tracking stopped due to depth limit.
553    pub depth_limited: bool,
554}
555
556/// Extract parameter names from a function signature string.
557///
558/// Strips language-specific receivers (`self`, `&self`, `&mut self` for Rust,
559/// `self` for Python) and type annotations / default values. Returns just
560/// the parameter names.
561pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
562    // Find the parameter list between parentheses
563    let start = match signature.find('(') {
564        Some(i) => i + 1,
565        None => return Vec::new(),
566    };
567    let end = match signature[start..].find(')') {
568        Some(i) => start + i,
569        None => return Vec::new(),
570    };
571
572    let params_str = &signature[start..end].trim();
573    if params_str.is_empty() {
574        return Vec::new();
575    }
576
577    // Split on commas, respecting nested generics/brackets
578    let parts = split_params(params_str);
579
580    let mut result = Vec::new();
581    for part in parts {
582        let trimmed = part.trim();
583        if trimmed.is_empty() {
584            continue;
585        }
586
587        // Skip language-specific receivers
588        match lang {
589            LangId::Rust => {
590                if trimmed == "self"
591                    || trimmed == "mut self"
592                    || trimmed.starts_with("&self")
593                    || trimmed.starts_with("&mut self")
594                {
595                    continue;
596                }
597            }
598            LangId::Python => {
599                if trimmed == "self" || trimmed.starts_with("self:") {
600                    continue;
601                }
602            }
603            _ => {}
604        }
605
606        // Extract just the parameter name
607        let name = extract_param_name(trimmed, lang);
608        if !name.is_empty() {
609            result.push(name);
610        }
611    }
612
613    result
614}
615
616/// Split parameter string on commas, respecting nested brackets/generics.
617fn split_params(s: &str) -> Vec<String> {
618    let mut parts = Vec::new();
619    let mut current = String::new();
620    let mut depth = 0i32;
621
622    for ch in s.chars() {
623        match ch {
624            '<' | '[' | '{' | '(' => {
625                depth += 1;
626                current.push(ch);
627            }
628            '>' | ']' | '}' | ')' => {
629                depth -= 1;
630                current.push(ch);
631            }
632            ',' if depth == 0 => {
633                parts.push(current.clone());
634                current.clear();
635            }
636            _ => {
637                current.push(ch);
638            }
639        }
640    }
641    if !current.is_empty() {
642        parts.push(current);
643    }
644    parts
645}
646
647/// Extract the parameter name from a single parameter declaration.
648///
649/// Handles:
650/// - TS/JS: `name: Type`, `name = default`, `...name`, `name?: Type`
651/// - Python: `name: Type`, `name=default`, `*args`, `**kwargs`
652/// - Rust: `name: Type`, `mut name: Type`
653/// - Go: `name Type`, `name, name2 Type`
654fn extract_param_name(param: &str, lang: LangId) -> String {
655    let trimmed = param.trim();
656
657    // Handle rest/spread params
658    let working = if trimmed.starts_with("...") {
659        &trimmed[3..]
660    } else if trimmed.starts_with("**") {
661        &trimmed[2..]
662    } else if trimmed.starts_with('*') && lang == LangId::Python {
663        &trimmed[1..]
664    } else {
665        trimmed
666    };
667
668    // Rust: `mut name: Type` → strip `mut `
669    let working = if lang == LangId::Rust && working.starts_with("mut ") {
670        &working[4..]
671    } else {
672        working
673    };
674
675    // Strip type annotation (`: Type`) and default values (`= default`)
676    // Take only the name part — everything before `:`, `=`, or `?`
677    let name = working
678        .split(|c: char| c == ':' || c == '=')
679        .next()
680        .unwrap_or("")
681        .trim();
682
683    // Strip trailing `?` (optional params in TS)
684    let name = name.trim_end_matches('?');
685
686    // For Go, the name might be just `name Type` — take the first word
687    if lang == LangId::Go && !name.contains(' ') {
688        return name.to_string();
689    }
690    if lang == LangId::Go {
691        return name.split_whitespace().next().unwrap_or("").to_string();
692    }
693
694    name.to_string()
695}
696
697// ---------------------------------------------------------------------------
698// CallGraph
699// ---------------------------------------------------------------------------
700
701/// Worktree-scoped call graph with lazy per-file construction.
702///
703/// Files are parsed and analyzed on first access, then cached. The graph
704/// can resolve cross-file call edges using the import engine.
705pub struct CallGraph {
706    /// Cached per-file call data.
707    data: HashMap<PathBuf, FileCallData>,
708    /// Project root for relative path resolution.
709    project_root: PathBuf,
710    /// All files discovered in the worktree (lazily populated).
711    project_files: Option<Vec<PathBuf>>,
712    /// Reverse index: target_file → target_symbol → callers.
713    /// Built lazily on first `callers_of` call, cleared on `invalidate_file`.
714    reverse_index: Option<ReverseIndex>,
715}
716
717impl CallGraph {
718    /// Create a new call graph for a project.
719    pub fn new(project_root: PathBuf) -> Self {
720        clear_workspace_package_cache();
721        Self {
722            data: HashMap::new(),
723            project_root,
724            project_files: None,
725            reverse_index: None,
726        }
727    }
728
729    /// Get the project root directory.
730    pub fn project_root(&self) -> &Path {
731        &self.project_root
732    }
733
734    fn resolve_cross_file_edge_with_exports<F, D>(
735        full_callee: &str,
736        short_name: &str,
737        caller_file: &Path,
738        import_block: &ImportBlock,
739        mut file_exports_symbol: F,
740        mut file_default_export_symbol: D,
741    ) -> EdgeResolution
742    where
743        F: FnMut(&Path, &str) -> bool,
744        D: FnMut(&Path) -> Option<String>,
745    {
746        let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
747
748        // Rust uses `::` module paths rather than JS/TS specifiers. Keep this
749        // branch gated to `.rs` callers so the existing JS/TS resolver below
750        // remains unchanged.
751        if is_rust_source_file(caller_file) {
752            if let Some(target) = resolve_rust_cross_file_edge(
753                full_callee,
754                short_name,
755                caller_file,
756                import_block,
757                &mut file_exports_symbol,
758            ) {
759                return EdgeResolution::Resolved {
760                    file: target.file,
761                    symbol: target.symbol,
762                };
763            }
764        }
765
766        // Check namespace imports: "utils.foo" where utils is a namespace import
767        if full_callee.contains('.') {
768            let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
769            if parts.len() == 2 {
770                let namespace = parts[0];
771                let member = parts[1];
772
773                for imp in &import_block.imports {
774                    if imp.namespace_import.as_deref() == Some(namespace) {
775                        if let Some(resolved_path) =
776                            resolve_module_path(caller_dir, &imp.module_path)
777                        {
778                            if let Some(target) = resolve_reexported_symbol(
779                                &resolved_path,
780                                member,
781                                &mut file_exports_symbol,
782                                &mut file_default_export_symbol,
783                            ) {
784                                return EdgeResolution::Resolved {
785                                    file: target.file,
786                                    symbol: target.symbol,
787                                };
788                            }
789                        }
790                    }
791                }
792            }
793        }
794
795        // Check named imports (direct and aliased)
796        for imp in &import_block.imports {
797            // Direct named import: import { foo } from './utils'
798            if imp.names.iter().any(|name| name == short_name) {
799                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
800                    let target = resolve_reexported_symbol(
801                        &resolved_path,
802                        short_name,
803                        &mut file_exports_symbol,
804                        &mut file_default_export_symbol,
805                    )
806                    .unwrap_or(ResolvedSymbol {
807                        file: resolved_path,
808                        symbol: short_name.to_owned(),
809                    });
810                    return EdgeResolution::Resolved {
811                        file: target.file,
812                        symbol: target.symbol,
813                    };
814                }
815            }
816
817            // Default import: import foo from './utils'
818            if imp.default_import.as_deref() == Some(short_name) {
819                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
820                    let target = resolve_reexported_symbol(
821                        &resolved_path,
822                        "default",
823                        &mut file_exports_symbol,
824                        &mut file_default_export_symbol,
825                    )
826                    .unwrap_or_else(|| ResolvedSymbol {
827                        symbol: file_default_export_symbol(&resolved_path)
828                            .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
829                        file: resolved_path,
830                    });
831                    return EdgeResolution::Resolved {
832                        file: target.file,
833                        symbol: target.symbol,
834                    };
835                }
836            }
837        }
838
839        // Check aliased imports by examining the raw import text.
840        // ImportStatement.names stores the original name (foo), but the local code
841        // uses the alias (bar). We need to parse `import { foo as bar }` to find
842        // that `bar` maps to `foo`.
843        if let Some((original_name, resolved_path)) =
844            resolve_aliased_import(short_name, import_block, caller_dir)
845        {
846            let target = resolve_reexported_symbol(
847                &resolved_path,
848                &original_name,
849                &mut file_exports_symbol,
850                &mut file_default_export_symbol,
851            )
852            .unwrap_or(ResolvedSymbol {
853                file: resolved_path,
854                symbol: original_name,
855            });
856            return EdgeResolution::Resolved {
857                file: target.file,
858                symbol: target.symbol,
859            };
860        }
861
862        // Try barrel file re-exports: if any import points to an index file,
863        // check if that file re-exports the symbol
864        for imp in &import_block.imports {
865            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
866                // Check if the resolved path is a directory (barrel file)
867                if resolved_path.is_dir() {
868                    if let Some(index_path) = find_index_file(&resolved_path) {
869                        // Check if the index file exports this symbol
870                        if file_exports_symbol(&index_path, short_name) {
871                            return EdgeResolution::Resolved {
872                                file: index_path,
873                                symbol: short_name.to_owned(),
874                            };
875                        }
876                    }
877                } else if file_exports_symbol(&resolved_path, short_name) {
878                    return EdgeResolution::Resolved {
879                        file: resolved_path,
880                        symbol: short_name.to_owned(),
881                    };
882                }
883            }
884        }
885
886        EdgeResolution::Unresolved {
887            callee_name: short_name.to_owned(),
888        }
889    }
890
891    /// Get or build the call data for a file.
892    pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
893        let canon = self.canonicalize(path)?;
894
895        if !self.data.contains_key(&canon) {
896            let file_data = build_file_data(&canon)?;
897            self.data.insert(canon.clone(), file_data);
898        }
899
900        Ok(&self.data[&canon])
901    }
902
903    /// Resolve a user-provided symbol query to the unique scoped symbol identity
904    /// used internally by the call graph.
905    pub fn resolve_symbol_query(&mut self, file: &Path, symbol: &str) -> Result<String, AftError> {
906        let canon = self.canonicalize(file)?;
907        let file_data = self.build_file(&canon)?;
908        resolve_symbol_query_in_data(file_data, &canon, symbol)
909    }
910
911    /// Resolve a cross-file call edge.
912    ///
913    /// Given a callee expression and the calling file's import block,
914    /// determines which file and symbol the call targets.
915    pub fn resolve_cross_file_edge(
916        &mut self,
917        full_callee: &str,
918        short_name: &str,
919        caller_file: &Path,
920        import_block: &ImportBlock,
921    ) -> EdgeResolution {
922        let graph = RefCell::new(self);
923        Self::resolve_cross_file_edge_with_exports(
924            full_callee,
925            short_name,
926            caller_file,
927            import_block,
928            |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
929            |path| graph.borrow_mut().file_default_export_symbol(path),
930        )
931    }
932
933    /// Check if a file exports a given symbol name.
934    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
935        match self.build_file(path) {
936            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
937            Err(_) => false,
938        }
939    }
940
941    fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
942        self.build_file(path)
943            .ok()
944            .and_then(|data| data.default_export_symbol.clone())
945    }
946
947    fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
948        self.lookup_file_data(path)
949            .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
950            .unwrap_or(false)
951    }
952
953    fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
954        self.lookup_file_data(path)
955            .and_then(|data| data.default_export_symbol.clone())
956    }
957
958    /// Depth-limited forward call tree traversal.
959    ///
960    /// Starting from a (file, symbol) pair, recursively follows calls
961    /// up to `max_depth` levels. Uses a visited set for cycle detection.
962    pub fn forward_tree(
963        &mut self,
964        file: &Path,
965        symbol: &str,
966        max_depth: usize,
967    ) -> Result<CallTreeNode, AftError> {
968        let canon = self.canonicalize(file)?;
969        let resolved_symbol = {
970            let file_data = self.build_file(&canon)?;
971            resolve_symbol_query_in_data(file_data, &canon, symbol)?
972        };
973        let mut visited = HashSet::new();
974        self.forward_tree_inner(&canon, &resolved_symbol, max_depth, 0, &mut visited)
975    }
976
977    fn forward_tree_inner(
978        &mut self,
979        file: &Path,
980        symbol: &str,
981        max_depth: usize,
982        current_depth: usize,
983        visited: &mut HashSet<(PathBuf, String)>,
984    ) -> Result<CallTreeNode, AftError> {
985        let canon = self.canonicalize(file)?;
986        let visit_key = (canon.clone(), symbol.to_string());
987
988        // Cycle detection
989        if visited.contains(&visit_key) {
990            let (line, signature) = self
991                .lookup_file_data(&canon)
992                .map(|data| get_symbol_meta_from_data(data, symbol))
993                .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
994            return Ok(CallTreeNode {
995                name: symbol.to_string(),
996                file: self.relative_path(&canon),
997                line,
998                signature,
999                resolved: true,
1000                children: vec![], // cycle — stop recursion
1001                depth_limited: false,
1002                truncated: 0,
1003            });
1004        }
1005
1006        visited.insert(visit_key.clone());
1007
1008        let (import_block, call_sites, sym_line, sym_signature) = {
1009            let file_data = self.build_file(&canon)?;
1010            let meta = get_symbol_meta_from_data(file_data, symbol);
1011
1012            (
1013                file_data.import_block.clone(),
1014                file_data
1015                    .calls_by_symbol
1016                    .get(symbol)
1017                    .cloned()
1018                    .unwrap_or_default(),
1019                meta.0,
1020                meta.1,
1021            )
1022        };
1023
1024        // Build children
1025        let mut children = Vec::new();
1026        let mut depth_limited = false;
1027        let mut truncated = 0;
1028
1029        if current_depth < max_depth {
1030            for call_site in &call_sites {
1031                let edge = self.resolve_cross_file_edge(
1032                    &call_site.full_callee,
1033                    &call_site.callee_name,
1034                    &canon,
1035                    &import_block,
1036                );
1037
1038                match edge {
1039                    EdgeResolution::Resolved {
1040                        file: ref target_file,
1041                        ref symbol,
1042                    } => {
1043                        match self.forward_tree_inner(
1044                            target_file,
1045                            symbol,
1046                            max_depth,
1047                            current_depth + 1,
1048                            visited,
1049                        ) {
1050                            Ok(child) => {
1051                                depth_limited |= child.depth_limited;
1052                                truncated += child.truncated;
1053                                children.push(child);
1054                            }
1055                            Err(_) => {
1056                                // Target file can't be parsed — mark as unresolved leaf
1057                                children.push(CallTreeNode {
1058                                    name: call_site.callee_name.clone(),
1059                                    file: self.relative_path(target_file),
1060                                    line: call_site.line,
1061                                    signature: None,
1062                                    resolved: false,
1063                                    children: vec![],
1064                                    depth_limited: false,
1065                                    truncated: 0,
1066                                });
1067                            }
1068                        }
1069                    }
1070                    EdgeResolution::Unresolved { callee_name } => {
1071                        if let Some(local_child) = self.resolve_local_call_tree_child(
1072                            &canon,
1073                            symbol,
1074                            call_site,
1075                            &callee_name,
1076                            max_depth,
1077                            current_depth,
1078                            visited,
1079                        )? {
1080                            depth_limited |= local_child.depth_limited;
1081                            truncated += local_child.truncated;
1082                            children.push(local_child);
1083                            continue;
1084                        }
1085                        children.push(CallTreeNode {
1086                            name: callee_name,
1087                            file: self.relative_path(&canon),
1088                            line: call_site.line,
1089                            signature: None,
1090                            resolved: false,
1091                            children: vec![],
1092                            depth_limited: false,
1093                            truncated: 0,
1094                        });
1095                    }
1096                }
1097            }
1098        } else if !call_sites.is_empty() {
1099            depth_limited = true;
1100            truncated = call_sites.len();
1101        }
1102
1103        visited.remove(&visit_key);
1104
1105        Ok(CallTreeNode {
1106            name: symbol.to_string(),
1107            file: self.relative_path(&canon),
1108            line: sym_line,
1109            signature: sym_signature,
1110            resolved: true,
1111            children,
1112            depth_limited,
1113            truncated,
1114        })
1115    }
1116
1117    fn resolve_local_call_tree_child(
1118        &mut self,
1119        canon: &Path,
1120        current_symbol: &str,
1121        call_site: &CallSite,
1122        callee_name: &str,
1123        max_depth: usize,
1124        current_depth: usize,
1125        visited: &mut HashSet<(PathBuf, String)>,
1126    ) -> Result<Option<CallTreeNode>, AftError> {
1127        if !is_bare_callee(&call_site.full_callee, callee_name) {
1128            return Ok(None);
1129        }
1130
1131        let target_symbol = match self
1132            .lookup_file_data(canon)
1133            .and_then(|data| resolve_symbol_query_in_data(data, canon, callee_name).ok())
1134        {
1135            Some(symbol) => symbol,
1136            None => return Ok(None),
1137        };
1138
1139        if target_symbol == current_symbol {
1140            return Ok(None);
1141        }
1142
1143        match self.forward_tree_inner(canon, &target_symbol, max_depth, current_depth + 1, visited)
1144        {
1145            Ok(child) => Ok(Some(child)),
1146            Err(_) => Ok(Some(CallTreeNode {
1147                name: target_symbol,
1148                file: self.relative_path(canon),
1149                line: call_site.line,
1150                signature: None,
1151                resolved: false,
1152                children: vec![],
1153                depth_limited: false,
1154                truncated: 0,
1155            })),
1156        }
1157    }
1158
1159    /// Get all project files (lazily discovered).
1160    pub fn project_files(&mut self) -> &[PathBuf] {
1161        if self.project_files.is_none() {
1162            let project_root = self.project_root.clone();
1163            self.project_files = Some(walk_project_files(&project_root).collect());
1164        }
1165        self.project_files.as_deref().unwrap_or(&[])
1166    }
1167
1168    /// Get the total number of project source files.
1169    ///
1170    /// Triggers project file discovery on first access and returns the cached
1171    /// count thereafter. Prefer [`project_file_count_bounded`] when the caller
1172    /// only needs to know whether a threshold is exceeded.
1173    pub fn project_file_count(&mut self) -> usize {
1174        self.project_files().len()
1175    }
1176
1177    /// Count project source files, stopping after `limit + 1` so huge roots
1178    /// do not pay for a full walk or allocate a giant vector.
1179    ///
1180    /// Returns the real count when ≤ `limit`, or `limit + 1` when exceeded.
1181    /// Uses the cached `project_files` vec when it already exists (e.g. a
1182    /// previous call-graph op succeeded at this cap), otherwise short-circuits
1183    /// the underlying `ignore::Walk` iterator via `.take(limit + 1)`.
1184    ///
1185    /// CRITICAL: This method must NOT populate `self.project_files`. The whole
1186    /// point is to reject oversized roots before the full walk-and-collect runs.
1187    pub fn project_file_count_bounded(&self, limit: usize) -> usize {
1188        if let Some(files) = self.project_files.as_deref() {
1189            return files.len();
1190        }
1191        walk_project_files(&self.project_root)
1192            .take(limit.saturating_add(1))
1193            .count()
1194    }
1195
1196    /// Build call data for all project files, failing fast when the configured
1197    /// source-file cap is exceeded.
1198    fn ensure_project_files_built(&mut self, max_files: usize) -> Result<(), AftError> {
1199        // Bounded count first — never populate project_files on oversized roots.
1200        // `walk_project_files(...).take(max_files + 1)` is lazy (Walk is an
1201        // iterator), so this costs at most (max_files + 1) directory entries
1202        // worth of work, not a full O(N) walk of the whole tree.
1203        let count = self.project_file_count_bounded(max_files);
1204        if count > max_files {
1205            return Err(AftError::ProjectTooLarge {
1206                count,
1207                max: max_files,
1208            });
1209        }
1210
1211        // TODO(v0.16): rust-side deadline for graceful timeout recovery
1212        // (unbounded walks remain a soft cliff for users who raise the cap).
1213        // Discover all project files first.
1214        let all_files = self.project_files().to_vec();
1215
1216        // Build file data for all project files.
1217        let uncached_files: Vec<PathBuf> = all_files
1218            .iter()
1219            .filter(|f| self.lookup_file_data(f).is_none())
1220            .cloned()
1221            .collect();
1222
1223        // Parsing every uncached source file is the dominant cost of a cold
1224        // call-graph query on a large repo. Log it so a slow (near-timeout)
1225        // first call is attributable to parse work rather than appearing as an
1226        // opaque bridge hang. Cheap no-op when nothing is uncached (warm cache).
1227        if !uncached_files.is_empty() {
1228            let started = Instant::now();
1229            let computed: Vec<(PathBuf, FileCallData)> = uncached_files
1230                .par_iter()
1231                .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
1232                .collect();
1233
1234            let parsed = computed.len();
1235            for (file, data) in computed {
1236                self.data.insert(file, data);
1237            }
1238            slog_info!(
1239                "callgraph: parsed {} uncached files ({} total project files) in {}ms",
1240                parsed,
1241                all_files.len(),
1242                started.elapsed().as_millis()
1243            );
1244        }
1245
1246        Ok(())
1247    }
1248
1249    /// Build the reverse index by scanning all project files.
1250    ///
1251    /// For each file, builds the call data (if not cached), then for each
1252    /// (symbol, call_sites) pair, resolves cross-file edges and inserts
1253    /// into the reverse map: `(target_file, target_symbol) → Vec<CallerSite>`.
1254    fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
1255        self.ensure_project_files_built(max_files)?;
1256        let all_files = self.project_files().to_vec();
1257
1258        // Cross-file edge resolution is the second dominant cost (after parsing)
1259        // of a cold callers/impact query; time it so a slow first call is fully
1260        // attributable across the two phases.
1261        let reverse_started = Instant::now();
1262
1263        // Now build the reverse map
1264        let mut reverse: ReverseIndex = HashMap::new();
1265
1266        for caller_file in &all_files {
1267            // Canonicalize the caller file path for consistent lookups
1268            let canon_caller = Arc::new(
1269                std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
1270            );
1271            let file_data = match self
1272                .data
1273                .get(caller_file)
1274                .or_else(|| self.data.get(canon_caller.as_ref()))
1275            {
1276                Some(d) => d,
1277                None => continue,
1278            };
1279
1280            for (symbol_name, call_sites) in &file_data.calls_by_symbol {
1281                let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
1282
1283                for call_site in call_sites {
1284                    let edge = Self::resolve_cross_file_edge_with_exports(
1285                        &call_site.full_callee,
1286                        &call_site.callee_name,
1287                        canon_caller.as_ref(),
1288                        &file_data.import_block,
1289                        |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1290                        |path| self.file_default_export_symbol_cached(path),
1291                    );
1292
1293                    let (target_file, target_symbol, resolved) = match edge {
1294                        EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1295                        EdgeResolution::Unresolved { callee_name } => {
1296                            if !is_bare_callee(&call_site.full_callee, &callee_name) {
1297                                continue;
1298                            }
1299
1300                            let Ok(target_symbol) = resolve_symbol_query_in_data(
1301                                file_data,
1302                                canon_caller.as_ref(),
1303                                &callee_name,
1304                            ) else {
1305                                continue;
1306                            };
1307
1308                            (canon_caller.as_ref().clone(), target_symbol, false)
1309                        }
1310                    };
1311
1312                    if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1313                        continue;
1314                    }
1315
1316                    reverse
1317                        .entry(target_file)
1318                        .or_default()
1319                        .entry(target_symbol)
1320                        .or_default()
1321                        .push(IndexedCallerSite {
1322                            caller_file: Arc::clone(&canon_caller),
1323                            caller_symbol: Arc::clone(&caller_symbol),
1324                            line: call_site.line,
1325                            col: 0,
1326                            resolved,
1327                        });
1328                }
1329            }
1330        }
1331
1332        let edges: usize = reverse
1333            .values()
1334            .map(|m| m.values().map(Vec::len).sum::<usize>())
1335            .sum();
1336        self.reverse_index = Some(reverse);
1337        slog_debug!(
1338            "callgraph: built reverse index ({} edges over {} files) in {}ms",
1339            edges,
1340            all_files.len(),
1341            reverse_started.elapsed().as_millis()
1342        );
1343        Ok(())
1344    }
1345
1346    fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1347        self.reverse_index
1348            .as_ref()?
1349            .get(file)?
1350            .get(symbol)
1351            .map(Vec::as_slice)
1352    }
1353
1354    /// Get callers of a symbol in a file, grouped by calling file.
1355    ///
1356    /// Builds the reverse index on first call (scans all project files).
1357    /// Supports recursive depth expansion: depth=1 returns direct callers,
1358    /// depth=2 returns callers-of-callers, etc. depth=0 is treated as 1.
1359    pub fn callers_of(
1360        &mut self,
1361        file: &Path,
1362        symbol: &str,
1363        depth: usize,
1364        max_files: usize,
1365    ) -> Result<CallersResult, AftError> {
1366        let canon = self.canonicalize(file)?;
1367
1368        // Ensure file is built (may already be cached) and resolve scoped identity.
1369        let resolved_symbol = {
1370            let file_data = self.build_file(&canon)?;
1371            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1372        };
1373
1374        // Build the reverse index if not cached
1375        if self.reverse_index.is_none() {
1376            self.build_reverse_index(max_files)?;
1377        }
1378
1379        let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1380        let effective_depth = if depth == 0 { 1 } else { depth };
1381
1382        let mut visited = HashSet::new();
1383        let mut all_sites: Vec<CallerSite> = Vec::new();
1384        let mut depth_limited = false;
1385        let mut truncated = 0;
1386        self.collect_callers_recursive(
1387            &canon,
1388            &resolved_symbol,
1389            effective_depth,
1390            0,
1391            &mut visited,
1392            &mut all_sites,
1393            &mut depth_limited,
1394            &mut truncated,
1395        );
1396
1397        // Group by file
1398
1399        let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1400        let total_callers = all_sites.len();
1401        for site in all_sites {
1402            let caller_file: PathBuf = site.caller_file;
1403            let caller_symbol: String = site.caller_symbol;
1404            let line = site.line;
1405            let entry = CallerEntry {
1406                symbol: caller_symbol,
1407                line,
1408            };
1409
1410            if let Some(entries) = groups_map.get_mut(&caller_file) {
1411                entries.push(entry);
1412            } else {
1413                groups_map.insert(caller_file, vec![entry]);
1414            }
1415        }
1416
1417        let mut callers: Vec<CallerGroup> = groups_map
1418            .into_iter()
1419            .map(|(file_path, entries)| CallerGroup {
1420                file: self.relative_path(&file_path),
1421                callers: entries,
1422            })
1423            .collect();
1424
1425        // Sort groups by file path for deterministic output
1426        callers.sort_by(|a, b| a.file.cmp(&b.file));
1427
1428        Ok(CallersResult {
1429            symbol: resolved_symbol,
1430            file: self.relative_path(&canon),
1431            callers,
1432            total_callers,
1433            scanned_files,
1434            depth_limited,
1435            truncated,
1436        })
1437    }
1438
1439    /// Trace backward from a symbol to all entry points.
1440    ///
1441    /// Returns complete paths (top-down: entry point first, target last).
1442    /// Uses BFS backward through the reverse index, with per-path cycle
1443    /// detection and depth limiting.
1444    pub fn trace_to(
1445        &mut self,
1446        file: &Path,
1447        symbol: &str,
1448        max_depth: usize,
1449        max_files: usize,
1450    ) -> Result<TraceToResult, AftError> {
1451        let canon = self.canonicalize(file)?;
1452
1453        // Ensure file is built and resolve scoped identity.
1454        let resolved_symbol = {
1455            let file_data = self.build_file(&canon)?;
1456            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1457        };
1458
1459        // Build the reverse index if not cached
1460        if self.reverse_index.is_none() {
1461            self.build_reverse_index(max_files)?;
1462        }
1463
1464        let target_rel = self.relative_path(&canon);
1465        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1466        if self.reverse_index.is_none() {
1467            return Err(AftError::ParseError {
1468                message: format!(
1469                    "reverse index unavailable after building callers for {}",
1470                    canon.display()
1471                ),
1472            });
1473        }
1474
1475        // Get line/signature for the target symbol
1476        let (target_line, target_sig) = self
1477            .lookup_file_data(&canon)
1478            .map(|data| get_symbol_meta_from_data(data, &resolved_symbol))
1479            .unwrap_or_else(|| get_symbol_meta(&canon, &resolved_symbol));
1480
1481        // Check if target itself is an entry point
1482        let target_is_entry = self
1483            .lookup_file_data(&canon)
1484            .and_then(|fd| {
1485                let meta = fd.symbol_metadata.get(&resolved_symbol)?;
1486                Some(is_entry_point(
1487                    &resolved_symbol,
1488                    &meta.kind,
1489                    meta.exported,
1490                    fd.lang,
1491                ))
1492            })
1493            .unwrap_or(false);
1494
1495        // BFS state: each item is a partial path (bottom-up, will be reversed later)
1496        // Each path element: (canonicalized file, symbol name, line, signature)
1497        type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1498        let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1499        let mut max_depth_reached = false;
1500        let mut truncated_paths: usize = 0;
1501
1502        // Initial path starts at the target
1503        let initial: Vec<PathElem> = vec![(
1504            Arc::new(canon.clone()),
1505            Arc::from(resolved_symbol.as_str()),
1506            target_line,
1507            target_sig,
1508        )];
1509
1510        // If the target itself is an entry point, record it as a trivial path
1511        if target_is_entry {
1512            complete_paths.push(initial.clone());
1513        }
1514
1515        // Queue of (current_path, depth)
1516        let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1517
1518        while let Some((path, depth)) = queue.pop() {
1519            if depth >= effective_max {
1520                max_depth_reached = true;
1521                continue;
1522            }
1523
1524            let Some((current_file, current_symbol, _, _)) = path.last() else {
1525                continue;
1526            };
1527
1528            // Look up callers in reverse index
1529            let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1530                Some(sites) => sites,
1531                None => {
1532                    // Dead end: no callers and not an entry point
1533                    // (if it were an entry point, we'd have recorded it already)
1534                    if path.len() > 1 {
1535                        // Only count as truncated if this isn't the target itself
1536                        // (the target with no callers is just "no paths found")
1537                        truncated_paths += 1;
1538                    }
1539                    continue;
1540                }
1541            };
1542
1543            let mut has_new_path = false;
1544            for site in callers {
1545                // Cycle detection: skip if this caller is already in the current path
1546                if path.iter().any(|(file_path, sym, _, _)| {
1547                    file_path.as_ref() == site.caller_file.as_ref()
1548                        && sym.as_ref() == site.caller_symbol.as_ref()
1549                }) {
1550                    continue;
1551                }
1552
1553                has_new_path = true;
1554
1555                // Get caller's metadata
1556                let (caller_line, caller_sig) = self
1557                    .lookup_file_data(site.caller_file.as_ref())
1558                    .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1559                    .unwrap_or_else(|| {
1560                        get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1561                    });
1562
1563                let mut new_path = path.clone();
1564                new_path.push((
1565                    Arc::clone(&site.caller_file),
1566                    Arc::clone(&site.caller_symbol),
1567                    caller_line,
1568                    caller_sig,
1569                ));
1570
1571                // Check if this caller is an entry point
1572                // Try both canonical and non-canonical keys (build_reverse_index
1573                // may have stored data under the raw walker path)
1574                let caller_is_entry = self
1575                    .lookup_file_data(site.caller_file.as_ref())
1576                    .and_then(|fd| {
1577                        let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1578                        Some(is_entry_point(
1579                            site.caller_symbol.as_ref(),
1580                            &meta.kind,
1581                            meta.exported,
1582                            fd.lang,
1583                        ))
1584                    })
1585                    .unwrap_or(false);
1586
1587                if caller_is_entry {
1588                    complete_paths.push(new_path.clone());
1589                }
1590                // Always continue searching backward — there may be longer
1591                // paths through other entry points beyond this one
1592                queue.push((new_path, depth + 1));
1593            }
1594
1595            // If we had callers but none were new (all cycles), count as truncated
1596            if !has_new_path && path.len() > 1 {
1597                truncated_paths += 1;
1598            }
1599        }
1600
1601        // Reverse each path so it reads top-down (entry point → ... → target)
1602        // and convert to TraceHop/TracePath
1603        let mut paths: Vec<TracePath> = complete_paths
1604            .into_iter()
1605            .map(|mut elems| {
1606                elems.reverse();
1607                let hops: Vec<TraceHop> = elems
1608                    .iter()
1609                    .enumerate()
1610                    .map(|(i, (file_path, sym, line, sig))| {
1611                        let is_ep = if i == 0 {
1612                            // First hop (after reverse) is the entry point
1613                            self.lookup_file_data(file_path.as_ref())
1614                                .and_then(|fd| {
1615                                    let meta = fd.symbol_metadata.get(sym.as_ref())?;
1616                                    Some(is_entry_point(
1617                                        sym.as_ref(),
1618                                        &meta.kind,
1619                                        meta.exported,
1620                                        fd.lang,
1621                                    ))
1622                                })
1623                                .unwrap_or(false)
1624                        } else {
1625                            false
1626                        };
1627                        TraceHop {
1628                            symbol: sym.to_string(),
1629                            file: self.relative_path(file_path.as_ref()),
1630                            line: *line,
1631                            signature: sig.clone(),
1632                            is_entry_point: is_ep,
1633                        }
1634                    })
1635                    .collect();
1636                TracePath { hops }
1637            })
1638            .collect();
1639
1640        // Sort paths for deterministic output (by entry point name, then path length)
1641        paths.sort_by(|a, b| {
1642            let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1643            let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1644            a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1645        });
1646
1647        // Count distinct entry points by identity, not just display name.
1648        let mut entry_points: HashSet<(String, String)> = HashSet::new();
1649        for p in &paths {
1650            if let Some(first) = p.hops.first() {
1651                if first.is_entry_point {
1652                    entry_points.insert((first.file.clone(), first.symbol.clone()));
1653                }
1654            }
1655        }
1656
1657        let total_paths = paths.len();
1658        let entry_points_found = entry_points.len();
1659
1660        Ok(TraceToResult {
1661            target_symbol: resolved_symbol,
1662            target_file: target_rel,
1663            paths,
1664            total_paths,
1665            entry_points_found,
1666            max_depth_reached,
1667            truncated_paths,
1668        })
1669    }
1670
1671    /// Find all files that define a symbol matching a `trace_to_symbol` target query.
1672    ///
1673    /// The result is de-duplicated by file because `toFile` is only required
1674    /// when a target symbol name exists in multiple files.
1675    pub fn trace_to_symbol_candidates(
1676        &mut self,
1677        to_symbol: &str,
1678        max_files: usize,
1679    ) -> Result<Vec<TraceToSymbolCandidate>, AftError> {
1680        self.ensure_project_files_built(max_files)?;
1681
1682        let mut candidates_by_file: HashMap<PathBuf, u32> = HashMap::new();
1683        let all_files = self.project_files().to_vec();
1684
1685        for file in all_files {
1686            let canon = self.canonicalize(&file)?;
1687            let Some(file_data) = self
1688                .lookup_file_data(&canon)
1689                .or_else(|| self.lookup_file_data(&file))
1690            else {
1691                continue;
1692            };
1693
1694            let symbol_candidates = symbol_query_candidates(file_data, to_symbol);
1695            if symbol_candidates.is_empty() {
1696                continue;
1697            }
1698
1699            let line = symbol_candidates
1700                .iter()
1701                .filter_map(|symbol| file_data.symbol_metadata.get(symbol).map(|meta| meta.line))
1702                .min()
1703                .unwrap_or(1);
1704
1705            candidates_by_file
1706                .entry(canon)
1707                .and_modify(|existing| *existing = (*existing).min(line))
1708                .or_insert(line);
1709        }
1710
1711        let mut candidates: Vec<TraceToSymbolCandidate> = candidates_by_file
1712            .into_iter()
1713            .map(|(file, line)| TraceToSymbolCandidate {
1714                file: self.relative_path(&file),
1715                line,
1716            })
1717            .collect();
1718        candidates.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
1719        Ok(candidates)
1720    }
1721
1722    /// Find the shortest forward call path from one symbol to another symbol.
1723    ///
1724    /// Performs breadth-first traversal over resolved call edges. A global
1725    /// `(file, symbol)` visited set keeps cycles finite while preserving BFS's
1726    /// shortest-path guarantee.
1727    pub fn trace_to_symbol(
1728        &mut self,
1729        file: &Path,
1730        symbol: &str,
1731        to_symbol: &str,
1732        to_file: Option<&Path>,
1733        max_depth: usize,
1734        max_files: usize,
1735    ) -> Result<TraceToSymbolResult, AftError> {
1736        let canon = self.canonicalize(file)?;
1737
1738        // Ensure the origin file is built and resolve scoped identity.
1739        let resolved_symbol = {
1740            let file_data = self.build_file(&canon)?;
1741            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1742        };
1743
1744        self.ensure_project_files_built(max_files)?;
1745
1746        let target_file = to_file.map(|path| self.canonicalize(path)).transpose()?;
1747        let effective_max = if max_depth == 0 {
1748            10
1749        } else {
1750            max_depth.min(16)
1751        };
1752
1753        let start_hop = self.trace_to_symbol_hop(&canon, &resolved_symbol);
1754        if Self::trace_to_symbol_matches_target(&canon, &resolved_symbol, to_symbol, &target_file) {
1755            return Ok(TraceToSymbolResult {
1756                path: Some(vec![start_hop]),
1757                complete: true,
1758                reason: None,
1759            });
1760        }
1761
1762        let mut queue: VecDeque<(PathBuf, String, Vec<TraceToSymbolHop>, usize)> = VecDeque::new();
1763        queue.push_back((canon.clone(), resolved_symbol.clone(), vec![start_hop], 0));
1764
1765        let mut visited: HashSet<(PathBuf, String)> = HashSet::new();
1766        visited.insert((canon, resolved_symbol));
1767        let mut max_depth_exhausted = false;
1768
1769        while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
1770            let callees = self.forward_resolved_callees(&current_file, &current_symbol)?;
1771
1772            if depth >= effective_max {
1773                if callees
1774                    .iter()
1775                    .any(|(file, symbol)| !visited.contains(&(file.clone(), symbol.clone())))
1776                {
1777                    max_depth_exhausted = true;
1778                }
1779                continue;
1780            }
1781
1782            for (callee_file, callee_symbol) in callees {
1783                let visit_key = (callee_file.clone(), callee_symbol.clone());
1784                if !visited.insert(visit_key) {
1785                    continue;
1786                }
1787
1788                let mut next_path = path.clone();
1789                next_path.push(self.trace_to_symbol_hop(&callee_file, &callee_symbol));
1790
1791                if Self::trace_to_symbol_matches_target(
1792                    &callee_file,
1793                    &callee_symbol,
1794                    to_symbol,
1795                    &target_file,
1796                ) {
1797                    return Ok(TraceToSymbolResult {
1798                        path: Some(next_path),
1799                        complete: true,
1800                        reason: None,
1801                    });
1802                }
1803
1804                queue.push_back((callee_file, callee_symbol, next_path, depth + 1));
1805            }
1806        }
1807
1808        if max_depth_exhausted {
1809            Ok(TraceToSymbolResult {
1810                path: None,
1811                complete: false,
1812                reason: Some("max_depth_exhausted".to_string()),
1813            })
1814        } else {
1815            Ok(TraceToSymbolResult {
1816                path: None,
1817                complete: true,
1818                reason: Some("no_path_found".to_string()),
1819            })
1820        }
1821    }
1822
1823    fn trace_to_symbol_matches_target(
1824        file: &Path,
1825        symbol: &str,
1826        to_symbol: &str,
1827        to_file: &Option<PathBuf>,
1828    ) -> bool {
1829        if !symbol_query_matches(symbol, to_symbol) {
1830            return false;
1831        }
1832
1833        if let Some(target_file) = to_file {
1834            file == target_file
1835        } else {
1836            true
1837        }
1838    }
1839
1840    fn trace_to_symbol_hop(&self, file: &Path, symbol: &str) -> TraceToSymbolHop {
1841        let (line, _) = self
1842            .lookup_file_data(file)
1843            .map(|data| get_symbol_meta_from_data(data, symbol))
1844            .unwrap_or_else(|| get_symbol_meta(file, symbol));
1845
1846        TraceToSymbolHop {
1847            symbol: symbol.to_string(),
1848            file: self.relative_path(file),
1849            line,
1850        }
1851    }
1852
1853    fn forward_resolved_callees(
1854        &mut self,
1855        file: &Path,
1856        symbol: &str,
1857    ) -> Result<Vec<(PathBuf, String)>, AftError> {
1858        let canon = self.canonicalize(file)?;
1859        let (import_block, call_sites) = {
1860            let file_data = self.build_file(&canon)?;
1861            (
1862                file_data.import_block.clone(),
1863                file_data
1864                    .calls_by_symbol
1865                    .get(symbol)
1866                    .cloned()
1867                    .unwrap_or_default(),
1868            )
1869        };
1870
1871        let mut callees = Vec::new();
1872        for call_site in call_sites {
1873            let edge = self.resolve_cross_file_edge(
1874                &call_site.full_callee,
1875                &call_site.callee_name,
1876                &canon,
1877                &import_block,
1878            );
1879
1880            match edge {
1881                EdgeResolution::Resolved {
1882                    file: target_file,
1883                    symbol: target_symbol,
1884                } => {
1885                    let target_canon = self.canonicalize(&target_file)?;
1886                    if self.build_file(&target_canon).is_err() {
1887                        continue;
1888                    }
1889
1890                    let resolved_target_symbol = self
1891                        .lookup_file_data(&target_canon)
1892                        .and_then(|data| {
1893                            resolve_symbol_query_in_data(data, &target_canon, &target_symbol).ok()
1894                        })
1895                        .unwrap_or(target_symbol);
1896
1897                    callees.push((target_canon, resolved_target_symbol));
1898                }
1899                EdgeResolution::Unresolved { callee_name } => {
1900                    if !is_bare_callee(&call_site.full_callee, &callee_name) {
1901                        continue;
1902                    }
1903
1904                    let local_symbol = self.lookup_file_data(&canon).and_then(|data| {
1905                        resolve_symbol_query_in_data(data, &canon, &callee_name).ok()
1906                    });
1907
1908                    if let Some(local_symbol) = local_symbol {
1909                        callees.push((canon.clone(), local_symbol));
1910                    }
1911                }
1912            }
1913        }
1914
1915        Ok(callees)
1916    }
1917
1918    /// Impact analysis: enriched callers query.
1919    ///
1920    /// Returns all call sites affected by a change to the given symbol,
1921    /// annotated with each caller's signature, entry point status, the
1922    /// source line at the call site, and extracted parameter names.
1923    pub fn impact(
1924        &mut self,
1925        file: &Path,
1926        symbol: &str,
1927        depth: usize,
1928        max_files: usize,
1929    ) -> Result<ImpactResult, AftError> {
1930        let canon = self.canonicalize(file)?;
1931
1932        // Ensure file is built and resolve scoped identity.
1933        let resolved_symbol = {
1934            let file_data = self.build_file(&canon)?;
1935            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1936        };
1937
1938        // Build the reverse index if not cached
1939        if self.reverse_index.is_none() {
1940            self.build_reverse_index(max_files)?;
1941        }
1942
1943        let effective_depth = if depth == 0 { 1 } else { depth };
1944
1945        // Get the target symbol's own metadata
1946        let (target_signature, target_parameters, target_lang) = {
1947            let file_data = match self.data.get(&canon) {
1948                Some(d) => d,
1949                None => {
1950                    return Err(AftError::InvalidRequest {
1951                        message: "file data missing after build".to_string(),
1952                    })
1953                }
1954            };
1955            let meta = file_data.symbol_metadata.get(&resolved_symbol);
1956            let sig = meta.and_then(|m| m.signature.clone());
1957            let lang = file_data.lang;
1958            let params = sig
1959                .as_deref()
1960                .map(|s| extract_parameters(s, lang))
1961                .unwrap_or_default();
1962            (sig, params, lang)
1963        };
1964
1965        // Collect all caller sites (transitive)
1966        let mut visited = HashSet::new();
1967        let mut all_sites: Vec<CallerSite> = Vec::new();
1968        let mut depth_limited = false;
1969        let mut truncated = 0;
1970        self.collect_callers_recursive(
1971            &canon,
1972            &resolved_symbol,
1973            effective_depth,
1974            0,
1975            &mut visited,
1976            &mut all_sites,
1977            &mut depth_limited,
1978            &mut truncated,
1979        );
1980
1981        // Deduplicate sites by (file, symbol, line)
1982        let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1983        all_sites.retain(|site| {
1984            seen.insert((
1985                site.caller_file.clone(),
1986                site.caller_symbol.clone(),
1987                site.line,
1988            ))
1989        });
1990
1991        // Enrich each caller site
1992        let mut callers = Vec::new();
1993        let mut affected_file_set = HashSet::new();
1994
1995        for site in &all_sites {
1996            // Build the caller's file to get metadata
1997            if let Err(e) = self.build_file(site.caller_file.as_path()) {
1998                log::debug!(
1999                    "callgraph: skipping caller file {}: {}",
2000                    site.caller_file.display(),
2001                    e
2002                );
2003            }
2004
2005            let (sig, is_ep, params, _lang) = {
2006                if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
2007                    let meta = fd.symbol_metadata.get(&site.caller_symbol);
2008                    let sig = meta.and_then(|m| m.signature.clone());
2009                    let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
2010                    let exported = meta.map(|m| m.exported).unwrap_or(false);
2011                    let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
2012                    let lang = fd.lang;
2013                    let params = sig
2014                        .as_deref()
2015                        .map(|s| extract_parameters(s, lang))
2016                        .unwrap_or_default();
2017                    (sig, is_ep, params, lang)
2018                } else {
2019                    (None, false, Vec::new(), target_lang)
2020                }
2021            };
2022
2023            // Read the source line at the call site
2024            let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
2025
2026            let rel_file = self.relative_path(site.caller_file.as_path());
2027            affected_file_set.insert(rel_file.clone());
2028
2029            callers.push(ImpactCaller {
2030                caller_symbol: site.caller_symbol.clone(),
2031                caller_file: rel_file,
2032                line: site.line,
2033                signature: sig,
2034                is_entry_point: is_ep,
2035                call_expression,
2036                parameters: params,
2037            });
2038        }
2039
2040        // Sort callers by file then line for deterministic output
2041        callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
2042
2043        let total_affected = callers.len();
2044        let affected_files = affected_file_set.len();
2045
2046        Ok(ImpactResult {
2047            symbol: resolved_symbol,
2048            file: self.relative_path(&canon),
2049            signature: target_signature,
2050            parameters: target_parameters,
2051            total_affected,
2052            affected_files,
2053            callers,
2054            depth_limited,
2055            truncated,
2056        })
2057    }
2058
2059    /// Trace how an expression flows through variable assignments within a
2060    /// function body and across function boundaries via argument-to-parameter
2061    /// matching.
2062    ///
2063    /// Algorithm:
2064    /// 1. Parse the function body, find the expression text.
2065    /// 2. Walk AST for assignments that reference the tracked name.
2066    /// 3. When the tracked name appears as a call argument, resolve the callee,
2067    ///    match argument position to parameter name, recurse.
2068    /// 4. Destructuring, spread, and unresolved calls produce approximate hops.
2069    pub fn trace_data(
2070        &mut self,
2071        file: &Path,
2072        symbol: &str,
2073        expression: &str,
2074        max_depth: usize,
2075        max_files: usize,
2076    ) -> Result<TraceDataResult, AftError> {
2077        let canon = self.canonicalize(file)?;
2078        let rel_file = self.relative_path(&canon);
2079
2080        // Ensure file data is built and resolve scoped identity.
2081        let resolved_symbol = {
2082            let file_data = self.build_file(&canon)?;
2083            resolve_symbol_query_in_data(file_data, &canon, symbol)?
2084        };
2085
2086        // Bounded count: short-circuits at `max_files + 1` so oversized roots
2087        // reject in microseconds instead of paying the full walk/collect cost.
2088        // Matches the guard used by build_reverse_index / callers_of / trace_to / impact.
2089        let count = self.project_file_count_bounded(max_files);
2090        if count > max_files {
2091            return Err(AftError::ProjectTooLarge {
2092                count,
2093                max: max_files,
2094            });
2095        }
2096
2097        let mut hops = Vec::new();
2098        let mut depth_limited = false;
2099
2100        self.trace_data_inner(
2101            &canon,
2102            &resolved_symbol,
2103            expression,
2104            max_depth,
2105            0,
2106            &mut hops,
2107            &mut depth_limited,
2108            &mut HashSet::new(),
2109        );
2110
2111        Ok(TraceDataResult {
2112            expression: expression.to_string(),
2113            origin_file: rel_file,
2114            origin_symbol: resolved_symbol,
2115            hops,
2116            depth_limited,
2117        })
2118    }
2119
2120    /// Inner recursive data flow tracking.
2121    fn trace_data_inner(
2122        &mut self,
2123        file: &Path,
2124        symbol: &str,
2125        tracking_name: &str,
2126        max_depth: usize,
2127        current_depth: usize,
2128        hops: &mut Vec<DataFlowHop>,
2129        depth_limited: &mut bool,
2130        visited: &mut HashSet<(PathBuf, String, String)>,
2131    ) {
2132        let visit_key = (
2133            file.to_path_buf(),
2134            symbol.to_string(),
2135            tracking_name.to_string(),
2136        );
2137        if visited.contains(&visit_key) {
2138            return; // cycle
2139        }
2140        visited.insert(visit_key);
2141
2142        // Read and parse the file
2143        let source = match std::fs::read_to_string(file) {
2144            Ok(s) => s,
2145            Err(_) => return,
2146        };
2147
2148        let lang = match detect_language(file) {
2149            Some(l) => l,
2150            None => return,
2151        };
2152
2153        let grammar = grammar_for(lang);
2154        let mut parser = Parser::new();
2155        if parser.set_language(&grammar).is_err() {
2156            return;
2157        }
2158        let tree = match parser.parse(&source, None) {
2159            Some(t) => t,
2160            None => return,
2161        };
2162
2163        // Find the symbol's AST node range
2164        let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
2165            Ok(symbols) => symbols,
2166            Err(_) => return,
2167        };
2168        let sym_info = match symbols
2169            .iter()
2170            .find(|s| symbol_identity(s) == symbol || s.name == symbol)
2171        {
2172            Some(s) => s,
2173            None => return,
2174        };
2175
2176        let body_start =
2177            line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
2178        let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
2179
2180        let root = tree.root_node();
2181
2182        // Find the symbol's body node (the function/method definition node)
2183        let body_node = match find_node_covering_range(root, body_start, body_end) {
2184            Some(n) => n,
2185            None => return,
2186        };
2187
2188        // Track names through the body
2189        let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
2190        let rel_file = self.relative_path(file);
2191
2192        // Walk the body looking for assignments and calls
2193        self.walk_for_data_flow(
2194            body_node,
2195            &source,
2196            &mut tracked_names,
2197            file,
2198            symbol,
2199            &rel_file,
2200            lang,
2201            max_depth,
2202            current_depth,
2203            hops,
2204            depth_limited,
2205            visited,
2206        );
2207    }
2208
2209    /// Walk an AST subtree looking for assignments and call expressions that
2210    /// reference tracked names.
2211    #[allow(clippy::too_many_arguments)]
2212    fn walk_for_data_flow(
2213        &mut self,
2214        node: tree_sitter::Node,
2215        source: &str,
2216        tracked_names: &mut Vec<String>,
2217        file: &Path,
2218        symbol: &str,
2219        rel_file: &str,
2220        lang: LangId,
2221        max_depth: usize,
2222        current_depth: usize,
2223        hops: &mut Vec<DataFlowHop>,
2224        depth_limited: &mut bool,
2225        visited: &mut HashSet<(PathBuf, String, String)>,
2226    ) {
2227        let kind = node.kind();
2228
2229        // Check for variable declarations / assignments
2230        let is_var_decl = matches!(
2231            kind,
2232            "variable_declarator"
2233                | "assignment_expression"
2234                | "augmented_assignment_expression"
2235                | "assignment"
2236                | "let_declaration"
2237                | "short_var_declaration"
2238        );
2239
2240        if is_var_decl {
2241            if let Some((new_name, init_text, line, is_approx)) =
2242                self.extract_assignment_info(node, source, lang, tracked_names)
2243            {
2244                // The RHS references a tracked name — add assignment hop
2245                if !is_approx {
2246                    hops.push(DataFlowHop {
2247                        file: rel_file.to_string(),
2248                        symbol: symbol.to_string(),
2249                        variable: new_name.clone(),
2250                        line,
2251                        flow_type: "assignment".to_string(),
2252                        approximate: false,
2253                    });
2254                    tracked_names.push(new_name);
2255                } else {
2256                    // Destructuring or pattern — approximate
2257                    hops.push(DataFlowHop {
2258                        file: rel_file.to_string(),
2259                        symbol: symbol.to_string(),
2260                        variable: init_text,
2261                        line,
2262                        flow_type: "assignment".to_string(),
2263                        approximate: true,
2264                    });
2265                    // Don't track further through this branch
2266                    return;
2267                }
2268            }
2269        }
2270
2271        // Check for call expressions where tracked name is an argument
2272        if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
2273            self.check_call_for_data_flow(
2274                node,
2275                source,
2276                tracked_names,
2277                file,
2278                symbol,
2279                rel_file,
2280                lang,
2281                max_depth,
2282                current_depth,
2283                hops,
2284                depth_limited,
2285                visited,
2286            );
2287        }
2288
2289        // Recurse into children
2290        let mut cursor = node.walk();
2291        if cursor.goto_first_child() {
2292            loop {
2293                let child = cursor.node();
2294                // Don't re-process the current node type in recursion
2295                self.walk_for_data_flow(
2296                    child,
2297                    source,
2298                    tracked_names,
2299                    file,
2300                    symbol,
2301                    rel_file,
2302                    lang,
2303                    max_depth,
2304                    current_depth,
2305                    hops,
2306                    depth_limited,
2307                    visited,
2308                );
2309                if !cursor.goto_next_sibling() {
2310                    break;
2311                }
2312            }
2313        }
2314    }
2315
2316    /// Check if an assignment/declaration node assigns from a tracked name.
2317    /// Returns (new_name, init_text, line, is_approximate).
2318    fn extract_assignment_info(
2319        &self,
2320        node: tree_sitter::Node,
2321        source: &str,
2322        _lang: LangId,
2323        tracked_names: &[String],
2324    ) -> Option<(String, String, u32, bool)> {
2325        let kind = node.kind();
2326        let line = node.start_position().row as u32 + 1;
2327
2328        match kind {
2329            "variable_declarator" => {
2330                // TS/JS: const x = <expr>
2331                let name_node = node.child_by_field_name("name")?;
2332                let value_node = node.child_by_field_name("value")?;
2333                let name_text = node_text(name_node, source);
2334                let value_text = node_text(value_node, source);
2335
2336                // Check if name is a destructuring pattern
2337                if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
2338                    // Check if value references a tracked name
2339                    if tracked_names.iter().any(|t| value_text.contains(t)) {
2340                        return Some((name_text.clone(), name_text, line, true));
2341                    }
2342                    return None;
2343                }
2344
2345                // Check if value references any tracked name
2346                if tracked_names.iter().any(|t| {
2347                    value_text == *t
2348                        || value_text.starts_with(&format!("{}.", t))
2349                        || value_text.starts_with(&format!("{}[", t))
2350                }) {
2351                    return Some((name_text, value_text, line, false));
2352                }
2353                None
2354            }
2355            "assignment_expression" | "augmented_assignment_expression" => {
2356                // TS/JS: x = <expr>
2357                let left = node.child_by_field_name("left")?;
2358                let right = node.child_by_field_name("right")?;
2359                let left_text = node_text(left, source);
2360                let right_text = node_text(right, source);
2361
2362                if tracked_names.iter().any(|t| right_text == *t) {
2363                    return Some((left_text, right_text, line, false));
2364                }
2365                None
2366            }
2367            "assignment" => {
2368                // Python: x = <expr>
2369                let left = node.child_by_field_name("left")?;
2370                let right = node.child_by_field_name("right")?;
2371                let left_text = node_text(left, source);
2372                let right_text = node_text(right, source);
2373
2374                if tracked_names.iter().any(|t| right_text == *t) {
2375                    return Some((left_text, right_text, line, false));
2376                }
2377                None
2378            }
2379            "let_declaration" | "short_var_declaration" => {
2380                // Rust / Go
2381                let left = node
2382                    .child_by_field_name("pattern")
2383                    .or_else(|| node.child_by_field_name("left"))?;
2384                let right = node
2385                    .child_by_field_name("value")
2386                    .or_else(|| node.child_by_field_name("right"))?;
2387                let left_text = node_text(left, source);
2388                let right_text = node_text(right, source);
2389
2390                if tracked_names.iter().any(|t| right_text == *t) {
2391                    return Some((left_text, right_text, line, false));
2392                }
2393                None
2394            }
2395            _ => None,
2396        }
2397    }
2398
2399    /// Check if a call expression uses a tracked name as an argument, and if so,
2400    /// resolve the callee and recurse into its body tracking the parameter name.
2401    #[allow(clippy::too_many_arguments)]
2402    fn check_call_for_data_flow(
2403        &mut self,
2404        node: tree_sitter::Node,
2405        source: &str,
2406        tracked_names: &[String],
2407        file: &Path,
2408        _symbol: &str,
2409        rel_file: &str,
2410        _lang: LangId,
2411        max_depth: usize,
2412        current_depth: usize,
2413        hops: &mut Vec<DataFlowHop>,
2414        depth_limited: &mut bool,
2415        visited: &mut HashSet<(PathBuf, String, String)>,
2416    ) {
2417        // Find the arguments node
2418        let args_node = find_child_by_kind(node, "arguments")
2419            .or_else(|| find_child_by_kind(node, "argument_list"));
2420
2421        let args_node = match args_node {
2422            Some(n) => n,
2423            None => return,
2424        };
2425
2426        // Collect argument texts and find which position a tracked name appears at
2427        let mut arg_positions: Vec<(usize, String)> = Vec::new(); // (position, tracked_name)
2428        let mut arg_idx = 0;
2429
2430        let mut cursor = args_node.walk();
2431        if cursor.goto_first_child() {
2432            loop {
2433                let child = cursor.node();
2434                let child_kind = child.kind();
2435
2436                // Skip punctuation (parentheses, commas)
2437                if child_kind == "(" || child_kind == ")" || child_kind == "," {
2438                    if !cursor.goto_next_sibling() {
2439                        break;
2440                    }
2441                    continue;
2442                }
2443
2444                let arg_text = node_text(child, source);
2445
2446                // Check for spread element — approximate
2447                if child_kind == "spread_element" || child_kind == "dictionary_splat" {
2448                    if tracked_names.iter().any(|t| arg_text.contains(t)) {
2449                        hops.push(DataFlowHop {
2450                            file: rel_file.to_string(),
2451                            symbol: _symbol.to_string(),
2452                            variable: arg_text,
2453                            line: child.start_position().row as u32 + 1,
2454                            flow_type: "parameter".to_string(),
2455                            approximate: true,
2456                        });
2457                    }
2458                    if !cursor.goto_next_sibling() {
2459                        break;
2460                    }
2461                    arg_idx += 1;
2462                    continue;
2463                }
2464
2465                if tracked_names.iter().any(|t| arg_text == *t) {
2466                    arg_positions.push((arg_idx, arg_text));
2467                }
2468
2469                arg_idx += 1;
2470                if !cursor.goto_next_sibling() {
2471                    break;
2472                }
2473            }
2474        }
2475
2476        if arg_positions.is_empty() {
2477            return;
2478        }
2479
2480        // Resolve the callee
2481        let (full_callee, short_callee) = extract_callee_names(node, source);
2482        let full_callee = match full_callee {
2483            Some(f) => f,
2484            None => return,
2485        };
2486        let short_callee = match short_callee {
2487            Some(s) => s,
2488            None => return,
2489        };
2490
2491        // Try to resolve cross-file edge
2492        let import_block = {
2493            match self.data.get(file) {
2494                Some(fd) => fd.import_block.clone(),
2495                None => return,
2496            }
2497        };
2498
2499        let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
2500
2501        match edge {
2502            EdgeResolution::Resolved {
2503                file: target_file,
2504                symbol: target_symbol,
2505            } => {
2506                if current_depth + 1 > max_depth {
2507                    *depth_limited = true;
2508                    return;
2509                }
2510
2511                // Build target file to get parameter info
2512                if let Err(e) = self.build_file(&target_file) {
2513                    log::debug!(
2514                        "callgraph: skipping target file {}: {}",
2515                        target_file.display(),
2516                        e
2517                    );
2518                }
2519                let (params, target_line) = {
2520                    match self.lookup_file_data(&target_file) {
2521                        Some(fd) => {
2522                            let meta = fd.symbol_metadata.get(&target_symbol);
2523                            let sig = meta.and_then(|m| m.signature.clone());
2524                            let params = sig
2525                                .as_deref()
2526                                .map(|s| extract_parameters(s, fd.lang))
2527                                .unwrap_or_default();
2528                            let line = meta.map(|m| m.line).unwrap_or(1);
2529                            (params, line)
2530                        }
2531                        None => return,
2532                    }
2533                };
2534
2535                let target_rel = self.relative_path(&target_file);
2536
2537                for (pos, _tracked) in &arg_positions {
2538                    if let Some(param_name) = params.get(*pos) {
2539                        // Add parameter hop
2540                        hops.push(DataFlowHop {
2541                            file: target_rel.clone(),
2542                            symbol: target_symbol.clone(),
2543                            variable: param_name.clone(),
2544                            line: target_line,
2545                            flow_type: "parameter".to_string(),
2546                            approximate: false,
2547                        });
2548
2549                        // Recurse into callee's body tracking the parameter name
2550                        self.trace_data_inner(
2551                            &target_file.clone(),
2552                            &target_symbol.clone(),
2553                            param_name,
2554                            max_depth,
2555                            current_depth + 1,
2556                            hops,
2557                            depth_limited,
2558                            visited,
2559                        );
2560                    }
2561                }
2562            }
2563            EdgeResolution::Unresolved { callee_name } => {
2564                let local_symbol = if is_bare_callee(&full_callee, &callee_name) {
2565                    self.data
2566                        .get(file)
2567                        .and_then(|fd| resolve_symbol_query_in_data(fd, file, &callee_name).ok())
2568                } else {
2569                    None
2570                };
2571
2572                if let Some(local_symbol) = local_symbol {
2573                    // Same-file bare call — get param info
2574                    let (params, target_line) = {
2575                        let Some(fd) = self.data.get(file) else {
2576                            return;
2577                        };
2578                        let meta = fd.symbol_metadata.get(&local_symbol);
2579                        let sig = meta.and_then(|m| m.signature.clone());
2580                        let params = sig
2581                            .as_deref()
2582                            .map(|s| extract_parameters(s, fd.lang))
2583                            .unwrap_or_default();
2584                        let line = meta.map(|m| m.line).unwrap_or(1);
2585                        (params, line)
2586                    };
2587
2588                    let file_rel = self.relative_path(file);
2589
2590                    for (pos, _tracked) in &arg_positions {
2591                        if let Some(param_name) = params.get(*pos) {
2592                            hops.push(DataFlowHop {
2593                                file: file_rel.clone(),
2594                                symbol: local_symbol.clone(),
2595                                variable: param_name.clone(),
2596                                line: target_line,
2597                                flow_type: "parameter".to_string(),
2598                                approximate: false,
2599                            });
2600
2601                            // Recurse into same-file function
2602                            self.trace_data_inner(
2603                                file,
2604                                &local_symbol,
2605                                param_name,
2606                                max_depth,
2607                                current_depth + 1,
2608                                hops,
2609                                depth_limited,
2610                                visited,
2611                            );
2612                        }
2613                    }
2614                } else {
2615                    // Truly unresolved — approximate hop
2616                    for (_pos, tracked) in &arg_positions {
2617                        hops.push(DataFlowHop {
2618                            file: self.relative_path(file),
2619                            symbol: callee_name.clone(),
2620                            variable: tracked.clone(),
2621                            line: node.start_position().row as u32 + 1,
2622                            flow_type: "parameter".to_string(),
2623                            approximate: true,
2624                        });
2625                    }
2626                }
2627            }
2628        }
2629    }
2630
2631    /// Read a single source line (1-based) from a file, trimmed.
2632    fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2633        let content = std::fs::read_to_string(path).ok()?;
2634        content
2635            .lines()
2636            .nth(line.saturating_sub(1) as usize)
2637            .map(|l| l.trim().to_string())
2638    }
2639
2640    /// Recursively collect callers up to the given depth.
2641    fn collect_callers_recursive(
2642        &self,
2643        file: &Path,
2644        symbol: &str,
2645        max_depth: usize,
2646        current_depth: usize,
2647        visited: &mut HashSet<(PathBuf, SharedStr)>,
2648        result: &mut Vec<CallerSite>,
2649        depth_limited: &mut bool,
2650        truncated: &mut usize,
2651    ) {
2652        // Canonicalize for consistent reverse index lookup
2653        let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2654        let key_symbol: SharedStr = Arc::from(symbol);
2655
2656        if current_depth >= max_depth {
2657            let omitted = self
2658                .reverse_sites(&canon, key_symbol.as_ref())
2659                .map(|sites| sites.len())
2660                .unwrap_or(0);
2661            if omitted > 0 {
2662                *depth_limited = true;
2663                *truncated += omitted;
2664            }
2665            return;
2666        }
2667
2668        if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2669            return; // cycle detection
2670        }
2671
2672        if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2673            for site in sites {
2674                result.push(CallerSite {
2675                    caller_file: site.caller_file.as_ref().clone(),
2676                    caller_symbol: site.caller_symbol.to_string(),
2677                    line: site.line,
2678                    col: site.col,
2679                    resolved: site.resolved,
2680                });
2681                // Recurse: find callers of the caller
2682                if current_depth + 1 < max_depth {
2683                    self.collect_callers_recursive(
2684                        site.caller_file.as_ref(),
2685                        site.caller_symbol.as_ref(),
2686                        max_depth,
2687                        current_depth + 1,
2688                        visited,
2689                        result,
2690                        depth_limited,
2691                        truncated,
2692                    );
2693                } else {
2694                    let omitted = self
2695                        .reverse_sites(site.caller_file.as_ref(), site.caller_symbol.as_ref())
2696                        .map(|sites| sites.len())
2697                        .unwrap_or(0);
2698                    if omitted > 0 {
2699                        *depth_limited = true;
2700                        *truncated += omitted;
2701                    }
2702                }
2703            }
2704        }
2705    }
2706
2707    /// Invalidate a file: remove its cached data and clear the reverse index.
2708    ///
2709    /// Called by the file watcher when a file changes on disk. The reverse
2710    /// index is rebuilt lazily on the next `callers_of` call.
2711    pub fn invalidate_file(&mut self, path: &Path) {
2712        // Remove from data cache (try both as-is and canonicalized)
2713        self.data.remove(path);
2714        if let Ok(canon) = self.canonicalize(path) {
2715            self.data.remove(&canon);
2716        }
2717        // Clear the reverse index — it's stale
2718        self.reverse_index = None;
2719        // Clear project_files cache for create/remove events
2720        self.project_files = None;
2721        clear_workspace_package_cache();
2722    }
2723
2724    /// Return a path relative to the project root, or the absolute path if
2725    /// it's outside the project.
2726    fn relative_path(&self, path: &Path) -> String {
2727        path.strip_prefix(&self.project_root)
2728            .unwrap_or(path)
2729            .display()
2730            .to_string()
2731    }
2732
2733    /// Canonicalize a path, falling back to the original if canonicalization fails.
2734    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2735        // If the path is relative, resolve it against project_root
2736        let full_path = if path.is_relative() {
2737            self.project_root.join(path)
2738        } else {
2739            path.to_path_buf()
2740        };
2741
2742        // Try canonicalize, fall back to the full path
2743        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2744    }
2745
2746    /// Look up cached file data, trying both the given path and its
2747    /// canonicalized form. Needed because `build_reverse_index` may store
2748    /// data under raw walker paths while CallerSite uses canonical paths.
2749    fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2750        if let Some(fd) = self.data.get(path) {
2751            return Some(fd);
2752        }
2753        // Try canonical
2754        let canon = std::fs::canonicalize(path).ok()?;
2755        self.data.get(&canon).or_else(|| {
2756            // Try non-canonical forms stored by the walker
2757            self.data.iter().find_map(|(k, v)| {
2758                if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2759                    Some(v)
2760                } else {
2761                    None
2762                }
2763            })
2764        })
2765    }
2766}
2767
2768// ---------------------------------------------------------------------------
2769// File-level building
2770// ---------------------------------------------------------------------------
2771
2772/// Build call data for a single file.
2773fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2774    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2775        message: format!("unsupported file for call graph: {}", path.display()),
2776    })?;
2777
2778    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2779        path: format!("{}: {}", path.display(), e),
2780    })?;
2781
2782    let grammar = grammar_for(lang);
2783    let mut parser = Parser::new();
2784    parser
2785        .set_language(&grammar)
2786        .map_err(|e| AftError::ParseError {
2787            message: format!("grammar init failed for {:?}: {}", lang, e),
2788        })?;
2789
2790    let tree = parser
2791        .parse(&source, None)
2792        .ok_or_else(|| AftError::ParseError {
2793            message: format!("parse failed for {}", path.display()),
2794        })?;
2795
2796    // Parse imports
2797    let import_block = imports::parse_imports(&source, &tree, lang);
2798
2799    // Get symbols (for call site extraction and export detection)
2800    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2801
2802    // Build calls_by_symbol
2803    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2804    let root = tree.root_node();
2805
2806    for sym in &symbols {
2807        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2808        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2809
2810        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2811
2812        let sites: Vec<CallSite> = raw_calls
2813            .into_iter()
2814            .map(|(full, short, line)| CallSite {
2815                callee_name: short,
2816                full_callee: full,
2817                line,
2818                byte_start,
2819                byte_end,
2820            })
2821            .collect();
2822
2823        if !sites.is_empty() {
2824            calls_by_symbol.insert(symbol_identity(sym), sites);
2825        }
2826    }
2827
2828    let symbol_ranges: Vec<(usize, usize)> = symbols
2829        .iter()
2830        .map(|sym| {
2831            (
2832                line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2833                line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2834            )
2835        })
2836        .collect();
2837
2838    let top_level_sites: Vec<CallSite> =
2839        collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2840            .into_iter()
2841            .filter(|site| {
2842                !symbol_ranges
2843                    .iter()
2844                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2845            })
2846            .map(|site| CallSite {
2847                callee_name: site.short,
2848                full_callee: site.full,
2849                line: site.line,
2850                byte_start: site.byte_start,
2851                byte_end: site.byte_end,
2852            })
2853            .collect();
2854
2855    if !top_level_sites.is_empty() {
2856        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2857    }
2858
2859    let default_export = find_default_export(&source, root, path, lang);
2860
2861    if let Some(default_export) = &default_export {
2862        if default_export.synthetic {
2863            let byte_start = default_export.node.byte_range().start;
2864            let byte_end = default_export.node.byte_range().end;
2865            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2866            let sites: Vec<CallSite> = raw_calls
2867                .into_iter()
2868                .filter(|(_, short, _)| *short != default_export.symbol)
2869                .map(|(full, short, line)| CallSite {
2870                    callee_name: short,
2871                    full_callee: full,
2872                    line,
2873                    byte_start,
2874                    byte_end,
2875                })
2876                .collect();
2877            if !sites.is_empty() {
2878                calls_by_symbol.insert(default_export.symbol.clone(), sites);
2879            }
2880        }
2881    }
2882
2883    // Collect exported symbol names
2884    let mut exported_symbols: Vec<String> = symbols
2885        .iter()
2886        .filter(|s| s.exported)
2887        .map(|s| s.name.clone())
2888        .collect();
2889    if let Some(default_export) = &default_export {
2890        if !exported_symbols
2891            .iter()
2892            .any(|name| name == &default_export.symbol)
2893        {
2894            exported_symbols.push(default_export.symbol.clone());
2895        }
2896    }
2897
2898    // Build per-symbol metadata for entry point detection
2899    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2900        .iter()
2901        .map(|s| {
2902            (
2903                symbol_identity(s),
2904                SymbolMeta {
2905                    kind: s.kind.clone(),
2906                    exported: s.exported,
2907                    signature: s.signature.clone(),
2908                    line: s.range.start_line + 1,
2909                    range: s.range.clone(),
2910                },
2911            )
2912        })
2913        .collect();
2914    if let Some(default_export) = &default_export {
2915        symbol_metadata
2916            .entry(default_export.symbol.clone())
2917            .or_insert_with(|| SymbolMeta {
2918                kind: default_export.kind.clone(),
2919                exported: true,
2920                signature: Some(first_line_signature(&source, &default_export.node)),
2921                line: default_export.node.start_position().row as u32 + 1,
2922                range: crate::parser::node_range(&default_export.node),
2923            });
2924    }
2925    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2926        symbol_metadata
2927            .entry(TOP_LEVEL_SYMBOL.to_string())
2928            .or_insert(SymbolMeta {
2929                kind: SymbolKind::Function,
2930                exported: false,
2931                signature: None,
2932                line: 1,
2933                range: Range {
2934                    start_line: 0,
2935                    start_col: 0,
2936                    end_line: 0,
2937                    end_col: 0,
2938                },
2939            });
2940    }
2941
2942    Ok(FileCallData {
2943        calls_by_symbol,
2944        exported_symbols,
2945        symbol_metadata,
2946        default_export_symbol: default_export.map(|export| export.symbol),
2947        import_block,
2948        lang,
2949    })
2950}
2951
2952#[derive(Debug, Clone)]
2953struct DefaultExport<'tree> {
2954    symbol: String,
2955    synthetic: bool,
2956    kind: SymbolKind,
2957    node: Node<'tree>,
2958}
2959
2960fn find_default_export<'tree>(
2961    source: &str,
2962    root: Node<'tree>,
2963    path: &Path,
2964    lang: LangId,
2965) -> Option<DefaultExport<'tree>> {
2966    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2967        return None;
2968    }
2969    find_default_export_inner(source, root, path)
2970}
2971
2972fn find_default_export_inner<'tree>(
2973    source: &str,
2974    node: Node<'tree>,
2975    path: &Path,
2976) -> Option<DefaultExport<'tree>> {
2977    if node.kind() == "export_statement" {
2978        if let Some(default_export) = default_export_from_statement(source, node, path) {
2979            return Some(default_export);
2980        }
2981    }
2982
2983    let mut cursor = node.walk();
2984    if !cursor.goto_first_child() {
2985        return None;
2986    }
2987
2988    loop {
2989        let child = cursor.node();
2990        if let Some(default_export) = find_default_export_inner(source, child, path) {
2991            return Some(default_export);
2992        }
2993        if !cursor.goto_next_sibling() {
2994            break;
2995        }
2996    }
2997
2998    None
2999}
3000
3001fn default_export_from_statement<'tree>(
3002    source: &str,
3003    node: Node<'tree>,
3004    path: &Path,
3005) -> Option<DefaultExport<'tree>> {
3006    let mut cursor = node.walk();
3007    if !cursor.goto_first_child() {
3008        return None;
3009    }
3010
3011    let mut saw_default = false;
3012    loop {
3013        let child = cursor.node();
3014        match child.kind() {
3015            "default" => saw_default = true,
3016            "function_declaration" | "generator_function_declaration" | "class_declaration"
3017                if saw_default =>
3018            {
3019                if let Some(name_node) = child.child_by_field_name("name") {
3020                    return Some(DefaultExport {
3021                        symbol: source[name_node.byte_range()].to_string(),
3022                        synthetic: false,
3023                        kind: default_export_kind(&child),
3024                        node: child,
3025                    });
3026                }
3027                return Some(DefaultExport {
3028                    symbol: synthetic_default_symbol(path),
3029                    synthetic: true,
3030                    kind: default_export_kind(&child),
3031                    node: child,
3032                });
3033            }
3034            "arrow_function"
3035            | "function"
3036            | "function_expression"
3037            | "class"
3038            | "class_expression"
3039                if saw_default =>
3040            {
3041                return Some(DefaultExport {
3042                    symbol: synthetic_default_symbol(path),
3043                    synthetic: true,
3044                    kind: default_export_kind(&child),
3045                    node: child,
3046                });
3047            }
3048            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
3049                return Some(DefaultExport {
3050                    symbol: source[child.byte_range()].to_string(),
3051                    synthetic: false,
3052                    kind: SymbolKind::Function,
3053                    node: child,
3054                });
3055            }
3056            _ => {}
3057        }
3058        if !cursor.goto_next_sibling() {
3059            break;
3060        }
3061    }
3062
3063    None
3064}
3065
3066fn default_export_kind(node: &Node) -> SymbolKind {
3067    if node.kind().contains("class") {
3068        SymbolKind::Class
3069    } else {
3070        SymbolKind::Function
3071    }
3072}
3073
3074fn synthetic_default_symbol(path: &Path) -> String {
3075    let file_name = path
3076        .file_name()
3077        .and_then(|name| name.to_str())
3078        .unwrap_or("unknown");
3079    format!("<default:{file_name}>")
3080}
3081
3082fn first_line_signature(source: &str, node: &Node) -> String {
3083    let text = &source[node.byte_range()];
3084    let first_line = text.lines().next().unwrap_or(text);
3085    first_line
3086        .trim_end()
3087        .trim_end_matches('{')
3088        .trim_end()
3089        .to_string()
3090}
3091
3092fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
3093    file_data
3094        .symbol_metadata
3095        .get(symbol_name)
3096        .map(|meta| (meta.line, meta.signature.clone()))
3097        .unwrap_or((1, None))
3098}
3099
3100/// Get symbol metadata (line, signature) from a file.
3101fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
3102    let provider = crate::parser::TreeSitterProvider::new();
3103    match provider.list_symbols(path) {
3104        Ok(symbols) => {
3105            for s in &symbols {
3106                if symbol_identity(s) == symbol_name || s.name == symbol_name {
3107                    return (s.range.start_line + 1, s.signature.clone());
3108                }
3109            }
3110            (1, None)
3111        }
3112        Err(_) => (1, None),
3113    }
3114}
3115
3116// ---------------------------------------------------------------------------
3117// Data flow tracking helpers
3118// ---------------------------------------------------------------------------
3119
3120/// Get the text of a tree-sitter node from the source.
3121fn node_text(node: tree_sitter::Node, source: &str) -> String {
3122    source[node.start_byte()..node.end_byte()].to_string()
3123}
3124
3125/// Find the smallest node that fully covers a byte range.
3126fn find_node_covering_range(
3127    root: tree_sitter::Node,
3128    start: usize,
3129    end: usize,
3130) -> Option<tree_sitter::Node> {
3131    let mut best = None;
3132    let mut cursor = root.walk();
3133
3134    fn walk_covering<'a>(
3135        cursor: &mut tree_sitter::TreeCursor<'a>,
3136        start: usize,
3137        end: usize,
3138        best: &mut Option<tree_sitter::Node<'a>>,
3139    ) {
3140        let node = cursor.node();
3141        if node.start_byte() <= start && node.end_byte() >= end {
3142            *best = Some(node);
3143            if cursor.goto_first_child() {
3144                loop {
3145                    walk_covering(cursor, start, end, best);
3146                    if !cursor.goto_next_sibling() {
3147                        break;
3148                    }
3149                }
3150                cursor.goto_parent();
3151            }
3152        }
3153    }
3154
3155    walk_covering(&mut cursor, start, end, &mut best);
3156    best
3157}
3158
3159/// Find a direct child node by kind name.
3160fn find_child_by_kind<'a>(
3161    node: tree_sitter::Node<'a>,
3162    kind: &str,
3163) -> Option<tree_sitter::Node<'a>> {
3164    let mut cursor = node.walk();
3165    if cursor.goto_first_child() {
3166        loop {
3167            if cursor.node().kind() == kind {
3168                return Some(cursor.node());
3169            }
3170            if !cursor.goto_next_sibling() {
3171                break;
3172            }
3173        }
3174    }
3175    None
3176}
3177
3178#[derive(Debug, Clone)]
3179struct CallSiteWithRange {
3180    full: String,
3181    short: String,
3182    line: u32,
3183    byte_start: usize,
3184    byte_end: usize,
3185}
3186
3187fn collect_calls_full_with_ranges(
3188    root: tree_sitter::Node,
3189    source: &str,
3190    byte_start: usize,
3191    byte_end: usize,
3192    lang: LangId,
3193) -> Vec<CallSiteWithRange> {
3194    let mut results = Vec::new();
3195    let call_kinds = call_node_kinds(lang);
3196    collect_calls_full_with_ranges_inner(
3197        root,
3198        source,
3199        byte_start,
3200        byte_end,
3201        &call_kinds,
3202        &mut results,
3203    );
3204    results
3205}
3206
3207fn collect_calls_full_with_ranges_inner(
3208    node: tree_sitter::Node,
3209    source: &str,
3210    byte_start: usize,
3211    byte_end: usize,
3212    call_kinds: &[&str],
3213    results: &mut Vec<CallSiteWithRange>,
3214) {
3215    let node_start = node.start_byte();
3216    let node_end = node.end_byte();
3217
3218    if node_end <= byte_start || node_start >= byte_end {
3219        return;
3220    }
3221
3222    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
3223        if let (Some(full), Some(short)) = (
3224            extract_full_callee(&node, source),
3225            extract_callee_name(&node, source),
3226        ) {
3227            results.push(CallSiteWithRange {
3228                full,
3229                short,
3230                line: node.start_position().row as u32 + 1,
3231                byte_start: node_start,
3232                byte_end: node_end,
3233            });
3234        }
3235    }
3236
3237    let mut cursor = node.walk();
3238    if cursor.goto_first_child() {
3239        loop {
3240            collect_calls_full_with_ranges_inner(
3241                cursor.node(),
3242                source,
3243                byte_start,
3244                byte_end,
3245                call_kinds,
3246                results,
3247            );
3248            if !cursor.goto_next_sibling() {
3249                break;
3250            }
3251        }
3252    }
3253}
3254
3255/// Extract full and short callee names from a call_expression node.
3256fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
3257    // The "function" field holds the callee
3258    let callee = match node.child_by_field_name("function") {
3259        Some(c) => c,
3260        None => return (None, None),
3261    };
3262
3263    let full = node_text(callee, source);
3264    let short = if full.contains('.') {
3265        full.rsplit('.').next().unwrap_or(&full).to_string()
3266    } else {
3267        full.clone()
3268    };
3269
3270    (Some(full), Some(short))
3271}
3272
3273// ---------------------------------------------------------------------------
3274// Module path resolution
3275// ---------------------------------------------------------------------------
3276
3277/// Resolve a module path (e.g. './utils') relative to a directory.
3278///
3279/// Tries common file extensions for TypeScript/JavaScript projects.
3280pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3281    if module_path.starts_with('.') {
3282        return resolve_relative_module_path(from_dir, module_path);
3283    }
3284
3285    if module_path.starts_with('/') {
3286        return None;
3287    }
3288
3289    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
3290        return Some(path);
3291    }
3292
3293    resolve_workspace_module_path(from_dir, module_path)
3294}
3295
3296fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3297    let base = from_dir.join(module_path);
3298    resolve_file_like_path(&base)
3299}
3300
3301fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
3302    let base = base.to_path_buf();
3303
3304    // Try exact path first
3305    if base.is_file() {
3306        return Some(std::fs::canonicalize(&base).unwrap_or(base));
3307    }
3308
3309    // Try common extensions, including ESM/CJS TypeScript pairs used by workspaces.
3310    for ext in JS_TS_EXTENSIONS {
3311        let with_ext = base.with_extension(ext);
3312        if with_ext.is_file() {
3313            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
3314        }
3315    }
3316
3317    // Try as directory with index file
3318    if base.is_dir() {
3319        if let Some(index) = find_index_file(&base) {
3320            return Some(index);
3321        }
3322    }
3323
3324    None
3325}
3326
3327fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3328    let (package_name, subpath) = split_package_import(module_path)?;
3329    let package_root = find_package_root_for_import(from_dir, &package_name)?;
3330    resolve_package_entry(&package_root, &subpath)
3331}
3332
3333fn is_rust_source_file(path: &Path) -> bool {
3334    path.extension().and_then(|ext| ext.to_str()) == Some("rs")
3335}
3336
3337fn resolve_rust_cross_file_edge<F>(
3338    full_callee: &str,
3339    short_name: &str,
3340    caller_file: &Path,
3341    import_block: &ImportBlock,
3342    file_exports_symbol: &mut F,
3343) -> Option<ResolvedSymbol>
3344where
3345    F: FnMut(&Path, &str) -> bool,
3346{
3347    if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
3348    {
3349        return Some(target);
3350    }
3351
3352    resolve_rust_imported_call(
3353        caller_file,
3354        full_callee,
3355        short_name,
3356        import_block,
3357        file_exports_symbol,
3358    )
3359}
3360
3361fn resolve_rust_qualified_call<F>(
3362    caller_file: &Path,
3363    full_callee: &str,
3364    file_exports_symbol: &mut F,
3365) -> Option<ResolvedSymbol>
3366where
3367    F: FnMut(&Path, &str) -> bool,
3368{
3369    if !full_callee.contains("::") {
3370        return None;
3371    }
3372
3373    let segments = rust_path_segments(full_callee)?;
3374    resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
3375}
3376
3377fn resolve_rust_imported_call<F>(
3378    caller_file: &Path,
3379    full_callee: &str,
3380    short_name: &str,
3381    import_block: &ImportBlock,
3382    file_exports_symbol: &mut F,
3383) -> Option<ResolvedSymbol>
3384where
3385    F: FnMut(&Path, &str) -> bool,
3386{
3387    let call_segments = rust_path_segments(full_callee).unwrap_or_default();
3388    let bare_call_name = if call_segments.len() <= 1 {
3389        call_segments
3390            .first()
3391            .map(String::as_str)
3392            .unwrap_or(short_name)
3393    } else {
3394        short_name
3395    };
3396
3397    for imp in &import_block.imports {
3398        for entry in rust_use_entries(imp) {
3399            match &entry.kind {
3400                RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
3401                    if entry.local_name != bare_call_name {
3402                        continue;
3403                    }
3404                    let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
3405                    else {
3406                        continue;
3407                    };
3408                    if file_exports_symbol(&file, imported_name) {
3409                        return Some(ResolvedSymbol {
3410                            file,
3411                            symbol: imported_name.clone(),
3412                        });
3413                    }
3414                }
3415                RustUseKind::Module if call_segments.len() >= 2 => {
3416                    if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
3417                    {
3418                        continue;
3419                    }
3420                    let symbol = call_segments.last()?.clone();
3421                    let mut module_path = entry.module_path.clone();
3422                    for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
3423                        module_path.push_str("::");
3424                        module_path.push_str(segment);
3425                    }
3426                    let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
3427                        continue;
3428                    };
3429                    if file_exports_symbol(&file, &symbol) {
3430                        return Some(ResolvedSymbol { file, symbol });
3431                    }
3432                }
3433                _ => {}
3434            }
3435        }
3436    }
3437
3438    None
3439}
3440
3441fn resolve_rust_call_segments<F>(
3442    caller_file: &Path,
3443    segments: &[String],
3444    file_exports_symbol: &mut F,
3445) -> Option<ResolvedSymbol>
3446where
3447    F: FnMut(&Path, &str) -> bool,
3448{
3449    if segments.len() < 2 {
3450        return None;
3451    }
3452
3453    let symbol = segments.last()?.clone();
3454    let module_path = segments[..segments.len() - 1].join("::");
3455    let file = resolve_rust_module_path(caller_file, &module_path)?;
3456    if file_exports_symbol(&file, &symbol) {
3457        Some(ResolvedSymbol { file, symbol })
3458    } else {
3459        None
3460    }
3461}
3462
3463fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
3464    let segments = rust_path_segments(module_path)?;
3465    let first = segments.first()?.as_str();
3466
3467    match first {
3468        "std" | "core" | "alloc" => None,
3469        "crate" => {
3470            let crate_root = find_rust_crate_root(caller_file)?;
3471            let crate_info = rust_crate_info(&crate_root)?;
3472            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3473            resolve_rust_module_segments(&base, &segments[1..])
3474        }
3475        "self" => {
3476            let crate_root = find_rust_crate_root(caller_file)?;
3477            let crate_info = rust_crate_info(&crate_root)?;
3478            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3479            if segments.len() == 1 {
3480                return Some(canonicalize_path(caller_file));
3481            }
3482            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3483            target_segments.extend(segments[1..].iter().cloned());
3484            resolve_rust_module_segments(&base, &target_segments)
3485        }
3486        "super" => {
3487            let crate_root = find_rust_crate_root(caller_file)?;
3488            let crate_info = rust_crate_info(&crate_root)?;
3489            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3490            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3491            target_segments.pop();
3492            target_segments.extend(segments[1..].iter().cloned());
3493            resolve_rust_module_segments(&base, &target_segments)
3494        }
3495        crate_name => {
3496            let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
3497            let workspace_crates = rust_workspace_crates(caller_dir)?;
3498            let crate_info = workspace_crates.get(crate_name)?;
3499            let base = rust_lib_module_base(crate_info)?;
3500            resolve_rust_module_segments(&base, &segments[1..])
3501        }
3502    }
3503}
3504
3505fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
3506    let Some(body) = rust_use_body(&imp.raw_text) else {
3507        return Vec::new();
3508    };
3509    let mut entries = Vec::new();
3510    expand_rust_use_tree(body, &mut entries);
3511    entries
3512}
3513
3514fn rust_use_body(raw: &str) -> Option<&str> {
3515    let use_pos = raw.find("use ")?;
3516    let body = raw[use_pos + 4..].trim();
3517    let body = body.strip_suffix(';').unwrap_or(body).trim();
3518    (!body.is_empty()).then_some(body)
3519}
3520
3521fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
3522    let path = path.trim();
3523    if path.is_empty() {
3524        return;
3525    }
3526
3527    if let Some((prefix, inner)) = split_rust_use_braces(path) {
3528        let prefix = prefix.trim().trim_end_matches("::").trim();
3529        for part in split_top_level_commas(inner) {
3530            let part = part.trim();
3531            if part.is_empty() {
3532                continue;
3533            }
3534            if part == "self" {
3535                if let Some(local_name) = rust_last_path_segment(prefix) {
3536                    entries.push(RustUseEntry {
3537                        module_path: prefix.to_string(),
3538                        local_name,
3539                        kind: RustUseKind::Module,
3540                    });
3541                }
3542                continue;
3543            }
3544            let combined = if prefix.is_empty() {
3545                part.to_string()
3546            } else {
3547                format!("{prefix}::{part}")
3548            };
3549            expand_rust_use_tree(&combined, entries);
3550        }
3551        return;
3552    }
3553
3554    add_rust_use_leaf(path, entries);
3555}
3556
3557fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
3558    let mut depth = 0usize;
3559    let mut start = None;
3560    for (idx, ch) in path.char_indices() {
3561        match ch {
3562            '{' => {
3563                if depth == 0 {
3564                    start = Some(idx);
3565                }
3566                depth += 1;
3567            }
3568            '}' => {
3569                depth = depth.checked_sub(1)?;
3570                if depth == 0 {
3571                    let start = start?;
3572                    if !path[idx + ch.len_utf8()..].trim().is_empty() {
3573                        return None;
3574                    }
3575                    return Some((&path[..start], &path[start + 1..idx]));
3576                }
3577            }
3578            _ => {}
3579        }
3580    }
3581    None
3582}
3583
3584fn split_top_level_commas(value: &str) -> Vec<&str> {
3585    let mut parts = Vec::new();
3586    let mut depth = 0usize;
3587    let mut start = 0usize;
3588    for (idx, ch) in value.char_indices() {
3589        match ch {
3590            '{' => depth += 1,
3591            '}' => depth = depth.saturating_sub(1),
3592            ',' if depth == 0 => {
3593                parts.push(&value[start..idx]);
3594                start = idx + ch.len_utf8();
3595            }
3596            _ => {}
3597        }
3598    }
3599    parts.push(&value[start..]);
3600    parts
3601}
3602
3603fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
3604    let (path, alias) = split_rust_alias(path);
3605    let Some(segments) = rust_path_segments(path) else {
3606        return;
3607    };
3608    if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
3609        return;
3610    }
3611
3612    let imported_name = segments.last().cloned().unwrap_or_default();
3613    let local_name = alias.unwrap_or(&imported_name).to_string();
3614    if segments.len() >= 2 {
3615        entries.push(RustUseEntry {
3616            module_path: segments[..segments.len() - 1].join("::"),
3617            local_name: local_name.clone(),
3618            kind: RustUseKind::Item {
3619                imported_name: imported_name.clone(),
3620            },
3621        });
3622    }
3623
3624    entries.push(RustUseEntry {
3625        module_path: segments.join("::"),
3626        local_name,
3627        kind: RustUseKind::Module,
3628    });
3629}
3630
3631fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
3632    if let Some(idx) = path.rfind(" as ") {
3633        let original = path[..idx].trim();
3634        let alias = path[idx + 4..].trim();
3635        if !original.is_empty() && !alias.is_empty() {
3636            return (original, Some(alias));
3637        }
3638    }
3639    (path.trim(), None)
3640}
3641
3642fn rust_path_segments(path: &str) -> Option<Vec<String>> {
3643    let path = path.trim().trim_end_matches(';').trim();
3644    if path.is_empty() || path.contains('{') || path.contains('}') {
3645        return None;
3646    }
3647
3648    let mut segments = Vec::new();
3649    for raw_segment in path.split("::") {
3650        let segment = raw_segment.trim();
3651        if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
3652            return None;
3653        }
3654        let segment = segment.strip_prefix("r#").unwrap_or(segment);
3655        if segment
3656            .chars()
3657            .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
3658        {
3659            return None;
3660        }
3661        segments.push(segment.to_string());
3662    }
3663
3664    (!segments.is_empty()).then_some(segments)
3665}
3666
3667fn rust_last_path_segment(path: &str) -> Option<String> {
3668    rust_path_segments(path)?.last().cloned()
3669}
3670
3671fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
3672    let mut current = if from.is_file() {
3673        from.parent()
3674    } else {
3675        Some(from)
3676    };
3677    while let Some(dir) = current {
3678        if dir.join("Cargo.toml").is_file() {
3679            return Some(canonicalize_path(dir));
3680        }
3681        current = dir.parent();
3682    }
3683    None
3684}
3685
3686fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3687    let root = canonicalize_path(crate_root);
3688    if let Some(cached) = RUST_CRATE_INFO_CACHE
3689        .read()
3690        .ok()
3691        .and_then(|cache| cache.get(&root).cloned())
3692    {
3693        return cached;
3694    }
3695
3696    let resolved = read_rust_crate_info(&root);
3697    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
3698        cache.insert(root, resolved.clone());
3699    }
3700    resolved
3701}
3702
3703fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3704    let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
3705    let package = cargo.get("package")?;
3706    let package_name = package.get("name")?.as_str()?;
3707    let lib_name = cargo
3708        .get("lib")
3709        .and_then(|lib| lib.get("name"))
3710        .and_then(|name| name.as_str())
3711        .map(ToOwned::to_owned)
3712        .unwrap_or_else(|| package_name.replace('-', "_"));
3713
3714    let lib_root = cargo
3715        .get("lib")
3716        .and_then(|lib| lib.get("path"))
3717        .and_then(|path| path.as_str())
3718        .map(|path| crate_root.join(path))
3719        .unwrap_or_else(|| crate_root.join("src/lib.rs"));
3720    let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
3721
3722    let main_root = crate_root.join("src/main.rs");
3723    let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
3724
3725    Some(RustCrateInfo {
3726        lib_name,
3727        lib_root,
3728        main_root,
3729    })
3730}
3731
3732fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
3733    let source = std::fs::read_to_string(path).ok()?;
3734    toml::from_str(&source).ok()
3735}
3736
3737fn rust_module_base_for_caller(
3738    crate_info: &RustCrateInfo,
3739    caller_file: &Path,
3740) -> Option<RustModuleBase> {
3741    let caller = canonicalize_path(caller_file);
3742    if crate_info.main_root.as_ref() == Some(&caller) {
3743        return rust_main_module_base(crate_info);
3744    }
3745    rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
3746}
3747
3748fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3749    let root_file = crate_info.lib_root.clone()?;
3750    let src_dir = root_file.parent()?.to_path_buf();
3751    Some(RustModuleBase { src_dir, root_file })
3752}
3753
3754fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3755    let root_file = crate_info.main_root.clone()?;
3756    let src_dir = root_file.parent()?.to_path_buf();
3757    Some(RustModuleBase { src_dir, root_file })
3758}
3759
3760fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
3761    if segments.is_empty() {
3762        return Some(base.root_file.clone());
3763    }
3764
3765    let module_base = segments
3766        .iter()
3767        .fold(base.src_dir.clone(), |path, segment| path.join(segment));
3768    let file_path = module_base.with_extension("rs");
3769    if file_path.is_file() {
3770        return Some(canonicalize_path(&file_path));
3771    }
3772
3773    let mod_path = module_base.join("mod.rs");
3774    if mod_path.is_file() {
3775        return Some(canonicalize_path(&mod_path));
3776    }
3777
3778    None
3779}
3780
3781fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
3782    let src_dir = canonicalize_path(src_dir);
3783    let file = canonicalize_path(file);
3784    let rel = file.strip_prefix(&src_dir).ok()?;
3785    let mut parts: Vec<String> = rel
3786        .components()
3787        .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
3788        .collect();
3789    if parts.is_empty() {
3790        return None;
3791    }
3792
3793    let last = parts.pop()?;
3794    if last == "lib.rs" || last == "main.rs" {
3795        return Some(Vec::new());
3796    }
3797    if last == "mod.rs" {
3798        return Some(parts);
3799    }
3800    let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
3801    parts.push(stem);
3802    Some(parts)
3803}
3804
3805fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
3806    let workspace_root =
3807        find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
3808    let workspace_root = canonicalize_path(&workspace_root);
3809
3810    if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
3811        .read()
3812        .ok()
3813        .and_then(|cache| cache.get(&workspace_root).cloned())
3814    {
3815        return Some(cached);
3816    }
3817
3818    let mut crates = HashMap::new();
3819    for member in rust_workspace_member_dirs(&workspace_root) {
3820        if let Some(info) = rust_crate_info(&member) {
3821            if info.lib_root.is_some() {
3822                crates.insert(info.lib_name.clone(), info);
3823            }
3824        }
3825    }
3826    if let Some(info) = rust_crate_info(&workspace_root) {
3827        if info.lib_root.is_some() {
3828            crates.insert(info.lib_name.clone(), info);
3829        }
3830    }
3831
3832    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
3833        cache.insert(workspace_root, crates.clone());
3834    }
3835    Some(crates)
3836}
3837
3838fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3839    let mut current = Some(from_dir);
3840    while let Some(dir) = current {
3841        let cargo = dir.join("Cargo.toml");
3842        if rust_manifest_value(&cargo)
3843            .and_then(|value| value.get("workspace").cloned())
3844            .is_some()
3845        {
3846            return Some(canonicalize_path(dir));
3847        }
3848        current = dir.parent();
3849    }
3850    None
3851}
3852
3853fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
3854    let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
3855        return Vec::new();
3856    };
3857    let Some(members) = cargo
3858        .get("workspace")
3859        .and_then(|workspace| workspace.get("members"))
3860        .and_then(|members| members.as_array())
3861    else {
3862        return Vec::new();
3863    };
3864
3865    let mut dirs = Vec::new();
3866    for member in members.iter().filter_map(|member| member.as_str()) {
3867        dirs.extend(expand_rust_workspace_member(workspace_root, member));
3868    }
3869    dirs.sort();
3870    dirs.dedup();
3871    dirs
3872}
3873
3874fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
3875    let member = member.trim();
3876    if member.is_empty() {
3877        return Vec::new();
3878    }
3879
3880    if member.contains('*') || member.contains('?') || member.contains('[') {
3881        let pattern = workspace_root.join(member).to_string_lossy().to_string();
3882        return glob::glob(&pattern)
3883            .ok()
3884            .into_iter()
3885            .flatten()
3886            .filter_map(Result::ok)
3887            .filter(|path| path.join("Cargo.toml").is_file())
3888            .map(|path| canonicalize_path(&path))
3889            .collect();
3890    }
3891
3892    let path = workspace_root.join(member);
3893    if path.join("Cargo.toml").is_file() {
3894        vec![canonicalize_path(&path)]
3895    } else {
3896        Vec::new()
3897    }
3898}
3899
3900fn canonicalize_path(path: &Path) -> PathBuf {
3901    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3902}
3903
3904fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3905    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
3906    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
3907    let compiler_options = tsconfig.get("compilerOptions")?;
3908    let paths = compiler_options.get("paths")?.as_object()?;
3909    let base_url = compiler_options
3910        .get("baseUrl")
3911        .and_then(Value::as_str)
3912        .unwrap_or(".");
3913    let base_dir = tsconfig_dir.join(base_url);
3914
3915    for (alias, targets) in paths {
3916        let Some(capture) = ts_path_capture(alias, module_path) else {
3917            continue;
3918        };
3919        let Some(targets) = targets.as_array() else {
3920            continue;
3921        };
3922        for target in targets.iter().filter_map(Value::as_str) {
3923            let target = if target.contains('*') {
3924                target.replace('*', capture)
3925            } else {
3926                target.to_string()
3927            };
3928            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
3929                return Some(path);
3930            }
3931        }
3932    }
3933
3934    None
3935}
3936
3937fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
3938    let mut current = Some(from_dir);
3939    while let Some(dir) = current {
3940        if dir.join("tsconfig.json").is_file() {
3941            return Some(dir.to_path_buf());
3942        }
3943        current = dir.parent();
3944    }
3945    None
3946}
3947
3948fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
3949    if let Some(star_index) = alias.find('*') {
3950        let (prefix, suffix_with_star) = alias.split_at(star_index);
3951        let suffix = &suffix_with_star[1..];
3952        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
3953            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
3954        }
3955        return None;
3956    }
3957
3958    (alias == module_path).then_some("")
3959}
3960
3961fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
3962    let mut parts = module_path.split('/');
3963    let first = parts.next()?;
3964    if first.is_empty() {
3965        return None;
3966    }
3967
3968    if first.starts_with('@') {
3969        let second = parts.next()?;
3970        if second.is_empty() {
3971            return None;
3972        }
3973        let package_name = format!("{first}/{second}");
3974        let subpath = parts.collect::<Vec<_>>().join("/");
3975        let subpath = (!subpath.is_empty()).then_some(subpath);
3976        Some((package_name, subpath))
3977    } else {
3978        let package_name = first.to_string();
3979        let subpath = parts.collect::<Vec<_>>().join("/");
3980        let subpath = (!subpath.is_empty()).then_some(subpath);
3981        Some((package_name, subpath))
3982    }
3983}
3984
3985fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
3986    let mut current = Some(from_dir);
3987    while let Some(dir) = current {
3988        if package_json_name(dir).as_deref() == Some(package_name) {
3989            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
3990        }
3991        current = dir.parent();
3992    }
3993
3994    find_workspace_root(from_dir)
3995        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
3996}
3997
3998fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3999    let mut current = Some(from_dir);
4000    while let Some(dir) = current {
4001        if is_workspace_root(dir) {
4002            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4003        }
4004        current = dir.parent();
4005    }
4006    None
4007}
4008
4009fn is_workspace_root(dir: &Path) -> bool {
4010    package_json_value(dir)
4011        .map(|value| !workspace_patterns(&value).is_empty())
4012        .unwrap_or(false)
4013        || !pnpm_workspace_patterns(dir).is_empty()
4014}
4015
4016fn clear_workspace_package_cache() {
4017    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4018        cache.clear();
4019    }
4020    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
4021        cache.clear();
4022    }
4023    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
4024        cache.clear();
4025    }
4026}
4027
4028fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
4029    let workspace_root =
4030        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
4031    let cache_key = (workspace_root.clone(), package_name.to_string());
4032
4033    if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
4034        if let Some(cached) = cache.get(&cache_key) {
4035            return cached.clone();
4036        }
4037    }
4038
4039    let resolved = workspace_member_dirs(&workspace_root)
4040        .into_iter()
4041        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
4042        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
4043
4044    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4045        cache.insert(cache_key, resolved.clone());
4046    }
4047
4048    resolved
4049}
4050
4051fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
4052    let mut patterns = package_json_value(workspace_root)
4053        .map(|package_json| workspace_patterns(&package_json))
4054        .unwrap_or_default();
4055    patterns.extend(pnpm_workspace_patterns(workspace_root));
4056
4057    expand_workspace_patterns(workspace_root, &patterns)
4058}
4059
4060fn workspace_patterns(package_json: &Value) -> Vec<String> {
4061    match package_json.get("workspaces") {
4062        Some(Value::Array(items)) => items
4063            .iter()
4064            .filter_map(non_empty_workspace_pattern)
4065            .collect(),
4066        Some(Value::Object(map)) => map
4067            .get("packages")
4068            .and_then(Value::as_array)
4069            .map(|items| {
4070                items
4071                    .iter()
4072                    .filter_map(non_empty_workspace_pattern)
4073                    .collect()
4074            })
4075            .unwrap_or_default(),
4076        _ => Vec::new(),
4077    }
4078}
4079
4080fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
4081    let pattern = value.as_str()?.trim();
4082    (!pattern.is_empty()).then(|| pattern.to_string())
4083}
4084
4085fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
4086    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
4087        return Vec::new();
4088    };
4089
4090    let mut patterns = Vec::new();
4091    let mut in_packages = false;
4092    for line in source.lines() {
4093        let without_comment = line.split('#').next().unwrap_or("").trim_end();
4094        let trimmed = without_comment.trim();
4095        if trimmed.is_empty() {
4096            continue;
4097        }
4098        if trimmed == "packages:" {
4099            in_packages = true;
4100            continue;
4101        }
4102        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
4103            in_packages = false;
4104        }
4105        if in_packages {
4106            if let Some(pattern) = trimmed.strip_prefix('-') {
4107                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
4108                if !pattern.is_empty() {
4109                    patterns.push(pattern.to_string());
4110                }
4111            }
4112        }
4113    }
4114    patterns
4115}
4116
4117fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
4118    let positive_patterns: Vec<&str> = patterns
4119        .iter()
4120        .map(|pattern| pattern.trim())
4121        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
4122        .collect();
4123    if positive_patterns.is_empty() {
4124        return Vec::new();
4125    }
4126
4127    let positives = build_glob_set(&positive_patterns);
4128    let negative_patterns: Vec<&str> = patterns
4129        .iter()
4130        .map(|pattern| pattern.trim())
4131        .filter_map(|pattern| pattern.strip_prefix('!'))
4132        .map(str::trim)
4133        .filter(|pattern| !pattern.is_empty())
4134        .collect();
4135    let negatives = build_glob_set(&negative_patterns);
4136
4137    let mut members = Vec::new();
4138    collect_workspace_member_dirs(
4139        workspace_root,
4140        workspace_root,
4141        &positives,
4142        &negatives,
4143        &mut members,
4144    );
4145    members
4146}
4147
4148fn build_glob_set(patterns: &[&str]) -> GlobSet {
4149    let mut builder = GlobSetBuilder::new();
4150    for pattern in patterns {
4151        if let Ok(glob) = Glob::new(pattern) {
4152            builder.add(glob);
4153        }
4154    }
4155    builder
4156        .build()
4157        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
4158}
4159
4160fn collect_workspace_member_dirs(
4161    workspace_root: &Path,
4162    dir: &Path,
4163    positives: &GlobSet,
4164    negatives: &GlobSet,
4165    members: &mut Vec<PathBuf>,
4166) {
4167    let Ok(entries) = std::fs::read_dir(dir) else {
4168        return;
4169    };
4170
4171    for entry in entries.filter_map(Result::ok) {
4172        let path = entry.path();
4173        let Ok(file_type) = entry.file_type() else {
4174            continue;
4175        };
4176        if !file_type.is_dir() {
4177            continue;
4178        }
4179        let name = entry.file_name();
4180        let name = name.to_string_lossy();
4181        if matches!(
4182            name.as_ref(),
4183            "node_modules" | ".git" | "target" | "dist" | "build"
4184        ) {
4185            continue;
4186        }
4187
4188        if path.join("package.json").is_file() {
4189            if let Ok(rel) = path.strip_prefix(workspace_root) {
4190                let rel = rel.to_string_lossy().replace('\\', "/");
4191                if positives.is_match(&rel) && !negatives.is_match(&rel) {
4192                    members.push(path.clone());
4193                }
4194            }
4195        }
4196
4197        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
4198    }
4199}
4200
4201fn package_json_value(dir: &Path) -> Option<Value> {
4202    package_json_like_value(&dir.join("package.json"))
4203}
4204
4205fn package_json_like_value(path: &Path) -> Option<Value> {
4206    let json = std::fs::read_to_string(path).ok()?;
4207    serde_json::from_str(&json).ok()
4208}
4209
4210fn package_json_name(dir: &Path) -> Option<String> {
4211    package_json_value(dir)?
4212        .get("name")?
4213        .as_str()
4214        .map(ToOwned::to_owned)
4215}
4216
4217fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
4218    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
4219
4220    if let Some(exports) = package_json.get("exports") {
4221        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
4222            if let Some(path) = resolve_package_target(package_root, &target) {
4223                return Some(path);
4224            }
4225        }
4226    }
4227
4228    if subpath.is_none() {
4229        for field in ["module", "main"] {
4230            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
4231                if let Some(path) = resolve_package_target(package_root, target) {
4232                    return Some(path);
4233                }
4234            }
4235        }
4236    }
4237
4238    resolve_package_fallback(package_root, subpath.as_deref())
4239}
4240
4241fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
4242    let key = subpath
4243        .map(|value| format!("./{value}"))
4244        .unwrap_or_else(|| ".".to_string());
4245
4246    match exports {
4247        Value::String(target) if key == "." => Some(target.clone()),
4248        Value::Object(map) => {
4249            if let Some(target) = map.get(&key).and_then(export_condition_target) {
4250                return Some(target);
4251            }
4252
4253            if let Some(target) = wildcard_export_target(map, &key) {
4254                return Some(target);
4255            }
4256
4257            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
4258                return export_condition_target(exports);
4259            }
4260
4261            None
4262        }
4263        _ => None,
4264    }
4265}
4266
4267fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
4268    for (pattern, target) in map {
4269        let Some(star_index) = pattern.find('*') else {
4270            continue;
4271        };
4272        let (prefix, suffix_with_star) = pattern.split_at(star_index);
4273        let suffix = &suffix_with_star[1..];
4274        if !key.starts_with(prefix) || !key.ends_with(suffix) {
4275            continue;
4276        }
4277        let matched = &key[prefix.len()..key.len() - suffix.len()];
4278        if let Some(target_pattern) = export_condition_target(target) {
4279            return Some(target_pattern.replace('*', matched));
4280        }
4281    }
4282    None
4283}
4284
4285fn export_condition_target(value: &Value) -> Option<String> {
4286    match value {
4287        Value::String(target) => Some(target.clone()),
4288        Value::Object(map) => ["source", "import", "module", "default", "types"]
4289            .into_iter()
4290            .find_map(|field| map.get(field).and_then(export_condition_target)),
4291        _ => None,
4292    }
4293}
4294
4295fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
4296    let target = target.strip_prefix("./").unwrap_or(target);
4297    // Prefer source over compiled bundle when both exist: the callgraph
4298    // walks source files and cannot extract symbols from a built JS bundle.
4299    if let Some(src_relative) = target.strip_prefix("dist/") {
4300        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
4301            return Some(path);
4302        }
4303    }
4304
4305    resolve_file_like_path(&package_root.join(target))
4306}
4307
4308fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
4309    match subpath {
4310        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
4311            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
4312        None => resolve_file_like_path(&package_root.join("src").join("index"))
4313            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
4314    }
4315}
4316
4317pub(crate) fn resolve_reexported_symbol_target<F, D>(
4318    file: &Path,
4319    symbol_name: &str,
4320    file_exports_symbol: &mut F,
4321    file_default_export_symbol: &mut D,
4322) -> Option<(PathBuf, String)>
4323where
4324    F: FnMut(&Path, &str) -> bool,
4325    D: FnMut(&Path) -> Option<String>,
4326{
4327    resolve_reexported_symbol(
4328        file,
4329        symbol_name,
4330        file_exports_symbol,
4331        file_default_export_symbol,
4332    )
4333    .map(|target| (target.file, target.symbol))
4334}
4335
4336fn resolve_reexported_symbol<F, D>(
4337    file: &Path,
4338    symbol_name: &str,
4339    file_exports_symbol: &mut F,
4340    file_default_export_symbol: &mut D,
4341) -> Option<ResolvedSymbol>
4342where
4343    F: FnMut(&Path, &str) -> bool,
4344    D: FnMut(&Path) -> Option<String>,
4345{
4346    let mut visited = HashSet::new();
4347    resolve_reexported_symbol_inner(
4348        file,
4349        symbol_name,
4350        file_exports_symbol,
4351        file_default_export_symbol,
4352        &mut visited,
4353    )
4354}
4355
4356fn resolve_reexported_symbol_inner<F, D>(
4357    file: &Path,
4358    symbol_name: &str,
4359    file_exports_symbol: &mut F,
4360    file_default_export_symbol: &mut D,
4361    visited: &mut HashSet<(PathBuf, String)>,
4362) -> Option<ResolvedSymbol>
4363where
4364    F: FnMut(&Path, &str) -> bool,
4365    D: FnMut(&Path) -> Option<String>,
4366{
4367    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
4368    if !visited.insert((canon.clone(), symbol_name.to_string())) {
4369        return None;
4370    }
4371
4372    let source = std::fs::read_to_string(&canon).ok()?;
4373    let lang = detect_language(&canon)?;
4374    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
4375        if symbol_name == "default" {
4376            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
4377                file: canon,
4378                symbol,
4379            });
4380        }
4381        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
4382            file: canon,
4383            symbol: symbol_name.to_string(),
4384        });
4385    }
4386
4387    let grammar = grammar_for(lang);
4388    let mut parser = Parser::new();
4389    parser.set_language(&grammar).ok()?;
4390    let tree = parser.parse(&source, None)?;
4391    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
4392
4393    let mut cursor = tree.root_node().walk();
4394    if !cursor.goto_first_child() {
4395        return None;
4396    }
4397
4398    loop {
4399        let node = cursor.node();
4400        if node.kind() == "export_statement" {
4401            if let Some(target) = resolve_reexport_statement(
4402                &source,
4403                node,
4404                from_dir,
4405                symbol_name,
4406                file_exports_symbol,
4407                file_default_export_symbol,
4408                visited,
4409            ) {
4410                return Some(target);
4411            }
4412        }
4413
4414        if !cursor.goto_next_sibling() {
4415            break;
4416        }
4417    }
4418
4419    if symbol_name == "default" {
4420        if let Some(symbol) = file_default_export_symbol(&canon) {
4421            return Some(ResolvedSymbol {
4422                file: canon,
4423                symbol,
4424            });
4425        }
4426    }
4427
4428    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
4429        return Some(ResolvedSymbol {
4430            file: canon,
4431            symbol,
4432        });
4433    }
4434
4435    if file_exports_symbol(&canon, symbol_name) {
4436        let symbol = symbol_name.to_string();
4437        return Some(ResolvedSymbol {
4438            file: canon,
4439            symbol,
4440        });
4441    }
4442
4443    None
4444}
4445
4446fn resolve_reexport_statement<F, D>(
4447    source: &str,
4448    node: tree_sitter::Node,
4449    from_dir: &Path,
4450    symbol_name: &str,
4451    file_exports_symbol: &mut F,
4452    file_default_export_symbol: &mut D,
4453    visited: &mut HashSet<(PathBuf, String)>,
4454) -> Option<ResolvedSymbol>
4455where
4456    F: FnMut(&Path, &str) -> bool,
4457    D: FnMut(&Path) -> Option<String>,
4458{
4459    let source_node = node
4460        .child_by_field_name("source")
4461        .or_else(|| find_child_by_kind(node, "string"))?;
4462    let module_path = string_literal_content(source, source_node)?;
4463    let target_file = resolve_module_path(from_dir, &module_path)?;
4464    let raw_export = node_text(node, source);
4465
4466    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
4467        return resolve_reexported_symbol_inner(
4468            &target_file,
4469            &source_symbol,
4470            file_exports_symbol,
4471            file_default_export_symbol,
4472            visited,
4473        )
4474        .or(Some(ResolvedSymbol {
4475            file: target_file,
4476            symbol: source_symbol,
4477        }));
4478    }
4479
4480    if raw_export.contains('*') {
4481        return resolve_reexported_symbol_inner(
4482            &target_file,
4483            symbol_name,
4484            file_exports_symbol,
4485            file_default_export_symbol,
4486            visited,
4487        );
4488    }
4489
4490    None
4491}
4492
4493fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
4494    let lang = detect_language(file)?;
4495    let grammar = grammar_for(lang);
4496    let mut parser = Parser::new();
4497    parser.set_language(&grammar).ok()?;
4498    let tree = parser.parse(source, None)?;
4499
4500    let mut cursor = tree.root_node().walk();
4501    if !cursor.goto_first_child() {
4502        return None;
4503    }
4504
4505    loop {
4506        let node = cursor.node();
4507        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
4508            let raw_export = node_text(node, source);
4509            if let Some(source_symbol) =
4510                reexport_clause_source_symbol(&raw_export, requested_export)
4511            {
4512                return Some(source_symbol);
4513            }
4514        }
4515
4516        if !cursor.goto_next_sibling() {
4517            break;
4518        }
4519    }
4520
4521    None
4522}
4523
4524fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
4525    let start = raw_export.find('{')? + 1;
4526    let end = raw_export[start..].find('}')? + start;
4527    for specifier in raw_export[start..end].split(',') {
4528        let specifier = specifier.trim();
4529        if specifier.is_empty() {
4530            continue;
4531        }
4532        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
4533        if let Some((imported, exported)) = specifier.split_once(" as ") {
4534            if exported.trim() == requested_export {
4535                return Some(imported.trim().to_string());
4536            }
4537        } else if specifier == requested_export {
4538            return Some(requested_export.to_string());
4539        }
4540    }
4541    None
4542}
4543
4544fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
4545    let raw = source[node.byte_range()].trim();
4546    let quote = raw.chars().next()?;
4547    if quote != '\'' && quote != '"' {
4548        return None;
4549    }
4550    raw.strip_prefix(quote)
4551        .and_then(|value| value.strip_suffix(quote))
4552        .map(ToOwned::to_owned)
4553}
4554
4555/// Find an index file in a directory.
4556fn find_index_file(dir: &Path) -> Option<PathBuf> {
4557    for name in JS_TS_INDEX_FILES {
4558        let p = dir.join(name);
4559        if p.is_file() {
4560            return Some(std::fs::canonicalize(&p).unwrap_or(p));
4561        }
4562    }
4563    None
4564}
4565
4566/// Resolve an aliased import: `import { foo as bar } from './utils'`
4567/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
4568fn resolve_aliased_import(
4569    local_name: &str,
4570    import_block: &ImportBlock,
4571    caller_dir: &Path,
4572) -> Option<(String, PathBuf)> {
4573    for imp in &import_block.imports {
4574        // Parse the raw text to find "as <alias>" patterns
4575        // This handles: import { foo as bar, baz as qux } from './mod'
4576        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
4577            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
4578                return Some((original, resolved_path));
4579            }
4580        }
4581    }
4582    None
4583}
4584
4585/// Parse import raw text to find the original name for an alias.
4586/// Given raw text like `import { foo as bar, baz } from './utils'` and
4587/// local_name "bar", returns Some("foo").
4588fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
4589    // Look for pattern: <original> as <alias>
4590    // This is a simple text-based search; handles the common TS/JS pattern
4591    let search = format!(" as {}", local_name);
4592    if let Some(pos) = raw_import.find(&search) {
4593        // Walk backwards from `pos` to find the original name
4594        let before = &raw_import[..pos];
4595        // The original name is the last word-like token before " as "
4596        let original = before
4597            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
4598            .find(|s| !s.is_empty())?;
4599        return Some(original.to_string());
4600    }
4601    None
4602}
4603
4604// ---------------------------------------------------------------------------
4605// Worktree file discovery
4606// ---------------------------------------------------------------------------
4607
4608/// Walk project files respecting .gitignore, excluding common non-source dirs.
4609///
4610/// Returns an iterator of file paths for supported source file types.
4611pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
4612    use ignore::WalkBuilder;
4613
4614    let walker = WalkBuilder::new(root)
4615        .hidden(true)         // skip hidden files/dirs
4616        .git_ignore(true)     // respect .gitignore
4617        .git_global(true)     // respect global gitignore
4618        .git_exclude(true)    // respect .git/info/exclude
4619        .add_custom_ignore_filename(".aftignore") // AFT-specific ignores (e.g. submodules)
4620        .filter_entry(|entry| {
4621            let name = entry.file_name().to_string_lossy();
4622            // Always exclude these directories regardless of .gitignore
4623            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4624                return !matches!(
4625                    name.as_ref(),
4626                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
4627                        | ".tox" | "dist" | "build"
4628                );
4629            }
4630            true
4631        })
4632        .build();
4633
4634    walker
4635        .filter_map(|entry| entry.ok())
4636        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
4637        .filter(|entry| detect_language(entry.path()).is_some())
4638        .map(|entry| entry.into_path())
4639}
4640
4641// ---------------------------------------------------------------------------
4642// Tests
4643// ---------------------------------------------------------------------------
4644
4645#[cfg(test)]
4646mod tests {
4647    use super::*;
4648    use std::fs;
4649    use tempfile::TempDir;
4650
4651    #[test]
4652    fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
4653        // exported_symbols carries the bare name; symbol_metadata is keyed by
4654        // scoped identity (impl method). A plain .get(bare) misses and would
4655        // force the degraded unknown/line-1 fallback. symbol_metadata_for must
4656        // recover the scoped entry via unqualified-name match.
4657        let mut symbol_metadata = HashMap::new();
4658        symbol_metadata.insert(
4659            "BackupStore::total_disk_bytes".to_string(),
4660            SymbolMeta {
4661                kind: SymbolKind::Method,
4662                exported: true,
4663                signature: None,
4664                line: 703,
4665                range: Range {
4666                    start_line: 702,
4667                    start_col: 0,
4668                    end_line: 705,
4669                    end_col: 0,
4670                },
4671            },
4672        );
4673        let file_data = FileCallData {
4674            calls_by_symbol: HashMap::new(),
4675            exported_symbols: vec!["total_disk_bytes".to_string()],
4676            symbol_metadata,
4677            default_export_symbol: None,
4678            import_block: ImportBlock::empty(),
4679            lang: LangId::Rust,
4680        };
4681
4682        let meta = file_data
4683            .symbol_metadata_for("total_disk_bytes")
4684            .expect("scoped method recovered by bare name");
4685        assert_eq!(meta.kind, SymbolKind::Method);
4686        assert_eq!(
4687            meta.line, 703,
4688            "real declaration line, not the line-1 fallback"
4689        );
4690
4691        // A genuinely-absent symbol still returns None (no false recovery).
4692        assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
4693    }
4694
4695    /// Create a temp directory with TypeScript files for testing.
4696    fn setup_ts_project() -> TempDir {
4697        let dir = TempDir::new().unwrap();
4698
4699        // main.ts: imports from utils and calls functions
4700        fs::write(
4701            dir.path().join("main.ts"),
4702            r#"import { helper, compute } from './utils';
4703import * as math from './math';
4704
4705export function main() {
4706    const a = helper(1);
4707    const b = compute(a, 2);
4708    const c = math.add(a, b);
4709    return c;
4710}
4711"#,
4712        )
4713        .unwrap();
4714
4715        // utils.ts: defines helper and compute, imports from helpers
4716        fs::write(
4717            dir.path().join("utils.ts"),
4718            r#"import { double } from './helpers';
4719
4720export function helper(x: number): number {
4721    return double(x);
4722}
4723
4724export function compute(a: number, b: number): number {
4725    return a + b;
4726}
4727"#,
4728        )
4729        .unwrap();
4730
4731        // helpers.ts: defines double
4732        fs::write(
4733            dir.path().join("helpers.ts"),
4734            r#"export function double(x: number): number {
4735    return x * 2;
4736}
4737
4738export function triple(x: number): number {
4739    return x * 3;
4740}
4741"#,
4742        )
4743        .unwrap();
4744
4745        // math.ts: defines add (for namespace import test)
4746        fs::write(
4747            dir.path().join("math.ts"),
4748            r#"export function add(a: number, b: number): number {
4749    return a + b;
4750}
4751
4752export function subtract(a: number, b: number): number {
4753    return a - b;
4754}
4755"#,
4756        )
4757        .unwrap();
4758
4759        dir
4760    }
4761
4762    /// Create a project with import aliasing.
4763    fn setup_alias_project() -> TempDir {
4764        let dir = TempDir::new().unwrap();
4765
4766        fs::write(
4767            dir.path().join("main.ts"),
4768            r#"import { helper as h } from './utils';
4769
4770export function main() {
4771    return h(42);
4772}
4773"#,
4774        )
4775        .unwrap();
4776
4777        fs::write(
4778            dir.path().join("utils.ts"),
4779            r#"export function helper(x: number): number {
4780    return x + 1;
4781}
4782"#,
4783        )
4784        .unwrap();
4785
4786        dir
4787    }
4788
4789    /// Create a project with a cycle: A → B → A.
4790    fn setup_cycle_project() -> TempDir {
4791        let dir = TempDir::new().unwrap();
4792
4793        fs::write(
4794            dir.path().join("a.ts"),
4795            r#"import { funcB } from './b';
4796
4797export function funcA() {
4798    return funcB();
4799}
4800"#,
4801        )
4802        .unwrap();
4803
4804        fs::write(
4805            dir.path().join("b.ts"),
4806            r#"import { funcA } from './a';
4807
4808export function funcB() {
4809    return funcA();
4810}
4811"#,
4812        )
4813        .unwrap();
4814
4815        dir
4816    }
4817
4818    // --- Single-file call extraction ---
4819
4820    #[test]
4821    fn callgraph_single_file_call_extraction() {
4822        let dir = setup_ts_project();
4823        let mut graph = CallGraph::new(dir.path().to_path_buf());
4824
4825        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
4826        let main_calls = &file_data.calls_by_symbol["main"];
4827
4828        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
4829        assert!(
4830            callee_names.contains(&"helper"),
4831            "main should call helper, got: {:?}",
4832            callee_names
4833        );
4834        assert!(
4835            callee_names.contains(&"compute"),
4836            "main should call compute, got: {:?}",
4837            callee_names
4838        );
4839        assert!(
4840            callee_names.contains(&"add"),
4841            "main should call math.add (short name: add), got: {:?}",
4842            callee_names
4843        );
4844    }
4845
4846    #[test]
4847    fn callgraph_file_data_has_exports() {
4848        let dir = setup_ts_project();
4849        let mut graph = CallGraph::new(dir.path().to_path_buf());
4850
4851        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4852        assert!(
4853            file_data.exported_symbols.contains(&"helper".to_string()),
4854            "utils.ts should export helper, got: {:?}",
4855            file_data.exported_symbols
4856        );
4857        assert!(
4858            file_data.exported_symbols.contains(&"compute".to_string()),
4859            "utils.ts should export compute, got: {:?}",
4860            file_data.exported_symbols
4861        );
4862    }
4863
4864    // --- Cross-file resolution ---
4865
4866    #[test]
4867    fn callgraph_resolve_direct_import() {
4868        let dir = setup_ts_project();
4869        let mut graph = CallGraph::new(dir.path().to_path_buf());
4870
4871        let main_path = dir.path().join("main.ts");
4872        let file_data = graph.build_file(&main_path).unwrap();
4873        let import_block = file_data.import_block.clone();
4874
4875        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4876        match edge {
4877            EdgeResolution::Resolved { file, symbol } => {
4878                assert!(
4879                    file.ends_with("utils.ts"),
4880                    "helper should resolve to utils.ts, got: {:?}",
4881                    file
4882                );
4883                assert_eq!(symbol, "helper");
4884            }
4885            EdgeResolution::Unresolved { callee_name } => {
4886                panic!("Expected resolved, got unresolved: {}", callee_name);
4887            }
4888        }
4889    }
4890
4891    #[test]
4892    fn callgraph_resolve_namespace_import() {
4893        let dir = setup_ts_project();
4894        let mut graph = CallGraph::new(dir.path().to_path_buf());
4895
4896        let main_path = dir.path().join("main.ts");
4897        let file_data = graph.build_file(&main_path).unwrap();
4898        let import_block = file_data.import_block.clone();
4899
4900        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4901        match edge {
4902            EdgeResolution::Resolved { file, symbol } => {
4903                assert!(
4904                    file.ends_with("math.ts"),
4905                    "math.add should resolve to math.ts, got: {:?}",
4906                    file
4907                );
4908                assert_eq!(symbol, "add");
4909            }
4910            EdgeResolution::Unresolved { callee_name } => {
4911                panic!("Expected resolved, got unresolved: {}", callee_name);
4912            }
4913        }
4914    }
4915
4916    #[test]
4917    fn callgraph_resolve_aliased_import() {
4918        let dir = setup_alias_project();
4919        let mut graph = CallGraph::new(dir.path().to_path_buf());
4920
4921        let main_path = dir.path().join("main.ts");
4922        let file_data = graph.build_file(&main_path).unwrap();
4923        let import_block = file_data.import_block.clone();
4924
4925        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
4926        match edge {
4927            EdgeResolution::Resolved { file, symbol } => {
4928                assert!(
4929                    file.ends_with("utils.ts"),
4930                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
4931                    file
4932                );
4933                assert_eq!(symbol, "helper");
4934            }
4935            EdgeResolution::Unresolved { callee_name } => {
4936                panic!("Expected resolved, got unresolved: {}", callee_name);
4937            }
4938        }
4939    }
4940
4941    #[test]
4942    fn callgraph_unresolved_edge_marked() {
4943        let dir = setup_ts_project();
4944        let mut graph = CallGraph::new(dir.path().to_path_buf());
4945
4946        let main_path = dir.path().join("main.ts");
4947        let file_data = graph.build_file(&main_path).unwrap();
4948        let import_block = file_data.import_block.clone();
4949
4950        let edge =
4951            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
4952        assert_eq!(
4953            edge,
4954            EdgeResolution::Unresolved {
4955                callee_name: "unknownFunc".to_string()
4956            },
4957            "Unknown callee should be unresolved"
4958        );
4959    }
4960
4961    // --- Cycle detection ---
4962
4963    #[test]
4964    fn callgraph_cycle_detection_stops() {
4965        let dir = setup_cycle_project();
4966        let mut graph = CallGraph::new(dir.path().to_path_buf());
4967
4968        // This should NOT infinite loop
4969        let tree = graph
4970            .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
4971            .unwrap();
4972
4973        assert_eq!(tree.name, "funcA");
4974        assert!(tree.resolved);
4975
4976        // funcA calls funcB, funcB calls funcA (cycle), so the depth should be bounded
4977        // The tree should have children but not infinitely deep
4978        fn count_depth(node: &CallTreeNode) -> usize {
4979            if node.children.is_empty() {
4980                1
4981            } else {
4982                1 + node.children.iter().map(count_depth).max().unwrap_or(0)
4983            }
4984        }
4985
4986        let depth = count_depth(&tree);
4987        assert!(
4988            depth <= 4,
4989            "Cycle should be detected and bounded, depth was: {}",
4990            depth
4991        );
4992    }
4993
4994    // --- Depth limiting ---
4995
4996    #[test]
4997    fn callgraph_depth_limit_truncates() {
4998        let dir = setup_ts_project();
4999        let mut graph = CallGraph::new(dir.path().to_path_buf());
5000
5001        // main → helper → double, main → compute
5002        // With depth 1, we should see direct callees but not their children
5003        let tree = graph
5004            .forward_tree(&dir.path().join("main.ts"), "main", 1)
5005            .unwrap();
5006
5007        assert_eq!(tree.name, "main");
5008        assert!(tree.depth_limited, "depth limit should be reported");
5009        assert!(
5010            tree.truncated > 0,
5011            "truncated edge count should be reported"
5012        );
5013
5014        // At depth 1, children should exist (direct calls) but their children should be empty
5015        for child in &tree.children {
5016            assert!(
5017                child.children.is_empty(),
5018                "At depth 1, child '{}' should have no children, got {:?}",
5019                child.name,
5020                child.children.len()
5021            );
5022        }
5023    }
5024
5025    #[test]
5026    fn callgraph_depth_zero_no_children() {
5027        let dir = setup_ts_project();
5028        let mut graph = CallGraph::new(dir.path().to_path_buf());
5029
5030        let tree = graph
5031            .forward_tree(&dir.path().join("main.ts"), "main", 0)
5032            .unwrap();
5033
5034        assert_eq!(tree.name, "main");
5035        assert!(
5036            tree.children.is_empty(),
5037            "At depth 0, should have no children"
5038        );
5039    }
5040
5041    // --- Forward tree cross-file ---
5042
5043    #[test]
5044    fn callgraph_forward_tree_cross_file() {
5045        let dir = setup_ts_project();
5046        let mut graph = CallGraph::new(dir.path().to_path_buf());
5047
5048        // main → helper (in utils.ts) → double (in helpers.ts)
5049        let tree = graph
5050            .forward_tree(&dir.path().join("main.ts"), "main", 5)
5051            .unwrap();
5052
5053        assert_eq!(tree.name, "main");
5054        assert!(tree.resolved);
5055
5056        // Find the helper child
5057        let helper_child = tree.children.iter().find(|c| c.name == "helper");
5058        assert!(
5059            helper_child.is_some(),
5060            "main should have helper as child, children: {:?}",
5061            tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5062        );
5063
5064        let helper = helper_child.unwrap();
5065        assert!(
5066            helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
5067            "helper should be in utils.ts, got: {}",
5068            helper.file
5069        );
5070
5071        // helper should call double (in helpers.ts)
5072        let double_child = helper.children.iter().find(|c| c.name == "double");
5073        assert!(
5074            double_child.is_some(),
5075            "helper should call double, children: {:?}",
5076            helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5077        );
5078
5079        let double = double_child.unwrap();
5080        assert!(
5081            double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
5082            "double should be in helpers.ts, got: {}",
5083            double.file
5084        );
5085    }
5086
5087    // --- Worktree walker ---
5088
5089    #[test]
5090    fn callgraph_walker_excludes_gitignored() {
5091        let dir = TempDir::new().unwrap();
5092
5093        // Create a .gitignore
5094        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
5095
5096        // Create files
5097        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5098        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
5099        fs::write(
5100            dir.path().join("ignored_dir").join("secret.ts"),
5101            "export function secret() {}",
5102        )
5103        .unwrap();
5104
5105        // Also create node_modules (should always be excluded)
5106        fs::create_dir(dir.path().join("node_modules")).unwrap();
5107        fs::write(
5108            dir.path().join("node_modules").join("dep.ts"),
5109            "export function dep() {}",
5110        )
5111        .unwrap();
5112
5113        // Init git repo for .gitignore to work
5114        std::process::Command::new("git")
5115            .args(["init"])
5116            .current_dir(dir.path())
5117            .output()
5118            .unwrap();
5119
5120        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5121        let file_names: Vec<String> = files
5122            .iter()
5123            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5124            .collect();
5125
5126        assert!(
5127            file_names.contains(&"main.ts".to_string()),
5128            "Should include main.ts, got: {:?}",
5129            file_names
5130        );
5131        assert!(
5132            !file_names.contains(&"secret.ts".to_string()),
5133            "Should exclude gitignored secret.ts, got: {:?}",
5134            file_names
5135        );
5136        assert!(
5137            !file_names.contains(&"dep.ts".to_string()),
5138            "Should exclude node_modules, got: {:?}",
5139            file_names
5140        );
5141    }
5142
5143    #[test]
5144    fn callgraph_walker_excludes_aftignored() {
5145        let dir = TempDir::new().unwrap();
5146
5147        // .aftignore is honored without a git repo (custom ignore file).
5148        fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
5149        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5150        fs::create_dir(dir.path().join("vendored")).unwrap();
5151        fs::write(
5152            dir.path().join("vendored").join("sub.ts"),
5153            "export function sub() {}",
5154        )
5155        .unwrap();
5156
5157        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5158        let file_names: Vec<String> = files
5159            .iter()
5160            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5161            .collect();
5162
5163        assert!(
5164            file_names.contains(&"main.ts".to_string()),
5165            "Should include main.ts, got: {:?}",
5166            file_names
5167        );
5168        assert!(
5169            !file_names.contains(&"sub.ts".to_string()),
5170            "Should exclude .aftignored sub.ts, got: {:?}",
5171            file_names
5172        );
5173    }
5174
5175    #[test]
5176    fn callgraph_walker_only_source_files() {
5177        let dir = TempDir::new().unwrap();
5178
5179        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5180        fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
5181        fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
5182        fs::write(
5183            dir.path().join("runtime.mjs"),
5184            "export function runtime() {}",
5185        )
5186        .unwrap();
5187        fs::write(
5188            dir.path().join("legacy.cjs"),
5189            "exports.legacy = function() {};",
5190        )
5191        .unwrap();
5192        fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
5193        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
5194        fs::write(dir.path().join("data.json"), "{}").unwrap();
5195
5196        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5197        let file_names: Vec<String> = files
5198            .iter()
5199            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5200            .collect();
5201
5202        assert!(file_names.contains(&"main.ts".to_string()));
5203        for modern_ext_file in [
5204            "module.mts",
5205            "common.cts",
5206            "runtime.mjs",
5207            "legacy.cjs",
5208            "types.pyi",
5209        ] {
5210            assert!(
5211                file_names.contains(&modern_ext_file.to_string()),
5212                "walker should include {modern_ext_file}, got: {:?}",
5213                file_names
5214            );
5215        }
5216        assert!(
5217            file_names.contains(&"readme.md".to_string()),
5218            "Markdown is now a supported source language"
5219        );
5220        assert!(
5221            file_names.contains(&"data.json".to_string()),
5222            "JSON is now a supported source language"
5223        );
5224    }
5225
5226    // --- find_alias_original ---
5227
5228    #[test]
5229    fn callgraph_find_alias_original_simple() {
5230        let raw = "import { foo as bar } from './utils';";
5231        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5232    }
5233
5234    #[test]
5235    fn callgraph_find_alias_original_multiple() {
5236        let raw = "import { foo as bar, baz as qux } from './utils';";
5237        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5238        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
5239    }
5240
5241    #[test]
5242    fn callgraph_find_alias_no_match() {
5243        let raw = "import { foo } from './utils';";
5244        assert_eq!(find_alias_original(raw, "foo"), None);
5245    }
5246
5247    // --- Reverse callers ---
5248
5249    #[test]
5250    fn callgraph_callers_of_direct() {
5251        let dir = setup_ts_project();
5252        let mut graph = CallGraph::new(dir.path().to_path_buf());
5253
5254        // helpers.ts:double is called by utils.ts:helper
5255        let result = graph
5256            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5257            .unwrap();
5258
5259        assert_eq!(result.symbol, "double");
5260        assert!(result.total_callers > 0, "double should have callers");
5261        assert!(result.scanned_files > 0, "should have scanned files");
5262
5263        // Find the caller from utils.ts
5264        let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
5265        assert!(
5266            utils_group.is_some(),
5267            "double should be called from utils.ts, groups: {:?}",
5268            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5269        );
5270
5271        let group = utils_group.unwrap();
5272        let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
5273        assert!(
5274            helper_caller.is_some(),
5275            "double should be called by helper, callers: {:?}",
5276            group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
5277        );
5278    }
5279
5280    #[test]
5281    fn callgraph_callers_of_no_callers() {
5282        let dir = setup_ts_project();
5283        let mut graph = CallGraph::new(dir.path().to_path_buf());
5284
5285        // main.ts:main is the entry point — nothing calls it
5286        let result = graph
5287            .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
5288            .unwrap();
5289
5290        assert_eq!(result.symbol, "main");
5291        assert_eq!(result.total_callers, 0, "main should have no callers");
5292        assert!(result.callers.is_empty());
5293    }
5294
5295    #[test]
5296    fn callgraph_callers_recursive_depth() {
5297        let dir = setup_ts_project();
5298        let mut graph = CallGraph::new(dir.path().to_path_buf());
5299
5300        // helpers.ts:double is called by utils.ts:helper
5301        // utils.ts:helper is called by main.ts:main
5302        // With depth=2, we should see both direct and transitive callers
5303        let result = graph
5304            .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
5305            .unwrap();
5306
5307        assert!(
5308            result.total_callers >= 2,
5309            "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
5310            result.total_callers
5311        );
5312
5313        // Should include caller from main.ts (transitive: main → helper → double)
5314        let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
5315        assert!(
5316            main_group.is_some(),
5317            "recursive callers should include main.ts, groups: {:?}",
5318            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5319        );
5320    }
5321
5322    #[test]
5323    fn callgraph_invalidate_file_clears_reverse_index() {
5324        let dir = setup_ts_project();
5325        let mut graph = CallGraph::new(dir.path().to_path_buf());
5326
5327        // Build callers to populate the reverse index
5328        let _ = graph
5329            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5330            .unwrap();
5331        assert!(
5332            graph.reverse_index.is_some(),
5333            "reverse index should be built"
5334        );
5335
5336        // Invalidate a file
5337        graph.invalidate_file(&dir.path().join("utils.ts"));
5338
5339        // Reverse index should be cleared
5340        assert!(
5341            graph.reverse_index.is_none(),
5342            "invalidate_file should clear reverse index"
5343        );
5344        // Data cache for the file should be cleared
5345        let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
5346        assert!(
5347            !graph.data.contains_key(&canon),
5348            "invalidate_file should remove file from data cache"
5349        );
5350        // Project files should be cleared
5351        assert!(
5352            graph.project_files.is_none(),
5353            "invalidate_file should clear project_files"
5354        );
5355    }
5356
5357    // --- is_entry_point ---
5358
5359    #[test]
5360    fn is_entry_point_exported_function() {
5361        assert!(is_entry_point(
5362            "handleRequest",
5363            &SymbolKind::Function,
5364            true,
5365            LangId::TypeScript
5366        ));
5367    }
5368
5369    #[test]
5370    fn is_entry_point_exported_method_is_not_entry() {
5371        // Methods are class members, not standalone entry points
5372        assert!(!is_entry_point(
5373            "handleRequest",
5374            &SymbolKind::Method,
5375            true,
5376            LangId::TypeScript
5377        ));
5378    }
5379
5380    #[test]
5381    fn is_entry_point_main_init_patterns() {
5382        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
5383            assert!(
5384                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
5385                "{} should be an entry point",
5386                name
5387            );
5388        }
5389    }
5390
5391    #[test]
5392    fn is_entry_point_test_patterns_ts() {
5393        assert!(is_entry_point(
5394            "describe",
5395            &SymbolKind::Function,
5396            false,
5397            LangId::TypeScript
5398        ));
5399        assert!(is_entry_point(
5400            "it",
5401            &SymbolKind::Function,
5402            false,
5403            LangId::TypeScript
5404        ));
5405        assert!(is_entry_point(
5406            "test",
5407            &SymbolKind::Function,
5408            false,
5409            LangId::TypeScript
5410        ));
5411        assert!(is_entry_point(
5412            "testValidation",
5413            &SymbolKind::Function,
5414            false,
5415            LangId::TypeScript
5416        ));
5417        assert!(is_entry_point(
5418            "specHelper",
5419            &SymbolKind::Function,
5420            false,
5421            LangId::TypeScript
5422        ));
5423    }
5424
5425    #[test]
5426    fn is_entry_point_test_patterns_python() {
5427        assert!(is_entry_point(
5428            "test_login",
5429            &SymbolKind::Function,
5430            false,
5431            LangId::Python
5432        ));
5433        assert!(is_entry_point(
5434            "setUp",
5435            &SymbolKind::Function,
5436            false,
5437            LangId::Python
5438        ));
5439        assert!(is_entry_point(
5440            "tearDown",
5441            &SymbolKind::Function,
5442            false,
5443            LangId::Python
5444        ));
5445        // "testSomething" should NOT match Python (needs test_ prefix)
5446        assert!(!is_entry_point(
5447            "testSomething",
5448            &SymbolKind::Function,
5449            false,
5450            LangId::Python
5451        ));
5452    }
5453
5454    #[test]
5455    fn is_entry_point_test_patterns_rust() {
5456        assert!(is_entry_point(
5457            "test_parse",
5458            &SymbolKind::Function,
5459            false,
5460            LangId::Rust
5461        ));
5462        assert!(!is_entry_point(
5463            "TestSomething",
5464            &SymbolKind::Function,
5465            false,
5466            LangId::Rust
5467        ));
5468    }
5469
5470    #[test]
5471    fn is_entry_point_test_patterns_go() {
5472        assert!(is_entry_point(
5473            "TestParsing",
5474            &SymbolKind::Function,
5475            false,
5476            LangId::Go
5477        ));
5478        // lowercase test should NOT match Go (needs uppercase Test prefix)
5479        assert!(!is_entry_point(
5480            "testParsing",
5481            &SymbolKind::Function,
5482            false,
5483            LangId::Go
5484        ));
5485    }
5486
5487    #[test]
5488    fn is_entry_point_non_exported_non_main_is_not_entry() {
5489        assert!(!is_entry_point(
5490            "helperUtil",
5491            &SymbolKind::Function,
5492            false,
5493            LangId::TypeScript
5494        ));
5495    }
5496
5497    // --- symbol_metadata ---
5498
5499    #[test]
5500    fn callgraph_symbol_metadata_populated() {
5501        let dir = setup_ts_project();
5502        let mut graph = CallGraph::new(dir.path().to_path_buf());
5503
5504        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
5505        assert!(
5506            file_data.symbol_metadata.contains_key("helper"),
5507            "symbol_metadata should contain helper"
5508        );
5509        let meta = &file_data.symbol_metadata["helper"];
5510        assert_eq!(meta.kind, SymbolKind::Function);
5511        assert!(meta.exported, "helper should be exported");
5512    }
5513
5514    // --- trace_to ---
5515
5516    /// Setup a multi-path project for trace_to tests.
5517    ///
5518    /// Structure:
5519    ///   main.ts: exported main() → processData (from utils)
5520    ///   service.ts: exported handleRequest() → processData (from utils)
5521    ///   utils.ts: exported processData() → validate (from helpers)
5522    ///   helpers.ts: exported validate() → checkFormat (local, not exported)
5523    ///   test_helpers.ts: testValidation() → validate (from helpers)
5524    ///
5525    /// checkFormat should have 3 paths:
5526    ///   main → processData → validate → checkFormat
5527    ///   handleRequest → processData → validate → checkFormat
5528    ///   testValidation → validate → checkFormat
5529    fn setup_trace_project() -> TempDir {
5530        let dir = TempDir::new().unwrap();
5531
5532        fs::write(
5533            dir.path().join("main.ts"),
5534            r#"import { processData } from './utils';
5535
5536export function main() {
5537    const result = processData("hello");
5538    return result;
5539}
5540"#,
5541        )
5542        .unwrap();
5543
5544        fs::write(
5545            dir.path().join("service.ts"),
5546            r#"import { processData } from './utils';
5547
5548export function handleRequest(input: string): string {
5549    return processData(input);
5550}
5551"#,
5552        )
5553        .unwrap();
5554
5555        fs::write(
5556            dir.path().join("utils.ts"),
5557            r#"import { validate } from './helpers';
5558
5559export function processData(input: string): string {
5560    const valid = validate(input);
5561    if (!valid) {
5562        throw new Error("invalid input");
5563    }
5564    return input.toUpperCase();
5565}
5566"#,
5567        )
5568        .unwrap();
5569
5570        fs::write(
5571            dir.path().join("helpers.ts"),
5572            r#"export function validate(input: string): boolean {
5573    return checkFormat(input);
5574}
5575
5576function checkFormat(input: string): boolean {
5577    return input.length > 0 && /^[a-zA-Z]+$/.test(input);
5578}
5579"#,
5580        )
5581        .unwrap();
5582
5583        fs::write(
5584            dir.path().join("test_helpers.ts"),
5585            r#"import { validate } from './helpers';
5586
5587function testValidation() {
5588    const result = validate("hello");
5589    console.log(result);
5590}
5591"#,
5592        )
5593        .unwrap();
5594
5595        // git init so the walker works
5596        std::process::Command::new("git")
5597            .args(["init"])
5598            .current_dir(dir.path())
5599            .output()
5600            .unwrap();
5601
5602        dir
5603    }
5604
5605    #[test]
5606    fn trace_to_multi_path() {
5607        let dir = setup_trace_project();
5608        let mut graph = CallGraph::new(dir.path().to_path_buf());
5609
5610        let result = graph
5611            .trace_to(
5612                &dir.path().join("helpers.ts"),
5613                "checkFormat",
5614                10,
5615                usize::MAX,
5616            )
5617            .unwrap();
5618
5619        assert_eq!(result.target_symbol, "checkFormat");
5620        assert!(
5621            result.total_paths >= 2,
5622            "checkFormat should have at least 2 paths, got {} (paths: {:?})",
5623            result.total_paths,
5624            result
5625                .paths
5626                .iter()
5627                .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
5628                .collect::<Vec<_>>()
5629        );
5630
5631        // Check that paths are top-down: entry point first, target last
5632        for path in &result.paths {
5633            assert!(
5634                path.hops.first().unwrap().is_entry_point,
5635                "First hop should be an entry point, got: {}",
5636                path.hops.first().unwrap().symbol
5637            );
5638            assert_eq!(
5639                path.hops.last().unwrap().symbol,
5640                "checkFormat",
5641                "Last hop should be checkFormat"
5642            );
5643        }
5644
5645        // Verify entry_points_found > 0
5646        assert!(
5647            result.entry_points_found >= 2,
5648            "should find at least 2 entry points, got {}",
5649            result.entry_points_found
5650        );
5651    }
5652
5653    #[test]
5654    fn trace_to_single_path() {
5655        let dir = setup_trace_project();
5656        let mut graph = CallGraph::new(dir.path().to_path_buf());
5657
5658        // validate is called from processData, testValidation
5659        // processData is called from main, handleRequest
5660        // So validate has paths: main→processData→validate, handleRequest→processData→validate, testValidation→validate
5661        let result = graph
5662            .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
5663            .unwrap();
5664
5665        assert_eq!(result.target_symbol, "validate");
5666        assert!(
5667            result.total_paths >= 2,
5668            "validate should have at least 2 paths, got {}",
5669            result.total_paths
5670        );
5671    }
5672
5673    #[test]
5674    fn trace_to_cycle_detection() {
5675        let dir = setup_cycle_project();
5676        let mut graph = CallGraph::new(dir.path().to_path_buf());
5677
5678        // funcA ↔ funcB cycle — should terminate
5679        let result = graph
5680            .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
5681            .unwrap();
5682
5683        // Should not hang — the fact we got here means cycle detection works
5684        assert_eq!(result.target_symbol, "funcA");
5685    }
5686
5687    #[test]
5688    fn trace_to_depth_limit() {
5689        let dir = setup_trace_project();
5690        let mut graph = CallGraph::new(dir.path().to_path_buf());
5691
5692        // With max_depth=1, should not be able to reach entry points that are 3+ hops away
5693        let result = graph
5694            .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
5695            .unwrap();
5696
5697        // testValidation→validate→checkFormat is 2 hops, which requires depth >= 2
5698        // main→processData→validate→checkFormat is 3 hops, which requires depth >= 3
5699        // With depth=1, most paths should be truncated
5700        assert_eq!(result.target_symbol, "checkFormat");
5701
5702        // The shallow result should have fewer paths than the deep one
5703        let deep_result = graph
5704            .trace_to(
5705                &dir.path().join("helpers.ts"),
5706                "checkFormat",
5707                10,
5708                usize::MAX,
5709            )
5710            .unwrap();
5711
5712        assert!(
5713            result.total_paths <= deep_result.total_paths,
5714            "shallow trace should find <= paths compared to deep: {} vs {}",
5715            result.total_paths,
5716            deep_result.total_paths
5717        );
5718    }
5719
5720    #[test]
5721    fn trace_to_entry_point_target() {
5722        let dir = setup_trace_project();
5723        let mut graph = CallGraph::new(dir.path().to_path_buf());
5724
5725        // main is itself an entry point — should return a single trivial path
5726        let result = graph
5727            .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
5728            .unwrap();
5729
5730        assert_eq!(result.target_symbol, "main");
5731        assert!(
5732            result.total_paths >= 1,
5733            "main should have at least 1 path (itself), got {}",
5734            result.total_paths
5735        );
5736        // Check the trivial path has just one hop
5737        let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
5738        assert!(
5739            trivial.is_some(),
5740            "should have a trivial path with just the entry point itself"
5741        );
5742    }
5743
5744    #[test]
5745    fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
5746        let dir = TempDir::new().unwrap();
5747        fs::write(
5748            dir.path().join("main.ts"),
5749            r#"import * as lib from './index';
5750
5751export function main() {
5752    lib.helper();
5753    lib.hidden();
5754}
5755"#,
5756        )
5757        .unwrap();
5758        fs::write(
5759            dir.path().join("index.ts"),
5760            "export { helper } from './utils';\n",
5761        )
5762        .unwrap();
5763        fs::write(
5764            dir.path().join("utils.ts"),
5765            r#"export function helper() {}
5766function hidden() {}
5767"#,
5768        )
5769        .unwrap();
5770
5771        let mut graph = CallGraph::new(dir.path().to_path_buf());
5772        let main_path = dir.path().join("main.ts");
5773        let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
5774
5775        let helper =
5776            graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
5777        match helper {
5778            EdgeResolution::Resolved { file, symbol } => {
5779                assert!(
5780                    file.ends_with("utils.ts"),
5781                    "helper should resolve through barrel: {file:?}"
5782                );
5783                assert_eq!(symbol, "helper");
5784            }
5785            other => panic!("expected helper to resolve through barrel, got {other:?}"),
5786        }
5787
5788        let hidden =
5789            graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
5790        assert_eq!(
5791            hidden,
5792            EdgeResolution::Unresolved {
5793                callee_name: "hidden".to_string()
5794            }
5795        );
5796    }
5797
5798    #[test]
5799    fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
5800        let dir = TempDir::new().unwrap();
5801        fs::write(
5802            dir.path().join("package.json"),
5803            r#"{"workspaces":["packages/*"]}"#,
5804        )
5805        .unwrap();
5806        let package_dir = dir.path().join("packages/lib");
5807        fs::create_dir_all(package_dir.join("src")).unwrap();
5808        fs::create_dir_all(package_dir.join("dist")).unwrap();
5809        fs::write(
5810            package_dir.join("package.json"),
5811            r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
5812        )
5813        .unwrap();
5814        fs::write(
5815            package_dir.join("src/index.mts"),
5816            "export function helper() {}\n",
5817        )
5818        .unwrap();
5819        fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
5820
5821        let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
5822        assert!(
5823            resolved.ends_with("src/index.mts"),
5824            "dist/index.mjs should map to src/index.mts, got {resolved:?}"
5825        );
5826    }
5827
5828    #[test]
5829    fn unresolved_member_calls_do_not_become_same_file_callers() {
5830        let dir = TempDir::new().unwrap();
5831        fs::write(
5832            dir.path().join("main.ts"),
5833            r#"function caller() {
5834    db.connect();
5835}
5836
5837function connect() {}
5838"#,
5839        )
5840        .unwrap();
5841
5842        let mut graph = CallGraph::new(dir.path().to_path_buf());
5843        let result = graph
5844            .callers_of(&dir.path().join("main.ts"), "connect", 1, usize::MAX)
5845            .unwrap();
5846
5847        assert_eq!(
5848            result.total_callers, 0,
5849            "db.connect() must not call local connect"
5850        );
5851    }
5852
5853    #[test]
5854    fn same_named_methods_use_scoped_symbol_identity() {
5855        let dir = TempDir::new().unwrap();
5856        fs::write(
5857            dir.path().join("classes.ts"),
5858            r#"class A {
5859    run() { helperA(); }
5860}
5861
5862class B {
5863    run() { helperB(); }
5864}
5865
5866function helperA() {}
5867function helperB() {}
5868"#,
5869        )
5870        .unwrap();
5871
5872        let mut graph = CallGraph::new(dir.path().to_path_buf());
5873        let path = dir.path().join("classes.ts");
5874        let data = graph.build_file(&path).unwrap();
5875
5876        assert!(
5877            data.symbol_metadata.contains_key("A::run"),
5878            "A::run metadata missing"
5879        );
5880        assert!(
5881            data.symbol_metadata.contains_key("B::run"),
5882            "B::run metadata missing"
5883        );
5884        assert!(
5885            data.calls_by_symbol["A::run"]
5886                .iter()
5887                .any(|call| call.callee_name == "helperA"),
5888            "A::run calls should not be overwritten"
5889        );
5890        assert!(
5891            data.calls_by_symbol["B::run"]
5892                .iter()
5893                .any(|call| call.callee_name == "helperB"),
5894            "B::run calls should not be overwritten"
5895        );
5896
5897        assert!(matches!(
5898            graph.resolve_symbol_query(&path, "run"),
5899            Err(AftError::AmbiguousSymbol { .. })
5900        ));
5901        assert_eq!(
5902            graph.resolve_symbol_query(&path, "A::run").unwrap(),
5903            "A::run"
5904        );
5905    }
5906
5907    #[test]
5908    fn trace_to_counts_same_named_entry_points_by_file_and_symbol() {
5909        let dir = TempDir::new().unwrap();
5910        fs::create_dir_all(dir.path().join("web")).unwrap();
5911        fs::create_dir_all(dir.path().join("cli")).unwrap();
5912        fs::write(
5913            dir.path().join("target.ts"),
5914            r#"export function target() {
5915    leaf();
5916}
5917
5918function leaf() {}
5919"#,
5920        )
5921        .unwrap();
5922        fs::write(
5923            dir.path().join("web/main.ts"),
5924            r#"import { target } from '../target';
5925
5926export function main() {
5927    target();
5928}
5929"#,
5930        )
5931        .unwrap();
5932        fs::write(
5933            dir.path().join("cli/main.ts"),
5934            r#"import { target } from '../target';
5935
5936export function main() {
5937    target();
5938}
5939"#,
5940        )
5941        .unwrap();
5942
5943        let mut graph = CallGraph::new(dir.path().to_path_buf());
5944        let result = graph
5945            .trace_to(&dir.path().join("target.ts"), "leaf", 10, usize::MAX)
5946            .unwrap();
5947
5948        assert_eq!(
5949            result.total_paths, 3,
5950            "target plus two main entry paths expected"
5951        );
5952        assert_eq!(
5953            result.entry_points_found, 3,
5954            "same-named main entry points in different files must both count"
5955        );
5956    }
5957
5958    #[test]
5959    fn callers_and_impact_report_depth_truncation() {
5960        let dir = setup_ts_project();
5961        let mut graph = CallGraph::new(dir.path().to_path_buf());
5962
5963        let callers = graph
5964            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5965            .unwrap();
5966        assert!(
5967            callers.depth_limited,
5968            "callers should report omitted transitive callers"
5969        );
5970        assert!(
5971            callers.truncated > 0,
5972            "callers should report truncated edge count"
5973        );
5974
5975        let impact = graph
5976            .impact(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5977            .unwrap();
5978        assert!(
5979            impact.depth_limited,
5980            "impact should report omitted transitive callers"
5981        );
5982        assert!(
5983            impact.truncated > 0,
5984            "impact should report truncated edge count"
5985        );
5986    }
5987
5988    // --- extract_parameters ---
5989
5990    #[test]
5991    fn extract_parameters_typescript() {
5992        let params = extract_parameters(
5993            "function processData(input: string, count: number): void",
5994            LangId::TypeScript,
5995        );
5996        assert_eq!(params, vec!["input", "count"]);
5997    }
5998
5999    #[test]
6000    fn extract_parameters_typescript_optional() {
6001        let params = extract_parameters(
6002            "function fetch(url: string, options?: RequestInit): Promise<Response>",
6003            LangId::TypeScript,
6004        );
6005        assert_eq!(params, vec!["url", "options"]);
6006    }
6007
6008    #[test]
6009    fn extract_parameters_typescript_defaults() {
6010        let params = extract_parameters(
6011            "function greet(name: string, greeting: string = \"hello\"): string",
6012            LangId::TypeScript,
6013        );
6014        assert_eq!(params, vec!["name", "greeting"]);
6015    }
6016
6017    #[test]
6018    fn extract_parameters_typescript_rest() {
6019        let params = extract_parameters(
6020            "function sum(...numbers: number[]): number",
6021            LangId::TypeScript,
6022        );
6023        assert_eq!(params, vec!["numbers"]);
6024    }
6025
6026    #[test]
6027    fn extract_parameters_python_self_skipped() {
6028        let params = extract_parameters(
6029            "def process(self, data: str, count: int) -> bool",
6030            LangId::Python,
6031        );
6032        assert_eq!(params, vec!["data", "count"]);
6033    }
6034
6035    #[test]
6036    fn extract_parameters_python_no_self() {
6037        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
6038        assert_eq!(params, vec!["input"]);
6039    }
6040
6041    #[test]
6042    fn extract_parameters_python_star_args() {
6043        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
6044        assert_eq!(params, vec!["args", "kwargs"]);
6045    }
6046
6047    #[test]
6048    fn extract_parameters_rust_self_skipped() {
6049        let params = extract_parameters(
6050            "fn process(&self, data: &str, count: usize) -> bool",
6051            LangId::Rust,
6052        );
6053        assert_eq!(params, vec!["data", "count"]);
6054    }
6055
6056    #[test]
6057    fn extract_parameters_rust_mut_self_skipped() {
6058        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
6059        assert_eq!(params, vec!["value"]);
6060    }
6061
6062    #[test]
6063    fn extract_parameters_rust_no_self() {
6064        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
6065        assert_eq!(params, vec!["input"]);
6066    }
6067
6068    #[test]
6069    fn extract_parameters_rust_mut_param() {
6070        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
6071        assert_eq!(params, vec!["buf", "len"]);
6072    }
6073
6074    #[test]
6075    fn extract_parameters_go() {
6076        let params = extract_parameters(
6077            "func ProcessData(input string, count int) error",
6078            LangId::Go,
6079        );
6080        assert_eq!(params, vec!["input", "count"]);
6081    }
6082
6083    #[test]
6084    fn extract_parameters_empty() {
6085        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
6086        assert!(
6087            params.is_empty(),
6088            "no-arg function should return empty params"
6089        );
6090    }
6091
6092    #[test]
6093    fn extract_parameters_no_parens() {
6094        let params = extract_parameters("const x = 42", LangId::TypeScript);
6095        assert!(params.is_empty(), "no parens should return empty params");
6096    }
6097
6098    #[test]
6099    fn extract_parameters_javascript() {
6100        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
6101        assert_eq!(params, vec!["event", "target"]);
6102    }
6103}