Skip to main content

aft/
callgraph.rs

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