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 user-provided symbol query to the unique scoped symbol identity
783    /// used internally by the call graph.
784    pub fn resolve_symbol_query(&mut self, file: &Path, symbol: &str) -> Result<String, AftError> {
785        let canon = self.canonicalize(file)?;
786        let file_data = self.build_file(&canon)?;
787        resolve_symbol_query_in_data(file_data, &canon, symbol)
788    }
789
790    /// Resolve a cross-file call edge.
791    ///
792    /// Given a callee expression and the calling file's import block,
793    /// determines which file and symbol the call targets.
794    pub fn resolve_cross_file_edge(
795        &mut self,
796        full_callee: &str,
797        short_name: &str,
798        caller_file: &Path,
799        import_block: &ImportBlock,
800    ) -> EdgeResolution {
801        let graph = RefCell::new(self);
802        Self::resolve_cross_file_edge_with_exports(
803            full_callee,
804            short_name,
805            caller_file,
806            import_block,
807            |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
808            |path| graph.borrow_mut().file_default_export_symbol(path),
809        )
810    }
811
812    /// Check if a file exports a given symbol name.
813    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
814        match self.build_file(path) {
815            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
816            Err(_) => false,
817        }
818    }
819
820    fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
821        self.build_file(path)
822            .ok()
823            .and_then(|data| data.default_export_symbol.clone())
824    }
825
826    /// Invalidate a file by removing its cached call data.
827    pub fn invalidate_file(&mut self, path: &Path) {
828        // Remove from data cache (try both as-is and canonicalized)
829        self.data.remove(path);
830        if let Ok(canon) = self.canonicalize(path) {
831            self.data.remove(&canon);
832        }
833        clear_workspace_package_cache();
834    }
835
836    /// Canonicalize a path, falling back to the original if canonicalization fails.
837    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
838        // If the path is relative, resolve it against project_root
839        let full_path = if path.is_relative() {
840            self.project_root.join(path)
841        } else {
842            path.to_path_buf()
843        };
844
845        // Try canonicalize, fall back to the full path
846        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
847    }
848}
849
850// ---------------------------------------------------------------------------
851// File-level building
852// ---------------------------------------------------------------------------
853
854/// Build call data for a single file.
855pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
856    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
857        message: format!("unsupported file for call graph: {}", path.display()),
858    })?;
859
860    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
861        path: format!("{}: {}", path.display(), e),
862    })?;
863
864    build_file_data_from_source_with_lang(path, &source, lang)
865}
866
867pub(crate) fn build_file_data_from_source(
868    path: &Path,
869    source: &str,
870) -> Result<FileCallData, AftError> {
871    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
872        message: format!("unsupported file for call graph: {}", path.display()),
873    })?;
874    build_file_data_from_source_with_lang(path, source, lang)
875}
876
877fn build_file_data_from_source_with_lang(
878    path: &Path,
879    source: &str,
880    lang: LangId,
881) -> Result<FileCallData, AftError> {
882    let grammar = grammar_for(lang);
883    let mut parser = Parser::new();
884    parser
885        .set_language(&grammar)
886        .map_err(|e| AftError::ParseError {
887            message: format!("grammar init failed for {:?}: {}", lang, e),
888        })?;
889
890    let tree = parser
891        .parse(&source, None)
892        .ok_or_else(|| AftError::ParseError {
893            message: format!("parse failed for {}", path.display()),
894        })?;
895
896    // Parse imports
897    let import_block = imports::parse_imports(&source, &tree, lang);
898
899    // Get symbols (for call site extraction and export detection)
900    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
901
902    // Build calls_by_symbol
903    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
904    let root = tree.root_node();
905
906    for sym in &symbols {
907        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
908        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
909
910        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
911
912        let sites: Vec<CallSite> = raw_calls
913            .into_iter()
914            .map(
915                |(full, short, line, call_byte_start, call_byte_end)| CallSite {
916                    callee_name: short,
917                    full_callee: full,
918                    line,
919                    byte_start: call_byte_start,
920                    byte_end: call_byte_end,
921                },
922            )
923            .collect();
924
925        if !sites.is_empty() {
926            calls_by_symbol.insert(symbol_identity(sym), sites);
927        }
928    }
929
930    let symbol_ranges: Vec<(usize, usize)> = symbols
931        .iter()
932        .map(|sym| {
933            (
934                line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
935                line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
936            )
937        })
938        .collect();
939
940    let top_level_sites: Vec<CallSite> =
941        collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
942            .into_iter()
943            .filter(|site| {
944                !symbol_ranges
945                    .iter()
946                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
947            })
948            .map(|site| CallSite {
949                callee_name: site.short,
950                full_callee: site.full,
951                line: site.line,
952                byte_start: site.byte_start,
953                byte_end: site.byte_end,
954            })
955            .collect();
956
957    if !top_level_sites.is_empty() {
958        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
959    }
960
961    let default_export = find_default_export(&source, root, path, lang);
962
963    if let Some(default_export) = &default_export {
964        if default_export.synthetic {
965            let byte_start = default_export.node.byte_range().start;
966            let byte_end = default_export.node.byte_range().end;
967            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
968            let sites: Vec<CallSite> = raw_calls
969                .into_iter()
970                .filter(|(_, short, _, _, _)| *short != default_export.symbol)
971                .map(
972                    |(full, short, line, call_byte_start, call_byte_end)| CallSite {
973                        callee_name: short,
974                        full_callee: full,
975                        line,
976                        byte_start: call_byte_start,
977                        byte_end: call_byte_end,
978                    },
979                )
980                .collect();
981            if !sites.is_empty() {
982                calls_by_symbol.insert(default_export.symbol.clone(), sites);
983            }
984        }
985    }
986
987    // Collect exported symbol names
988    let mut exported_symbols: Vec<String> = symbols
989        .iter()
990        .filter(|s| s.exported)
991        .map(|s| s.name.clone())
992        .collect();
993    if let Some(default_export) = &default_export {
994        if !exported_symbols
995            .iter()
996            .any(|name| name == &default_export.symbol)
997        {
998            exported_symbols.push(default_export.symbol.clone());
999        }
1000    }
1001
1002    let rust_attribute_entry_points = if lang == LangId::Rust {
1003        crate::parser::rust_attribute_entry_points(&source, root)
1004            .into_iter()
1005            .map(|entry| (entry.scoped_name, entry.attribute.to_string()))
1006            .collect::<HashMap<_, _>>()
1007    } else {
1008        HashMap::new()
1009    };
1010
1011    // Build per-symbol metadata for entry point detection
1012    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
1013        .iter()
1014        .map(|s| {
1015            let identity = symbol_identity(s);
1016            (
1017                identity.clone(),
1018                SymbolMeta {
1019                    kind: s.kind.clone(),
1020                    exported: s.exported,
1021                    signature: s.signature.clone(),
1022                    line: s.range.start_line + 1,
1023                    range: s.range.clone(),
1024                    entry_point_attribute: rust_attribute_entry_points.get(&identity).cloned(),
1025                },
1026            )
1027        })
1028        .collect();
1029    if let Some(default_export) = &default_export {
1030        symbol_metadata
1031            .entry(default_export.symbol.clone())
1032            .or_insert_with(|| SymbolMeta {
1033                kind: default_export.kind.clone(),
1034                exported: true,
1035                signature: Some(first_line_signature(&source, &default_export.node)),
1036                line: default_export.node.start_position().row as u32 + 1,
1037                range: crate::parser::node_range(&default_export.node),
1038                entry_point_attribute: None,
1039            });
1040    }
1041    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
1042        symbol_metadata
1043            .entry(TOP_LEVEL_SYMBOL.to_string())
1044            .or_insert(SymbolMeta {
1045                kind: SymbolKind::Function,
1046                exported: false,
1047                signature: None,
1048                line: 1,
1049                range: Range {
1050                    start_line: 0,
1051                    start_col: 0,
1052                    end_line: 0,
1053                    end_col: 0,
1054                },
1055                entry_point_attribute: None,
1056            });
1057    }
1058
1059    Ok(FileCallData {
1060        calls_by_symbol,
1061        exported_symbols,
1062        symbol_metadata,
1063        default_export_symbol: default_export.map(|export| export.symbol),
1064        import_block,
1065        lang,
1066    })
1067}
1068
1069#[derive(Debug, Clone)]
1070struct DefaultExport<'tree> {
1071    symbol: String,
1072    synthetic: bool,
1073    kind: SymbolKind,
1074    node: Node<'tree>,
1075}
1076
1077fn find_default_export<'tree>(
1078    source: &str,
1079    root: Node<'tree>,
1080    path: &Path,
1081    lang: LangId,
1082) -> Option<DefaultExport<'tree>> {
1083    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
1084        return None;
1085    }
1086    find_default_export_inner(source, root, path)
1087}
1088
1089fn find_default_export_inner<'tree>(
1090    source: &str,
1091    node: Node<'tree>,
1092    path: &Path,
1093) -> Option<DefaultExport<'tree>> {
1094    if node.kind() == "export_statement" {
1095        if let Some(default_export) = default_export_from_statement(source, node, path) {
1096            return Some(default_export);
1097        }
1098    }
1099
1100    let mut cursor = node.walk();
1101    if !cursor.goto_first_child() {
1102        return None;
1103    }
1104
1105    loop {
1106        let child = cursor.node();
1107        if let Some(default_export) = find_default_export_inner(source, child, path) {
1108            return Some(default_export);
1109        }
1110        if !cursor.goto_next_sibling() {
1111            break;
1112        }
1113    }
1114
1115    None
1116}
1117
1118fn default_export_from_statement<'tree>(
1119    source: &str,
1120    node: Node<'tree>,
1121    path: &Path,
1122) -> Option<DefaultExport<'tree>> {
1123    let mut cursor = node.walk();
1124    if !cursor.goto_first_child() {
1125        return None;
1126    }
1127
1128    let mut saw_default = false;
1129    loop {
1130        let child = cursor.node();
1131        match child.kind() {
1132            "default" => saw_default = true,
1133            "function_declaration" | "generator_function_declaration" | "class_declaration"
1134                if saw_default =>
1135            {
1136                if let Some(name_node) = child.child_by_field_name("name") {
1137                    return Some(DefaultExport {
1138                        symbol: source[name_node.byte_range()].to_string(),
1139                        synthetic: false,
1140                        kind: default_export_kind(&child),
1141                        node: child,
1142                    });
1143                }
1144                return Some(DefaultExport {
1145                    symbol: synthetic_default_symbol(path),
1146                    synthetic: true,
1147                    kind: default_export_kind(&child),
1148                    node: child,
1149                });
1150            }
1151            "arrow_function"
1152            | "function"
1153            | "function_expression"
1154            | "class"
1155            | "class_expression"
1156                if saw_default =>
1157            {
1158                return Some(DefaultExport {
1159                    symbol: synthetic_default_symbol(path),
1160                    synthetic: true,
1161                    kind: default_export_kind(&child),
1162                    node: child,
1163                });
1164            }
1165            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
1166                return Some(DefaultExport {
1167                    symbol: source[child.byte_range()].to_string(),
1168                    synthetic: false,
1169                    kind: SymbolKind::Function,
1170                    node: child,
1171                });
1172            }
1173            _ => {}
1174        }
1175        if !cursor.goto_next_sibling() {
1176            break;
1177        }
1178    }
1179
1180    None
1181}
1182
1183fn default_export_kind(node: &Node) -> SymbolKind {
1184    if node.kind().contains("class") {
1185        SymbolKind::Class
1186    } else {
1187        SymbolKind::Function
1188    }
1189}
1190
1191fn synthetic_default_symbol(path: &Path) -> String {
1192    let file_name = path
1193        .file_name()
1194        .and_then(|name| name.to_str())
1195        .unwrap_or("unknown");
1196    format!("<default:{file_name}>")
1197}
1198
1199fn first_line_signature(source: &str, node: &Node) -> String {
1200    let text = &source[node.byte_range()];
1201    let first_line = text.lines().next().unwrap_or(text);
1202    first_line
1203        .trim_end()
1204        .trim_end_matches('{')
1205        .trim_end()
1206        .to_string()
1207}
1208
1209fn node_text(node: tree_sitter::Node, source: &str) -> String {
1210    source[node.start_byte()..node.end_byte()].to_string()
1211}
1212
1213/// Find a direct child node by kind name.
1214fn find_child_by_kind<'a>(
1215    node: tree_sitter::Node<'a>,
1216    kind: &str,
1217) -> Option<tree_sitter::Node<'a>> {
1218    let mut cursor = node.walk();
1219    if cursor.goto_first_child() {
1220        loop {
1221            if cursor.node().kind() == kind {
1222                return Some(cursor.node());
1223            }
1224            if !cursor.goto_next_sibling() {
1225                break;
1226            }
1227        }
1228    }
1229    None
1230}
1231
1232#[derive(Debug, Clone)]
1233struct CallSiteWithRange {
1234    full: String,
1235    short: String,
1236    line: u32,
1237    byte_start: usize,
1238    byte_end: usize,
1239}
1240
1241fn collect_calls_full_with_ranges(
1242    root: tree_sitter::Node,
1243    source: &str,
1244    byte_start: usize,
1245    byte_end: usize,
1246    lang: LangId,
1247) -> Vec<CallSiteWithRange> {
1248    let mut results = Vec::new();
1249    let call_kinds = call_node_kinds(lang);
1250    collect_calls_full_with_ranges_inner(
1251        root,
1252        source,
1253        byte_start,
1254        byte_end,
1255        &call_kinds,
1256        &mut results,
1257    );
1258    results
1259}
1260
1261fn collect_calls_full_with_ranges_inner(
1262    node: tree_sitter::Node,
1263    source: &str,
1264    byte_start: usize,
1265    byte_end: usize,
1266    call_kinds: &[&str],
1267    results: &mut Vec<CallSiteWithRange>,
1268) {
1269    let node_start = node.start_byte();
1270    let node_end = node.end_byte();
1271
1272    if node_end <= byte_start || node_start >= byte_end {
1273        return;
1274    }
1275
1276    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
1277        if let (Some(full), Some(short)) = (
1278            extract_full_callee(&node, source),
1279            extract_callee_name(&node, source),
1280        ) {
1281            results.push(CallSiteWithRange {
1282                full,
1283                short,
1284                line: node.start_position().row as u32 + 1,
1285                byte_start: node_start,
1286                byte_end: node_end,
1287            });
1288        }
1289    }
1290
1291    let mut cursor = node.walk();
1292    if cursor.goto_first_child() {
1293        loop {
1294            collect_calls_full_with_ranges_inner(
1295                cursor.node(),
1296                source,
1297                byte_start,
1298                byte_end,
1299                call_kinds,
1300                results,
1301            );
1302            if !cursor.goto_next_sibling() {
1303                break;
1304            }
1305        }
1306    }
1307}
1308
1309// ---------------------------------------------------------------------------
1310// Module path resolution
1311// ---------------------------------------------------------------------------
1312
1313/// Resolve a module path (e.g. './utils') relative to a directory.
1314///
1315/// Tries common file extensions for TypeScript/JavaScript projects.
1316pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1317    if module_path.starts_with('.') {
1318        return resolve_relative_module_path(from_dir, module_path);
1319    }
1320
1321    if module_path.starts_with('/') {
1322        return None;
1323    }
1324
1325    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
1326        return Some(path);
1327    }
1328
1329    resolve_workspace_module_path(from_dir, module_path)
1330}
1331
1332fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1333    let base = from_dir.join(module_path);
1334    resolve_file_like_path(&base)
1335}
1336
1337fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
1338    let base = base.to_path_buf();
1339
1340    // Try exact path first
1341    if base.is_file() {
1342        return Some(std::fs::canonicalize(&base).unwrap_or(base));
1343    }
1344
1345    // Try common extensions, including ESM/CJS TypeScript pairs used by workspaces.
1346    for ext in JS_TS_EXTENSIONS {
1347        let with_ext = base.with_extension(ext);
1348        if with_ext.is_file() {
1349            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
1350        }
1351    }
1352
1353    // Try as directory with index file
1354    if base.is_dir() {
1355        if let Some(index) = find_index_file(&base) {
1356            return Some(index);
1357        }
1358    }
1359
1360    None
1361}
1362
1363fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1364    let (package_name, subpath) = split_package_import(module_path)?;
1365    let package_root = find_package_root_for_import(from_dir, &package_name)?;
1366    resolve_package_entry(&package_root, &subpath)
1367}
1368
1369fn is_rust_source_file(path: &Path) -> bool {
1370    path.extension().and_then(|ext| ext.to_str()) == Some("rs")
1371}
1372
1373fn resolve_rust_cross_file_edge<F>(
1374    full_callee: &str,
1375    short_name: &str,
1376    caller_file: &Path,
1377    import_block: &ImportBlock,
1378    file_exports_symbol: &mut F,
1379) -> Option<ResolvedSymbol>
1380where
1381    F: FnMut(&Path, &str) -> bool,
1382{
1383    if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
1384    {
1385        return Some(target);
1386    }
1387
1388    resolve_rust_imported_call(
1389        caller_file,
1390        full_callee,
1391        short_name,
1392        import_block,
1393        file_exports_symbol,
1394    )
1395}
1396
1397fn resolve_rust_qualified_call<F>(
1398    caller_file: &Path,
1399    full_callee: &str,
1400    file_exports_symbol: &mut F,
1401) -> Option<ResolvedSymbol>
1402where
1403    F: FnMut(&Path, &str) -> bool,
1404{
1405    if !full_callee.contains("::") {
1406        return None;
1407    }
1408
1409    let segments = rust_path_segments(full_callee)?;
1410    resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
1411}
1412
1413fn resolve_rust_imported_call<F>(
1414    caller_file: &Path,
1415    full_callee: &str,
1416    short_name: &str,
1417    import_block: &ImportBlock,
1418    file_exports_symbol: &mut F,
1419) -> Option<ResolvedSymbol>
1420where
1421    F: FnMut(&Path, &str) -> bool,
1422{
1423    let call_segments = rust_path_segments(full_callee).unwrap_or_default();
1424    let bare_call_name = if call_segments.len() <= 1 {
1425        call_segments
1426            .first()
1427            .map(String::as_str)
1428            .unwrap_or(short_name)
1429    } else {
1430        short_name
1431    };
1432
1433    for imp in &import_block.imports {
1434        for entry in rust_use_entries(imp) {
1435            match &entry.kind {
1436                RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
1437                    if entry.local_name != bare_call_name {
1438                        continue;
1439                    }
1440                    let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
1441                    else {
1442                        continue;
1443                    };
1444                    if file_exports_symbol(&file, imported_name) {
1445                        return Some(ResolvedSymbol {
1446                            file,
1447                            symbol: imported_name.clone(),
1448                        });
1449                    }
1450                }
1451                RustUseKind::Module if call_segments.len() >= 2 => {
1452                    if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
1453                    {
1454                        continue;
1455                    }
1456                    let symbol = call_segments.last()?.clone();
1457                    let mut module_path = entry.module_path.clone();
1458                    for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
1459                        module_path.push_str("::");
1460                        module_path.push_str(segment);
1461                    }
1462                    let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
1463                        continue;
1464                    };
1465                    if file_exports_symbol(&file, &symbol) {
1466                        return Some(ResolvedSymbol { file, symbol });
1467                    }
1468                }
1469                _ => {}
1470            }
1471        }
1472    }
1473
1474    None
1475}
1476
1477fn resolve_rust_call_segments<F>(
1478    caller_file: &Path,
1479    segments: &[String],
1480    file_exports_symbol: &mut F,
1481) -> Option<ResolvedSymbol>
1482where
1483    F: FnMut(&Path, &str) -> bool,
1484{
1485    if segments.len() < 2 {
1486        return None;
1487    }
1488
1489    let symbol = segments.last()?.clone();
1490    let module_path = segments[..segments.len() - 1].join("::");
1491    let file = resolve_rust_module_path(caller_file, &module_path)?;
1492    if file_exports_symbol(&file, &symbol) {
1493        Some(ResolvedSymbol { file, symbol })
1494    } else {
1495        None
1496    }
1497}
1498
1499fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
1500    let segments = rust_path_segments(module_path)?;
1501    let first = segments.first()?.as_str();
1502
1503    match first {
1504        "std" | "core" | "alloc" => None,
1505        "crate" => {
1506            let crate_root = find_rust_crate_root(caller_file)?;
1507            let crate_info = rust_crate_info(&crate_root)?;
1508            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1509            resolve_rust_module_segments(&base, &segments[1..])
1510        }
1511        "self" => {
1512            let crate_root = find_rust_crate_root(caller_file)?;
1513            let crate_info = rust_crate_info(&crate_root)?;
1514            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1515            if segments.len() == 1 {
1516                return Some(canonicalize_path(caller_file));
1517            }
1518            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1519            target_segments.extend(segments[1..].iter().cloned());
1520            resolve_rust_module_segments(&base, &target_segments)
1521        }
1522        "super" => {
1523            let crate_root = find_rust_crate_root(caller_file)?;
1524            let crate_info = rust_crate_info(&crate_root)?;
1525            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
1526            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
1527            target_segments.pop();
1528            target_segments.extend(segments[1..].iter().cloned());
1529            resolve_rust_module_segments(&base, &target_segments)
1530        }
1531        crate_name => {
1532            let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
1533            let workspace_crates = rust_workspace_crates(caller_dir)?;
1534            let crate_info = workspace_crates.get(crate_name)?;
1535            let base = rust_lib_module_base(crate_info)?;
1536            resolve_rust_module_segments(&base, &segments[1..])
1537        }
1538    }
1539}
1540
1541fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
1542    let Some(body) = rust_use_body(&imp.raw_text) else {
1543        return Vec::new();
1544    };
1545    let mut entries = Vec::new();
1546    expand_rust_use_tree(body, &mut entries);
1547    entries
1548}
1549
1550fn rust_use_body(raw: &str) -> Option<&str> {
1551    let use_pos = raw.find("use ")?;
1552    let body = raw[use_pos + 4..].trim();
1553    let body = body.strip_suffix(';').unwrap_or(body).trim();
1554    (!body.is_empty()).then_some(body)
1555}
1556
1557fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
1558    let path = path.trim();
1559    if path.is_empty() {
1560        return;
1561    }
1562
1563    if let Some((prefix, inner)) = split_rust_use_braces(path) {
1564        let prefix = prefix.trim().trim_end_matches("::").trim();
1565        for part in split_top_level_commas(inner) {
1566            let part = part.trim();
1567            if part.is_empty() {
1568                continue;
1569            }
1570            if part == "self" {
1571                if let Some(local_name) = rust_last_path_segment(prefix) {
1572                    entries.push(RustUseEntry {
1573                        module_path: prefix.to_string(),
1574                        local_name,
1575                        kind: RustUseKind::Module,
1576                    });
1577                }
1578                continue;
1579            }
1580            let combined = if prefix.is_empty() {
1581                part.to_string()
1582            } else {
1583                format!("{prefix}::{part}")
1584            };
1585            expand_rust_use_tree(&combined, entries);
1586        }
1587        return;
1588    }
1589
1590    add_rust_use_leaf(path, entries);
1591}
1592
1593fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
1594    let mut depth = 0usize;
1595    let mut start = None;
1596    for (idx, ch) in path.char_indices() {
1597        match ch {
1598            '{' => {
1599                if depth == 0 {
1600                    start = Some(idx);
1601                }
1602                depth += 1;
1603            }
1604            '}' => {
1605                depth = depth.checked_sub(1)?;
1606                if depth == 0 {
1607                    let start = start?;
1608                    if !path[idx + ch.len_utf8()..].trim().is_empty() {
1609                        return None;
1610                    }
1611                    return Some((&path[..start], &path[start + 1..idx]));
1612                }
1613            }
1614            _ => {}
1615        }
1616    }
1617    None
1618}
1619
1620fn split_top_level_commas(value: &str) -> Vec<&str> {
1621    let mut parts = Vec::new();
1622    let mut depth = 0usize;
1623    let mut start = 0usize;
1624    for (idx, ch) in value.char_indices() {
1625        match ch {
1626            '{' => depth += 1,
1627            '}' => depth = depth.saturating_sub(1),
1628            ',' if depth == 0 => {
1629                parts.push(&value[start..idx]);
1630                start = idx + ch.len_utf8();
1631            }
1632            _ => {}
1633        }
1634    }
1635    parts.push(&value[start..]);
1636    parts
1637}
1638
1639fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
1640    let (path, alias) = split_rust_alias(path);
1641    let Some(segments) = rust_path_segments(path) else {
1642        return;
1643    };
1644    if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
1645        return;
1646    }
1647
1648    let imported_name = segments.last().cloned().unwrap_or_default();
1649    let local_name = alias.unwrap_or(&imported_name).to_string();
1650    if segments.len() >= 2 {
1651        entries.push(RustUseEntry {
1652            module_path: segments[..segments.len() - 1].join("::"),
1653            local_name: local_name.clone(),
1654            kind: RustUseKind::Item {
1655                imported_name: imported_name.clone(),
1656            },
1657        });
1658    }
1659
1660    entries.push(RustUseEntry {
1661        module_path: segments.join("::"),
1662        local_name,
1663        kind: RustUseKind::Module,
1664    });
1665}
1666
1667fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
1668    if let Some(idx) = path.rfind(" as ") {
1669        let original = path[..idx].trim();
1670        let alias = path[idx + 4..].trim();
1671        if !original.is_empty() && !alias.is_empty() {
1672            return (original, Some(alias));
1673        }
1674    }
1675    (path.trim(), None)
1676}
1677
1678fn rust_path_segments(path: &str) -> Option<Vec<String>> {
1679    let path = path.trim().trim_end_matches(';').trim();
1680    if path.is_empty() || path.contains('{') || path.contains('}') {
1681        return None;
1682    }
1683
1684    let mut segments = Vec::new();
1685    for raw_segment in path.split("::") {
1686        let segment = raw_segment.trim();
1687        if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
1688            return None;
1689        }
1690        let segment = segment.strip_prefix("r#").unwrap_or(segment);
1691        if segment
1692            .chars()
1693            .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
1694        {
1695            return None;
1696        }
1697        segments.push(segment.to_string());
1698    }
1699
1700    (!segments.is_empty()).then_some(segments)
1701}
1702
1703fn rust_last_path_segment(path: &str) -> Option<String> {
1704    rust_path_segments(path)?.last().cloned()
1705}
1706
1707fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
1708    let mut current = if from.is_file() {
1709        from.parent()
1710    } else {
1711        Some(from)
1712    };
1713    while let Some(dir) = current {
1714        if dir.join("Cargo.toml").is_file() {
1715            return Some(canonicalize_path(dir));
1716        }
1717        current = dir.parent();
1718    }
1719    None
1720}
1721
1722fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1723    let root = canonicalize_path(crate_root);
1724    if let Some(cached) = RUST_CRATE_INFO_CACHE
1725        .read()
1726        .ok()
1727        .and_then(|cache| cache.get(&root).cloned())
1728    {
1729        return cached;
1730    }
1731
1732    let resolved = read_rust_crate_info(&root);
1733    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
1734        cache.insert(root, resolved.clone());
1735    }
1736    resolved
1737}
1738
1739fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
1740    let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
1741    let package = cargo.get("package")?;
1742    let package_name = package.get("name")?.as_str()?;
1743    let lib_name = cargo
1744        .get("lib")
1745        .and_then(|lib| lib.get("name"))
1746        .and_then(|name| name.as_str())
1747        .map(ToOwned::to_owned)
1748        .unwrap_or_else(|| package_name.replace('-', "_"));
1749
1750    let lib_root = cargo
1751        .get("lib")
1752        .and_then(|lib| lib.get("path"))
1753        .and_then(|path| path.as_str())
1754        .map(|path| crate_root.join(path))
1755        .unwrap_or_else(|| crate_root.join("src/lib.rs"));
1756    let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
1757
1758    let main_root = crate_root.join("src/main.rs");
1759    let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
1760
1761    Some(RustCrateInfo {
1762        lib_name,
1763        lib_root,
1764        main_root,
1765    })
1766}
1767
1768fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
1769    let source = std::fs::read_to_string(path).ok()?;
1770    toml::from_str(&source).ok()
1771}
1772
1773fn rust_module_base_for_caller(
1774    crate_info: &RustCrateInfo,
1775    caller_file: &Path,
1776) -> Option<RustModuleBase> {
1777    let caller = canonicalize_path(caller_file);
1778    if crate_info.main_root.as_ref() == Some(&caller) {
1779        return rust_main_module_base(crate_info);
1780    }
1781    rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
1782}
1783
1784fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1785    let root_file = crate_info.lib_root.clone()?;
1786    let src_dir = root_file.parent()?.to_path_buf();
1787    Some(RustModuleBase { src_dir, root_file })
1788}
1789
1790fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
1791    let root_file = crate_info.main_root.clone()?;
1792    let src_dir = root_file.parent()?.to_path_buf();
1793    Some(RustModuleBase { src_dir, root_file })
1794}
1795
1796fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
1797    if segments.is_empty() {
1798        return Some(base.root_file.clone());
1799    }
1800
1801    let module_base = segments
1802        .iter()
1803        .fold(base.src_dir.clone(), |path, segment| path.join(segment));
1804    let file_path = module_base.with_extension("rs");
1805    if file_path.is_file() {
1806        return Some(canonicalize_path(&file_path));
1807    }
1808
1809    let mod_path = module_base.join("mod.rs");
1810    if mod_path.is_file() {
1811        return Some(canonicalize_path(&mod_path));
1812    }
1813
1814    None
1815}
1816
1817fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
1818    let src_dir = canonicalize_path(src_dir);
1819    let file = canonicalize_path(file);
1820    let rel = file.strip_prefix(&src_dir).ok()?;
1821    let mut parts: Vec<String> = rel
1822        .components()
1823        .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
1824        .collect();
1825    if parts.is_empty() {
1826        return None;
1827    }
1828
1829    let last = parts.pop()?;
1830    if last == "lib.rs" || last == "main.rs" {
1831        return Some(Vec::new());
1832    }
1833    if last == "mod.rs" {
1834        return Some(parts);
1835    }
1836    let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
1837    parts.push(stem);
1838    Some(parts)
1839}
1840
1841fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
1842    let workspace_root =
1843        find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
1844    let workspace_root = canonicalize_path(&workspace_root);
1845
1846    if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
1847        .read()
1848        .ok()
1849        .and_then(|cache| cache.get(&workspace_root).cloned())
1850    {
1851        return Some(cached);
1852    }
1853
1854    let mut crates = HashMap::new();
1855    for member in rust_workspace_member_dirs(&workspace_root) {
1856        if let Some(info) = rust_crate_info(&member) {
1857            if info.lib_root.is_some() {
1858                crates.insert(info.lib_name.clone(), info);
1859            }
1860        }
1861    }
1862    if let Some(info) = rust_crate_info(&workspace_root) {
1863        if info.lib_root.is_some() {
1864            crates.insert(info.lib_name.clone(), info);
1865        }
1866    }
1867
1868    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
1869        cache.insert(workspace_root, crates.clone());
1870    }
1871    Some(crates)
1872}
1873
1874fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
1875    let mut current = Some(from_dir);
1876    while let Some(dir) = current {
1877        let cargo = dir.join("Cargo.toml");
1878        if rust_manifest_value(&cargo)
1879            .and_then(|value| value.get("workspace").cloned())
1880            .is_some()
1881        {
1882            return Some(canonicalize_path(dir));
1883        }
1884        current = dir.parent();
1885    }
1886    None
1887}
1888
1889fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
1890    let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
1891        return Vec::new();
1892    };
1893    let Some(members) = cargo
1894        .get("workspace")
1895        .and_then(|workspace| workspace.get("members"))
1896        .and_then(|members| members.as_array())
1897    else {
1898        return Vec::new();
1899    };
1900
1901    let mut dirs = Vec::new();
1902    for member in members.iter().filter_map(|member| member.as_str()) {
1903        dirs.extend(expand_rust_workspace_member(workspace_root, member));
1904    }
1905    dirs.sort();
1906    dirs.dedup();
1907    dirs
1908}
1909
1910fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
1911    let member = member.trim();
1912    if member.is_empty() {
1913        return Vec::new();
1914    }
1915
1916    if member.contains('*') || member.contains('?') || member.contains('[') {
1917        let pattern = workspace_root.join(member).to_string_lossy().to_string();
1918        return glob::glob(&pattern)
1919            .ok()
1920            .into_iter()
1921            .flatten()
1922            .filter_map(Result::ok)
1923            .filter(|path| path.join("Cargo.toml").is_file())
1924            .map(|path| canonicalize_path(&path))
1925            .collect();
1926    }
1927
1928    let path = workspace_root.join(member);
1929    if path.join("Cargo.toml").is_file() {
1930        vec![canonicalize_path(&path)]
1931    } else {
1932        Vec::new()
1933    }
1934}
1935
1936fn canonicalize_path(path: &Path) -> PathBuf {
1937    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1938}
1939
1940fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
1941    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
1942    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
1943    let compiler_options = tsconfig.get("compilerOptions")?;
1944    let paths = compiler_options.get("paths")?.as_object()?;
1945    let base_url = compiler_options
1946        .get("baseUrl")
1947        .and_then(Value::as_str)
1948        .unwrap_or(".");
1949    let base_dir = tsconfig_dir.join(base_url);
1950
1951    for (alias, targets) in paths {
1952        let Some(capture) = ts_path_capture(alias, module_path) else {
1953            continue;
1954        };
1955        let Some(targets) = targets.as_array() else {
1956            continue;
1957        };
1958        for target in targets.iter().filter_map(Value::as_str) {
1959            let target = if target.contains('*') {
1960                target.replace('*', capture)
1961            } else {
1962                target.to_string()
1963            };
1964            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
1965                return Some(path);
1966            }
1967        }
1968    }
1969
1970    None
1971}
1972
1973fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
1974    let mut current = Some(from_dir);
1975    while let Some(dir) = current {
1976        if dir.join("tsconfig.json").is_file() {
1977            return Some(dir.to_path_buf());
1978        }
1979        current = dir.parent();
1980    }
1981    None
1982}
1983
1984fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
1985    if let Some(star_index) = alias.find('*') {
1986        let (prefix, suffix_with_star) = alias.split_at(star_index);
1987        let suffix = &suffix_with_star[1..];
1988        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
1989            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
1990        }
1991        return None;
1992    }
1993
1994    (alias == module_path).then_some("")
1995}
1996
1997fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
1998    let mut parts = module_path.split('/');
1999    let first = parts.next()?;
2000    if first.is_empty() {
2001        return None;
2002    }
2003
2004    if first.starts_with('@') {
2005        let second = parts.next()?;
2006        if second.is_empty() {
2007            return None;
2008        }
2009        let package_name = format!("{first}/{second}");
2010        let subpath = parts.collect::<Vec<_>>().join("/");
2011        let subpath = (!subpath.is_empty()).then_some(subpath);
2012        Some((package_name, subpath))
2013    } else {
2014        let package_name = first.to_string();
2015        let subpath = parts.collect::<Vec<_>>().join("/");
2016        let subpath = (!subpath.is_empty()).then_some(subpath);
2017        Some((package_name, subpath))
2018    }
2019}
2020
2021fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2022    let mut current = Some(from_dir);
2023    while let Some(dir) = current {
2024        if package_json_name(dir).as_deref() == Some(package_name) {
2025            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2026        }
2027        current = dir.parent();
2028    }
2029
2030    find_workspace_root(from_dir)
2031        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2032}
2033
2034fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2035    let mut current = Some(from_dir);
2036    while let Some(dir) = current {
2037        if is_workspace_root(dir) {
2038            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2039        }
2040        current = dir.parent();
2041    }
2042    None
2043}
2044
2045fn is_workspace_root(dir: &Path) -> bool {
2046    package_json_value(dir)
2047        .map(|value| !workspace_patterns(&value).is_empty())
2048        .unwrap_or(false)
2049        || !pnpm_workspace_patterns(dir).is_empty()
2050}
2051
2052pub(crate) fn clear_workspace_package_cache() {
2053    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2054        cache.clear();
2055    }
2056    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
2057        cache.clear();
2058    }
2059    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
2060        cache.clear();
2061    }
2062}
2063
2064fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2065    let workspace_root =
2066        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2067    let cache_key = (workspace_root.clone(), package_name.to_string());
2068
2069    if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
2070        if let Some(cached) = cache.get(&cache_key) {
2071            return cached.clone();
2072        }
2073    }
2074
2075    let resolved = workspace_member_dirs(&workspace_root)
2076        .into_iter()
2077        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2078        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2079
2080    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2081        cache.insert(cache_key, resolved.clone());
2082    }
2083
2084    resolved
2085}
2086
2087fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2088    let mut patterns = package_json_value(workspace_root)
2089        .map(|package_json| workspace_patterns(&package_json))
2090        .unwrap_or_default();
2091    patterns.extend(pnpm_workspace_patterns(workspace_root));
2092
2093    expand_workspace_patterns(workspace_root, &patterns)
2094}
2095
2096fn workspace_patterns(package_json: &Value) -> Vec<String> {
2097    match package_json.get("workspaces") {
2098        Some(Value::Array(items)) => items
2099            .iter()
2100            .filter_map(non_empty_workspace_pattern)
2101            .collect(),
2102        Some(Value::Object(map)) => map
2103            .get("packages")
2104            .and_then(Value::as_array)
2105            .map(|items| {
2106                items
2107                    .iter()
2108                    .filter_map(non_empty_workspace_pattern)
2109                    .collect()
2110            })
2111            .unwrap_or_default(),
2112        _ => Vec::new(),
2113    }
2114}
2115
2116fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2117    let pattern = value.as_str()?.trim();
2118    (!pattern.is_empty()).then(|| pattern.to_string())
2119}
2120
2121fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2122    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2123        return Vec::new();
2124    };
2125
2126    let mut patterns = Vec::new();
2127    let mut in_packages = false;
2128    for line in source.lines() {
2129        let without_comment = line.split('#').next().unwrap_or("").trim_end();
2130        let trimmed = without_comment.trim();
2131        if trimmed.is_empty() {
2132            continue;
2133        }
2134        if trimmed == "packages:" {
2135            in_packages = true;
2136            continue;
2137        }
2138        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2139            in_packages = false;
2140        }
2141        if in_packages {
2142            if let Some(pattern) = trimmed.strip_prefix('-') {
2143                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2144                if !pattern.is_empty() {
2145                    patterns.push(pattern.to_string());
2146                }
2147            }
2148        }
2149    }
2150    patterns
2151}
2152
2153fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2154    let positive_patterns: Vec<&str> = patterns
2155        .iter()
2156        .map(|pattern| pattern.trim())
2157        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2158        .collect();
2159    if positive_patterns.is_empty() {
2160        return Vec::new();
2161    }
2162
2163    let positives = build_glob_set(&positive_patterns);
2164    let negative_patterns: Vec<&str> = patterns
2165        .iter()
2166        .map(|pattern| pattern.trim())
2167        .filter_map(|pattern| pattern.strip_prefix('!'))
2168        .map(str::trim)
2169        .filter(|pattern| !pattern.is_empty())
2170        .collect();
2171    let negatives = build_glob_set(&negative_patterns);
2172
2173    let mut members = Vec::new();
2174    collect_workspace_member_dirs(
2175        workspace_root,
2176        workspace_root,
2177        &positives,
2178        &negatives,
2179        &mut members,
2180    );
2181    members
2182}
2183
2184fn build_glob_set(patterns: &[&str]) -> GlobSet {
2185    let mut builder = GlobSetBuilder::new();
2186    for pattern in patterns {
2187        if let Ok(glob) = Glob::new(pattern) {
2188            builder.add(glob);
2189        }
2190    }
2191    builder
2192        .build()
2193        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
2194}
2195
2196fn collect_workspace_member_dirs(
2197    workspace_root: &Path,
2198    dir: &Path,
2199    positives: &GlobSet,
2200    negatives: &GlobSet,
2201    members: &mut Vec<PathBuf>,
2202) {
2203    let Ok(entries) = std::fs::read_dir(dir) else {
2204        return;
2205    };
2206
2207    for entry in entries.filter_map(Result::ok) {
2208        let path = entry.path();
2209        let Ok(file_type) = entry.file_type() else {
2210            continue;
2211        };
2212        if !file_type.is_dir() {
2213            continue;
2214        }
2215        let name = entry.file_name();
2216        let name = name.to_string_lossy();
2217        if matches!(
2218            name.as_ref(),
2219            "node_modules" | ".git" | "target" | "dist" | "build"
2220        ) {
2221            continue;
2222        }
2223
2224        if path.join("package.json").is_file() {
2225            if let Ok(rel) = path.strip_prefix(workspace_root) {
2226                let rel = rel.to_string_lossy().replace('\\', "/");
2227                if positives.is_match(&rel) && !negatives.is_match(&rel) {
2228                    members.push(path.clone());
2229                }
2230            }
2231        }
2232
2233        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
2234    }
2235}
2236
2237fn package_json_value(dir: &Path) -> Option<Value> {
2238    package_json_like_value(&dir.join("package.json"))
2239}
2240
2241fn package_json_like_value(path: &Path) -> Option<Value> {
2242    let json = std::fs::read_to_string(path).ok()?;
2243    serde_json::from_str(&json).ok()
2244}
2245
2246fn package_json_name(dir: &Path) -> Option<String> {
2247    package_json_value(dir)?
2248        .get("name")?
2249        .as_str()
2250        .map(ToOwned::to_owned)
2251}
2252
2253fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
2254    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
2255
2256    if let Some(exports) = package_json.get("exports") {
2257        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
2258            if let Some(path) = resolve_package_target(package_root, &target) {
2259                return Some(path);
2260            }
2261        }
2262    }
2263
2264    if subpath.is_none() {
2265        for field in ["module", "main"] {
2266            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
2267                if let Some(path) = resolve_package_target(package_root, target) {
2268                    return Some(path);
2269                }
2270            }
2271        }
2272    }
2273
2274    resolve_package_fallback(package_root, subpath.as_deref())
2275}
2276
2277fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
2278    let key = subpath
2279        .map(|value| format!("./{value}"))
2280        .unwrap_or_else(|| ".".to_string());
2281
2282    match exports {
2283        Value::String(target) if key == "." => Some(target.clone()),
2284        Value::Object(map) => {
2285            if let Some(target) = map.get(&key).and_then(export_condition_target) {
2286                return Some(target);
2287            }
2288
2289            if let Some(target) = wildcard_export_target(map, &key) {
2290                return Some(target);
2291            }
2292
2293            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
2294                return export_condition_target(exports);
2295            }
2296
2297            None
2298        }
2299        _ => None,
2300    }
2301}
2302
2303fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
2304    for (pattern, target) in map {
2305        let Some(star_index) = pattern.find('*') else {
2306            continue;
2307        };
2308        let (prefix, suffix_with_star) = pattern.split_at(star_index);
2309        let suffix = &suffix_with_star[1..];
2310        if !key.starts_with(prefix) || !key.ends_with(suffix) {
2311            continue;
2312        }
2313        let matched = &key[prefix.len()..key.len() - suffix.len()];
2314        if let Some(target_pattern) = export_condition_target(target) {
2315            return Some(target_pattern.replace('*', matched));
2316        }
2317    }
2318    None
2319}
2320
2321fn export_condition_target(value: &Value) -> Option<String> {
2322    match value {
2323        Value::String(target) => Some(target.clone()),
2324        Value::Object(map) => ["source", "import", "module", "default", "types"]
2325            .into_iter()
2326            .find_map(|field| map.get(field).and_then(export_condition_target)),
2327        _ => None,
2328    }
2329}
2330
2331fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
2332    let target = target.strip_prefix("./").unwrap_or(target);
2333    // Prefer source over compiled bundle when both exist: the callgraph
2334    // walks source files and cannot extract symbols from a built JS bundle.
2335    if let Some(src_relative) = target.strip_prefix("dist/") {
2336        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
2337            return Some(path);
2338        }
2339    }
2340
2341    resolve_file_like_path(&package_root.join(target))
2342}
2343
2344fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
2345    match subpath {
2346        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
2347            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
2348        None => resolve_file_like_path(&package_root.join("src").join("index"))
2349            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
2350    }
2351}
2352
2353pub(crate) fn resolve_reexported_symbol_target<F, D>(
2354    file: &Path,
2355    symbol_name: &str,
2356    file_exports_symbol: &mut F,
2357    file_default_export_symbol: &mut D,
2358) -> Option<(PathBuf, String)>
2359where
2360    F: FnMut(&Path, &str) -> bool,
2361    D: FnMut(&Path) -> Option<String>,
2362{
2363    resolve_reexported_symbol(
2364        file,
2365        symbol_name,
2366        file_exports_symbol,
2367        file_default_export_symbol,
2368    )
2369    .map(|target| (target.file, target.symbol))
2370}
2371
2372fn resolve_reexported_symbol<F, D>(
2373    file: &Path,
2374    symbol_name: &str,
2375    file_exports_symbol: &mut F,
2376    file_default_export_symbol: &mut D,
2377) -> Option<ResolvedSymbol>
2378where
2379    F: FnMut(&Path, &str) -> bool,
2380    D: FnMut(&Path) -> Option<String>,
2381{
2382    let mut visited = HashSet::new();
2383    resolve_reexported_symbol_inner(
2384        file,
2385        symbol_name,
2386        file_exports_symbol,
2387        file_default_export_symbol,
2388        &mut visited,
2389    )
2390}
2391
2392fn resolve_reexported_symbol_inner<F, D>(
2393    file: &Path,
2394    symbol_name: &str,
2395    file_exports_symbol: &mut F,
2396    file_default_export_symbol: &mut D,
2397    visited: &mut HashSet<(PathBuf, String)>,
2398) -> Option<ResolvedSymbol>
2399where
2400    F: FnMut(&Path, &str) -> bool,
2401    D: FnMut(&Path) -> Option<String>,
2402{
2403    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2404    if !visited.insert((canon.clone(), symbol_name.to_string())) {
2405        return None;
2406    }
2407
2408    let source = std::fs::read_to_string(&canon).ok()?;
2409    let lang = detect_language(&canon)?;
2410    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2411        if symbol_name == "default" {
2412            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
2413                file: canon,
2414                symbol,
2415            });
2416        }
2417        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
2418            file: canon,
2419            symbol: symbol_name.to_string(),
2420        });
2421    }
2422
2423    let grammar = grammar_for(lang);
2424    let mut parser = Parser::new();
2425    parser.set_language(&grammar).ok()?;
2426    let tree = parser.parse(&source, None)?;
2427    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
2428
2429    let mut cursor = tree.root_node().walk();
2430    if !cursor.goto_first_child() {
2431        return None;
2432    }
2433
2434    loop {
2435        let node = cursor.node();
2436        if node.kind() == "export_statement" {
2437            if let Some(target) = resolve_reexport_statement(
2438                &source,
2439                node,
2440                from_dir,
2441                symbol_name,
2442                file_exports_symbol,
2443                file_default_export_symbol,
2444                visited,
2445            ) {
2446                return Some(target);
2447            }
2448        }
2449
2450        if !cursor.goto_next_sibling() {
2451            break;
2452        }
2453    }
2454
2455    if symbol_name == "default" {
2456        if let Some(symbol) = file_default_export_symbol(&canon) {
2457            return Some(ResolvedSymbol {
2458                file: canon,
2459                symbol,
2460            });
2461        }
2462    }
2463
2464    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
2465        return Some(ResolvedSymbol {
2466            file: canon,
2467            symbol,
2468        });
2469    }
2470
2471    if file_exports_symbol(&canon, symbol_name) {
2472        let symbol = symbol_name.to_string();
2473        return Some(ResolvedSymbol {
2474            file: canon,
2475            symbol,
2476        });
2477    }
2478
2479    None
2480}
2481
2482fn resolve_reexport_statement<F, D>(
2483    source: &str,
2484    node: tree_sitter::Node,
2485    from_dir: &Path,
2486    symbol_name: &str,
2487    file_exports_symbol: &mut F,
2488    file_default_export_symbol: &mut D,
2489    visited: &mut HashSet<(PathBuf, String)>,
2490) -> Option<ResolvedSymbol>
2491where
2492    F: FnMut(&Path, &str) -> bool,
2493    D: FnMut(&Path) -> Option<String>,
2494{
2495    let source_node = node
2496        .child_by_field_name("source")
2497        .or_else(|| find_child_by_kind(node, "string"))?;
2498    let module_path = string_literal_content(source, source_node)?;
2499    let target_file = resolve_module_path(from_dir, &module_path)?;
2500    let raw_export = node_text(node, source);
2501
2502    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
2503        return resolve_reexported_symbol_inner(
2504            &target_file,
2505            &source_symbol,
2506            file_exports_symbol,
2507            file_default_export_symbol,
2508            visited,
2509        )
2510        .or(Some(ResolvedSymbol {
2511            file: target_file,
2512            symbol: source_symbol,
2513        }));
2514    }
2515
2516    if raw_export.contains('*') {
2517        return resolve_reexported_symbol_inner(
2518            &target_file,
2519            symbol_name,
2520            file_exports_symbol,
2521            file_default_export_symbol,
2522            visited,
2523        );
2524    }
2525
2526    None
2527}
2528
2529fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
2530    let lang = detect_language(file)?;
2531    let grammar = grammar_for(lang);
2532    let mut parser = Parser::new();
2533    parser.set_language(&grammar).ok()?;
2534    let tree = parser.parse(source, None)?;
2535
2536    let mut cursor = tree.root_node().walk();
2537    if !cursor.goto_first_child() {
2538        return None;
2539    }
2540
2541    loop {
2542        let node = cursor.node();
2543        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
2544            let raw_export = node_text(node, source);
2545            if let Some(source_symbol) =
2546                reexport_clause_source_symbol(&raw_export, requested_export)
2547            {
2548                return Some(source_symbol);
2549            }
2550        }
2551
2552        if !cursor.goto_next_sibling() {
2553            break;
2554        }
2555    }
2556
2557    None
2558}
2559
2560fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
2561    let start = raw_export.find('{')? + 1;
2562    let end = raw_export[start..].find('}')? + start;
2563    for specifier in raw_export[start..end].split(',') {
2564        let specifier = specifier.trim();
2565        if specifier.is_empty() {
2566            continue;
2567        }
2568        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
2569        if let Some((imported, exported)) = specifier.split_once(" as ") {
2570            if exported.trim() == requested_export {
2571                return Some(imported.trim().to_string());
2572            }
2573        } else if specifier == requested_export {
2574            return Some(requested_export.to_string());
2575        }
2576    }
2577    None
2578}
2579
2580fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
2581    let raw = source[node.byte_range()].trim();
2582    let quote = raw.chars().next()?;
2583    if quote != '\'' && quote != '"' {
2584        return None;
2585    }
2586    raw.strip_prefix(quote)
2587        .and_then(|value| value.strip_suffix(quote))
2588        .map(ToOwned::to_owned)
2589}
2590
2591/// Find an index file in a directory.
2592fn find_index_file(dir: &Path) -> Option<PathBuf> {
2593    for name in JS_TS_INDEX_FILES {
2594        let p = dir.join(name);
2595        if p.is_file() {
2596            return Some(std::fs::canonicalize(&p).unwrap_or(p));
2597        }
2598    }
2599    None
2600}
2601
2602/// Resolve an aliased import: `import { foo as bar } from './utils'`
2603/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
2604fn resolve_aliased_import(
2605    local_name: &str,
2606    import_block: &ImportBlock,
2607    caller_dir: &Path,
2608) -> Option<(String, PathBuf)> {
2609    for imp in &import_block.imports {
2610        // Parse the raw text to find "as <alias>" patterns
2611        // This handles: import { foo as bar, baz as qux } from './mod'
2612        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2613            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2614                return Some((original, resolved_path));
2615            }
2616        }
2617    }
2618    None
2619}
2620
2621/// Parse import raw text to find the original name for an alias.
2622/// Given raw text like `import { foo as bar, baz } from './utils'` and
2623/// local_name "bar", returns Some("foo").
2624fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2625    // Look for pattern: <original> as <alias>
2626    // This is a simple text-based search; handles the common TS/JS pattern
2627    let search = format!(" as {}", local_name);
2628    if let Some(pos) = raw_import.find(&search) {
2629        // Walk backwards from `pos` to find the original name
2630        let before = &raw_import[..pos];
2631        // The original name is the last word-like token before " as "
2632        let original = before
2633            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2634            .find(|s| !s.is_empty())?;
2635        return Some(original.to_string());
2636    }
2637    None
2638}
2639
2640// ---------------------------------------------------------------------------
2641// Worktree file discovery
2642// ---------------------------------------------------------------------------
2643
2644/// Walk project files respecting .gitignore, excluding common non-source dirs.
2645///
2646/// Returns an iterator of file paths for supported source file types.
2647pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2648    use ignore::WalkBuilder;
2649
2650    let walker = WalkBuilder::new(root)
2651        .hidden(true)         // skip hidden files/dirs
2652        .git_ignore(true)     // respect .gitignore
2653        .git_global(true)     // respect global gitignore
2654        .git_exclude(true)    // respect .git/info/exclude
2655        .add_custom_ignore_filename(".aftignore") // AFT-specific ignores (e.g. submodules)
2656        .filter_entry(|entry| {
2657            let name = entry.file_name().to_string_lossy();
2658            // Always exclude these directories regardless of .gitignore
2659            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2660                return !matches!(
2661                    name.as_ref(),
2662                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2663                        | ".tox" | "dist" | "build"
2664                );
2665            }
2666            true
2667        })
2668        .build();
2669
2670    walker
2671        .filter_map(|entry| entry.ok())
2672        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2673        .filter(|entry| detect_language(entry.path()).is_some())
2674        .map(|entry| entry.into_path())
2675}
2676
2677// ---------------------------------------------------------------------------
2678// Tests
2679// ---------------------------------------------------------------------------
2680
2681#[cfg(test)]
2682mod tests {
2683    use super::*;
2684    use std::fs;
2685    use tempfile::TempDir;
2686
2687    #[test]
2688    fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
2689        // exported_symbols carries the bare name; symbol_metadata is keyed by
2690        // scoped identity (impl method). A plain .get(bare) misses and would
2691        // force the degraded unknown/line-1 fallback. symbol_metadata_for must
2692        // recover the scoped entry via unqualified-name match.
2693        let mut symbol_metadata = HashMap::new();
2694        symbol_metadata.insert(
2695            "BackupStore::total_disk_bytes".to_string(),
2696            SymbolMeta {
2697                kind: SymbolKind::Method,
2698                exported: true,
2699                signature: None,
2700                line: 703,
2701                range: Range {
2702                    start_line: 702,
2703                    start_col: 0,
2704                    end_line: 705,
2705                    end_col: 0,
2706                },
2707                entry_point_attribute: None,
2708            },
2709        );
2710        let file_data = FileCallData {
2711            calls_by_symbol: HashMap::new(),
2712            exported_symbols: vec!["total_disk_bytes".to_string()],
2713            symbol_metadata,
2714            default_export_symbol: None,
2715            import_block: ImportBlock::empty(),
2716            lang: LangId::Rust,
2717        };
2718
2719        let meta = file_data
2720            .symbol_metadata_for("total_disk_bytes")
2721            .expect("scoped method recovered by bare name");
2722        assert_eq!(meta.kind, SymbolKind::Method);
2723        assert_eq!(
2724            meta.line, 703,
2725            "real declaration line, not the line-1 fallback"
2726        );
2727
2728        // A genuinely-absent symbol still returns None (no false recovery).
2729        assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
2730    }
2731
2732    /// Create a temp directory with TypeScript files for testing.
2733    fn setup_ts_project() -> TempDir {
2734        let dir = TempDir::new().unwrap();
2735
2736        // main.ts: imports from utils and calls functions
2737        fs::write(
2738            dir.path().join("main.ts"),
2739            r#"import { helper, compute } from './utils';
2740import * as math from './math';
2741
2742export function main() {
2743    const a = helper(1);
2744    const b = compute(a, 2);
2745    const c = math.add(a, b);
2746    return c;
2747}
2748"#,
2749        )
2750        .unwrap();
2751
2752        // utils.ts: defines helper and compute, imports from helpers
2753        fs::write(
2754            dir.path().join("utils.ts"),
2755            r#"import { double } from './helpers';
2756
2757export function helper(x: number): number {
2758    return double(x);
2759}
2760
2761export function compute(a: number, b: number): number {
2762    return a + b;
2763}
2764"#,
2765        )
2766        .unwrap();
2767
2768        // helpers.ts: defines double
2769        fs::write(
2770            dir.path().join("helpers.ts"),
2771            r#"export function double(x: number): number {
2772    return x * 2;
2773}
2774
2775export function triple(x: number): number {
2776    return x * 3;
2777}
2778"#,
2779        )
2780        .unwrap();
2781
2782        // math.ts: defines add (for namespace import test)
2783        fs::write(
2784            dir.path().join("math.ts"),
2785            r#"export function add(a: number, b: number): number {
2786    return a + b;
2787}
2788
2789export function subtract(a: number, b: number): number {
2790    return a - b;
2791}
2792"#,
2793        )
2794        .unwrap();
2795
2796        dir
2797    }
2798
2799    /// Create a project with import aliasing.
2800    fn setup_alias_project() -> TempDir {
2801        let dir = TempDir::new().unwrap();
2802
2803        fs::write(
2804            dir.path().join("main.ts"),
2805            r#"import { helper as h } from './utils';
2806
2807export function main() {
2808    return h(42);
2809}
2810"#,
2811        )
2812        .unwrap();
2813
2814        fs::write(
2815            dir.path().join("utils.ts"),
2816            r#"export function helper(x: number): number {
2817    return x + 1;
2818}
2819"#,
2820        )
2821        .unwrap();
2822
2823        dir
2824    }
2825
2826    // --- Single-file call extraction ---
2827
2828    #[test]
2829    fn callgraph_single_file_call_extraction() {
2830        let dir = setup_ts_project();
2831        let mut graph = CallGraph::new(dir.path().to_path_buf());
2832
2833        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2834        let main_calls = &file_data.calls_by_symbol["main"];
2835
2836        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2837        assert!(
2838            callee_names.contains(&"helper"),
2839            "main should call helper, got: {:?}",
2840            callee_names
2841        );
2842        assert!(
2843            callee_names.contains(&"compute"),
2844            "main should call compute, got: {:?}",
2845            callee_names
2846        );
2847        assert!(
2848            callee_names.contains(&"add"),
2849            "main should call math.add (short name: add), got: {:?}",
2850            callee_names
2851        );
2852    }
2853
2854    #[test]
2855    fn callgraph_file_data_has_exports() {
2856        let dir = setup_ts_project();
2857        let mut graph = CallGraph::new(dir.path().to_path_buf());
2858
2859        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2860        assert!(
2861            file_data.exported_symbols.contains(&"helper".to_string()),
2862            "utils.ts should export helper, got: {:?}",
2863            file_data.exported_symbols
2864        );
2865        assert!(
2866            file_data.exported_symbols.contains(&"compute".to_string()),
2867            "utils.ts should export compute, got: {:?}",
2868            file_data.exported_symbols
2869        );
2870    }
2871
2872    // --- Cross-file resolution ---
2873
2874    #[test]
2875    fn callgraph_resolve_direct_import() {
2876        let dir = setup_ts_project();
2877        let mut graph = CallGraph::new(dir.path().to_path_buf());
2878
2879        let main_path = dir.path().join("main.ts");
2880        let file_data = graph.build_file(&main_path).unwrap();
2881        let import_block = file_data.import_block.clone();
2882
2883        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2884        match edge {
2885            EdgeResolution::Resolved { file, symbol } => {
2886                assert!(
2887                    file.ends_with("utils.ts"),
2888                    "helper should resolve to utils.ts, got: {:?}",
2889                    file
2890                );
2891                assert_eq!(symbol, "helper");
2892            }
2893            EdgeResolution::Unresolved { callee_name } => {
2894                panic!("Expected resolved, got unresolved: {}", callee_name);
2895            }
2896        }
2897    }
2898
2899    #[test]
2900    fn callgraph_resolve_namespace_import() {
2901        let dir = setup_ts_project();
2902        let mut graph = CallGraph::new(dir.path().to_path_buf());
2903
2904        let main_path = dir.path().join("main.ts");
2905        let file_data = graph.build_file(&main_path).unwrap();
2906        let import_block = file_data.import_block.clone();
2907
2908        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2909        match edge {
2910            EdgeResolution::Resolved { file, symbol } => {
2911                assert!(
2912                    file.ends_with("math.ts"),
2913                    "math.add should resolve to math.ts, got: {:?}",
2914                    file
2915                );
2916                assert_eq!(symbol, "add");
2917            }
2918            EdgeResolution::Unresolved { callee_name } => {
2919                panic!("Expected resolved, got unresolved: {}", callee_name);
2920            }
2921        }
2922    }
2923
2924    #[test]
2925    fn callgraph_resolve_aliased_import() {
2926        let dir = setup_alias_project();
2927        let mut graph = CallGraph::new(dir.path().to_path_buf());
2928
2929        let main_path = dir.path().join("main.ts");
2930        let file_data = graph.build_file(&main_path).unwrap();
2931        let import_block = file_data.import_block.clone();
2932
2933        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2934        match edge {
2935            EdgeResolution::Resolved { file, symbol } => {
2936                assert!(
2937                    file.ends_with("utils.ts"),
2938                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
2939                    file
2940                );
2941                assert_eq!(symbol, "helper");
2942            }
2943            EdgeResolution::Unresolved { callee_name } => {
2944                panic!("Expected resolved, got unresolved: {}", callee_name);
2945            }
2946        }
2947    }
2948
2949    #[test]
2950    fn callgraph_unresolved_edge_marked() {
2951        let dir = setup_ts_project();
2952        let mut graph = CallGraph::new(dir.path().to_path_buf());
2953
2954        let main_path = dir.path().join("main.ts");
2955        let file_data = graph.build_file(&main_path).unwrap();
2956        let import_block = file_data.import_block.clone();
2957
2958        let edge =
2959            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2960        assert_eq!(
2961            edge,
2962            EdgeResolution::Unresolved {
2963                callee_name: "unknownFunc".to_string()
2964            },
2965            "Unknown callee should be unresolved"
2966        );
2967    }
2968
2969    // --- Worktree walker ---
2970
2971    #[test]
2972    fn callgraph_walker_excludes_gitignored() {
2973        let dir = TempDir::new().unwrap();
2974
2975        // Create a .gitignore
2976        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2977
2978        // Create files
2979        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2980        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2981        fs::write(
2982            dir.path().join("ignored_dir").join("secret.ts"),
2983            "export function secret() {}",
2984        )
2985        .unwrap();
2986
2987        // Also create node_modules (should always be excluded)
2988        fs::create_dir(dir.path().join("node_modules")).unwrap();
2989        fs::write(
2990            dir.path().join("node_modules").join("dep.ts"),
2991            "export function dep() {}",
2992        )
2993        .unwrap();
2994
2995        // Init git repo for .gitignore to work
2996        std::process::Command::new("git")
2997            .args(["init"])
2998            .current_dir(dir.path())
2999            .output()
3000            .unwrap();
3001
3002        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3003        let file_names: Vec<String> = files
3004            .iter()
3005            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3006            .collect();
3007
3008        assert!(
3009            file_names.contains(&"main.ts".to_string()),
3010            "Should include main.ts, got: {:?}",
3011            file_names
3012        );
3013        assert!(
3014            !file_names.contains(&"secret.ts".to_string()),
3015            "Should exclude gitignored secret.ts, got: {:?}",
3016            file_names
3017        );
3018        assert!(
3019            !file_names.contains(&"dep.ts".to_string()),
3020            "Should exclude node_modules, got: {:?}",
3021            file_names
3022        );
3023    }
3024
3025    #[test]
3026    fn callgraph_walker_excludes_aftignored() {
3027        let dir = TempDir::new().unwrap();
3028
3029        // .aftignore is honored without a git repo (custom ignore file).
3030        fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
3031        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3032        fs::create_dir(dir.path().join("vendored")).unwrap();
3033        fs::write(
3034            dir.path().join("vendored").join("sub.ts"),
3035            "export function sub() {}",
3036        )
3037        .unwrap();
3038
3039        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3040        let file_names: Vec<String> = files
3041            .iter()
3042            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3043            .collect();
3044
3045        assert!(
3046            file_names.contains(&"main.ts".to_string()),
3047            "Should include main.ts, got: {:?}",
3048            file_names
3049        );
3050        assert!(
3051            !file_names.contains(&"sub.ts".to_string()),
3052            "Should exclude .aftignored sub.ts, got: {:?}",
3053            file_names
3054        );
3055    }
3056
3057    #[test]
3058    fn callgraph_walker_only_source_files() {
3059        let dir = TempDir::new().unwrap();
3060
3061        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3062        fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
3063        fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
3064        fs::write(
3065            dir.path().join("runtime.mjs"),
3066            "export function runtime() {}",
3067        )
3068        .unwrap();
3069        fs::write(
3070            dir.path().join("legacy.cjs"),
3071            "exports.legacy = function() {};",
3072        )
3073        .unwrap();
3074        fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
3075        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3076        fs::write(dir.path().join("data.json"), "{}").unwrap();
3077
3078        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3079        let file_names: Vec<String> = files
3080            .iter()
3081            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3082            .collect();
3083
3084        assert!(file_names.contains(&"main.ts".to_string()));
3085        for modern_ext_file in [
3086            "module.mts",
3087            "common.cts",
3088            "runtime.mjs",
3089            "legacy.cjs",
3090            "types.pyi",
3091        ] {
3092            assert!(
3093                file_names.contains(&modern_ext_file.to_string()),
3094                "walker should include {modern_ext_file}, got: {:?}",
3095                file_names
3096            );
3097        }
3098        assert!(
3099            file_names.contains(&"readme.md".to_string()),
3100            "Markdown is now a supported source language"
3101        );
3102        assert!(
3103            file_names.contains(&"data.json".to_string()),
3104            "JSON is now a supported source language"
3105        );
3106    }
3107
3108    // --- find_alias_original ---
3109
3110    #[test]
3111    fn callgraph_find_alias_original_simple() {
3112        let raw = "import { foo as bar } from './utils';";
3113        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3114    }
3115
3116    #[test]
3117    fn callgraph_find_alias_original_multiple() {
3118        let raw = "import { foo as bar, baz as qux } from './utils';";
3119        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3120        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3121    }
3122
3123    #[test]
3124    fn callgraph_find_alias_no_match() {
3125        let raw = "import { foo } from './utils';";
3126        assert_eq!(find_alias_original(raw, "foo"), None);
3127    }
3128
3129    // --- Reverse callers ---
3130
3131    #[test]
3132    fn is_entry_point_exported_function() {
3133        assert!(is_entry_point(
3134            "handleRequest",
3135            &SymbolKind::Function,
3136            true,
3137            LangId::TypeScript
3138        ));
3139    }
3140
3141    #[test]
3142    fn is_entry_point_exported_method_is_not_entry() {
3143        // Methods are class members, not standalone entry points
3144        assert!(!is_entry_point(
3145            "handleRequest",
3146            &SymbolKind::Method,
3147            true,
3148            LangId::TypeScript
3149        ));
3150    }
3151
3152    #[test]
3153    fn is_entry_point_main_init_patterns() {
3154        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3155            assert!(
3156                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3157                "{} should be an entry point",
3158                name
3159            );
3160        }
3161    }
3162
3163    #[test]
3164    fn is_entry_point_test_patterns_ts() {
3165        assert!(is_entry_point(
3166            "describe",
3167            &SymbolKind::Function,
3168            false,
3169            LangId::TypeScript
3170        ));
3171        assert!(is_entry_point(
3172            "it",
3173            &SymbolKind::Function,
3174            false,
3175            LangId::TypeScript
3176        ));
3177        assert!(is_entry_point(
3178            "test",
3179            &SymbolKind::Function,
3180            false,
3181            LangId::TypeScript
3182        ));
3183        assert!(is_entry_point(
3184            "testValidation",
3185            &SymbolKind::Function,
3186            false,
3187            LangId::TypeScript
3188        ));
3189        assert!(is_entry_point(
3190            "specHelper",
3191            &SymbolKind::Function,
3192            false,
3193            LangId::TypeScript
3194        ));
3195    }
3196
3197    #[test]
3198    fn is_entry_point_test_patterns_python() {
3199        assert!(is_entry_point(
3200            "test_login",
3201            &SymbolKind::Function,
3202            false,
3203            LangId::Python
3204        ));
3205        assert!(is_entry_point(
3206            "setUp",
3207            &SymbolKind::Function,
3208            false,
3209            LangId::Python
3210        ));
3211        assert!(is_entry_point(
3212            "tearDown",
3213            &SymbolKind::Function,
3214            false,
3215            LangId::Python
3216        ));
3217        // "testSomething" should NOT match Python (needs test_ prefix)
3218        assert!(!is_entry_point(
3219            "testSomething",
3220            &SymbolKind::Function,
3221            false,
3222            LangId::Python
3223        ));
3224    }
3225
3226    #[test]
3227    fn is_entry_point_test_patterns_rust() {
3228        assert!(is_entry_point(
3229            "test_parse",
3230            &SymbolKind::Function,
3231            false,
3232            LangId::Rust
3233        ));
3234        assert!(!is_entry_point(
3235            "TestSomething",
3236            &SymbolKind::Function,
3237            false,
3238            LangId::Rust
3239        ));
3240    }
3241
3242    #[test]
3243    fn is_entry_point_test_patterns_go() {
3244        assert!(is_entry_point(
3245            "TestParsing",
3246            &SymbolKind::Function,
3247            false,
3248            LangId::Go
3249        ));
3250        // lowercase test should NOT match Go (needs uppercase Test prefix)
3251        assert!(!is_entry_point(
3252            "testParsing",
3253            &SymbolKind::Function,
3254            false,
3255            LangId::Go
3256        ));
3257    }
3258
3259    #[test]
3260    fn is_entry_point_non_exported_non_main_is_not_entry() {
3261        assert!(!is_entry_point(
3262            "helperUtil",
3263            &SymbolKind::Function,
3264            false,
3265            LangId::TypeScript
3266        ));
3267    }
3268
3269    // --- symbol_metadata ---
3270
3271    #[test]
3272    fn callgraph_symbol_metadata_populated() {
3273        let dir = setup_ts_project();
3274        let mut graph = CallGraph::new(dir.path().to_path_buf());
3275
3276        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3277        assert!(
3278            file_data.symbol_metadata.contains_key("helper"),
3279            "symbol_metadata should contain helper"
3280        );
3281        let meta = &file_data.symbol_metadata["helper"];
3282        assert_eq!(meta.kind, SymbolKind::Function);
3283        assert!(meta.exported, "helper should be exported");
3284    }
3285
3286    #[test]
3287    fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
3288        let dir = TempDir::new().unwrap();
3289        fs::write(
3290            dir.path().join("main.ts"),
3291            r#"import * as lib from './index';
3292
3293export function main() {
3294    lib.helper();
3295    lib.hidden();
3296}
3297"#,
3298        )
3299        .unwrap();
3300        fs::write(
3301            dir.path().join("index.ts"),
3302            "export { helper } from './utils';\n",
3303        )
3304        .unwrap();
3305        fs::write(
3306            dir.path().join("utils.ts"),
3307            r#"export function helper() {}
3308function hidden() {}
3309"#,
3310        )
3311        .unwrap();
3312
3313        let mut graph = CallGraph::new(dir.path().to_path_buf());
3314        let main_path = dir.path().join("main.ts");
3315        let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
3316
3317        let helper =
3318            graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
3319        match helper {
3320            EdgeResolution::Resolved { file, symbol } => {
3321                assert!(
3322                    file.ends_with("utils.ts"),
3323                    "helper should resolve through barrel: {file:?}"
3324                );
3325                assert_eq!(symbol, "helper");
3326            }
3327            other => panic!("expected helper to resolve through barrel, got {other:?}"),
3328        }
3329
3330        let hidden =
3331            graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
3332        assert_eq!(
3333            hidden,
3334            EdgeResolution::Unresolved {
3335                callee_name: "hidden".to_string()
3336            }
3337        );
3338    }
3339
3340    #[test]
3341    fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
3342        let dir = TempDir::new().unwrap();
3343        fs::write(
3344            dir.path().join("package.json"),
3345            r#"{"workspaces":["packages/*"]}"#,
3346        )
3347        .unwrap();
3348        let package_dir = dir.path().join("packages/lib");
3349        fs::create_dir_all(package_dir.join("src")).unwrap();
3350        fs::create_dir_all(package_dir.join("dist")).unwrap();
3351        fs::write(
3352            package_dir.join("package.json"),
3353            r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
3354        )
3355        .unwrap();
3356        fs::write(
3357            package_dir.join("src/index.mts"),
3358            "export function helper() {}\n",
3359        )
3360        .unwrap();
3361        fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
3362
3363        let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
3364        assert!(
3365            resolved.ends_with("src/index.mts"),
3366            "dist/index.mjs should map to src/index.mts, got {resolved:?}"
3367        );
3368    }
3369
3370    #[test]
3371    fn same_named_methods_use_scoped_symbol_identity() {
3372        let dir = TempDir::new().unwrap();
3373        fs::write(
3374            dir.path().join("classes.ts"),
3375            r#"class A {
3376    run() { helperA(); }
3377}
3378
3379class B {
3380    run() { helperB(); }
3381}
3382
3383function helperA() {}
3384function helperB() {}
3385"#,
3386        )
3387        .unwrap();
3388
3389        let mut graph = CallGraph::new(dir.path().to_path_buf());
3390        let path = dir.path().join("classes.ts");
3391        let data = graph.build_file(&path).unwrap();
3392
3393        assert!(
3394            data.symbol_metadata.contains_key("A::run"),
3395            "A::run metadata missing"
3396        );
3397        assert!(
3398            data.symbol_metadata.contains_key("B::run"),
3399            "B::run metadata missing"
3400        );
3401        assert!(
3402            data.calls_by_symbol["A::run"]
3403                .iter()
3404                .any(|call| call.callee_name == "helperA"),
3405            "A::run calls should not be overwritten"
3406        );
3407        assert!(
3408            data.calls_by_symbol["B::run"]
3409                .iter()
3410                .any(|call| call.callee_name == "helperB"),
3411            "B::run calls should not be overwritten"
3412        );
3413
3414        assert!(matches!(
3415            graph.resolve_symbol_query(&path, "run"),
3416            Err(AftError::AmbiguousSymbol { .. })
3417        ));
3418        assert_eq!(
3419            graph.resolve_symbol_query(&path, "A::run").unwrap(),
3420            "A::run"
3421        );
3422    }
3423
3424    // --- extract_parameters ---
3425
3426    #[test]
3427    fn extract_parameters_typescript() {
3428        let params = extract_parameters(
3429            "function processData(input: string, count: number): void",
3430            LangId::TypeScript,
3431        );
3432        assert_eq!(params, vec!["input", "count"]);
3433    }
3434
3435    #[test]
3436    fn extract_parameters_typescript_optional() {
3437        let params = extract_parameters(
3438            "function fetch(url: string, options?: RequestInit): Promise<Response>",
3439            LangId::TypeScript,
3440        );
3441        assert_eq!(params, vec!["url", "options"]);
3442    }
3443
3444    #[test]
3445    fn extract_parameters_typescript_defaults() {
3446        let params = extract_parameters(
3447            "function greet(name: string, greeting: string = \"hello\"): string",
3448            LangId::TypeScript,
3449        );
3450        assert_eq!(params, vec!["name", "greeting"]);
3451    }
3452
3453    #[test]
3454    fn extract_parameters_typescript_rest() {
3455        let params = extract_parameters(
3456            "function sum(...numbers: number[]): number",
3457            LangId::TypeScript,
3458        );
3459        assert_eq!(params, vec!["numbers"]);
3460    }
3461
3462    #[test]
3463    fn extract_parameters_python_self_skipped() {
3464        let params = extract_parameters(
3465            "def process(self, data: str, count: int) -> bool",
3466            LangId::Python,
3467        );
3468        assert_eq!(params, vec!["data", "count"]);
3469    }
3470
3471    #[test]
3472    fn extract_parameters_python_no_self() {
3473        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3474        assert_eq!(params, vec!["input"]);
3475    }
3476
3477    #[test]
3478    fn extract_parameters_python_star_args() {
3479        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3480        assert_eq!(params, vec!["args", "kwargs"]);
3481    }
3482
3483    #[test]
3484    fn extract_parameters_rust_self_skipped() {
3485        let params = extract_parameters(
3486            "fn process(&self, data: &str, count: usize) -> bool",
3487            LangId::Rust,
3488        );
3489        assert_eq!(params, vec!["data", "count"]);
3490    }
3491
3492    #[test]
3493    fn extract_parameters_rust_mut_self_skipped() {
3494        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3495        assert_eq!(params, vec!["value"]);
3496    }
3497
3498    #[test]
3499    fn extract_parameters_rust_no_self() {
3500        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3501        assert_eq!(params, vec!["input"]);
3502    }
3503
3504    #[test]
3505    fn extract_parameters_rust_mut_param() {
3506        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3507        assert_eq!(params, vec!["buf", "len"]);
3508    }
3509
3510    #[test]
3511    fn extract_parameters_go() {
3512        let params = extract_parameters(
3513            "func ProcessData(input string, count int) error",
3514            LangId::Go,
3515        );
3516        assert_eq!(params, vec!["input", "count"]);
3517    }
3518
3519    #[test]
3520    fn extract_parameters_empty() {
3521        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3522        assert!(
3523            params.is_empty(),
3524            "no-arg function should return empty params"
3525        );
3526    }
3527
3528    #[test]
3529    fn extract_parameters_no_parens() {
3530        let params = extract_parameters("const x = 42", LangId::TypeScript);
3531        assert!(params.is_empty(), "no parens should return empty params");
3532    }
3533
3534    #[test]
3535    fn extract_parameters_javascript() {
3536        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3537        assert_eq!(params, vec!["event", "target"]);
3538    }
3539}