Skip to main content

aft/
callgraph.rs

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