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