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