use super::{FileSet, ImportTarget, normalize_path, parent_dir};
use crate::types::ImportStatement;
pub fn resolve_rust_import(
import: &ImportStatement,
from_file: usize,
files: &FileSet,
) -> ImportTarget {
let raw = import.module_path.trim();
if raw.is_empty() {
return ImportTarget::Unresolved(import.module_path.clone());
}
let base = match raw.find('{') {
Some(i) => raw[..i].trim_end_matches(':').trim(),
None => raw,
};
let base = base.split(" as ").next().unwrap_or(base).trim();
let no_drop = raw.contains('{') || raw.contains('*') || import.is_glob;
let segments: Vec<&str> = base
.split("::")
.map(str::trim)
.filter(|s| !s.is_empty() && *s != "*")
.collect();
if segments.is_empty() {
return ImportTarget::Unresolved(import.module_path.clone());
}
let root = segments[0];
let after: &[&str] = &segments[1..];
let resolved = match root {
"crate" => {
let root_dir = crate_root(from_file, files);
root_dir
.as_deref()
.and_then(|dir| probe_from(dir, after, no_drop, files))
.or_else(|| resolve_in_repo(after, no_drop, root_dir.as_deref(), files))
}
"self" => resolve_relative(after, no_drop, from_file, 0, files),
"super" => {
let mut hops = 1usize;
let mut rest = after;
while let Some((first, tail)) = rest.split_first() {
if *first == "super" {
hops += 1;
rest = tail;
} else {
break;
}
}
resolve_relative(rest, no_drop, from_file, hops, files)
}
_ => {
let in_repo = if files.declares_module(from_file, root) {
resolve_in_repo(
&segments,
no_drop,
crate_root(from_file, files).as_deref(),
files,
)
} else {
sibling_crate_root(root, files)
.and_then(|anchor| resolve_in_repo(after, no_drop, Some(&anchor), files))
};
return in_repo.unwrap_or_else(|| ImportTarget::External(import.module_path.clone()));
}
};
resolved.unwrap_or_else(|| ImportTarget::Unresolved(import.module_path.clone()))
}
fn module_candidates<'a>(segs: &'a [&'a str], no_drop: bool) -> Vec<&'a [&'a str]> {
if segs.is_empty() {
return Vec::new();
}
if no_drop {
return vec![segs];
}
let mut out: Vec<&[&str]> = Vec::new();
if segs.len() >= 2 {
out.push(&segs[..segs.len() - 1]); }
out.push(segs); out
}
fn resolve_in_repo(
module_segs: &[&str],
no_drop: bool,
anchor: Option<&str>,
files: &FileSet,
) -> Option<ImportTarget> {
for cand in module_candidates(module_segs, no_drop) {
if let Some(idx) = suffix_match(cand, anchor, files) {
return Some(ImportTarget::File(idx));
}
}
None
}
fn probe_from(
base: &str,
module_segs: &[&str],
no_drop: bool,
files: &FileSet,
) -> Option<ImportTarget> {
for cand in module_candidates(module_segs, no_drop) {
let joined = cand.join("/");
for rel in [format!("{joined}.rs"), format!("{joined}/mod.rs")] {
if let Some(idx) = files.probe(base, &rel) {
return Some(ImportTarget::File(idx));
}
}
}
None
}
fn crate_root(from_file: usize, files: &FileSet) -> Option<String> {
let path = normalize_path(files.path(from_file)?);
if let Some(i) = path.rfind("/src/") {
return Some(path[..i + 4].to_string());
}
if path.starts_with("src/") {
return Some("src".to_string());
}
None
}
fn sibling_crate_root(name: &str, files: &FileSet) -> Option<String> {
let marker = format!("/{name}/src/");
let mut root: Option<String> = None;
for i in 0..files.len() {
let Some(raw) = files.path(i) else { continue };
let path = normalize_path(raw);
let Some(at) = path.find(&marker) else {
continue;
};
let candidate = path[..at + marker.len() - 1].to_string();
match &root {
Some(existing) if *existing != candidate => return None,
Some(_) => {}
None => root = Some(candidate),
}
}
root
}
fn module_dir(from_file: usize, files: &FileSet) -> Option<String> {
let path = normalize_path(files.path(from_file)?);
let dir = parent_dir(&path);
let stem = path.rsplit('/').next().unwrap_or_default();
let stem = stem.strip_suffix(".rs").unwrap_or(stem);
if matches!(stem, "mod" | "lib" | "main") {
return Some(dir);
}
Some(match dir.as_str() {
"" => stem.to_string(),
"/" => format!("/{stem}"),
d => format!("{d}/{stem}"),
})
}
fn resolve_relative(
module_segs: &[&str],
no_drop: bool,
from_file: usize,
up_hops: usize,
files: &FileSet,
) -> Option<ImportTarget> {
let mut base = module_dir(from_file, files)?;
for _ in 0..up_hops {
base = parent_dir(&base);
}
for cand in module_candidates(module_segs, no_drop) {
let joined = cand.join("/");
if let Some(idx) = files.probe(&base, &format!("{joined}.rs")) {
return Some(ImportTarget::File(idx));
}
if let Some(idx) = files.probe(&base, &format!("{joined}/mod.rs")) {
return Some(ImportTarget::File(idx));
}
}
None
}
fn suffix_match(segs: &[&str], anchor: Option<&str>, files: &FileSet) -> Option<usize> {
if segs.is_empty() {
return None;
}
let joined = segs.join("/");
for suffix in [format!("{joined}.rs"), format!("{joined}/mod.rs")] {
let needle = format!("/{suffix}");
let mut hit: Option<usize> = None;
let mut ambiguous = false;
for i in 0..files.len() {
if let Some(p) = files.path(i) {
let np = normalize_path(p);
if anchor.is_some_and(|root| !np.starts_with(&format!("{root}/"))) {
continue;
}
if np == suffix || np.ends_with(&needle) {
if hit.is_some() {
ambiguous = true;
break;
}
hit = Some(i);
}
}
}
if !ambiguous {
if let Some(idx) = hit {
return Some(idx);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TreeNode;
fn rust_files(paths: &[&str]) -> Vec<TreeNode> {
paths
.iter()
.map(|p| TreeNode::new(p.to_string(), "rust".to_string()))
.collect()
}
fn imp(use_path: &str, from: &str) -> ImportStatement {
let is_ext = !use_path.starts_with("crate::")
&& !use_path.starts_with("self::")
&& !use_path.starts_with("super::");
ImportStatement::new(use_path.to_string(), from.to_string())
.with_external(is_ext)
.with_glob(use_path.contains('*'))
}
#[test]
fn crate_path_resolves_to_file() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/config.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::config::Config", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn crate_nested_resolves_to_foo_rs() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/a/b.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::a::b::Item", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn crate_nested_resolves_to_mod_rs() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/a/b/mod.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::a::b::Item", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn crate_bare_module_import_resolves() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/config.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::config", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn crate_glob_resolves_module() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/a.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::a::*", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn crate_group_resolves_module() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/a.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::a::{X, Y}", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn super_resolves_to_the_parent_module_not_the_parent_directory() {
let files = rust_files(&[
"/repo/src/x.rs",
"/repo/src/parent/x.rs",
"/repo/src/parent/child.rs",
]);
let fs = FileSet::new(&files);
let import = imp("super::x::Y", "/repo/src/parent/child.rs");
assert_eq!(resolve_rust_import(&import, 2, &fs), ImportTarget::File(1));
}
#[test]
fn super_from_mod_rs_climbs_a_real_directory() {
let files = rust_files(&["/repo/src/x.rs", "/repo/src/parent/mod.rs"]);
let fs = FileSet::new(&files);
let import = imp("super::x::Y", "/repo/src/parent/mod.rs");
assert_eq!(resolve_rust_import(&import, 1, &fs), ImportTarget::File(0));
}
#[test]
fn super_super_climbs_two_module_levels() {
let files = rust_files(&["/repo/src/x.rs", "/repo/src/a/b/c.rs"]);
let fs = FileSet::new(&files);
assert!(matches!(
resolve_rust_import(&imp("super::super::x::Y", "/repo/src/a/b/c.rs"), 1, &fs),
ImportTarget::Unresolved(_)
));
}
#[test]
fn self_resolves_submodule_of_a_module_root_file() {
let files = rust_files(&["/repo/src/a/lib.rs", "/repo/src/a/child.rs"]);
let fs = FileSet::new(&files);
let import = imp("self::child::Thing", "/repo/src/a/lib.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn self_resolves_into_the_module_directory_not_the_sibling() {
let files = rust_files(&[
"/repo/src/a/foo.rs",
"/repo/src/a/child.rs",
"/repo/src/a/foo/child.rs",
]);
let fs = FileSet::new(&files);
let import = imp("self::child::Thing", "/repo/src/a/foo.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(2));
}
#[test]
fn aliased_module_import_resolves() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/config.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::config as cfg", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn local_module_never_shadows_an_external_crate() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/log.rs"]);
let fs = FileSet::new(&files);
let import = imp("log::info", "/repo/src/main.rs");
assert_eq!(
resolve_rust_import(&import, 0, &fs),
ImportTarget::External("log::info".to_string())
);
}
#[test]
fn crate_path_probes_the_crate_root_before_matching_by_suffix() {
let files = rust_files(&[
"/repo/src/main.rs",
"/repo/src/core/types.rs",
"/repo/src/types/mod.rs",
]);
let fs = FileSet::new(&files);
let import = imp("crate::types::Thing", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(2));
}
#[test]
fn declared_module_makes_a_bare_root_in_repo() {
let mut files = rust_files(&["/repo/src/main.rs", "/repo/src/config.rs"]);
files[0].declared_modules = vec!["config".to_string()];
let fs = FileSet::new(&files);
let import = imp("config::parse_config", "/repo/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn sibling_workspace_crate_resolves_by_crate_directory() {
let files = rust_files(&["/repo/crates/a/src/main.rs", "/repo/crates/b/src/util.rs"]);
let fs = FileSet::new(&files);
let import = imp("b::util::X", "/repo/crates/a/src/main.rs");
assert_eq!(resolve_rust_import(&import, 0, &fs), ImportTarget::File(1));
}
#[test]
fn crate_path_does_not_cross_into_another_workspace_crate() {
let files = rust_files(&["/repo/crates/a/src/main.rs", "/repo/crates/b/src/util.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::util::X", "/repo/crates/a/src/main.rs");
assert!(matches!(
resolve_rust_import(&import, 0, &fs),
ImportTarget::Unresolved(_)
));
}
#[test]
fn std_path_is_external() {
let files = rust_files(&["/repo/src/main.rs"]);
let fs = FileSet::new(&files);
let import = imp("std::collections::HashMap", "/repo/src/main.rs");
assert_eq!(
resolve_rust_import(&import, 0, &fs),
ImportTarget::External("std::collections::HashMap".to_string())
);
}
#[test]
fn third_party_crate_is_external() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/config.rs"]);
let fs = FileSet::new(&files);
let import = imp("serde::Deserialize", "/repo/src/main.rs");
assert_eq!(
resolve_rust_import(&import, 0, &fs),
ImportTarget::External("serde::Deserialize".to_string())
);
}
#[test]
fn unmappable_crate_path_is_unresolved_not_a_wrong_file() {
let files = rust_files(&["/repo/src/main.rs", "/repo/src/config.rs"]);
let fs = FileSet::new(&files);
let import = imp("crate::reexports::deep::Symbol", "/repo/src/main.rs");
match resolve_rust_import(&import, 0, &fs) {
ImportTarget::Unresolved(p) => assert_eq!(p, "crate::reexports::deep::Symbol"),
other => panic!("expected Unresolved, got {other:?}"),
}
}
#[test]
fn ambiguous_suffix_match_does_not_guess() {
let files = rust_files(&[
"/repo/src/main.rs",
"/repo/src/a/deep/util.rs",
"/repo/src/b/deep/util.rs",
]);
let fs = FileSet::new(&files);
let import = imp("crate::deep::util::X", "/repo/src/main.rs");
assert!(matches!(
resolve_rust_import(&import, 0, &fs),
ImportTarget::Unresolved(_)
));
}
}