Skip to main content

aft/
callgraph.rs

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