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::{LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use serde::Serialize;
14use serde_json::Value;
15use tree_sitter::{Node, Parser};
16
17use crate::calls::extract_calls_full;
18#[cfg(test)]
19use crate::calls::{call_node_kinds, extract_callee_name, extract_full_callee};
20#[cfg(test)]
21use crate::edit::line_col_to_byte;
22use crate::error::AftError;
23use crate::imports::{self, ImportBlock};
24use crate::parser::{detect_language, grammar_for, LangId};
25use crate::symbols::{Range, Symbol, SymbolKind};
26
27// ---------------------------------------------------------------------------
28// Core types
29// ---------------------------------------------------------------------------
30
31type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
32type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
33type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36    LazyLock::new(|| RwLock::new(HashMap::new()));
37static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
38    LazyLock::new(|| RwLock::new(HashMap::new()));
39static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
40    LazyLock::new(|| RwLock::new(HashMap::new()));
41
42const TOP_LEVEL_SYMBOL: &str = "<top-level>";
43const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
44const JS_TS_INDEX_FILES: &[&str] = &[
45    "index.ts",
46    "index.tsx",
47    "index.mts",
48    "index.cts",
49    "index.js",
50    "index.jsx",
51    "index.mjs",
52    "index.cjs",
53];
54
55fn symbol_identity(symbol: &Symbol) -> String {
56    if symbol.scope_chain.is_empty() {
57        symbol.name.clone()
58    } else {
59        format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
60    }
61}
62
63fn symbol_unqualified_name(symbol: &str) -> &str {
64    symbol.rsplit("::").next().unwrap_or(symbol)
65}
66
67pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
68    full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
69}
70
71fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
72    let mut seen = HashSet::new();
73    let mut candidates = Vec::new();
74    let qualified_query = symbol_name.contains("::");
75
76    let mut consider = |candidate: &str| {
77        let matches = if qualified_query {
78            candidate == symbol_name
79        } else {
80            candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
81        };
82
83        if matches && seen.insert(candidate.to_string()) {
84            candidates.push(candidate.to_string());
85        }
86    };
87
88    for candidate in file_data.symbol_metadata.keys() {
89        consider(candidate);
90    }
91    for candidate in file_data.calls_by_symbol.keys() {
92        consider(candidate);
93    }
94    for candidate in &file_data.exported_symbols {
95        consider(candidate);
96    }
97
98    candidates.sort();
99    candidates
100}
101
102pub(crate) fn resolve_symbol_query_in_data(
103    file_data: &FileCallData,
104    file: &Path,
105    symbol_name: &str,
106) -> Result<String, AftError> {
107    let candidates = symbol_query_candidates(file_data, symbol_name);
108    match candidates.as_slice() {
109        [candidate] => Ok(candidate.clone()),
110        [] => Err(AftError::SymbolNotFound {
111            name: symbol_name.to_string(),
112            file: file.display().to_string(),
113        }),
114        _ => Err(AftError::AmbiguousSymbol {
115            name: symbol_name.to_string(),
116            candidates,
117        }),
118    }
119}
120
121/// A single call site within a function body.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CallSite {
124    /// The short callee name (last segment, e.g. "foo" for `utils.foo()`).
125    pub callee_name: String,
126    /// The full callee expression (e.g. "utils.foo" for `utils.foo()`).
127    pub full_callee: String,
128    /// 1-based line number of the call.
129    pub line: u32,
130    /// Byte range of the call expression in the source.
131    pub byte_start: usize,
132    pub byte_end: usize,
133}
134
135/// Per-symbol metadata for entry point detection (avoids re-parsing).
136#[derive(Debug, Clone, Serialize)]
137pub struct SymbolMeta {
138    /// The kind of symbol (function, class, method, etc).
139    pub kind: SymbolKind,
140    /// Whether this symbol is exported.
141    pub exported: bool,
142    /// Function/method signature if available.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub signature: Option<String>,
145    /// 1-based start line of the symbol.
146    pub line: u32,
147    /// 0-based source range of the symbol.
148    pub range: Range,
149    /// Attribute that marks the symbol as externally reachable, if any.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub entry_point_attribute: Option<String>,
152}
153
154/// Per-file call data: call sites grouped by containing symbol, plus
155/// exported symbol names and parsed imports.
156#[derive(Debug, Clone)]
157pub struct FileCallData {
158    /// Map from symbol name → list of call sites within that symbol's body.
159    pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
160    /// Names of exported symbols in this file.
161    pub exported_symbols: Vec<String>,
162    /// Per-symbol metadata (kind, exported, signature).
163    pub symbol_metadata: HashMap<String, SymbolMeta>,
164    /// Real or synthetic symbol name for this file's default export.
165    pub default_export_symbol: Option<String>,
166    /// Parsed import block for cross-file resolution.
167    pub import_block: ImportBlock,
168    /// Language of the file.
169    pub lang: LangId,
170}
171
172impl FileCallData {
173    /// Look up metadata for an exported symbol name.
174    ///
175    /// `exported_symbols` stores bare names (e.g. `total_disk_bytes`), but
176    /// `symbol_metadata` is keyed by scoped identity (e.g.
177    /// `BackupStore::total_disk_bytes` for impl methods, via
178    /// [`symbol_identity`]). A bare-name `.get()` therefore misses scoped
179    /// symbols and forces callers into degraded `unknown`/line-1 fallbacks.
180    /// This resolves an exact key first, then falls back to the first entry
181    /// whose unqualified name matches — recovering correct kind and line for
182    /// methods. (Bare-name exports are already ambiguous across scopes, so
183    /// first-match is the best available signal; this only affects displayed
184    /// metadata, never liveness, which keys on the symbol name.)
185    pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
186        if let Some(meta) = self.symbol_metadata.get(name) {
187            return Some(meta);
188        }
189        self.symbol_metadata
190            .iter()
191            .find(|(key, _)| symbol_unqualified_name(key) == name)
192            .map(|(_, meta)| meta)
193    }
194}
195
196/// Result of resolving a cross-file call edge.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum EdgeResolution {
199    /// Successfully resolved to a specific file and symbol.
200    Resolved { file: PathBuf, symbol: String },
201    /// Could not resolve — callee name preserved for diagnostics.
202    Unresolved { callee_name: String },
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
206struct ResolvedSymbol {
207    file: PathBuf,
208    symbol: String,
209}
210
211#[derive(Debug, Clone)]
212struct RustCrateInfo {
213    lib_name: String,
214    lib_root: Option<PathBuf>,
215    main_root: Option<PathBuf>,
216}
217
218#[derive(Debug, Clone)]
219struct RustModuleBase {
220    src_dir: PathBuf,
221    root_file: PathBuf,
222}
223
224#[derive(Debug, Clone)]
225struct RustUseEntry {
226    module_path: String,
227    local_name: String,
228    kind: RustUseKind,
229}
230
231#[derive(Debug, Clone)]
232enum RustUseKind {
233    Item { imported_name: String },
234    Module,
235}
236
237/// A node in the forward call tree.
238#[derive(Debug, Clone, Serialize)]
239pub struct CallTreeNode {
240    /// Symbol name.
241    pub name: String,
242    /// File path (relative to project root when possible).
243    pub file: String,
244    /// 1-based line number.
245    pub line: u32,
246    /// Function signature if available.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub signature: Option<String>,
249    /// Whether this edge was resolved cross-file.
250    pub resolved: bool,
251    /// Child calls (recursive).
252    pub children: Vec<CallTreeNode>,
253    /// Whether traversal below this node stopped at the requested depth.
254    pub depth_limited: bool,
255    /// Number of child call edges omitted because of the depth limit.
256    pub truncated: usize,
257}
258
259// ---------------------------------------------------------------------------
260// Entry point detection
261// ---------------------------------------------------------------------------
262
263/// Well-known main/init function names (case-insensitive exact match).
264const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
265
266/// Determine whether a symbol is an entry point.
267///
268/// Entry points are:
269/// - Exported standalone functions (not methods — methods are class members)
270/// - Functions matching well-known main/init patterns (any language)
271/// - Test functions matching language-specific patterns
272pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
273    // Exported standalone functions
274    if exported && *kind == SymbolKind::Function {
275        return true;
276    }
277
278    // Main/init patterns (case-insensitive exact match, any kind)
279    let lower = name.to_lowercase();
280    if MAIN_INIT_NAMES.contains(&lower.as_str()) {
281        return true;
282    }
283
284    // Test patterns by language
285    match lang {
286        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
287            // describe, it, test (exact), or starts with test/spec
288            matches!(lower.as_str(), "describe" | "it" | "test")
289                || lower.starts_with("test")
290                || lower.starts_with("spec")
291        }
292        LangId::Python => {
293            // starts with test_ or matches setUp/tearDown
294            lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
295        }
296        LangId::Rust => {
297            // starts with test_
298            lower.starts_with("test_")
299        }
300        LangId::Go => {
301            // starts with Test (case-sensitive)
302            name.starts_with("Test")
303        }
304        LangId::C
305        | LangId::Cpp
306        | LangId::Zig
307        | LangId::CSharp
308        | LangId::Bash
309        | LangId::Solidity
310        | LangId::Scss
311        | LangId::Vue
312        | LangId::Json
313        | LangId::Scala
314        | LangId::Java
315        | LangId::Ruby
316        | LangId::Kotlin
317        | LangId::Swift
318        | LangId::Php
319        | LangId::Lua
320        | LangId::Perl
321        | LangId::Html
322        | LangId::Markdown
323        | LangId::Yaml
324        | LangId::Pascal
325        | LangId::R
326        | LangId::Groovy
327        | LangId::ObjC => false,
328    }
329}
330
331// ---------------------------------------------------------------------------
332// Trace-to types
333// ---------------------------------------------------------------------------
334
335/// A single hop in a trace path.
336#[derive(Debug, Clone, Serialize)]
337pub struct TraceHop {
338    /// Symbol name at this hop.
339    pub symbol: String,
340    /// File path (relative to project root).
341    pub file: String,
342    /// 1-based line number.
343    pub line: u32,
344    /// Function signature if available.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub signature: Option<String>,
347    /// Whether this hop is an entry point.
348    pub is_entry_point: bool,
349}
350
351/// A complete path from an entry point to the target symbol (top-down).
352#[derive(Debug, Clone, Serialize)]
353pub struct TracePath {
354    /// Hops from entry point (first) to target (last).
355    pub hops: Vec<TraceHop>,
356}
357
358/// Result of a `trace_to` query.
359#[derive(Debug, Clone, Serialize)]
360pub struct TraceToResult {
361    /// The target symbol that was traced.
362    pub target_symbol: String,
363    /// The target file (relative to project root).
364    pub target_file: String,
365    /// Complete paths from entry points to the target.
366    pub paths: Vec<TracePath>,
367    /// Total number of complete paths found.
368    pub total_paths: usize,
369    /// Number of distinct entry points found across all paths.
370    pub entry_points_found: usize,
371    /// Whether any path was cut short by the depth limit.
372    pub max_depth_reached: bool,
373    /// Number of paths that reached a dead end (no callers, not entry point).
374    pub truncated_paths: usize,
375}
376
377/// A single hop in a `trace_to_symbol` path.
378#[derive(Debug, Clone, Serialize)]
379pub struct TraceToSymbolHop {
380    /// Symbol name at this hop.
381    pub symbol: String,
382    /// File path (relative to project root).
383    pub file: String,
384    /// 1-based definition line number.
385    pub line: u32,
386}
387
388/// Candidate target location for an ambiguous `trace_to_symbol` request.
389#[derive(Debug, Clone, Serialize)]
390pub struct TraceToSymbolCandidate {
391    /// File path (relative to project root).
392    pub file: String,
393    /// 1-based definition line number.
394    pub line: u32,
395}
396
397/// Result of a `trace_to_symbol` query.
398#[derive(Debug, Clone, Serialize)]
399pub struct TraceToSymbolResult {
400    /// Shortest path from the origin symbol to the target symbol, if found.
401    pub path: Option<Vec<TraceToSymbolHop>>,
402    /// Whether traversal was complete within the requested depth.
403    pub complete: bool,
404    /// Machine-readable explanation when `path` is null.
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub reason: Option<String>,
407}
408
409// ---------------------------------------------------------------------------
410// Data flow tracking types
411// ---------------------------------------------------------------------------
412
413/// A single hop in a data flow trace.
414#[derive(Debug, Clone, Serialize)]
415pub struct DataFlowHop {
416    /// File path (relative to project root).
417    pub file: String,
418    /// Symbol (function/method) containing this hop.
419    pub symbol: String,
420    /// Variable or parameter name being tracked at this hop.
421    pub variable: String,
422    /// 1-based line number.
423    pub line: u32,
424    /// Type of data flow: "assignment", "parameter", or "return".
425    pub flow_type: String,
426    /// Whether this hop is an approximation (destructuring, spread, unresolved).
427    pub approximate: bool,
428}
429
430/// Result of a `trace_data` query — tracks how an expression flows through
431/// variable assignments and function parameters.
432#[derive(Debug, Clone, Serialize)]
433pub struct TraceDataResult {
434    /// The expression being tracked.
435    pub expression: String,
436    /// The file where tracking started.
437    pub origin_file: String,
438    /// The symbol where tracking started.
439    pub origin_symbol: String,
440    /// Hops through assignments and parameters.
441    pub hops: Vec<DataFlowHop>,
442    /// Whether tracking stopped due to depth limit.
443    pub depth_limited: bool,
444}
445
446/// Extract parameter names from a function signature string.
447///
448/// Strips language-specific receivers (`self`, `&self`, `&mut self` for Rust,
449/// `self` for Python) and type annotations / default values. Returns just
450/// the parameter names.
451pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
452    // Find the parameter list between parentheses
453    let start = match signature.find('(') {
454        Some(i) => i + 1,
455        None => return Vec::new(),
456    };
457    let end = match signature[start..].find(')') {
458        Some(i) => start + i,
459        None => return Vec::new(),
460    };
461
462    let params_str = &signature[start..end].trim();
463    if params_str.is_empty() {
464        return Vec::new();
465    }
466
467    // Split on commas, respecting nested generics/brackets
468    let parts = split_params(params_str);
469
470    let mut result = Vec::new();
471    for part in parts {
472        let trimmed = part.trim();
473        if trimmed.is_empty() {
474            continue;
475        }
476
477        // Skip language-specific receivers
478        match lang {
479            LangId::Rust => {
480                if trimmed == "self"
481                    || trimmed == "mut self"
482                    || trimmed.starts_with("&self")
483                    || trimmed.starts_with("&mut self")
484                {
485                    continue;
486                }
487            }
488            LangId::Python => {
489                if trimmed == "self" || trimmed.starts_with("self:") {
490                    continue;
491                }
492            }
493            _ => {}
494        }
495
496        // Extract just the parameter name
497        let name = extract_param_name(trimmed, lang);
498        if !name.is_empty() {
499            result.push(name);
500        }
501    }
502
503    result
504}
505
506/// Split parameter string on commas, respecting nested brackets/generics.
507fn split_params(s: &str) -> Vec<String> {
508    let mut parts = Vec::new();
509    let mut current = String::new();
510    let mut depth = 0i32;
511
512    for ch in s.chars() {
513        match ch {
514            '<' | '[' | '{' | '(' => {
515                depth += 1;
516                current.push(ch);
517            }
518            '>' | ']' | '}' | ')' => {
519                depth -= 1;
520                current.push(ch);
521            }
522            ',' if depth == 0 => {
523                parts.push(current.clone());
524                current.clear();
525            }
526            _ => {
527                current.push(ch);
528            }
529        }
530    }
531    if !current.is_empty() {
532        parts.push(current);
533    }
534    parts
535}
536
537/// Extract the parameter name from a single parameter declaration.
538///
539/// Handles:
540/// - TS/JS: `name: Type`, `name = default`, `...name`, `name?: Type`
541/// - Python: `name: Type`, `name=default`, `*args`, `**kwargs`
542/// - Rust: `name: Type`, `mut name: Type`
543/// - Go: `name Type`, `name, name2 Type`
544fn extract_param_name(param: &str, lang: LangId) -> String {
545    let trimmed = param.trim();
546
547    // Handle rest/spread params
548    let working = if trimmed.starts_with("...") {
549        &trimmed[3..]
550    } else if trimmed.starts_with("**") {
551        &trimmed[2..]
552    } else if trimmed.starts_with('*') && lang == LangId::Python {
553        &trimmed[1..]
554    } else {
555        trimmed
556    };
557
558    // Rust: `mut name: Type` → strip `mut `
559    let working = if lang == LangId::Rust && working.starts_with("mut ") {
560        &working[4..]
561    } else {
562        working
563    };
564
565    // Strip type annotation (`: Type`) and default values (`= default`)
566    // Take only the name part — everything before `:`, `=`, or `?`
567    let name = working
568        .split(|c: char| c == ':' || c == '=')
569        .next()
570        .unwrap_or("")
571        .trim();
572
573    // Strip trailing `?` (optional params in TS)
574    let name = name.trim_end_matches('?');
575
576    // For Go, the name might be just `name Type` — take the first word
577    if lang == LangId::Go && !name.contains(' ') {
578        return name.to_string();
579    }
580    if lang == LangId::Go {
581        return name.split_whitespace().next().unwrap_or("").to_string();
582    }
583
584    name.to_string()
585}
586
587// ---------------------------------------------------------------------------
588// CallGraph
589// ---------------------------------------------------------------------------
590
591/// Worktree-scoped call graph with lazy per-file construction.
592///
593/// Files are parsed and analyzed on first access, then cached. The graph
594/// can resolve cross-file call edges using the import engine.
595pub struct CallGraph {
596    /// Cached per-file call data.
597    data: HashMap<PathBuf, FileCallData>,
598    /// Project root for relative path resolution.
599    project_root: PathBuf,
600}
601
602impl CallGraph {
603    /// Create a new call graph for a project.
604    pub fn new(project_root: PathBuf) -> Self {
605        clear_workspace_package_cache();
606        Self {
607            data: HashMap::new(),
608            project_root,
609        }
610    }
611
612    /// Get the project root directory.
613    pub fn project_root(&self) -> &Path {
614        &self.project_root
615    }
616
617    fn resolve_cross_file_edge_with_exports<F, D>(
618        full_callee: &str,
619        short_name: &str,
620        caller_file: &Path,
621        import_block: &ImportBlock,
622        mut file_exports_symbol: F,
623        mut file_default_export_symbol: D,
624    ) -> EdgeResolution
625    where
626        F: FnMut(&Path, &str) -> bool,
627        D: FnMut(&Path) -> Option<String>,
628    {
629        let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
630
631        // Rust uses `::` module paths rather than JS/TS specifiers. Keep this
632        // branch gated to `.rs` callers so the existing JS/TS resolver below
633        // remains unchanged.
634        if is_rust_source_file(caller_file) {
635            if let Some(target) = resolve_rust_cross_file_edge(
636                full_callee,
637                short_name,
638                caller_file,
639                import_block,
640                &mut file_exports_symbol,
641            ) {
642                return EdgeResolution::Resolved {
643                    file: target.file,
644                    symbol: target.symbol,
645                };
646            }
647        }
648
649        // Check namespace imports: "utils.foo" where utils is a namespace import
650        if full_callee.contains('.') {
651            let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
652            if parts.len() == 2 {
653                let namespace = parts[0];
654                let member = parts[1];
655
656                for imp in &import_block.imports {
657                    if imp.namespace_import.as_deref() == Some(namespace) {
658                        if let Some(resolved_path) =
659                            resolve_module_path(caller_dir, &imp.module_path)
660                        {
661                            if let Some(target) = resolve_reexported_symbol(
662                                &resolved_path,
663                                member,
664                                &mut file_exports_symbol,
665                                &mut file_default_export_symbol,
666                            ) {
667                                return EdgeResolution::Resolved {
668                                    file: target.file,
669                                    symbol: target.symbol,
670                                };
671                            }
672                        }
673                    }
674                }
675            }
676        }
677
678        // Check named imports (direct and aliased)
679        for imp in &import_block.imports {
680            // Direct named import: import { foo } from './utils'
681            if imp.names.iter().any(|name| name == short_name) {
682                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
683                    let target = resolve_reexported_symbol(
684                        &resolved_path,
685                        short_name,
686                        &mut file_exports_symbol,
687                        &mut file_default_export_symbol,
688                    )
689                    .unwrap_or(ResolvedSymbol {
690                        file: resolved_path,
691                        symbol: short_name.to_owned(),
692                    });
693                    return EdgeResolution::Resolved {
694                        file: target.file,
695                        symbol: target.symbol,
696                    };
697                }
698            }
699
700            // Default import: import foo from './utils'
701            if imp.default_import.as_deref() == Some(short_name) {
702                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
703                    let target = resolve_reexported_symbol(
704                        &resolved_path,
705                        "default",
706                        &mut file_exports_symbol,
707                        &mut file_default_export_symbol,
708                    )
709                    .unwrap_or_else(|| ResolvedSymbol {
710                        symbol: file_default_export_symbol(&resolved_path)
711                            .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
712                        file: resolved_path,
713                    });
714                    return EdgeResolution::Resolved {
715                        file: target.file,
716                        symbol: target.symbol,
717                    };
718                }
719            }
720        }
721
722        // Check aliased imports by examining the raw import text.
723        // ImportStatement.names stores the original name (foo), but the local code
724        // uses the alias (bar). We need to parse `import { foo as bar }` to find
725        // that `bar` maps to `foo`.
726        if let Some((original_name, resolved_path)) =
727            resolve_aliased_import(short_name, import_block, caller_dir)
728        {
729            let target = resolve_reexported_symbol(
730                &resolved_path,
731                &original_name,
732                &mut file_exports_symbol,
733                &mut file_default_export_symbol,
734            )
735            .unwrap_or(ResolvedSymbol {
736                file: resolved_path,
737                symbol: original_name,
738            });
739            return EdgeResolution::Resolved {
740                file: target.file,
741                symbol: target.symbol,
742            };
743        }
744
745        // Try barrel file re-exports: if any import points to an index file,
746        // check if that file re-exports the symbol
747        for imp in &import_block.imports {
748            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
749                // Check if the resolved path is a directory (barrel file)
750                if resolved_path.is_dir() {
751                    if let Some(index_path) = find_index_file(&resolved_path) {
752                        // Check if the index file exports this symbol
753                        if file_exports_symbol(&index_path, short_name) {
754                            return EdgeResolution::Resolved {
755                                file: index_path,
756                                symbol: short_name.to_owned(),
757                            };
758                        }
759                    }
760                } else if file_exports_symbol(&resolved_path, short_name) {
761                    return EdgeResolution::Resolved {
762                        file: resolved_path,
763                        symbol: short_name.to_owned(),
764                    };
765                }
766            }
767        }
768
769        EdgeResolution::Unresolved {
770            callee_name: short_name.to_owned(),
771        }
772    }
773
774    /// Get or build the call data for a file.
775    pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
776        let canon = self.canonicalize(path)?;
777
778        if !self.data.contains_key(&canon) {
779            let file_data = build_file_data(&canon)?;
780            self.data.insert(canon.clone(), file_data);
781        }
782
783        Ok(&self.data[&canon])
784    }
785
786    /// Resolve a cross-file call edge.
787    ///
788    /// Given a callee expression and the calling file's import block,
789    /// determines which file and symbol the call targets.
790    pub fn resolve_cross_file_edge(
791        &mut self,
792        full_callee: &str,
793        short_name: &str,
794        caller_file: &Path,
795        import_block: &ImportBlock,
796    ) -> EdgeResolution {
797        let graph = RefCell::new(self);
798        Self::resolve_cross_file_edge_with_exports(
799            full_callee,
800            short_name,
801            caller_file,
802            import_block,
803            |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
804            |path| graph.borrow_mut().file_default_export_symbol(path),
805        )
806    }
807
808    /// Check if a file exports a given symbol name.
809    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
810        match self.build_file(path) {
811            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
812            Err(_) => false,
813        }
814    }
815
816    fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
817        self.build_file(path)
818            .ok()
819            .and_then(|data| data.default_export_symbol.clone())
820    }
821
822    /// Invalidate a file by removing its cached call data.
823    pub fn invalidate_file(&mut self, path: &Path) {
824        // Remove from data cache (try both as-is and canonicalized)
825        self.data.remove(path);
826        if let Ok(canon) = self.canonicalize(path) {
827            self.data.remove(&canon);
828        }
829        clear_workspace_package_cache();
830    }
831
832    /// Canonicalize a path, falling back to the original if canonicalization fails.
833    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
834        // If the path is relative, resolve it against project_root
835        let full_path = if path.is_relative() {
836            self.project_root.join(path)
837        } else {
838            path.to_path_buf()
839        };
840
841        // Try canonicalize, fall back to the full path
842        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
843    }
844}
845
846// ---------------------------------------------------------------------------
847// File-level building
848// ---------------------------------------------------------------------------
849
850/// Build call data for a single file.
851pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
852    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
853        message: format!("unsupported file for call graph: {}", path.display()),
854    })?;
855
856    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
857        path: format!("{}: {}", path.display(), e),
858    })?;
859
860    build_file_data_from_source_with_lang(path, &source, lang)
861}
862
863pub(crate) fn build_file_data_from_source(
864    path: &Path,
865    source: &str,
866) -> Result<FileCallData, AftError> {
867    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
868        message: format!("unsupported file for call graph: {}", path.display()),
869    })?;
870    build_file_data_from_source_with_lang(path, source, lang)
871}
872
873#[derive(Debug)]
874struct SymbolCallRange {
875    symbol_index: usize,
876    byte_start: usize,
877    byte_end: usize,
878}
879
880struct SourceLineIndex {
881    bounds: Vec<(usize, usize)>,
882    source_len: usize,
883}
884
885impl SourceLineIndex {
886    fn new(source: &str) -> Self {
887        let bytes = source.as_bytes();
888        let mut bounds = Vec::new();
889        let mut line_start = 0usize;
890        let mut index = 0usize;
891
892        while index < bytes.len() {
893            match bytes[index] {
894                b'\r' => {
895                    bounds.push((line_start, index));
896                    index += if bytes.get(index + 1) == Some(&b'\n') {
897                        2
898                    } else {
899                        1
900                    };
901                    line_start = index;
902                }
903                b'\n' => {
904                    bounds.push((line_start, index));
905                    index += 1;
906                    line_start = index;
907                }
908                _ => index += 1,
909            }
910        }
911        bounds.push((line_start, bytes.len()));
912
913        Self {
914            bounds,
915            source_len: bytes.len(),
916        }
917    }
918
919    fn byte_offset(&self, line: u32, column: u32) -> usize {
920        let Some(&(line_start, line_end)) = self.bounds.get(line as usize) else {
921            return self.source_len;
922        };
923        line_start + (column as usize).min(line_end.saturating_sub(line_start))
924    }
925}
926
927fn collect_calls_by_symbol(
928    source: &str,
929    root: Node<'_>,
930    lang: LangId,
931    symbols: &[Symbol],
932) -> HashMap<String, Vec<CallSite>> {
933    let line_index = SourceLineIndex::new(source);
934    let mut ranges = symbols
935        .iter()
936        .enumerate()
937        .map(|(symbol_index, symbol)| SymbolCallRange {
938            symbol_index,
939            byte_start: line_index.byte_offset(symbol.range.start_line, symbol.range.start_col),
940            byte_end: line_index.byte_offset(symbol.range.end_line, symbol.range.end_col),
941        })
942        .collect::<Vec<_>>();
943    ranges.sort_by(|left, right| {
944        left.byte_start
945            .cmp(&right.byte_start)
946            .then_with(|| left.symbol_index.cmp(&right.symbol_index))
947    });
948
949    let mut sites_by_symbol = vec![Vec::new(); symbols.len()];
950    let mut top_level_sites = Vec::new();
951    let mut active_ranges = Vec::<usize>::new();
952    let mut next_range = 0usize;
953
954    for (full, short, line, byte_start, byte_end) in
955        extract_calls_full(source, root, 0, source.len(), lang)
956    {
957        // AST preorder gives nondecreasing call starts. Expire by start only:
958        // an outer call can end past a range that still contains a nested call.
959        active_ranges.retain(|range_index| ranges[*range_index].byte_end > byte_start);
960        while next_range < ranges.len() && ranges[next_range].byte_start <= byte_start {
961            if ranges[next_range].byte_end > byte_start {
962                active_ranges.push(next_range);
963            }
964            next_range += 1;
965        }
966
967        let site = CallSite {
968            callee_name: short,
969            full_callee: full,
970            line,
971            byte_start,
972            byte_end,
973        };
974        let mut attributed = false;
975        for range_index in &active_ranges {
976            let range = &ranges[*range_index];
977            if byte_end <= range.byte_end {
978                sites_by_symbol[range.symbol_index].push(site.clone());
979                attributed = true;
980            }
981        }
982        if !attributed {
983            top_level_sites.push(site);
984        }
985    }
986
987    let mut calls_by_symbol = HashMap::new();
988    for (symbol, sites) in symbols.iter().zip(sites_by_symbol) {
989        if !sites.is_empty() {
990            calls_by_symbol.insert(symbol_identity(symbol), sites);
991        }
992    }
993    if !top_level_sites.is_empty() {
994        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
995    }
996    calls_by_symbol
997}
998
999fn build_file_data_from_source_with_lang(
1000    path: &Path,
1001    source: &str,
1002    lang: LangId,
1003) -> Result<FileCallData, AftError> {
1004    let grammar = grammar_for(lang);
1005    let mut parser = Parser::new();
1006    parser
1007        .set_language(&grammar)
1008        .map_err(|e| AftError::ParseError {
1009            message: format!("grammar init failed for {:?}: {}", lang, e),
1010        })?;
1011
1012    let tree = parser
1013        .parse(&source, None)
1014        .ok_or_else(|| AftError::ParseError {
1015            message: format!("parse failed for {}", path.display()),
1016        })?;
1017
1018    // Parse imports
1019    let import_block = imports::parse_imports(&source, &tree, lang);
1020
1021    // Get symbols (for call site extraction and export detection)
1022    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
1023
1024    let root = tree.root_node();
1025    let mut calls_by_symbol = collect_calls_by_symbol(&source, root, lang, &symbols);
1026
1027    let default_export = find_default_export(&source, root, path, lang);
1028
1029    if let Some(default_export) = &default_export {
1030        if default_export.synthetic {
1031            let byte_start = default_export.node.byte_range().start;
1032            let byte_end = default_export.node.byte_range().end;
1033            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
1034            let sites: Vec<CallSite> = raw_calls
1035                .into_iter()
1036                .filter(|(_, short, _, _, _)| *short != default_export.symbol)
1037                .map(
1038                    |(full, short, line, call_byte_start, call_byte_end)| CallSite {
1039                        callee_name: short,
1040                        full_callee: full,
1041                        line,
1042                        byte_start: call_byte_start,
1043                        byte_end: call_byte_end,
1044                    },
1045                )
1046                .collect();
1047            if !sites.is_empty() {
1048                calls_by_symbol.insert(default_export.symbol.clone(), sites);
1049            }
1050        }
1051    }
1052
1053    // Collect exported symbol names
1054    let mut exported_symbols: Vec<String> = symbols
1055        .iter()
1056        .filter(|s| s.exported)
1057        .map(|s| s.name.clone())
1058        .collect();
1059    if let Some(default_export) = &default_export {
1060        if !exported_symbols
1061            .iter()
1062            .any(|name| name == &default_export.symbol)
1063        {
1064            exported_symbols.push(default_export.symbol.clone());
1065        }
1066    }
1067
1068    let rust_attribute_entry_points = if lang == LangId::Rust {
1069        crate::parser::rust_attribute_entry_points(&source, root)
1070            .into_iter()
1071            .map(|entry| (entry.scoped_name, entry.attribute.to_string()))
1072            .collect::<HashMap<_, _>>()
1073    } else {
1074        HashMap::new()
1075    };
1076
1077    // Build per-symbol metadata for entry point detection
1078    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
1079        .iter()
1080        .map(|s| {
1081            let identity = symbol_identity(s);
1082            (
1083                identity.clone(),
1084                SymbolMeta {
1085                    kind: s.kind.clone(),
1086                    exported: s.exported,
1087                    signature: s.signature.clone(),
1088                    line: s.range.start_line + 1,
1089                    range: s.range.clone(),
1090                    entry_point_attribute: rust_attribute_entry_points.get(&identity).cloned(),
1091                },
1092            )
1093        })
1094        .collect();
1095    if let Some(default_export) = &default_export {
1096        symbol_metadata
1097            .entry(default_export.symbol.clone())
1098            .or_insert_with(|| SymbolMeta {
1099                kind: default_export.kind.clone(),
1100                exported: true,
1101                signature: Some(first_line_signature(&source, &default_export.node)),
1102                line: default_export.node.start_position().row as u32 + 1,
1103                range: crate::parser::node_range(&default_export.node),
1104                entry_point_attribute: None,
1105            });
1106    }
1107    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
1108        symbol_metadata
1109            .entry(TOP_LEVEL_SYMBOL.to_string())
1110            .or_insert(SymbolMeta {
1111                kind: SymbolKind::Function,
1112                exported: false,
1113                signature: None,
1114                line: 1,
1115                range: Range {
1116                    start_line: 0,
1117                    start_col: 0,
1118                    end_line: 0,
1119                    end_col: 0,
1120                },
1121                entry_point_attribute: None,
1122            });
1123    }
1124
1125    Ok(FileCallData {
1126        calls_by_symbol,
1127        exported_symbols,
1128        symbol_metadata,
1129        default_export_symbol: default_export.map(|export| export.symbol),
1130        import_block,
1131        lang,
1132    })
1133}
1134
1135#[derive(Debug, Clone)]
1136struct DefaultExport<'tree> {
1137    symbol: String,
1138    synthetic: bool,
1139    kind: SymbolKind,
1140    node: Node<'tree>,
1141}
1142
1143fn find_default_export<'tree>(
1144    source: &str,
1145    root: Node<'tree>,
1146    path: &Path,
1147    lang: LangId,
1148) -> Option<DefaultExport<'tree>> {
1149    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
1150        return None;
1151    }
1152    find_default_export_inner(source, root, path)
1153}
1154
1155fn find_default_export_inner<'tree>(
1156    source: &str,
1157    node: Node<'tree>,
1158    path: &Path,
1159) -> Option<DefaultExport<'tree>> {
1160    if node.kind() == "export_statement" {
1161        if let Some(default_export) = default_export_from_statement(source, node, path) {
1162            return Some(default_export);
1163        }
1164    }
1165
1166    let mut cursor = node.walk();
1167    if !cursor.goto_first_child() {
1168        return None;
1169    }
1170
1171    loop {
1172        let child = cursor.node();
1173        if let Some(default_export) = find_default_export_inner(source, child, path) {
1174            return Some(default_export);
1175        }
1176        if !cursor.goto_next_sibling() {
1177            break;
1178        }
1179    }
1180
1181    None
1182}
1183
1184fn default_export_from_statement<'tree>(
1185    source: &str,
1186    node: Node<'tree>,
1187    path: &Path,
1188) -> Option<DefaultExport<'tree>> {
1189    let mut cursor = node.walk();
1190    if !cursor.goto_first_child() {
1191        return None;
1192    }
1193
1194    let mut saw_default = false;
1195    loop {
1196        let child = cursor.node();
1197        match child.kind() {
1198            "default" => saw_default = true,
1199            "function_declaration" | "generator_function_declaration" | "class_declaration"
1200                if saw_default =>
1201            {
1202                if let Some(name_node) = child.child_by_field_name("name") {
1203                    return Some(DefaultExport {
1204                        symbol: source[name_node.byte_range()].to_string(),
1205                        synthetic: false,
1206                        kind: default_export_kind(&child),
1207                        node: child,
1208                    });
1209                }
1210                return Some(DefaultExport {
1211                    symbol: synthetic_default_symbol(path),
1212                    synthetic: true,
1213                    kind: default_export_kind(&child),
1214                    node: child,
1215                });
1216            }
1217            "arrow_function"
1218            | "function"
1219            | "function_expression"
1220            | "class"
1221            | "class_expression"
1222                if saw_default =>
1223            {
1224                return Some(DefaultExport {
1225                    symbol: synthetic_default_symbol(path),
1226                    synthetic: true,
1227                    kind: default_export_kind(&child),
1228                    node: child,
1229                });
1230            }
1231            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
1232                return Some(DefaultExport {
1233                    symbol: source[child.byte_range()].to_string(),
1234                    synthetic: false,
1235                    kind: SymbolKind::Function,
1236                    node: child,
1237                });
1238            }
1239            _ => {}
1240        }
1241        if !cursor.goto_next_sibling() {
1242            break;
1243        }
1244    }
1245
1246    None
1247}
1248
1249fn default_export_kind(node: &Node) -> SymbolKind {
1250    if node.kind().contains("class") {
1251        SymbolKind::Class
1252    } else {
1253        SymbolKind::Function
1254    }
1255}
1256
1257fn synthetic_default_symbol(path: &Path) -> String {
1258    let file_name = path
1259        .file_name()
1260        .and_then(|name| name.to_str())
1261        .unwrap_or("unknown");
1262    format!("<default:{file_name}>")
1263}
1264
1265fn first_line_signature(source: &str, node: &Node) -> String {
1266    let text = &source[node.byte_range()];
1267    let first_line = text.lines().next().unwrap_or(text);
1268    first_line
1269        .trim_end()
1270        .trim_end_matches('{')
1271        .trim_end()
1272        .to_string()
1273}
1274
1275fn node_text(node: tree_sitter::Node, source: &str) -> String {
1276    source[node.start_byte()..node.end_byte()].to_string()
1277}
1278
1279/// Find a direct child node by kind name.
1280fn find_child_by_kind<'a>(
1281    node: tree_sitter::Node<'a>,
1282    kind: &str,
1283) -> Option<tree_sitter::Node<'a>> {
1284    let mut cursor = node.walk();
1285    if cursor.goto_first_child() {
1286        loop {
1287            if cursor.node().kind() == kind {
1288                return Some(cursor.node());
1289            }
1290            if !cursor.goto_next_sibling() {
1291                break;
1292            }
1293        }
1294    }
1295    None
1296}
1297
1298#[cfg(test)]
1299#[derive(Debug, Clone)]
1300struct CallSiteWithRange {
1301    full: String,
1302    short: String,
1303    line: u32,
1304    byte_start: usize,
1305    byte_end: usize,
1306}
1307
1308#[cfg(test)]
1309fn collect_calls_full_with_ranges(
1310    root: tree_sitter::Node,
1311    source: &str,
1312    byte_start: usize,
1313    byte_end: usize,
1314    lang: LangId,
1315) -> Vec<CallSiteWithRange> {
1316    let mut results = Vec::new();
1317    let call_kinds = call_node_kinds(lang);
1318    collect_calls_full_with_ranges_inner(
1319        root,
1320        source,
1321        byte_start,
1322        byte_end,
1323        &call_kinds,
1324        &mut results,
1325    );
1326    results
1327}
1328
1329#[cfg(test)]
1330fn collect_calls_full_with_ranges_inner(
1331    node: tree_sitter::Node,
1332    source: &str,
1333    byte_start: usize,
1334    byte_end: usize,
1335    call_kinds: &[&str],
1336    results: &mut Vec<CallSiteWithRange>,
1337) {
1338    let node_start = node.start_byte();
1339    let node_end = node.end_byte();
1340
1341    if node_end <= byte_start || node_start >= byte_end {
1342        return;
1343    }
1344
1345    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
1346        if let (Some(full), Some(short)) = (
1347            extract_full_callee(&node, source),
1348            extract_callee_name(&node, source),
1349        ) {
1350            results.push(CallSiteWithRange {
1351                full,
1352                short,
1353                line: node.start_position().row as u32 + 1,
1354                byte_start: node_start,
1355                byte_end: node_end,
1356            });
1357        }
1358    }
1359
1360    let mut cursor = node.walk();
1361    if cursor.goto_first_child() {
1362        loop {
1363            collect_calls_full_with_ranges_inner(
1364                cursor.node(),
1365                source,
1366                byte_start,
1367                byte_end,
1368                call_kinds,
1369                results,
1370            );
1371            if !cursor.goto_next_sibling() {
1372                break;
1373            }
1374        }
1375    }
1376}
1377
1378// ---------------------------------------------------------------------------
1379// Module path resolution
1380// ---------------------------------------------------------------------------
1381
1382/// Resolve a module path (e.g. './utils') relative to a directory.
1383///
1384/// Tries common file extensions for TypeScript/JavaScript projects.
1385pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1386    if module_path.starts_with('.') {
1387        return resolve_relative_module_path(from_dir, module_path);
1388    }
1389
1390    if module_path.starts_with('/') {
1391        return None;
1392    }
1393
1394    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
1395        return Some(path);
1396    }
1397
1398    resolve_workspace_module_path(from_dir, module_path)
1399}
1400
1401fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1402    let base = from_dir.join(module_path);
1403    resolve_file_like_path(&base)
1404}
1405
1406fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
1407    let base = base.to_path_buf();
1408
1409    // Try exact path first
1410    if base.is_file() {
1411        return Some(std::fs::canonicalize(&base).unwrap_or(base));
1412    }
1413
1414    // Try common extensions, including ESM/CJS TypeScript pairs used by workspaces.
1415    for ext in JS_TS_EXTENSIONS {
1416        let with_ext = base.with_extension(ext);
1417        if with_ext.is_file() {
1418            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
1419        }
1420    }
1421
1422    // Try as directory with index file
1423    if base.is_dir() {
1424        if let Some(index) = find_index_file(&base) {
1425            return Some(index);
1426        }
1427    }
1428
1429    None
1430}
1431
1432fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1433    let (package_name, subpath) = split_package_import(module_path)?;
1434    let package_root = find_package_root_for_import(from_dir, &package_name)?;
1435    resolve_package_entry(&package_root, &subpath)
1436}
1437
1438fn is_rust_source_file(path: &Path) -> bool {
1439    path.extension().and_then(|ext| ext.to_str()) == Some("rs")
1440}
1441
1442fn resolve_rust_cross_file_edge<F>(
1443    full_callee: &str,
1444    short_name: &str,
1445    caller_file: &Path,
1446    import_block: &ImportBlock,
1447    file_exports_symbol: &mut F,
1448) -> Option<ResolvedSymbol>
1449where
1450    F: FnMut(&Path, &str) -> bool,
1451{
1452    if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
1453    {
1454        return Some(target);
1455    }
1456
1457    resolve_rust_imported_call(
1458        caller_file,
1459        full_callee,
1460        short_name,
1461        import_block,
1462        file_exports_symbol,
1463    )
1464}
1465
1466fn resolve_rust_qualified_call<F>(
1467    caller_file: &Path,
1468    full_callee: &str,
1469    file_exports_symbol: &mut F,
1470) -> Option<ResolvedSymbol>
1471where
1472    F: FnMut(&Path, &str) -> bool,
1473{
1474    if !full_callee.contains("::") {
1475        return None;
1476    }
1477
1478    let segments = rust_path_segments(full_callee)?;
1479    resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
1480}
1481
1482fn resolve_rust_imported_call<F>(
1483    caller_file: &Path,
1484    full_callee: &str,
1485    short_name: &str,
1486    import_block: &ImportBlock,
1487    file_exports_symbol: &mut F,
1488) -> Option<ResolvedSymbol>
1489where
1490    F: FnMut(&Path, &str) -> bool,
1491{
1492    let call_segments = rust_path_segments(full_callee).unwrap_or_default();
1493    let bare_call_name = if call_segments.len() <= 1 {
1494        call_segments
1495            .first()
1496            .map(String::as_str)
1497            .unwrap_or(short_name)
1498    } else {
1499        short_name
1500    };
1501
1502    for imp in &import_block.imports {
1503        for entry in rust_use_entries(imp) {
1504            match &entry.kind {
1505                RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
1506                    if entry.local_name != bare_call_name {
1507                        continue;
1508                    }
1509                    let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
1510                    else {
1511                        continue;
1512                    };
1513                    if file_exports_symbol(&file, imported_name) {
1514                        return Some(ResolvedSymbol {
1515                            file,
1516                            symbol: imported_name.clone(),
1517                        });
1518                    }
1519                }
1520                RustUseKind::Module if call_segments.len() >= 2 => {
1521                    if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
1522                    {
1523                        continue;
1524                    }
1525                    let symbol = call_segments.last()?.clone();
1526                    let mut module_path = entry.module_path.clone();
1527                    for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
1528                        module_path.push_str("::");
1529                        module_path.push_str(segment);
1530                    }
1531                    let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
1532                        continue;
1533                    };
1534                    if file_exports_symbol(&file, &symbol) {
1535                        return Some(ResolvedSymbol { file, symbol });
1536                    }
1537                }
1538                _ => {}
1539            }
1540        }
1541    }
1542
1543    None
1544}
1545
1546fn resolve_rust_call_segments<F>(
1547    caller_file: &Path,
1548    segments: &[String],
1549    file_exports_symbol: &mut F,
1550) -> Option<ResolvedSymbol>
1551where
1552    F: FnMut(&Path, &str) -> bool,
1553{
1554    if segments.len() < 2 {
1555        return None;
1556    }
1557
1558    let symbol = segments.last()?.clone();
1559    let module_path = segments[..segments.len() - 1].join("::");
1560    let file = resolve_rust_module_path(caller_file, &module_path)?;
1561    if file_exports_symbol(&file, &symbol) {
1562        Some(ResolvedSymbol { file, symbol })
1563    } else {
1564        None
1565    }
1566}
1567
1568fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
1569    let segments = rust_path_segments(module_path)?;
1570    let first = segments.first()?.as_str();
1571
1572    match first {
1573        "std" | "core" | "alloc" => None,
1574        "crate" => {
1575            let crate_root = find_rust_crate_root(caller_file)?;
1576            let crate_info = rust_crate_info(&crate_root)?;
1577            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1578            resolve_rust_module_segments(&base, &segments[1..])
1579        }
1580        "self" => {
1581            let crate_root = find_rust_crate_root(caller_file)?;
1582            let crate_info = rust_crate_info(&crate_root)?;
1583            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1584            if segments.len() == 1 {
1585                return Some(canonicalize_path(caller_file));
1586            }
1587            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1588            target_segments.extend(segments[1..].iter().cloned());
1589            resolve_rust_module_segments(&base, &target_segments)
1590        }
1591        "super" => {
1592            let crate_root = find_rust_crate_root(caller_file)?;
1593            let crate_info = rust_crate_info(&crate_root)?;
1594            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1595            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1596            target_segments.pop();
1597            target_segments.extend(segments[1..].iter().cloned());
1598            resolve_rust_module_segments(&base, &target_segments)
1599        }
1600        crate_name => {
1601            let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
1602            let workspace_crates = rust_workspace_crates(caller_dir)?;
1603            let crate_info = workspace_crates.get(crate_name)?;
1604            let base = rust_lib_module_base(crate_info)?;
1605            resolve_rust_module_segments(&base, &segments[1..])
1606        }
1607    }
1608}
1609
1610fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
1611    let Some(body) = rust_use_body(&imp.raw_text) else {
1612        return Vec::new();
1613    };
1614    let mut entries = Vec::new();
1615    expand_rust_use_tree(body, &mut entries);
1616    entries
1617}
1618
1619fn rust_use_body(raw: &str) -> Option<&str> {
1620    let use_pos = raw.find("use ")?;
1621    let body = raw[use_pos + 4..].trim();
1622    let body = body.strip_suffix(';').unwrap_or(body).trim();
1623    (!body.is_empty()).then_some(body)
1624}
1625
1626fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
1627    let path = path.trim();
1628    if path.is_empty() {
1629        return;
1630    }
1631
1632    if let Some((prefix, inner)) = split_rust_use_braces(path) {
1633        let prefix = prefix.trim().trim_end_matches("::").trim();
1634        for part in split_top_level_commas(inner) {
1635            let part = part.trim();
1636            if part.is_empty() {
1637                continue;
1638            }
1639            if part == "self" {
1640                if let Some(local_name) = rust_last_path_segment(prefix) {
1641                    entries.push(RustUseEntry {
1642                        module_path: prefix.to_string(),
1643                        local_name,
1644                        kind: RustUseKind::Module,
1645                    });
1646                }
1647                continue;
1648            }
1649            let combined = if prefix.is_empty() {
1650                part.to_string()
1651            } else {
1652                format!("{prefix}::{part}")
1653            };
1654            expand_rust_use_tree(&combined, entries);
1655        }
1656        return;
1657    }
1658
1659    add_rust_use_leaf(path, entries);
1660}
1661
1662fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
1663    let mut depth = 0usize;
1664    let mut start = None;
1665    for (idx, ch) in path.char_indices() {
1666        match ch {
1667            '{' => {
1668                if depth == 0 {
1669                    start = Some(idx);
1670                }
1671                depth += 1;
1672            }
1673            '}' => {
1674                depth = depth.checked_sub(1)?;
1675                if depth == 0 {
1676                    let start = start?;
1677                    if !path[idx + ch.len_utf8()..].trim().is_empty() {
1678                        return None;
1679                    }
1680                    return Some((&path[..start], &path[start + 1..idx]));
1681                }
1682            }
1683            _ => {}
1684        }
1685    }
1686    None
1687}
1688
1689fn split_top_level_commas(value: &str) -> Vec<&str> {
1690    let mut parts = Vec::new();
1691    let mut depth = 0usize;
1692    let mut start = 0usize;
1693    for (idx, ch) in value.char_indices() {
1694        match ch {
1695            '{' => depth += 1,
1696            '}' => depth = depth.saturating_sub(1),
1697            ',' if depth == 0 => {
1698                parts.push(&value[start..idx]);
1699                start = idx + ch.len_utf8();
1700            }
1701            _ => {}
1702        }
1703    }
1704    parts.push(&value[start..]);
1705    parts
1706}
1707
1708fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
1709    let (path, alias) = split_rust_alias(path);
1710    let Some(segments) = rust_path_segments(path) else {
1711        return;
1712    };
1713    if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
1714        return;
1715    }
1716
1717    let imported_name = segments.last().cloned().unwrap_or_default();
1718    let local_name = alias.unwrap_or(&imported_name).to_string();
1719    if segments.len() >= 2 {
1720        entries.push(RustUseEntry {
1721            module_path: segments[..segments.len() - 1].join("::"),
1722            local_name: local_name.clone(),
1723            kind: RustUseKind::Item {
1724                imported_name: imported_name.clone(),
1725            },
1726        });
1727    }
1728
1729    entries.push(RustUseEntry {
1730        module_path: segments.join("::"),
1731        local_name,
1732        kind: RustUseKind::Module,
1733    });
1734}
1735
1736fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
1737    if let Some(idx) = path.rfind(" as ") {
1738        let original = path[..idx].trim();
1739        let alias = path[idx + 4..].trim();
1740        if !original.is_empty() && !alias.is_empty() {
1741            return (original, Some(alias));
1742        }
1743    }
1744    (path.trim(), None)
1745}
1746
1747fn rust_path_segments(path: &str) -> Option<Vec<String>> {
1748    let path = path.trim().trim_end_matches(';').trim();
1749    if path.is_empty() || path.contains('{') || path.contains('}') {
1750        return None;
1751    }
1752
1753    let mut segments = Vec::new();
1754    for raw_segment in path.split("::") {
1755        let segment = raw_segment.trim();
1756        if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
1757            return None;
1758        }
1759        let segment = segment.strip_prefix("r#").unwrap_or(segment);
1760        if segment
1761            .chars()
1762            .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
1763        {
1764            return None;
1765        }
1766        segments.push(segment.to_string());
1767    }
1768
1769    (!segments.is_empty()).then_some(segments)
1770}
1771
1772fn rust_last_path_segment(path: &str) -> Option<String> {
1773    rust_path_segments(path)?.last().cloned()
1774}
1775
1776fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
1777    let mut current = if from.is_file() {
1778        from.parent()
1779    } else {
1780        Some(from)
1781    };
1782    while let Some(dir) = current {
1783        if dir.join("Cargo.toml").is_file() {
1784            return Some(canonicalize_path(dir));
1785        }
1786        current = dir.parent();
1787    }
1788    None
1789}
1790
1791fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1792    let root = canonicalize_path(crate_root);
1793    if let Some(cached) = RUST_CRATE_INFO_CACHE
1794        .read()
1795        .ok()
1796        .and_then(|cache| cache.get(&root).cloned())
1797    {
1798        return cached;
1799    }
1800
1801    let resolved = read_rust_crate_info(&root);
1802    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
1803        cache.insert(root, resolved.clone());
1804    }
1805    resolved
1806}
1807
1808fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1809    let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
1810    let package = cargo.get("package")?;
1811    let package_name = package.get("name")?.as_str()?;
1812    let lib_name = cargo
1813        .get("lib")
1814        .and_then(|lib| lib.get("name"))
1815        .and_then(|name| name.as_str())
1816        .map(ToOwned::to_owned)
1817        .unwrap_or_else(|| package_name.replace('-', "_"));
1818
1819    let lib_root = cargo
1820        .get("lib")
1821        .and_then(|lib| lib.get("path"))
1822        .and_then(|path| path.as_str())
1823        .map(|path| crate_root.join(path))
1824        .unwrap_or_else(|| crate_root.join("src/lib.rs"));
1825    let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
1826
1827    let main_root = crate_root.join("src/main.rs");
1828    let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
1829
1830    Some(RustCrateInfo {
1831        lib_name,
1832        lib_root,
1833        main_root,
1834    })
1835}
1836
1837fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
1838    let source = std::fs::read_to_string(path).ok()?;
1839    toml::from_str(&source).ok()
1840}
1841
1842fn rust_module_base_for_caller(
1843    crate_info: &RustCrateInfo,
1844    caller_file: &Path,
1845) -> Option<RustModuleBase> {
1846    let caller = canonicalize_path(caller_file);
1847    if crate_info.main_root.as_ref() == Some(&caller) {
1848        return rust_main_module_base(crate_info);
1849    }
1850    rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
1851}
1852
1853fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1854    let root_file = crate_info.lib_root.clone()?;
1855    let src_dir = root_file.parent()?.to_path_buf();
1856    Some(RustModuleBase { src_dir, root_file })
1857}
1858
1859fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1860    let root_file = crate_info.main_root.clone()?;
1861    let src_dir = root_file.parent()?.to_path_buf();
1862    Some(RustModuleBase { src_dir, root_file })
1863}
1864
1865fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
1866    if segments.is_empty() {
1867        return Some(base.root_file.clone());
1868    }
1869
1870    let module_base = segments
1871        .iter()
1872        .fold(base.src_dir.clone(), |path, segment| path.join(segment));
1873    let file_path = module_base.with_extension("rs");
1874    if file_path.is_file() {
1875        return Some(canonicalize_path(&file_path));
1876    }
1877
1878    let mod_path = module_base.join("mod.rs");
1879    if mod_path.is_file() {
1880        return Some(canonicalize_path(&mod_path));
1881    }
1882
1883    None
1884}
1885
1886fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
1887    let src_dir = canonicalize_path(src_dir);
1888    let file = canonicalize_path(file);
1889    let rel = file.strip_prefix(&src_dir).ok()?;
1890    let mut parts: Vec<String> = rel
1891        .components()
1892        .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
1893        .collect();
1894    if parts.is_empty() {
1895        return None;
1896    }
1897
1898    let last = parts.pop()?;
1899    if last == "lib.rs" || last == "main.rs" {
1900        return Some(Vec::new());
1901    }
1902    if last == "mod.rs" {
1903        return Some(parts);
1904    }
1905    let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
1906    parts.push(stem);
1907    Some(parts)
1908}
1909
1910fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
1911    let workspace_root =
1912        find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
1913    let workspace_root = canonicalize_path(&workspace_root);
1914
1915    if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
1916        .read()
1917        .ok()
1918        .and_then(|cache| cache.get(&workspace_root).cloned())
1919    {
1920        return Some(cached);
1921    }
1922
1923    let mut crates = HashMap::new();
1924    for member in rust_workspace_member_dirs(&workspace_root) {
1925        if let Some(info) = rust_crate_info(&member) {
1926            if info.lib_root.is_some() {
1927                crates.insert(info.lib_name.clone(), info);
1928            }
1929        }
1930    }
1931    if let Some(info) = rust_crate_info(&workspace_root) {
1932        if info.lib_root.is_some() {
1933            crates.insert(info.lib_name.clone(), info);
1934        }
1935    }
1936
1937    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
1938        cache.insert(workspace_root, crates.clone());
1939    }
1940    Some(crates)
1941}
1942
1943fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
1944    let mut current = Some(from_dir);
1945    while let Some(dir) = current {
1946        let cargo = dir.join("Cargo.toml");
1947        if rust_manifest_value(&cargo)
1948            .and_then(|value| value.get("workspace").cloned())
1949            .is_some()
1950        {
1951            return Some(canonicalize_path(dir));
1952        }
1953        current = dir.parent();
1954    }
1955    None
1956}
1957
1958fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
1959    let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
1960        return Vec::new();
1961    };
1962    let Some(members) = cargo
1963        .get("workspace")
1964        .and_then(|workspace| workspace.get("members"))
1965        .and_then(|members| members.as_array())
1966    else {
1967        return Vec::new();
1968    };
1969
1970    let mut dirs = Vec::new();
1971    for member in members.iter().filter_map(|member| member.as_str()) {
1972        dirs.extend(expand_rust_workspace_member(workspace_root, member));
1973    }
1974    dirs.sort();
1975    dirs.dedup();
1976    dirs
1977}
1978
1979fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
1980    let member = member.trim();
1981    if member.is_empty() {
1982        return Vec::new();
1983    }
1984
1985    if member.contains('*') || member.contains('?') || member.contains('[') {
1986        let pattern = workspace_root.join(member).to_string_lossy().to_string();
1987        return glob::glob(&pattern)
1988            .ok()
1989            .into_iter()
1990            .flatten()
1991            .filter_map(Result::ok)
1992            .filter(|path| path.join("Cargo.toml").is_file())
1993            .map(|path| canonicalize_path(&path))
1994            .collect();
1995    }
1996
1997    let path = workspace_root.join(member);
1998    if path.join("Cargo.toml").is_file() {
1999        vec![canonicalize_path(&path)]
2000    } else {
2001        Vec::new()
2002    }
2003}
2004
2005fn canonicalize_path(path: &Path) -> PathBuf {
2006    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2007}
2008
2009fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2010    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2011    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2012    let compiler_options = tsconfig.get("compilerOptions")?;
2013    let paths = compiler_options.get("paths")?.as_object()?;
2014    let base_url = compiler_options
2015        .get("baseUrl")
2016        .and_then(Value::as_str)
2017        .unwrap_or(".");
2018    let base_dir = tsconfig_dir.join(base_url);
2019
2020    for (alias, targets) in paths {
2021        let Some(capture) = ts_path_capture(alias, module_path) else {
2022            continue;
2023        };
2024        let Some(targets) = targets.as_array() else {
2025            continue;
2026        };
2027        for target in targets.iter().filter_map(Value::as_str) {
2028            let target = if target.contains('*') {
2029                target.replace('*', capture)
2030            } else {
2031                target.to_string()
2032            };
2033            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2034                return Some(path);
2035            }
2036        }
2037    }
2038
2039    None
2040}
2041
2042fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2043    let mut current = Some(from_dir);
2044    while let Some(dir) = current {
2045        if dir.join("tsconfig.json").is_file() {
2046            return Some(dir.to_path_buf());
2047        }
2048        current = dir.parent();
2049    }
2050    None
2051}
2052
2053fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2054    if let Some(star_index) = alias.find('*') {
2055        let (prefix, suffix_with_star) = alias.split_at(star_index);
2056        let suffix = &suffix_with_star[1..];
2057        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2058            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2059        }
2060        return None;
2061    }
2062
2063    (alias == module_path).then_some("")
2064}
2065
2066fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2067    let mut parts = module_path.split('/');
2068    let first = parts.next()?;
2069    if first.is_empty() {
2070        return None;
2071    }
2072
2073    if first.starts_with('@') {
2074        let second = parts.next()?;
2075        if second.is_empty() {
2076            return None;
2077        }
2078        let package_name = format!("{first}/{second}");
2079        let subpath = parts.collect::<Vec<_>>().join("/");
2080        let subpath = (!subpath.is_empty()).then_some(subpath);
2081        Some((package_name, subpath))
2082    } else {
2083        let package_name = first.to_string();
2084        let subpath = parts.collect::<Vec<_>>().join("/");
2085        let subpath = (!subpath.is_empty()).then_some(subpath);
2086        Some((package_name, subpath))
2087    }
2088}
2089
2090fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2091    let mut current = Some(from_dir);
2092    while let Some(dir) = current {
2093        if package_json_name(dir).as_deref() == Some(package_name) {
2094            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2095        }
2096        current = dir.parent();
2097    }
2098
2099    find_workspace_root(from_dir)
2100        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2101}
2102
2103fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2104    let mut current = Some(from_dir);
2105    while let Some(dir) = current {
2106        if is_workspace_root(dir) {
2107            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2108        }
2109        current = dir.parent();
2110    }
2111    None
2112}
2113
2114fn is_workspace_root(dir: &Path) -> bool {
2115    package_json_value(dir)
2116        .map(|value| !workspace_patterns(&value).is_empty())
2117        .unwrap_or(false)
2118        || !pnpm_workspace_patterns(dir).is_empty()
2119}
2120
2121pub(crate) fn clear_workspace_package_cache() {
2122    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2123        cache.clear();
2124    }
2125    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2126        cache.clear();
2127    }
2128    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2129        cache.clear();
2130    }
2131}
2132
2133fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2134    let workspace_root =
2135        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2136    let cache_key = (workspace_root.clone(), package_name.to_string());
2137
2138    if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2139        if let Some(cached) = cache.get(&cache_key) {
2140            return cached.clone();
2141        }
2142    }
2143
2144    let resolved = workspace_member_dirs(&workspace_root)
2145        .into_iter()
2146        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2147        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2148
2149    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2150        cache.insert(cache_key, resolved.clone());
2151    }
2152
2153    resolved
2154}
2155
2156fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2157    let mut patterns = package_json_value(workspace_root)
2158        .map(|package_json| workspace_patterns(&package_json))
2159        .unwrap_or_default();
2160    patterns.extend(pnpm_workspace_patterns(workspace_root));
2161
2162    expand_workspace_patterns(workspace_root, &patterns)
2163}
2164
2165fn workspace_patterns(package_json: &Value) -> Vec<String> {
2166    match package_json.get("workspaces") {
2167        Some(Value::Array(items)) => items
2168            .iter()
2169            .filter_map(non_empty_workspace_pattern)
2170            .collect(),
2171        Some(Value::Object(map)) => map
2172            .get("packages")
2173            .and_then(Value::as_array)
2174            .map(|items| {
2175                items
2176                    .iter()
2177                    .filter_map(non_empty_workspace_pattern)
2178                    .collect()
2179            })
2180            .unwrap_or_default(),
2181        _ => Vec::new(),
2182    }
2183}
2184
2185fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2186    let pattern = value.as_str()?.trim();
2187    (!pattern.is_empty()).then(|| pattern.to_string())
2188}
2189
2190fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2191    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2192        return Vec::new();
2193    };
2194
2195    let mut patterns = Vec::new();
2196    let mut in_packages = false;
2197    for line in source.lines() {
2198        let without_comment = line.split('#').next().unwrap_or("").trim_end();
2199        let trimmed = without_comment.trim();
2200        if trimmed.is_empty() {
2201            continue;
2202        }
2203        if trimmed == "packages:" {
2204            in_packages = true;
2205            continue;
2206        }
2207        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2208            in_packages = false;
2209        }
2210        if in_packages {
2211            if let Some(pattern) = trimmed.strip_prefix('-') {
2212                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2213                if !pattern.is_empty() {
2214                    patterns.push(pattern.to_string());
2215                }
2216            }
2217        }
2218    }
2219    patterns
2220}
2221
2222fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2223    let positive_patterns: Vec<&str> = patterns
2224        .iter()
2225        .map(|pattern| pattern.trim())
2226        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2227        .collect();
2228    if positive_patterns.is_empty() {
2229        return Vec::new();
2230    }
2231
2232    let positives = build_glob_set(&positive_patterns);
2233    let negative_patterns: Vec<&str> = patterns
2234        .iter()
2235        .map(|pattern| pattern.trim())
2236        .filter_map(|pattern| pattern.strip_prefix('!'))
2237        .map(str::trim)
2238        .filter(|pattern| !pattern.is_empty())
2239        .collect();
2240    let negatives = build_glob_set(&negative_patterns);
2241
2242    let mut members = Vec::new();
2243    collect_workspace_member_dirs(
2244        workspace_root,
2245        workspace_root,
2246        &positives,
2247        &negatives,
2248        &mut members,
2249    );
2250    members
2251}
2252
2253fn build_glob_set(patterns: &[&str]) -> GlobSet {
2254    let mut builder = GlobSetBuilder::new();
2255    for pattern in patterns {
2256        if let Ok(glob) = Glob::new(pattern) {
2257            builder.add(glob);
2258        }
2259    }
2260    builder
2261        .build()
2262        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
2263}
2264
2265fn collect_workspace_member_dirs(
2266    workspace_root: &Path,
2267    dir: &Path,
2268    positives: &GlobSet,
2269    negatives: &GlobSet,
2270    members: &mut Vec<PathBuf>,
2271) {
2272    let Ok(entries) = std::fs::read_dir(dir) else {
2273        return;
2274    };
2275
2276    for entry in entries.filter_map(Result::ok) {
2277        let path = entry.path();
2278        let Ok(file_type) = entry.file_type() else {
2279            continue;
2280        };
2281        if !file_type.is_dir() {
2282            continue;
2283        }
2284        let name = entry.file_name();
2285        let name = name.to_string_lossy();
2286        if matches!(
2287            name.as_ref(),
2288            "node_modules" | ".git" | "target" | "dist" | "build"
2289        ) {
2290            continue;
2291        }
2292
2293        if path.join("package.json").is_file() {
2294            if let Ok(rel) = path.strip_prefix(workspace_root) {
2295                let rel = rel.to_string_lossy().replace('\\', "/");
2296                if positives.is_match(&rel) && !negatives.is_match(&rel) {
2297                    members.push(path.clone());
2298                }
2299            }
2300        }
2301
2302        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
2303    }
2304}
2305
2306fn package_json_value(dir: &Path) -> Option<Value> {
2307    package_json_like_value(&dir.join("package.json"))
2308}
2309
2310fn package_json_like_value(path: &Path) -> Option<Value> {
2311    let json = std::fs::read_to_string(path).ok()?;
2312    serde_json::from_str(&json).ok()
2313}
2314
2315fn package_json_name(dir: &Path) -> Option<String> {
2316    package_json_value(dir)?
2317        .get("name")?
2318        .as_str()
2319        .map(ToOwned::to_owned)
2320}
2321
2322fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
2323    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
2324
2325    if let Some(exports) = package_json.get("exports") {
2326        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2327            if let Some(path) = resolve_package_target(package_root, &target) {
2328                return Some(path);
2329            }
2330        }
2331    }
2332
2333    if subpath.is_none() {
2334        for field in ["module", "main"] {
2335            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2336                if let Some(path) = resolve_package_target(package_root, target) {
2337                    return Some(path);
2338                }
2339            }
2340        }
2341    }
2342
2343    resolve_package_fallback(package_root, subpath.as_deref())
2344}
2345
2346fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2347    let key = subpath
2348        .map(|value| format!("./{value}"))
2349        .unwrap_or_else(|| ".".to_string());
2350
2351    match exports {
2352        Value::String(target) if key == "." => Some(target.clone()),
2353        Value::Object(map) => {
2354            if let Some(target) = map.get(&key).and_then(export_condition_target) {
2355                return Some(target);
2356            }
2357
2358            if let Some(target) = wildcard_export_target(map, &key) {
2359                return Some(target);
2360            }
2361
2362            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2363                return export_condition_target(exports);
2364            }
2365
2366            None
2367        }
2368        _ => None,
2369    }
2370}
2371
2372fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2373    for (pattern, target) in map {
2374        let Some(star_index) = pattern.find('*') else {
2375            continue;
2376        };
2377        let (prefix, suffix_with_star) = pattern.split_at(star_index);
2378        let suffix = &suffix_with_star[1..];
2379        if !key.starts_with(prefix) || !key.ends_with(suffix) {
2380            continue;
2381        }
2382        let matched = &key[prefix.len()..key.len() - suffix.len()];
2383        if let Some(target_pattern) = export_condition_target(target) {
2384            return Some(target_pattern.replace('*', matched));
2385        }
2386    }
2387    None
2388}
2389
2390fn export_condition_target(value: &Value) -> Option<String> {
2391    match value {
2392        Value::String(target) => Some(target.clone()),
2393        Value::Object(map) => ["source", "import", "module", "default", "types"]
2394            .into_iter()
2395            .find_map(|field| map.get(field).and_then(export_condition_target)),
2396        _ => None,
2397    }
2398}
2399
2400fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2401    let target = target.strip_prefix("./").unwrap_or(target);
2402    // Prefer source over compiled bundle when both exist: the callgraph
2403    // walks source files and cannot extract symbols from a built JS bundle.
2404    if let Some(src_relative) = target.strip_prefix("dist/") {
2405        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2406            return Some(path);
2407        }
2408    }
2409
2410    resolve_file_like_path(&package_root.join(target))
2411}
2412
2413fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2414    match subpath {
2415        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2416            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2417        None => resolve_file_like_path(&package_root.join("src").join("index"))
2418            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2419    }
2420}
2421
2422pub(crate) fn resolve_reexported_symbol_target<F, D>(
2423    file: &Path,
2424    symbol_name: &str,
2425    file_exports_symbol: &mut F,
2426    file_default_export_symbol: &mut D,
2427) -> Option<(PathBuf, String)>
2428where
2429    F: FnMut(&Path, &str) -> bool,
2430    D: FnMut(&Path) -> Option<String>,
2431{
2432    resolve_reexported_symbol(
2433        file,
2434        symbol_name,
2435        file_exports_symbol,
2436        file_default_export_symbol,
2437    )
2438    .map(|target| (target.file, target.symbol))
2439}
2440
2441fn resolve_reexported_symbol<F, D>(
2442    file: &Path,
2443    symbol_name: &str,
2444    file_exports_symbol: &mut F,
2445    file_default_export_symbol: &mut D,
2446) -> Option<ResolvedSymbol>
2447where
2448    F: FnMut(&Path, &str) -> bool,
2449    D: FnMut(&Path) -> Option<String>,
2450{
2451    let mut visited = HashSet::new();
2452    resolve_reexported_symbol_inner(
2453        file,
2454        symbol_name,
2455        file_exports_symbol,
2456        file_default_export_symbol,
2457        &mut visited,
2458    )
2459}
2460
2461fn resolve_reexported_symbol_inner<F, D>(
2462    file: &Path,
2463    symbol_name: &str,
2464    file_exports_symbol: &mut F,
2465    file_default_export_symbol: &mut D,
2466    visited: &mut HashSet<(PathBuf, String)>,
2467) -> Option<ResolvedSymbol>
2468where
2469    F: FnMut(&Path, &str) -> bool,
2470    D: FnMut(&Path) -> Option<String>,
2471{
2472    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2473    if !visited.insert((canon.clone(), symbol_name.to_string())) {
2474        return None;
2475    }
2476
2477    let source = std::fs::read_to_string(&canon).ok()?;
2478    let lang = detect_language(&canon)?;
2479    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2480        if symbol_name == "default" {
2481            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
2482                file: canon,
2483                symbol,
2484            });
2485        }
2486        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
2487            file: canon,
2488            symbol: symbol_name.to_string(),
2489        });
2490    }
2491
2492    let grammar = grammar_for(lang);
2493    let mut parser = Parser::new();
2494    parser.set_language(&grammar).ok()?;
2495    let tree = parser.parse(&source, None)?;
2496    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2497
2498    let mut cursor = tree.root_node().walk();
2499    if !cursor.goto_first_child() {
2500        return None;
2501    }
2502
2503    loop {
2504        let node = cursor.node();
2505        if node.kind() == "export_statement" {
2506            if let Some(target) = resolve_reexport_statement(
2507                &source,
2508                node,
2509                from_dir,
2510                symbol_name,
2511                file_exports_symbol,
2512                file_default_export_symbol,
2513                visited,
2514            ) {
2515                return Some(target);
2516            }
2517        }
2518
2519        if !cursor.goto_next_sibling() {
2520            break;
2521        }
2522    }
2523
2524    if symbol_name == "default" {
2525        if let Some(symbol) = file_default_export_symbol(&canon) {
2526            return Some(ResolvedSymbol {
2527                file: canon,
2528                symbol,
2529            });
2530        }
2531    }
2532
2533    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
2534        return Some(ResolvedSymbol {
2535            file: canon,
2536            symbol,
2537        });
2538    }
2539
2540    if file_exports_symbol(&canon, symbol_name) {
2541        let symbol = symbol_name.to_string();
2542        return Some(ResolvedSymbol {
2543            file: canon,
2544            symbol,
2545        });
2546    }
2547
2548    None
2549}
2550
2551fn resolve_reexport_statement<F, D>(
2552    source: &str,
2553    node: tree_sitter::Node,
2554    from_dir: &Path,
2555    symbol_name: &str,
2556    file_exports_symbol: &mut F,
2557    file_default_export_symbol: &mut D,
2558    visited: &mut HashSet<(PathBuf, String)>,
2559) -> Option<ResolvedSymbol>
2560where
2561    F: FnMut(&Path, &str) -> bool,
2562    D: FnMut(&Path) -> Option<String>,
2563{
2564    let source_node = node
2565        .child_by_field_name("source")
2566        .or_else(|| find_child_by_kind(node, "string"))?;
2567    let module_path = string_literal_content(source, source_node)?;
2568    let target_file = resolve_module_path(from_dir, &module_path)?;
2569    let raw_export = node_text(node, source);
2570
2571    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2572        return resolve_reexported_symbol_inner(
2573            &target_file,
2574            &source_symbol,
2575            file_exports_symbol,
2576            file_default_export_symbol,
2577            visited,
2578        )
2579        .or(Some(ResolvedSymbol {
2580            file: target_file,
2581            symbol: source_symbol,
2582        }));
2583    }
2584
2585    if raw_export.contains('*') {
2586        return resolve_reexported_symbol_inner(
2587            &target_file,
2588            symbol_name,
2589            file_exports_symbol,
2590            file_default_export_symbol,
2591            visited,
2592        );
2593    }
2594
2595    None
2596}
2597
2598fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
2599    let lang = detect_language(file)?;
2600    let grammar = grammar_for(lang);
2601    let mut parser = Parser::new();
2602    parser.set_language(&grammar).ok()?;
2603    let tree = parser.parse(source, None)?;
2604
2605    let mut cursor = tree.root_node().walk();
2606    if !cursor.goto_first_child() {
2607        return None;
2608    }
2609
2610    loop {
2611        let node = cursor.node();
2612        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
2613            let raw_export = node_text(node, source);
2614            if let Some(source_symbol) =
2615                reexport_clause_source_symbol(&raw_export, requested_export)
2616            {
2617                return Some(source_symbol);
2618            }
2619        }
2620
2621        if !cursor.goto_next_sibling() {
2622            break;
2623        }
2624    }
2625
2626    None
2627}
2628
2629fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
2630    let start = raw_export.find('{')? + 1;
2631    let end = raw_export[start..].find('}')? + start;
2632    for specifier in raw_export[start..end].split(',') {
2633        let specifier = specifier.trim();
2634        if specifier.is_empty() {
2635            continue;
2636        }
2637        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
2638        if let Some((imported, exported)) = specifier.split_once(" as ") {
2639            if exported.trim() == requested_export {
2640                return Some(imported.trim().to_string());
2641            }
2642        } else if specifier == requested_export {
2643            return Some(requested_export.to_string());
2644        }
2645    }
2646    None
2647}
2648
2649fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2650    let raw = source[node.byte_range()].trim();
2651    let quote = raw.chars().next()?;
2652    if quote != '\'' && quote != '"' {
2653        return None;
2654    }
2655    raw.strip_prefix(quote)
2656        .and_then(|value| value.strip_suffix(quote))
2657        .map(ToOwned::to_owned)
2658}
2659
2660/// Find an index file in a directory.
2661fn find_index_file(dir: &Path) -> Option<PathBuf> {
2662    for name in JS_TS_INDEX_FILES {
2663        let p = dir.join(name);
2664        if p.is_file() {
2665            return Some(std::fs::canonicalize(&p).unwrap_or(p));
2666        }
2667    }
2668    None
2669}
2670
2671/// Resolve an aliased import: `import { foo as bar } from './utils'`
2672/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
2673fn resolve_aliased_import(
2674    local_name: &str,
2675    import_block: &ImportBlock,
2676    caller_dir: &Path,
2677) -> Option<(String, PathBuf)> {
2678    for imp in &import_block.imports {
2679        // Parse the raw text to find "as <alias>" patterns
2680        // This handles: import { foo as bar, baz as qux } from './mod'
2681        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2682            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2683                return Some((original, resolved_path));
2684            }
2685        }
2686    }
2687    None
2688}
2689
2690/// Parse import raw text to find the original name for an alias.
2691/// Given raw text like `import { foo as bar, baz } from './utils'` and
2692/// local_name "bar", returns Some("foo").
2693fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2694    // Look for pattern: <original> as <alias>
2695    // This is a simple text-based search; handles the common TS/JS pattern
2696    let search = format!(" as {}", local_name);
2697    if let Some(pos) = raw_import.find(&search) {
2698        // Walk backwards from `pos` to find the original name
2699        let before = &raw_import[..pos];
2700        // The original name is the last word-like token before " as "
2701        let original = before
2702            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2703            .find(|s| !s.is_empty())?;
2704        return Some(original.to_string());
2705    }
2706    None
2707}
2708
2709// ---------------------------------------------------------------------------
2710// Worktree file discovery
2711// ---------------------------------------------------------------------------
2712
2713/// Walk project files respecting .gitignore, excluding common non-source dirs.
2714///
2715/// Returns an iterator of file paths for supported source file types.
2716pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2717    use ignore::WalkBuilder;
2718
2719    let walker = WalkBuilder::new(root)
2720        .hidden(true)         // skip hidden files/dirs
2721        .git_ignore(true)     // respect .gitignore
2722        .git_global(true)     // respect global gitignore
2723        .git_exclude(true)    // respect .git/info/exclude
2724        .add_custom_ignore_filename(".aftignore") // AFT-specific ignores (e.g. submodules)
2725        .filter_entry(|entry| {
2726            let name = entry.file_name().to_string_lossy();
2727            // Always exclude these directories regardless of .gitignore
2728            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2729                return !matches!(
2730                    name.as_ref(),
2731                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2732                        | ".tox" | "dist" | "build"
2733                );
2734            }
2735            true
2736        })
2737        .build();
2738
2739    walker
2740        .filter_map(|entry| entry.ok())
2741        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2742        .filter(|entry| detect_language(entry.path()).is_some())
2743        .map(|entry| entry.into_path())
2744}
2745
2746// ---------------------------------------------------------------------------
2747// Tests
2748// ---------------------------------------------------------------------------
2749
2750#[cfg(test)]
2751mod tests {
2752    use super::*;
2753    use std::fs;
2754    use tempfile::TempDir;
2755
2756    fn collect_calls_by_symbol_reference(
2757        source: &str,
2758        root: Node<'_>,
2759        lang: LangId,
2760        symbols: &[Symbol],
2761    ) -> HashMap<String, Vec<CallSite>> {
2762        let mut calls_by_symbol = HashMap::new();
2763        for symbol in symbols {
2764            let byte_start =
2765                line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col);
2766            let byte_end = line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col);
2767            let sites = extract_calls_full(source, root, byte_start, byte_end, lang)
2768                .into_iter()
2769                .map(
2770                    |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2771                        callee_name: short,
2772                        full_callee: full,
2773                        line,
2774                        byte_start: call_byte_start,
2775                        byte_end: call_byte_end,
2776                    },
2777                )
2778                .collect::<Vec<_>>();
2779            if !sites.is_empty() {
2780                calls_by_symbol.insert(symbol_identity(symbol), sites);
2781            }
2782        }
2783
2784        let symbol_ranges = symbols
2785            .iter()
2786            .map(|symbol| {
2787                (
2788                    line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col),
2789                    line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col),
2790                )
2791            })
2792            .collect::<Vec<_>>();
2793        let top_level_sites = collect_calls_full_with_ranges(root, source, 0, source.len(), lang)
2794            .into_iter()
2795            .filter(|site| {
2796                !symbol_ranges
2797                    .iter()
2798                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2799            })
2800            .map(|site| CallSite {
2801                callee_name: site.short,
2802                full_callee: site.full,
2803                line: site.line,
2804                byte_start: site.byte_start,
2805                byte_end: site.byte_end,
2806            })
2807            .collect::<Vec<_>>();
2808        if !top_level_sites.is_empty() {
2809            calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2810        }
2811        calls_by_symbol
2812    }
2813
2814    fn parse_symbols(source: &str, lang: LangId) -> (tree_sitter::Tree, Vec<Symbol>) {
2815        let mut parser = Parser::new();
2816        parser.set_language(&grammar_for(lang)).unwrap();
2817        let tree = parser.parse(source, None).unwrap();
2818        let symbols = crate::parser::extract_symbols_from_tree(source, &tree, lang).unwrap();
2819        (tree, symbols)
2820    }
2821
2822    fn test_symbol(name: &str, start_col: u32, end_col: u32) -> Symbol {
2823        Symbol {
2824            name: name.to_string(),
2825            kind: SymbolKind::Function,
2826            range: Range {
2827                start_line: 0,
2828                start_col,
2829                end_line: 0,
2830                end_col,
2831            },
2832            signature: None,
2833            scope_chain: Vec::new(),
2834            exported: false,
2835            parent: None,
2836        }
2837    }
2838
2839    #[test]
2840    fn source_line_index_matches_shared_line_column_conversion() {
2841        let source = "a\r\nbb\rc\n";
2842        let index = SourceLineIndex::new(source);
2843        for line in 0..=5 {
2844            for column in 0..=5 {
2845                assert_eq!(
2846                    index.byte_offset(line, column),
2847                    line_col_to_byte(source, line, column),
2848                    "line={line}, column={column}"
2849                );
2850            }
2851        }
2852    }
2853
2854    #[test]
2855    fn single_pass_call_attribution_matches_per_symbol_reference() {
2856        let corpora = [
2857            (
2858                "typescript",
2859                LangId::TypeScript,
2860                r#"bootstrap();
2861class Worker {
2862    run() {
2863        before();
2864        function nested() { nestedCall(); }
2865        nested();
2866    }
2867    next() { adjacentCall(); }
2868}
2869function left() { leftCall(); }
2870function right() { rightCall(); }
2871"#,
2872            ),
2873            (
2874                "python",
2875                LangId::Python,
2876                r#"bootstrap()
2877class Worker:
2878    def run(self):
2879        before()
2880        def nested():
2881            nested_call()
2882        nested()
2883
2884    def next(self):
2885        adjacent_call()
2886
2887def left():
2888    left_call()
2889
2890def right():
2891    right_call()
2892"#,
2893            ),
2894        ];
2895
2896        for (name, lang, source) in corpora {
2897            let (tree, symbols) = parse_symbols(source, lang);
2898            let reference =
2899                collect_calls_by_symbol_reference(source, tree.root_node(), lang, &symbols);
2900            let actual = collect_calls_by_symbol(source, tree.root_node(), lang, &symbols);
2901            assert_eq!(actual, reference, "call attribution changed for {name}");
2902
2903            let class_sites = actual.get("Worker").expect("class receives method calls");
2904            let method_sites = actual
2905                .get("Worker::run")
2906                .expect("method receives its own calls");
2907            assert!(class_sites.iter().any(|site| site.callee_name == "before"));
2908            assert!(method_sites.iter().any(|site| site.callee_name == "before"));
2909            let nested_sites = actual
2910                .iter()
2911                .find(|(symbol, _)| symbol.rsplit("::").next() == Some("nested"))
2912                .map(|(_, sites)| sites)
2913                .expect("nested function receives its own calls");
2914            assert!(nested_sites.iter().any(|site| {
2915                site.callee_name == "nested_call" || site.callee_name == "nestedCall"
2916            }));
2917            assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "bootstrap");
2918        }
2919
2920        let source = "first();second();third();";
2921        let (tree, _) = parse_symbols(source, LangId::TypeScript);
2922        let symbols = vec![
2923            test_symbol("outer", 0, 17),
2924            test_symbol("left", 0, 8),
2925            test_symbol("right", 8, 17),
2926            test_symbol("empty", 24, 24),
2927        ];
2928        let reference = collect_calls_by_symbol_reference(
2929            source,
2930            tree.root_node(),
2931            LangId::TypeScript,
2932            &symbols,
2933        );
2934        let actual =
2935            collect_calls_by_symbol(source, tree.root_node(), LangId::TypeScript, &symbols);
2936        assert_eq!(actual, reference, "overlapping and adjacent ranges changed");
2937        assert_eq!(
2938            actual["outer"]
2939                .iter()
2940                .map(|site| site.callee_name.as_str())
2941                .collect::<Vec<_>>(),
2942            ["first", "second"]
2943        );
2944        assert_eq!(actual["left"][0].callee_name, "first");
2945        assert_eq!(actual["right"][0].callee_name, "second");
2946        assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "third");
2947        assert!(!actual.contains_key("empty"));
2948    }
2949
2950    #[test]
2951    fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
2952        // exported_symbols carries the bare name; symbol_metadata is keyed by
2953        // scoped identity (impl method). A plain .get(bare) misses and would
2954        // force the degraded unknown/line-1 fallback. symbol_metadata_for must
2955        // recover the scoped entry via unqualified-name match.
2956        let mut symbol_metadata = HashMap::new();
2957        symbol_metadata.insert(
2958            "BackupStore::total_disk_bytes".to_string(),
2959            SymbolMeta {
2960                kind: SymbolKind::Method,
2961                exported: true,
2962                signature: None,
2963                line: 703,
2964                range: Range {
2965                    start_line: 702,
2966                    start_col: 0,
2967                    end_line: 705,
2968                    end_col: 0,
2969                },
2970                entry_point_attribute: None,
2971            },
2972        );
2973        let file_data = FileCallData {
2974            calls_by_symbol: HashMap::new(),
2975            exported_symbols: vec!["total_disk_bytes".to_string()],
2976            symbol_metadata,
2977            default_export_symbol: None,
2978            import_block: ImportBlock::empty(),
2979            lang: LangId::Rust,
2980        };
2981
2982        let meta = file_data
2983            .symbol_metadata_for("total_disk_bytes")
2984            .expect("scoped method recovered by bare name");
2985        assert_eq!(meta.kind, SymbolKind::Method);
2986        assert_eq!(
2987            meta.line, 703,
2988            "real declaration line, not the line-1 fallback"
2989        );
2990
2991        // A genuinely-absent symbol still returns None (no false recovery).
2992        assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
2993    }
2994
2995    /// Create a temp directory with TypeScript files for testing.
2996    fn setup_ts_project() -> TempDir {
2997        let dir = TempDir::new().unwrap();
2998
2999        // main.ts: imports from utils and calls functions
3000        fs::write(
3001            dir.path().join("main.ts"),
3002            r#"import { helper, compute } from './utils';
3003import * as math from './math';
3004
3005export function main() {
3006    const a = helper(1);
3007    const b = compute(a, 2);
3008    const c = math.add(a, b);
3009    return c;
3010}
3011"#,
3012        )
3013        .unwrap();
3014
3015        // utils.ts: defines helper and compute, imports from helpers
3016        fs::write(
3017            dir.path().join("utils.ts"),
3018            r#"import { double } from './helpers';
3019
3020export function helper(x: number): number {
3021    return double(x);
3022}
3023
3024export function compute(a: number, b: number): number {
3025    return a + b;
3026}
3027"#,
3028        )
3029        .unwrap();
3030
3031        // helpers.ts: defines double
3032        fs::write(
3033            dir.path().join("helpers.ts"),
3034            r#"export function double(x: number): number {
3035    return x * 2;
3036}
3037
3038export function triple(x: number): number {
3039    return x * 3;
3040}
3041"#,
3042        )
3043        .unwrap();
3044
3045        // math.ts: defines add (for namespace import test)
3046        fs::write(
3047            dir.path().join("math.ts"),
3048            r#"export function add(a: number, b: number): number {
3049    return a + b;
3050}
3051
3052export function subtract(a: number, b: number): number {
3053    return a - b;
3054}
3055"#,
3056        )
3057        .unwrap();
3058
3059        dir
3060    }
3061
3062    /// Create a project with import aliasing.
3063    fn setup_alias_project() -> TempDir {
3064        let dir = TempDir::new().unwrap();
3065
3066        fs::write(
3067            dir.path().join("main.ts"),
3068            r#"import { helper as h } from './utils';
3069
3070export function main() {
3071    return h(42);
3072}
3073"#,
3074        )
3075        .unwrap();
3076
3077        fs::write(
3078            dir.path().join("utils.ts"),
3079            r#"export function helper(x: number): number {
3080    return x + 1;
3081}
3082"#,
3083        )
3084        .unwrap();
3085
3086        dir
3087    }
3088
3089    // --- Single-file call extraction ---
3090
3091    #[test]
3092    fn callgraph_single_file_call_extraction() {
3093        let dir = setup_ts_project();
3094        let mut graph = CallGraph::new(dir.path().to_path_buf());
3095
3096        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3097        let main_calls = &file_data.calls_by_symbol["main"];
3098
3099        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3100        assert!(
3101            callee_names.contains(&"helper"),
3102            "main should call helper, got: {:?}",
3103            callee_names
3104        );
3105        assert!(
3106            callee_names.contains(&"compute"),
3107            "main should call compute, got: {:?}",
3108            callee_names
3109        );
3110        assert!(
3111            callee_names.contains(&"add"),
3112            "main should call math.add (short name: add), got: {:?}",
3113            callee_names
3114        );
3115    }
3116
3117    #[test]
3118    fn callgraph_file_data_has_exports() {
3119        let dir = setup_ts_project();
3120        let mut graph = CallGraph::new(dir.path().to_path_buf());
3121
3122        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3123        assert!(
3124            file_data.exported_symbols.contains(&"helper".to_string()),
3125            "utils.ts should export helper, got: {:?}",
3126            file_data.exported_symbols
3127        );
3128        assert!(
3129            file_data.exported_symbols.contains(&"compute".to_string()),
3130            "utils.ts should export compute, got: {:?}",
3131            file_data.exported_symbols
3132        );
3133    }
3134
3135    // --- Cross-file resolution ---
3136
3137    #[test]
3138    fn callgraph_resolve_direct_import() {
3139        let dir = setup_ts_project();
3140        let mut graph = CallGraph::new(dir.path().to_path_buf());
3141
3142        let main_path = dir.path().join("main.ts");
3143        let file_data = graph.build_file(&main_path).unwrap();
3144        let import_block = file_data.import_block.clone();
3145
3146        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3147        match edge {
3148            EdgeResolution::Resolved { file, symbol } => {
3149                assert!(
3150                    file.ends_with("utils.ts"),
3151                    "helper should resolve to utils.ts, got: {:?}",
3152                    file
3153                );
3154                assert_eq!(symbol, "helper");
3155            }
3156            EdgeResolution::Unresolved { callee_name } => {
3157                panic!("Expected resolved, got unresolved: {}", callee_name);
3158            }
3159        }
3160    }
3161
3162    #[test]
3163    fn callgraph_resolve_namespace_import() {
3164        let dir = setup_ts_project();
3165        let mut graph = CallGraph::new(dir.path().to_path_buf());
3166
3167        let main_path = dir.path().join("main.ts");
3168        let file_data = graph.build_file(&main_path).unwrap();
3169        let import_block = file_data.import_block.clone();
3170
3171        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3172        match edge {
3173            EdgeResolution::Resolved { file, symbol } => {
3174                assert!(
3175                    file.ends_with("math.ts"),
3176                    "math.add should resolve to math.ts, got: {:?}",
3177                    file
3178                );
3179                assert_eq!(symbol, "add");
3180            }
3181            EdgeResolution::Unresolved { callee_name } => {
3182                panic!("Expected resolved, got unresolved: {}", callee_name);
3183            }
3184        }
3185    }
3186
3187    #[test]
3188    fn callgraph_resolve_aliased_import() {
3189        let dir = setup_alias_project();
3190        let mut graph = CallGraph::new(dir.path().to_path_buf());
3191
3192        let main_path = dir.path().join("main.ts");
3193        let file_data = graph.build_file(&main_path).unwrap();
3194        let import_block = file_data.import_block.clone();
3195
3196        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3197        match edge {
3198            EdgeResolution::Resolved { file, symbol } => {
3199                assert!(
3200                    file.ends_with("utils.ts"),
3201                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
3202                    file
3203                );
3204                assert_eq!(symbol, "helper");
3205            }
3206            EdgeResolution::Unresolved { callee_name } => {
3207                panic!("Expected resolved, got unresolved: {}", callee_name);
3208            }
3209        }
3210    }
3211
3212    #[test]
3213    fn callgraph_unresolved_edge_marked() {
3214        let dir = setup_ts_project();
3215        let mut graph = CallGraph::new(dir.path().to_path_buf());
3216
3217        let main_path = dir.path().join("main.ts");
3218        let file_data = graph.build_file(&main_path).unwrap();
3219        let import_block = file_data.import_block.clone();
3220
3221        let edge =
3222            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3223        assert_eq!(
3224            edge,
3225            EdgeResolution::Unresolved {
3226                callee_name: "unknownFunc".to_string()
3227            },
3228            "Unknown callee should be unresolved"
3229        );
3230    }
3231
3232    // --- Worktree walker ---
3233
3234    #[test]
3235    fn callgraph_walker_excludes_gitignored() {
3236        let dir = TempDir::new().unwrap();
3237
3238        // Create a .gitignore
3239        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3240
3241        // Create files
3242        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3243        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3244        fs::write(
3245            dir.path().join("ignored_dir").join("secret.ts"),
3246            "export function secret() {}",
3247        )
3248        .unwrap();
3249
3250        // Also create node_modules (should always be excluded)
3251        fs::create_dir(dir.path().join("node_modules")).unwrap();
3252        fs::write(
3253            dir.path().join("node_modules").join("dep.ts"),
3254            "export function dep() {}",
3255        )
3256        .unwrap();
3257
3258        // Init git repo for .gitignore to work
3259        let mut command = std::process::Command::new("git");
3260        crate::test_env::apply_hermetic_git_env(command.current_dir(dir.path()))
3261            .args(["init"])
3262            .output()
3263            .unwrap();
3264
3265        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3266        let file_names: Vec<String> = files
3267            .iter()
3268            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3269            .collect();
3270
3271        assert!(
3272            file_names.contains(&"main.ts".to_string()),
3273            "Should include main.ts, got: {:?}",
3274            file_names
3275        );
3276        assert!(
3277            !file_names.contains(&"secret.ts".to_string()),
3278            "Should exclude gitignored secret.ts, got: {:?}",
3279            file_names
3280        );
3281        assert!(
3282            !file_names.contains(&"dep.ts".to_string()),
3283            "Should exclude node_modules, got: {:?}",
3284            file_names
3285        );
3286    }
3287
3288    #[test]
3289    fn callgraph_walker_excludes_aftignored() {
3290        let dir = TempDir::new().unwrap();
3291
3292        // .aftignore is honored without a git repo (custom ignore file).
3293        fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
3294        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3295        fs::create_dir(dir.path().join("vendored")).unwrap();
3296        fs::write(
3297            dir.path().join("vendored").join("sub.ts"),
3298            "export function sub() {}",
3299        )
3300        .unwrap();
3301
3302        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3303        let file_names: Vec<String> = files
3304            .iter()
3305            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3306            .collect();
3307
3308        assert!(
3309            file_names.contains(&"main.ts".to_string()),
3310            "Should include main.ts, got: {:?}",
3311            file_names
3312        );
3313        assert!(
3314            !file_names.contains(&"sub.ts".to_string()),
3315            "Should exclude .aftignored sub.ts, got: {:?}",
3316            file_names
3317        );
3318    }
3319
3320    #[test]
3321    fn callgraph_walker_only_source_files() {
3322        let dir = TempDir::new().unwrap();
3323
3324        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3325        fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
3326        fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
3327        fs::write(
3328            dir.path().join("runtime.mjs"),
3329            "export function runtime() {}",
3330        )
3331        .unwrap();
3332        fs::write(
3333            dir.path().join("legacy.cjs"),
3334            "exports.legacy = function() {};",
3335        )
3336        .unwrap();
3337        fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
3338        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3339        fs::write(dir.path().join("data.json"), "{}").unwrap();
3340
3341        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3342        let file_names: Vec<String> = files
3343            .iter()
3344            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3345            .collect();
3346
3347        assert!(file_names.contains(&"main.ts".to_string()));
3348        for modern_ext_file in [
3349            "module.mts",
3350            "common.cts",
3351            "runtime.mjs",
3352            "legacy.cjs",
3353            "types.pyi",
3354        ] {
3355            assert!(
3356                file_names.contains(&modern_ext_file.to_string()),
3357                "walker should include {modern_ext_file}, got: {:?}",
3358                file_names
3359            );
3360        }
3361        assert!(
3362            file_names.contains(&"readme.md".to_string()),
3363            "Markdown is now a supported source language"
3364        );
3365        assert!(
3366            file_names.contains(&"data.json".to_string()),
3367            "JSON is now a supported source language"
3368        );
3369    }
3370
3371    // --- find_alias_original ---
3372
3373    #[test]
3374    fn callgraph_find_alias_original_simple() {
3375        let raw = "import { foo as bar } from './utils';";
3376        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3377    }
3378
3379    #[test]
3380    fn callgraph_find_alias_original_multiple() {
3381        let raw = "import { foo as bar, baz as qux } from './utils';";
3382        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3383        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3384    }
3385
3386    #[test]
3387    fn callgraph_find_alias_no_match() {
3388        let raw = "import { foo } from './utils';";
3389        assert_eq!(find_alias_original(raw, "foo"), None);
3390    }
3391
3392    // --- Reverse callers ---
3393
3394    #[test]
3395    fn is_entry_point_exported_function() {
3396        assert!(is_entry_point(
3397            "handleRequest",
3398            &SymbolKind::Function,
3399            true,
3400            LangId::TypeScript
3401        ));
3402    }
3403
3404    #[test]
3405    fn is_entry_point_exported_method_is_not_entry() {
3406        // Methods are class members, not standalone entry points
3407        assert!(!is_entry_point(
3408            "handleRequest",
3409            &SymbolKind::Method,
3410            true,
3411            LangId::TypeScript
3412        ));
3413    }
3414
3415    #[test]
3416    fn is_entry_point_main_init_patterns() {
3417        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3418            assert!(
3419                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3420                "{} should be an entry point",
3421                name
3422            );
3423        }
3424    }
3425
3426    #[test]
3427    fn is_entry_point_test_patterns_ts() {
3428        assert!(is_entry_point(
3429            "describe",
3430            &SymbolKind::Function,
3431            false,
3432            LangId::TypeScript
3433        ));
3434        assert!(is_entry_point(
3435            "it",
3436            &SymbolKind::Function,
3437            false,
3438            LangId::TypeScript
3439        ));
3440        assert!(is_entry_point(
3441            "test",
3442            &SymbolKind::Function,
3443            false,
3444            LangId::TypeScript
3445        ));
3446        assert!(is_entry_point(
3447            "testValidation",
3448            &SymbolKind::Function,
3449            false,
3450            LangId::TypeScript
3451        ));
3452        assert!(is_entry_point(
3453            "specHelper",
3454            &SymbolKind::Function,
3455            false,
3456            LangId::TypeScript
3457        ));
3458    }
3459
3460    #[test]
3461    fn is_entry_point_test_patterns_python() {
3462        assert!(is_entry_point(
3463            "test_login",
3464            &SymbolKind::Function,
3465            false,
3466            LangId::Python
3467        ));
3468        assert!(is_entry_point(
3469            "setUp",
3470            &SymbolKind::Function,
3471            false,
3472            LangId::Python
3473        ));
3474        assert!(is_entry_point(
3475            "tearDown",
3476            &SymbolKind::Function,
3477            false,
3478            LangId::Python
3479        ));
3480        // "testSomething" should NOT match Python (needs test_ prefix)
3481        assert!(!is_entry_point(
3482            "testSomething",
3483            &SymbolKind::Function,
3484            false,
3485            LangId::Python
3486        ));
3487    }
3488
3489    #[test]
3490    fn is_entry_point_test_patterns_rust() {
3491        assert!(is_entry_point(
3492            "test_parse",
3493            &SymbolKind::Function,
3494            false,
3495            LangId::Rust
3496        ));
3497        assert!(!is_entry_point(
3498            "TestSomething",
3499            &SymbolKind::Function,
3500            false,
3501            LangId::Rust
3502        ));
3503    }
3504
3505    #[test]
3506    fn is_entry_point_test_patterns_go() {
3507        assert!(is_entry_point(
3508            "TestParsing",
3509            &SymbolKind::Function,
3510            false,
3511            LangId::Go
3512        ));
3513        // lowercase test should NOT match Go (needs uppercase Test prefix)
3514        assert!(!is_entry_point(
3515            "testParsing",
3516            &SymbolKind::Function,
3517            false,
3518            LangId::Go
3519        ));
3520    }
3521
3522    #[test]
3523    fn is_entry_point_non_exported_non_main_is_not_entry() {
3524        assert!(!is_entry_point(
3525            "helperUtil",
3526            &SymbolKind::Function,
3527            false,
3528            LangId::TypeScript
3529        ));
3530    }
3531
3532    // --- symbol_metadata ---
3533
3534    #[test]
3535    fn callgraph_symbol_metadata_populated() {
3536        let dir = setup_ts_project();
3537        let mut graph = CallGraph::new(dir.path().to_path_buf());
3538
3539        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3540        assert!(
3541            file_data.symbol_metadata.contains_key("helper"),
3542            "symbol_metadata should contain helper"
3543        );
3544        let meta = &file_data.symbol_metadata["helper"];
3545        assert_eq!(meta.kind, SymbolKind::Function);
3546        assert!(meta.exported, "helper should be exported");
3547    }
3548
3549    #[test]
3550    fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
3551        let dir = TempDir::new().unwrap();
3552        fs::write(
3553            dir.path().join("main.ts"),
3554            r#"import * as lib from './index';
3555
3556export function main() {
3557    lib.helper();
3558    lib.hidden();
3559}
3560"#,
3561        )
3562        .unwrap();
3563        fs::write(
3564            dir.path().join("index.ts"),
3565            "export { helper } from './utils';\n",
3566        )
3567        .unwrap();
3568        fs::write(
3569            dir.path().join("utils.ts"),
3570            r#"export function helper() {}
3571function hidden() {}
3572"#,
3573        )
3574        .unwrap();
3575
3576        let mut graph = CallGraph::new(dir.path().to_path_buf());
3577        let main_path = dir.path().join("main.ts");
3578        let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
3579
3580        let helper =
3581            graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
3582        match helper {
3583            EdgeResolution::Resolved { file, symbol } => {
3584                assert!(
3585                    file.ends_with("utils.ts"),
3586                    "helper should resolve through barrel: {file:?}"
3587                );
3588                assert_eq!(symbol, "helper");
3589            }
3590            other => panic!("expected helper to resolve through barrel, got {other:?}"),
3591        }
3592
3593        let hidden =
3594            graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
3595        assert_eq!(
3596            hidden,
3597            EdgeResolution::Unresolved {
3598                callee_name: "hidden".to_string()
3599            }
3600        );
3601    }
3602
3603    #[test]
3604    fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
3605        let dir = TempDir::new().unwrap();
3606        fs::write(
3607            dir.path().join("package.json"),
3608            r#"{"workspaces":["packages/*"]}"#,
3609        )
3610        .unwrap();
3611        let package_dir = dir.path().join("packages/lib");
3612        fs::create_dir_all(package_dir.join("src")).unwrap();
3613        fs::create_dir_all(package_dir.join("dist")).unwrap();
3614        fs::write(
3615            package_dir.join("package.json"),
3616            r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
3617        )
3618        .unwrap();
3619        fs::write(
3620            package_dir.join("src/index.mts"),
3621            "export function helper() {}\n",
3622        )
3623        .unwrap();
3624        fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
3625
3626        let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
3627        assert!(
3628            resolved.ends_with("src/index.mts"),
3629            "dist/index.mjs should map to src/index.mts, got {resolved:?}"
3630        );
3631    }
3632
3633    #[test]
3634    fn same_named_methods_use_scoped_symbol_identity() {
3635        let dir = TempDir::new().unwrap();
3636        fs::write(
3637            dir.path().join("classes.ts"),
3638            r#"class A {
3639    run() { helperA(); }
3640}
3641
3642class B {
3643    run() { helperB(); }
3644}
3645
3646function helperA() {}
3647function helperB() {}
3648"#,
3649        )
3650        .unwrap();
3651
3652        let mut graph = CallGraph::new(dir.path().to_path_buf());
3653        let path = dir.path().join("classes.ts");
3654        let data = graph.build_file(&path).unwrap();
3655
3656        assert!(
3657            data.symbol_metadata.contains_key("A::run"),
3658            "A::run metadata missing"
3659        );
3660        assert!(
3661            data.symbol_metadata.contains_key("B::run"),
3662            "B::run metadata missing"
3663        );
3664        assert!(
3665            data.calls_by_symbol["A::run"]
3666                .iter()
3667                .any(|call| call.callee_name == "helperA"),
3668            "A::run calls should not be overwritten"
3669        );
3670        assert!(
3671            data.calls_by_symbol["B::run"]
3672                .iter()
3673                .any(|call| call.callee_name == "helperB"),
3674            "B::run calls should not be overwritten"
3675        );
3676    }
3677
3678    // --- extract_parameters ---
3679
3680    #[test]
3681    fn extract_parameters_typescript() {
3682        let params = extract_parameters(
3683            "function processData(input: string, count: number): void",
3684            LangId::TypeScript,
3685        );
3686        assert_eq!(params, vec!["input", "count"]);
3687    }
3688
3689    #[test]
3690    fn extract_parameters_typescript_optional() {
3691        let params = extract_parameters(
3692            "function fetch(url: string, options?: RequestInit): Promise<Response>",
3693            LangId::TypeScript,
3694        );
3695        assert_eq!(params, vec!["url", "options"]);
3696    }
3697
3698    #[test]
3699    fn extract_parameters_typescript_defaults() {
3700        let params = extract_parameters(
3701            "function greet(name: string, greeting: string = \"hello\"): string",
3702            LangId::TypeScript,
3703        );
3704        assert_eq!(params, vec!["name", "greeting"]);
3705    }
3706
3707    #[test]
3708    fn extract_parameters_typescript_rest() {
3709        let params = extract_parameters(
3710            "function sum(...numbers: number[]): number",
3711            LangId::TypeScript,
3712        );
3713        assert_eq!(params, vec!["numbers"]);
3714    }
3715
3716    #[test]
3717    fn extract_parameters_python_self_skipped() {
3718        let params = extract_parameters(
3719            "def process(self, data: str, count: int) -> bool",
3720            LangId::Python,
3721        );
3722        assert_eq!(params, vec!["data", "count"]);
3723    }
3724
3725    #[test]
3726    fn extract_parameters_python_no_self() {
3727        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3728        assert_eq!(params, vec!["input"]);
3729    }
3730
3731    #[test]
3732    fn extract_parameters_python_star_args() {
3733        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3734        assert_eq!(params, vec!["args", "kwargs"]);
3735    }
3736
3737    #[test]
3738    fn extract_parameters_rust_self_skipped() {
3739        let params = extract_parameters(
3740            "fn process(&self, data: &str, count: usize) -> bool",
3741            LangId::Rust,
3742        );
3743        assert_eq!(params, vec!["data", "count"]);
3744    }
3745
3746    #[test]
3747    fn extract_parameters_rust_mut_self_skipped() {
3748        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3749        assert_eq!(params, vec!["value"]);
3750    }
3751
3752    #[test]
3753    fn extract_parameters_rust_no_self() {
3754        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3755        assert_eq!(params, vec!["input"]);
3756    }
3757
3758    #[test]
3759    fn extract_parameters_rust_mut_param() {
3760        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3761        assert_eq!(params, vec!["buf", "len"]);
3762    }
3763
3764    #[test]
3765    fn extract_parameters_go() {
3766        let params = extract_parameters(
3767            "func ProcessData(input string, count int) error",
3768            LangId::Go,
3769        );
3770        assert_eq!(params, vec!["input", "count"]);
3771    }
3772
3773    #[test]
3774    fn extract_parameters_empty() {
3775        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3776        assert!(
3777            params.is_empty(),
3778            "no-arg function should return empty params"
3779        );
3780    }
3781
3782    #[test]
3783    fn extract_parameters_no_parens() {
3784        let params = extract_parameters("const x = 42", LangId::TypeScript);
3785        assert!(params.is_empty(), "no parens should return empty params");
3786    }
3787
3788    #[test]
3789    fn extract_parameters_javascript() {
3790        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3791        assert_eq!(params, vec!["event", "target"]);
3792    }
3793}