use simd_json::prelude::*;
use std::path::{Path, PathBuf};
const EXTENSIONS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "tsx", "jsx", "json"];
const INDEX_STEMS: &[&str] = &["index"];
pub fn resolve(base_dir: &Path, specifier: &str) -> Result<PathBuf, String> {
if specifier.is_empty() {
return Err(not_found(specifier, base_dir));
}
if is_path_specifier(specifier) {
let joined = if Path::new(specifier).is_absolute() {
PathBuf::from(specifier)
} else {
base_dir.join(specifier)
};
return as_file_or_directory(&joined).ok_or_else(|| not_found(specifier, base_dir));
}
from_node_modules(base_dir, specifier).ok_or_else(|| not_found(specifier, base_dir))
}
fn is_path_specifier(specifier: &str) -> bool {
specifier.starts_with("./")
|| specifier.starts_with("../")
|| specifier.starts_with('/')
|| specifier == "."
|| specifier == ".."
}
fn as_file_or_directory(path: &Path) -> Option<PathBuf> {
as_file(path).or_else(|| as_directory(path))
}
fn as_file(path: &Path) -> Option<PathBuf> {
if path.is_file() {
return canonical(path);
}
for ext in EXTENSIONS {
let mut candidate = path.as_os_str().to_os_string();
candidate.push(".");
candidate.push(ext);
let candidate = PathBuf::from(candidate);
if candidate.is_file() {
return canonical(&candidate);
}
}
None
}
fn as_directory(path: &Path) -> Option<PathBuf> {
if !path.is_dir() {
return None;
}
if let Some(entry) = manifest_entry(path) {
if let Some(found) = as_file_or_directory(&path.join(entry)) {
return Some(found);
}
}
for stem in INDEX_STEMS {
if let Some(found) = as_file(&path.join(stem)) {
return Some(found);
}
}
None
}
fn manifest_entry(pkg_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(pkg_dir.join("package.json")).ok()?;
let mut bytes = text.into_bytes();
let json = simd_json::to_owned_value(&mut bytes).ok()?;
if let Some(exports) = json.get("exports") {
if let Some(entry) = export_target(exports) {
return Some(entry);
}
}
for field in ["module", "main"] {
if let Some(value) = json.get(field).and_then(|v| v.as_str()) {
return Some(value.to_string());
}
}
None
}
fn export_target(exports: &simd_json::OwnedValue) -> Option<String> {
if let Some(direct) = exports.as_str() {
return Some(direct.to_string());
}
let root = exports.get(".").unwrap_or(exports);
if let Some(direct) = root.as_str() {
return Some(direct.to_string());
}
for condition in ["import", "require", "default"] {
if let Some(value) = root.get(condition) {
if let Some(direct) = value.as_str() {
return Some(direct.to_string());
}
if let Some(nested) = export_target(value) {
return Some(nested);
}
}
}
None
}
fn from_node_modules(base_dir: &Path, specifier: &str) -> Option<PathBuf> {
let (package, subpath) = split_package(specifier);
for dir in base_dir.ancestors() {
let candidate = dir.join("node_modules").join(&package);
if !candidate.is_dir() {
continue;
}
let found = match subpath {
Some(sub) => as_file_or_directory(&candidate.join(sub)),
None => as_directory(&candidate),
};
if found.is_some() {
return found;
}
}
None
}
fn split_package(specifier: &str) -> (String, Option<&str>) {
let mut parts = specifier.splitn(if specifier.starts_with('@') { 3 } else { 2 }, '/');
let mut name = parts.next().unwrap_or(specifier).to_string();
if specifier.starts_with('@') {
if let Some(second) = parts.next() {
name.push('/');
name.push_str(second);
}
}
let rest = parts.next().filter(|s| !s.is_empty());
(name, rest)
}
fn canonical(path: &Path) -> Option<PathBuf> {
Some(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
}
fn not_found(specifier: &str, base_dir: &Path) -> String {
format!("Cannot find module '{specifier}' from {}", base_dir.display())
}
#[cfg(test)]
mod tests {
use super::*;
fn tree() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(root.join("sibling.ts"), b"").expect("write");
std::fs::write(root.join("chart.min.js"), b"").expect("write");
std::fs::create_dir_all(root.join("folder")).expect("mkdir");
std::fs::write(root.join("folder/index.js"), b"").expect("write");
std::fs::create_dir_all(root.join("nested/deep")).expect("mkdir");
std::fs::write(root.join("nested/deep/leaf.ts"), b"").expect("write");
tmp
}
#[test]
fn a_relative_specifier_gets_its_extension_appended() {
let tmp = tree();
let found = resolve(tmp.path(), "./sibling").expect("resolve");
assert_eq!(found.file_name().expect("name"), "sibling.ts");
}
#[test]
fn a_dotted_stem_keeps_its_own_suffix() {
let tmp = tree();
let found = resolve(tmp.path(), "./chart.min").expect("resolve");
assert_eq!(found.file_name().expect("name"), "chart.min.js");
}
#[test]
fn a_directory_resolves_through_its_index() {
let tmp = tree();
let found = resolve(tmp.path(), "./folder").expect("resolve");
assert_eq!(found.file_name().expect("name"), "index.js");
}
#[test]
fn a_parent_specifier_resolves_from_the_asking_directory() {
let tmp = tree();
let found = resolve(&tmp.path().join("nested/deep"), "../../sibling.ts").expect("resolve");
assert_eq!(found.file_name().expect("name"), "sibling.ts");
}
#[test]
fn a_missing_module_names_itself_and_where_it_was_asked_from() {
let tmp = tree();
let err = resolve(tmp.path(), "./nope").expect_err("missing");
assert!(err.contains("Cannot find module './nope'"), "{err}");
assert!(err.contains(&tmp.path().display().to_string()), "{err}");
}
#[test]
fn a_bare_specifier_walks_node_modules_upward() {
let tmp = tree();
let pkg = tmp.path().join("node_modules/acme");
std::fs::create_dir_all(&pkg).expect("mkdir");
std::fs::write(pkg.join("package.json"), br#"{"main":"./lib/entry.js"}"#).expect("write");
std::fs::create_dir_all(pkg.join("lib")).expect("mkdir");
std::fs::write(pkg.join("lib/entry.js"), b"").expect("write");
let found = resolve(&tmp.path().join("nested/deep"), "acme").expect("resolve");
assert_eq!(found, canonical(&pkg.join("lib/entry.js")).expect("canonical"));
}
#[test]
fn a_scoped_package_subpath_resolves() {
let tmp = tree();
let pkg = tmp.path().join("node_modules/@acme/kit");
std::fs::create_dir_all(pkg.join("src")).expect("mkdir");
std::fs::write(pkg.join("package.json"), br#"{"main":"./index.js"}"#).expect("write");
std::fs::write(pkg.join("src/helper.ts"), b"").expect("write");
let found = resolve(tmp.path(), "@acme/kit/src/helper").expect("resolve");
assert_eq!(found.file_name().expect("name"), "helper.ts");
}
#[test]
fn an_exports_condition_decides_the_entry() {
let tmp = tree();
let pkg = tmp.path().join("node_modules/conditional");
std::fs::create_dir_all(&pkg).expect("mkdir");
std::fs::write(
pkg.join("package.json"),
br#"{"exports":{".":{"import":"./esm.js","require":"./cjs.js"}},"main":"./ignored.js"}"#,
)
.expect("write");
std::fs::write(pkg.join("esm.js"), b"").expect("write");
std::fs::write(pkg.join("cjs.js"), b"").expect("write");
std::fs::write(pkg.join("ignored.js"), b"").expect("write");
let found = resolve(tmp.path(), "conditional").expect("resolve");
assert_eq!(found.file_name().expect("name"), "esm.js");
}
}