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