const EXTENSIONLESS_SUFFIXES: &[&str] = &[
".ts", ".tsx", ".d.ts", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
];
#[must_use]
pub fn is_js_family_importer(path: &str) -> bool {
matches!(
extension(path),
"js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" | "vue" | "svelte" | "astro"
)
}
#[must_use]
pub fn is_relative_specifier(specifier: &str) -> bool {
specifier == "."
|| specifier == ".."
|| specifier.starts_with("./")
|| specifier.starts_with("../")
}
#[must_use]
pub fn relative_module_candidates(importer: &str, specifier: &str) -> Vec<String> {
if !is_js_family_importer(importer) || !is_relative_specifier(specifier) {
return Vec::new();
}
let specifier = specifier.split(['?', '#']).next().unwrap_or(specifier);
let base_dir = importer.rfind('/').map_or("", |i| &importer[..i]);
let Some(target) = normalize_join(base_dir, specifier) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
let mut push = |path: String| {
if !path.is_empty() && !out.contains(&path) {
out.push(path);
}
};
if specifier.ends_with('/') || specifier == "." || specifier == ".." {
for suffix in EXTENSIONLESS_SUFFIXES {
push(join_index(&target, suffix));
}
return out;
}
let ts_importer = matches!(extension(importer), "ts" | "tsx" | "mts" | "cts");
let file_name = target.rsplit('/').next().unwrap_or(&target);
let mapped: Option<(&str, &[&str])> = match extension(file_name) {
"js" => Some(("js", &[".ts", ".tsx", ".d.ts"])),
"jsx" => Some(("jsx", &[".tsx"])),
"mjs" => Some(("mjs", &[".mts", ".d.mts"])),
"cjs" => Some(("cjs", &[".cts", ".d.cts"])),
_ => None,
};
if let Some((ext, sources)) = mapped {
let stem = &target[..target.len() - ext.len() - 1];
if !ts_importer {
push(target.clone());
}
for source in sources {
push(format!("{stem}{source}"));
}
push(target.clone());
return out;
}
push(target.clone());
for suffix in EXTENSIONLESS_SUFFIXES {
push(format!("{target}{suffix}"));
}
for suffix in EXTENSIONLESS_SUFFIXES {
push(join_index(&target, suffix));
}
out
}
fn extension(path: &str) -> &str {
let name = path.rsplit('/').next().unwrap_or(path);
name.rfind('.').map_or("", |i| &name[i + 1..])
}
fn join_index(dir: &str, suffix: &str) -> String {
if dir.is_empty() {
format!("index{suffix}")
} else {
format!("{dir}/index{suffix}")
}
}
fn normalize_join(base_dir: &str, relative: &str) -> Option<String> {
let mut segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
for segment in relative.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop()?;
}
other => segments.push(other),
}
}
Some(segments.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn js_specifier_from_ts_prefers_ts_source() {
assert_eq!(
relative_module_candidates("tests/lib/hash.test.ts", "../../src/lib/hash.js"),
vec![
"src/lib/hash.ts",
"src/lib/hash.tsx",
"src/lib/hash.d.ts",
"src/lib/hash.js",
]
);
}
#[test]
fn js_specifier_from_js_prefers_named_file() {
assert_eq!(
relative_module_candidates("src/a.mjs", "./b.js")[0],
"src/b.js".to_string()
);
}
#[test]
fn module_extension_mapping() {
assert_eq!(
relative_module_candidates("a.ts", "./v.jsx"),
vec!["v.tsx", "v.jsx"]
);
assert_eq!(
relative_module_candidates("a.ts", "./m.mjs"),
vec!["m.mts", "m.d.mts", "m.mjs"]
);
assert_eq!(
relative_module_candidates("a.ts", "./c.cjs"),
vec!["c.cts", "c.d.cts", "c.cjs"]
);
}
#[test]
fn extensionless_tries_sources_then_index() {
let c = relative_module_candidates("src/a.ts", "./util");
assert_eq!(c[0], "src/util");
assert_eq!(c[1], "src/util.ts");
assert!(c.contains(&"src/util/index.ts".to_string()));
}
#[test]
fn non_relative_or_escaping_specifiers_yield_nothing() {
assert!(relative_module_candidates("src/a.ts", "vitest").is_empty());
assert!(relative_module_candidates("src/a.ts", "@scope/pkg/x.js").is_empty());
assert!(relative_module_candidates("a.ts", "../outside.js").is_empty());
assert!(relative_module_candidates("src/a.py", "./b.js").is_empty());
}
#[test]
fn query_suffix_and_directory_specifiers() {
assert_eq!(
relative_module_candidates("src/a.ts", "./w.js?worker")[0],
"src/w.ts"
);
assert_eq!(
relative_module_candidates("src/a/b.ts", "..")[0],
"src/index.ts"
);
}
}