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::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_json::Value;
16use tree_sitter::{Node, Parser};
17
18use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
19use crate::edit::line_col_to_byte;
20use crate::error::AftError;
21use crate::imports::{self, ImportBlock};
22use crate::language::LanguageProvider;
23use crate::parser::{detect_language, grammar_for, LangId};
24use crate::symbols::{Range, SymbolKind};
25
26// ---------------------------------------------------------------------------
27// Core types
28// ---------------------------------------------------------------------------
29
30type SharedPath = Arc<PathBuf>;
31type SharedStr = Arc<str>;
32type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
33type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36    LazyLock::new(|| RwLock::new(HashMap::new()));
37
38const TOP_LEVEL_SYMBOL: &str = "<top-level>";
39
40/// A single call site within a function body.
41#[derive(Debug, Clone)]
42pub struct CallSite {
43    /// The short callee name (last segment, e.g. "foo" for `utils.foo()`).
44    pub callee_name: String,
45    /// The full callee expression (e.g. "utils.foo" for `utils.foo()`).
46    pub full_callee: String,
47    /// 1-based line number of the call.
48    pub line: u32,
49    /// Byte range of the call expression in the source.
50    pub byte_start: usize,
51    pub byte_end: usize,
52}
53
54/// Per-symbol metadata for entry point detection (avoids re-parsing).
55#[derive(Debug, Clone, Serialize)]
56pub struct SymbolMeta {
57    /// The kind of symbol (function, class, method, etc).
58    pub kind: SymbolKind,
59    /// Whether this symbol is exported.
60    pub exported: bool,
61    /// Function/method signature if available.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub signature: Option<String>,
64    /// 1-based start line of the symbol.
65    pub line: u32,
66    /// 0-based source range of the symbol.
67    pub range: Range,
68}
69
70/// Per-file call data: call sites grouped by containing symbol, plus
71/// exported symbol names and parsed imports.
72#[derive(Debug, Clone)]
73pub struct FileCallData {
74    /// Map from symbol name → list of call sites within that symbol's body.
75    pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
76    /// Names of exported symbols in this file.
77    pub exported_symbols: Vec<String>,
78    /// Per-symbol metadata (kind, exported, signature).
79    pub symbol_metadata: HashMap<String, SymbolMeta>,
80    /// Real or synthetic symbol name for this file's default export.
81    pub default_export_symbol: Option<String>,
82    /// Parsed import block for cross-file resolution.
83    pub import_block: ImportBlock,
84    /// Language of the file.
85    pub lang: LangId,
86}
87
88/// Result of resolving a cross-file call edge.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum EdgeResolution {
91    /// Successfully resolved to a specific file and symbol.
92    Resolved { file: PathBuf, symbol: String },
93    /// Could not resolve — callee name preserved for diagnostics.
94    Unresolved { callee_name: String },
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98struct ResolvedSymbol {
99    file: PathBuf,
100    symbol: String,
101}
102
103/// A single caller site: who calls a given symbol and from where.
104#[derive(Debug, Clone, Serialize)]
105pub struct CallerSite {
106    /// File containing the caller.
107    pub caller_file: PathBuf,
108    /// Symbol that makes the call.
109    pub caller_symbol: String,
110    /// 1-based line number of the call.
111    pub line: u32,
112    /// 0-based column (byte start within file, kept for future use).
113    pub col: u32,
114    /// Whether the edge was resolved via import chain.
115    pub resolved: bool,
116}
117
118#[derive(Debug, Clone)]
119struct IndexedCallerSite {
120    caller_file: SharedPath,
121    caller_symbol: SharedStr,
122    line: u32,
123    col: u32,
124    resolved: bool,
125}
126
127/// A group of callers from a single file.
128#[derive(Debug, Clone, Serialize)]
129pub struct CallerGroup {
130    /// File path (relative to project root).
131    pub file: String,
132    /// Individual call sites in this file.
133    pub callers: Vec<CallerEntry>,
134}
135
136/// A single caller entry within a CallerGroup.
137#[derive(Debug, Clone, Serialize)]
138pub struct CallerEntry {
139    pub symbol: String,
140    /// 1-based line number of the call.
141    pub line: u32,
142}
143
144/// Result of a `callers_of` query.
145#[derive(Debug, Clone, Serialize)]
146pub struct CallersResult {
147    /// Target symbol queried.
148    pub symbol: String,
149    /// Target file queried.
150    pub file: String,
151    /// Caller groups, one per calling file.
152    pub callers: Vec<CallerGroup>,
153    /// Total number of call sites found.
154    pub total_callers: usize,
155    /// Number of files scanned to build the reverse index.
156    pub scanned_files: usize,
157}
158
159/// A node in the forward call tree.
160#[derive(Debug, Clone, Serialize)]
161pub struct CallTreeNode {
162    /// Symbol name.
163    pub name: String,
164    /// File path (relative to project root when possible).
165    pub file: String,
166    /// 1-based line number.
167    pub line: u32,
168    /// Function signature if available.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub signature: Option<String>,
171    /// Whether this edge was resolved cross-file.
172    pub resolved: bool,
173    /// Child calls (recursive).
174    pub children: Vec<CallTreeNode>,
175}
176
177// ---------------------------------------------------------------------------
178// Entry point detection
179// ---------------------------------------------------------------------------
180
181/// Well-known main/init function names (case-insensitive exact match).
182const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
183
184/// Determine whether a symbol is an entry point.
185///
186/// Entry points are:
187/// - Exported standalone functions (not methods — methods are class members)
188/// - Functions matching well-known main/init patterns (any language)
189/// - Test functions matching language-specific patterns
190pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
191    // Exported standalone functions
192    if exported && *kind == SymbolKind::Function {
193        return true;
194    }
195
196    // Main/init patterns (case-insensitive exact match, any kind)
197    let lower = name.to_lowercase();
198    if MAIN_INIT_NAMES.contains(&lower.as_str()) {
199        return true;
200    }
201
202    // Test patterns by language
203    match lang {
204        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
205            // describe, it, test (exact), or starts with test/spec
206            matches!(lower.as_str(), "describe" | "it" | "test")
207                || lower.starts_with("test")
208                || lower.starts_with("spec")
209        }
210        LangId::Python => {
211            // starts with test_ or matches setUp/tearDown
212            lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
213        }
214        LangId::Rust => {
215            // starts with test_
216            lower.starts_with("test_")
217        }
218        LangId::Go => {
219            // starts with Test (case-sensitive)
220            name.starts_with("Test")
221        }
222        LangId::C
223        | LangId::Cpp
224        | LangId::Zig
225        | LangId::CSharp
226        | LangId::Bash
227        | LangId::Solidity
228        | LangId::Vue
229        | LangId::Json
230        | LangId::Scala
231        | LangId::Java
232        | LangId::Ruby
233        | LangId::Kotlin
234        | LangId::Swift
235        | LangId::Php
236        | LangId::Lua
237        | LangId::Perl
238        | LangId::Html
239        | LangId::Markdown => false,
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Trace-to types
245// ---------------------------------------------------------------------------
246
247/// A single hop in a trace path.
248#[derive(Debug, Clone, Serialize)]
249pub struct TraceHop {
250    /// Symbol name at this hop.
251    pub symbol: String,
252    /// File path (relative to project root).
253    pub file: String,
254    /// 1-based line number.
255    pub line: u32,
256    /// Function signature if available.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub signature: Option<String>,
259    /// Whether this hop is an entry point.
260    pub is_entry_point: bool,
261}
262
263/// A complete path from an entry point to the target symbol (top-down).
264#[derive(Debug, Clone, Serialize)]
265pub struct TracePath {
266    /// Hops from entry point (first) to target (last).
267    pub hops: Vec<TraceHop>,
268}
269
270/// Result of a `trace_to` query.
271#[derive(Debug, Clone, Serialize)]
272pub struct TraceToResult {
273    /// The target symbol that was traced.
274    pub target_symbol: String,
275    /// The target file (relative to project root).
276    pub target_file: String,
277    /// Complete paths from entry points to the target.
278    pub paths: Vec<TracePath>,
279    /// Total number of complete paths found.
280    pub total_paths: usize,
281    /// Number of distinct entry points found across all paths.
282    pub entry_points_found: usize,
283    /// Whether any path was cut short by the depth limit.
284    pub max_depth_reached: bool,
285    /// Number of paths that reached a dead end (no callers, not entry point).
286    pub truncated_paths: usize,
287}
288
289// ---------------------------------------------------------------------------
290// Impact analysis types
291// ---------------------------------------------------------------------------
292
293/// A single caller in an impact analysis result.
294#[derive(Debug, Clone, Serialize)]
295pub struct ImpactCaller {
296    /// Symbol that calls the target.
297    pub caller_symbol: String,
298    /// File containing the caller (relative to project root).
299    pub caller_file: String,
300    /// 1-based line number of the call site.
301    pub line: u32,
302    /// Caller's function/method signature, if available.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub signature: Option<String>,
305    /// Whether the caller is an entry point.
306    pub is_entry_point: bool,
307    /// Source line at the call site (trimmed).
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub call_expression: Option<String>,
310    /// Parameter names extracted from the caller's signature.
311    pub parameters: Vec<String>,
312}
313
314/// Result of an `impact` query — enriched callers analysis.
315#[derive(Debug, Clone, Serialize)]
316pub struct ImpactResult {
317    /// The target symbol being analyzed.
318    pub symbol: String,
319    /// The target file (relative to project root).
320    pub file: String,
321    /// Target symbol's signature, if available.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub signature: Option<String>,
324    /// Parameter names extracted from the target's signature.
325    pub parameters: Vec<String>,
326    /// Total number of affected call sites.
327    pub total_affected: usize,
328    /// Number of distinct files containing callers.
329    pub affected_files: usize,
330    /// Enriched caller details.
331    pub callers: Vec<ImpactCaller>,
332}
333
334// ---------------------------------------------------------------------------
335// Data flow tracking types
336// ---------------------------------------------------------------------------
337
338/// A single hop in a data flow trace.
339#[derive(Debug, Clone, Serialize)]
340pub struct DataFlowHop {
341    /// File path (relative to project root).
342    pub file: String,
343    /// Symbol (function/method) containing this hop.
344    pub symbol: String,
345    /// Variable or parameter name being tracked at this hop.
346    pub variable: String,
347    /// 1-based line number.
348    pub line: u32,
349    /// Type of data flow: "assignment", "parameter", or "return".
350    pub flow_type: String,
351    /// Whether this hop is an approximation (destructuring, spread, unresolved).
352    pub approximate: bool,
353}
354
355/// Result of a `trace_data` query — tracks how an expression flows through
356/// variable assignments and function parameters.
357#[derive(Debug, Clone, Serialize)]
358pub struct TraceDataResult {
359    /// The expression being tracked.
360    pub expression: String,
361    /// The file where tracking started.
362    pub origin_file: String,
363    /// The symbol where tracking started.
364    pub origin_symbol: String,
365    /// Hops through assignments and parameters.
366    pub hops: Vec<DataFlowHop>,
367    /// Whether tracking stopped due to depth limit.
368    pub depth_limited: bool,
369}
370
371/// Extract parameter names from a function signature string.
372///
373/// Strips language-specific receivers (`self`, `&self`, `&mut self` for Rust,
374/// `self` for Python) and type annotations / default values. Returns just
375/// the parameter names.
376pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
377    // Find the parameter list between parentheses
378    let start = match signature.find('(') {
379        Some(i) => i + 1,
380        None => return Vec::new(),
381    };
382    let end = match signature[start..].find(')') {
383        Some(i) => start + i,
384        None => return Vec::new(),
385    };
386
387    let params_str = &signature[start..end].trim();
388    if params_str.is_empty() {
389        return Vec::new();
390    }
391
392    // Split on commas, respecting nested generics/brackets
393    let parts = split_params(params_str);
394
395    let mut result = Vec::new();
396    for part in parts {
397        let trimmed = part.trim();
398        if trimmed.is_empty() {
399            continue;
400        }
401
402        // Skip language-specific receivers
403        match lang {
404            LangId::Rust => {
405                if trimmed == "self"
406                    || trimmed == "mut self"
407                    || trimmed.starts_with("&self")
408                    || trimmed.starts_with("&mut self")
409                {
410                    continue;
411                }
412            }
413            LangId::Python => {
414                if trimmed == "self" || trimmed.starts_with("self:") {
415                    continue;
416                }
417            }
418            _ => {}
419        }
420
421        // Extract just the parameter name
422        let name = extract_param_name(trimmed, lang);
423        if !name.is_empty() {
424            result.push(name);
425        }
426    }
427
428    result
429}
430
431/// Split parameter string on commas, respecting nested brackets/generics.
432fn split_params(s: &str) -> Vec<String> {
433    let mut parts = Vec::new();
434    let mut current = String::new();
435    let mut depth = 0i32;
436
437    for ch in s.chars() {
438        match ch {
439            '<' | '[' | '{' | '(' => {
440                depth += 1;
441                current.push(ch);
442            }
443            '>' | ']' | '}' | ')' => {
444                depth -= 1;
445                current.push(ch);
446            }
447            ',' if depth == 0 => {
448                parts.push(current.clone());
449                current.clear();
450            }
451            _ => {
452                current.push(ch);
453            }
454        }
455    }
456    if !current.is_empty() {
457        parts.push(current);
458    }
459    parts
460}
461
462/// Extract the parameter name from a single parameter declaration.
463///
464/// Handles:
465/// - TS/JS: `name: Type`, `name = default`, `...name`, `name?: Type`
466/// - Python: `name: Type`, `name=default`, `*args`, `**kwargs`
467/// - Rust: `name: Type`, `mut name: Type`
468/// - Go: `name Type`, `name, name2 Type`
469fn extract_param_name(param: &str, lang: LangId) -> String {
470    let trimmed = param.trim();
471
472    // Handle rest/spread params
473    let working = if trimmed.starts_with("...") {
474        &trimmed[3..]
475    } else if trimmed.starts_with("**") {
476        &trimmed[2..]
477    } else if trimmed.starts_with('*') && lang == LangId::Python {
478        &trimmed[1..]
479    } else {
480        trimmed
481    };
482
483    // Rust: `mut name: Type` → strip `mut `
484    let working = if lang == LangId::Rust && working.starts_with("mut ") {
485        &working[4..]
486    } else {
487        working
488    };
489
490    // Strip type annotation (`: Type`) and default values (`= default`)
491    // Take only the name part — everything before `:`, `=`, or `?`
492    let name = working
493        .split(|c: char| c == ':' || c == '=')
494        .next()
495        .unwrap_or("")
496        .trim();
497
498    // Strip trailing `?` (optional params in TS)
499    let name = name.trim_end_matches('?');
500
501    // For Go, the name might be just `name Type` — take the first word
502    if lang == LangId::Go && !name.contains(' ') {
503        return name.to_string();
504    }
505    if lang == LangId::Go {
506        return name.split_whitespace().next().unwrap_or("").to_string();
507    }
508
509    name.to_string()
510}
511
512// ---------------------------------------------------------------------------
513// CallGraph
514// ---------------------------------------------------------------------------
515
516/// Worktree-scoped call graph with lazy per-file construction.
517///
518/// Files are parsed and analyzed on first access, then cached. The graph
519/// can resolve cross-file call edges using the import engine.
520pub struct CallGraph {
521    /// Cached per-file call data.
522    data: HashMap<PathBuf, FileCallData>,
523    /// Project root for relative path resolution.
524    project_root: PathBuf,
525    /// All files discovered in the worktree (lazily populated).
526    project_files: Option<Vec<PathBuf>>,
527    /// Reverse index: target_file → target_symbol → callers.
528    /// Built lazily on first `callers_of` call, cleared on `invalidate_file`.
529    reverse_index: Option<ReverseIndex>,
530}
531
532impl CallGraph {
533    /// Create a new call graph for a project.
534    pub fn new(project_root: PathBuf) -> Self {
535        clear_workspace_package_cache();
536        Self {
537            data: HashMap::new(),
538            project_root,
539            project_files: None,
540            reverse_index: None,
541        }
542    }
543
544    /// Get the project root directory.
545    pub fn project_root(&self) -> &Path {
546        &self.project_root
547    }
548
549    fn resolve_cross_file_edge_with_exports<F, D>(
550        full_callee: &str,
551        short_name: &str,
552        caller_file: &Path,
553        import_block: &ImportBlock,
554        mut file_exports_symbol: F,
555        mut file_default_export_symbol: D,
556    ) -> EdgeResolution
557    where
558        F: FnMut(&Path, &str) -> bool,
559        D: FnMut(&Path) -> Option<String>,
560    {
561        let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
562
563        // Check namespace imports: "utils.foo" where utils is a namespace import
564        if full_callee.contains('.') {
565            let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
566            if parts.len() == 2 {
567                let namespace = parts[0];
568                let member = parts[1];
569
570                for imp in &import_block.imports {
571                    if imp.namespace_import.as_deref() == Some(namespace) {
572                        if let Some(resolved_path) =
573                            resolve_module_path(caller_dir, &imp.module_path)
574                        {
575                            return EdgeResolution::Resolved {
576                                file: resolved_path,
577                                symbol: member.to_owned(),
578                            };
579                        }
580                    }
581                }
582            }
583        }
584
585        // Check named imports (direct and aliased)
586        for imp in &import_block.imports {
587            // Direct named import: import { foo } from './utils'
588            if imp.names.iter().any(|name| name == short_name) {
589                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
590                    let target = resolve_reexported_symbol(
591                        &resolved_path,
592                        short_name,
593                        &mut file_exports_symbol,
594                        &mut file_default_export_symbol,
595                    )
596                    .unwrap_or(ResolvedSymbol {
597                        file: resolved_path,
598                        symbol: short_name.to_owned(),
599                    });
600                    return EdgeResolution::Resolved {
601                        file: target.file,
602                        symbol: target.symbol,
603                    };
604                }
605            }
606
607            // Default import: import foo from './utils'
608            if imp.default_import.as_deref() == Some(short_name) {
609                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
610                    let target = resolve_reexported_symbol(
611                        &resolved_path,
612                        "default",
613                        &mut file_exports_symbol,
614                        &mut file_default_export_symbol,
615                    )
616                    .unwrap_or_else(|| ResolvedSymbol {
617                        symbol: file_default_export_symbol(&resolved_path)
618                            .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
619                        file: resolved_path,
620                    });
621                    return EdgeResolution::Resolved {
622                        file: target.file,
623                        symbol: target.symbol,
624                    };
625                }
626            }
627        }
628
629        // Check aliased imports by examining the raw import text.
630        // ImportStatement.names stores the original name (foo), but the local code
631        // uses the alias (bar). We need to parse `import { foo as bar }` to find
632        // that `bar` maps to `foo`.
633        if let Some((original_name, resolved_path)) =
634            resolve_aliased_import(short_name, import_block, caller_dir)
635        {
636            let target = resolve_reexported_symbol(
637                &resolved_path,
638                &original_name,
639                &mut file_exports_symbol,
640                &mut file_default_export_symbol,
641            )
642            .unwrap_or(ResolvedSymbol {
643                file: resolved_path,
644                symbol: original_name,
645            });
646            return EdgeResolution::Resolved {
647                file: target.file,
648                symbol: target.symbol,
649            };
650        }
651
652        // Try barrel file re-exports: if any import points to an index file,
653        // check if that file re-exports the symbol
654        for imp in &import_block.imports {
655            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
656                // Check if the resolved path is a directory (barrel file)
657                if resolved_path.is_dir() {
658                    if let Some(index_path) = find_index_file(&resolved_path) {
659                        // Check if the index file exports this symbol
660                        if file_exports_symbol(&index_path, short_name) {
661                            return EdgeResolution::Resolved {
662                                file: index_path,
663                                symbol: short_name.to_owned(),
664                            };
665                        }
666                    }
667                } else if file_exports_symbol(&resolved_path, short_name) {
668                    return EdgeResolution::Resolved {
669                        file: resolved_path,
670                        symbol: short_name.to_owned(),
671                    };
672                }
673            }
674        }
675
676        EdgeResolution::Unresolved {
677            callee_name: short_name.to_owned(),
678        }
679    }
680
681    /// Get or build the call data for a file.
682    pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
683        let canon = self.canonicalize(path)?;
684
685        if !self.data.contains_key(&canon) {
686            let file_data = build_file_data(&canon)?;
687            self.data.insert(canon.clone(), file_data);
688        }
689
690        Ok(&self.data[&canon])
691    }
692
693    /// Resolve a cross-file call edge.
694    ///
695    /// Given a callee expression and the calling file's import block,
696    /// determines which file and symbol the call targets.
697    pub fn resolve_cross_file_edge(
698        &mut self,
699        full_callee: &str,
700        short_name: &str,
701        caller_file: &Path,
702        import_block: &ImportBlock,
703    ) -> EdgeResolution {
704        let graph = RefCell::new(self);
705        Self::resolve_cross_file_edge_with_exports(
706            full_callee,
707            short_name,
708            caller_file,
709            import_block,
710            |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
711            |path| graph.borrow_mut().file_default_export_symbol(path),
712        )
713    }
714
715    /// Check if a file exports a given symbol name.
716    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
717        match self.build_file(path) {
718            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
719            Err(_) => false,
720        }
721    }
722
723    fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
724        self.build_file(path)
725            .ok()
726            .and_then(|data| data.default_export_symbol.clone())
727    }
728
729    fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
730        self.lookup_file_data(path)
731            .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
732            .unwrap_or(false)
733    }
734
735    fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
736        self.lookup_file_data(path)
737            .and_then(|data| data.default_export_symbol.clone())
738    }
739
740    /// Depth-limited forward call tree traversal.
741    ///
742    /// Starting from a (file, symbol) pair, recursively follows calls
743    /// up to `max_depth` levels. Uses a visited set for cycle detection.
744    pub fn forward_tree(
745        &mut self,
746        file: &Path,
747        symbol: &str,
748        max_depth: usize,
749    ) -> Result<CallTreeNode, AftError> {
750        let mut visited = HashSet::new();
751        self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
752    }
753
754    fn forward_tree_inner(
755        &mut self,
756        file: &Path,
757        symbol: &str,
758        max_depth: usize,
759        current_depth: usize,
760        visited: &mut HashSet<(PathBuf, String)>,
761    ) -> Result<CallTreeNode, AftError> {
762        let canon = self.canonicalize(file)?;
763        let visit_key = (canon.clone(), symbol.to_string());
764
765        // Cycle detection
766        if visited.contains(&visit_key) {
767            let (line, signature) = self
768                .lookup_file_data(&canon)
769                .map(|data| get_symbol_meta_from_data(data, symbol))
770                .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
771            return Ok(CallTreeNode {
772                name: symbol.to_string(),
773                file: self.relative_path(&canon),
774                line,
775                signature,
776                resolved: true,
777                children: vec![], // cycle — stop recursion
778            });
779        }
780
781        visited.insert(visit_key.clone());
782
783        let (import_block, call_sites, sym_line, sym_signature) = {
784            let file_data = self.build_file(&canon)?;
785            let meta = get_symbol_meta_from_data(file_data, symbol);
786
787            (
788                file_data.import_block.clone(),
789                file_data
790                    .calls_by_symbol
791                    .get(symbol)
792                    .cloned()
793                    .unwrap_or_default(),
794                meta.0,
795                meta.1,
796            )
797        };
798
799        // Build children
800        let mut children = Vec::new();
801
802        if current_depth < max_depth {
803            for call_site in &call_sites {
804                let edge = self.resolve_cross_file_edge(
805                    &call_site.full_callee,
806                    &call_site.callee_name,
807                    &canon,
808                    &import_block,
809                );
810
811                match edge {
812                    EdgeResolution::Resolved {
813                        file: ref target_file,
814                        ref symbol,
815                    } => {
816                        match self.forward_tree_inner(
817                            target_file,
818                            symbol,
819                            max_depth,
820                            current_depth + 1,
821                            visited,
822                        ) {
823                            Ok(child) => children.push(child),
824                            Err(_) => {
825                                // Target file can't be parsed — mark as unresolved leaf
826                                children.push(CallTreeNode {
827                                    name: call_site.callee_name.clone(),
828                                    file: self.relative_path(target_file),
829                                    line: call_site.line,
830                                    signature: None,
831                                    resolved: false,
832                                    children: vec![],
833                                });
834                            }
835                        }
836                    }
837                    EdgeResolution::Unresolved { callee_name } => {
838                        if let Some(local_child) = self.resolve_local_call_tree_child(
839                            &canon,
840                            symbol,
841                            call_site,
842                            &callee_name,
843                            max_depth,
844                            current_depth,
845                            visited,
846                        )? {
847                            children.push(local_child);
848                            continue;
849                        }
850                        children.push(CallTreeNode {
851                            name: callee_name,
852                            file: self.relative_path(&canon),
853                            line: call_site.line,
854                            signature: None,
855                            resolved: false,
856                            children: vec![],
857                        });
858                    }
859                }
860            }
861        }
862
863        visited.remove(&visit_key);
864
865        Ok(CallTreeNode {
866            name: symbol.to_string(),
867            file: self.relative_path(&canon),
868            line: sym_line,
869            signature: sym_signature,
870            resolved: true,
871            children,
872        })
873    }
874
875    fn resolve_local_call_tree_child(
876        &mut self,
877        canon: &Path,
878        current_symbol: &str,
879        call_site: &CallSite,
880        callee_name: &str,
881        max_depth: usize,
882        current_depth: usize,
883        visited: &mut HashSet<(PathBuf, String)>,
884    ) -> Result<Option<CallTreeNode>, AftError> {
885        let has_local_symbol = self
886            .lookup_file_data(canon)
887            .map(|data| data.symbol_metadata.contains_key(callee_name))
888            .unwrap_or(false);
889        if !has_local_symbol {
890            return Ok(None);
891        }
892        if callee_name == current_symbol {
893            return Ok(None);
894        }
895
896        match self.forward_tree_inner(canon, callee_name, max_depth, current_depth + 1, visited) {
897            Ok(child) => Ok(Some(child)),
898            Err(_) => Ok(Some(CallTreeNode {
899                name: callee_name.to_string(),
900                file: self.relative_path(canon),
901                line: call_site.line,
902                signature: None,
903                resolved: false,
904                children: vec![],
905            })),
906        }
907    }
908
909    /// Get all project files (lazily discovered).
910    pub fn project_files(&mut self) -> &[PathBuf] {
911        if self.project_files.is_none() {
912            let project_root = self.project_root.clone();
913            self.project_files = Some(walk_project_files(&project_root).collect());
914        }
915        self.project_files.as_deref().unwrap_or(&[])
916    }
917
918    /// Get the total number of project source files.
919    ///
920    /// Triggers project file discovery on first access and returns the cached
921    /// count thereafter. Prefer [`project_file_count_bounded`] when the caller
922    /// only needs to know whether a threshold is exceeded.
923    pub fn project_file_count(&mut self) -> usize {
924        self.project_files().len()
925    }
926
927    /// Count project source files, stopping after `limit + 1` so huge roots
928    /// do not pay for a full walk or allocate a giant vector.
929    ///
930    /// Returns the real count when ≤ `limit`, or `limit + 1` when exceeded.
931    /// Uses the cached `project_files` vec when it already exists (e.g. a
932    /// previous call-graph op succeeded at this cap), otherwise short-circuits
933    /// the underlying `ignore::Walk` iterator via `.take(limit + 1)`.
934    ///
935    /// CRITICAL: This method must NOT populate `self.project_files`. The whole
936    /// point is to reject oversized roots before the full walk-and-collect runs.
937    pub fn project_file_count_bounded(&self, limit: usize) -> usize {
938        if let Some(files) = self.project_files.as_deref() {
939            return files.len();
940        }
941        walk_project_files(&self.project_root)
942            .take(limit.saturating_add(1))
943            .count()
944    }
945
946    /// Build the reverse index by scanning all project files.
947    ///
948    /// For each file, builds the call data (if not cached), then for each
949    /// (symbol, call_sites) pair, resolves cross-file edges and inserts
950    /// into the reverse map: `(target_file, target_symbol) → Vec<CallerSite>`.
951    fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
952        // Bounded count first — never populate project_files on oversized roots.
953        // `walk_project_files(...).take(max_files + 1)` is lazy (Walk is an
954        // iterator), so this costs at most (max_files + 1) directory entries
955        // worth of work, not a full O(N) walk of the whole tree.
956        let count = self.project_file_count_bounded(max_files);
957        if count > max_files {
958            return Err(AftError::ProjectTooLarge {
959                count,
960                max: max_files,
961            });
962        }
963
964        // TODO(v0.16): rust-side deadline for graceful timeout recovery
965        // (unbounded walks remain a soft cliff for users who raise the cap).
966        // Discover all project files first
967        let all_files = self.project_files().to_vec();
968
969        // Build file data for all project files
970        let uncached_files: Vec<PathBuf> = all_files
971            .iter()
972            .filter(|f| self.lookup_file_data(f).is_none())
973            .cloned()
974            .collect();
975
976        let computed: Vec<(PathBuf, FileCallData)> = uncached_files
977            .par_iter()
978            .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
979            .collect();
980
981        for (file, data) in computed {
982            self.data.insert(file, data);
983        }
984
985        // Now build the reverse map
986        let mut reverse: ReverseIndex = HashMap::new();
987
988        for caller_file in &all_files {
989            // Canonicalize the caller file path for consistent lookups
990            let canon_caller = Arc::new(
991                std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
992            );
993            let file_data = match self
994                .data
995                .get(caller_file)
996                .or_else(|| self.data.get(canon_caller.as_ref()))
997            {
998                Some(d) => d,
999                None => continue,
1000            };
1001
1002            for (symbol_name, call_sites) in &file_data.calls_by_symbol {
1003                let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
1004
1005                for call_site in call_sites {
1006                    let edge = Self::resolve_cross_file_edge_with_exports(
1007                        &call_site.full_callee,
1008                        &call_site.callee_name,
1009                        canon_caller.as_ref(),
1010                        &file_data.import_block,
1011                        |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1012                        |path| self.file_default_export_symbol_cached(path),
1013                    );
1014
1015                    let (target_file, target_symbol, resolved) = match edge {
1016                        EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1017                        EdgeResolution::Unresolved { callee_name } => {
1018                            (canon_caller.as_ref().clone(), callee_name, false)
1019                        }
1020                    };
1021
1022                    if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1023                        continue;
1024                    }
1025
1026                    reverse
1027                        .entry(target_file)
1028                        .or_default()
1029                        .entry(target_symbol)
1030                        .or_default()
1031                        .push(IndexedCallerSite {
1032                            caller_file: Arc::clone(&canon_caller),
1033                            caller_symbol: Arc::clone(&caller_symbol),
1034                            line: call_site.line,
1035                            col: 0,
1036                            resolved,
1037                        });
1038                }
1039            }
1040        }
1041
1042        self.reverse_index = Some(reverse);
1043        Ok(())
1044    }
1045
1046    fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1047        self.reverse_index
1048            .as_ref()?
1049            .get(file)?
1050            .get(symbol)
1051            .map(Vec::as_slice)
1052    }
1053
1054    /// Get callers of a symbol in a file, grouped by calling file.
1055    ///
1056    /// Builds the reverse index on first call (scans all project files).
1057    /// Supports recursive depth expansion: depth=1 returns direct callers,
1058    /// depth=2 returns callers-of-callers, etc. depth=0 is treated as 1.
1059    pub fn callers_of(
1060        &mut self,
1061        file: &Path,
1062        symbol: &str,
1063        depth: usize,
1064        max_files: usize,
1065    ) -> Result<CallersResult, AftError> {
1066        let canon = self.canonicalize(file)?;
1067
1068        // Ensure file is built (may already be cached)
1069        self.build_file(&canon)?;
1070
1071        // Build the reverse index if not cached
1072        if self.reverse_index.is_none() {
1073            self.build_reverse_index(max_files)?;
1074        }
1075
1076        let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1077        let effective_depth = if depth == 0 { 1 } else { depth };
1078
1079        let mut visited = HashSet::new();
1080        let mut all_sites: Vec<CallerSite> = Vec::new();
1081        self.collect_callers_recursive(
1082            &canon,
1083            symbol,
1084            effective_depth,
1085            0,
1086            &mut visited,
1087            &mut all_sites,
1088        );
1089
1090        // Group by file
1091
1092        let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1093        let total_callers = all_sites.len();
1094        for site in all_sites {
1095            let caller_file: PathBuf = site.caller_file;
1096            let caller_symbol: String = site.caller_symbol;
1097            let line = site.line;
1098            let entry = CallerEntry {
1099                symbol: caller_symbol,
1100                line,
1101            };
1102
1103            if let Some(entries) = groups_map.get_mut(&caller_file) {
1104                entries.push(entry);
1105            } else {
1106                groups_map.insert(caller_file, vec![entry]);
1107            }
1108        }
1109
1110        let mut callers: Vec<CallerGroup> = groups_map
1111            .into_iter()
1112            .map(|(file_path, entries)| CallerGroup {
1113                file: self.relative_path(&file_path),
1114                callers: entries,
1115            })
1116            .collect();
1117
1118        // Sort groups by file path for deterministic output
1119        callers.sort_by(|a, b| a.file.cmp(&b.file));
1120
1121        Ok(CallersResult {
1122            symbol: symbol.to_string(),
1123            file: self.relative_path(&canon),
1124            callers,
1125            total_callers,
1126            scanned_files,
1127        })
1128    }
1129
1130    /// Trace backward from a symbol to all entry points.
1131    ///
1132    /// Returns complete paths (top-down: entry point first, target last).
1133    /// Uses BFS backward through the reverse index, with per-path cycle
1134    /// detection and depth limiting.
1135    pub fn trace_to(
1136        &mut self,
1137        file: &Path,
1138        symbol: &str,
1139        max_depth: usize,
1140        max_files: usize,
1141    ) -> Result<TraceToResult, AftError> {
1142        let canon = self.canonicalize(file)?;
1143
1144        // Ensure file is built
1145        self.build_file(&canon)?;
1146
1147        // Build the reverse index if not cached
1148        if self.reverse_index.is_none() {
1149            self.build_reverse_index(max_files)?;
1150        }
1151
1152        let target_rel = self.relative_path(&canon);
1153        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1154        if self.reverse_index.is_none() {
1155            return Err(AftError::ParseError {
1156                message: format!(
1157                    "reverse index unavailable after building callers for {}",
1158                    canon.display()
1159                ),
1160            });
1161        }
1162
1163        // Get line/signature for the target symbol
1164        let (target_line, target_sig) = self
1165            .lookup_file_data(&canon)
1166            .map(|data| get_symbol_meta_from_data(data, symbol))
1167            .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1168
1169        // Check if target itself is an entry point
1170        let target_is_entry = self
1171            .lookup_file_data(&canon)
1172            .and_then(|fd| {
1173                let meta = fd.symbol_metadata.get(symbol)?;
1174                Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1175            })
1176            .unwrap_or(false);
1177
1178        // BFS state: each item is a partial path (bottom-up, will be reversed later)
1179        // Each path element: (canonicalized file, symbol name, line, signature)
1180        type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1181        let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1182        let mut max_depth_reached = false;
1183        let mut truncated_paths: usize = 0;
1184
1185        // Initial path starts at the target
1186        let initial: Vec<PathElem> = vec![(
1187            Arc::new(canon.clone()),
1188            Arc::from(symbol),
1189            target_line,
1190            target_sig,
1191        )];
1192
1193        // If the target itself is an entry point, record it as a trivial path
1194        if target_is_entry {
1195            complete_paths.push(initial.clone());
1196        }
1197
1198        // Queue of (current_path, depth)
1199        let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1200
1201        while let Some((path, depth)) = queue.pop() {
1202            if depth >= effective_max {
1203                max_depth_reached = true;
1204                continue;
1205            }
1206
1207            let Some((current_file, current_symbol, _, _)) = path.last() else {
1208                continue;
1209            };
1210
1211            // Look up callers in reverse index
1212            let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1213                Some(sites) => sites,
1214                None => {
1215                    // Dead end: no callers and not an entry point
1216                    // (if it were an entry point, we'd have recorded it already)
1217                    if path.len() > 1 {
1218                        // Only count as truncated if this isn't the target itself
1219                        // (the target with no callers is just "no paths found")
1220                        truncated_paths += 1;
1221                    }
1222                    continue;
1223                }
1224            };
1225
1226            let mut has_new_path = false;
1227            for site in callers {
1228                // Cycle detection: skip if this caller is already in the current path
1229                if path.iter().any(|(file_path, sym, _, _)| {
1230                    file_path.as_ref() == site.caller_file.as_ref()
1231                        && sym.as_ref() == site.caller_symbol.as_ref()
1232                }) {
1233                    continue;
1234                }
1235
1236                has_new_path = true;
1237
1238                // Get caller's metadata
1239                let (caller_line, caller_sig) = self
1240                    .lookup_file_data(site.caller_file.as_ref())
1241                    .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1242                    .unwrap_or_else(|| {
1243                        get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1244                    });
1245
1246                let mut new_path = path.clone();
1247                new_path.push((
1248                    Arc::clone(&site.caller_file),
1249                    Arc::clone(&site.caller_symbol),
1250                    caller_line,
1251                    caller_sig,
1252                ));
1253
1254                // Check if this caller is an entry point
1255                // Try both canonical and non-canonical keys (build_reverse_index
1256                // may have stored data under the raw walker path)
1257                let caller_is_entry = self
1258                    .lookup_file_data(site.caller_file.as_ref())
1259                    .and_then(|fd| {
1260                        let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1261                        Some(is_entry_point(
1262                            site.caller_symbol.as_ref(),
1263                            &meta.kind,
1264                            meta.exported,
1265                            fd.lang,
1266                        ))
1267                    })
1268                    .unwrap_or(false);
1269
1270                if caller_is_entry {
1271                    complete_paths.push(new_path.clone());
1272                }
1273                // Always continue searching backward — there may be longer
1274                // paths through other entry points beyond this one
1275                queue.push((new_path, depth + 1));
1276            }
1277
1278            // If we had callers but none were new (all cycles), count as truncated
1279            if !has_new_path && path.len() > 1 {
1280                truncated_paths += 1;
1281            }
1282        }
1283
1284        // Reverse each path so it reads top-down (entry point → ... → target)
1285        // and convert to TraceHop/TracePath
1286        let mut paths: Vec<TracePath> = complete_paths
1287            .into_iter()
1288            .map(|mut elems| {
1289                elems.reverse();
1290                let hops: Vec<TraceHop> = elems
1291                    .iter()
1292                    .enumerate()
1293                    .map(|(i, (file_path, sym, line, sig))| {
1294                        let is_ep = if i == 0 {
1295                            // First hop (after reverse) is the entry point
1296                            self.lookup_file_data(file_path.as_ref())
1297                                .and_then(|fd| {
1298                                    let meta = fd.symbol_metadata.get(sym.as_ref())?;
1299                                    Some(is_entry_point(
1300                                        sym.as_ref(),
1301                                        &meta.kind,
1302                                        meta.exported,
1303                                        fd.lang,
1304                                    ))
1305                                })
1306                                .unwrap_or(false)
1307                        } else {
1308                            false
1309                        };
1310                        TraceHop {
1311                            symbol: sym.to_string(),
1312                            file: self.relative_path(file_path.as_ref()),
1313                            line: *line,
1314                            signature: sig.clone(),
1315                            is_entry_point: is_ep,
1316                        }
1317                    })
1318                    .collect();
1319                TracePath { hops }
1320            })
1321            .collect();
1322
1323        // Sort paths for deterministic output (by entry point name, then path length)
1324        paths.sort_by(|a, b| {
1325            let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1326            let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1327            a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1328        });
1329
1330        // Count distinct entry points
1331        let mut entry_point_names: HashSet<String> = HashSet::new();
1332        for p in &paths {
1333            if let Some(first) = p.hops.first() {
1334                if first.is_entry_point {
1335                    entry_point_names.insert(first.symbol.clone());
1336                }
1337            }
1338        }
1339
1340        let total_paths = paths.len();
1341        let entry_points_found = entry_point_names.len();
1342
1343        Ok(TraceToResult {
1344            target_symbol: symbol.to_string(),
1345            target_file: target_rel,
1346            paths,
1347            total_paths,
1348            entry_points_found,
1349            max_depth_reached,
1350            truncated_paths,
1351        })
1352    }
1353
1354    /// Impact analysis: enriched callers query.
1355    ///
1356    /// Returns all call sites affected by a change to the given symbol,
1357    /// annotated with each caller's signature, entry point status, the
1358    /// source line at the call site, and extracted parameter names.
1359    pub fn impact(
1360        &mut self,
1361        file: &Path,
1362        symbol: &str,
1363        depth: usize,
1364        max_files: usize,
1365    ) -> Result<ImpactResult, AftError> {
1366        let canon = self.canonicalize(file)?;
1367
1368        // Ensure file is built
1369        self.build_file(&canon)?;
1370
1371        // Build the reverse index if not cached
1372        if self.reverse_index.is_none() {
1373            self.build_reverse_index(max_files)?;
1374        }
1375
1376        let effective_depth = if depth == 0 { 1 } else { depth };
1377
1378        // Get the target symbol's own metadata
1379        let (target_signature, target_parameters, target_lang) = {
1380            let file_data = match self.data.get(&canon) {
1381                Some(d) => d,
1382                None => {
1383                    return Err(AftError::InvalidRequest {
1384                        message: "file data missing after build".to_string(),
1385                    })
1386                }
1387            };
1388            let meta = file_data.symbol_metadata.get(symbol);
1389            let sig = meta.and_then(|m| m.signature.clone());
1390            let lang = file_data.lang;
1391            let params = sig
1392                .as_deref()
1393                .map(|s| extract_parameters(s, lang))
1394                .unwrap_or_default();
1395            (sig, params, lang)
1396        };
1397
1398        // Collect all caller sites (transitive)
1399        let mut visited = HashSet::new();
1400        let mut all_sites: Vec<CallerSite> = Vec::new();
1401        self.collect_callers_recursive(
1402            &canon,
1403            symbol,
1404            effective_depth,
1405            0,
1406            &mut visited,
1407            &mut all_sites,
1408        );
1409
1410        // Deduplicate sites by (file, symbol, line)
1411        let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1412        all_sites.retain(|site| {
1413            seen.insert((
1414                site.caller_file.clone(),
1415                site.caller_symbol.clone(),
1416                site.line,
1417            ))
1418        });
1419
1420        // Enrich each caller site
1421        let mut callers = Vec::new();
1422        let mut affected_file_set = HashSet::new();
1423
1424        for site in &all_sites {
1425            // Build the caller's file to get metadata
1426            if let Err(e) = self.build_file(site.caller_file.as_path()) {
1427                log::debug!(
1428                    "callgraph: skipping caller file {}: {}",
1429                    site.caller_file.display(),
1430                    e
1431                );
1432            }
1433
1434            let (sig, is_ep, params, _lang) = {
1435                if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1436                    let meta = fd.symbol_metadata.get(&site.caller_symbol);
1437                    let sig = meta.and_then(|m| m.signature.clone());
1438                    let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1439                    let exported = meta.map(|m| m.exported).unwrap_or(false);
1440                    let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1441                    let lang = fd.lang;
1442                    let params = sig
1443                        .as_deref()
1444                        .map(|s| extract_parameters(s, lang))
1445                        .unwrap_or_default();
1446                    (sig, is_ep, params, lang)
1447                } else {
1448                    (None, false, Vec::new(), target_lang)
1449                }
1450            };
1451
1452            // Read the source line at the call site
1453            let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1454
1455            let rel_file = self.relative_path(site.caller_file.as_path());
1456            affected_file_set.insert(rel_file.clone());
1457
1458            callers.push(ImpactCaller {
1459                caller_symbol: site.caller_symbol.clone(),
1460                caller_file: rel_file,
1461                line: site.line,
1462                signature: sig,
1463                is_entry_point: is_ep,
1464                call_expression,
1465                parameters: params,
1466            });
1467        }
1468
1469        // Sort callers by file then line for deterministic output
1470        callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1471
1472        let total_affected = callers.len();
1473        let affected_files = affected_file_set.len();
1474
1475        Ok(ImpactResult {
1476            symbol: symbol.to_string(),
1477            file: self.relative_path(&canon),
1478            signature: target_signature,
1479            parameters: target_parameters,
1480            total_affected,
1481            affected_files,
1482            callers,
1483        })
1484    }
1485
1486    /// Trace how an expression flows through variable assignments within a
1487    /// function body and across function boundaries via argument-to-parameter
1488    /// matching.
1489    ///
1490    /// Algorithm:
1491    /// 1. Parse the function body, find the expression text.
1492    /// 2. Walk AST for assignments that reference the tracked name.
1493    /// 3. When the tracked name appears as a call argument, resolve the callee,
1494    ///    match argument position to parameter name, recurse.
1495    /// 4. Destructuring, spread, and unresolved calls produce approximate hops.
1496    pub fn trace_data(
1497        &mut self,
1498        file: &Path,
1499        symbol: &str,
1500        expression: &str,
1501        max_depth: usize,
1502        max_files: usize,
1503    ) -> Result<TraceDataResult, AftError> {
1504        let canon = self.canonicalize(file)?;
1505        let rel_file = self.relative_path(&canon);
1506
1507        // Ensure file data is built
1508        self.build_file(&canon)?;
1509
1510        // Verify symbol exists
1511        {
1512            let fd = match self.data.get(&canon) {
1513                Some(d) => d,
1514                None => {
1515                    return Err(AftError::InvalidRequest {
1516                        message: "file data missing after build".to_string(),
1517                    })
1518                }
1519            };
1520            let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1521                || fd.exported_symbols.iter().any(|name| name == symbol)
1522                || fd.symbol_metadata.contains_key(symbol);
1523            if !has_symbol {
1524                return Err(AftError::InvalidRequest {
1525                    message: format!(
1526                        "trace_data: symbol '{}' not found in {}",
1527                        symbol,
1528                        file.display()
1529                    ),
1530                });
1531            }
1532        }
1533
1534        // Bounded count: short-circuits at `max_files + 1` so oversized roots
1535        // reject in microseconds instead of paying the full walk/collect cost.
1536        // Matches the guard used by build_reverse_index / callers_of / trace_to / impact.
1537        let count = self.project_file_count_bounded(max_files);
1538        if count > max_files {
1539            return Err(AftError::ProjectTooLarge {
1540                count,
1541                max: max_files,
1542            });
1543        }
1544
1545        let mut hops = Vec::new();
1546        let mut depth_limited = false;
1547
1548        self.trace_data_inner(
1549            &canon,
1550            symbol,
1551            expression,
1552            max_depth,
1553            0,
1554            &mut hops,
1555            &mut depth_limited,
1556            &mut HashSet::new(),
1557        );
1558
1559        Ok(TraceDataResult {
1560            expression: expression.to_string(),
1561            origin_file: rel_file,
1562            origin_symbol: symbol.to_string(),
1563            hops,
1564            depth_limited,
1565        })
1566    }
1567
1568    /// Inner recursive data flow tracking.
1569    fn trace_data_inner(
1570        &mut self,
1571        file: &Path,
1572        symbol: &str,
1573        tracking_name: &str,
1574        max_depth: usize,
1575        current_depth: usize,
1576        hops: &mut Vec<DataFlowHop>,
1577        depth_limited: &mut bool,
1578        visited: &mut HashSet<(PathBuf, String, String)>,
1579    ) {
1580        let visit_key = (
1581            file.to_path_buf(),
1582            symbol.to_string(),
1583            tracking_name.to_string(),
1584        );
1585        if visited.contains(&visit_key) {
1586            return; // cycle
1587        }
1588        visited.insert(visit_key);
1589
1590        // Read and parse the file
1591        let source = match std::fs::read_to_string(file) {
1592            Ok(s) => s,
1593            Err(_) => return,
1594        };
1595
1596        let lang = match detect_language(file) {
1597            Some(l) => l,
1598            None => return,
1599        };
1600
1601        let grammar = grammar_for(lang);
1602        let mut parser = Parser::new();
1603        if parser.set_language(&grammar).is_err() {
1604            return;
1605        }
1606        let tree = match parser.parse(&source, None) {
1607            Some(t) => t,
1608            None => return,
1609        };
1610
1611        // Find the symbol's AST node range
1612        let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1613            Ok(symbols) => symbols,
1614            Err(_) => return,
1615        };
1616        let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1617            Some(s) => s,
1618            None => return,
1619        };
1620
1621        let body_start =
1622            line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1623        let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1624
1625        let root = tree.root_node();
1626
1627        // Find the symbol's body node (the function/method definition node)
1628        let body_node = match find_node_covering_range(root, body_start, body_end) {
1629            Some(n) => n,
1630            None => return,
1631        };
1632
1633        // Track names through the body
1634        let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1635        let rel_file = self.relative_path(file);
1636
1637        // Walk the body looking for assignments and calls
1638        self.walk_for_data_flow(
1639            body_node,
1640            &source,
1641            &mut tracked_names,
1642            file,
1643            symbol,
1644            &rel_file,
1645            lang,
1646            max_depth,
1647            current_depth,
1648            hops,
1649            depth_limited,
1650            visited,
1651        );
1652    }
1653
1654    /// Walk an AST subtree looking for assignments and call expressions that
1655    /// reference tracked names.
1656    #[allow(clippy::too_many_arguments)]
1657    fn walk_for_data_flow(
1658        &mut self,
1659        node: tree_sitter::Node,
1660        source: &str,
1661        tracked_names: &mut Vec<String>,
1662        file: &Path,
1663        symbol: &str,
1664        rel_file: &str,
1665        lang: LangId,
1666        max_depth: usize,
1667        current_depth: usize,
1668        hops: &mut Vec<DataFlowHop>,
1669        depth_limited: &mut bool,
1670        visited: &mut HashSet<(PathBuf, String, String)>,
1671    ) {
1672        let kind = node.kind();
1673
1674        // Check for variable declarations / assignments
1675        let is_var_decl = matches!(
1676            kind,
1677            "variable_declarator"
1678                | "assignment_expression"
1679                | "augmented_assignment_expression"
1680                | "assignment"
1681                | "let_declaration"
1682                | "short_var_declaration"
1683        );
1684
1685        if is_var_decl {
1686            if let Some((new_name, init_text, line, is_approx)) =
1687                self.extract_assignment_info(node, source, lang, tracked_names)
1688            {
1689                // The RHS references a tracked name — add assignment hop
1690                if !is_approx {
1691                    hops.push(DataFlowHop {
1692                        file: rel_file.to_string(),
1693                        symbol: symbol.to_string(),
1694                        variable: new_name.clone(),
1695                        line,
1696                        flow_type: "assignment".to_string(),
1697                        approximate: false,
1698                    });
1699                    tracked_names.push(new_name);
1700                } else {
1701                    // Destructuring or pattern — approximate
1702                    hops.push(DataFlowHop {
1703                        file: rel_file.to_string(),
1704                        symbol: symbol.to_string(),
1705                        variable: init_text,
1706                        line,
1707                        flow_type: "assignment".to_string(),
1708                        approximate: true,
1709                    });
1710                    // Don't track further through this branch
1711                    return;
1712                }
1713            }
1714        }
1715
1716        // Check for call expressions where tracked name is an argument
1717        if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1718            self.check_call_for_data_flow(
1719                node,
1720                source,
1721                tracked_names,
1722                file,
1723                symbol,
1724                rel_file,
1725                lang,
1726                max_depth,
1727                current_depth,
1728                hops,
1729                depth_limited,
1730                visited,
1731            );
1732        }
1733
1734        // Recurse into children
1735        let mut cursor = node.walk();
1736        if cursor.goto_first_child() {
1737            loop {
1738                let child = cursor.node();
1739                // Don't re-process the current node type in recursion
1740                self.walk_for_data_flow(
1741                    child,
1742                    source,
1743                    tracked_names,
1744                    file,
1745                    symbol,
1746                    rel_file,
1747                    lang,
1748                    max_depth,
1749                    current_depth,
1750                    hops,
1751                    depth_limited,
1752                    visited,
1753                );
1754                if !cursor.goto_next_sibling() {
1755                    break;
1756                }
1757            }
1758        }
1759    }
1760
1761    /// Check if an assignment/declaration node assigns from a tracked name.
1762    /// Returns (new_name, init_text, line, is_approximate).
1763    fn extract_assignment_info(
1764        &self,
1765        node: tree_sitter::Node,
1766        source: &str,
1767        _lang: LangId,
1768        tracked_names: &[String],
1769    ) -> Option<(String, String, u32, bool)> {
1770        let kind = node.kind();
1771        let line = node.start_position().row as u32 + 1;
1772
1773        match kind {
1774            "variable_declarator" => {
1775                // TS/JS: const x = <expr>
1776                let name_node = node.child_by_field_name("name")?;
1777                let value_node = node.child_by_field_name("value")?;
1778                let name_text = node_text(name_node, source);
1779                let value_text = node_text(value_node, source);
1780
1781                // Check if name is a destructuring pattern
1782                if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1783                    // Check if value references a tracked name
1784                    if tracked_names.iter().any(|t| value_text.contains(t)) {
1785                        return Some((name_text.clone(), name_text, line, true));
1786                    }
1787                    return None;
1788                }
1789
1790                // Check if value references any tracked name
1791                if tracked_names.iter().any(|t| {
1792                    value_text == *t
1793                        || value_text.starts_with(&format!("{}.", t))
1794                        || value_text.starts_with(&format!("{}[", t))
1795                }) {
1796                    return Some((name_text, value_text, line, false));
1797                }
1798                None
1799            }
1800            "assignment_expression" | "augmented_assignment_expression" => {
1801                // TS/JS: x = <expr>
1802                let left = node.child_by_field_name("left")?;
1803                let right = node.child_by_field_name("right")?;
1804                let left_text = node_text(left, source);
1805                let right_text = node_text(right, source);
1806
1807                if tracked_names.iter().any(|t| right_text == *t) {
1808                    return Some((left_text, right_text, line, false));
1809                }
1810                None
1811            }
1812            "assignment" => {
1813                // Python: x = <expr>
1814                let left = node.child_by_field_name("left")?;
1815                let right = node.child_by_field_name("right")?;
1816                let left_text = node_text(left, source);
1817                let right_text = node_text(right, source);
1818
1819                if tracked_names.iter().any(|t| right_text == *t) {
1820                    return Some((left_text, right_text, line, false));
1821                }
1822                None
1823            }
1824            "let_declaration" | "short_var_declaration" => {
1825                // Rust / Go
1826                let left = node
1827                    .child_by_field_name("pattern")
1828                    .or_else(|| node.child_by_field_name("left"))?;
1829                let right = node
1830                    .child_by_field_name("value")
1831                    .or_else(|| node.child_by_field_name("right"))?;
1832                let left_text = node_text(left, source);
1833                let right_text = node_text(right, source);
1834
1835                if tracked_names.iter().any(|t| right_text == *t) {
1836                    return Some((left_text, right_text, line, false));
1837                }
1838                None
1839            }
1840            _ => None,
1841        }
1842    }
1843
1844    /// Check if a call expression uses a tracked name as an argument, and if so,
1845    /// resolve the callee and recurse into its body tracking the parameter name.
1846    #[allow(clippy::too_many_arguments)]
1847    fn check_call_for_data_flow(
1848        &mut self,
1849        node: tree_sitter::Node,
1850        source: &str,
1851        tracked_names: &[String],
1852        file: &Path,
1853        _symbol: &str,
1854        rel_file: &str,
1855        _lang: LangId,
1856        max_depth: usize,
1857        current_depth: usize,
1858        hops: &mut Vec<DataFlowHop>,
1859        depth_limited: &mut bool,
1860        visited: &mut HashSet<(PathBuf, String, String)>,
1861    ) {
1862        // Find the arguments node
1863        let args_node = find_child_by_kind(node, "arguments")
1864            .or_else(|| find_child_by_kind(node, "argument_list"));
1865
1866        let args_node = match args_node {
1867            Some(n) => n,
1868            None => return,
1869        };
1870
1871        // Collect argument texts and find which position a tracked name appears at
1872        let mut arg_positions: Vec<(usize, String)> = Vec::new(); // (position, tracked_name)
1873        let mut arg_idx = 0;
1874
1875        let mut cursor = args_node.walk();
1876        if cursor.goto_first_child() {
1877            loop {
1878                let child = cursor.node();
1879                let child_kind = child.kind();
1880
1881                // Skip punctuation (parentheses, commas)
1882                if child_kind == "(" || child_kind == ")" || child_kind == "," {
1883                    if !cursor.goto_next_sibling() {
1884                        break;
1885                    }
1886                    continue;
1887                }
1888
1889                let arg_text = node_text(child, source);
1890
1891                // Check for spread element — approximate
1892                if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1893                    if tracked_names.iter().any(|t| arg_text.contains(t)) {
1894                        hops.push(DataFlowHop {
1895                            file: rel_file.to_string(),
1896                            symbol: _symbol.to_string(),
1897                            variable: arg_text,
1898                            line: child.start_position().row as u32 + 1,
1899                            flow_type: "parameter".to_string(),
1900                            approximate: true,
1901                        });
1902                    }
1903                    if !cursor.goto_next_sibling() {
1904                        break;
1905                    }
1906                    arg_idx += 1;
1907                    continue;
1908                }
1909
1910                if tracked_names.iter().any(|t| arg_text == *t) {
1911                    arg_positions.push((arg_idx, arg_text));
1912                }
1913
1914                arg_idx += 1;
1915                if !cursor.goto_next_sibling() {
1916                    break;
1917                }
1918            }
1919        }
1920
1921        if arg_positions.is_empty() {
1922            return;
1923        }
1924
1925        // Resolve the callee
1926        let (full_callee, short_callee) = extract_callee_names(node, source);
1927        let full_callee = match full_callee {
1928            Some(f) => f,
1929            None => return,
1930        };
1931        let short_callee = match short_callee {
1932            Some(s) => s,
1933            None => return,
1934        };
1935
1936        // Try to resolve cross-file edge
1937        let import_block = {
1938            match self.data.get(file) {
1939                Some(fd) => fd.import_block.clone(),
1940                None => return,
1941            }
1942        };
1943
1944        let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1945
1946        match edge {
1947            EdgeResolution::Resolved {
1948                file: target_file,
1949                symbol: target_symbol,
1950            } => {
1951                if current_depth + 1 > max_depth {
1952                    *depth_limited = true;
1953                    return;
1954                }
1955
1956                // Build target file to get parameter info
1957                if let Err(e) = self.build_file(&target_file) {
1958                    log::debug!(
1959                        "callgraph: skipping target file {}: {}",
1960                        target_file.display(),
1961                        e
1962                    );
1963                }
1964                let (params, target_line) = {
1965                    match self.lookup_file_data(&target_file) {
1966                        Some(fd) => {
1967                            let meta = fd.symbol_metadata.get(&target_symbol);
1968                            let sig = meta.and_then(|m| m.signature.clone());
1969                            let params = sig
1970                                .as_deref()
1971                                .map(|s| extract_parameters(s, fd.lang))
1972                                .unwrap_or_default();
1973                            let line = meta.map(|m| m.line).unwrap_or(1);
1974                            (params, line)
1975                        }
1976                        None => return,
1977                    }
1978                };
1979
1980                let target_rel = self.relative_path(&target_file);
1981
1982                for (pos, _tracked) in &arg_positions {
1983                    if let Some(param_name) = params.get(*pos) {
1984                        // Add parameter hop
1985                        hops.push(DataFlowHop {
1986                            file: target_rel.clone(),
1987                            symbol: target_symbol.clone(),
1988                            variable: param_name.clone(),
1989                            line: target_line,
1990                            flow_type: "parameter".to_string(),
1991                            approximate: false,
1992                        });
1993
1994                        // Recurse into callee's body tracking the parameter name
1995                        self.trace_data_inner(
1996                            &target_file.clone(),
1997                            &target_symbol.clone(),
1998                            param_name,
1999                            max_depth,
2000                            current_depth + 1,
2001                            hops,
2002                            depth_limited,
2003                            visited,
2004                        );
2005                    }
2006                }
2007            }
2008            EdgeResolution::Unresolved { callee_name } => {
2009                // Check if it's a same-file call
2010                let has_local = self
2011                    .data
2012                    .get(file)
2013                    .map(|fd| {
2014                        fd.calls_by_symbol.contains_key(&callee_name)
2015                            || fd.symbol_metadata.contains_key(&callee_name)
2016                    })
2017                    .unwrap_or(false);
2018
2019                if has_local {
2020                    // Same-file call — get param info
2021                    let (params, target_line) = {
2022                        let Some(fd) = self.data.get(file) else {
2023                            return;
2024                        };
2025                        let meta = fd.symbol_metadata.get(&callee_name);
2026                        let sig = meta.and_then(|m| m.signature.clone());
2027                        let params = sig
2028                            .as_deref()
2029                            .map(|s| extract_parameters(s, fd.lang))
2030                            .unwrap_or_default();
2031                        let line = meta.map(|m| m.line).unwrap_or(1);
2032                        (params, line)
2033                    };
2034
2035                    let file_rel = self.relative_path(file);
2036
2037                    for (pos, _tracked) in &arg_positions {
2038                        if let Some(param_name) = params.get(*pos) {
2039                            hops.push(DataFlowHop {
2040                                file: file_rel.clone(),
2041                                symbol: callee_name.clone(),
2042                                variable: param_name.clone(),
2043                                line: target_line,
2044                                flow_type: "parameter".to_string(),
2045                                approximate: false,
2046                            });
2047
2048                            // Recurse into same-file function
2049                            self.trace_data_inner(
2050                                file,
2051                                &callee_name.clone(),
2052                                param_name,
2053                                max_depth,
2054                                current_depth + 1,
2055                                hops,
2056                                depth_limited,
2057                                visited,
2058                            );
2059                        }
2060                    }
2061                } else {
2062                    // Truly unresolved — approximate hop
2063                    for (_pos, tracked) in &arg_positions {
2064                        hops.push(DataFlowHop {
2065                            file: self.relative_path(file),
2066                            symbol: callee_name.clone(),
2067                            variable: tracked.clone(),
2068                            line: node.start_position().row as u32 + 1,
2069                            flow_type: "parameter".to_string(),
2070                            approximate: true,
2071                        });
2072                    }
2073                }
2074            }
2075        }
2076    }
2077
2078    /// Read a single source line (1-based) from a file, trimmed.
2079    fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2080        let content = std::fs::read_to_string(path).ok()?;
2081        content
2082            .lines()
2083            .nth(line.saturating_sub(1) as usize)
2084            .map(|l| l.trim().to_string())
2085    }
2086
2087    /// Recursively collect callers up to the given depth.
2088    fn collect_callers_recursive(
2089        &self,
2090        file: &Path,
2091        symbol: &str,
2092        max_depth: usize,
2093        current_depth: usize,
2094        visited: &mut HashSet<(PathBuf, SharedStr)>,
2095        result: &mut Vec<CallerSite>,
2096    ) {
2097        if current_depth >= max_depth {
2098            return;
2099        }
2100
2101        // Canonicalize for consistent reverse index lookup
2102        let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2103        let key_symbol: SharedStr = Arc::from(symbol);
2104        if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2105            return; // cycle detection
2106        }
2107
2108        if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2109            for site in sites {
2110                result.push(CallerSite {
2111                    caller_file: site.caller_file.as_ref().clone(),
2112                    caller_symbol: site.caller_symbol.to_string(),
2113                    line: site.line,
2114                    col: site.col,
2115                    resolved: site.resolved,
2116                });
2117                // Recurse: find callers of the caller
2118                if current_depth + 1 < max_depth {
2119                    self.collect_callers_recursive(
2120                        site.caller_file.as_ref(),
2121                        site.caller_symbol.as_ref(),
2122                        max_depth,
2123                        current_depth + 1,
2124                        visited,
2125                        result,
2126                    );
2127                }
2128            }
2129        }
2130    }
2131
2132    /// Invalidate a file: remove its cached data and clear the reverse index.
2133    ///
2134    /// Called by the file watcher when a file changes on disk. The reverse
2135    /// index is rebuilt lazily on the next `callers_of` call.
2136    pub fn invalidate_file(&mut self, path: &Path) {
2137        // Remove from data cache (try both as-is and canonicalized)
2138        self.data.remove(path);
2139        if let Ok(canon) = self.canonicalize(path) {
2140            self.data.remove(&canon);
2141        }
2142        // Clear the reverse index — it's stale
2143        self.reverse_index = None;
2144        // Clear project_files cache for create/remove events
2145        self.project_files = None;
2146        clear_workspace_package_cache();
2147    }
2148
2149    /// Return a path relative to the project root, or the absolute path if
2150    /// it's outside the project.
2151    fn relative_path(&self, path: &Path) -> String {
2152        path.strip_prefix(&self.project_root)
2153            .unwrap_or(path)
2154            .display()
2155            .to_string()
2156    }
2157
2158    /// Canonicalize a path, falling back to the original if canonicalization fails.
2159    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2160        // If the path is relative, resolve it against project_root
2161        let full_path = if path.is_relative() {
2162            self.project_root.join(path)
2163        } else {
2164            path.to_path_buf()
2165        };
2166
2167        // Try canonicalize, fall back to the full path
2168        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2169    }
2170
2171    /// Look up cached file data, trying both the given path and its
2172    /// canonicalized form. Needed because `build_reverse_index` may store
2173    /// data under raw walker paths while CallerSite uses canonical paths.
2174    fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2175        if let Some(fd) = self.data.get(path) {
2176            return Some(fd);
2177        }
2178        // Try canonical
2179        let canon = std::fs::canonicalize(path).ok()?;
2180        self.data.get(&canon).or_else(|| {
2181            // Try non-canonical forms stored by the walker
2182            self.data.iter().find_map(|(k, v)| {
2183                if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2184                    Some(v)
2185                } else {
2186                    None
2187                }
2188            })
2189        })
2190    }
2191}
2192
2193// ---------------------------------------------------------------------------
2194// File-level building
2195// ---------------------------------------------------------------------------
2196
2197/// Build call data for a single file.
2198fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2199    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2200        message: format!("unsupported file for call graph: {}", path.display()),
2201    })?;
2202
2203    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2204        path: format!("{}: {}", path.display(), e),
2205    })?;
2206
2207    let grammar = grammar_for(lang);
2208    let mut parser = Parser::new();
2209    parser
2210        .set_language(&grammar)
2211        .map_err(|e| AftError::ParseError {
2212            message: format!("grammar init failed for {:?}: {}", lang, e),
2213        })?;
2214
2215    let tree = parser
2216        .parse(&source, None)
2217        .ok_or_else(|| AftError::ParseError {
2218            message: format!("parse failed for {}", path.display()),
2219        })?;
2220
2221    // Parse imports
2222    let import_block = imports::parse_imports(&source, &tree, lang);
2223
2224    // Get symbols (for call site extraction and export detection)
2225    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2226
2227    // Build calls_by_symbol
2228    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2229    let root = tree.root_node();
2230
2231    for sym in &symbols {
2232        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2233        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2234
2235        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2236
2237        let sites: Vec<CallSite> = raw_calls
2238            .into_iter()
2239            .map(|(full, short, line)| CallSite {
2240                callee_name: short,
2241                full_callee: full,
2242                line,
2243                byte_start,
2244                byte_end,
2245            })
2246            .collect();
2247
2248        if !sites.is_empty() {
2249            calls_by_symbol.insert(sym.name.clone(), sites);
2250        }
2251    }
2252
2253    let symbol_ranges: Vec<(usize, usize)> = symbols
2254        .iter()
2255        .map(|sym| {
2256            (
2257                line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2258                line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2259            )
2260        })
2261        .collect();
2262
2263    let top_level_sites: Vec<CallSite> =
2264        collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2265            .into_iter()
2266            .filter(|site| {
2267                !symbol_ranges
2268                    .iter()
2269                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2270            })
2271            .map(|site| CallSite {
2272                callee_name: site.short,
2273                full_callee: site.full,
2274                line: site.line,
2275                byte_start: site.byte_start,
2276                byte_end: site.byte_end,
2277            })
2278            .collect();
2279
2280    if !top_level_sites.is_empty() {
2281        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2282    }
2283
2284    let default_export = find_default_export(&source, root, path, lang);
2285
2286    if let Some(default_export) = &default_export {
2287        if default_export.synthetic {
2288            let byte_start = default_export.node.byte_range().start;
2289            let byte_end = default_export.node.byte_range().end;
2290            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2291            let sites: Vec<CallSite> = raw_calls
2292                .into_iter()
2293                .filter(|(_, short, _)| *short != default_export.symbol)
2294                .map(|(full, short, line)| CallSite {
2295                    callee_name: short,
2296                    full_callee: full,
2297                    line,
2298                    byte_start,
2299                    byte_end,
2300                })
2301                .collect();
2302            if !sites.is_empty() {
2303                calls_by_symbol.insert(default_export.symbol.clone(), sites);
2304            }
2305        }
2306    }
2307
2308    // Collect exported symbol names
2309    let mut exported_symbols: Vec<String> = symbols
2310        .iter()
2311        .filter(|s| s.exported)
2312        .map(|s| s.name.clone())
2313        .collect();
2314    if let Some(default_export) = &default_export {
2315        if !exported_symbols
2316            .iter()
2317            .any(|name| name == &default_export.symbol)
2318        {
2319            exported_symbols.push(default_export.symbol.clone());
2320        }
2321    }
2322
2323    // Build per-symbol metadata for entry point detection
2324    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2325        .iter()
2326        .map(|s| {
2327            (
2328                s.name.clone(),
2329                SymbolMeta {
2330                    kind: s.kind.clone(),
2331                    exported: s.exported,
2332                    signature: s.signature.clone(),
2333                    line: s.range.start_line + 1,
2334                    range: s.range.clone(),
2335                },
2336            )
2337        })
2338        .collect();
2339    if let Some(default_export) = &default_export {
2340        symbol_metadata
2341            .entry(default_export.symbol.clone())
2342            .or_insert_with(|| SymbolMeta {
2343                kind: default_export.kind.clone(),
2344                exported: true,
2345                signature: Some(first_line_signature(&source, &default_export.node)),
2346                line: default_export.node.start_position().row as u32 + 1,
2347                range: crate::parser::node_range(&default_export.node),
2348            });
2349    }
2350    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2351        symbol_metadata
2352            .entry(TOP_LEVEL_SYMBOL.to_string())
2353            .or_insert(SymbolMeta {
2354                kind: SymbolKind::Function,
2355                exported: false,
2356                signature: None,
2357                line: 1,
2358                range: Range {
2359                    start_line: 0,
2360                    start_col: 0,
2361                    end_line: 0,
2362                    end_col: 0,
2363                },
2364            });
2365    }
2366
2367    Ok(FileCallData {
2368        calls_by_symbol,
2369        exported_symbols,
2370        symbol_metadata,
2371        default_export_symbol: default_export.map(|export| export.symbol),
2372        import_block,
2373        lang,
2374    })
2375}
2376
2377#[derive(Debug, Clone)]
2378struct DefaultExport<'tree> {
2379    symbol: String,
2380    synthetic: bool,
2381    kind: SymbolKind,
2382    node: Node<'tree>,
2383}
2384
2385fn find_default_export<'tree>(
2386    source: &str,
2387    root: Node<'tree>,
2388    path: &Path,
2389    lang: LangId,
2390) -> Option<DefaultExport<'tree>> {
2391    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2392        return None;
2393    }
2394    find_default_export_inner(source, root, path)
2395}
2396
2397fn find_default_export_inner<'tree>(
2398    source: &str,
2399    node: Node<'tree>,
2400    path: &Path,
2401) -> Option<DefaultExport<'tree>> {
2402    if node.kind() == "export_statement" {
2403        if let Some(default_export) = default_export_from_statement(source, node, path) {
2404            return Some(default_export);
2405        }
2406    }
2407
2408    let mut cursor = node.walk();
2409    if !cursor.goto_first_child() {
2410        return None;
2411    }
2412
2413    loop {
2414        let child = cursor.node();
2415        if let Some(default_export) = find_default_export_inner(source, child, path) {
2416            return Some(default_export);
2417        }
2418        if !cursor.goto_next_sibling() {
2419            break;
2420        }
2421    }
2422
2423    None
2424}
2425
2426fn default_export_from_statement<'tree>(
2427    source: &str,
2428    node: Node<'tree>,
2429    path: &Path,
2430) -> Option<DefaultExport<'tree>> {
2431    let mut cursor = node.walk();
2432    if !cursor.goto_first_child() {
2433        return None;
2434    }
2435
2436    let mut saw_default = false;
2437    loop {
2438        let child = cursor.node();
2439        match child.kind() {
2440            "default" => saw_default = true,
2441            "function_declaration" | "generator_function_declaration" | "class_declaration"
2442                if saw_default =>
2443            {
2444                if let Some(name_node) = child.child_by_field_name("name") {
2445                    return Some(DefaultExport {
2446                        symbol: source[name_node.byte_range()].to_string(),
2447                        synthetic: false,
2448                        kind: default_export_kind(&child),
2449                        node: child,
2450                    });
2451                }
2452                return Some(DefaultExport {
2453                    symbol: synthetic_default_symbol(path),
2454                    synthetic: true,
2455                    kind: default_export_kind(&child),
2456                    node: child,
2457                });
2458            }
2459            "arrow_function"
2460            | "function"
2461            | "function_expression"
2462            | "class"
2463            | "class_expression"
2464                if saw_default =>
2465            {
2466                return Some(DefaultExport {
2467                    symbol: synthetic_default_symbol(path),
2468                    synthetic: true,
2469                    kind: default_export_kind(&child),
2470                    node: child,
2471                });
2472            }
2473            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
2474                return Some(DefaultExport {
2475                    symbol: source[child.byte_range()].to_string(),
2476                    synthetic: false,
2477                    kind: SymbolKind::Function,
2478                    node: child,
2479                });
2480            }
2481            _ => {}
2482        }
2483        if !cursor.goto_next_sibling() {
2484            break;
2485        }
2486    }
2487
2488    None
2489}
2490
2491fn default_export_kind(node: &Node) -> SymbolKind {
2492    if node.kind().contains("class") {
2493        SymbolKind::Class
2494    } else {
2495        SymbolKind::Function
2496    }
2497}
2498
2499fn synthetic_default_symbol(path: &Path) -> String {
2500    let file_name = path
2501        .file_name()
2502        .and_then(|name| name.to_str())
2503        .unwrap_or("unknown");
2504    format!("<default:{file_name}>")
2505}
2506
2507fn first_line_signature(source: &str, node: &Node) -> String {
2508    let text = &source[node.byte_range()];
2509    let first_line = text.lines().next().unwrap_or(text);
2510    first_line
2511        .trim_end()
2512        .trim_end_matches('{')
2513        .trim_end()
2514        .to_string()
2515}
2516
2517fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2518    file_data
2519        .symbol_metadata
2520        .get(symbol_name)
2521        .map(|meta| (meta.line, meta.signature.clone()))
2522        .unwrap_or((1, None))
2523}
2524
2525/// Get symbol metadata (line, signature) from a file.
2526fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2527    let provider = crate::parser::TreeSitterProvider::new();
2528    match provider.list_symbols(path) {
2529        Ok(symbols) => {
2530            for s in &symbols {
2531                if s.name == symbol_name {
2532                    return (s.range.start_line + 1, s.signature.clone());
2533                }
2534            }
2535            (1, None)
2536        }
2537        Err(_) => (1, None),
2538    }
2539}
2540
2541// ---------------------------------------------------------------------------
2542// Data flow tracking helpers
2543// ---------------------------------------------------------------------------
2544
2545/// Get the text of a tree-sitter node from the source.
2546fn node_text(node: tree_sitter::Node, source: &str) -> String {
2547    source[node.start_byte()..node.end_byte()].to_string()
2548}
2549
2550/// Find the smallest node that fully covers a byte range.
2551fn find_node_covering_range(
2552    root: tree_sitter::Node,
2553    start: usize,
2554    end: usize,
2555) -> Option<tree_sitter::Node> {
2556    let mut best = None;
2557    let mut cursor = root.walk();
2558
2559    fn walk_covering<'a>(
2560        cursor: &mut tree_sitter::TreeCursor<'a>,
2561        start: usize,
2562        end: usize,
2563        best: &mut Option<tree_sitter::Node<'a>>,
2564    ) {
2565        let node = cursor.node();
2566        if node.start_byte() <= start && node.end_byte() >= end {
2567            *best = Some(node);
2568            if cursor.goto_first_child() {
2569                loop {
2570                    walk_covering(cursor, start, end, best);
2571                    if !cursor.goto_next_sibling() {
2572                        break;
2573                    }
2574                }
2575                cursor.goto_parent();
2576            }
2577        }
2578    }
2579
2580    walk_covering(&mut cursor, start, end, &mut best);
2581    best
2582}
2583
2584/// Find a direct child node by kind name.
2585fn find_child_by_kind<'a>(
2586    node: tree_sitter::Node<'a>,
2587    kind: &str,
2588) -> Option<tree_sitter::Node<'a>> {
2589    let mut cursor = node.walk();
2590    if cursor.goto_first_child() {
2591        loop {
2592            if cursor.node().kind() == kind {
2593                return Some(cursor.node());
2594            }
2595            if !cursor.goto_next_sibling() {
2596                break;
2597            }
2598        }
2599    }
2600    None
2601}
2602
2603#[derive(Debug, Clone)]
2604struct CallSiteWithRange {
2605    full: String,
2606    short: String,
2607    line: u32,
2608    byte_start: usize,
2609    byte_end: usize,
2610}
2611
2612fn collect_calls_full_with_ranges(
2613    root: tree_sitter::Node,
2614    source: &str,
2615    byte_start: usize,
2616    byte_end: usize,
2617    lang: LangId,
2618) -> Vec<CallSiteWithRange> {
2619    let mut results = Vec::new();
2620    let call_kinds = call_node_kinds(lang);
2621    collect_calls_full_with_ranges_inner(
2622        root,
2623        source,
2624        byte_start,
2625        byte_end,
2626        &call_kinds,
2627        &mut results,
2628    );
2629    results
2630}
2631
2632fn collect_calls_full_with_ranges_inner(
2633    node: tree_sitter::Node,
2634    source: &str,
2635    byte_start: usize,
2636    byte_end: usize,
2637    call_kinds: &[&str],
2638    results: &mut Vec<CallSiteWithRange>,
2639) {
2640    let node_start = node.start_byte();
2641    let node_end = node.end_byte();
2642
2643    if node_end <= byte_start || node_start >= byte_end {
2644        return;
2645    }
2646
2647    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
2648        if let (Some(full), Some(short)) = (
2649            extract_full_callee(&node, source),
2650            extract_callee_name(&node, source),
2651        ) {
2652            results.push(CallSiteWithRange {
2653                full,
2654                short,
2655                line: node.start_position().row as u32 + 1,
2656                byte_start: node_start,
2657                byte_end: node_end,
2658            });
2659        }
2660    }
2661
2662    let mut cursor = node.walk();
2663    if cursor.goto_first_child() {
2664        loop {
2665            collect_calls_full_with_ranges_inner(
2666                cursor.node(),
2667                source,
2668                byte_start,
2669                byte_end,
2670                call_kinds,
2671                results,
2672            );
2673            if !cursor.goto_next_sibling() {
2674                break;
2675            }
2676        }
2677    }
2678}
2679
2680/// Extract full and short callee names from a call_expression node.
2681fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2682    // The "function" field holds the callee
2683    let callee = match node.child_by_field_name("function") {
2684        Some(c) => c,
2685        None => return (None, None),
2686    };
2687
2688    let full = node_text(callee, source);
2689    let short = if full.contains('.') {
2690        full.rsplit('.').next().unwrap_or(&full).to_string()
2691    } else {
2692        full.clone()
2693    };
2694
2695    (Some(full), Some(short))
2696}
2697
2698// ---------------------------------------------------------------------------
2699// Module path resolution
2700// ---------------------------------------------------------------------------
2701
2702/// Resolve a module path (e.g. './utils') relative to a directory.
2703///
2704/// Tries common file extensions for TypeScript/JavaScript projects.
2705pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2706    if module_path.starts_with('.') {
2707        return resolve_relative_module_path(from_dir, module_path);
2708    }
2709
2710    if module_path.starts_with('/') {
2711        return None;
2712    }
2713
2714    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
2715        return Some(path);
2716    }
2717
2718    resolve_workspace_module_path(from_dir, module_path)
2719}
2720
2721fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2722    let base = from_dir.join(module_path);
2723    resolve_file_like_path(&base)
2724}
2725
2726fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
2727    let base = base.to_path_buf();
2728
2729    // Try exact path first
2730    if base.is_file() {
2731        return Some(std::fs::canonicalize(&base).unwrap_or(base));
2732    }
2733
2734    // Try common extensions
2735    let extensions = [".ts", ".tsx", ".js", ".jsx"];
2736    for ext in &extensions {
2737        let with_ext = base.with_extension(ext.trim_start_matches('.'));
2738        if with_ext.is_file() {
2739            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2740        }
2741    }
2742
2743    // Try as directory with index file
2744    if base.is_dir() {
2745        if let Some(index) = find_index_file(&base) {
2746            return Some(index);
2747        }
2748    }
2749
2750    None
2751}
2752
2753fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2754    let (package_name, subpath) = split_package_import(module_path)?;
2755    let package_root = find_package_root_for_import(from_dir, &package_name)?;
2756    resolve_package_entry(&package_root, &subpath)
2757}
2758
2759fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2760    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2761    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2762    let compiler_options = tsconfig.get("compilerOptions")?;
2763    let paths = compiler_options.get("paths")?.as_object()?;
2764    let base_url = compiler_options
2765        .get("baseUrl")
2766        .and_then(Value::as_str)
2767        .unwrap_or(".");
2768    let base_dir = tsconfig_dir.join(base_url);
2769
2770    for (alias, targets) in paths {
2771        let Some(capture) = ts_path_capture(alias, module_path) else {
2772            continue;
2773        };
2774        let Some(targets) = targets.as_array() else {
2775            continue;
2776        };
2777        for target in targets.iter().filter_map(Value::as_str) {
2778            let target = if target.contains('*') {
2779                target.replace('*', capture)
2780            } else {
2781                target.to_string()
2782            };
2783            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2784                return Some(path);
2785            }
2786        }
2787    }
2788
2789    None
2790}
2791
2792fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2793    let mut current = Some(from_dir);
2794    while let Some(dir) = current {
2795        if dir.join("tsconfig.json").is_file() {
2796            return Some(dir.to_path_buf());
2797        }
2798        current = dir.parent();
2799    }
2800    None
2801}
2802
2803fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2804    if let Some(star_index) = alias.find('*') {
2805        let (prefix, suffix_with_star) = alias.split_at(star_index);
2806        let suffix = &suffix_with_star[1..];
2807        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2808            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2809        }
2810        return None;
2811    }
2812
2813    (alias == module_path).then_some("")
2814}
2815
2816fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2817    let mut parts = module_path.split('/');
2818    let first = parts.next()?;
2819    if first.is_empty() {
2820        return None;
2821    }
2822
2823    if first.starts_with('@') {
2824        let second = parts.next()?;
2825        if second.is_empty() {
2826            return None;
2827        }
2828        let package_name = format!("{first}/{second}");
2829        let subpath = parts.collect::<Vec<_>>().join("/");
2830        let subpath = (!subpath.is_empty()).then_some(subpath);
2831        Some((package_name, subpath))
2832    } else {
2833        let package_name = first.to_string();
2834        let subpath = parts.collect::<Vec<_>>().join("/");
2835        let subpath = (!subpath.is_empty()).then_some(subpath);
2836        Some((package_name, subpath))
2837    }
2838}
2839
2840fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2841    let mut current = Some(from_dir);
2842    while let Some(dir) = current {
2843        if package_json_name(dir).as_deref() == Some(package_name) {
2844            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2845        }
2846        current = dir.parent();
2847    }
2848
2849    find_workspace_root(from_dir)
2850        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2851}
2852
2853fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2854    let mut current = Some(from_dir);
2855    while let Some(dir) = current {
2856        if is_workspace_root(dir) {
2857            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2858        }
2859        current = dir.parent();
2860    }
2861    None
2862}
2863
2864fn is_workspace_root(dir: &Path) -> bool {
2865    package_json_value(dir)
2866        .map(|value| !workspace_patterns(&value).is_empty())
2867        .unwrap_or(false)
2868        || !pnpm_workspace_patterns(dir).is_empty()
2869}
2870
2871fn clear_workspace_package_cache() {
2872    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2873        cache.clear();
2874    }
2875}
2876
2877fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2878    let workspace_root =
2879        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2880    let cache_key = (workspace_root.clone(), package_name.to_string());
2881
2882    if let Some(cached) = WORKSPACE_PACKAGE_CACHE
2883        .read()
2884        .ok()
2885        .and_then(|cache| cache.get(&cache_key).cloned())
2886    {
2887        return cached;
2888    }
2889
2890    let resolved = workspace_member_dirs(&workspace_root)
2891        .into_iter()
2892        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2893        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2894
2895    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2896        cache.insert(cache_key, resolved.clone());
2897    }
2898
2899    resolved
2900}
2901
2902fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2903    let mut patterns = package_json_value(workspace_root)
2904        .map(|package_json| workspace_patterns(&package_json))
2905        .unwrap_or_default();
2906    patterns.extend(pnpm_workspace_patterns(workspace_root));
2907
2908    expand_workspace_patterns(workspace_root, &patterns)
2909}
2910
2911fn workspace_patterns(package_json: &Value) -> Vec<String> {
2912    match package_json.get("workspaces") {
2913        Some(Value::Array(items)) => items
2914            .iter()
2915            .filter_map(non_empty_workspace_pattern)
2916            .collect(),
2917        Some(Value::Object(map)) => map
2918            .get("packages")
2919            .and_then(Value::as_array)
2920            .map(|items| {
2921                items
2922                    .iter()
2923                    .filter_map(non_empty_workspace_pattern)
2924                    .collect()
2925            })
2926            .unwrap_or_default(),
2927        _ => Vec::new(),
2928    }
2929}
2930
2931fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2932    let pattern = value.as_str()?.trim();
2933    (!pattern.is_empty()).then(|| pattern.to_string())
2934}
2935
2936fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2937    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2938        return Vec::new();
2939    };
2940
2941    let mut patterns = Vec::new();
2942    let mut in_packages = false;
2943    for line in source.lines() {
2944        let without_comment = line.split('#').next().unwrap_or("").trim_end();
2945        let trimmed = without_comment.trim();
2946        if trimmed.is_empty() {
2947            continue;
2948        }
2949        if trimmed == "packages:" {
2950            in_packages = true;
2951            continue;
2952        }
2953        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2954            in_packages = false;
2955        }
2956        if in_packages {
2957            if let Some(pattern) = trimmed.strip_prefix('-') {
2958                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2959                if !pattern.is_empty() {
2960                    patterns.push(pattern.to_string());
2961                }
2962            }
2963        }
2964    }
2965    patterns
2966}
2967
2968fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2969    let positive_patterns: Vec<&str> = patterns
2970        .iter()
2971        .map(|pattern| pattern.trim())
2972        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2973        .collect();
2974    if positive_patterns.is_empty() {
2975        return Vec::new();
2976    }
2977
2978    let positives = build_glob_set(&positive_patterns);
2979    let negative_patterns: Vec<&str> = patterns
2980        .iter()
2981        .map(|pattern| pattern.trim())
2982        .filter_map(|pattern| pattern.strip_prefix('!'))
2983        .map(str::trim)
2984        .filter(|pattern| !pattern.is_empty())
2985        .collect();
2986    let negatives = build_glob_set(&negative_patterns);
2987
2988    let mut members = Vec::new();
2989    collect_workspace_member_dirs(
2990        workspace_root,
2991        workspace_root,
2992        &positives,
2993        &negatives,
2994        &mut members,
2995    );
2996    members
2997}
2998
2999fn build_glob_set(patterns: &[&str]) -> GlobSet {
3000    let mut builder = GlobSetBuilder::new();
3001    for pattern in patterns {
3002        if let Ok(glob) = Glob::new(pattern) {
3003            builder.add(glob);
3004        }
3005    }
3006    builder
3007        .build()
3008        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
3009}
3010
3011fn collect_workspace_member_dirs(
3012    workspace_root: &Path,
3013    dir: &Path,
3014    positives: &GlobSet,
3015    negatives: &GlobSet,
3016    members: &mut Vec<PathBuf>,
3017) {
3018    let Ok(entries) = std::fs::read_dir(dir) else {
3019        return;
3020    };
3021
3022    for entry in entries.filter_map(Result::ok) {
3023        let path = entry.path();
3024        let Ok(file_type) = entry.file_type() else {
3025            continue;
3026        };
3027        if !file_type.is_dir() {
3028            continue;
3029        }
3030        let name = entry.file_name();
3031        let name = name.to_string_lossy();
3032        if matches!(
3033            name.as_ref(),
3034            "node_modules" | ".git" | "target" | "dist" | "build"
3035        ) {
3036            continue;
3037        }
3038
3039        if path.join("package.json").is_file() {
3040            if let Ok(rel) = path.strip_prefix(workspace_root) {
3041                let rel = rel.to_string_lossy().replace('\\', "/");
3042                if positives.is_match(&rel) && !negatives.is_match(&rel) {
3043                    members.push(path.clone());
3044                }
3045            }
3046        }
3047
3048        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
3049    }
3050}
3051
3052fn package_json_value(dir: &Path) -> Option<Value> {
3053    package_json_like_value(&dir.join("package.json"))
3054}
3055
3056fn package_json_like_value(path: &Path) -> Option<Value> {
3057    let json = std::fs::read_to_string(path).ok()?;
3058    serde_json::from_str(&json).ok()
3059}
3060
3061fn package_json_name(dir: &Path) -> Option<String> {
3062    package_json_value(dir)?
3063        .get("name")?
3064        .as_str()
3065        .map(ToOwned::to_owned)
3066}
3067
3068fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
3069    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
3070
3071    if let Some(exports) = package_json.get("exports") {
3072        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
3073            if let Some(path) = resolve_package_target(package_root, &target) {
3074                return Some(path);
3075            }
3076        }
3077    }
3078
3079    if subpath.is_none() {
3080        for field in ["module", "main"] {
3081            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
3082                if let Some(path) = resolve_package_target(package_root, target) {
3083                    return Some(path);
3084                }
3085            }
3086        }
3087    }
3088
3089    resolve_package_fallback(package_root, subpath.as_deref())
3090}
3091
3092fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
3093    let key = subpath
3094        .map(|value| format!("./{value}"))
3095        .unwrap_or_else(|| ".".to_string());
3096
3097    match exports {
3098        Value::String(target) if key == "." => Some(target.clone()),
3099        Value::Object(map) => {
3100            if let Some(target) = map.get(&key).and_then(export_condition_target) {
3101                return Some(target);
3102            }
3103
3104            if let Some(target) = wildcard_export_target(map, &key) {
3105                return Some(target);
3106            }
3107
3108            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
3109                return export_condition_target(exports);
3110            }
3111
3112            None
3113        }
3114        _ => None,
3115    }
3116}
3117
3118fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
3119    for (pattern, target) in map {
3120        let Some(star_index) = pattern.find('*') else {
3121            continue;
3122        };
3123        let (prefix, suffix_with_star) = pattern.split_at(star_index);
3124        let suffix = &suffix_with_star[1..];
3125        if !key.starts_with(prefix) || !key.ends_with(suffix) {
3126            continue;
3127        }
3128        let matched = &key[prefix.len()..key.len() - suffix.len()];
3129        if let Some(target_pattern) = export_condition_target(target) {
3130            return Some(target_pattern.replace('*', matched));
3131        }
3132    }
3133    None
3134}
3135
3136fn export_condition_target(value: &Value) -> Option<String> {
3137    match value {
3138        Value::String(target) => Some(target.clone()),
3139        Value::Object(map) => ["source", "import", "module", "default", "types"]
3140            .into_iter()
3141            .find_map(|field| map.get(field).and_then(export_condition_target)),
3142        _ => None,
3143    }
3144}
3145
3146fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
3147    let target = target.strip_prefix("./").unwrap_or(target);
3148    // Prefer source over compiled bundle when both exist: the callgraph
3149    // walks source files and cannot extract symbols from a built JS bundle.
3150    if let Some(src_relative) = target.strip_prefix("dist/") {
3151        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
3152            return Some(path);
3153        }
3154    }
3155
3156    resolve_file_like_path(&package_root.join(target))
3157}
3158
3159fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
3160    match subpath {
3161        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
3162            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
3163        None => resolve_file_like_path(&package_root.join("src").join("index"))
3164            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
3165    }
3166}
3167
3168fn resolve_reexported_symbol<F, D>(
3169    file: &Path,
3170    symbol_name: &str,
3171    file_exports_symbol: &mut F,
3172    file_default_export_symbol: &mut D,
3173) -> Option<ResolvedSymbol>
3174where
3175    F: FnMut(&Path, &str) -> bool,
3176    D: FnMut(&Path) -> Option<String>,
3177{
3178    let mut visited = HashSet::new();
3179    resolve_reexported_symbol_inner(
3180        file,
3181        symbol_name,
3182        file_exports_symbol,
3183        file_default_export_symbol,
3184        &mut visited,
3185    )
3186}
3187
3188fn resolve_reexported_symbol_inner<F, D>(
3189    file: &Path,
3190    symbol_name: &str,
3191    file_exports_symbol: &mut F,
3192    file_default_export_symbol: &mut D,
3193    visited: &mut HashSet<(PathBuf, String)>,
3194) -> Option<ResolvedSymbol>
3195where
3196    F: FnMut(&Path, &str) -> bool,
3197    D: FnMut(&Path) -> Option<String>,
3198{
3199    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
3200    if !visited.insert((canon.clone(), symbol_name.to_string())) {
3201        return None;
3202    }
3203
3204    let source = std::fs::read_to_string(&canon).ok()?;
3205    let lang = detect_language(&canon)?;
3206    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3207        if symbol_name == "default" {
3208            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
3209                file: canon,
3210                symbol,
3211            });
3212        }
3213        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
3214            file: canon,
3215            symbol: symbol_name.to_string(),
3216        });
3217    }
3218
3219    let grammar = grammar_for(lang);
3220    let mut parser = Parser::new();
3221    parser.set_language(&grammar).ok()?;
3222    let tree = parser.parse(&source, None)?;
3223    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
3224
3225    let mut cursor = tree.root_node().walk();
3226    if !cursor.goto_first_child() {
3227        return None;
3228    }
3229
3230    loop {
3231        let node = cursor.node();
3232        if node.kind() == "export_statement" {
3233            if let Some(target) = resolve_reexport_statement(
3234                &source,
3235                node,
3236                from_dir,
3237                symbol_name,
3238                file_exports_symbol,
3239                file_default_export_symbol,
3240                visited,
3241            ) {
3242                return Some(target);
3243            }
3244        }
3245
3246        if !cursor.goto_next_sibling() {
3247            break;
3248        }
3249    }
3250
3251    if symbol_name == "default" {
3252        if let Some(symbol) = file_default_export_symbol(&canon) {
3253            return Some(ResolvedSymbol {
3254                file: canon,
3255                symbol,
3256            });
3257        }
3258    }
3259
3260    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
3261        return Some(ResolvedSymbol {
3262            file: canon,
3263            symbol,
3264        });
3265    }
3266
3267    if file_exports_symbol(&canon, symbol_name) {
3268        let symbol = symbol_name.to_string();
3269        return Some(ResolvedSymbol {
3270            file: canon,
3271            symbol,
3272        });
3273    }
3274
3275    None
3276}
3277
3278fn resolve_reexport_statement<F, D>(
3279    source: &str,
3280    node: tree_sitter::Node,
3281    from_dir: &Path,
3282    symbol_name: &str,
3283    file_exports_symbol: &mut F,
3284    file_default_export_symbol: &mut D,
3285    visited: &mut HashSet<(PathBuf, String)>,
3286) -> Option<ResolvedSymbol>
3287where
3288    F: FnMut(&Path, &str) -> bool,
3289    D: FnMut(&Path) -> Option<String>,
3290{
3291    let source_node = node.child_by_field_name("source")?;
3292    let module_path = string_literal_content(source, source_node)?;
3293    let target_file = resolve_module_path(from_dir, &module_path)?;
3294    let raw_export = node_text(node, source);
3295
3296    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
3297        return resolve_reexported_symbol_inner(
3298            &target_file,
3299            &source_symbol,
3300            file_exports_symbol,
3301            file_default_export_symbol,
3302            visited,
3303        )
3304        .or(Some(ResolvedSymbol {
3305            file: target_file,
3306            symbol: source_symbol,
3307        }));
3308    }
3309
3310    if raw_export.contains('*') {
3311        return resolve_reexported_symbol_inner(
3312            &target_file,
3313            symbol_name,
3314            file_exports_symbol,
3315            file_default_export_symbol,
3316            visited,
3317        );
3318    }
3319
3320    None
3321}
3322
3323fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
3324    let lang = detect_language(file)?;
3325    let grammar = grammar_for(lang);
3326    let mut parser = Parser::new();
3327    parser.set_language(&grammar).ok()?;
3328    let tree = parser.parse(source, None)?;
3329
3330    let mut cursor = tree.root_node().walk();
3331    if !cursor.goto_first_child() {
3332        return None;
3333    }
3334
3335    loop {
3336        let node = cursor.node();
3337        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
3338            let raw_export = node_text(node, source);
3339            if let Some(source_symbol) =
3340                reexport_clause_source_symbol(&raw_export, requested_export)
3341            {
3342                return Some(source_symbol);
3343            }
3344        }
3345
3346        if !cursor.goto_next_sibling() {
3347            break;
3348        }
3349    }
3350
3351    None
3352}
3353
3354fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3355    let start = raw_export.find('{')? + 1;
3356    let end = raw_export[start..].find('}')? + start;
3357    for specifier in raw_export[start..end].split(',') {
3358        let specifier = specifier.trim();
3359        if specifier.is_empty() {
3360            continue;
3361        }
3362        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3363        if let Some((imported, exported)) = specifier.split_once(" as ") {
3364            if exported.trim() == requested_export {
3365                return Some(imported.trim().to_string());
3366            }
3367        } else if specifier == requested_export {
3368            return Some(requested_export.to_string());
3369        }
3370    }
3371    None
3372}
3373
3374fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3375    let raw = source[node.byte_range()].trim();
3376    let quote = raw.chars().next()?;
3377    if quote != '\'' && quote != '"' {
3378        return None;
3379    }
3380    raw.strip_prefix(quote)
3381        .and_then(|value| value.strip_suffix(quote))
3382        .map(ToOwned::to_owned)
3383}
3384
3385/// Find an index file in a directory.
3386fn find_index_file(dir: &Path) -> Option<PathBuf> {
3387    let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
3388    for name in &candidates {
3389        let p = dir.join(name);
3390        if p.is_file() {
3391            return Some(std::fs::canonicalize(&p).unwrap_or(p));
3392        }
3393    }
3394    None
3395}
3396
3397/// Resolve an aliased import: `import { foo as bar } from './utils'`
3398/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
3399fn resolve_aliased_import(
3400    local_name: &str,
3401    import_block: &ImportBlock,
3402    caller_dir: &Path,
3403) -> Option<(String, PathBuf)> {
3404    for imp in &import_block.imports {
3405        // Parse the raw text to find "as <alias>" patterns
3406        // This handles: import { foo as bar, baz as qux } from './mod'
3407        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3408            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3409                return Some((original, resolved_path));
3410            }
3411        }
3412    }
3413    None
3414}
3415
3416/// Parse import raw text to find the original name for an alias.
3417/// Given raw text like `import { foo as bar, baz } from './utils'` and
3418/// local_name "bar", returns Some("foo").
3419fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3420    // Look for pattern: <original> as <alias>
3421    // This is a simple text-based search; handles the common TS/JS pattern
3422    let search = format!(" as {}", local_name);
3423    if let Some(pos) = raw_import.find(&search) {
3424        // Walk backwards from `pos` to find the original name
3425        let before = &raw_import[..pos];
3426        // The original name is the last word-like token before " as "
3427        let original = before
3428            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3429            .find(|s| !s.is_empty())?;
3430        return Some(original.to_string());
3431    }
3432    None
3433}
3434
3435// ---------------------------------------------------------------------------
3436// Worktree file discovery
3437// ---------------------------------------------------------------------------
3438
3439/// Walk project files respecting .gitignore, excluding common non-source dirs.
3440///
3441/// Returns an iterator of file paths for supported source file types.
3442pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3443    use ignore::WalkBuilder;
3444
3445    let walker = WalkBuilder::new(root)
3446        .hidden(true)         // skip hidden files/dirs
3447        .git_ignore(true)     // respect .gitignore
3448        .git_global(true)     // respect global gitignore
3449        .git_exclude(true)    // respect .git/info/exclude
3450        .filter_entry(|entry| {
3451            let name = entry.file_name().to_string_lossy();
3452            // Always exclude these directories regardless of .gitignore
3453            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3454                return !matches!(
3455                    name.as_ref(),
3456                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3457                        | ".tox" | "dist" | "build"
3458                );
3459            }
3460            true
3461        })
3462        .build();
3463
3464    walker
3465        .filter_map(|entry| entry.ok())
3466        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3467        .filter(|entry| detect_language(entry.path()).is_some())
3468        .map(|entry| entry.into_path())
3469}
3470
3471// ---------------------------------------------------------------------------
3472// Tests
3473// ---------------------------------------------------------------------------
3474
3475#[cfg(test)]
3476mod tests {
3477    use super::*;
3478    use std::fs;
3479    use tempfile::TempDir;
3480
3481    /// Create a temp directory with TypeScript files for testing.
3482    fn setup_ts_project() -> TempDir {
3483        let dir = TempDir::new().unwrap();
3484
3485        // main.ts: imports from utils and calls functions
3486        fs::write(
3487            dir.path().join("main.ts"),
3488            r#"import { helper, compute } from './utils';
3489import * as math from './math';
3490
3491export function main() {
3492    const a = helper(1);
3493    const b = compute(a, 2);
3494    const c = math.add(a, b);
3495    return c;
3496}
3497"#,
3498        )
3499        .unwrap();
3500
3501        // utils.ts: defines helper and compute, imports from helpers
3502        fs::write(
3503            dir.path().join("utils.ts"),
3504            r#"import { double } from './helpers';
3505
3506export function helper(x: number): number {
3507    return double(x);
3508}
3509
3510export function compute(a: number, b: number): number {
3511    return a + b;
3512}
3513"#,
3514        )
3515        .unwrap();
3516
3517        // helpers.ts: defines double
3518        fs::write(
3519            dir.path().join("helpers.ts"),
3520            r#"export function double(x: number): number {
3521    return x * 2;
3522}
3523
3524export function triple(x: number): number {
3525    return x * 3;
3526}
3527"#,
3528        )
3529        .unwrap();
3530
3531        // math.ts: defines add (for namespace import test)
3532        fs::write(
3533            dir.path().join("math.ts"),
3534            r#"export function add(a: number, b: number): number {
3535    return a + b;
3536}
3537
3538export function subtract(a: number, b: number): number {
3539    return a - b;
3540}
3541"#,
3542        )
3543        .unwrap();
3544
3545        dir
3546    }
3547
3548    /// Create a project with import aliasing.
3549    fn setup_alias_project() -> TempDir {
3550        let dir = TempDir::new().unwrap();
3551
3552        fs::write(
3553            dir.path().join("main.ts"),
3554            r#"import { helper as h } from './utils';
3555
3556export function main() {
3557    return h(42);
3558}
3559"#,
3560        )
3561        .unwrap();
3562
3563        fs::write(
3564            dir.path().join("utils.ts"),
3565            r#"export function helper(x: number): number {
3566    return x + 1;
3567}
3568"#,
3569        )
3570        .unwrap();
3571
3572        dir
3573    }
3574
3575    /// Create a project with a cycle: A → B → A.
3576    fn setup_cycle_project() -> TempDir {
3577        let dir = TempDir::new().unwrap();
3578
3579        fs::write(
3580            dir.path().join("a.ts"),
3581            r#"import { funcB } from './b';
3582
3583export function funcA() {
3584    return funcB();
3585}
3586"#,
3587        )
3588        .unwrap();
3589
3590        fs::write(
3591            dir.path().join("b.ts"),
3592            r#"import { funcA } from './a';
3593
3594export function funcB() {
3595    return funcA();
3596}
3597"#,
3598        )
3599        .unwrap();
3600
3601        dir
3602    }
3603
3604    // --- Single-file call extraction ---
3605
3606    #[test]
3607    fn callgraph_single_file_call_extraction() {
3608        let dir = setup_ts_project();
3609        let mut graph = CallGraph::new(dir.path().to_path_buf());
3610
3611        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3612        let main_calls = &file_data.calls_by_symbol["main"];
3613
3614        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3615        assert!(
3616            callee_names.contains(&"helper"),
3617            "main should call helper, got: {:?}",
3618            callee_names
3619        );
3620        assert!(
3621            callee_names.contains(&"compute"),
3622            "main should call compute, got: {:?}",
3623            callee_names
3624        );
3625        assert!(
3626            callee_names.contains(&"add"),
3627            "main should call math.add (short name: add), got: {:?}",
3628            callee_names
3629        );
3630    }
3631
3632    #[test]
3633    fn callgraph_file_data_has_exports() {
3634        let dir = setup_ts_project();
3635        let mut graph = CallGraph::new(dir.path().to_path_buf());
3636
3637        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3638        assert!(
3639            file_data.exported_symbols.contains(&"helper".to_string()),
3640            "utils.ts should export helper, got: {:?}",
3641            file_data.exported_symbols
3642        );
3643        assert!(
3644            file_data.exported_symbols.contains(&"compute".to_string()),
3645            "utils.ts should export compute, got: {:?}",
3646            file_data.exported_symbols
3647        );
3648    }
3649
3650    // --- Cross-file resolution ---
3651
3652    #[test]
3653    fn callgraph_resolve_direct_import() {
3654        let dir = setup_ts_project();
3655        let mut graph = CallGraph::new(dir.path().to_path_buf());
3656
3657        let main_path = dir.path().join("main.ts");
3658        let file_data = graph.build_file(&main_path).unwrap();
3659        let import_block = file_data.import_block.clone();
3660
3661        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3662        match edge {
3663            EdgeResolution::Resolved { file, symbol } => {
3664                assert!(
3665                    file.ends_with("utils.ts"),
3666                    "helper should resolve to utils.ts, got: {:?}",
3667                    file
3668                );
3669                assert_eq!(symbol, "helper");
3670            }
3671            EdgeResolution::Unresolved { callee_name } => {
3672                panic!("Expected resolved, got unresolved: {}", callee_name);
3673            }
3674        }
3675    }
3676
3677    #[test]
3678    fn callgraph_resolve_namespace_import() {
3679        let dir = setup_ts_project();
3680        let mut graph = CallGraph::new(dir.path().to_path_buf());
3681
3682        let main_path = dir.path().join("main.ts");
3683        let file_data = graph.build_file(&main_path).unwrap();
3684        let import_block = file_data.import_block.clone();
3685
3686        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3687        match edge {
3688            EdgeResolution::Resolved { file, symbol } => {
3689                assert!(
3690                    file.ends_with("math.ts"),
3691                    "math.add should resolve to math.ts, got: {:?}",
3692                    file
3693                );
3694                assert_eq!(symbol, "add");
3695            }
3696            EdgeResolution::Unresolved { callee_name } => {
3697                panic!("Expected resolved, got unresolved: {}", callee_name);
3698            }
3699        }
3700    }
3701
3702    #[test]
3703    fn callgraph_resolve_aliased_import() {
3704        let dir = setup_alias_project();
3705        let mut graph = CallGraph::new(dir.path().to_path_buf());
3706
3707        let main_path = dir.path().join("main.ts");
3708        let file_data = graph.build_file(&main_path).unwrap();
3709        let import_block = file_data.import_block.clone();
3710
3711        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3712        match edge {
3713            EdgeResolution::Resolved { file, symbol } => {
3714                assert!(
3715                    file.ends_with("utils.ts"),
3716                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
3717                    file
3718                );
3719                assert_eq!(symbol, "helper");
3720            }
3721            EdgeResolution::Unresolved { callee_name } => {
3722                panic!("Expected resolved, got unresolved: {}", callee_name);
3723            }
3724        }
3725    }
3726
3727    #[test]
3728    fn callgraph_unresolved_edge_marked() {
3729        let dir = setup_ts_project();
3730        let mut graph = CallGraph::new(dir.path().to_path_buf());
3731
3732        let main_path = dir.path().join("main.ts");
3733        let file_data = graph.build_file(&main_path).unwrap();
3734        let import_block = file_data.import_block.clone();
3735
3736        let edge =
3737            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3738        assert_eq!(
3739            edge,
3740            EdgeResolution::Unresolved {
3741                callee_name: "unknownFunc".to_string()
3742            },
3743            "Unknown callee should be unresolved"
3744        );
3745    }
3746
3747    // --- Cycle detection ---
3748
3749    #[test]
3750    fn callgraph_cycle_detection_stops() {
3751        let dir = setup_cycle_project();
3752        let mut graph = CallGraph::new(dir.path().to_path_buf());
3753
3754        // This should NOT infinite loop
3755        let tree = graph
3756            .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
3757            .unwrap();
3758
3759        assert_eq!(tree.name, "funcA");
3760        assert!(tree.resolved);
3761
3762        // funcA calls funcB, funcB calls funcA (cycle), so the depth should be bounded
3763        // The tree should have children but not infinitely deep
3764        fn count_depth(node: &CallTreeNode) -> usize {
3765            if node.children.is_empty() {
3766                1
3767            } else {
3768                1 + node.children.iter().map(count_depth).max().unwrap_or(0)
3769            }
3770        }
3771
3772        let depth = count_depth(&tree);
3773        assert!(
3774            depth <= 4,
3775            "Cycle should be detected and bounded, depth was: {}",
3776            depth
3777        );
3778    }
3779
3780    // --- Depth limiting ---
3781
3782    #[test]
3783    fn callgraph_depth_limit_truncates() {
3784        let dir = setup_ts_project();
3785        let mut graph = CallGraph::new(dir.path().to_path_buf());
3786
3787        // main → helper → double, main → compute
3788        // With depth 1, we should see direct callees but not their children
3789        let tree = graph
3790            .forward_tree(&dir.path().join("main.ts"), "main", 1)
3791            .unwrap();
3792
3793        assert_eq!(tree.name, "main");
3794
3795        // At depth 1, children should exist (direct calls) but their children should be empty
3796        for child in &tree.children {
3797            assert!(
3798                child.children.is_empty(),
3799                "At depth 1, child '{}' should have no children, got {:?}",
3800                child.name,
3801                child.children.len()
3802            );
3803        }
3804    }
3805
3806    #[test]
3807    fn callgraph_depth_zero_no_children() {
3808        let dir = setup_ts_project();
3809        let mut graph = CallGraph::new(dir.path().to_path_buf());
3810
3811        let tree = graph
3812            .forward_tree(&dir.path().join("main.ts"), "main", 0)
3813            .unwrap();
3814
3815        assert_eq!(tree.name, "main");
3816        assert!(
3817            tree.children.is_empty(),
3818            "At depth 0, should have no children"
3819        );
3820    }
3821
3822    // --- Forward tree cross-file ---
3823
3824    #[test]
3825    fn callgraph_forward_tree_cross_file() {
3826        let dir = setup_ts_project();
3827        let mut graph = CallGraph::new(dir.path().to_path_buf());
3828
3829        // main → helper (in utils.ts) → double (in helpers.ts)
3830        let tree = graph
3831            .forward_tree(&dir.path().join("main.ts"), "main", 5)
3832            .unwrap();
3833
3834        assert_eq!(tree.name, "main");
3835        assert!(tree.resolved);
3836
3837        // Find the helper child
3838        let helper_child = tree.children.iter().find(|c| c.name == "helper");
3839        assert!(
3840            helper_child.is_some(),
3841            "main should have helper as child, children: {:?}",
3842            tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3843        );
3844
3845        let helper = helper_child.unwrap();
3846        assert!(
3847            helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
3848            "helper should be in utils.ts, got: {}",
3849            helper.file
3850        );
3851
3852        // helper should call double (in helpers.ts)
3853        let double_child = helper.children.iter().find(|c| c.name == "double");
3854        assert!(
3855            double_child.is_some(),
3856            "helper should call double, children: {:?}",
3857            helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3858        );
3859
3860        let double = double_child.unwrap();
3861        assert!(
3862            double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
3863            "double should be in helpers.ts, got: {}",
3864            double.file
3865        );
3866    }
3867
3868    // --- Worktree walker ---
3869
3870    #[test]
3871    fn callgraph_walker_excludes_gitignored() {
3872        let dir = TempDir::new().unwrap();
3873
3874        // Create a .gitignore
3875        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3876
3877        // Create files
3878        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3879        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3880        fs::write(
3881            dir.path().join("ignored_dir").join("secret.ts"),
3882            "export function secret() {}",
3883        )
3884        .unwrap();
3885
3886        // Also create node_modules (should always be excluded)
3887        fs::create_dir(dir.path().join("node_modules")).unwrap();
3888        fs::write(
3889            dir.path().join("node_modules").join("dep.ts"),
3890            "export function dep() {}",
3891        )
3892        .unwrap();
3893
3894        // Init git repo for .gitignore to work
3895        std::process::Command::new("git")
3896            .args(["init"])
3897            .current_dir(dir.path())
3898            .output()
3899            .unwrap();
3900
3901        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3902        let file_names: Vec<String> = files
3903            .iter()
3904            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3905            .collect();
3906
3907        assert!(
3908            file_names.contains(&"main.ts".to_string()),
3909            "Should include main.ts, got: {:?}",
3910            file_names
3911        );
3912        assert!(
3913            !file_names.contains(&"secret.ts".to_string()),
3914            "Should exclude gitignored secret.ts, got: {:?}",
3915            file_names
3916        );
3917        assert!(
3918            !file_names.contains(&"dep.ts".to_string()),
3919            "Should exclude node_modules, got: {:?}",
3920            file_names
3921        );
3922    }
3923
3924    #[test]
3925    fn callgraph_walker_only_source_files() {
3926        let dir = TempDir::new().unwrap();
3927
3928        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3929        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3930        fs::write(dir.path().join("data.json"), "{}").unwrap();
3931
3932        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3933        let file_names: Vec<String> = files
3934            .iter()
3935            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3936            .collect();
3937
3938        assert!(file_names.contains(&"main.ts".to_string()));
3939        assert!(
3940            file_names.contains(&"readme.md".to_string()),
3941            "Markdown is now a supported source language"
3942        );
3943        assert!(
3944            file_names.contains(&"data.json".to_string()),
3945            "JSON is now a supported source language"
3946        );
3947    }
3948
3949    // --- find_alias_original ---
3950
3951    #[test]
3952    fn callgraph_find_alias_original_simple() {
3953        let raw = "import { foo as bar } from './utils';";
3954        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3955    }
3956
3957    #[test]
3958    fn callgraph_find_alias_original_multiple() {
3959        let raw = "import { foo as bar, baz as qux } from './utils';";
3960        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3961        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3962    }
3963
3964    #[test]
3965    fn callgraph_find_alias_no_match() {
3966        let raw = "import { foo } from './utils';";
3967        assert_eq!(find_alias_original(raw, "foo"), None);
3968    }
3969
3970    // --- Reverse callers ---
3971
3972    #[test]
3973    fn callgraph_callers_of_direct() {
3974        let dir = setup_ts_project();
3975        let mut graph = CallGraph::new(dir.path().to_path_buf());
3976
3977        // helpers.ts:double is called by utils.ts:helper
3978        let result = graph
3979            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
3980            .unwrap();
3981
3982        assert_eq!(result.symbol, "double");
3983        assert!(result.total_callers > 0, "double should have callers");
3984        assert!(result.scanned_files > 0, "should have scanned files");
3985
3986        // Find the caller from utils.ts
3987        let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
3988        assert!(
3989            utils_group.is_some(),
3990            "double should be called from utils.ts, groups: {:?}",
3991            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
3992        );
3993
3994        let group = utils_group.unwrap();
3995        let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
3996        assert!(
3997            helper_caller.is_some(),
3998            "double should be called by helper, callers: {:?}",
3999            group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
4000        );
4001    }
4002
4003    #[test]
4004    fn callgraph_callers_of_no_callers() {
4005        let dir = setup_ts_project();
4006        let mut graph = CallGraph::new(dir.path().to_path_buf());
4007
4008        // main.ts:main is the entry point — nothing calls it
4009        let result = graph
4010            .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
4011            .unwrap();
4012
4013        assert_eq!(result.symbol, "main");
4014        assert_eq!(result.total_callers, 0, "main should have no callers");
4015        assert!(result.callers.is_empty());
4016    }
4017
4018    #[test]
4019    fn callgraph_callers_recursive_depth() {
4020        let dir = setup_ts_project();
4021        let mut graph = CallGraph::new(dir.path().to_path_buf());
4022
4023        // helpers.ts:double is called by utils.ts:helper
4024        // utils.ts:helper is called by main.ts:main
4025        // With depth=2, we should see both direct and transitive callers
4026        let result = graph
4027            .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
4028            .unwrap();
4029
4030        assert!(
4031            result.total_callers >= 2,
4032            "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
4033            result.total_callers
4034        );
4035
4036        // Should include caller from main.ts (transitive: main → helper → double)
4037        let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
4038        assert!(
4039            main_group.is_some(),
4040            "recursive callers should include main.ts, groups: {:?}",
4041            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
4042        );
4043    }
4044
4045    #[test]
4046    fn callgraph_invalidate_file_clears_reverse_index() {
4047        let dir = setup_ts_project();
4048        let mut graph = CallGraph::new(dir.path().to_path_buf());
4049
4050        // Build callers to populate the reverse index
4051        let _ = graph
4052            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
4053            .unwrap();
4054        assert!(
4055            graph.reverse_index.is_some(),
4056            "reverse index should be built"
4057        );
4058
4059        // Invalidate a file
4060        graph.invalidate_file(&dir.path().join("utils.ts"));
4061
4062        // Reverse index should be cleared
4063        assert!(
4064            graph.reverse_index.is_none(),
4065            "invalidate_file should clear reverse index"
4066        );
4067        // Data cache for the file should be cleared
4068        let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
4069        assert!(
4070            !graph.data.contains_key(&canon),
4071            "invalidate_file should remove file from data cache"
4072        );
4073        // Project files should be cleared
4074        assert!(
4075            graph.project_files.is_none(),
4076            "invalidate_file should clear project_files"
4077        );
4078    }
4079
4080    // --- is_entry_point ---
4081
4082    #[test]
4083    fn is_entry_point_exported_function() {
4084        assert!(is_entry_point(
4085            "handleRequest",
4086            &SymbolKind::Function,
4087            true,
4088            LangId::TypeScript
4089        ));
4090    }
4091
4092    #[test]
4093    fn is_entry_point_exported_method_is_not_entry() {
4094        // Methods are class members, not standalone entry points
4095        assert!(!is_entry_point(
4096            "handleRequest",
4097            &SymbolKind::Method,
4098            true,
4099            LangId::TypeScript
4100        ));
4101    }
4102
4103    #[test]
4104    fn is_entry_point_main_init_patterns() {
4105        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
4106            assert!(
4107                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
4108                "{} should be an entry point",
4109                name
4110            );
4111        }
4112    }
4113
4114    #[test]
4115    fn is_entry_point_test_patterns_ts() {
4116        assert!(is_entry_point(
4117            "describe",
4118            &SymbolKind::Function,
4119            false,
4120            LangId::TypeScript
4121        ));
4122        assert!(is_entry_point(
4123            "it",
4124            &SymbolKind::Function,
4125            false,
4126            LangId::TypeScript
4127        ));
4128        assert!(is_entry_point(
4129            "test",
4130            &SymbolKind::Function,
4131            false,
4132            LangId::TypeScript
4133        ));
4134        assert!(is_entry_point(
4135            "testValidation",
4136            &SymbolKind::Function,
4137            false,
4138            LangId::TypeScript
4139        ));
4140        assert!(is_entry_point(
4141            "specHelper",
4142            &SymbolKind::Function,
4143            false,
4144            LangId::TypeScript
4145        ));
4146    }
4147
4148    #[test]
4149    fn is_entry_point_test_patterns_python() {
4150        assert!(is_entry_point(
4151            "test_login",
4152            &SymbolKind::Function,
4153            false,
4154            LangId::Python
4155        ));
4156        assert!(is_entry_point(
4157            "setUp",
4158            &SymbolKind::Function,
4159            false,
4160            LangId::Python
4161        ));
4162        assert!(is_entry_point(
4163            "tearDown",
4164            &SymbolKind::Function,
4165            false,
4166            LangId::Python
4167        ));
4168        // "testSomething" should NOT match Python (needs test_ prefix)
4169        assert!(!is_entry_point(
4170            "testSomething",
4171            &SymbolKind::Function,
4172            false,
4173            LangId::Python
4174        ));
4175    }
4176
4177    #[test]
4178    fn is_entry_point_test_patterns_rust() {
4179        assert!(is_entry_point(
4180            "test_parse",
4181            &SymbolKind::Function,
4182            false,
4183            LangId::Rust
4184        ));
4185        assert!(!is_entry_point(
4186            "TestSomething",
4187            &SymbolKind::Function,
4188            false,
4189            LangId::Rust
4190        ));
4191    }
4192
4193    #[test]
4194    fn is_entry_point_test_patterns_go() {
4195        assert!(is_entry_point(
4196            "TestParsing",
4197            &SymbolKind::Function,
4198            false,
4199            LangId::Go
4200        ));
4201        // lowercase test should NOT match Go (needs uppercase Test prefix)
4202        assert!(!is_entry_point(
4203            "testParsing",
4204            &SymbolKind::Function,
4205            false,
4206            LangId::Go
4207        ));
4208    }
4209
4210    #[test]
4211    fn is_entry_point_non_exported_non_main_is_not_entry() {
4212        assert!(!is_entry_point(
4213            "helperUtil",
4214            &SymbolKind::Function,
4215            false,
4216            LangId::TypeScript
4217        ));
4218    }
4219
4220    // --- symbol_metadata ---
4221
4222    #[test]
4223    fn callgraph_symbol_metadata_populated() {
4224        let dir = setup_ts_project();
4225        let mut graph = CallGraph::new(dir.path().to_path_buf());
4226
4227        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4228        assert!(
4229            file_data.symbol_metadata.contains_key("helper"),
4230            "symbol_metadata should contain helper"
4231        );
4232        let meta = &file_data.symbol_metadata["helper"];
4233        assert_eq!(meta.kind, SymbolKind::Function);
4234        assert!(meta.exported, "helper should be exported");
4235    }
4236
4237    // --- trace_to ---
4238
4239    /// Setup a multi-path project for trace_to tests.
4240    ///
4241    /// Structure:
4242    ///   main.ts: exported main() → processData (from utils)
4243    ///   service.ts: exported handleRequest() → processData (from utils)
4244    ///   utils.ts: exported processData() → validate (from helpers)
4245    ///   helpers.ts: exported validate() → checkFormat (local, not exported)
4246    ///   test_helpers.ts: testValidation() → validate (from helpers)
4247    ///
4248    /// checkFormat should have 3 paths:
4249    ///   main → processData → validate → checkFormat
4250    ///   handleRequest → processData → validate → checkFormat
4251    ///   testValidation → validate → checkFormat
4252    fn setup_trace_project() -> TempDir {
4253        let dir = TempDir::new().unwrap();
4254
4255        fs::write(
4256            dir.path().join("main.ts"),
4257            r#"import { processData } from './utils';
4258
4259export function main() {
4260    const result = processData("hello");
4261    return result;
4262}
4263"#,
4264        )
4265        .unwrap();
4266
4267        fs::write(
4268            dir.path().join("service.ts"),
4269            r#"import { processData } from './utils';
4270
4271export function handleRequest(input: string): string {
4272    return processData(input);
4273}
4274"#,
4275        )
4276        .unwrap();
4277
4278        fs::write(
4279            dir.path().join("utils.ts"),
4280            r#"import { validate } from './helpers';
4281
4282export function processData(input: string): string {
4283    const valid = validate(input);
4284    if (!valid) {
4285        throw new Error("invalid input");
4286    }
4287    return input.toUpperCase();
4288}
4289"#,
4290        )
4291        .unwrap();
4292
4293        fs::write(
4294            dir.path().join("helpers.ts"),
4295            r#"export function validate(input: string): boolean {
4296    return checkFormat(input);
4297}
4298
4299function checkFormat(input: string): boolean {
4300    return input.length > 0 && /^[a-zA-Z]+$/.test(input);
4301}
4302"#,
4303        )
4304        .unwrap();
4305
4306        fs::write(
4307            dir.path().join("test_helpers.ts"),
4308            r#"import { validate } from './helpers';
4309
4310function testValidation() {
4311    const result = validate("hello");
4312    console.log(result);
4313}
4314"#,
4315        )
4316        .unwrap();
4317
4318        // git init so the walker works
4319        std::process::Command::new("git")
4320            .args(["init"])
4321            .current_dir(dir.path())
4322            .output()
4323            .unwrap();
4324
4325        dir
4326    }
4327
4328    #[test]
4329    fn trace_to_multi_path() {
4330        let dir = setup_trace_project();
4331        let mut graph = CallGraph::new(dir.path().to_path_buf());
4332
4333        let result = graph
4334            .trace_to(
4335                &dir.path().join("helpers.ts"),
4336                "checkFormat",
4337                10,
4338                usize::MAX,
4339            )
4340            .unwrap();
4341
4342        assert_eq!(result.target_symbol, "checkFormat");
4343        assert!(
4344            result.total_paths >= 2,
4345            "checkFormat should have at least 2 paths, got {} (paths: {:?})",
4346            result.total_paths,
4347            result
4348                .paths
4349                .iter()
4350                .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
4351                .collect::<Vec<_>>()
4352        );
4353
4354        // Check that paths are top-down: entry point first, target last
4355        for path in &result.paths {
4356            assert!(
4357                path.hops.first().unwrap().is_entry_point,
4358                "First hop should be an entry point, got: {}",
4359                path.hops.first().unwrap().symbol
4360            );
4361            assert_eq!(
4362                path.hops.last().unwrap().symbol,
4363                "checkFormat",
4364                "Last hop should be checkFormat"
4365            );
4366        }
4367
4368        // Verify entry_points_found > 0
4369        assert!(
4370            result.entry_points_found >= 2,
4371            "should find at least 2 entry points, got {}",
4372            result.entry_points_found
4373        );
4374    }
4375
4376    #[test]
4377    fn trace_to_single_path() {
4378        let dir = setup_trace_project();
4379        let mut graph = CallGraph::new(dir.path().to_path_buf());
4380
4381        // validate is called from processData, testValidation
4382        // processData is called from main, handleRequest
4383        // So validate has paths: main→processData→validate, handleRequest→processData→validate, testValidation→validate
4384        let result = graph
4385            .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
4386            .unwrap();
4387
4388        assert_eq!(result.target_symbol, "validate");
4389        assert!(
4390            result.total_paths >= 2,
4391            "validate should have at least 2 paths, got {}",
4392            result.total_paths
4393        );
4394    }
4395
4396    #[test]
4397    fn trace_to_cycle_detection() {
4398        let dir = setup_cycle_project();
4399        let mut graph = CallGraph::new(dir.path().to_path_buf());
4400
4401        // funcA ↔ funcB cycle — should terminate
4402        let result = graph
4403            .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
4404            .unwrap();
4405
4406        // Should not hang — the fact we got here means cycle detection works
4407        assert_eq!(result.target_symbol, "funcA");
4408    }
4409
4410    #[test]
4411    fn trace_to_depth_limit() {
4412        let dir = setup_trace_project();
4413        let mut graph = CallGraph::new(dir.path().to_path_buf());
4414
4415        // With max_depth=1, should not be able to reach entry points that are 3+ hops away
4416        let result = graph
4417            .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
4418            .unwrap();
4419
4420        // testValidation→validate→checkFormat is 2 hops, which requires depth >= 2
4421        // main→processData→validate→checkFormat is 3 hops, which requires depth >= 3
4422        // With depth=1, most paths should be truncated
4423        assert_eq!(result.target_symbol, "checkFormat");
4424
4425        // The shallow result should have fewer paths than the deep one
4426        let deep_result = graph
4427            .trace_to(
4428                &dir.path().join("helpers.ts"),
4429                "checkFormat",
4430                10,
4431                usize::MAX,
4432            )
4433            .unwrap();
4434
4435        assert!(
4436            result.total_paths <= deep_result.total_paths,
4437            "shallow trace should find <= paths compared to deep: {} vs {}",
4438            result.total_paths,
4439            deep_result.total_paths
4440        );
4441    }
4442
4443    #[test]
4444    fn trace_to_entry_point_target() {
4445        let dir = setup_trace_project();
4446        let mut graph = CallGraph::new(dir.path().to_path_buf());
4447
4448        // main is itself an entry point — should return a single trivial path
4449        let result = graph
4450            .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
4451            .unwrap();
4452
4453        assert_eq!(result.target_symbol, "main");
4454        assert!(
4455            result.total_paths >= 1,
4456            "main should have at least 1 path (itself), got {}",
4457            result.total_paths
4458        );
4459        // Check the trivial path has just one hop
4460        let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
4461        assert!(
4462            trivial.is_some(),
4463            "should have a trivial path with just the entry point itself"
4464        );
4465    }
4466
4467    // --- extract_parameters ---
4468
4469    #[test]
4470    fn extract_parameters_typescript() {
4471        let params = extract_parameters(
4472            "function processData(input: string, count: number): void",
4473            LangId::TypeScript,
4474        );
4475        assert_eq!(params, vec!["input", "count"]);
4476    }
4477
4478    #[test]
4479    fn extract_parameters_typescript_optional() {
4480        let params = extract_parameters(
4481            "function fetch(url: string, options?: RequestInit): Promise<Response>",
4482            LangId::TypeScript,
4483        );
4484        assert_eq!(params, vec!["url", "options"]);
4485    }
4486
4487    #[test]
4488    fn extract_parameters_typescript_defaults() {
4489        let params = extract_parameters(
4490            "function greet(name: string, greeting: string = \"hello\"): string",
4491            LangId::TypeScript,
4492        );
4493        assert_eq!(params, vec!["name", "greeting"]);
4494    }
4495
4496    #[test]
4497    fn extract_parameters_typescript_rest() {
4498        let params = extract_parameters(
4499            "function sum(...numbers: number[]): number",
4500            LangId::TypeScript,
4501        );
4502        assert_eq!(params, vec!["numbers"]);
4503    }
4504
4505    #[test]
4506    fn extract_parameters_python_self_skipped() {
4507        let params = extract_parameters(
4508            "def process(self, data: str, count: int) -> bool",
4509            LangId::Python,
4510        );
4511        assert_eq!(params, vec!["data", "count"]);
4512    }
4513
4514    #[test]
4515    fn extract_parameters_python_no_self() {
4516        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4517        assert_eq!(params, vec!["input"]);
4518    }
4519
4520    #[test]
4521    fn extract_parameters_python_star_args() {
4522        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4523        assert_eq!(params, vec!["args", "kwargs"]);
4524    }
4525
4526    #[test]
4527    fn extract_parameters_rust_self_skipped() {
4528        let params = extract_parameters(
4529            "fn process(&self, data: &str, count: usize) -> bool",
4530            LangId::Rust,
4531        );
4532        assert_eq!(params, vec!["data", "count"]);
4533    }
4534
4535    #[test]
4536    fn extract_parameters_rust_mut_self_skipped() {
4537        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4538        assert_eq!(params, vec!["value"]);
4539    }
4540
4541    #[test]
4542    fn extract_parameters_rust_no_self() {
4543        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4544        assert_eq!(params, vec!["input"]);
4545    }
4546
4547    #[test]
4548    fn extract_parameters_rust_mut_param() {
4549        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4550        assert_eq!(params, vec!["buf", "len"]);
4551    }
4552
4553    #[test]
4554    fn extract_parameters_go() {
4555        let params = extract_parameters(
4556            "func ProcessData(input string, count int) error",
4557            LangId::Go,
4558        );
4559        assert_eq!(params, vec!["input", "count"]);
4560    }
4561
4562    #[test]
4563    fn extract_parameters_empty() {
4564        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4565        assert!(
4566            params.is_empty(),
4567            "no-arg function should return empty params"
4568        );
4569    }
4570
4571    #[test]
4572    fn extract_parameters_no_parens() {
4573        let params = extract_parameters("const x = 42", LangId::TypeScript);
4574        assert!(params.is_empty(), "no parens should return empty params");
4575    }
4576
4577    #[test]
4578    fn extract_parameters_javascript() {
4579        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4580        assert_eq!(params, vec!["event", "target"]);
4581    }
4582}