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