Skip to main content

aft/
callgraph.rs

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