Skip to main content

aft/
callgraph.rs

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