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