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        // Emit forward slashes on every platform so the agent-facing `file`
2728        // field is consistent across the whole call-graph surface (the
2729        // persisted store's `relative_path` already normalizes to `/`, and
2730        // Windows accepts `/` as a path input). Without this, legacy ops
2731        // (trace_data, dead_code) emitted `src\foo.ts` on Windows while
2732        // store-backed ops emitted `src/foo.ts`.
2733        path.strip_prefix(&self.project_root)
2734            .unwrap_or(path)
2735            .to_string_lossy()
2736            .replace('\\', "/")
2737    }
2738
2739    /// Canonicalize a path, falling back to the original if canonicalization fails.
2740    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2741        // If the path is relative, resolve it against project_root
2742        let full_path = if path.is_relative() {
2743            self.project_root.join(path)
2744        } else {
2745            path.to_path_buf()
2746        };
2747
2748        // Try canonicalize, fall back to the full path
2749        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2750    }
2751
2752    /// Look up cached file data, trying both the given path and its
2753    /// canonicalized form. Needed because `build_reverse_index` may store
2754    /// data under raw walker paths while CallerSite uses canonical paths.
2755    fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2756        if let Some(fd) = self.data.get(path) {
2757            return Some(fd);
2758        }
2759        // Try canonical
2760        let canon = std::fs::canonicalize(path).ok()?;
2761        self.data.get(&canon).or_else(|| {
2762            // Try non-canonical forms stored by the walker
2763            self.data.iter().find_map(|(k, v)| {
2764                if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2765                    Some(v)
2766                } else {
2767                    None
2768                }
2769            })
2770        })
2771    }
2772}
2773
2774// ---------------------------------------------------------------------------
2775// File-level building
2776// ---------------------------------------------------------------------------
2777
2778/// Build call data for a single file.
2779pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2780    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2781        message: format!("unsupported file for call graph: {}", path.display()),
2782    })?;
2783
2784    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2785        path: format!("{}: {}", path.display(), e),
2786    })?;
2787
2788    let grammar = grammar_for(lang);
2789    let mut parser = Parser::new();
2790    parser
2791        .set_language(&grammar)
2792        .map_err(|e| AftError::ParseError {
2793            message: format!("grammar init failed for {:?}: {}", lang, e),
2794        })?;
2795
2796    let tree = parser
2797        .parse(&source, None)
2798        .ok_or_else(|| AftError::ParseError {
2799            message: format!("parse failed for {}", path.display()),
2800        })?;
2801
2802    // Parse imports
2803    let import_block = imports::parse_imports(&source, &tree, lang);
2804
2805    // Get symbols (for call site extraction and export detection)
2806    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2807
2808    // Build calls_by_symbol
2809    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2810    let root = tree.root_node();
2811
2812    for sym in &symbols {
2813        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2814        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2815
2816        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2817
2818        let sites: Vec<CallSite> = raw_calls
2819            .into_iter()
2820            .map(
2821                |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2822                    callee_name: short,
2823                    full_callee: full,
2824                    line,
2825                    byte_start: call_byte_start,
2826                    byte_end: call_byte_end,
2827                },
2828            )
2829            .collect();
2830
2831        if !sites.is_empty() {
2832            calls_by_symbol.insert(symbol_identity(sym), sites);
2833        }
2834    }
2835
2836    let symbol_ranges: Vec<(usize, usize)> = symbols
2837        .iter()
2838        .map(|sym| {
2839            (
2840                line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2841                line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2842            )
2843        })
2844        .collect();
2845
2846    let top_level_sites: Vec<CallSite> =
2847        collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2848            .into_iter()
2849            .filter(|site| {
2850                !symbol_ranges
2851                    .iter()
2852                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2853            })
2854            .map(|site| CallSite {
2855                callee_name: site.short,
2856                full_callee: site.full,
2857                line: site.line,
2858                byte_start: site.byte_start,
2859                byte_end: site.byte_end,
2860            })
2861            .collect();
2862
2863    if !top_level_sites.is_empty() {
2864        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2865    }
2866
2867    let default_export = find_default_export(&source, root, path, lang);
2868
2869    if let Some(default_export) = &default_export {
2870        if default_export.synthetic {
2871            let byte_start = default_export.node.byte_range().start;
2872            let byte_end = default_export.node.byte_range().end;
2873            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2874            let sites: Vec<CallSite> = raw_calls
2875                .into_iter()
2876                .filter(|(_, short, _, _, _)| *short != default_export.symbol)
2877                .map(
2878                    |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2879                        callee_name: short,
2880                        full_callee: full,
2881                        line,
2882                        byte_start: call_byte_start,
2883                        byte_end: call_byte_end,
2884                    },
2885                )
2886                .collect();
2887            if !sites.is_empty() {
2888                calls_by_symbol.insert(default_export.symbol.clone(), sites);
2889            }
2890        }
2891    }
2892
2893    // Collect exported symbol names
2894    let mut exported_symbols: Vec<String> = symbols
2895        .iter()
2896        .filter(|s| s.exported)
2897        .map(|s| s.name.clone())
2898        .collect();
2899    if let Some(default_export) = &default_export {
2900        if !exported_symbols
2901            .iter()
2902            .any(|name| name == &default_export.symbol)
2903        {
2904            exported_symbols.push(default_export.symbol.clone());
2905        }
2906    }
2907
2908    // Build per-symbol metadata for entry point detection
2909    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2910        .iter()
2911        .map(|s| {
2912            (
2913                symbol_identity(s),
2914                SymbolMeta {
2915                    kind: s.kind.clone(),
2916                    exported: s.exported,
2917                    signature: s.signature.clone(),
2918                    line: s.range.start_line + 1,
2919                    range: s.range.clone(),
2920                },
2921            )
2922        })
2923        .collect();
2924    if let Some(default_export) = &default_export {
2925        symbol_metadata
2926            .entry(default_export.symbol.clone())
2927            .or_insert_with(|| SymbolMeta {
2928                kind: default_export.kind.clone(),
2929                exported: true,
2930                signature: Some(first_line_signature(&source, &default_export.node)),
2931                line: default_export.node.start_position().row as u32 + 1,
2932                range: crate::parser::node_range(&default_export.node),
2933            });
2934    }
2935    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2936        symbol_metadata
2937            .entry(TOP_LEVEL_SYMBOL.to_string())
2938            .or_insert(SymbolMeta {
2939                kind: SymbolKind::Function,
2940                exported: false,
2941                signature: None,
2942                line: 1,
2943                range: Range {
2944                    start_line: 0,
2945                    start_col: 0,
2946                    end_line: 0,
2947                    end_col: 0,
2948                },
2949            });
2950    }
2951
2952    Ok(FileCallData {
2953        calls_by_symbol,
2954        exported_symbols,
2955        symbol_metadata,
2956        default_export_symbol: default_export.map(|export| export.symbol),
2957        import_block,
2958        lang,
2959    })
2960}
2961
2962#[derive(Debug, Clone)]
2963struct DefaultExport<'tree> {
2964    symbol: String,
2965    synthetic: bool,
2966    kind: SymbolKind,
2967    node: Node<'tree>,
2968}
2969
2970fn find_default_export<'tree>(
2971    source: &str,
2972    root: Node<'tree>,
2973    path: &Path,
2974    lang: LangId,
2975) -> Option<DefaultExport<'tree>> {
2976    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2977        return None;
2978    }
2979    find_default_export_inner(source, root, path)
2980}
2981
2982fn find_default_export_inner<'tree>(
2983    source: &str,
2984    node: Node<'tree>,
2985    path: &Path,
2986) -> Option<DefaultExport<'tree>> {
2987    if node.kind() == "export_statement" {
2988        if let Some(default_export) = default_export_from_statement(source, node, path) {
2989            return Some(default_export);
2990        }
2991    }
2992
2993    let mut cursor = node.walk();
2994    if !cursor.goto_first_child() {
2995        return None;
2996    }
2997
2998    loop {
2999        let child = cursor.node();
3000        if let Some(default_export) = find_default_export_inner(source, child, path) {
3001            return Some(default_export);
3002        }
3003        if !cursor.goto_next_sibling() {
3004            break;
3005        }
3006    }
3007
3008    None
3009}
3010
3011fn default_export_from_statement<'tree>(
3012    source: &str,
3013    node: Node<'tree>,
3014    path: &Path,
3015) -> Option<DefaultExport<'tree>> {
3016    let mut cursor = node.walk();
3017    if !cursor.goto_first_child() {
3018        return None;
3019    }
3020
3021    let mut saw_default = false;
3022    loop {
3023        let child = cursor.node();
3024        match child.kind() {
3025            "default" => saw_default = true,
3026            "function_declaration" | "generator_function_declaration" | "class_declaration"
3027                if saw_default =>
3028            {
3029                if let Some(name_node) = child.child_by_field_name("name") {
3030                    return Some(DefaultExport {
3031                        symbol: source[name_node.byte_range()].to_string(),
3032                        synthetic: false,
3033                        kind: default_export_kind(&child),
3034                        node: child,
3035                    });
3036                }
3037                return Some(DefaultExport {
3038                    symbol: synthetic_default_symbol(path),
3039                    synthetic: true,
3040                    kind: default_export_kind(&child),
3041                    node: child,
3042                });
3043            }
3044            "arrow_function"
3045            | "function"
3046            | "function_expression"
3047            | "class"
3048            | "class_expression"
3049                if saw_default =>
3050            {
3051                return Some(DefaultExport {
3052                    symbol: synthetic_default_symbol(path),
3053                    synthetic: true,
3054                    kind: default_export_kind(&child),
3055                    node: child,
3056                });
3057            }
3058            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
3059                return Some(DefaultExport {
3060                    symbol: source[child.byte_range()].to_string(),
3061                    synthetic: false,
3062                    kind: SymbolKind::Function,
3063                    node: child,
3064                });
3065            }
3066            _ => {}
3067        }
3068        if !cursor.goto_next_sibling() {
3069            break;
3070        }
3071    }
3072
3073    None
3074}
3075
3076fn default_export_kind(node: &Node) -> SymbolKind {
3077    if node.kind().contains("class") {
3078        SymbolKind::Class
3079    } else {
3080        SymbolKind::Function
3081    }
3082}
3083
3084fn synthetic_default_symbol(path: &Path) -> String {
3085    let file_name = path
3086        .file_name()
3087        .and_then(|name| name.to_str())
3088        .unwrap_or("unknown");
3089    format!("<default:{file_name}>")
3090}
3091
3092fn first_line_signature(source: &str, node: &Node) -> String {
3093    let text = &source[node.byte_range()];
3094    let first_line = text.lines().next().unwrap_or(text);
3095    first_line
3096        .trim_end()
3097        .trim_end_matches('{')
3098        .trim_end()
3099        .to_string()
3100}
3101
3102fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
3103    file_data
3104        .symbol_metadata
3105        .get(symbol_name)
3106        .map(|meta| (meta.line, meta.signature.clone()))
3107        .unwrap_or((1, None))
3108}
3109
3110/// Get symbol metadata (line, signature) from a file.
3111fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
3112    let provider = crate::parser::TreeSitterProvider::new();
3113    match provider.list_symbols(path) {
3114        Ok(symbols) => {
3115            for s in &symbols {
3116                if symbol_identity(s) == symbol_name || s.name == symbol_name {
3117                    return (s.range.start_line + 1, s.signature.clone());
3118                }
3119            }
3120            (1, None)
3121        }
3122        Err(_) => (1, None),
3123    }
3124}
3125
3126// ---------------------------------------------------------------------------
3127// Data flow tracking helpers
3128// ---------------------------------------------------------------------------
3129
3130/// Get the text of a tree-sitter node from the source.
3131fn node_text(node: tree_sitter::Node, source: &str) -> String {
3132    source[node.start_byte()..node.end_byte()].to_string()
3133}
3134
3135/// Find the smallest node that fully covers a byte range.
3136fn find_node_covering_range(
3137    root: tree_sitter::Node,
3138    start: usize,
3139    end: usize,
3140) -> Option<tree_sitter::Node> {
3141    let mut best = None;
3142    let mut cursor = root.walk();
3143
3144    fn walk_covering<'a>(
3145        cursor: &mut tree_sitter::TreeCursor<'a>,
3146        start: usize,
3147        end: usize,
3148        best: &mut Option<tree_sitter::Node<'a>>,
3149    ) {
3150        let node = cursor.node();
3151        if node.start_byte() <= start && node.end_byte() >= end {
3152            *best = Some(node);
3153            if cursor.goto_first_child() {
3154                loop {
3155                    walk_covering(cursor, start, end, best);
3156                    if !cursor.goto_next_sibling() {
3157                        break;
3158                    }
3159                }
3160                cursor.goto_parent();
3161            }
3162        }
3163    }
3164
3165    walk_covering(&mut cursor, start, end, &mut best);
3166    best
3167}
3168
3169/// Find a direct child node by kind name.
3170fn find_child_by_kind<'a>(
3171    node: tree_sitter::Node<'a>,
3172    kind: &str,
3173) -> Option<tree_sitter::Node<'a>> {
3174    let mut cursor = node.walk();
3175    if cursor.goto_first_child() {
3176        loop {
3177            if cursor.node().kind() == kind {
3178                return Some(cursor.node());
3179            }
3180            if !cursor.goto_next_sibling() {
3181                break;
3182            }
3183        }
3184    }
3185    None
3186}
3187
3188#[derive(Debug, Clone)]
3189struct CallSiteWithRange {
3190    full: String,
3191    short: String,
3192    line: u32,
3193    byte_start: usize,
3194    byte_end: usize,
3195}
3196
3197fn collect_calls_full_with_ranges(
3198    root: tree_sitter::Node,
3199    source: &str,
3200    byte_start: usize,
3201    byte_end: usize,
3202    lang: LangId,
3203) -> Vec<CallSiteWithRange> {
3204    let mut results = Vec::new();
3205    let call_kinds = call_node_kinds(lang);
3206    collect_calls_full_with_ranges_inner(
3207        root,
3208        source,
3209        byte_start,
3210        byte_end,
3211        &call_kinds,
3212        &mut results,
3213    );
3214    results
3215}
3216
3217fn collect_calls_full_with_ranges_inner(
3218    node: tree_sitter::Node,
3219    source: &str,
3220    byte_start: usize,
3221    byte_end: usize,
3222    call_kinds: &[&str],
3223    results: &mut Vec<CallSiteWithRange>,
3224) {
3225    let node_start = node.start_byte();
3226    let node_end = node.end_byte();
3227
3228    if node_end <= byte_start || node_start >= byte_end {
3229        return;
3230    }
3231
3232    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
3233        if let (Some(full), Some(short)) = (
3234            extract_full_callee(&node, source),
3235            extract_callee_name(&node, source),
3236        ) {
3237            results.push(CallSiteWithRange {
3238                full,
3239                short,
3240                line: node.start_position().row as u32 + 1,
3241                byte_start: node_start,
3242                byte_end: node_end,
3243            });
3244        }
3245    }
3246
3247    let mut cursor = node.walk();
3248    if cursor.goto_first_child() {
3249        loop {
3250            collect_calls_full_with_ranges_inner(
3251                cursor.node(),
3252                source,
3253                byte_start,
3254                byte_end,
3255                call_kinds,
3256                results,
3257            );
3258            if !cursor.goto_next_sibling() {
3259                break;
3260            }
3261        }
3262    }
3263}
3264
3265/// Extract full and short callee names from a call_expression node.
3266fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
3267    // The "function" field holds the callee
3268    let callee = match node.child_by_field_name("function") {
3269        Some(c) => c,
3270        None => return (None, None),
3271    };
3272
3273    let full = node_text(callee, source);
3274    let short = if full.contains('.') {
3275        full.rsplit('.').next().unwrap_or(&full).to_string()
3276    } else {
3277        full.clone()
3278    };
3279
3280    (Some(full), Some(short))
3281}
3282
3283// ---------------------------------------------------------------------------
3284// Module path resolution
3285// ---------------------------------------------------------------------------
3286
3287/// Resolve a module path (e.g. './utils') relative to a directory.
3288///
3289/// Tries common file extensions for TypeScript/JavaScript projects.
3290pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3291    if module_path.starts_with('.') {
3292        return resolve_relative_module_path(from_dir, module_path);
3293    }
3294
3295    if module_path.starts_with('/') {
3296        return None;
3297    }
3298
3299    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
3300        return Some(path);
3301    }
3302
3303    resolve_workspace_module_path(from_dir, module_path)
3304}
3305
3306fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3307    let base = from_dir.join(module_path);
3308    resolve_file_like_path(&base)
3309}
3310
3311fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
3312    let base = base.to_path_buf();
3313
3314    // Try exact path first
3315    if base.is_file() {
3316        return Some(std::fs::canonicalize(&base).unwrap_or(base));
3317    }
3318
3319    // Try common extensions, including ESM/CJS TypeScript pairs used by workspaces.
3320    for ext in JS_TS_EXTENSIONS {
3321        let with_ext = base.with_extension(ext);
3322        if with_ext.is_file() {
3323            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
3324        }
3325    }
3326
3327    // Try as directory with index file
3328    if base.is_dir() {
3329        if let Some(index) = find_index_file(&base) {
3330            return Some(index);
3331        }
3332    }
3333
3334    None
3335}
3336
3337fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3338    let (package_name, subpath) = split_package_import(module_path)?;
3339    let package_root = find_package_root_for_import(from_dir, &package_name)?;
3340    resolve_package_entry(&package_root, &subpath)
3341}
3342
3343fn is_rust_source_file(path: &Path) -> bool {
3344    path.extension().and_then(|ext| ext.to_str()) == Some("rs")
3345}
3346
3347fn resolve_rust_cross_file_edge<F>(
3348    full_callee: &str,
3349    short_name: &str,
3350    caller_file: &Path,
3351    import_block: &ImportBlock,
3352    file_exports_symbol: &mut F,
3353) -> Option<ResolvedSymbol>
3354where
3355    F: FnMut(&Path, &str) -> bool,
3356{
3357    if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
3358    {
3359        return Some(target);
3360    }
3361
3362    resolve_rust_imported_call(
3363        caller_file,
3364        full_callee,
3365        short_name,
3366        import_block,
3367        file_exports_symbol,
3368    )
3369}
3370
3371fn resolve_rust_qualified_call<F>(
3372    caller_file: &Path,
3373    full_callee: &str,
3374    file_exports_symbol: &mut F,
3375) -> Option<ResolvedSymbol>
3376where
3377    F: FnMut(&Path, &str) -> bool,
3378{
3379    if !full_callee.contains("::") {
3380        return None;
3381    }
3382
3383    let segments = rust_path_segments(full_callee)?;
3384    resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
3385}
3386
3387fn resolve_rust_imported_call<F>(
3388    caller_file: &Path,
3389    full_callee: &str,
3390    short_name: &str,
3391    import_block: &ImportBlock,
3392    file_exports_symbol: &mut F,
3393) -> Option<ResolvedSymbol>
3394where
3395    F: FnMut(&Path, &str) -> bool,
3396{
3397    let call_segments = rust_path_segments(full_callee).unwrap_or_default();
3398    let bare_call_name = if call_segments.len() <= 1 {
3399        call_segments
3400            .first()
3401            .map(String::as_str)
3402            .unwrap_or(short_name)
3403    } else {
3404        short_name
3405    };
3406
3407    for imp in &import_block.imports {
3408        for entry in rust_use_entries(imp) {
3409            match &entry.kind {
3410                RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
3411                    if entry.local_name != bare_call_name {
3412                        continue;
3413                    }
3414                    let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
3415                    else {
3416                        continue;
3417                    };
3418                    if file_exports_symbol(&file, imported_name) {
3419                        return Some(ResolvedSymbol {
3420                            file,
3421                            symbol: imported_name.clone(),
3422                        });
3423                    }
3424                }
3425                RustUseKind::Module if call_segments.len() >= 2 => {
3426                    if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
3427                    {
3428                        continue;
3429                    }
3430                    let symbol = call_segments.last()?.clone();
3431                    let mut module_path = entry.module_path.clone();
3432                    for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
3433                        module_path.push_str("::");
3434                        module_path.push_str(segment);
3435                    }
3436                    let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
3437                        continue;
3438                    };
3439                    if file_exports_symbol(&file, &symbol) {
3440                        return Some(ResolvedSymbol { file, symbol });
3441                    }
3442                }
3443                _ => {}
3444            }
3445        }
3446    }
3447
3448    None
3449}
3450
3451fn resolve_rust_call_segments<F>(
3452    caller_file: &Path,
3453    segments: &[String],
3454    file_exports_symbol: &mut F,
3455) -> Option<ResolvedSymbol>
3456where
3457    F: FnMut(&Path, &str) -> bool,
3458{
3459    if segments.len() < 2 {
3460        return None;
3461    }
3462
3463    let symbol = segments.last()?.clone();
3464    let module_path = segments[..segments.len() - 1].join("::");
3465    let file = resolve_rust_module_path(caller_file, &module_path)?;
3466    if file_exports_symbol(&file, &symbol) {
3467        Some(ResolvedSymbol { file, symbol })
3468    } else {
3469        None
3470    }
3471}
3472
3473fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
3474    let segments = rust_path_segments(module_path)?;
3475    let first = segments.first()?.as_str();
3476
3477    match first {
3478        "std" | "core" | "alloc" => None,
3479        "crate" => {
3480            let crate_root = find_rust_crate_root(caller_file)?;
3481            let crate_info = rust_crate_info(&crate_root)?;
3482            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3483            resolve_rust_module_segments(&base, &segments[1..])
3484        }
3485        "self" => {
3486            let crate_root = find_rust_crate_root(caller_file)?;
3487            let crate_info = rust_crate_info(&crate_root)?;
3488            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3489            if segments.len() == 1 {
3490                return Some(canonicalize_path(caller_file));
3491            }
3492            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3493            target_segments.extend(segments[1..].iter().cloned());
3494            resolve_rust_module_segments(&base, &target_segments)
3495        }
3496        "super" => {
3497            let crate_root = find_rust_crate_root(caller_file)?;
3498            let crate_info = rust_crate_info(&crate_root)?;
3499            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3500            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3501            target_segments.pop();
3502            target_segments.extend(segments[1..].iter().cloned());
3503            resolve_rust_module_segments(&base, &target_segments)
3504        }
3505        crate_name => {
3506            let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
3507            let workspace_crates = rust_workspace_crates(caller_dir)?;
3508            let crate_info = workspace_crates.get(crate_name)?;
3509            let base = rust_lib_module_base(crate_info)?;
3510            resolve_rust_module_segments(&base, &segments[1..])
3511        }
3512    }
3513}
3514
3515fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
3516    let Some(body) = rust_use_body(&imp.raw_text) else {
3517        return Vec::new();
3518    };
3519    let mut entries = Vec::new();
3520    expand_rust_use_tree(body, &mut entries);
3521    entries
3522}
3523
3524fn rust_use_body(raw: &str) -> Option<&str> {
3525    let use_pos = raw.find("use ")?;
3526    let body = raw[use_pos + 4..].trim();
3527    let body = body.strip_suffix(';').unwrap_or(body).trim();
3528    (!body.is_empty()).then_some(body)
3529}
3530
3531fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
3532    let path = path.trim();
3533    if path.is_empty() {
3534        return;
3535    }
3536
3537    if let Some((prefix, inner)) = split_rust_use_braces(path) {
3538        let prefix = prefix.trim().trim_end_matches("::").trim();
3539        for part in split_top_level_commas(inner) {
3540            let part = part.trim();
3541            if part.is_empty() {
3542                continue;
3543            }
3544            if part == "self" {
3545                if let Some(local_name) = rust_last_path_segment(prefix) {
3546                    entries.push(RustUseEntry {
3547                        module_path: prefix.to_string(),
3548                        local_name,
3549                        kind: RustUseKind::Module,
3550                    });
3551                }
3552                continue;
3553            }
3554            let combined = if prefix.is_empty() {
3555                part.to_string()
3556            } else {
3557                format!("{prefix}::{part}")
3558            };
3559            expand_rust_use_tree(&combined, entries);
3560        }
3561        return;
3562    }
3563
3564    add_rust_use_leaf(path, entries);
3565}
3566
3567fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
3568    let mut depth = 0usize;
3569    let mut start = None;
3570    for (idx, ch) in path.char_indices() {
3571        match ch {
3572            '{' => {
3573                if depth == 0 {
3574                    start = Some(idx);
3575                }
3576                depth += 1;
3577            }
3578            '}' => {
3579                depth = depth.checked_sub(1)?;
3580                if depth == 0 {
3581                    let start = start?;
3582                    if !path[idx + ch.len_utf8()..].trim().is_empty() {
3583                        return None;
3584                    }
3585                    return Some((&path[..start], &path[start + 1..idx]));
3586                }
3587            }
3588            _ => {}
3589        }
3590    }
3591    None
3592}
3593
3594fn split_top_level_commas(value: &str) -> Vec<&str> {
3595    let mut parts = Vec::new();
3596    let mut depth = 0usize;
3597    let mut start = 0usize;
3598    for (idx, ch) in value.char_indices() {
3599        match ch {
3600            '{' => depth += 1,
3601            '}' => depth = depth.saturating_sub(1),
3602            ',' if depth == 0 => {
3603                parts.push(&value[start..idx]);
3604                start = idx + ch.len_utf8();
3605            }
3606            _ => {}
3607        }
3608    }
3609    parts.push(&value[start..]);
3610    parts
3611}
3612
3613fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
3614    let (path, alias) = split_rust_alias(path);
3615    let Some(segments) = rust_path_segments(path) else {
3616        return;
3617    };
3618    if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
3619        return;
3620    }
3621
3622    let imported_name = segments.last().cloned().unwrap_or_default();
3623    let local_name = alias.unwrap_or(&imported_name).to_string();
3624    if segments.len() >= 2 {
3625        entries.push(RustUseEntry {
3626            module_path: segments[..segments.len() - 1].join("::"),
3627            local_name: local_name.clone(),
3628            kind: RustUseKind::Item {
3629                imported_name: imported_name.clone(),
3630            },
3631        });
3632    }
3633
3634    entries.push(RustUseEntry {
3635        module_path: segments.join("::"),
3636        local_name,
3637        kind: RustUseKind::Module,
3638    });
3639}
3640
3641fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
3642    if let Some(idx) = path.rfind(" as ") {
3643        let original = path[..idx].trim();
3644        let alias = path[idx + 4..].trim();
3645        if !original.is_empty() && !alias.is_empty() {
3646            return (original, Some(alias));
3647        }
3648    }
3649    (path.trim(), None)
3650}
3651
3652fn rust_path_segments(path: &str) -> Option<Vec<String>> {
3653    let path = path.trim().trim_end_matches(';').trim();
3654    if path.is_empty() || path.contains('{') || path.contains('}') {
3655        return None;
3656    }
3657
3658    let mut segments = Vec::new();
3659    for raw_segment in path.split("::") {
3660        let segment = raw_segment.trim();
3661        if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
3662            return None;
3663        }
3664        let segment = segment.strip_prefix("r#").unwrap_or(segment);
3665        if segment
3666            .chars()
3667            .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
3668        {
3669            return None;
3670        }
3671        segments.push(segment.to_string());
3672    }
3673
3674    (!segments.is_empty()).then_some(segments)
3675}
3676
3677fn rust_last_path_segment(path: &str) -> Option<String> {
3678    rust_path_segments(path)?.last().cloned()
3679}
3680
3681fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
3682    let mut current = if from.is_file() {
3683        from.parent()
3684    } else {
3685        Some(from)
3686    };
3687    while let Some(dir) = current {
3688        if dir.join("Cargo.toml").is_file() {
3689            return Some(canonicalize_path(dir));
3690        }
3691        current = dir.parent();
3692    }
3693    None
3694}
3695
3696fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3697    let root = canonicalize_path(crate_root);
3698    if let Some(cached) = RUST_CRATE_INFO_CACHE
3699        .read()
3700        .ok()
3701        .and_then(|cache| cache.get(&root).cloned())
3702    {
3703        return cached;
3704    }
3705
3706    let resolved = read_rust_crate_info(&root);
3707    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
3708        cache.insert(root, resolved.clone());
3709    }
3710    resolved
3711}
3712
3713fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3714    let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
3715    let package = cargo.get("package")?;
3716    let package_name = package.get("name")?.as_str()?;
3717    let lib_name = cargo
3718        .get("lib")
3719        .and_then(|lib| lib.get("name"))
3720        .and_then(|name| name.as_str())
3721        .map(ToOwned::to_owned)
3722        .unwrap_or_else(|| package_name.replace('-', "_"));
3723
3724    let lib_root = cargo
3725        .get("lib")
3726        .and_then(|lib| lib.get("path"))
3727        .and_then(|path| path.as_str())
3728        .map(|path| crate_root.join(path))
3729        .unwrap_or_else(|| crate_root.join("src/lib.rs"));
3730    let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
3731
3732    let main_root = crate_root.join("src/main.rs");
3733    let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
3734
3735    Some(RustCrateInfo {
3736        lib_name,
3737        lib_root,
3738        main_root,
3739    })
3740}
3741
3742fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
3743    let source = std::fs::read_to_string(path).ok()?;
3744    toml::from_str(&source).ok()
3745}
3746
3747fn rust_module_base_for_caller(
3748    crate_info: &RustCrateInfo,
3749    caller_file: &Path,
3750) -> Option<RustModuleBase> {
3751    let caller = canonicalize_path(caller_file);
3752    if crate_info.main_root.as_ref() == Some(&caller) {
3753        return rust_main_module_base(crate_info);
3754    }
3755    rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
3756}
3757
3758fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3759    let root_file = crate_info.lib_root.clone()?;
3760    let src_dir = root_file.parent()?.to_path_buf();
3761    Some(RustModuleBase { src_dir, root_file })
3762}
3763
3764fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3765    let root_file = crate_info.main_root.clone()?;
3766    let src_dir = root_file.parent()?.to_path_buf();
3767    Some(RustModuleBase { src_dir, root_file })
3768}
3769
3770fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
3771    if segments.is_empty() {
3772        return Some(base.root_file.clone());
3773    }
3774
3775    let module_base = segments
3776        .iter()
3777        .fold(base.src_dir.clone(), |path, segment| path.join(segment));
3778    let file_path = module_base.with_extension("rs");
3779    if file_path.is_file() {
3780        return Some(canonicalize_path(&file_path));
3781    }
3782
3783    let mod_path = module_base.join("mod.rs");
3784    if mod_path.is_file() {
3785        return Some(canonicalize_path(&mod_path));
3786    }
3787
3788    None
3789}
3790
3791fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
3792    let src_dir = canonicalize_path(src_dir);
3793    let file = canonicalize_path(file);
3794    let rel = file.strip_prefix(&src_dir).ok()?;
3795    let mut parts: Vec<String> = rel
3796        .components()
3797        .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
3798        .collect();
3799    if parts.is_empty() {
3800        return None;
3801    }
3802
3803    let last = parts.pop()?;
3804    if last == "lib.rs" || last == "main.rs" {
3805        return Some(Vec::new());
3806    }
3807    if last == "mod.rs" {
3808        return Some(parts);
3809    }
3810    let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
3811    parts.push(stem);
3812    Some(parts)
3813}
3814
3815fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
3816    let workspace_root =
3817        find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
3818    let workspace_root = canonicalize_path(&workspace_root);
3819
3820    if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
3821        .read()
3822        .ok()
3823        .and_then(|cache| cache.get(&workspace_root).cloned())
3824    {
3825        return Some(cached);
3826    }
3827
3828    let mut crates = HashMap::new();
3829    for member in rust_workspace_member_dirs(&workspace_root) {
3830        if let Some(info) = rust_crate_info(&member) {
3831            if info.lib_root.is_some() {
3832                crates.insert(info.lib_name.clone(), info);
3833            }
3834        }
3835    }
3836    if let Some(info) = rust_crate_info(&workspace_root) {
3837        if info.lib_root.is_some() {
3838            crates.insert(info.lib_name.clone(), info);
3839        }
3840    }
3841
3842    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
3843        cache.insert(workspace_root, crates.clone());
3844    }
3845    Some(crates)
3846}
3847
3848fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3849    let mut current = Some(from_dir);
3850    while let Some(dir) = current {
3851        let cargo = dir.join("Cargo.toml");
3852        if rust_manifest_value(&cargo)
3853            .and_then(|value| value.get("workspace").cloned())
3854            .is_some()
3855        {
3856            return Some(canonicalize_path(dir));
3857        }
3858        current = dir.parent();
3859    }
3860    None
3861}
3862
3863fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
3864    let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
3865        return Vec::new();
3866    };
3867    let Some(members) = cargo
3868        .get("workspace")
3869        .and_then(|workspace| workspace.get("members"))
3870        .and_then(|members| members.as_array())
3871    else {
3872        return Vec::new();
3873    };
3874
3875    let mut dirs = Vec::new();
3876    for member in members.iter().filter_map(|member| member.as_str()) {
3877        dirs.extend(expand_rust_workspace_member(workspace_root, member));
3878    }
3879    dirs.sort();
3880    dirs.dedup();
3881    dirs
3882}
3883
3884fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
3885    let member = member.trim();
3886    if member.is_empty() {
3887        return Vec::new();
3888    }
3889
3890    if member.contains('*') || member.contains('?') || member.contains('[') {
3891        let pattern = workspace_root.join(member).to_string_lossy().to_string();
3892        return glob::glob(&pattern)
3893            .ok()
3894            .into_iter()
3895            .flatten()
3896            .filter_map(Result::ok)
3897            .filter(|path| path.join("Cargo.toml").is_file())
3898            .map(|path| canonicalize_path(&path))
3899            .collect();
3900    }
3901
3902    let path = workspace_root.join(member);
3903    if path.join("Cargo.toml").is_file() {
3904        vec![canonicalize_path(&path)]
3905    } else {
3906        Vec::new()
3907    }
3908}
3909
3910fn canonicalize_path(path: &Path) -> PathBuf {
3911    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3912}
3913
3914fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3915    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
3916    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
3917    let compiler_options = tsconfig.get("compilerOptions")?;
3918    let paths = compiler_options.get("paths")?.as_object()?;
3919    let base_url = compiler_options
3920        .get("baseUrl")
3921        .and_then(Value::as_str)
3922        .unwrap_or(".");
3923    let base_dir = tsconfig_dir.join(base_url);
3924
3925    for (alias, targets) in paths {
3926        let Some(capture) = ts_path_capture(alias, module_path) else {
3927            continue;
3928        };
3929        let Some(targets) = targets.as_array() else {
3930            continue;
3931        };
3932        for target in targets.iter().filter_map(Value::as_str) {
3933            let target = if target.contains('*') {
3934                target.replace('*', capture)
3935            } else {
3936                target.to_string()
3937            };
3938            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
3939                return Some(path);
3940            }
3941        }
3942    }
3943
3944    None
3945}
3946
3947fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
3948    let mut current = Some(from_dir);
3949    while let Some(dir) = current {
3950        if dir.join("tsconfig.json").is_file() {
3951            return Some(dir.to_path_buf());
3952        }
3953        current = dir.parent();
3954    }
3955    None
3956}
3957
3958fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
3959    if let Some(star_index) = alias.find('*') {
3960        let (prefix, suffix_with_star) = alias.split_at(star_index);
3961        let suffix = &suffix_with_star[1..];
3962        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
3963            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
3964        }
3965        return None;
3966    }
3967
3968    (alias == module_path).then_some("")
3969}
3970
3971fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
3972    let mut parts = module_path.split('/');
3973    let first = parts.next()?;
3974    if first.is_empty() {
3975        return None;
3976    }
3977
3978    if first.starts_with('@') {
3979        let second = parts.next()?;
3980        if second.is_empty() {
3981            return None;
3982        }
3983        let package_name = format!("{first}/{second}");
3984        let subpath = parts.collect::<Vec<_>>().join("/");
3985        let subpath = (!subpath.is_empty()).then_some(subpath);
3986        Some((package_name, subpath))
3987    } else {
3988        let package_name = first.to_string();
3989        let subpath = parts.collect::<Vec<_>>().join("/");
3990        let subpath = (!subpath.is_empty()).then_some(subpath);
3991        Some((package_name, subpath))
3992    }
3993}
3994
3995fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
3996    let mut current = Some(from_dir);
3997    while let Some(dir) = current {
3998        if package_json_name(dir).as_deref() == Some(package_name) {
3999            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4000        }
4001        current = dir.parent();
4002    }
4003
4004    find_workspace_root(from_dir)
4005        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
4006}
4007
4008fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
4009    let mut current = Some(from_dir);
4010    while let Some(dir) = current {
4011        if is_workspace_root(dir) {
4012            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4013        }
4014        current = dir.parent();
4015    }
4016    None
4017}
4018
4019fn is_workspace_root(dir: &Path) -> bool {
4020    package_json_value(dir)
4021        .map(|value| !workspace_patterns(&value).is_empty())
4022        .unwrap_or(false)
4023        || !pnpm_workspace_patterns(dir).is_empty()
4024}
4025
4026fn clear_workspace_package_cache() {
4027    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4028        cache.clear();
4029    }
4030    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
4031        cache.clear();
4032    }
4033    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
4034        cache.clear();
4035    }
4036}
4037
4038fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
4039    let workspace_root =
4040        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
4041    let cache_key = (workspace_root.clone(), package_name.to_string());
4042
4043    if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
4044        if let Some(cached) = cache.get(&cache_key) {
4045            return cached.clone();
4046        }
4047    }
4048
4049    let resolved = workspace_member_dirs(&workspace_root)
4050        .into_iter()
4051        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
4052        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
4053
4054    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4055        cache.insert(cache_key, resolved.clone());
4056    }
4057
4058    resolved
4059}
4060
4061fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
4062    let mut patterns = package_json_value(workspace_root)
4063        .map(|package_json| workspace_patterns(&package_json))
4064        .unwrap_or_default();
4065    patterns.extend(pnpm_workspace_patterns(workspace_root));
4066
4067    expand_workspace_patterns(workspace_root, &patterns)
4068}
4069
4070fn workspace_patterns(package_json: &Value) -> Vec<String> {
4071    match package_json.get("workspaces") {
4072        Some(Value::Array(items)) => items
4073            .iter()
4074            .filter_map(non_empty_workspace_pattern)
4075            .collect(),
4076        Some(Value::Object(map)) => map
4077            .get("packages")
4078            .and_then(Value::as_array)
4079            .map(|items| {
4080                items
4081                    .iter()
4082                    .filter_map(non_empty_workspace_pattern)
4083                    .collect()
4084            })
4085            .unwrap_or_default(),
4086        _ => Vec::new(),
4087    }
4088}
4089
4090fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
4091    let pattern = value.as_str()?.trim();
4092    (!pattern.is_empty()).then(|| pattern.to_string())
4093}
4094
4095fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
4096    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
4097        return Vec::new();
4098    };
4099
4100    let mut patterns = Vec::new();
4101    let mut in_packages = false;
4102    for line in source.lines() {
4103        let without_comment = line.split('#').next().unwrap_or("").trim_end();
4104        let trimmed = without_comment.trim();
4105        if trimmed.is_empty() {
4106            continue;
4107        }
4108        if trimmed == "packages:" {
4109            in_packages = true;
4110            continue;
4111        }
4112        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
4113            in_packages = false;
4114        }
4115        if in_packages {
4116            if let Some(pattern) = trimmed.strip_prefix('-') {
4117                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
4118                if !pattern.is_empty() {
4119                    patterns.push(pattern.to_string());
4120                }
4121            }
4122        }
4123    }
4124    patterns
4125}
4126
4127fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
4128    let positive_patterns: Vec<&str> = patterns
4129        .iter()
4130        .map(|pattern| pattern.trim())
4131        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
4132        .collect();
4133    if positive_patterns.is_empty() {
4134        return Vec::new();
4135    }
4136
4137    let positives = build_glob_set(&positive_patterns);
4138    let negative_patterns: Vec<&str> = patterns
4139        .iter()
4140        .map(|pattern| pattern.trim())
4141        .filter_map(|pattern| pattern.strip_prefix('!'))
4142        .map(str::trim)
4143        .filter(|pattern| !pattern.is_empty())
4144        .collect();
4145    let negatives = build_glob_set(&negative_patterns);
4146
4147    let mut members = Vec::new();
4148    collect_workspace_member_dirs(
4149        workspace_root,
4150        workspace_root,
4151        &positives,
4152        &negatives,
4153        &mut members,
4154    );
4155    members
4156}
4157
4158fn build_glob_set(patterns: &[&str]) -> GlobSet {
4159    let mut builder = GlobSetBuilder::new();
4160    for pattern in patterns {
4161        if let Ok(glob) = Glob::new(pattern) {
4162            builder.add(glob);
4163        }
4164    }
4165    builder
4166        .build()
4167        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
4168}
4169
4170fn collect_workspace_member_dirs(
4171    workspace_root: &Path,
4172    dir: &Path,
4173    positives: &GlobSet,
4174    negatives: &GlobSet,
4175    members: &mut Vec<PathBuf>,
4176) {
4177    let Ok(entries) = std::fs::read_dir(dir) else {
4178        return;
4179    };
4180
4181    for entry in entries.filter_map(Result::ok) {
4182        let path = entry.path();
4183        let Ok(file_type) = entry.file_type() else {
4184            continue;
4185        };
4186        if !file_type.is_dir() {
4187            continue;
4188        }
4189        let name = entry.file_name();
4190        let name = name.to_string_lossy();
4191        if matches!(
4192            name.as_ref(),
4193            "node_modules" | ".git" | "target" | "dist" | "build"
4194        ) {
4195            continue;
4196        }
4197
4198        if path.join("package.json").is_file() {
4199            if let Ok(rel) = path.strip_prefix(workspace_root) {
4200                let rel = rel.to_string_lossy().replace('\\', "/");
4201                if positives.is_match(&rel) && !negatives.is_match(&rel) {
4202                    members.push(path.clone());
4203                }
4204            }
4205        }
4206
4207        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
4208    }
4209}
4210
4211fn package_json_value(dir: &Path) -> Option<Value> {
4212    package_json_like_value(&dir.join("package.json"))
4213}
4214
4215fn package_json_like_value(path: &Path) -> Option<Value> {
4216    let json = std::fs::read_to_string(path).ok()?;
4217    serde_json::from_str(&json).ok()
4218}
4219
4220fn package_json_name(dir: &Path) -> Option<String> {
4221    package_json_value(dir)?
4222        .get("name")?
4223        .as_str()
4224        .map(ToOwned::to_owned)
4225}
4226
4227fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
4228    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
4229
4230    if let Some(exports) = package_json.get("exports") {
4231        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
4232            if let Some(path) = resolve_package_target(package_root, &target) {
4233                return Some(path);
4234            }
4235        }
4236    }
4237
4238    if subpath.is_none() {
4239        for field in ["module", "main"] {
4240            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
4241                if let Some(path) = resolve_package_target(package_root, target) {
4242                    return Some(path);
4243                }
4244            }
4245        }
4246    }
4247
4248    resolve_package_fallback(package_root, subpath.as_deref())
4249}
4250
4251fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
4252    let key = subpath
4253        .map(|value| format!("./{value}"))
4254        .unwrap_or_else(|| ".".to_string());
4255
4256    match exports {
4257        Value::String(target) if key == "." => Some(target.clone()),
4258        Value::Object(map) => {
4259            if let Some(target) = map.get(&key).and_then(export_condition_target) {
4260                return Some(target);
4261            }
4262
4263            if let Some(target) = wildcard_export_target(map, &key) {
4264                return Some(target);
4265            }
4266
4267            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
4268                return export_condition_target(exports);
4269            }
4270
4271            None
4272        }
4273        _ => None,
4274    }
4275}
4276
4277fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
4278    for (pattern, target) in map {
4279        let Some(star_index) = pattern.find('*') else {
4280            continue;
4281        };
4282        let (prefix, suffix_with_star) = pattern.split_at(star_index);
4283        let suffix = &suffix_with_star[1..];
4284        if !key.starts_with(prefix) || !key.ends_with(suffix) {
4285            continue;
4286        }
4287        let matched = &key[prefix.len()..key.len() - suffix.len()];
4288        if let Some(target_pattern) = export_condition_target(target) {
4289            return Some(target_pattern.replace('*', matched));
4290        }
4291    }
4292    None
4293}
4294
4295fn export_condition_target(value: &Value) -> Option<String> {
4296    match value {
4297        Value::String(target) => Some(target.clone()),
4298        Value::Object(map) => ["source", "import", "module", "default", "types"]
4299            .into_iter()
4300            .find_map(|field| map.get(field).and_then(export_condition_target)),
4301        _ => None,
4302    }
4303}
4304
4305fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
4306    let target = target.strip_prefix("./").unwrap_or(target);
4307    // Prefer source over compiled bundle when both exist: the callgraph
4308    // walks source files and cannot extract symbols from a built JS bundle.
4309    if let Some(src_relative) = target.strip_prefix("dist/") {
4310        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
4311            return Some(path);
4312        }
4313    }
4314
4315    resolve_file_like_path(&package_root.join(target))
4316}
4317
4318fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
4319    match subpath {
4320        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
4321            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
4322        None => resolve_file_like_path(&package_root.join("src").join("index"))
4323            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
4324    }
4325}
4326
4327pub(crate) fn resolve_reexported_symbol_target<F, D>(
4328    file: &Path,
4329    symbol_name: &str,
4330    file_exports_symbol: &mut F,
4331    file_default_export_symbol: &mut D,
4332) -> Option<(PathBuf, String)>
4333where
4334    F: FnMut(&Path, &str) -> bool,
4335    D: FnMut(&Path) -> Option<String>,
4336{
4337    resolve_reexported_symbol(
4338        file,
4339        symbol_name,
4340        file_exports_symbol,
4341        file_default_export_symbol,
4342    )
4343    .map(|target| (target.file, target.symbol))
4344}
4345
4346fn resolve_reexported_symbol<F, D>(
4347    file: &Path,
4348    symbol_name: &str,
4349    file_exports_symbol: &mut F,
4350    file_default_export_symbol: &mut D,
4351) -> Option<ResolvedSymbol>
4352where
4353    F: FnMut(&Path, &str) -> bool,
4354    D: FnMut(&Path) -> Option<String>,
4355{
4356    let mut visited = HashSet::new();
4357    resolve_reexported_symbol_inner(
4358        file,
4359        symbol_name,
4360        file_exports_symbol,
4361        file_default_export_symbol,
4362        &mut visited,
4363    )
4364}
4365
4366fn resolve_reexported_symbol_inner<F, D>(
4367    file: &Path,
4368    symbol_name: &str,
4369    file_exports_symbol: &mut F,
4370    file_default_export_symbol: &mut D,
4371    visited: &mut HashSet<(PathBuf, String)>,
4372) -> Option<ResolvedSymbol>
4373where
4374    F: FnMut(&Path, &str) -> bool,
4375    D: FnMut(&Path) -> Option<String>,
4376{
4377    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
4378    if !visited.insert((canon.clone(), symbol_name.to_string())) {
4379        return None;
4380    }
4381
4382    let source = std::fs::read_to_string(&canon).ok()?;
4383    let lang = detect_language(&canon)?;
4384    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
4385        if symbol_name == "default" {
4386            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
4387                file: canon,
4388                symbol,
4389            });
4390        }
4391        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
4392            file: canon,
4393            symbol: symbol_name.to_string(),
4394        });
4395    }
4396
4397    let grammar = grammar_for(lang);
4398    let mut parser = Parser::new();
4399    parser.set_language(&grammar).ok()?;
4400    let tree = parser.parse(&source, None)?;
4401    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
4402
4403    let mut cursor = tree.root_node().walk();
4404    if !cursor.goto_first_child() {
4405        return None;
4406    }
4407
4408    loop {
4409        let node = cursor.node();
4410        if node.kind() == "export_statement" {
4411            if let Some(target) = resolve_reexport_statement(
4412                &source,
4413                node,
4414                from_dir,
4415                symbol_name,
4416                file_exports_symbol,
4417                file_default_export_symbol,
4418                visited,
4419            ) {
4420                return Some(target);
4421            }
4422        }
4423
4424        if !cursor.goto_next_sibling() {
4425            break;
4426        }
4427    }
4428
4429    if symbol_name == "default" {
4430        if let Some(symbol) = file_default_export_symbol(&canon) {
4431            return Some(ResolvedSymbol {
4432                file: canon,
4433                symbol,
4434            });
4435        }
4436    }
4437
4438    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
4439        return Some(ResolvedSymbol {
4440            file: canon,
4441            symbol,
4442        });
4443    }
4444
4445    if file_exports_symbol(&canon, symbol_name) {
4446        let symbol = symbol_name.to_string();
4447        return Some(ResolvedSymbol {
4448            file: canon,
4449            symbol,
4450        });
4451    }
4452
4453    None
4454}
4455
4456fn resolve_reexport_statement<F, D>(
4457    source: &str,
4458    node: tree_sitter::Node,
4459    from_dir: &Path,
4460    symbol_name: &str,
4461    file_exports_symbol: &mut F,
4462    file_default_export_symbol: &mut D,
4463    visited: &mut HashSet<(PathBuf, String)>,
4464) -> Option<ResolvedSymbol>
4465where
4466    F: FnMut(&Path, &str) -> bool,
4467    D: FnMut(&Path) -> Option<String>,
4468{
4469    let source_node = node
4470        .child_by_field_name("source")
4471        .or_else(|| find_child_by_kind(node, "string"))?;
4472    let module_path = string_literal_content(source, source_node)?;
4473    let target_file = resolve_module_path(from_dir, &module_path)?;
4474    let raw_export = node_text(node, source);
4475
4476    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
4477        return resolve_reexported_symbol_inner(
4478            &target_file,
4479            &source_symbol,
4480            file_exports_symbol,
4481            file_default_export_symbol,
4482            visited,
4483        )
4484        .or(Some(ResolvedSymbol {
4485            file: target_file,
4486            symbol: source_symbol,
4487        }));
4488    }
4489
4490    if raw_export.contains('*') {
4491        return resolve_reexported_symbol_inner(
4492            &target_file,
4493            symbol_name,
4494            file_exports_symbol,
4495            file_default_export_symbol,
4496            visited,
4497        );
4498    }
4499
4500    None
4501}
4502
4503fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
4504    let lang = detect_language(file)?;
4505    let grammar = grammar_for(lang);
4506    let mut parser = Parser::new();
4507    parser.set_language(&grammar).ok()?;
4508    let tree = parser.parse(source, None)?;
4509
4510    let mut cursor = tree.root_node().walk();
4511    if !cursor.goto_first_child() {
4512        return None;
4513    }
4514
4515    loop {
4516        let node = cursor.node();
4517        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
4518            let raw_export = node_text(node, source);
4519            if let Some(source_symbol) =
4520                reexport_clause_source_symbol(&raw_export, requested_export)
4521            {
4522                return Some(source_symbol);
4523            }
4524        }
4525
4526        if !cursor.goto_next_sibling() {
4527            break;
4528        }
4529    }
4530
4531    None
4532}
4533
4534fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
4535    let start = raw_export.find('{')? + 1;
4536    let end = raw_export[start..].find('}')? + start;
4537    for specifier in raw_export[start..end].split(',') {
4538        let specifier = specifier.trim();
4539        if specifier.is_empty() {
4540            continue;
4541        }
4542        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
4543        if let Some((imported, exported)) = specifier.split_once(" as ") {
4544            if exported.trim() == requested_export {
4545                return Some(imported.trim().to_string());
4546            }
4547        } else if specifier == requested_export {
4548            return Some(requested_export.to_string());
4549        }
4550    }
4551    None
4552}
4553
4554fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
4555    let raw = source[node.byte_range()].trim();
4556    let quote = raw.chars().next()?;
4557    if quote != '\'' && quote != '"' {
4558        return None;
4559    }
4560    raw.strip_prefix(quote)
4561        .and_then(|value| value.strip_suffix(quote))
4562        .map(ToOwned::to_owned)
4563}
4564
4565/// Find an index file in a directory.
4566fn find_index_file(dir: &Path) -> Option<PathBuf> {
4567    for name in JS_TS_INDEX_FILES {
4568        let p = dir.join(name);
4569        if p.is_file() {
4570            return Some(std::fs::canonicalize(&p).unwrap_or(p));
4571        }
4572    }
4573    None
4574}
4575
4576/// Resolve an aliased import: `import { foo as bar } from './utils'`
4577/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
4578fn resolve_aliased_import(
4579    local_name: &str,
4580    import_block: &ImportBlock,
4581    caller_dir: &Path,
4582) -> Option<(String, PathBuf)> {
4583    for imp in &import_block.imports {
4584        // Parse the raw text to find "as <alias>" patterns
4585        // This handles: import { foo as bar, baz as qux } from './mod'
4586        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
4587            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
4588                return Some((original, resolved_path));
4589            }
4590        }
4591    }
4592    None
4593}
4594
4595/// Parse import raw text to find the original name for an alias.
4596/// Given raw text like `import { foo as bar, baz } from './utils'` and
4597/// local_name "bar", returns Some("foo").
4598fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
4599    // Look for pattern: <original> as <alias>
4600    // This is a simple text-based search; handles the common TS/JS pattern
4601    let search = format!(" as {}", local_name);
4602    if let Some(pos) = raw_import.find(&search) {
4603        // Walk backwards from `pos` to find the original name
4604        let before = &raw_import[..pos];
4605        // The original name is the last word-like token before " as "
4606        let original = before
4607            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
4608            .find(|s| !s.is_empty())?;
4609        return Some(original.to_string());
4610    }
4611    None
4612}
4613
4614// ---------------------------------------------------------------------------
4615// Worktree file discovery
4616// ---------------------------------------------------------------------------
4617
4618/// Walk project files respecting .gitignore, excluding common non-source dirs.
4619///
4620/// Returns an iterator of file paths for supported source file types.
4621pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
4622    use ignore::WalkBuilder;
4623
4624    let walker = WalkBuilder::new(root)
4625        .hidden(true)         // skip hidden files/dirs
4626        .git_ignore(true)     // respect .gitignore
4627        .git_global(true)     // respect global gitignore
4628        .git_exclude(true)    // respect .git/info/exclude
4629        .add_custom_ignore_filename(".aftignore") // AFT-specific ignores (e.g. submodules)
4630        .filter_entry(|entry| {
4631            let name = entry.file_name().to_string_lossy();
4632            // Always exclude these directories regardless of .gitignore
4633            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4634                return !matches!(
4635                    name.as_ref(),
4636                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
4637                        | ".tox" | "dist" | "build"
4638                );
4639            }
4640            true
4641        })
4642        .build();
4643
4644    walker
4645        .filter_map(|entry| entry.ok())
4646        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
4647        .filter(|entry| detect_language(entry.path()).is_some())
4648        .map(|entry| entry.into_path())
4649}
4650
4651// ---------------------------------------------------------------------------
4652// Tests
4653// ---------------------------------------------------------------------------
4654
4655#[cfg(test)]
4656mod tests {
4657    use super::*;
4658    use std::fs;
4659    use tempfile::TempDir;
4660
4661    #[test]
4662    fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
4663        // exported_symbols carries the bare name; symbol_metadata is keyed by
4664        // scoped identity (impl method). A plain .get(bare) misses and would
4665        // force the degraded unknown/line-1 fallback. symbol_metadata_for must
4666        // recover the scoped entry via unqualified-name match.
4667        let mut symbol_metadata = HashMap::new();
4668        symbol_metadata.insert(
4669            "BackupStore::total_disk_bytes".to_string(),
4670            SymbolMeta {
4671                kind: SymbolKind::Method,
4672                exported: true,
4673                signature: None,
4674                line: 703,
4675                range: Range {
4676                    start_line: 702,
4677                    start_col: 0,
4678                    end_line: 705,
4679                    end_col: 0,
4680                },
4681            },
4682        );
4683        let file_data = FileCallData {
4684            calls_by_symbol: HashMap::new(),
4685            exported_symbols: vec!["total_disk_bytes".to_string()],
4686            symbol_metadata,
4687            default_export_symbol: None,
4688            import_block: ImportBlock::empty(),
4689            lang: LangId::Rust,
4690        };
4691
4692        let meta = file_data
4693            .symbol_metadata_for("total_disk_bytes")
4694            .expect("scoped method recovered by bare name");
4695        assert_eq!(meta.kind, SymbolKind::Method);
4696        assert_eq!(
4697            meta.line, 703,
4698            "real declaration line, not the line-1 fallback"
4699        );
4700
4701        // A genuinely-absent symbol still returns None (no false recovery).
4702        assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
4703    }
4704
4705    /// Create a temp directory with TypeScript files for testing.
4706    fn setup_ts_project() -> TempDir {
4707        let dir = TempDir::new().unwrap();
4708
4709        // main.ts: imports from utils and calls functions
4710        fs::write(
4711            dir.path().join("main.ts"),
4712            r#"import { helper, compute } from './utils';
4713import * as math from './math';
4714
4715export function main() {
4716    const a = helper(1);
4717    const b = compute(a, 2);
4718    const c = math.add(a, b);
4719    return c;
4720}
4721"#,
4722        )
4723        .unwrap();
4724
4725        // utils.ts: defines helper and compute, imports from helpers
4726        fs::write(
4727            dir.path().join("utils.ts"),
4728            r#"import { double } from './helpers';
4729
4730export function helper(x: number): number {
4731    return double(x);
4732}
4733
4734export function compute(a: number, b: number): number {
4735    return a + b;
4736}
4737"#,
4738        )
4739        .unwrap();
4740
4741        // helpers.ts: defines double
4742        fs::write(
4743            dir.path().join("helpers.ts"),
4744            r#"export function double(x: number): number {
4745    return x * 2;
4746}
4747
4748export function triple(x: number): number {
4749    return x * 3;
4750}
4751"#,
4752        )
4753        .unwrap();
4754
4755        // math.ts: defines add (for namespace import test)
4756        fs::write(
4757            dir.path().join("math.ts"),
4758            r#"export function add(a: number, b: number): number {
4759    return a + b;
4760}
4761
4762export function subtract(a: number, b: number): number {
4763    return a - b;
4764}
4765"#,
4766        )
4767        .unwrap();
4768
4769        dir
4770    }
4771
4772    /// Create a project with import aliasing.
4773    fn setup_alias_project() -> TempDir {
4774        let dir = TempDir::new().unwrap();
4775
4776        fs::write(
4777            dir.path().join("main.ts"),
4778            r#"import { helper as h } from './utils';
4779
4780export function main() {
4781    return h(42);
4782}
4783"#,
4784        )
4785        .unwrap();
4786
4787        fs::write(
4788            dir.path().join("utils.ts"),
4789            r#"export function helper(x: number): number {
4790    return x + 1;
4791}
4792"#,
4793        )
4794        .unwrap();
4795
4796        dir
4797    }
4798
4799    /// Create a project with a cycle: A → B → A.
4800    fn setup_cycle_project() -> TempDir {
4801        let dir = TempDir::new().unwrap();
4802
4803        fs::write(
4804            dir.path().join("a.ts"),
4805            r#"import { funcB } from './b';
4806
4807export function funcA() {
4808    return funcB();
4809}
4810"#,
4811        )
4812        .unwrap();
4813
4814        fs::write(
4815            dir.path().join("b.ts"),
4816            r#"import { funcA } from './a';
4817
4818export function funcB() {
4819    return funcA();
4820}
4821"#,
4822        )
4823        .unwrap();
4824
4825        dir
4826    }
4827
4828    // --- Single-file call extraction ---
4829
4830    #[test]
4831    fn callgraph_single_file_call_extraction() {
4832        let dir = setup_ts_project();
4833        let mut graph = CallGraph::new(dir.path().to_path_buf());
4834
4835        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
4836        let main_calls = &file_data.calls_by_symbol["main"];
4837
4838        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
4839        assert!(
4840            callee_names.contains(&"helper"),
4841            "main should call helper, got: {:?}",
4842            callee_names
4843        );
4844        assert!(
4845            callee_names.contains(&"compute"),
4846            "main should call compute, got: {:?}",
4847            callee_names
4848        );
4849        assert!(
4850            callee_names.contains(&"add"),
4851            "main should call math.add (short name: add), got: {:?}",
4852            callee_names
4853        );
4854    }
4855
4856    #[test]
4857    fn callgraph_file_data_has_exports() {
4858        let dir = setup_ts_project();
4859        let mut graph = CallGraph::new(dir.path().to_path_buf());
4860
4861        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4862        assert!(
4863            file_data.exported_symbols.contains(&"helper".to_string()),
4864            "utils.ts should export helper, got: {:?}",
4865            file_data.exported_symbols
4866        );
4867        assert!(
4868            file_data.exported_symbols.contains(&"compute".to_string()),
4869            "utils.ts should export compute, got: {:?}",
4870            file_data.exported_symbols
4871        );
4872    }
4873
4874    // --- Cross-file resolution ---
4875
4876    #[test]
4877    fn callgraph_resolve_direct_import() {
4878        let dir = setup_ts_project();
4879        let mut graph = CallGraph::new(dir.path().to_path_buf());
4880
4881        let main_path = dir.path().join("main.ts");
4882        let file_data = graph.build_file(&main_path).unwrap();
4883        let import_block = file_data.import_block.clone();
4884
4885        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4886        match edge {
4887            EdgeResolution::Resolved { file, symbol } => {
4888                assert!(
4889                    file.ends_with("utils.ts"),
4890                    "helper should resolve to utils.ts, got: {:?}",
4891                    file
4892                );
4893                assert_eq!(symbol, "helper");
4894            }
4895            EdgeResolution::Unresolved { callee_name } => {
4896                panic!("Expected resolved, got unresolved: {}", callee_name);
4897            }
4898        }
4899    }
4900
4901    #[test]
4902    fn callgraph_resolve_namespace_import() {
4903        let dir = setup_ts_project();
4904        let mut graph = CallGraph::new(dir.path().to_path_buf());
4905
4906        let main_path = dir.path().join("main.ts");
4907        let file_data = graph.build_file(&main_path).unwrap();
4908        let import_block = file_data.import_block.clone();
4909
4910        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4911        match edge {
4912            EdgeResolution::Resolved { file, symbol } => {
4913                assert!(
4914                    file.ends_with("math.ts"),
4915                    "math.add should resolve to math.ts, got: {:?}",
4916                    file
4917                );
4918                assert_eq!(symbol, "add");
4919            }
4920            EdgeResolution::Unresolved { callee_name } => {
4921                panic!("Expected resolved, got unresolved: {}", callee_name);
4922            }
4923        }
4924    }
4925
4926    #[test]
4927    fn callgraph_resolve_aliased_import() {
4928        let dir = setup_alias_project();
4929        let mut graph = CallGraph::new(dir.path().to_path_buf());
4930
4931        let main_path = dir.path().join("main.ts");
4932        let file_data = graph.build_file(&main_path).unwrap();
4933        let import_block = file_data.import_block.clone();
4934
4935        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
4936        match edge {
4937            EdgeResolution::Resolved { file, symbol } => {
4938                assert!(
4939                    file.ends_with("utils.ts"),
4940                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
4941                    file
4942                );
4943                assert_eq!(symbol, "helper");
4944            }
4945            EdgeResolution::Unresolved { callee_name } => {
4946                panic!("Expected resolved, got unresolved: {}", callee_name);
4947            }
4948        }
4949    }
4950
4951    #[test]
4952    fn callgraph_unresolved_edge_marked() {
4953        let dir = setup_ts_project();
4954        let mut graph = CallGraph::new(dir.path().to_path_buf());
4955
4956        let main_path = dir.path().join("main.ts");
4957        let file_data = graph.build_file(&main_path).unwrap();
4958        let import_block = file_data.import_block.clone();
4959
4960        let edge =
4961            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
4962        assert_eq!(
4963            edge,
4964            EdgeResolution::Unresolved {
4965                callee_name: "unknownFunc".to_string()
4966            },
4967            "Unknown callee should be unresolved"
4968        );
4969    }
4970
4971    // --- Cycle detection ---
4972
4973    #[test]
4974    fn callgraph_cycle_detection_stops() {
4975        let dir = setup_cycle_project();
4976        let mut graph = CallGraph::new(dir.path().to_path_buf());
4977
4978        // This should NOT infinite loop
4979        let tree = graph
4980            .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
4981            .unwrap();
4982
4983        assert_eq!(tree.name, "funcA");
4984        assert!(tree.resolved);
4985
4986        // funcA calls funcB, funcB calls funcA (cycle), so the depth should be bounded
4987        // The tree should have children but not infinitely deep
4988        fn count_depth(node: &CallTreeNode) -> usize {
4989            if node.children.is_empty() {
4990                1
4991            } else {
4992                1 + node.children.iter().map(count_depth).max().unwrap_or(0)
4993            }
4994        }
4995
4996        let depth = count_depth(&tree);
4997        assert!(
4998            depth <= 4,
4999            "Cycle should be detected and bounded, depth was: {}",
5000            depth
5001        );
5002    }
5003
5004    // --- Depth limiting ---
5005
5006    #[test]
5007    fn callgraph_depth_limit_truncates() {
5008        let dir = setup_ts_project();
5009        let mut graph = CallGraph::new(dir.path().to_path_buf());
5010
5011        // main → helper → double, main → compute
5012        // With depth 1, we should see direct callees but not their children
5013        let tree = graph
5014            .forward_tree(&dir.path().join("main.ts"), "main", 1)
5015            .unwrap();
5016
5017        assert_eq!(tree.name, "main");
5018        assert!(tree.depth_limited, "depth limit should be reported");
5019        assert!(
5020            tree.truncated > 0,
5021            "truncated edge count should be reported"
5022        );
5023
5024        // At depth 1, children should exist (direct calls) but their children should be empty
5025        for child in &tree.children {
5026            assert!(
5027                child.children.is_empty(),
5028                "At depth 1, child '{}' should have no children, got {:?}",
5029                child.name,
5030                child.children.len()
5031            );
5032        }
5033    }
5034
5035    #[test]
5036    fn callgraph_depth_zero_no_children() {
5037        let dir = setup_ts_project();
5038        let mut graph = CallGraph::new(dir.path().to_path_buf());
5039
5040        let tree = graph
5041            .forward_tree(&dir.path().join("main.ts"), "main", 0)
5042            .unwrap();
5043
5044        assert_eq!(tree.name, "main");
5045        assert!(
5046            tree.children.is_empty(),
5047            "At depth 0, should have no children"
5048        );
5049    }
5050
5051    // --- Forward tree cross-file ---
5052
5053    #[test]
5054    fn callgraph_forward_tree_cross_file() {
5055        let dir = setup_ts_project();
5056        let mut graph = CallGraph::new(dir.path().to_path_buf());
5057
5058        // main → helper (in utils.ts) → double (in helpers.ts)
5059        let tree = graph
5060            .forward_tree(&dir.path().join("main.ts"), "main", 5)
5061            .unwrap();
5062
5063        assert_eq!(tree.name, "main");
5064        assert!(tree.resolved);
5065
5066        // Find the helper child
5067        let helper_child = tree.children.iter().find(|c| c.name == "helper");
5068        assert!(
5069            helper_child.is_some(),
5070            "main should have helper as child, children: {:?}",
5071            tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5072        );
5073
5074        let helper = helper_child.unwrap();
5075        assert!(
5076            helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
5077            "helper should be in utils.ts, got: {}",
5078            helper.file
5079        );
5080
5081        // helper should call double (in helpers.ts)
5082        let double_child = helper.children.iter().find(|c| c.name == "double");
5083        assert!(
5084            double_child.is_some(),
5085            "helper should call double, children: {:?}",
5086            helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5087        );
5088
5089        let double = double_child.unwrap();
5090        assert!(
5091            double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
5092            "double should be in helpers.ts, got: {}",
5093            double.file
5094        );
5095    }
5096
5097    // --- Worktree walker ---
5098
5099    #[test]
5100    fn callgraph_walker_excludes_gitignored() {
5101        let dir = TempDir::new().unwrap();
5102
5103        // Create a .gitignore
5104        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
5105
5106        // Create files
5107        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5108        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
5109        fs::write(
5110            dir.path().join("ignored_dir").join("secret.ts"),
5111            "export function secret() {}",
5112        )
5113        .unwrap();
5114
5115        // Also create node_modules (should always be excluded)
5116        fs::create_dir(dir.path().join("node_modules")).unwrap();
5117        fs::write(
5118            dir.path().join("node_modules").join("dep.ts"),
5119            "export function dep() {}",
5120        )
5121        .unwrap();
5122
5123        // Init git repo for .gitignore to work
5124        std::process::Command::new("git")
5125            .args(["init"])
5126            .current_dir(dir.path())
5127            .output()
5128            .unwrap();
5129
5130        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5131        let file_names: Vec<String> = files
5132            .iter()
5133            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5134            .collect();
5135
5136        assert!(
5137            file_names.contains(&"main.ts".to_string()),
5138            "Should include main.ts, got: {:?}",
5139            file_names
5140        );
5141        assert!(
5142            !file_names.contains(&"secret.ts".to_string()),
5143            "Should exclude gitignored secret.ts, got: {:?}",
5144            file_names
5145        );
5146        assert!(
5147            !file_names.contains(&"dep.ts".to_string()),
5148            "Should exclude node_modules, got: {:?}",
5149            file_names
5150        );
5151    }
5152
5153    #[test]
5154    fn callgraph_walker_excludes_aftignored() {
5155        let dir = TempDir::new().unwrap();
5156
5157        // .aftignore is honored without a git repo (custom ignore file).
5158        fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
5159        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5160        fs::create_dir(dir.path().join("vendored")).unwrap();
5161        fs::write(
5162            dir.path().join("vendored").join("sub.ts"),
5163            "export function sub() {}",
5164        )
5165        .unwrap();
5166
5167        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5168        let file_names: Vec<String> = files
5169            .iter()
5170            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5171            .collect();
5172
5173        assert!(
5174            file_names.contains(&"main.ts".to_string()),
5175            "Should include main.ts, got: {:?}",
5176            file_names
5177        );
5178        assert!(
5179            !file_names.contains(&"sub.ts".to_string()),
5180            "Should exclude .aftignored sub.ts, got: {:?}",
5181            file_names
5182        );
5183    }
5184
5185    #[test]
5186    fn callgraph_walker_only_source_files() {
5187        let dir = TempDir::new().unwrap();
5188
5189        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5190        fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
5191        fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
5192        fs::write(
5193            dir.path().join("runtime.mjs"),
5194            "export function runtime() {}",
5195        )
5196        .unwrap();
5197        fs::write(
5198            dir.path().join("legacy.cjs"),
5199            "exports.legacy = function() {};",
5200        )
5201        .unwrap();
5202        fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
5203        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
5204        fs::write(dir.path().join("data.json"), "{}").unwrap();
5205
5206        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5207        let file_names: Vec<String> = files
5208            .iter()
5209            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5210            .collect();
5211
5212        assert!(file_names.contains(&"main.ts".to_string()));
5213        for modern_ext_file in [
5214            "module.mts",
5215            "common.cts",
5216            "runtime.mjs",
5217            "legacy.cjs",
5218            "types.pyi",
5219        ] {
5220            assert!(
5221                file_names.contains(&modern_ext_file.to_string()),
5222                "walker should include {modern_ext_file}, got: {:?}",
5223                file_names
5224            );
5225        }
5226        assert!(
5227            file_names.contains(&"readme.md".to_string()),
5228            "Markdown is now a supported source language"
5229        );
5230        assert!(
5231            file_names.contains(&"data.json".to_string()),
5232            "JSON is now a supported source language"
5233        );
5234    }
5235
5236    // --- find_alias_original ---
5237
5238    #[test]
5239    fn callgraph_find_alias_original_simple() {
5240        let raw = "import { foo as bar } from './utils';";
5241        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5242    }
5243
5244    #[test]
5245    fn callgraph_find_alias_original_multiple() {
5246        let raw = "import { foo as bar, baz as qux } from './utils';";
5247        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5248        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
5249    }
5250
5251    #[test]
5252    fn callgraph_find_alias_no_match() {
5253        let raw = "import { foo } from './utils';";
5254        assert_eq!(find_alias_original(raw, "foo"), None);
5255    }
5256
5257    // --- Reverse callers ---
5258
5259    #[test]
5260    fn callgraph_callers_of_direct() {
5261        let dir = setup_ts_project();
5262        let mut graph = CallGraph::new(dir.path().to_path_buf());
5263
5264        // helpers.ts:double is called by utils.ts:helper
5265        let result = graph
5266            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5267            .unwrap();
5268
5269        assert_eq!(result.symbol, "double");
5270        assert!(result.total_callers > 0, "double should have callers");
5271        assert!(result.scanned_files > 0, "should have scanned files");
5272
5273        // Find the caller from utils.ts
5274        let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
5275        assert!(
5276            utils_group.is_some(),
5277            "double should be called from utils.ts, groups: {:?}",
5278            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5279        );
5280
5281        let group = utils_group.unwrap();
5282        let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
5283        assert!(
5284            helper_caller.is_some(),
5285            "double should be called by helper, callers: {:?}",
5286            group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
5287        );
5288    }
5289
5290    #[test]
5291    fn callgraph_callers_of_no_callers() {
5292        let dir = setup_ts_project();
5293        let mut graph = CallGraph::new(dir.path().to_path_buf());
5294
5295        // main.ts:main is the entry point — nothing calls it
5296        let result = graph
5297            .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
5298            .unwrap();
5299
5300        assert_eq!(result.symbol, "main");
5301        assert_eq!(result.total_callers, 0, "main should have no callers");
5302        assert!(result.callers.is_empty());
5303    }
5304
5305    #[test]
5306    fn callgraph_callers_recursive_depth() {
5307        let dir = setup_ts_project();
5308        let mut graph = CallGraph::new(dir.path().to_path_buf());
5309
5310        // helpers.ts:double is called by utils.ts:helper
5311        // utils.ts:helper is called by main.ts:main
5312        // With depth=2, we should see both direct and transitive callers
5313        let result = graph
5314            .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
5315            .unwrap();
5316
5317        assert!(
5318            result.total_callers >= 2,
5319            "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
5320            result.total_callers
5321        );
5322
5323        // Should include caller from main.ts (transitive: main → helper → double)
5324        let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
5325        assert!(
5326            main_group.is_some(),
5327            "recursive callers should include main.ts, groups: {:?}",
5328            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5329        );
5330    }
5331
5332    #[test]
5333    fn callgraph_invalidate_file_clears_reverse_index() {
5334        let dir = setup_ts_project();
5335        let mut graph = CallGraph::new(dir.path().to_path_buf());
5336
5337        // Build callers to populate the reverse index
5338        let _ = graph
5339            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5340            .unwrap();
5341        assert!(
5342            graph.reverse_index.is_some(),
5343            "reverse index should be built"
5344        );
5345
5346        // Invalidate a file
5347        graph.invalidate_file(&dir.path().join("utils.ts"));
5348
5349        // Reverse index should be cleared
5350        assert!(
5351            graph.reverse_index.is_none(),
5352            "invalidate_file should clear reverse index"
5353        );
5354        // Data cache for the file should be cleared
5355        let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
5356        assert!(
5357            !graph.data.contains_key(&canon),
5358            "invalidate_file should remove file from data cache"
5359        );
5360        // Project files should be cleared
5361        assert!(
5362            graph.project_files.is_none(),
5363            "invalidate_file should clear project_files"
5364        );
5365    }
5366
5367    // --- is_entry_point ---
5368
5369    #[test]
5370    fn is_entry_point_exported_function() {
5371        assert!(is_entry_point(
5372            "handleRequest",
5373            &SymbolKind::Function,
5374            true,
5375            LangId::TypeScript
5376        ));
5377    }
5378
5379    #[test]
5380    fn is_entry_point_exported_method_is_not_entry() {
5381        // Methods are class members, not standalone entry points
5382        assert!(!is_entry_point(
5383            "handleRequest",
5384            &SymbolKind::Method,
5385            true,
5386            LangId::TypeScript
5387        ));
5388    }
5389
5390    #[test]
5391    fn is_entry_point_main_init_patterns() {
5392        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
5393            assert!(
5394                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
5395                "{} should be an entry point",
5396                name
5397            );
5398        }
5399    }
5400
5401    #[test]
5402    fn is_entry_point_test_patterns_ts() {
5403        assert!(is_entry_point(
5404            "describe",
5405            &SymbolKind::Function,
5406            false,
5407            LangId::TypeScript
5408        ));
5409        assert!(is_entry_point(
5410            "it",
5411            &SymbolKind::Function,
5412            false,
5413            LangId::TypeScript
5414        ));
5415        assert!(is_entry_point(
5416            "test",
5417            &SymbolKind::Function,
5418            false,
5419            LangId::TypeScript
5420        ));
5421        assert!(is_entry_point(
5422            "testValidation",
5423            &SymbolKind::Function,
5424            false,
5425            LangId::TypeScript
5426        ));
5427        assert!(is_entry_point(
5428            "specHelper",
5429            &SymbolKind::Function,
5430            false,
5431            LangId::TypeScript
5432        ));
5433    }
5434
5435    #[test]
5436    fn is_entry_point_test_patterns_python() {
5437        assert!(is_entry_point(
5438            "test_login",
5439            &SymbolKind::Function,
5440            false,
5441            LangId::Python
5442        ));
5443        assert!(is_entry_point(
5444            "setUp",
5445            &SymbolKind::Function,
5446            false,
5447            LangId::Python
5448        ));
5449        assert!(is_entry_point(
5450            "tearDown",
5451            &SymbolKind::Function,
5452            false,
5453            LangId::Python
5454        ));
5455        // "testSomething" should NOT match Python (needs test_ prefix)
5456        assert!(!is_entry_point(
5457            "testSomething",
5458            &SymbolKind::Function,
5459            false,
5460            LangId::Python
5461        ));
5462    }
5463
5464    #[test]
5465    fn is_entry_point_test_patterns_rust() {
5466        assert!(is_entry_point(
5467            "test_parse",
5468            &SymbolKind::Function,
5469            false,
5470            LangId::Rust
5471        ));
5472        assert!(!is_entry_point(
5473            "TestSomething",
5474            &SymbolKind::Function,
5475            false,
5476            LangId::Rust
5477        ));
5478    }
5479
5480    #[test]
5481    fn is_entry_point_test_patterns_go() {
5482        assert!(is_entry_point(
5483            "TestParsing",
5484            &SymbolKind::Function,
5485            false,
5486            LangId::Go
5487        ));
5488        // lowercase test should NOT match Go (needs uppercase Test prefix)
5489        assert!(!is_entry_point(
5490            "testParsing",
5491            &SymbolKind::Function,
5492            false,
5493            LangId::Go
5494        ));
5495    }
5496
5497    #[test]
5498    fn is_entry_point_non_exported_non_main_is_not_entry() {
5499        assert!(!is_entry_point(
5500            "helperUtil",
5501            &SymbolKind::Function,
5502            false,
5503            LangId::TypeScript
5504        ));
5505    }
5506
5507    // --- symbol_metadata ---
5508
5509    #[test]
5510    fn callgraph_symbol_metadata_populated() {
5511        let dir = setup_ts_project();
5512        let mut graph = CallGraph::new(dir.path().to_path_buf());
5513
5514        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
5515        assert!(
5516            file_data.symbol_metadata.contains_key("helper"),
5517            "symbol_metadata should contain helper"
5518        );
5519        let meta = &file_data.symbol_metadata["helper"];
5520        assert_eq!(meta.kind, SymbolKind::Function);
5521        assert!(meta.exported, "helper should be exported");
5522    }
5523
5524    // --- trace_to ---
5525
5526    /// Setup a multi-path project for trace_to tests.
5527    ///
5528    /// Structure:
5529    ///   main.ts: exported main() → processData (from utils)
5530    ///   service.ts: exported handleRequest() → processData (from utils)
5531    ///   utils.ts: exported processData() → validate (from helpers)
5532    ///   helpers.ts: exported validate() → checkFormat (local, not exported)
5533    ///   test_helpers.ts: testValidation() → validate (from helpers)
5534    ///
5535    /// checkFormat should have 3 paths:
5536    ///   main → processData → validate → checkFormat
5537    ///   handleRequest → processData → validate → checkFormat
5538    ///   testValidation → validate → checkFormat
5539    fn setup_trace_project() -> TempDir {
5540        let dir = TempDir::new().unwrap();
5541
5542        fs::write(
5543            dir.path().join("main.ts"),
5544            r#"import { processData } from './utils';
5545
5546export function main() {
5547    const result = processData("hello");
5548    return result;
5549}
5550"#,
5551        )
5552        .unwrap();
5553
5554        fs::write(
5555            dir.path().join("service.ts"),
5556            r#"import { processData } from './utils';
5557
5558export function handleRequest(input: string): string {
5559    return processData(input);
5560}
5561"#,
5562        )
5563        .unwrap();
5564
5565        fs::write(
5566            dir.path().join("utils.ts"),
5567            r#"import { validate } from './helpers';
5568
5569export function processData(input: string): string {
5570    const valid = validate(input);
5571    if (!valid) {
5572        throw new Error("invalid input");
5573    }
5574    return input.toUpperCase();
5575}
5576"#,
5577        )
5578        .unwrap();
5579
5580        fs::write(
5581            dir.path().join("helpers.ts"),
5582            r#"export function validate(input: string): boolean {
5583    return checkFormat(input);
5584}
5585
5586function checkFormat(input: string): boolean {
5587    return input.length > 0 && /^[a-zA-Z]+$/.test(input);
5588}
5589"#,
5590        )
5591        .unwrap();
5592
5593        fs::write(
5594            dir.path().join("test_helpers.ts"),
5595            r#"import { validate } from './helpers';
5596
5597function testValidation() {
5598    const result = validate("hello");
5599    console.log(result);
5600}
5601"#,
5602        )
5603        .unwrap();
5604
5605        // git init so the walker works
5606        std::process::Command::new("git")
5607            .args(["init"])
5608            .current_dir(dir.path())
5609            .output()
5610            .unwrap();
5611
5612        dir
5613    }
5614
5615    #[test]
5616    fn trace_to_multi_path() {
5617        let dir = setup_trace_project();
5618        let mut graph = CallGraph::new(dir.path().to_path_buf());
5619
5620        let result = graph
5621            .trace_to(
5622                &dir.path().join("helpers.ts"),
5623                "checkFormat",
5624                10,
5625                usize::MAX,
5626            )
5627            .unwrap();
5628
5629        assert_eq!(result.target_symbol, "checkFormat");
5630        assert!(
5631            result.total_paths >= 2,
5632            "checkFormat should have at least 2 paths, got {} (paths: {:?})",
5633            result.total_paths,
5634            result
5635                .paths
5636                .iter()
5637                .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
5638                .collect::<Vec<_>>()
5639        );
5640
5641        // Check that paths are top-down: entry point first, target last
5642        for path in &result.paths {
5643            assert!(
5644                path.hops.first().unwrap().is_entry_point,
5645                "First hop should be an entry point, got: {}",
5646                path.hops.first().unwrap().symbol
5647            );
5648            assert_eq!(
5649                path.hops.last().unwrap().symbol,
5650                "checkFormat",
5651                "Last hop should be checkFormat"
5652            );
5653        }
5654
5655        // Verify entry_points_found > 0
5656        assert!(
5657            result.entry_points_found >= 2,
5658            "should find at least 2 entry points, got {}",
5659            result.entry_points_found
5660        );
5661    }
5662
5663    #[test]
5664    fn trace_to_single_path() {
5665        let dir = setup_trace_project();
5666        let mut graph = CallGraph::new(dir.path().to_path_buf());
5667
5668        // validate is called from processData, testValidation
5669        // processData is called from main, handleRequest
5670        // So validate has paths: main→processData→validate, handleRequest→processData→validate, testValidation→validate
5671        let result = graph
5672            .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
5673            .unwrap();
5674
5675        assert_eq!(result.target_symbol, "validate");
5676        assert!(
5677            result.total_paths >= 2,
5678            "validate should have at least 2 paths, got {}",
5679            result.total_paths
5680        );
5681    }
5682
5683    #[test]
5684    fn trace_to_cycle_detection() {
5685        let dir = setup_cycle_project();
5686        let mut graph = CallGraph::new(dir.path().to_path_buf());
5687
5688        // funcA ↔ funcB cycle — should terminate
5689        let result = graph
5690            .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
5691            .unwrap();
5692
5693        // Should not hang — the fact we got here means cycle detection works
5694        assert_eq!(result.target_symbol, "funcA");
5695    }
5696
5697    #[test]
5698    fn trace_to_depth_limit() {
5699        let dir = setup_trace_project();
5700        let mut graph = CallGraph::new(dir.path().to_path_buf());
5701
5702        // With max_depth=1, should not be able to reach entry points that are 3+ hops away
5703        let result = graph
5704            .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
5705            .unwrap();
5706
5707        // testValidation→validate→checkFormat is 2 hops, which requires depth >= 2
5708        // main→processData→validate→checkFormat is 3 hops, which requires depth >= 3
5709        // With depth=1, most paths should be truncated
5710        assert_eq!(result.target_symbol, "checkFormat");
5711
5712        // The shallow result should have fewer paths than the deep one
5713        let deep_result = graph
5714            .trace_to(
5715                &dir.path().join("helpers.ts"),
5716                "checkFormat",
5717                10,
5718                usize::MAX,
5719            )
5720            .unwrap();
5721
5722        assert!(
5723            result.total_paths <= deep_result.total_paths,
5724            "shallow trace should find <= paths compared to deep: {} vs {}",
5725            result.total_paths,
5726            deep_result.total_paths
5727        );
5728    }
5729
5730    #[test]
5731    fn trace_to_entry_point_target() {
5732        let dir = setup_trace_project();
5733        let mut graph = CallGraph::new(dir.path().to_path_buf());
5734
5735        // main is itself an entry point — should return a single trivial path
5736        let result = graph
5737            .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
5738            .unwrap();
5739
5740        assert_eq!(result.target_symbol, "main");
5741        assert!(
5742            result.total_paths >= 1,
5743            "main should have at least 1 path (itself), got {}",
5744            result.total_paths
5745        );
5746        // Check the trivial path has just one hop
5747        let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
5748        assert!(
5749            trivial.is_some(),
5750            "should have a trivial path with just the entry point itself"
5751        );
5752    }
5753
5754    #[test]
5755    fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
5756        let dir = TempDir::new().unwrap();
5757        fs::write(
5758            dir.path().join("main.ts"),
5759            r#"import * as lib from './index';
5760
5761export function main() {
5762    lib.helper();
5763    lib.hidden();
5764}
5765"#,
5766        )
5767        .unwrap();
5768        fs::write(
5769            dir.path().join("index.ts"),
5770            "export { helper } from './utils';\n",
5771        )
5772        .unwrap();
5773        fs::write(
5774            dir.path().join("utils.ts"),
5775            r#"export function helper() {}
5776function hidden() {}
5777"#,
5778        )
5779        .unwrap();
5780
5781        let mut graph = CallGraph::new(dir.path().to_path_buf());
5782        let main_path = dir.path().join("main.ts");
5783        let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
5784
5785        let helper =
5786            graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
5787        match helper {
5788            EdgeResolution::Resolved { file, symbol } => {
5789                assert!(
5790                    file.ends_with("utils.ts"),
5791                    "helper should resolve through barrel: {file:?}"
5792                );
5793                assert_eq!(symbol, "helper");
5794            }
5795            other => panic!("expected helper to resolve through barrel, got {other:?}"),
5796        }
5797
5798        let hidden =
5799            graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
5800        assert_eq!(
5801            hidden,
5802            EdgeResolution::Unresolved {
5803                callee_name: "hidden".to_string()
5804            }
5805        );
5806    }
5807
5808    #[test]
5809    fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
5810        let dir = TempDir::new().unwrap();
5811        fs::write(
5812            dir.path().join("package.json"),
5813            r#"{"workspaces":["packages/*"]}"#,
5814        )
5815        .unwrap();
5816        let package_dir = dir.path().join("packages/lib");
5817        fs::create_dir_all(package_dir.join("src")).unwrap();
5818        fs::create_dir_all(package_dir.join("dist")).unwrap();
5819        fs::write(
5820            package_dir.join("package.json"),
5821            r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
5822        )
5823        .unwrap();
5824        fs::write(
5825            package_dir.join("src/index.mts"),
5826            "export function helper() {}\n",
5827        )
5828        .unwrap();
5829        fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
5830
5831        let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
5832        assert!(
5833            resolved.ends_with("src/index.mts"),
5834            "dist/index.mjs should map to src/index.mts, got {resolved:?}"
5835        );
5836    }
5837
5838    #[test]
5839    fn unresolved_member_calls_do_not_become_same_file_callers() {
5840        let dir = TempDir::new().unwrap();
5841        fs::write(
5842            dir.path().join("main.ts"),
5843            r#"function caller() {
5844    db.connect();
5845}
5846
5847function connect() {}
5848"#,
5849        )
5850        .unwrap();
5851
5852        let mut graph = CallGraph::new(dir.path().to_path_buf());
5853        let result = graph
5854            .callers_of(&dir.path().join("main.ts"), "connect", 1, usize::MAX)
5855            .unwrap();
5856
5857        assert_eq!(
5858            result.total_callers, 0,
5859            "db.connect() must not call local connect"
5860        );
5861    }
5862
5863    #[test]
5864    fn same_named_methods_use_scoped_symbol_identity() {
5865        let dir = TempDir::new().unwrap();
5866        fs::write(
5867            dir.path().join("classes.ts"),
5868            r#"class A {
5869    run() { helperA(); }
5870}
5871
5872class B {
5873    run() { helperB(); }
5874}
5875
5876function helperA() {}
5877function helperB() {}
5878"#,
5879        )
5880        .unwrap();
5881
5882        let mut graph = CallGraph::new(dir.path().to_path_buf());
5883        let path = dir.path().join("classes.ts");
5884        let data = graph.build_file(&path).unwrap();
5885
5886        assert!(
5887            data.symbol_metadata.contains_key("A::run"),
5888            "A::run metadata missing"
5889        );
5890        assert!(
5891            data.symbol_metadata.contains_key("B::run"),
5892            "B::run metadata missing"
5893        );
5894        assert!(
5895            data.calls_by_symbol["A::run"]
5896                .iter()
5897                .any(|call| call.callee_name == "helperA"),
5898            "A::run calls should not be overwritten"
5899        );
5900        assert!(
5901            data.calls_by_symbol["B::run"]
5902                .iter()
5903                .any(|call| call.callee_name == "helperB"),
5904            "B::run calls should not be overwritten"
5905        );
5906
5907        assert!(matches!(
5908            graph.resolve_symbol_query(&path, "run"),
5909            Err(AftError::AmbiguousSymbol { .. })
5910        ));
5911        assert_eq!(
5912            graph.resolve_symbol_query(&path, "A::run").unwrap(),
5913            "A::run"
5914        );
5915    }
5916
5917    #[test]
5918    fn trace_to_counts_same_named_entry_points_by_file_and_symbol() {
5919        let dir = TempDir::new().unwrap();
5920        fs::create_dir_all(dir.path().join("web")).unwrap();
5921        fs::create_dir_all(dir.path().join("cli")).unwrap();
5922        fs::write(
5923            dir.path().join("target.ts"),
5924            r#"export function target() {
5925    leaf();
5926}
5927
5928function leaf() {}
5929"#,
5930        )
5931        .unwrap();
5932        fs::write(
5933            dir.path().join("web/main.ts"),
5934            r#"import { target } from '../target';
5935
5936export function main() {
5937    target();
5938}
5939"#,
5940        )
5941        .unwrap();
5942        fs::write(
5943            dir.path().join("cli/main.ts"),
5944            r#"import { target } from '../target';
5945
5946export function main() {
5947    target();
5948}
5949"#,
5950        )
5951        .unwrap();
5952
5953        let mut graph = CallGraph::new(dir.path().to_path_buf());
5954        let result = graph
5955            .trace_to(&dir.path().join("target.ts"), "leaf", 10, usize::MAX)
5956            .unwrap();
5957
5958        assert_eq!(
5959            result.total_paths, 3,
5960            "target plus two main entry paths expected"
5961        );
5962        assert_eq!(
5963            result.entry_points_found, 3,
5964            "same-named main entry points in different files must both count"
5965        );
5966    }
5967
5968    #[test]
5969    fn callers_and_impact_report_depth_truncation() {
5970        let dir = setup_ts_project();
5971        let mut graph = CallGraph::new(dir.path().to_path_buf());
5972
5973        let callers = graph
5974            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5975            .unwrap();
5976        assert!(
5977            callers.depth_limited,
5978            "callers should report omitted transitive callers"
5979        );
5980        assert!(
5981            callers.truncated > 0,
5982            "callers should report truncated edge count"
5983        );
5984
5985        let impact = graph
5986            .impact(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5987            .unwrap();
5988        assert!(
5989            impact.depth_limited,
5990            "impact should report omitted transitive callers"
5991        );
5992        assert!(
5993            impact.truncated > 0,
5994            "impact should report truncated edge count"
5995        );
5996    }
5997
5998    // --- extract_parameters ---
5999
6000    #[test]
6001    fn extract_parameters_typescript() {
6002        let params = extract_parameters(
6003            "function processData(input: string, count: number): void",
6004            LangId::TypeScript,
6005        );
6006        assert_eq!(params, vec!["input", "count"]);
6007    }
6008
6009    #[test]
6010    fn extract_parameters_typescript_optional() {
6011        let params = extract_parameters(
6012            "function fetch(url: string, options?: RequestInit): Promise<Response>",
6013            LangId::TypeScript,
6014        );
6015        assert_eq!(params, vec!["url", "options"]);
6016    }
6017
6018    #[test]
6019    fn extract_parameters_typescript_defaults() {
6020        let params = extract_parameters(
6021            "function greet(name: string, greeting: string = \"hello\"): string",
6022            LangId::TypeScript,
6023        );
6024        assert_eq!(params, vec!["name", "greeting"]);
6025    }
6026
6027    #[test]
6028    fn extract_parameters_typescript_rest() {
6029        let params = extract_parameters(
6030            "function sum(...numbers: number[]): number",
6031            LangId::TypeScript,
6032        );
6033        assert_eq!(params, vec!["numbers"]);
6034    }
6035
6036    #[test]
6037    fn extract_parameters_python_self_skipped() {
6038        let params = extract_parameters(
6039            "def process(self, data: str, count: int) -> bool",
6040            LangId::Python,
6041        );
6042        assert_eq!(params, vec!["data", "count"]);
6043    }
6044
6045    #[test]
6046    fn extract_parameters_python_no_self() {
6047        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
6048        assert_eq!(params, vec!["input"]);
6049    }
6050
6051    #[test]
6052    fn extract_parameters_python_star_args() {
6053        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
6054        assert_eq!(params, vec!["args", "kwargs"]);
6055    }
6056
6057    #[test]
6058    fn extract_parameters_rust_self_skipped() {
6059        let params = extract_parameters(
6060            "fn process(&self, data: &str, count: usize) -> bool",
6061            LangId::Rust,
6062        );
6063        assert_eq!(params, vec!["data", "count"]);
6064    }
6065
6066    #[test]
6067    fn extract_parameters_rust_mut_self_skipped() {
6068        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
6069        assert_eq!(params, vec!["value"]);
6070    }
6071
6072    #[test]
6073    fn extract_parameters_rust_no_self() {
6074        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
6075        assert_eq!(params, vec!["input"]);
6076    }
6077
6078    #[test]
6079    fn extract_parameters_rust_mut_param() {
6080        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
6081        assert_eq!(params, vec!["buf", "len"]);
6082    }
6083
6084    #[test]
6085    fn extract_parameters_go() {
6086        let params = extract_parameters(
6087            "func ProcessData(input string, count int) error",
6088            LangId::Go,
6089        );
6090        assert_eq!(params, vec!["input", "count"]);
6091    }
6092
6093    #[test]
6094    fn extract_parameters_empty() {
6095        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
6096        assert!(
6097            params.is_empty(),
6098            "no-arg function should return empty params"
6099        );
6100    }
6101
6102    #[test]
6103    fn extract_parameters_no_parens() {
6104        let params = extract_parameters("const x = 42", LangId::TypeScript);
6105        assert!(params.is_empty(), "no parens should return empty params");
6106    }
6107
6108    #[test]
6109    fn extract_parameters_javascript() {
6110        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
6111        assert_eq!(params, vec!["event", "target"]);
6112    }
6113}