use std::collections::HashSet;
use std::path::{Path, PathBuf};
const JS_EXTENSIONS: &[&str] = &["js", "jsx", "mjs", "cjs"];
const TS_EXTENSIONS: &[&str] = &["ts", "tsx"];
#[must_use]
pub fn resolve_by_extension<S: std::hash::BuildHasher>(
importer_path: &str,
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
let ext = Path::new(importer_path)
.extension()
.and_then(std::ffi::OsStr::to_str);
match ext {
Some("rs") => resolve_rust_path(importer_path, target, live_paths),
Some("py" | "pyi") => resolve_python(importer_path, target, live_paths),
Some("js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx") => {
resolve_js_relative(importer_path, target, live_paths)
}
Some("java") => resolve_java(target, live_paths),
_ => None,
}
}
#[must_use]
pub fn resolve_rust_path<S: std::hash::BuildHasher>(
importer_path: &str,
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
let segments: Vec<&str> = target
.trim_end_matches(';')
.split("::")
.filter(|s| !s.is_empty())
.collect();
if segments.is_empty() {
return None;
}
let (rest, root): (&[&str], PathBuf) = match segments[0] {
"crate" => (&segments[1..], crate_src_root(importer_path)),
"self" => (&segments[1..], module_dir(importer_path)),
"super" => {
let mut climbed = 1;
while segments.get(climbed) == Some(&"super") {
climbed += 1;
}
let mut base = module_dir(importer_path);
for _ in 0..climbed {
base.pop();
}
(&segments[climbed..], base)
}
_ => return None,
};
if rest.is_empty() {
return None;
}
let mut path_parts: Vec<&str> = rest
.iter()
.take_while(|s| !s.starts_with('{') && !s.starts_with('*') && !s.contains(' '))
.copied()
.collect();
if path_parts.is_empty() {
return None;
}
let mut joined = root.clone();
for part in &path_parts {
joined.push(part);
}
let candidates = [
format!("{}.rs", to_posix(&joined)),
format!("{}/mod.rs", to_posix(&joined)),
];
for c in &candidates {
if live_paths.contains(c) {
return Some(c.clone());
}
}
path_parts.pop()?;
let mut joined2 = root;
for part in &path_parts {
joined2.push(part);
}
let candidates2 = [
format!("{}.rs", to_posix(&joined2)),
format!("{}/mod.rs", to_posix(&joined2)),
];
for c in &candidates2 {
if live_paths.contains(c) {
return Some(c.clone());
}
}
None
}
#[must_use]
pub fn resolve_python_relative<S: std::hash::BuildHasher>(
importer_path: &str,
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
if !target.starts_with('.') {
return None;
}
let dots = target.chars().take_while(|c| *c == '.').count();
let rest = &target[dots..];
let mut dir = parent_dir(importer_path);
for _ in 1..dots {
dir.pop();
}
let segments: Vec<&str> = rest
.split('.')
.filter(|s| !s.is_empty() && !s.contains(' '))
.collect();
if segments.is_empty() {
return None;
}
let mut joined = dir.clone();
for part in &segments {
joined.push(part);
}
let candidates = [
format!("{}.py", to_posix(&joined)),
format!("{}/__init__.py", to_posix(&joined)),
];
for c in &candidates {
if live_paths.contains(c) {
return Some(c.clone());
}
}
let mut parts2 = segments.clone();
parts2.pop()?;
if parts2.is_empty() {
return None;
}
let mut joined2 = dir;
for part in &parts2 {
joined2.push(part);
}
let candidates2 = [
format!("{}.py", to_posix(&joined2)),
format!("{}/__init__.py", to_posix(&joined2)),
];
for c in &candidates2 {
if live_paths.contains(c) {
return Some(c.clone());
}
}
None
}
fn resolve_python<S: std::hash::BuildHasher>(
importer_path: &str,
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
if target.starts_with('.') {
resolve_python_relative(importer_path, target, live_paths)
} else {
resolve_python_absolute(target, live_paths)
}
}
#[must_use]
pub fn resolve_python_absolute<S: std::hash::BuildHasher>(
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
let rel: String = target
.split('.')
.filter(|s| !s.is_empty() && !s.contains(' '))
.collect::<Vec<_>>()
.join("/");
if rel.is_empty() {
return None;
}
let module = format!("{rel}.py");
let package = format!("{rel}/__init__.py");
let module_suffix = format!("/{module}");
let package_suffix = format!("/{package}");
let mut found: Option<&String> = None;
for path in live_paths {
if path == &module
|| path == &package
|| path.ends_with(&module_suffix)
|| path.ends_with(&package_suffix)
{
if found.is_some() {
return None; }
found = Some(path);
}
}
found.cloned()
}
#[must_use]
pub fn resolve_java<S: std::hash::BuildHasher>(
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
if target.contains('*') {
return None;
}
let mut segments: Vec<&str> = target
.split('.')
.filter(|s| !s.is_empty() && !s.contains(' '))
.collect();
if segments.is_empty() {
return None;
}
if let Some(hit) = java_suffix_match(&segments, live_paths) {
return Some(hit);
}
segments.pop();
if segments.is_empty() {
return None;
}
java_suffix_match(&segments, live_paths)
}
fn java_suffix_match<S: std::hash::BuildHasher>(
segments: &[&str],
live_paths: &HashSet<String, S>,
) -> Option<String> {
let file = format!("{}.java", segments.join("/"));
let file_suffix = format!("/{file}");
let mut found: Option<&String> = None;
for path in live_paths {
if path == &file || path.ends_with(&file_suffix) {
if found.is_some() {
return None; }
found = Some(path);
}
}
found.cloned()
}
fn parent_dir(path: &str) -> PathBuf {
Path::new(path)
.parent()
.map_or_else(PathBuf::new, std::path::Path::to_path_buf)
}
fn module_dir(importer_path: &str) -> PathBuf {
let path = Path::new(importer_path);
let parent = path.parent().unwrap_or_else(|| Path::new(""));
match path.file_stem().and_then(std::ffi::OsStr::to_str) {
Some("mod" | "lib" | "main") | None => parent.to_path_buf(),
Some(stem) => parent.join(stem),
}
}
fn crate_src_root(importer_path: &str) -> PathBuf {
let segments: Vec<&str> = importer_path.split('/').collect();
if let Some(idx) = segments.iter().rposition(|s| *s == "src") {
let prefix = &segments[..=idx];
let mut p = PathBuf::new();
for seg in prefix {
p.push(seg);
}
return p;
}
PathBuf::from("src")
}
fn to_posix(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
#[must_use]
pub fn resolve_js_relative<S: std::hash::BuildHasher>(
importer_path: &str,
target: &str,
live_paths: &HashSet<String, S>,
) -> Option<String> {
if !target.starts_with("./") && !target.starts_with("../") {
return None;
}
let importer_dir = Path::new(importer_path).parent().unwrap_or(Path::new(""));
let joined = normalise_path(&importer_dir.join(target));
let base = to_posix(&joined);
let importer_ext = Path::new(importer_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let primary: &[&str] = match importer_ext {
"ts" | "tsx" => TS_EXTENSIONS,
_ => JS_EXTENSIONS,
};
let secondary: &[&str] = match importer_ext {
"ts" | "tsx" => JS_EXTENSIONS,
_ => TS_EXTENSIONS,
};
if Path::new(&base).extension().is_some() && live_paths.contains(&base) {
return Some(base);
}
let probe_base = match Path::new(&base).extension().and_then(|e| e.to_str()) {
Some(e @ ("js" | "jsx" | "mjs" | "cjs")) => base[..base.len() - e.len() - 1].to_string(),
_ => base.clone(),
};
for ext in primary.iter().chain(secondary.iter()) {
let candidate = format!("{probe_base}.{ext}");
if live_paths.contains(&candidate) {
return Some(candidate);
}
}
for ext in primary.iter().chain(secondary.iter()) {
let candidate = format!("{probe_base}/index.{ext}");
if live_paths.contains(&candidate) {
return Some(candidate);
}
}
None
}
fn normalise_path(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in p.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
_ => out.push(component.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn live(paths: &[&str]) -> HashSet<String> {
paths.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn js_relative_resolves_to_sibling_js_file() {
let live = live(&["src/foo.js", "src/util.js"]);
let got = resolve_js_relative("src/foo.js", "./util", &live);
assert_eq!(got, Some("src/util.js".to_string()));
}
#[test]
fn js_relative_resolves_via_index_file() {
let live = live(&["src/foo.js", "src/util/index.js"]);
let got = resolve_js_relative("src/foo.js", "./util", &live);
assert_eq!(got, Some("src/util/index.js".to_string()));
}
#[test]
fn ts_importer_prefers_ts_extension() {
let live = live(&["src/foo.ts", "src/util.ts", "src/util.js"]);
let got = resolve_js_relative("src/foo.ts", "./util", &live);
assert_eq!(got, Some("src/util.ts".to_string()));
}
#[test]
fn relative_parent_directory_climbs_correctly() {
let live = live(&["src/foo.js", "shared/helper.js"]);
let got = resolve_js_relative("src/foo.js", "../shared/helper", &live);
assert_eq!(got, Some("shared/helper.js".to_string()));
}
#[test]
fn external_npm_imports_dont_resolve() {
let live = live(&["src/foo.js"]);
let got = resolve_js_relative("src/foo.js", "react", &live);
assert!(got.is_none());
}
#[test]
fn explicit_extension_skips_probing() {
let live = live(&["src/foo.js", "src/util.mjs"]);
let got = resolve_js_relative("src/foo.js", "./util.mjs", &live);
assert_eq!(got, Some("src/util.mjs".to_string()));
}
#[test]
fn missing_target_returns_none() {
let live = live(&["src/foo.js"]);
let got = resolve_js_relative("src/foo.js", "./nonexistent", &live);
assert!(got.is_none());
}
#[test]
fn tsx_importer_prefers_tsx_then_ts_then_js() {
let live = live(&["src/App.tsx", "src/Button.jsx"]);
let got = resolve_js_relative("src/App.tsx", "./Button", &live);
assert_eq!(got, Some("src/Button.jsx".to_string()));
}
#[test]
fn nodenext_js_specifier_strips_to_ts_source() {
let live = live(&["src/app.ts", "src/foo.ts"]);
let got = resolve_js_relative("src/app.ts", "./foo.js", &live);
assert_eq!(got, Some("src/foo.ts".to_string()));
}
#[test]
fn nodenext_js_specifier_strips_to_tsx_source() {
let live = live(&["src/app.ts", "src/Widget.tsx"]);
let got = resolve_js_relative("src/app.ts", "./Widget.js", &live);
assert_eq!(got, Some("src/Widget.tsx".to_string()));
}
#[test]
fn real_mjs_file_wins_over_ts_strip_retry() {
let live = live(&["src/app.ts", "src/foo.mjs", "src/foo.ts"]);
let got = resolve_js_relative("src/app.ts", "./foo.mjs", &live);
assert_eq!(got, Some("src/foo.mjs".to_string()));
}
#[test]
fn nodenext_strip_retry_still_existence_guarded() {
let live = live(&["src/app.ts", "src/other.ts"]);
let got = resolve_js_relative("src/app.ts", "./foo.js", &live);
assert!(got.is_none(), "strip-retry must stay existence-guarded");
}
#[test]
fn rust_self_resolves_against_sibling_dir_for_non_mod_file() {
let live = live(&["src/foo.rs", "src/foo/x.rs"]);
let got = resolve_rust_path("src/foo.rs", "self::x", &live);
assert_eq!(got, Some("src/foo/x.rs".to_string()));
}
#[test]
fn rust_super_resolves_to_parent_module_for_non_mod_file() {
let live = live(&["src/foo/bar.rs", "src/foo/y.rs"]);
let got = resolve_rust_path("src/foo/bar.rs", "super::y", &live);
assert_eq!(got, Some("src/foo/y.rs".to_string()));
}
#[test]
fn rust_self_from_mod_rs_is_unchanged() {
let live = live(&["src/foo/mod.rs", "src/foo/x.rs"]);
let got = resolve_rust_path("src/foo/mod.rs", "self::x", &live);
assert_eq!(got, Some("src/foo/x.rs".to_string()));
}
#[test]
fn rust_super_from_mod_rs_is_unchanged() {
let live = live(&["src/foo/mod.rs", "src/y.rs"]);
let got = resolve_rust_path("src/foo/mod.rs", "super::y", &live);
assert_eq!(got, Some("src/y.rs".to_string()));
}
#[test]
fn rust_grouped_leaves_each_resolve() {
let live = live(&["src/main.rs", "src/a.rs", "src/b.rs"]);
assert_eq!(
resolve_rust_path("src/main.rs", "crate::a", &live),
Some("src/a.rs".to_string()),
);
assert_eq!(
resolve_rust_path("src/main.rs", "crate::b", &live),
Some("src/b.rs".to_string()),
);
}
#[test]
fn rust_super_does_not_false_edge_to_crate_root_decoy() {
let live = live(&["src/foo/bar.rs", "src/x.rs"]);
let got = resolve_rust_path("src/foo/bar.rs", "super::x", &live);
assert!(got.is_none(), "must not resolve to the crate-root decoy");
}
#[test]
fn rust_chained_super_climbs_each_level() {
let live = live(&["src/a/b/c.rs", "src/a/x.rs"]);
let got = resolve_rust_path("src/a/b/c.rs", "super::super::x", &live);
assert_eq!(got, Some("src/a/x.rs".to_string()));
}
#[test]
fn python_relative_sibling_resolves() {
let live = live(&["pkg/app.py", "pkg/x.py"]);
let got = resolve_python("pkg/app.py", ".x", &live);
assert_eq!(got, Some("pkg/x.py".to_string()));
}
#[test]
fn python_relative_module_resolves() {
let live = live(&["pkg/app.py", "pkg/mod.py"]);
let got = resolve_python("pkg/app.py", ".mod", &live);
assert_eq!(got, Some("pkg/mod.py".to_string()));
}
#[test]
fn python_parent_relative_resolves() {
let live = live(&["a/b/app.py", "a/pkg.py"]);
let got = resolve_python("a/b/app.py", "..pkg", &live);
assert_eq!(got, Some("a/pkg.py".to_string()));
}
#[test]
fn python_relative_subpackage_init_resolves() {
let live = live(&["pkg/app.py", "pkg/sub/__init__.py"]);
let got = resolve_python("pkg/app.py", ".sub", &live);
assert_eq!(got, Some("pkg/sub/__init__.py".to_string()));
}
#[test]
fn python_absolute_module_resolves_by_suffix() {
let live = live(&["src/mypkg/utils.py", "src/mypkg/__init__.py"]);
let got = resolve_python_absolute("mypkg.utils", &live);
assert_eq!(got, Some("src/mypkg/utils.py".to_string()));
}
#[test]
fn python_absolute_package_init_resolves() {
let live = live(&["src/mypkg/__init__.py", "src/other.py"]);
let got = resolve_python_absolute("mypkg", &live);
assert_eq!(got, Some("src/mypkg/__init__.py".to_string()));
}
#[test]
fn python_absolute_repo_root_module_resolves() {
let live = live(&["mypkg.py", "other.py"]);
let got = resolve_python_absolute("mypkg", &live);
assert_eq!(got, Some("mypkg.py".to_string()));
}
#[test]
fn python_absolute_ambiguous_suffix_returns_none() {
let live = live(&["a/mypkg/utils.py", "b/mypkg/utils.py"]);
let got = resolve_python_absolute("mypkg.utils", &live);
assert!(got.is_none(), "ambiguous suffix must resolve to None");
}
#[test]
fn python_absolute_stdlib_returns_none() {
let live = live(&["src/app.py", "src/mypkg/utils.py"]);
let got = resolve_python_absolute("os", &live);
assert!(got.is_none(), "stdlib module must resolve to None");
}
#[test]
fn python_absolute_partial_segment_does_not_match() {
let live = live(&["src/notmypkg/utils.py"]);
let got = resolve_python_absolute("mypkg.utils", &live);
assert!(got.is_none(), "partial-segment suffix must not match");
}
#[test]
fn java_import_resolves_by_package_suffix() {
let live = live(&["src/main/java/com/foo/Bar.java"]);
let got = resolve_java("com.foo.Bar", &live);
assert_eq!(got, Some("src/main/java/com/foo/Bar.java".to_string()));
}
#[test]
fn java_repo_root_class_resolves() {
let live = live(&["Bar.java", "Other.java"]);
let got = resolve_java("Bar", &live);
assert_eq!(got, Some("Bar.java".to_string()));
}
#[test]
fn java_jdk_import_returns_none() {
let live = live(&["src/main/java/com/foo/App.java"]);
let got = resolve_java("java.util.List", &live);
assert!(got.is_none(), "JDK import must resolve to None");
}
#[test]
fn java_ambiguous_suffix_returns_none() {
let live = live(&["a/com/foo/Bar.java", "b/com/foo/Bar.java"]);
let got = resolve_java("com.foo.Bar", &live);
assert!(got.is_none(), "ambiguous suffix must resolve to None");
}
#[test]
fn java_partial_segment_does_not_match() {
let live = live(&["src/notcom/foo/Bar.java"]);
let got = resolve_java("com.foo.Bar", &live);
assert!(got.is_none(), "partial-segment suffix must not match");
}
#[test]
fn java_wildcard_import_returns_none() {
let live = live(&["src/main/java/com/foo/Bar.java"]);
let got = resolve_java("com.foo.*", &live);
assert!(got.is_none(), "wildcard import must resolve to None");
}
#[test]
fn java_inner_class_strips_to_enclosing_file() {
let live = live(&["src/main/java/com/foo/Outer.java"]);
let got = resolve_java("com.foo.Outer.Inner", &live);
assert_eq!(got, Some("src/main/java/com/foo/Outer.java".to_string()));
}
#[test]
fn java_static_member_strips_to_class_file() {
let live = live(&["src/main/java/com/foo/Bar.java"]);
let got = resolve_java("com.foo.Bar.baz", &live);
assert_eq!(got, Some("src/main/java/com/foo/Bar.java".to_string()));
}
#[test]
fn java_full_path_wins_over_strip_retry() {
let live = live(&[
"src/main/java/com/foo/Outer/Inner.java",
"src/main/java/com/foo/Outer.java",
]);
let got = resolve_java("com.foo.Outer.Inner", &live);
assert_eq!(
got,
Some("src/main/java/com/foo/Outer/Inner.java".to_string()),
);
}
#[test]
fn java_strip_retry_still_ambiguity_guarded() {
let live = live(&["a/com/foo/Outer.java", "b/com/foo/Outer.java"]);
let got = resolve_java("com.foo.Outer.Inner", &live);
assert!(got.is_none(), "ambiguous strip-retry must resolve to None");
}
}