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