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