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