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