Skip to main content

mati_core/analysis/resolvers/
rust.rs

1//! Rust import resolver.
2//!
3//! Resolves `crate::`, `self::`, and `super::` module paths against Rust
4//! crate roots. Supports both single-crate (`src/`) and Cargo workspace
5//! (`crates/*/src/`) layouts. Crate roots are detected from `Cargo.toml`
6//! `[workspace].members` during edge building and stored in `FileIndex`.
7
8use std::path::Path;
9
10use super::{FileIndex, LanguageResolver};
11use crate::analysis::parser::ImportStatement;
12use crate::analysis::walker::Language;
13
14/// Rust import resolver for `crate::`, `self::`, and `super::` paths.
15pub struct RustResolver;
16
17impl LanguageResolver for RustResolver {
18    fn resolve(
19        &self,
20        import: &ImportStatement,
21        importing_file: &str,
22        file_index: &FileIndex,
23    ) -> Option<String> {
24        resolve_rust(&import.path, importing_file, file_index)
25    }
26
27    fn language(&self) -> Language {
28        Language::Rust
29    }
30
31    fn name(&self) -> &'static str {
32        "rust"
33    }
34}
35
36/// Resolve a cross-crate workspace import to the target member's entry point.
37///
38/// Called from `build_edges` for imports classified as `External` that might
39/// actually be workspace-internal. Maps the first `::` segment to a workspace
40/// member name and resolves to its `lib.rs` (or `mod.rs` fallback).
41///
42/// Example: `grep_regex::matcher::Foo` → `crates/regex/src/lib.rs`
43pub fn resolve_cross_crate(import_path: &str, file_index: &FileIndex) -> Option<String> {
44    let clean = import_path
45        .split(" as ")
46        .next()
47        .unwrap_or(import_path)
48        .trim()
49        .trim_end_matches("::*");
50    let first_seg = clean.split("::").next()?;
51    if first_seg.is_empty() {
52        return None;
53    }
54    let member_root = file_index.workspace_member_root(first_seg)?;
55    let lib = format!("{member_root}lib.rs");
56    if file_index.contains(&lib) {
57        return Some(lib);
58    }
59    let mod_rs = format!("{member_root}mod.rs");
60    if file_index.contains(&mod_rs) {
61        return Some(mod_rs);
62    }
63    None
64}
65
66/// Core resolution logic, extracted for direct testing.
67fn resolve_rust(import_path: &str, importing_file: &str, file_index: &FileIndex) -> Option<String> {
68    // Determine the crate root for this file. Falls back to "src/" when
69    // crate_roots is empty (e.g. in unit tests with a plain FileIndex).
70    let crate_root = file_index.crate_root_for(importing_file).unwrap_or("src/");
71
72    // Strip `as` alias and `::*` wildcard suffix for path resolution.
73    let clean = import_path
74        .split(" as ")
75        .next()
76        .unwrap_or(import_path)
77        .trim()
78        .trim_end_matches("::*");
79
80    let current_module = rust_module_segments(importing_file, crate_root)?;
81
82    // Handle bare keyword paths left after wildcard stripping:
83    // `super::*` → `super`, `self::*` → `self`, `crate::*` → `crate`.
84    let segments = if clean == "crate" {
85        // `use crate::*` — re-export of entire crate root, no single file target.
86        return None;
87    } else if clean == "self" {
88        // `use self::*` — re-export of current module directory.
89        current_module.clone()
90    } else if clean == "super" {
91        // `use super::*` — re-export of parent module.
92        if current_module.is_empty() {
93            return None;
94        }
95        current_module[..current_module.len() - 1].to_vec()
96    } else if let Some(path) = clean.strip_prefix("crate::") {
97        parse_rust_segments(path)
98    } else if let Some(path) = clean.strip_prefix("self::") {
99        current_module
100            .iter()
101            .cloned()
102            .chain(parse_rust_segments(path))
103            .collect()
104    } else if clean.starts_with("super::") {
105        let mut remaining = clean;
106        let mut up = 0usize;
107        while let Some(rest) = remaining.strip_prefix("super::") {
108            remaining = rest;
109            up += 1;
110        }
111        if up > current_module.len() {
112            return None;
113        }
114        current_module[..current_module.len() - up]
115            .iter()
116            .cloned()
117            .chain(parse_rust_segments(remaining))
118            .collect()
119    } else {
120        return None;
121    };
122
123    if segments.is_empty() {
124        return None;
125    }
126
127    // Prefix-stripping resolution loop: try the full path first, then
128    // progressively drop the last segment (which may be a symbol name like
129    // `FileRecord` rather than a module). This correctly resolves paths like
130    // `crate::store::record::FileRecord` → `src/store/record.rs` and
131    // brace-grouped imports like `crate::store::{A, B}` → `src/store/mod.rs`.
132    let mut depth = segments.len();
133    while depth > 0 {
134        let fs_path = format!("{crate_root}{}", segments[..depth].join("/"));
135
136        // Try direct file: <crate_root>/foo/bar.rs
137        let direct = format!("{fs_path}.rs");
138        if file_index.contains(&direct) {
139            return Some(direct);
140        }
141
142        // Try module directory: <crate_root>/foo/bar/mod.rs
143        let mod_rs = format!("{fs_path}/mod.rs");
144        if file_index.contains(&mod_rs) {
145            return Some(mod_rs);
146        }
147
148        depth -= 1;
149    }
150
151    None
152}
153
154fn parse_rust_segments(path: &str) -> Vec<String> {
155    path.split("::")
156        .map(str::trim)
157        .filter(|segment| !segment.is_empty() && *segment != "self")
158        .map(|segment| segment.to_string())
159        .collect()
160}
161
162fn rust_module_segments(importing_file: &str, crate_root: &str) -> Option<Vec<String>> {
163    let rel = importing_file.strip_prefix(crate_root)?;
164
165    if rel == "lib.rs" || rel == "main.rs" {
166        return Some(Vec::new());
167    }
168
169    if let Some(parent) = rel.strip_suffix("/mod.rs") {
170        return Some(
171            parent
172                .split('/')
173                .filter(|segment| !segment.is_empty())
174                .map(|segment| segment.to_string())
175                .collect(),
176        );
177    }
178
179    let path = Path::new(rel);
180    let stem = path.file_stem()?.to_str()?;
181    let mut segments: Vec<String> = path
182        .parent()
183        .into_iter()
184        .flat_map(|parent| parent.iter())
185        .filter_map(|segment| segment.to_str())
186        .filter(|segment| !segment.is_empty())
187        .map(|segment| segment.to_string())
188        .collect();
189    segments.push(stem.to_string());
190    Some(segments)
191}
192
193// ── Tests ───────────────────────────────────────────────────────────────────
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::analysis::parser::import::ImportKind;
199
200    fn idx(paths: &[&str]) -> FileIndex {
201        FileIndex::new(paths.iter().map(|s| s.to_string()))
202    }
203
204    fn import(path: &str) -> ImportStatement {
205        ImportStatement::new(path, ImportKind::Normal, 1)
206    }
207
208    #[test]
209    fn crate_import_resolves_to_file() {
210        let file_index = idx(&["src/lib.rs", "src/utils.rs"]);
211        let resolver = RustResolver;
212        let result = resolver.resolve(&import("crate::utils"), "src/lib.rs", &file_index);
213        assert_eq!(result, Some("src/utils.rs".into()));
214    }
215
216    #[test]
217    fn crate_import_resolves_to_mod_rs() {
218        let file_index = idx(&["src/lib.rs", "src/store/mod.rs"]);
219        let resolver = RustResolver;
220        let result = resolver.resolve(&import("crate::store"), "src/lib.rs", &file_index);
221        assert_eq!(result, Some("src/store/mod.rs".into()));
222    }
223
224    #[test]
225    fn self_import_resolves() {
226        let file_index = idx(&["src/store/mod.rs", "src/store/helpers.rs"]);
227        let resolver = RustResolver;
228        let result = resolver.resolve(&import("self::helpers"), "src/store/mod.rs", &file_index);
229        assert_eq!(result, Some("src/store/helpers.rs".into()));
230    }
231
232    #[test]
233    fn super_import_resolves() {
234        let file_index = idx(&["src/store/db.rs", "src/store/helpers.rs"]);
235        let resolver = RustResolver;
236        let result = resolver.resolve(&import("super::helpers"), "src/store/db.rs", &file_index);
237        assert_eq!(result, Some("src/store/helpers.rs".into()));
238    }
239
240    #[test]
241    fn nested_crate_import() {
242        let file_index = idx(&["src/main.rs", "src/store/db.rs"]);
243        let resolver = RustResolver;
244        let result = resolver.resolve(&import("crate::store::db"), "src/main.rs", &file_index);
245        assert_eq!(result, Some("src/store/db.rs".into()));
246    }
247
248    #[test]
249    fn unresolvable_returns_none() {
250        let file_index = idx(&["src/lib.rs"]);
251        let resolver = RustResolver;
252        let result = resolver.resolve(&import("crate::nonexistent"), "src/lib.rs", &file_index);
253        assert_eq!(result, None);
254    }
255
256    #[test]
257    fn wildcard_stripped_before_resolution() {
258        let file_index = idx(&["src/lib.rs", "src/prelude.rs"]);
259        let resolver = RustResolver;
260        let imp = ImportStatement::new("crate::prelude::*", ImportKind::Wildcard, 1);
261        let result = resolver.resolve(&imp, "src/lib.rs", &file_index);
262        assert_eq!(result, Some("src/prelude.rs".into()));
263    }
264
265    #[test]
266    fn alias_stripped_before_resolution() {
267        let file_index = idx(&["src/lib.rs", "src/utils.rs"]);
268        let resolver = RustResolver;
269        let imp = ImportStatement::new("crate::utils as u", ImportKind::Normal, 1);
270        let result = resolver.resolve(&imp, "src/lib.rs", &file_index);
271        assert_eq!(result, Some("src/utils.rs".into()));
272    }
273
274    // ── Prefix-stripping resolution tests ────────────────────────────────────
275
276    #[test]
277    fn crate_import_with_trailing_symbol_resolves_to_file() {
278        // crate::store::record::FileRecord → src/store/record.rs
279        let file_index = idx(&["src/lib.rs", "src/store/record.rs"]);
280        let result = resolve_rust(
281            "crate::store::record::FileRecord",
282            "src/lib.rs",
283            &file_index,
284        );
285        assert_eq!(result, Some("src/store/record.rs".into()));
286    }
287
288    #[test]
289    fn crate_import_with_trailing_symbol_resolves_to_mod_rs() {
290        // crate::analysis::parser::Language → src/analysis/parser/mod.rs
291        let file_index = idx(&["src/lib.rs", "src/analysis/parser/mod.rs"]);
292        let result = resolve_rust(
293            "crate::analysis::parser::Language",
294            "src/lib.rs",
295            &file_index,
296        );
297        assert_eq!(result, Some("src/analysis/parser/mod.rs".into()));
298    }
299
300    #[test]
301    fn crate_import_deep_symbol_chain_strips_multiple() {
302        // crate::error::MatiError::NotFound → src/error.rs (strips 2 segments)
303        let file_index = idx(&["src/lib.rs", "src/error.rs"]);
304        let result = resolve_rust(
305            "crate::error::MatiError::NotFound",
306            "src/lib.rs",
307            &file_index,
308        );
309        assert_eq!(result, Some("src/error.rs".into()));
310    }
311
312    #[test]
313    fn brace_group_import_resolves_to_parent_module() {
314        // crate::store::{FileRecord, GotchaRecord} → src/store/mod.rs
315        // Tree-sitter captures the full text including braces
316        let file_index = idx(&["src/lib.rs", "src/store/mod.rs"]);
317        let result = resolve_rust(
318            "crate::store::{FileRecord, GotchaRecord}",
319            "src/lib.rs",
320            &file_index,
321        );
322        assert_eq!(result, Some("src/store/mod.rs".into()));
323    }
324
325    #[test]
326    fn super_import_with_trailing_symbol() {
327        // super::helpers::format_score from src/cli/review.rs → src/cli/helpers.rs
328        let file_index = idx(&["src/cli/review.rs", "src/cli/helpers.rs"]);
329        let result = resolve_rust(
330            "super::helpers::format_score",
331            "src/cli/review.rs",
332            &file_index,
333        );
334        assert_eq!(result, Some("src/cli/helpers.rs".into()));
335    }
336
337    #[test]
338    fn self_import_with_trailing_symbol() {
339        // self::types::MyType from src/store/mod.rs → src/store/types.rs
340        let file_index = idx(&["src/store/mod.rs", "src/store/types.rs"]);
341        let result = resolve_rust("self::types::MyType", "src/store/mod.rs", &file_index);
342        assert_eq!(result, Some("src/store/types.rs".into()));
343    }
344
345    #[test]
346    fn crate_direct_module_still_resolves() {
347        // crate::util → src/util.rs (no trailing symbol, existing behavior preserved)
348        let file_index = idx(&["src/lib.rs", "src/util.rs"]);
349        let result = resolve_rust("crate::util", "src/lib.rs", &file_index);
350        assert_eq!(result, Some("src/util.rs".into()));
351    }
352
353    #[test]
354    fn crate_direct_module_prefers_file_over_mod_rs() {
355        // crate::util where src/util.rs exists → src/util.rs (not src/util/mod.rs)
356        let file_index = idx(&["src/lib.rs", "src/util.rs", "src/util/mod.rs"]);
357        let result = resolve_rust("crate::util", "src/lib.rs", &file_index);
358        assert_eq!(result, Some("src/util.rs".into()));
359    }
360
361    #[test]
362    fn crate_direct_module_falls_back_to_mod_rs() {
363        // crate::util where only src/util/mod.rs exists → src/util/mod.rs
364        let file_index = idx(&["src/lib.rs", "src/util/mod.rs"]);
365        let result = resolve_rust("crate::util", "src/lib.rs", &file_index);
366        assert_eq!(result, Some("src/util/mod.rs".into()));
367    }
368
369    #[test]
370    fn nonexistent_path_returns_none() {
371        let file_index = idx(&["src/lib.rs"]);
372        let result = resolve_rust("crate::nonexistent::thing", "src/lib.rs", &file_index);
373        assert_eq!(result, None);
374    }
375
376    #[test]
377    fn crate_root_alone_returns_none() {
378        // "crate" or "crate::" should not resolve to src.rs or src/mod.rs
379        let file_index = idx(&["src/lib.rs", "src/mod.rs"]);
380        let result = resolve_rust("crate::", "src/lib.rs", &file_index);
381        assert_eq!(result, None);
382    }
383
384    #[test]
385    fn prefix_stripping_stops_before_crate_root() {
386        // crate::x::y::z where nothing matches — should not resolve to src itself
387        let file_index = idx(&["src/lib.rs", "src.rs"]);
388        let result = resolve_rust("crate::x::y::z", "src/lib.rs", &file_index);
389        assert_eq!(result, None);
390    }
391
392    #[test]
393    fn existing_exact_match_preferred_over_stripped() {
394        // crate::store::record where src/store/record.rs exists should NOT strip
395        // to src/store.rs even if that also exists
396        let file_index = idx(&["src/lib.rs", "src/store.rs", "src/store/record.rs"]);
397        let result = resolve_rust("crate::store::record", "src/lib.rs", &file_index);
398        assert_eq!(result, Some("src/store/record.rs".into()));
399    }
400
401    // ── Wildcard bare-keyword resolution ─────────────────────────────────
402
403    #[test]
404    fn super_wildcard_resolves_to_parent_module() {
405        // use super::* from src/cli/review.rs → resolves to src/cli/mod.rs
406        let file_index = idx(&["src/cli/review.rs", "src/cli/mod.rs"]);
407        let imp = ImportStatement::new("super::*", ImportKind::Wildcard, 1);
408        let result = RustResolver.resolve(&imp, "src/cli/review.rs", &file_index);
409        assert_eq!(result, Some("src/cli/mod.rs".into()));
410    }
411
412    #[test]
413    fn self_wildcard_resolves_to_current_module() {
414        // use self::* from src/store/mod.rs → resolves to src/store/mod.rs itself
415        let file_index = idx(&["src/store/mod.rs", "src/store/db.rs"]);
416        let imp = ImportStatement::new("self::*", ImportKind::Wildcard, 1);
417        let result = RustResolver.resolve(&imp, "src/store/mod.rs", &file_index);
418        assert_eq!(result, Some("src/store/mod.rs".into()));
419    }
420
421    #[test]
422    fn crate_wildcard_returns_none() {
423        // use crate::* → no single file target for crate root
424        let file_index = idx(&["src/lib.rs", "src/main.rs"]);
425        let imp = ImportStatement::new("crate::*", ImportKind::Wildcard, 1);
426        let result = RustResolver.resolve(&imp, "src/lib.rs", &file_index);
427        assert_eq!(result, None);
428    }
429
430    // ── Workspace resolution tests ──────────────────────────────────────
431
432    fn idx_with_roots(paths: &[&str], roots: Vec<&str>) -> FileIndex {
433        let mut fi = FileIndex::new(paths.iter().map(|s| s.to_string()));
434        fi.set_crate_roots(roots.into_iter().map(|s| s.to_string()).collect());
435        fi
436    }
437
438    #[test]
439    fn workspace_member_resolves_within_own_crate() {
440        let file_index = idx_with_roots(
441            &[
442                "crates/foo/src/lib.rs",
443                "crates/foo/src/helper.rs",
444                "crates/bar/src/lib.rs",
445            ],
446            vec!["crates/foo/src/", "crates/bar/src/"],
447        );
448        let result = resolve_rust("crate::helper", "crates/foo/src/lib.rs", &file_index);
449        assert_eq!(result, Some("crates/foo/src/helper.rs".into()));
450    }
451
452    #[test]
453    fn workspace_member_does_not_cross_crate_boundaries() {
454        let file_index = idx_with_roots(
455            &[
456                "crates/foo/src/lib.rs",
457                "crates/bar/src/lib.rs",
458                "crates/bar/src/util.rs",
459            ],
460            vec!["crates/foo/src/", "crates/bar/src/"],
461        );
462        // File in crates/foo trying to resolve crate::util — should NOT find
463        // crates/bar/src/util.rs because that's a different crate.
464        let result = resolve_rust("crate::util", "crates/foo/src/lib.rs", &file_index);
465        assert_eq!(result, None);
466    }
467
468    #[test]
469    fn single_crate_project_still_works_with_explicit_root() {
470        let file_index = idx_with_roots(&["src/lib.rs", "src/utils.rs"], vec!["src/"]);
471        let result = resolve_rust("crate::utils", "src/lib.rs", &file_index);
472        assert_eq!(result, Some("src/utils.rs".into()));
473    }
474
475    #[test]
476    fn workspace_super_import_resolves() {
477        let file_index = idx_with_roots(
478            &[
479                "crates/searcher/src/searcher/core.rs",
480                "crates/searcher/src/searcher/mod.rs",
481            ],
482            vec!["crates/searcher/src/"],
483        );
484        let result = resolve_rust(
485            "super::core",
486            "crates/searcher/src/searcher/mod.rs",
487            &file_index,
488        );
489        // super from searcher/mod.rs goes up to searcher/, then resolves core
490        // → but core is a sibling, so super::core from mod.rs is the parent's child
491        // Actually from mod.rs (module = ["searcher"]), super goes up to [],
492        // then core → crates/searcher/src/core.rs — doesn't exist. Let me fix.
493        // From core.rs (module = ["searcher", "core"]), super::mod → ["searcher"]
494        assert_eq!(result, None); // core.rs doesn't exist at crate root level
495    }
496
497    #[test]
498    fn workspace_self_import_resolves() {
499        let file_index = idx_with_roots(
500            &[
501                "crates/searcher/src/searcher/mod.rs",
502                "crates/searcher/src/searcher/glue.rs",
503            ],
504            vec!["crates/searcher/src/"],
505        );
506        let result = resolve_rust(
507            "self::glue",
508            "crates/searcher/src/searcher/mod.rs",
509            &file_index,
510        );
511        assert_eq!(result, Some("crates/searcher/src/searcher/glue.rs".into()));
512    }
513
514    #[test]
515    fn workspace_nested_crate_import() {
516        let file_index = idx_with_roots(
517            &[
518                "crates/printer/src/lib.rs",
519                "crates/printer/src/hyperlink/mod.rs",
520            ],
521            vec!["crates/printer/src/"],
522        );
523        let result = resolve_rust("crate::hyperlink", "crates/printer/src/lib.rs", &file_index);
524        assert_eq!(result, Some("crates/printer/src/hyperlink/mod.rs".into()));
525    }
526
527    #[test]
528    fn fallback_to_src_when_no_crate_roots_set() {
529        // Empty crate_roots → falls back to "src/" (backward compat)
530        let file_index =
531            FileIndex::new(["src/lib.rs", "src/store.rs"].iter().map(|s| s.to_string()));
532        let result = resolve_rust("crate::store", "src/lib.rs", &file_index);
533        assert_eq!(result, Some("src/store.rs".into()));
534    }
535
536    // ── Cross-crate workspace resolution tests ──────────────────────────
537
538    fn idx_with_members(paths: &[&str], members: &[(&str, &str)]) -> FileIndex {
539        let mut fi = FileIndex::new(paths.iter().map(|s| s.to_string()));
540        let map: std::collections::HashMap<String, String> = members
541            .iter()
542            .map(|(name, root)| (name.to_string(), root.to_string()))
543            .collect();
544        fi.set_workspace_members(map);
545        fi
546    }
547
548    #[test]
549    fn cross_crate_import_resolves_to_lib_rs() {
550        let fi = idx_with_members(
551            &["crates/regex/src/lib.rs", "crates/searcher/src/searcher.rs"],
552            &[("grep_regex", "crates/regex/src/")],
553        );
554        let result = resolve_cross_crate("grep_regex::RegexMatcher", &fi);
555        assert_eq!(result, Some("crates/regex/src/lib.rs".into()));
556    }
557
558    #[test]
559    fn cross_crate_import_with_deep_path() {
560        let fi = idx_with_members(
561            &["crates/regex/src/lib.rs"],
562            &[("grep_regex", "crates/regex/src/")],
563        );
564        let result = resolve_cross_crate("grep_regex::matcher::Foo::Bar", &fi);
565        assert_eq!(result, Some("crates/regex/src/lib.rs".into()));
566    }
567
568    #[test]
569    fn cross_crate_falls_back_to_mod_rs() {
570        let fi = idx_with_members(
571            &["crates/regex/src/mod.rs"],
572            &[("grep_regex", "crates/regex/src/")],
573        );
574        let result = resolve_cross_crate("grep_regex::Foo", &fi);
575        assert_eq!(result, Some("crates/regex/src/mod.rs".into()));
576    }
577
578    #[test]
579    fn cross_crate_unknown_member_returns_none() {
580        let fi = idx_with_members(
581            &["crates/regex/src/lib.rs"],
582            &[("grep_regex", "crates/regex/src/")],
583        );
584        // serde is not a workspace member — should return None
585        let result = resolve_cross_crate("serde::Deserialize", &fi);
586        assert_eq!(result, None);
587    }
588
589    #[test]
590    fn kebab_case_crate_name_normalized_to_snake_case() {
591        // Cargo.toml has name = "grep-regex", stored as "grep_regex" in map.
592        // Import uses grep_regex::Foo — should match.
593        let fi = idx_with_members(
594            &["crates/regex/src/lib.rs"],
595            &[("grep_regex", "crates/regex/src/")],
596        );
597        let result = resolve_cross_crate("grep_regex::Foo", &fi);
598        assert_eq!(result, Some("crates/regex/src/lib.rs".into()));
599    }
600
601    #[test]
602    fn intra_crate_resolution_still_works_after_cross_crate() {
603        // Sanity: the previous workspace fix for intra-crate resolution still works.
604        let mut fi = idx_with_members(
605            &[
606                "crates/foo/src/lib.rs",
607                "crates/foo/src/helper.rs",
608                "crates/bar/src/lib.rs",
609            ],
610            &[("foo", "crates/foo/src/"), ("bar", "crates/bar/src/")],
611        );
612        fi.set_crate_roots(vec![
613            "crates/foo/src/".to_string(),
614            "crates/bar/src/".to_string(),
615        ]);
616        let result = resolve_rust("crate::helper", "crates/foo/src/lib.rs", &fi);
617        assert_eq!(result, Some("crates/foo/src/helper.rs".into()));
618    }
619}