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