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 crate::walk_boundary::expand_glob_same_file_system(&pattern)
2017            .unwrap_or_default()
2018            .into_iter()
2019            .filter(|path| path.join("Cargo.toml").is_file())
2020            .map(|path| canonicalize_path(&path))
2021            .collect();
2022    }
2023
2024    let path = workspace_root.join(member);
2025    if path.join("Cargo.toml").is_file() {
2026        vec![canonicalize_path(&path)]
2027    } else {
2028        Vec::new()
2029    }
2030}
2031
2032fn canonicalize_path(path: &Path) -> PathBuf {
2033    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
2034}
2035
2036fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2037    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2038    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2039    let compiler_options = tsconfig.get("compilerOptions")?;
2040    let paths = compiler_options.get("paths")?.as_object()?;
2041    let base_url = compiler_options
2042        .get("baseUrl")
2043        .and_then(Value::as_str)
2044        .unwrap_or(".");
2045    let base_dir = tsconfig_dir.join(base_url);
2046
2047    for (alias, targets) in paths {
2048        let Some(capture) = ts_path_capture(alias, module_path) else {
2049            continue;
2050        };
2051        let Some(targets) = targets.as_array() else {
2052            continue;
2053        };
2054        for target in targets.iter().filter_map(Value::as_str) {
2055            let target = if target.contains('*') {
2056                target.replace('*', capture)
2057            } else {
2058                target.to_string()
2059            };
2060            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2061                return Some(path);
2062            }
2063        }
2064    }
2065
2066    None
2067}
2068
2069fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2070    let mut current = Some(from_dir);
2071    while let Some(dir) = current {
2072        if dir.join("tsconfig.json").is_file() {
2073            return Some(dir.to_path_buf());
2074        }
2075        current = dir.parent();
2076    }
2077    None
2078}
2079
2080fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2081    if let Some(star_index) = alias.find('*') {
2082        let (prefix, suffix_with_star) = alias.split_at(star_index);
2083        let suffix = &suffix_with_star[1..];
2084        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2085            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2086        }
2087        return None;
2088    }
2089
2090    (alias == module_path).then_some("")
2091}
2092
2093fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2094    let mut parts = module_path.split('/');
2095    let first = parts.next()?;
2096    if first.is_empty() {
2097        return None;
2098    }
2099
2100    if first.starts_with('@') {
2101        let second = parts.next()?;
2102        if second.is_empty() {
2103            return None;
2104        }
2105        let package_name = format!("{first}/{second}");
2106        let subpath = parts.collect::<Vec<_>>().join("/");
2107        let subpath = (!subpath.is_empty()).then_some(subpath);
2108        Some((package_name, subpath))
2109    } else {
2110        let package_name = first.to_string();
2111        let subpath = parts.collect::<Vec<_>>().join("/");
2112        let subpath = (!subpath.is_empty()).then_some(subpath);
2113        Some((package_name, subpath))
2114    }
2115}
2116
2117fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2118    let mut current = Some(from_dir);
2119    while let Some(dir) = current {
2120        if package_json_name(dir).as_deref() == Some(package_name) {
2121            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2122        }
2123        current = dir.parent();
2124    }
2125
2126    find_workspace_root(from_dir)
2127        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2128}
2129
2130fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2131    let mut current = Some(from_dir);
2132    while let Some(dir) = current {
2133        if is_workspace_root(dir) {
2134            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2135        }
2136        current = dir.parent();
2137    }
2138    None
2139}
2140
2141fn is_workspace_root(dir: &Path) -> bool {
2142    package_json_value(dir)
2143        .map(|value| !workspace_patterns(&value).is_empty())
2144        .unwrap_or(false)
2145        || !pnpm_workspace_patterns(dir).is_empty()
2146}
2147
2148pub(crate) fn clear_workspace_package_cache() {
2149    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2150        cache.clear();
2151    }
2152    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2153        cache.clear();
2154    }
2155    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2156        cache.clear();
2157    }
2158}
2159
2160fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2161    let workspace_root =
2162        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2163    let cache_key = (workspace_root.clone(), package_name.to_string());
2164
2165    if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2166        if let Some(cached) = cache.get(&cache_key) {
2167            return cached.clone();
2168        }
2169    }
2170
2171    let resolved = workspace_member_dirs(&workspace_root)
2172        .into_iter()
2173        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2174        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2175
2176    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2177        cache.insert(cache_key, resolved.clone());
2178    }
2179
2180    resolved
2181}
2182
2183fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2184    let mut patterns = package_json_value(workspace_root)
2185        .map(|package_json| workspace_patterns(&package_json))
2186        .unwrap_or_default();
2187    patterns.extend(pnpm_workspace_patterns(workspace_root));
2188
2189    expand_workspace_patterns(workspace_root, &patterns)
2190}
2191
2192fn workspace_patterns(package_json: &Value) -> Vec<String> {
2193    match package_json.get("workspaces") {
2194        Some(Value::Array(items)) => items
2195            .iter()
2196            .filter_map(non_empty_workspace_pattern)
2197            .collect(),
2198        Some(Value::Object(map)) => map
2199            .get("packages")
2200            .and_then(Value::as_array)
2201            .map(|items| {
2202                items
2203                    .iter()
2204                    .filter_map(non_empty_workspace_pattern)
2205                    .collect()
2206            })
2207            .unwrap_or_default(),
2208        _ => Vec::new(),
2209    }
2210}
2211
2212fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2213    let pattern = value.as_str()?.trim();
2214    (!pattern.is_empty()).then(|| pattern.to_string())
2215}
2216
2217fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2218    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2219        return Vec::new();
2220    };
2221
2222    let mut patterns = Vec::new();
2223    let mut in_packages = false;
2224    for line in source.lines() {
2225        let without_comment = line.split('#').next().unwrap_or("").trim_end();
2226        let trimmed = without_comment.trim();
2227        if trimmed.is_empty() {
2228            continue;
2229        }
2230        if trimmed == "packages:" {
2231            in_packages = true;
2232            continue;
2233        }
2234        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2235            in_packages = false;
2236        }
2237        if in_packages {
2238            if let Some(pattern) = trimmed.strip_prefix('-') {
2239                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2240                if !pattern.is_empty() {
2241                    patterns.push(pattern.to_string());
2242                }
2243            }
2244        }
2245    }
2246    patterns
2247}
2248
2249fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2250    let positive_patterns: Vec<&str> = patterns
2251        .iter()
2252        .map(|pattern| pattern.trim())
2253        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2254        .collect();
2255    if positive_patterns.is_empty() {
2256        return Vec::new();
2257    }
2258
2259    let positives = build_glob_set(&positive_patterns);
2260    let negative_patterns: Vec<&str> = patterns
2261        .iter()
2262        .map(|pattern| pattern.trim())
2263        .filter_map(|pattern| pattern.strip_prefix('!'))
2264        .map(str::trim)
2265        .filter(|pattern| !pattern.is_empty())
2266        .collect();
2267    let negatives = build_glob_set(&negative_patterns);
2268
2269    let Ok(boundary) = crate::walk_boundary::DeviceBoundary::for_root(workspace_root) else {
2270        return Vec::new();
2271    };
2272    let mut members = Vec::new();
2273    collect_workspace_member_dirs(
2274        workspace_root,
2275        workspace_root,
2276        &boundary,
2277        &positives,
2278        &negatives,
2279        &mut members,
2280    );
2281    members
2282}
2283
2284fn build_glob_set(patterns: &[&str]) -> GlobSet {
2285    let mut builder = GlobSetBuilder::new();
2286    for pattern in patterns {
2287        if let Ok(glob) = Glob::new(pattern) {
2288            builder.add(glob);
2289        }
2290    }
2291    builder
2292        .build()
2293        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
2294}
2295
2296fn collect_workspace_member_dirs(
2297    workspace_root: &Path,
2298    dir: &Path,
2299    boundary: &crate::walk_boundary::DeviceBoundary,
2300    positives: &GlobSet,
2301    negatives: &GlobSet,
2302    members: &mut Vec<PathBuf>,
2303) {
2304    let Ok(entries) = std::fs::read_dir(dir) else {
2305        return;
2306    };
2307
2308    for entry in entries.filter_map(Result::ok) {
2309        let path = entry.path();
2310        let Ok(file_type) = entry.file_type() else {
2311            continue;
2312        };
2313        if !file_type.is_dir() {
2314            continue;
2315        }
2316        // Do not open a mounted child: its ReadDir destructor can abort on ENXIO
2317        // when the mount disappears while callgraph discovery is running.
2318        if !boundary.should_descend(&path).unwrap_or(false) {
2319            crate::slog_warn!(
2320                "callgraph workspace-member walk skipped foreign filesystem mount {}",
2321                path.display()
2322            );
2323            continue;
2324        }
2325        let name = entry.file_name();
2326        let name = name.to_string_lossy();
2327        if matches!(
2328            name.as_ref(),
2329            "node_modules" | ".git" | "target" | "dist" | "build"
2330        ) {
2331            continue;
2332        }
2333
2334        if path.join("package.json").is_file() {
2335            if let Ok(rel) = path.strip_prefix(workspace_root) {
2336                let rel = rel.to_string_lossy().replace('\\', "/");
2337                if positives.is_match(&rel) && !negatives.is_match(&rel) {
2338                    members.push(path.clone());
2339                }
2340            }
2341        }
2342
2343        collect_workspace_member_dirs(
2344            workspace_root,
2345            &path,
2346            boundary,
2347            positives,
2348            negatives,
2349            members,
2350        );
2351    }
2352}
2353
2354fn package_json_value(dir: &Path) -> Option<Value> {
2355    package_json_like_value(&dir.join("package.json"))
2356}
2357
2358fn package_json_like_value(path: &Path) -> Option<Value> {
2359    let json = std::fs::read_to_string(path).ok()?;
2360    serde_json::from_str(&json).ok()
2361}
2362
2363fn package_json_name(dir: &Path) -> Option<String> {
2364    package_json_value(dir)?
2365        .get("name")?
2366        .as_str()
2367        .map(ToOwned::to_owned)
2368}
2369
2370fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
2371    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
2372
2373    if let Some(exports) = package_json.get("exports") {
2374        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2375            if let Some(path) = resolve_package_target(package_root, &target) {
2376                return Some(path);
2377            }
2378        }
2379    }
2380
2381    if subpath.is_none() {
2382        for field in ["module", "main"] {
2383            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2384                if let Some(path) = resolve_package_target(package_root, target) {
2385                    return Some(path);
2386                }
2387            }
2388        }
2389    }
2390
2391    resolve_package_fallback(package_root, subpath.as_deref())
2392}
2393
2394fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2395    let key = subpath
2396        .map(|value| format!("./{value}"))
2397        .unwrap_or_else(|| ".".to_string());
2398
2399    match exports {
2400        Value::String(target) if key == "." => Some(target.clone()),
2401        Value::Object(map) => {
2402            if let Some(target) = map.get(&key).and_then(export_condition_target) {
2403                return Some(target);
2404            }
2405
2406            if let Some(target) = wildcard_export_target(map, &key) {
2407                return Some(target);
2408            }
2409
2410            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2411                return export_condition_target(exports);
2412            }
2413
2414            None
2415        }
2416        _ => None,
2417    }
2418}
2419
2420fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2421    for (pattern, target) in map {
2422        let Some(star_index) = pattern.find('*') else {
2423            continue;
2424        };
2425        let (prefix, suffix_with_star) = pattern.split_at(star_index);
2426        let suffix = &suffix_with_star[1..];
2427        if !key.starts_with(prefix) || !key.ends_with(suffix) {
2428            continue;
2429        }
2430        let matched = &key[prefix.len()..key.len() - suffix.len()];
2431        if let Some(target_pattern) = export_condition_target(target) {
2432            return Some(target_pattern.replace('*', matched));
2433        }
2434    }
2435    None
2436}
2437
2438fn export_condition_target(value: &Value) -> Option<String> {
2439    match value {
2440        Value::String(target) => Some(target.clone()),
2441        Value::Object(map) => ["source", "import", "module", "default", "types"]
2442            .into_iter()
2443            .find_map(|field| map.get(field).and_then(export_condition_target)),
2444        _ => None,
2445    }
2446}
2447
2448fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2449    let target = target.strip_prefix("./").unwrap_or(target);
2450    // Prefer source over compiled bundle when both exist: the callgraph
2451    // walks source files and cannot extract symbols from a built JS bundle.
2452    if let Some(src_relative) = target.strip_prefix("dist/") {
2453        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2454            return Some(path);
2455        }
2456    }
2457
2458    resolve_file_like_path(&package_root.join(target))
2459}
2460
2461fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2462    match subpath {
2463        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2464            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2465        None => resolve_file_like_path(&package_root.join("src").join("index"))
2466            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2467    }
2468}
2469
2470pub(crate) fn resolve_reexported_symbol_target<F, D>(
2471    file: &Path,
2472    symbol_name: &str,
2473    file_exports_symbol: &mut F,
2474    file_default_export_symbol: &mut D,
2475) -> Option<(PathBuf, String)>
2476where
2477    F: FnMut(&Path, &str) -> bool,
2478    D: FnMut(&Path) -> Option<String>,
2479{
2480    resolve_reexported_symbol(
2481        file,
2482        symbol_name,
2483        file_exports_symbol,
2484        file_default_export_symbol,
2485    )
2486    .map(|target| (target.file, target.symbol))
2487}
2488
2489fn resolve_reexported_symbol<F, D>(
2490    file: &Path,
2491    symbol_name: &str,
2492    file_exports_symbol: &mut F,
2493    file_default_export_symbol: &mut D,
2494) -> Option<ResolvedSymbol>
2495where
2496    F: FnMut(&Path, &str) -> bool,
2497    D: FnMut(&Path) -> Option<String>,
2498{
2499    let mut visited = HashSet::new();
2500    resolve_reexported_symbol_inner(
2501        file,
2502        symbol_name,
2503        file_exports_symbol,
2504        file_default_export_symbol,
2505        &mut visited,
2506    )
2507}
2508
2509fn resolve_reexported_symbol_inner<F, D>(
2510    file: &Path,
2511    symbol_name: &str,
2512    file_exports_symbol: &mut F,
2513    file_default_export_symbol: &mut D,
2514    visited: &mut HashSet<(PathBuf, String)>,
2515) -> Option<ResolvedSymbol>
2516where
2517    F: FnMut(&Path, &str) -> bool,
2518    D: FnMut(&Path) -> Option<String>,
2519{
2520    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2521    if !visited.insert((canon.clone(), symbol_name.to_string())) {
2522        return None;
2523    }
2524
2525    let source = std::fs::read_to_string(&canon).ok()?;
2526    let lang = detect_language(&canon)?;
2527    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2528        if symbol_name == "default" {
2529            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
2530                file: canon,
2531                symbol,
2532            });
2533        }
2534        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
2535            file: canon,
2536            symbol: symbol_name.to_string(),
2537        });
2538    }
2539
2540    let grammar = grammar_for(lang);
2541    let mut parser = Parser::new();
2542    parser.set_language(&grammar).ok()?;
2543    let tree = parser.parse(&source, None)?;
2544    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2545
2546    let mut cursor = tree.root_node().walk();
2547    if !cursor.goto_first_child() {
2548        return None;
2549    }
2550
2551    loop {
2552        let node = cursor.node();
2553        if node.kind() == "export_statement" {
2554            if let Some(target) = resolve_reexport_statement(
2555                &source,
2556                node,
2557                from_dir,
2558                symbol_name,
2559                file_exports_symbol,
2560                file_default_export_symbol,
2561                visited,
2562            ) {
2563                return Some(target);
2564            }
2565        }
2566
2567        if !cursor.goto_next_sibling() {
2568            break;
2569        }
2570    }
2571
2572    if symbol_name == "default" {
2573        if let Some(symbol) = file_default_export_symbol(&canon) {
2574            return Some(ResolvedSymbol {
2575                file: canon,
2576                symbol,
2577            });
2578        }
2579    }
2580
2581    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
2582        return Some(ResolvedSymbol {
2583            file: canon,
2584            symbol,
2585        });
2586    }
2587
2588    if file_exports_symbol(&canon, symbol_name) {
2589        let symbol = symbol_name.to_string();
2590        return Some(ResolvedSymbol {
2591            file: canon,
2592            symbol,
2593        });
2594    }
2595
2596    None
2597}
2598
2599fn resolve_reexport_statement<F, D>(
2600    source: &str,
2601    node: tree_sitter::Node,
2602    from_dir: &Path,
2603    symbol_name: &str,
2604    file_exports_symbol: &mut F,
2605    file_default_export_symbol: &mut D,
2606    visited: &mut HashSet<(PathBuf, String)>,
2607) -> Option<ResolvedSymbol>
2608where
2609    F: FnMut(&Path, &str) -> bool,
2610    D: FnMut(&Path) -> Option<String>,
2611{
2612    let source_node = node
2613        .child_by_field_name("source")
2614        .or_else(|| find_child_by_kind(node, "string"))?;
2615    let module_path = string_literal_content(source, source_node)?;
2616    let target_file = resolve_module_path(from_dir, &module_path)?;
2617    let raw_export = node_text(node, source);
2618
2619    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2620        return resolve_reexported_symbol_inner(
2621            &target_file,
2622            &source_symbol,
2623            file_exports_symbol,
2624            file_default_export_symbol,
2625            visited,
2626        )
2627        .or(Some(ResolvedSymbol {
2628            file: target_file,
2629            symbol: source_symbol,
2630        }));
2631    }
2632
2633    if raw_export.contains('*') {
2634        return resolve_reexported_symbol_inner(
2635            &target_file,
2636            symbol_name,
2637            file_exports_symbol,
2638            file_default_export_symbol,
2639            visited,
2640        );
2641    }
2642
2643    None
2644}
2645
2646fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
2647    let lang = detect_language(file)?;
2648    let grammar = grammar_for(lang);
2649    let mut parser = Parser::new();
2650    parser.set_language(&grammar).ok()?;
2651    let tree = parser.parse(source, None)?;
2652
2653    let mut cursor = tree.root_node().walk();
2654    if !cursor.goto_first_child() {
2655        return None;
2656    }
2657
2658    loop {
2659        let node = cursor.node();
2660        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
2661            let raw_export = node_text(node, source);
2662            if let Some(source_symbol) =
2663                reexport_clause_source_symbol(&raw_export, requested_export)
2664            {
2665                return Some(source_symbol);
2666            }
2667        }
2668
2669        if !cursor.goto_next_sibling() {
2670            break;
2671        }
2672    }
2673
2674    None
2675}
2676
2677fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
2678    let start = raw_export.find('{')? + 1;
2679    let end = raw_export[start..].find('}')? + start;
2680    for specifier in raw_export[start..end].split(',') {
2681        let specifier = specifier.trim();
2682        if specifier.is_empty() {
2683            continue;
2684        }
2685        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
2686        if let Some((imported, exported)) = specifier.split_once(" as ") {
2687            if exported.trim() == requested_export {
2688                return Some(imported.trim().to_string());
2689            }
2690        } else if specifier == requested_export {
2691            return Some(requested_export.to_string());
2692        }
2693    }
2694    None
2695}
2696
2697fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2698    let raw = source[node.byte_range()].trim();
2699    let quote = raw.chars().next()?;
2700    if quote != '\'' && quote != '"' {
2701        return None;
2702    }
2703    raw.strip_prefix(quote)
2704        .and_then(|value| value.strip_suffix(quote))
2705        .map(ToOwned::to_owned)
2706}
2707
2708/// Find an index file in a directory.
2709fn find_index_file(dir: &Path) -> Option<PathBuf> {
2710    for name in JS_TS_INDEX_FILES {
2711        let p = dir.join(name);
2712        if p.is_file() {
2713            return Some(std::fs::canonicalize(&p).unwrap_or(p));
2714        }
2715    }
2716    None
2717}
2718
2719/// Resolve an aliased import: `import { foo as bar } from './utils'`
2720/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
2721fn resolve_aliased_import(
2722    local_name: &str,
2723    import_block: &ImportBlock,
2724    caller_dir: &Path,
2725) -> Option<(String, PathBuf)> {
2726    for imp in &import_block.imports {
2727        // Parse the raw text to find "as <alias>" patterns
2728        // This handles: import { foo as bar, baz as qux } from './mod'
2729        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2730            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2731                return Some((original, resolved_path));
2732            }
2733        }
2734    }
2735    None
2736}
2737
2738/// Parse import raw text to find the original name for an alias.
2739/// Given raw text like `import { foo as bar, baz } from './utils'` and
2740/// local_name "bar", returns Some("foo").
2741fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2742    // Look for pattern: <original> as <alias>
2743    // This is a simple text-based search; handles the common TS/JS pattern
2744    let search = format!(" as {}", local_name);
2745    if let Some(pos) = raw_import.find(&search) {
2746        // Walk backwards from `pos` to find the original name
2747        let before = &raw_import[..pos];
2748        // The original name is the last word-like token before " as "
2749        let original = before
2750            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2751            .find(|s| !s.is_empty())?;
2752        return Some(original.to_string());
2753    }
2754    None
2755}
2756
2757// ---------------------------------------------------------------------------
2758// Worktree file discovery
2759// ---------------------------------------------------------------------------
2760
2761/// Walk project files respecting .gitignore, excluding common non-source dirs.
2762///
2763/// Returns an iterator of file paths for supported source file types.
2764pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2765    use ignore::WalkBuilder;
2766
2767    // A disappearing child mount can make ReadDir::drop panic on ENXIO and abort
2768    // the daemon, so never open directories outside this walk root's filesystem.
2769    let walker = WalkBuilder::new(root)
2770        .same_file_system(true)
2771        .hidden(true)         // skip hidden files/dirs
2772        .git_ignore(true)     // respect .gitignore
2773        .git_global(true)     // respect global gitignore
2774        .git_exclude(true)    // respect .git/info/exclude
2775        .add_custom_ignore_filename(".aftignore") // AFT-specific ignores (e.g. submodules)
2776        .filter_entry(|entry| {
2777            let name = entry.file_name().to_string_lossy();
2778            // Always exclude these directories regardless of .gitignore
2779            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2780                return !matches!(
2781                    name.as_ref(),
2782                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2783                        | ".tox" | "dist" | "build"
2784                );
2785            }
2786            true
2787        })
2788        .build();
2789
2790    walker
2791        .filter_map(|entry| entry.ok())
2792        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2793        .filter(|entry| detect_language(entry.path()).is_some())
2794        .map(|entry| entry.into_path())
2795}
2796
2797// ---------------------------------------------------------------------------
2798// Tests
2799// ---------------------------------------------------------------------------
2800
2801#[cfg(test)]
2802mod tests {
2803    use super::*;
2804    use std::fs;
2805    use tempfile::TempDir;
2806
2807    fn collect_calls_by_symbol_reference(
2808        source: &str,
2809        root: Node<'_>,
2810        lang: LangId,
2811        symbols: &[Symbol],
2812    ) -> HashMap<String, Vec<CallSite>> {
2813        let mut calls_by_symbol = HashMap::new();
2814        for symbol in symbols {
2815            let byte_start =
2816                line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col);
2817            let byte_end = line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col);
2818            let sites = extract_calls_full(source, root, byte_start, byte_end, lang)
2819                .into_iter()
2820                .map(
2821                    |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2822                        callee_name: short,
2823                        full_callee: full,
2824                        line,
2825                        byte_start: call_byte_start,
2826                        byte_end: call_byte_end,
2827                    },
2828                )
2829                .collect::<Vec<_>>();
2830            if !sites.is_empty() {
2831                calls_by_symbol.insert(symbol_identity(symbol), sites);
2832            }
2833        }
2834
2835        let symbol_ranges = symbols
2836            .iter()
2837            .map(|symbol| {
2838                (
2839                    line_col_to_byte(source, symbol.range.start_line, symbol.range.start_col),
2840                    line_col_to_byte(source, symbol.range.end_line, symbol.range.end_col),
2841                )
2842            })
2843            .collect::<Vec<_>>();
2844        let top_level_sites = collect_calls_full_with_ranges(root, source, 0, source.len(), lang)
2845            .into_iter()
2846            .filter(|site| {
2847                !symbol_ranges
2848                    .iter()
2849                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2850            })
2851            .map(|site| CallSite {
2852                callee_name: site.short,
2853                full_callee: site.full,
2854                line: site.line,
2855                byte_start: site.byte_start,
2856                byte_end: site.byte_end,
2857            })
2858            .collect::<Vec<_>>();
2859        if !top_level_sites.is_empty() {
2860            calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2861        }
2862        calls_by_symbol
2863    }
2864
2865    fn parse_symbols(source: &str, lang: LangId) -> (tree_sitter::Tree, Vec<Symbol>) {
2866        let mut parser = Parser::new();
2867        parser.set_language(&grammar_for(lang)).unwrap();
2868        let tree = parser.parse(source, None).unwrap();
2869        let symbols = crate::parser::extract_symbols_from_tree(source, &tree, lang).unwrap();
2870        (tree, symbols)
2871    }
2872
2873    fn test_symbol(name: &str, start_col: u32, end_col: u32) -> Symbol {
2874        Symbol {
2875            name: name.to_string(),
2876            kind: SymbolKind::Function,
2877            range: Range {
2878                start_line: 0,
2879                start_col,
2880                end_line: 0,
2881                end_col,
2882            },
2883            signature: None,
2884            scope_chain: Vec::new(),
2885            exported: false,
2886            parent: None,
2887        }
2888    }
2889
2890    #[test]
2891    fn source_line_index_matches_shared_line_column_conversion() {
2892        let source = "a\r\nbb\rc\n";
2893        let index = SourceLineIndex::new(source);
2894        for line in 0..=5 {
2895            for column in 0..=5 {
2896                assert_eq!(
2897                    index.byte_offset(line, column),
2898                    line_col_to_byte(source, line, column),
2899                    "line={line}, column={column}"
2900                );
2901            }
2902        }
2903    }
2904
2905    #[test]
2906    fn single_pass_call_attribution_matches_per_symbol_reference() {
2907        let corpora = [
2908            (
2909                "typescript",
2910                LangId::TypeScript,
2911                r#"bootstrap();
2912class Worker {
2913    run() {
2914        before();
2915        function nested() { nestedCall(); }
2916        nested();
2917    }
2918    next() { adjacentCall(); }
2919}
2920function left() { leftCall(); }
2921function right() { rightCall(); }
2922"#,
2923            ),
2924            (
2925                "python",
2926                LangId::Python,
2927                r#"bootstrap()
2928class Worker:
2929    def run(self):
2930        before()
2931        def nested():
2932            nested_call()
2933        nested()
2934
2935    def next(self):
2936        adjacent_call()
2937
2938def left():
2939    left_call()
2940
2941def right():
2942    right_call()
2943"#,
2944            ),
2945        ];
2946
2947        for (name, lang, source) in corpora {
2948            let (tree, symbols) = parse_symbols(source, lang);
2949            let reference =
2950                collect_calls_by_symbol_reference(source, tree.root_node(), lang, &symbols);
2951            let actual = collect_calls_by_symbol(source, tree.root_node(), lang, &symbols);
2952            assert_eq!(actual, reference, "call attribution changed for {name}");
2953
2954            let class_sites = actual.get("Worker").expect("class receives method calls");
2955            let method_sites = actual
2956                .get("Worker::run")
2957                .expect("method receives its own calls");
2958            assert!(class_sites.iter().any(|site| site.callee_name == "before"));
2959            assert!(method_sites.iter().any(|site| site.callee_name == "before"));
2960            let nested_sites = actual
2961                .iter()
2962                .find(|(symbol, _)| symbol.rsplit("::").next() == Some("nested"))
2963                .map(|(_, sites)| sites)
2964                .expect("nested function receives its own calls");
2965            assert!(nested_sites.iter().any(|site| {
2966                site.callee_name == "nested_call" || site.callee_name == "nestedCall"
2967            }));
2968            assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "bootstrap");
2969        }
2970
2971        let source = "first();second();third();";
2972        let (tree, _) = parse_symbols(source, LangId::TypeScript);
2973        let symbols = vec![
2974            test_symbol("outer", 0, 17),
2975            test_symbol("left", 0, 8),
2976            test_symbol("right", 8, 17),
2977            test_symbol("empty", 24, 24),
2978        ];
2979        let reference = collect_calls_by_symbol_reference(
2980            source,
2981            tree.root_node(),
2982            LangId::TypeScript,
2983            &symbols,
2984        );
2985        let actual =
2986            collect_calls_by_symbol(source, tree.root_node(), LangId::TypeScript, &symbols);
2987        assert_eq!(actual, reference, "overlapping and adjacent ranges changed");
2988        assert_eq!(
2989            actual["outer"]
2990                .iter()
2991                .map(|site| site.callee_name.as_str())
2992                .collect::<Vec<_>>(),
2993            ["first", "second"]
2994        );
2995        assert_eq!(actual["left"][0].callee_name, "first");
2996        assert_eq!(actual["right"][0].callee_name, "second");
2997        assert_eq!(actual[TOP_LEVEL_SYMBOL][0].callee_name, "third");
2998        assert!(!actual.contains_key("empty"));
2999    }
3000
3001    #[test]
3002    fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
3003        // exported_symbols carries the bare name; symbol_metadata is keyed by
3004        // scoped identity (impl method). A plain .get(bare) misses and would
3005        // force the degraded unknown/line-1 fallback. symbol_metadata_for must
3006        // recover the scoped entry via unqualified-name match.
3007        let mut symbol_metadata = HashMap::new();
3008        symbol_metadata.insert(
3009            "BackupStore::total_disk_bytes".to_string(),
3010            SymbolMeta {
3011                kind: SymbolKind::Method,
3012                exported: true,
3013                signature: None,
3014                line: 703,
3015                range: Range {
3016                    start_line: 702,
3017                    start_col: 0,
3018                    end_line: 705,
3019                    end_col: 0,
3020                },
3021                entry_point_attribute: None,
3022            },
3023        );
3024        let file_data = FileCallData {
3025            calls_by_symbol: HashMap::new(),
3026            value_refs_by_symbol: HashMap::new(),
3027            exported_symbols: vec!["total_disk_bytes".to_string()],
3028            symbol_metadata,
3029            default_export_symbol: None,
3030            import_block: ImportBlock::empty(),
3031            lang: LangId::Rust,
3032        };
3033
3034        let meta = file_data
3035            .symbol_metadata_for("total_disk_bytes")
3036            .expect("scoped method recovered by bare name");
3037        assert_eq!(meta.kind, SymbolKind::Method);
3038        assert_eq!(
3039            meta.line, 703,
3040            "real declaration line, not the line-1 fallback"
3041        );
3042
3043        // A genuinely-absent symbol still returns None (no false recovery).
3044        assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
3045    }
3046
3047    /// Create a temp directory with TypeScript files for testing.
3048    fn setup_ts_project() -> TempDir {
3049        let dir = TempDir::new().unwrap();
3050
3051        // main.ts: imports from utils and calls functions
3052        fs::write(
3053            dir.path().join("main.ts"),
3054            r#"import { helper, compute } from './utils';
3055import * as math from './math';
3056
3057export function main() {
3058    const a = helper(1);
3059    const b = compute(a, 2);
3060    const c = math.add(a, b);
3061    return c;
3062}
3063"#,
3064        )
3065        .unwrap();
3066
3067        // utils.ts: defines helper and compute, imports from helpers
3068        fs::write(
3069            dir.path().join("utils.ts"),
3070            r#"import { double } from './helpers';
3071
3072export function helper(x: number): number {
3073    return double(x);
3074}
3075
3076export function compute(a: number, b: number): number {
3077    return a + b;
3078}
3079"#,
3080        )
3081        .unwrap();
3082
3083        // helpers.ts: defines double
3084        fs::write(
3085            dir.path().join("helpers.ts"),
3086            r#"export function double(x: number): number {
3087    return x * 2;
3088}
3089
3090export function triple(x: number): number {
3091    return x * 3;
3092}
3093"#,
3094        )
3095        .unwrap();
3096
3097        // math.ts: defines add (for namespace import test)
3098        fs::write(
3099            dir.path().join("math.ts"),
3100            r#"export function add(a: number, b: number): number {
3101    return a + b;
3102}
3103
3104export function subtract(a: number, b: number): number {
3105    return a - b;
3106}
3107"#,
3108        )
3109        .unwrap();
3110
3111        dir
3112    }
3113
3114    /// Create a project with import aliasing.
3115    fn setup_alias_project() -> TempDir {
3116        let dir = TempDir::new().unwrap();
3117
3118        fs::write(
3119            dir.path().join("main.ts"),
3120            r#"import { helper as h } from './utils';
3121
3122export function main() {
3123    return h(42);
3124}
3125"#,
3126        )
3127        .unwrap();
3128
3129        fs::write(
3130            dir.path().join("utils.ts"),
3131            r#"export function helper(x: number): number {
3132    return x + 1;
3133}
3134"#,
3135        )
3136        .unwrap();
3137
3138        dir
3139    }
3140
3141    // --- Single-file call extraction ---
3142
3143    #[test]
3144    fn callgraph_single_file_call_extraction() {
3145        let dir = setup_ts_project();
3146        let mut graph = CallGraph::new(dir.path().to_path_buf());
3147
3148        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3149        let main_calls = &file_data.calls_by_symbol["main"];
3150
3151        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3152        assert!(
3153            callee_names.contains(&"helper"),
3154            "main should call helper, got: {:?}",
3155            callee_names
3156        );
3157        assert!(
3158            callee_names.contains(&"compute"),
3159            "main should call compute, got: {:?}",
3160            callee_names
3161        );
3162        assert!(
3163            callee_names.contains(&"add"),
3164            "main should call math.add (short name: add), got: {:?}",
3165            callee_names
3166        );
3167    }
3168
3169    #[test]
3170    fn callgraph_file_data_has_exports() {
3171        let dir = setup_ts_project();
3172        let mut graph = CallGraph::new(dir.path().to_path_buf());
3173
3174        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3175        assert!(
3176            file_data.exported_symbols.contains(&"helper".to_string()),
3177            "utils.ts should export helper, got: {:?}",
3178            file_data.exported_symbols
3179        );
3180        assert!(
3181            file_data.exported_symbols.contains(&"compute".to_string()),
3182            "utils.ts should export compute, got: {:?}",
3183            file_data.exported_symbols
3184        );
3185    }
3186
3187    // --- Cross-file resolution ---
3188
3189    #[test]
3190    fn callgraph_resolve_direct_import() {
3191        let dir = setup_ts_project();
3192        let mut graph = CallGraph::new(dir.path().to_path_buf());
3193
3194        let main_path = dir.path().join("main.ts");
3195        let file_data = graph.build_file(&main_path).unwrap();
3196        let import_block = file_data.import_block.clone();
3197
3198        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3199        match edge {
3200            EdgeResolution::Resolved { file, symbol } => {
3201                assert!(
3202                    file.ends_with("utils.ts"),
3203                    "helper should resolve to utils.ts, got: {:?}",
3204                    file
3205                );
3206                assert_eq!(symbol, "helper");
3207            }
3208            EdgeResolution::Unresolved { callee_name } => {
3209                panic!("Expected resolved, got unresolved: {}", callee_name);
3210            }
3211        }
3212    }
3213
3214    #[test]
3215    fn callgraph_resolve_namespace_import() {
3216        let dir = setup_ts_project();
3217        let mut graph = CallGraph::new(dir.path().to_path_buf());
3218
3219        let main_path = dir.path().join("main.ts");
3220        let file_data = graph.build_file(&main_path).unwrap();
3221        let import_block = file_data.import_block.clone();
3222
3223        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3224        match edge {
3225            EdgeResolution::Resolved { file, symbol } => {
3226                assert!(
3227                    file.ends_with("math.ts"),
3228                    "math.add should resolve to math.ts, got: {:?}",
3229                    file
3230                );
3231                assert_eq!(symbol, "add");
3232            }
3233            EdgeResolution::Unresolved { callee_name } => {
3234                panic!("Expected resolved, got unresolved: {}", callee_name);
3235            }
3236        }
3237    }
3238
3239    #[test]
3240    fn callgraph_resolve_aliased_import() {
3241        let dir = setup_alias_project();
3242        let mut graph = CallGraph::new(dir.path().to_path_buf());
3243
3244        let main_path = dir.path().join("main.ts");
3245        let file_data = graph.build_file(&main_path).unwrap();
3246        let import_block = file_data.import_block.clone();
3247
3248        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3249        match edge {
3250            EdgeResolution::Resolved { file, symbol } => {
3251                assert!(
3252                    file.ends_with("utils.ts"),
3253                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
3254                    file
3255                );
3256                assert_eq!(symbol, "helper");
3257            }
3258            EdgeResolution::Unresolved { callee_name } => {
3259                panic!("Expected resolved, got unresolved: {}", callee_name);
3260            }
3261        }
3262    }
3263
3264    #[test]
3265    fn callgraph_unresolved_edge_marked() {
3266        let dir = setup_ts_project();
3267        let mut graph = CallGraph::new(dir.path().to_path_buf());
3268
3269        let main_path = dir.path().join("main.ts");
3270        let file_data = graph.build_file(&main_path).unwrap();
3271        let import_block = file_data.import_block.clone();
3272
3273        let edge =
3274            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3275        assert_eq!(
3276            edge,
3277            EdgeResolution::Unresolved {
3278                callee_name: "unknownFunc".to_string()
3279            },
3280            "Unknown callee should be unresolved"
3281        );
3282    }
3283
3284    // --- Worktree walker ---
3285
3286    #[test]
3287    fn callgraph_walker_excludes_gitignored() {
3288        let dir = TempDir::new().unwrap();
3289
3290        // Create a .gitignore
3291        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3292
3293        // Create files
3294        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3295        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3296        fs::write(
3297            dir.path().join("ignored_dir").join("secret.ts"),
3298            "export function secret() {}",
3299        )
3300        .unwrap();
3301
3302        // Also create node_modules (should always be excluded)
3303        fs::create_dir(dir.path().join("node_modules")).unwrap();
3304        fs::write(
3305            dir.path().join("node_modules").join("dep.ts"),
3306            "export function dep() {}",
3307        )
3308        .unwrap();
3309
3310        // Init git repo for .gitignore to work
3311        let mut command = std::process::Command::new("git");
3312        crate::test_env::apply_hermetic_git_env(command.current_dir(dir.path()))
3313            .args(["init"])
3314            .output()
3315            .unwrap();
3316
3317        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3318        let file_names: Vec<String> = files
3319            .iter()
3320            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3321            .collect();
3322
3323        assert!(
3324            file_names.contains(&"main.ts".to_string()),
3325            "Should include main.ts, got: {:?}",
3326            file_names
3327        );
3328        assert!(
3329            !file_names.contains(&"secret.ts".to_string()),
3330            "Should exclude gitignored secret.ts, got: {:?}",
3331            file_names
3332        );
3333        assert!(
3334            !file_names.contains(&"dep.ts".to_string()),
3335            "Should exclude node_modules, got: {:?}",
3336            file_names
3337        );
3338    }
3339
3340    #[test]
3341    fn callgraph_walker_excludes_aftignored() {
3342        let dir = TempDir::new().unwrap();
3343
3344        // .aftignore is honored without a git repo (custom ignore file).
3345        fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
3346        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3347        fs::create_dir(dir.path().join("vendored")).unwrap();
3348        fs::write(
3349            dir.path().join("vendored").join("sub.ts"),
3350            "export function sub() {}",
3351        )
3352        .unwrap();
3353
3354        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3355        let file_names: Vec<String> = files
3356            .iter()
3357            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3358            .collect();
3359
3360        assert!(
3361            file_names.contains(&"main.ts".to_string()),
3362            "Should include main.ts, got: {:?}",
3363            file_names
3364        );
3365        assert!(
3366            !file_names.contains(&"sub.ts".to_string()),
3367            "Should exclude .aftignored sub.ts, got: {:?}",
3368            file_names
3369        );
3370    }
3371
3372    #[test]
3373    fn callgraph_walker_only_source_files() {
3374        let dir = TempDir::new().unwrap();
3375
3376        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3377        fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
3378        fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
3379        fs::write(
3380            dir.path().join("runtime.mjs"),
3381            "export function runtime() {}",
3382        )
3383        .unwrap();
3384        fs::write(
3385            dir.path().join("legacy.cjs"),
3386            "exports.legacy = function() {};",
3387        )
3388        .unwrap();
3389        fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
3390        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3391        fs::write(dir.path().join("data.json"), "{}").unwrap();
3392
3393        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3394        let file_names: Vec<String> = files
3395            .iter()
3396            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3397            .collect();
3398
3399        assert!(file_names.contains(&"main.ts".to_string()));
3400        for modern_ext_file in [
3401            "module.mts",
3402            "common.cts",
3403            "runtime.mjs",
3404            "legacy.cjs",
3405            "types.pyi",
3406        ] {
3407            assert!(
3408                file_names.contains(&modern_ext_file.to_string()),
3409                "walker should include {modern_ext_file}, got: {:?}",
3410                file_names
3411            );
3412        }
3413        assert!(
3414            file_names.contains(&"readme.md".to_string()),
3415            "Markdown is now a supported source language"
3416        );
3417        assert!(
3418            file_names.contains(&"data.json".to_string()),
3419            "JSON is now a supported source language"
3420        );
3421    }
3422
3423    // --- find_alias_original ---
3424
3425    #[test]
3426    fn callgraph_find_alias_original_simple() {
3427        let raw = "import { foo as bar } from './utils';";
3428        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3429    }
3430
3431    #[test]
3432    fn callgraph_find_alias_original_multiple() {
3433        let raw = "import { foo as bar, baz as qux } from './utils';";
3434        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3435        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3436    }
3437
3438    #[test]
3439    fn callgraph_find_alias_no_match() {
3440        let raw = "import { foo } from './utils';";
3441        assert_eq!(find_alias_original(raw, "foo"), None);
3442    }
3443
3444    // --- Reverse callers ---
3445
3446    #[test]
3447    fn is_entry_point_exported_function() {
3448        assert!(is_entry_point(
3449            "handleRequest",
3450            &SymbolKind::Function,
3451            true,
3452            LangId::TypeScript
3453        ));
3454    }
3455
3456    #[test]
3457    fn is_entry_point_exported_method_is_not_entry() {
3458        // Methods are class members, not standalone entry points
3459        assert!(!is_entry_point(
3460            "handleRequest",
3461            &SymbolKind::Method,
3462            true,
3463            LangId::TypeScript
3464        ));
3465    }
3466
3467    #[test]
3468    fn is_entry_point_main_init_patterns() {
3469        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3470            assert!(
3471                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3472                "{} should be an entry point",
3473                name
3474            );
3475        }
3476    }
3477
3478    #[test]
3479    fn is_entry_point_test_patterns_ts() {
3480        assert!(is_entry_point(
3481            "describe",
3482            &SymbolKind::Function,
3483            false,
3484            LangId::TypeScript
3485        ));
3486        assert!(is_entry_point(
3487            "it",
3488            &SymbolKind::Function,
3489            false,
3490            LangId::TypeScript
3491        ));
3492        assert!(is_entry_point(
3493            "test",
3494            &SymbolKind::Function,
3495            false,
3496            LangId::TypeScript
3497        ));
3498        assert!(is_entry_point(
3499            "testValidation",
3500            &SymbolKind::Function,
3501            false,
3502            LangId::TypeScript
3503        ));
3504        assert!(is_entry_point(
3505            "specHelper",
3506            &SymbolKind::Function,
3507            false,
3508            LangId::TypeScript
3509        ));
3510    }
3511
3512    #[test]
3513    fn is_entry_point_test_patterns_python() {
3514        assert!(is_entry_point(
3515            "test_login",
3516            &SymbolKind::Function,
3517            false,
3518            LangId::Python
3519        ));
3520        assert!(is_entry_point(
3521            "setUp",
3522            &SymbolKind::Function,
3523            false,
3524            LangId::Python
3525        ));
3526        assert!(is_entry_point(
3527            "tearDown",
3528            &SymbolKind::Function,
3529            false,
3530            LangId::Python
3531        ));
3532        // "testSomething" should NOT match Python (needs test_ prefix)
3533        assert!(!is_entry_point(
3534            "testSomething",
3535            &SymbolKind::Function,
3536            false,
3537            LangId::Python
3538        ));
3539    }
3540
3541    #[test]
3542    fn is_entry_point_test_patterns_rust() {
3543        assert!(is_entry_point(
3544            "test_parse",
3545            &SymbolKind::Function,
3546            false,
3547            LangId::Rust
3548        ));
3549        assert!(!is_entry_point(
3550            "TestSomething",
3551            &SymbolKind::Function,
3552            false,
3553            LangId::Rust
3554        ));
3555    }
3556
3557    #[test]
3558    fn is_entry_point_test_patterns_go() {
3559        assert!(is_entry_point(
3560            "TestParsing",
3561            &SymbolKind::Function,
3562            false,
3563            LangId::Go
3564        ));
3565        // lowercase test should NOT match Go (needs uppercase Test prefix)
3566        assert!(!is_entry_point(
3567            "testParsing",
3568            &SymbolKind::Function,
3569            false,
3570            LangId::Go
3571        ));
3572    }
3573
3574    #[test]
3575    fn is_entry_point_non_exported_non_main_is_not_entry() {
3576        assert!(!is_entry_point(
3577            "helperUtil",
3578            &SymbolKind::Function,
3579            false,
3580            LangId::TypeScript
3581        ));
3582    }
3583
3584    // --- symbol_metadata ---
3585
3586    #[test]
3587    fn callgraph_symbol_metadata_populated() {
3588        let dir = setup_ts_project();
3589        let mut graph = CallGraph::new(dir.path().to_path_buf());
3590
3591        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3592        assert!(
3593            file_data.symbol_metadata.contains_key("helper"),
3594            "symbol_metadata should contain helper"
3595        );
3596        let meta = &file_data.symbol_metadata["helper"];
3597        assert_eq!(meta.kind, SymbolKind::Function);
3598        assert!(meta.exported, "helper should be exported");
3599    }
3600
3601    #[test]
3602    fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
3603        let dir = TempDir::new().unwrap();
3604        fs::write(
3605            dir.path().join("main.ts"),
3606            r#"import * as lib from './index';
3607
3608export function main() {
3609    lib.helper();
3610    lib.hidden();
3611}
3612"#,
3613        )
3614        .unwrap();
3615        fs::write(
3616            dir.path().join("index.ts"),
3617            "export { helper } from './utils';\n",
3618        )
3619        .unwrap();
3620        fs::write(
3621            dir.path().join("utils.ts"),
3622            r#"export function helper() {}
3623function hidden() {}
3624"#,
3625        )
3626        .unwrap();
3627
3628        let mut graph = CallGraph::new(dir.path().to_path_buf());
3629        let main_path = dir.path().join("main.ts");
3630        let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
3631
3632        let helper =
3633            graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
3634        match helper {
3635            EdgeResolution::Resolved { file, symbol } => {
3636                assert!(
3637                    file.ends_with("utils.ts"),
3638                    "helper should resolve through barrel: {file:?}"
3639                );
3640                assert_eq!(symbol, "helper");
3641            }
3642            other => panic!("expected helper to resolve through barrel, got {other:?}"),
3643        }
3644
3645        let hidden =
3646            graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
3647        assert_eq!(
3648            hidden,
3649            EdgeResolution::Unresolved {
3650                callee_name: "hidden".to_string()
3651            }
3652        );
3653    }
3654
3655    #[test]
3656    fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
3657        let dir = TempDir::new().unwrap();
3658        fs::write(
3659            dir.path().join("package.json"),
3660            r#"{"workspaces":["packages/*"]}"#,
3661        )
3662        .unwrap();
3663        let package_dir = dir.path().join("packages/lib");
3664        fs::create_dir_all(package_dir.join("src")).unwrap();
3665        fs::create_dir_all(package_dir.join("dist")).unwrap();
3666        fs::write(
3667            package_dir.join("package.json"),
3668            r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
3669        )
3670        .unwrap();
3671        fs::write(
3672            package_dir.join("src/index.mts"),
3673            "export function helper() {}\n",
3674        )
3675        .unwrap();
3676        fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
3677
3678        let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
3679        assert!(
3680            resolved.ends_with("src/index.mts"),
3681            "dist/index.mjs should map to src/index.mts, got {resolved:?}"
3682        );
3683    }
3684
3685    #[test]
3686    fn same_named_methods_use_scoped_symbol_identity() {
3687        let dir = TempDir::new().unwrap();
3688        fs::write(
3689            dir.path().join("classes.ts"),
3690            r#"class A {
3691    run() { helperA(); }
3692}
3693
3694class B {
3695    run() { helperB(); }
3696}
3697
3698function helperA() {}
3699function helperB() {}
3700"#,
3701        )
3702        .unwrap();
3703
3704        let mut graph = CallGraph::new(dir.path().to_path_buf());
3705        let path = dir.path().join("classes.ts");
3706        let data = graph.build_file(&path).unwrap();
3707
3708        assert!(
3709            data.symbol_metadata.contains_key("A::run"),
3710            "A::run metadata missing"
3711        );
3712        assert!(
3713            data.symbol_metadata.contains_key("B::run"),
3714            "B::run metadata missing"
3715        );
3716        assert!(
3717            data.calls_by_symbol["A::run"]
3718                .iter()
3719                .any(|call| call.callee_name == "helperA"),
3720            "A::run calls should not be overwritten"
3721        );
3722        assert!(
3723            data.calls_by_symbol["B::run"]
3724                .iter()
3725                .any(|call| call.callee_name == "helperB"),
3726            "B::run calls should not be overwritten"
3727        );
3728    }
3729
3730    // --- extract_parameters ---
3731
3732    #[test]
3733    fn extract_parameters_typescript() {
3734        let params = extract_parameters(
3735            "function processData(input: string, count: number): void",
3736            LangId::TypeScript,
3737        );
3738        assert_eq!(params, vec!["input", "count"]);
3739    }
3740
3741    #[test]
3742    fn extract_parameters_typescript_optional() {
3743        let params = extract_parameters(
3744            "function fetch(url: string, options?: RequestInit): Promise<Response>",
3745            LangId::TypeScript,
3746        );
3747        assert_eq!(params, vec!["url", "options"]);
3748    }
3749
3750    #[test]
3751    fn extract_parameters_typescript_defaults() {
3752        let params = extract_parameters(
3753            "function greet(name: string, greeting: string = \"hello\"): string",
3754            LangId::TypeScript,
3755        );
3756        assert_eq!(params, vec!["name", "greeting"]);
3757    }
3758
3759    #[test]
3760    fn extract_parameters_typescript_rest() {
3761        let params = extract_parameters(
3762            "function sum(...numbers: number[]): number",
3763            LangId::TypeScript,
3764        );
3765        assert_eq!(params, vec!["numbers"]);
3766    }
3767
3768    #[test]
3769    fn extract_parameters_python_self_skipped() {
3770        let params = extract_parameters(
3771            "def process(self, data: str, count: int) -> bool",
3772            LangId::Python,
3773        );
3774        assert_eq!(params, vec!["data", "count"]);
3775    }
3776
3777    #[test]
3778    fn extract_parameters_python_no_self() {
3779        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3780        assert_eq!(params, vec!["input"]);
3781    }
3782
3783    #[test]
3784    fn extract_parameters_python_star_args() {
3785        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3786        assert_eq!(params, vec!["args", "kwargs"]);
3787    }
3788
3789    #[test]
3790    fn extract_parameters_rust_self_skipped() {
3791        let params = extract_parameters(
3792            "fn process(&self, data: &str, count: usize) -> bool",
3793            LangId::Rust,
3794        );
3795        assert_eq!(params, vec!["data", "count"]);
3796    }
3797
3798    #[test]
3799    fn extract_parameters_rust_mut_self_skipped() {
3800        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3801        assert_eq!(params, vec!["value"]);
3802    }
3803
3804    #[test]
3805    fn extract_parameters_rust_no_self() {
3806        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3807        assert_eq!(params, vec!["input"]);
3808    }
3809
3810    #[test]
3811    fn extract_parameters_rust_mut_param() {
3812        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3813        assert_eq!(params, vec!["buf", "len"]);
3814    }
3815
3816    #[test]
3817    fn extract_parameters_go() {
3818        let params = extract_parameters(
3819            "func ProcessData(input string, count int) error",
3820            LangId::Go,
3821        );
3822        assert_eq!(params, vec!["input", "count"]);
3823    }
3824
3825    #[test]
3826    fn extract_parameters_empty() {
3827        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3828        assert!(
3829            params.is_empty(),
3830            "no-arg function should return empty params"
3831        );
3832    }
3833
3834    #[test]
3835    fn extract_parameters_no_parens() {
3836        let params = extract_parameters("const x = 42", LangId::TypeScript);
3837        assert!(params.is_empty(), "no parens should return empty params");
3838    }
3839
3840    #[test]
3841    fn extract_parameters_javascript() {
3842        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3843        assert_eq!(params, vec!["event", "target"]);
3844    }
3845}