ast-bro 2.2.0

Fast, AST-based code-navigation: shape, public API, deps & call graphs, hybrid semantic search, structural rewrite. MCP server included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Resolve a single import string against the suffix index.
//!
//! Two pre-processing steps before the suffix lookup:
//!
//! - **Relative imports** (`.x`, `./x`, `../x`) — resolved against the
//!   importer's directory using path arithmetic, then handed off to the
//!   suffix lookup.
//! - **Manifest aliases** (Go `mymod/` prefix, TS `tsconfig.json`
//!   `compilerOptions.paths`, Rust `crate::` prefix) — stripped to a
//!   slash-joined module path.

use std::path::{Path, PathBuf};

use super::build::{Lang, SuffixIndex};

/// Per-call resolution context — what file is doing the import, and which
/// language we're resolving for.
#[derive(Debug, Clone)]
pub struct ResolveCtx<'a> {
    /// The importer file (used for relative resolution and pick-closest).
    pub from_file: &'a Path,
    pub lang: Lang,
    /// Optional alias prefix (e.g. `mymod` from `go.mod`, the crate name from
    /// `Cargo.toml`). When the import path starts with this prefix, it's
    /// stripped before the suffix lookup.
    pub alias_prefix: Option<&'a str>,
    /// Manifest path-alias mappings (TS `tsconfig.json` `paths` field).
    /// Keys are bare prefixes (`@app/`); values are the substitution paths
    /// (`src/app/`) that the prefix expands to before suffix lookup.
    pub path_aliases: &'a [(String, String)],
    /// PHP PSR-4 prefix → directory pairs from `composer.json`. Prefixes are
    /// already in slash form (`App/`) — see `manifest::parse_composer_psr4`.
    /// Sorted longest-first so the resolver picks the most specific match.
    pub php_psr4: &'a [(String, String)],
}

impl<'a> ResolveCtx<'a> {
    #[allow(dead_code)]
    pub fn new(from_file: &'a Path, lang: Lang) -> Self {
        Self {
            from_file,
            lang,
            alias_prefix: None,
            path_aliases: &[],
            php_psr4: &[],
        }
    }
}

/// Resolve `spec` to a file in the project, or `None` if external/unresolvable.
pub fn resolve(spec: &str, ctx: &ResolveCtx<'_>, idx: &SuffixIndex) -> Option<PathBuf> {
    let spec = spec.trim();
    if spec.is_empty() {
        return None;
    }

    // Language-specific normalisation: `crate::x::y` → `x/y`,
    // `self::x` → relative-to-current-dir, `super::x` → ascend one.
    if ctx.lang == Lang::Rust {
        let resolve_with_fallback =
            |key: String| -> Option<PathBuf> {
                if let Some(p) = pick_closest(idx.lookup(&key), ctx.from_file) {
                    return Some(p);
                }
                let mut parts: Vec<&str> = key.split('/').collect();
                while parts.len() > 1 {
                    parts.pop();
                    let trimmed = parts.join("/");
                    if let Some(p) = pick_closest(idx.lookup(&trimmed), ctx.from_file) {
                        return Some(p);
                    }
                }
                None
            };

        if let Some(rest) = spec.strip_prefix("crate::") {
            return resolve_with_fallback(rest.replace("::", "/"));
        }
        if let Some(rest) = spec.strip_prefix("self::") {
            let key = rest.replace("::", "/");
            return resolve_relative(&key, ctx, idx, 0);
        }
        let mut s = spec;
        let mut up = 0usize;
        while let Some(rest) = s.strip_prefix("super::") {
            s = rest;
            up += 1;
        }
        if up > 0 {
            let key = s.replace("::", "/");
            return resolve_relative(&key, ctx, idx, up);
        }
        return resolve_with_fallback(spec.replace("::", "/"));
    }

    // Python relative imports come in as a key like `./x/y` or
    // `../pkg/util` — `extract` already accounts for the leading dots.
    if spec.starts_with("./") || spec.starts_with("../") {
        return resolve_relative_path(spec, ctx, idx);
    }

    // TS/JS relative: `./foo` / `../foo`.
    if (matches!(
        ctx.lang,
        Lang::TypeScript | Lang::Tsx | Lang::JavaScript
    )) && (spec.starts_with('.'))
    {
        return resolve_relative_path(spec, ctx, idx);
    }

    // tsconfig path aliases.
    if matches!(
        ctx.lang,
        Lang::TypeScript | Lang::Tsx | Lang::JavaScript
    ) {
        for (prefix, replacement) in ctx.path_aliases {
            if let Some(rest) = spec.strip_prefix(prefix.as_str()) {
                let combined = format!("{}{}", replacement, rest);
                let key = combined.trim_start_matches("./").to_string();
                if let Some(p) = pick_closest(idx.lookup(&key), ctx.from_file) {
                    return Some(p);
                }
            }
        }
    }

    // Go `import "mymod/pkg/foo"` — strip the module prefix.
    if ctx.lang == Lang::Go {
        if let Some(prefix) = ctx.alias_prefix {
            let trimmed = spec.trim_matches('"');
            let prefix_slash = format!("{}/", prefix);
            if trimmed == prefix {
                return None; // self-reference; nothing to resolve.
            }
            if let Some(rest) = trimmed.strip_prefix(&prefix_slash) {
                // Look for any file in `<root>/<rest>/` directory.
                if let Some(found) = find_dir_file(idx, rest) {
                    return Some(found);
                }
                return None;
            }
            // Not in this module → external.
            return None;
        }
        // No go.mod found — drop everything; we can't tell what's local.
        return None;
    }

    // Java/Kotlin/C#/Scala: `import com.foo.Bar;` — slash-joined suffix.
    if matches!(
        ctx.lang,
        Lang::Java | Lang::Kotlin | Lang::CSharp | Lang::Scala
    ) {
        let key = spec.replace('.', "/");
        // Try as-is first (matches `<package>/<TypeName>` index entries).
        if let Some(p) = pick_closest(idx.lookup(&key), ctx.from_file) {
            return Some(p);
        }
        // Try with last segment stripped — handles `import foo.Bar.Inner`
        // where Inner is a nested class inside `foo/Bar.java`.
        if let Some((parent, _)) = key.rsplit_once('/') {
            if let Some(p) = pick_closest(idx.lookup(parent), ctx.from_file) {
                return Some(p);
            }
        }
        return None;
    }

    // PHP: `use App\Core\Foo;` arrives normalised to `App/Core/Foo`.
    // Resolution order: PSR-4 prefix → suffix lookup → last-segment fallback.
    if ctx.lang == Lang::Php {
        // 1. PSR-4 prefix replacement (longest-first; `php_psr4` is pre-sorted).
        for (prefix, dir) in ctx.php_psr4 {
            if let Some(rest) = spec.strip_prefix(prefix.as_str()) {
                let key = if dir.is_empty() {
                    rest.to_string()
                } else {
                    format!("{}/{}", dir.trim_end_matches('/'), rest)
                };
                if let Some(p) = pick_closest(idx.lookup(&key), ctx.from_file) {
                    return Some(p);
                }
                // Direct file existence check — handles cases where the
                // suffix index doesn't have a matching key (e.g., file at
                // root is indexed by stem only).
                let abs = idx.root.join(format!("{}.php", key));
                if abs.is_file() {
                    return Some(abs);
                }
            }
        }
        // 2. Direct suffix lookup — covers projects without composer.json
        // where the file layout happens to mirror the namespace.
        if let Some(p) = pick_closest(idx.lookup(spec), ctx.from_file) {
            return Some(p);
        }
        // 3. Last-segment fallback — class name only (e.g. `Foo` from
        // `App\Core\Foo`). Mirrors the Java nested-class fallback above.
        if let Some(last) = spec.rsplit('/').next() {
            if last != spec {
                if let Some(p) = pick_closest(idx.lookup(last), ctx.from_file) {
                    return Some(p);
                }
            }
        }
        return None;
    }

    // C++: only relative includes resolve. System headers (`<vector>` etc.)
    // arrive here without a `./` prefix and have no project-local target.
    if ctx.lang == Lang::Cpp {
        return None;
    }

    // Ruby: only `require_relative` resolves. Bare `require 'gem'` and
    // `load`/`autoload` paths target $LOAD_PATH or installed gems.
    if ctx.lang == Lang::Ruby {
        return None;
    }

    // Python `from a.b import c` arrives normalised to `a/b/c` already.
    let key = spec.replace('.', "/");
    pick_closest(idx.lookup(&key), ctx.from_file)
}

/// Walk a relative `./x/y` style path against `from_file`'s directory.
fn resolve_relative_path(
    spec: &str,
    ctx: &ResolveCtx<'_>,
    idx: &SuffixIndex,
) -> Option<PathBuf> {
    let parent = ctx.from_file.parent()?;
    let mut cur = parent.to_path_buf();
    let mut remaining = spec;
    while let Some(rest) = remaining.strip_prefix("../") {
        cur = cur.parent()?.to_path_buf();
        remaining = rest;
    }
    while let Some(rest) = remaining.strip_prefix("./") {
        remaining = rest;
    }
    // Strip a known extension if present.
    let target = cur.join(remaining);
    if target.is_file() {
        return Some(target);
    }
    // Try common extensions for TS/JS.
    if matches!(
        ctx.lang,
        Lang::TypeScript | Lang::Tsx | Lang::JavaScript
    ) {
        let ext_order: &[&str] = &[
            ".ts", ".tsx", ".mts", ".cts", ".d.ts", ".js", ".jsx", ".mjs", ".cjs", ".json",
        ];
        for e in ext_order {
            let p = with_ext(&target, e);
            if p.is_file() {
                return Some(p);
            }
        }
        // Index file fallback.
        for e in ext_order {
            let p = target.join(format!("index{}", e));
            if p.is_file() {
                return Some(p);
            }
        }
    }
    if ctx.lang == Lang::Python {
        for e in [".py", ".pyi"] {
            let p = with_ext(&target, e);
            if p.is_file() {
                return Some(p);
            }
        }
        let init = target.join("__init__.py");
        if init.is_file() {
            return Some(init);
        }
        // The imported name may not be its own file — drop the trailing
        // segment and try again. `from .helpers import greet` → first
        // tries `helpers/greet.py`, then falls back to `helpers.py`.
        if let Some(parent) = target.parent() {
            for e in [".py", ".pyi"] {
                let p = with_ext(parent, e);
                if p.is_file() {
                    return Some(p);
                }
            }
            let init_parent = parent.join("__init__.py");
            if init_parent.is_file() {
                return Some(init_parent);
            }
        }
    }
    // Last-ditch: ask the suffix index using the relative key.
    let rel = match target.strip_prefix(&idx.root) {
        Ok(r) => r
            .components()
            .map(|c| c.as_os_str().to_string_lossy().into_owned())
            .collect::<Vec<_>>()
            .join("/"),
        Err(_) => target.display().to_string(),
    };
    pick_closest(idx.lookup(&rel), ctx.from_file)
}

fn with_ext(p: &Path, ext: &str) -> PathBuf {
    let mut s = p.as_os_str().to_string_lossy().into_owned();
    s.push_str(ext);
    PathBuf::from(s)
}

/// Variant for Rust `super::` / `self::` chains that *don't* arrive as
/// `./x` strings — `key` is already slash-joined.
fn resolve_relative(
    key: &str,
    ctx: &ResolveCtx<'_>,
    idx: &SuffixIndex,
    ascend: usize,
) -> Option<PathBuf> {
    let mut parent = ctx.from_file.parent()?.to_path_buf();
    for _ in 0..ascend {
        parent = parent.parent()?.to_path_buf();
    }
    let target = parent.join(key);
    let candidates = [
        with_ext(&target, ".rs"),
        target.join("mod.rs"),
        target.clone(),
    ];
    for c in candidates {
        if c.is_file() {
            return Some(c);
        }
    }
    // Fall back to suffix index (caller may have given us a path that
    // matches a deeper file).
    let rel = match target.strip_prefix(&idx.root) {
        Ok(r) => r
            .components()
            .map(|c| c.as_os_str().to_string_lossy().into_owned())
            .collect::<Vec<_>>()
            .join("/"),
        Err(_) => return None,
    };
    pick_closest(idx.lookup(&rel), ctx.from_file)
}

/// For Go: given a relative directory like `pkg/foo`, return any source
/// file inside it that we've indexed.
fn find_dir_file(idx: &SuffixIndex, rel_dir: &str) -> Option<PathBuf> {
    let dir_abs = idx.root.join(rel_dir);
    let mut best: Option<PathBuf> = None;
    for f in idx.by_file.keys() {
        if f.starts_with(&dir_abs) && f.parent() == Some(dir_abs.as_path()) {
            // Prefer non-test-file by name; otherwise lexicographic.
            match &best {
                None => best = Some(f.clone()),
                Some(prev) => {
                    if f < prev {
                        best = Some(f.clone());
                    }
                }
            }
        }
    }
    best
}

/// When multiple files match a suffix, prefer the one whose path shares
/// the most leading components with the importer.
fn pick_closest(candidates: Option<&[PathBuf]>, from_file: &Path) -> Option<PathBuf> {
    let cands = candidates?;
    if cands.is_empty() {
        return None;
    }
    if cands.len() == 1 {
        return Some(cands[0].clone());
    }
    let from_segs: Vec<String> = from_file
        .components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect();
    let mut best: Option<(usize, &PathBuf)> = None;
    for c in cands {
        let segs: Vec<String> = c
            .components()
            .map(|c| c.as_os_str().to_string_lossy().into_owned())
            .collect();
        let common = from_segs
            .iter()
            .zip(segs.iter())
            .take_while(|(a, b)| a == b)
            .count();
        match best {
            None => best = Some((common, c)),
            Some((prev_common, prev_c)) => {
                if common > prev_common || (common == prev_common && c < prev_c) {
                    best = Some((common, c));
                }
            }
        }
    }
    best.map(|(_, p)| p.clone())
}