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