use std::path::Path;
use lanekeep_core::FilePath;
use lanekeep_core::files::{FileAccess, normalize};
const RELATIVE_SUFFIXES: &[&str] = &[
".ts",
".tsx",
".mts",
".cts",
".d.ts",
"/index.ts",
"/index.tsx",
"/index.d.ts",
];
#[must_use]
pub fn resolve_specifier(files: &FileAccess, from: &FilePath, specifier: &str) -> Option<FilePath> {
if specifier.starts_with("./") || specifier.starts_with("../") {
return relative(files, from, specifier);
}
if specifier.starts_with('/') || specifier.starts_with('.') || specifier.is_empty() {
return None;
}
bare(files, from, specifier)
}
fn relative(files: &FileAccess, from: &FilePath, specifier: &str) -> Option<FilePath> {
let stem = [".js", ".jsx", ".mjs", ".cjs"]
.iter()
.find_map(|suffix| specifier.strip_suffix(suffix))
.unwrap_or(specifier);
let base = within_root(&join(parent_of(from.as_str()), stem))?;
for suffix in RELATIVE_SUFFIXES {
let candidate = format!("{base}{suffix}");
if files.exists(&candidate).unwrap_or(false) {
return Some(FilePath::new(&candidate));
}
}
None
}
fn parent_of(path: &str) -> &str {
match path.rfind('/') {
Some(at) => &path[..at],
None => "",
}
}
fn join(left: &str, right: &str) -> String {
if left.is_empty() {
right.to_owned()
} else {
format!("{left}/{right}")
}
}
fn within_root(path: &str) -> Option<String> {
let normalized = normalize(Path::new(path))
.to_string_lossy()
.replace('\\', "/");
if normalized.is_empty() || normalized == ".." || normalized.starts_with("../") {
return None;
}
Some(normalized)
}
fn bare(files: &FileAccess, from: &FilePath, specifier: &str) -> Option<FilePath> {
let (package, subpath) = split_specifier(specifier)?;
let types_package = at_types_name(&package);
let mut directory = parent_of(from.as_str()).to_owned();
loop {
for name in [package.as_str(), types_package.as_str()] {
let root = join(&directory, &format!("node_modules/{name}"));
if let Some(found) = in_package(files, &root, &subpath) {
return Some(found);
}
}
if directory.is_empty() {
return None;
}
directory = parent_of(&directory).to_owned();
}
}
fn split_specifier(specifier: &str) -> Option<(String, String)> {
let scoped = specifier.starts_with('@');
let mut parts = specifier.splitn(if scoped { 3 } else { 2 }, '/');
let first = parts.next()?;
if first.is_empty() {
return None;
}
if scoped {
let name = parts.next()?;
if name.is_empty() {
return None;
}
Some((
format!("{first}/{name}"),
parts.next().unwrap_or_default().to_owned(),
))
} else {
Some((
first.to_owned(),
parts.next().unwrap_or_default().to_owned(),
))
}
}
fn at_types_name(package: &str) -> String {
match package.strip_prefix('@') {
Some(rest) => format!("@types/{}", rest.replacen('/', "__", 1)),
None => format!("@types/{package}"),
}
}
fn in_package(files: &FileAccess, root: &str, subpath: &str) -> Option<FilePath> {
if let Ok(Some(text)) = files.read(&join(root, "package.json"))
&& let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text)
{
if let Some(target) = exports_target(&manifest, subpath)
&& let Some(found) = candidate(files, root, &target)
{
return Some(found);
}
if subpath.is_empty() {
for field in ["types", "typings"] {
if let Some(target) = manifest.get(field).and_then(serde_json::Value::as_str)
&& let Some(found) = candidate(files, root, target)
{
return Some(found);
}
}
}
}
let fallback = if subpath.is_empty() {
"index.d.ts".to_owned()
} else {
format!("{subpath}/index.d.ts")
};
candidate(files, root, &fallback)
}
fn candidate(files: &FileAccess, root: &str, target: &str) -> Option<FilePath> {
let target = target.strip_prefix("./").unwrap_or(target);
let mut spellings = vec![target.to_owned()];
if !Path::new(target)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
{
spellings.push(format!("{target}.d.ts"));
spellings.push(format!("{target}/index.d.ts"));
}
for spelling in spellings {
let Some(path) = within_root(&join(root, &spelling)) else {
continue;
};
if files.exists(&path).unwrap_or(false) {
return Some(FilePath::new(&path));
}
}
None
}
fn exports_target(manifest: &serde_json::Value, subpath: &str) -> Option<String> {
let exports = manifest.get("exports")?;
let key = if subpath.is_empty() {
".".to_owned()
} else {
format!("./{subpath}")
};
let subpaths = exports
.as_object()
.is_some_and(|map| map.keys().any(|k| k.starts_with('.')));
if !subpaths {
return if key == "." {
types_condition(exports)
} else {
None
};
}
let map = exports.as_object()?;
if let Some(target) = map.get(&key).and_then(types_condition) {
return Some(target);
}
let mut patterns: Vec<(&String, &serde_json::Value)> =
map.iter().filter(|(k, _)| k.contains('*')).collect();
patterns.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(b.0)));
for (pattern, value) in patterns {
if let Some(matched) = star_match(pattern, &key)
&& let Some(target) = types_condition(value)
{
return Some(target.replace('*', &matched));
}
}
None
}
fn types_condition(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(target) => Some(target.clone()),
serde_json::Value::Object(map) => map.get("types").and_then(under_types).or_else(|| {
map.values().find_map(|nested| match nested {
serde_json::Value::Object(_) => types_condition(nested),
_ => None,
})
}),
_ => None,
}
}
fn under_types(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(target) => Some(target.clone()),
serde_json::Value::Object(map) => map
.get("types")
.and_then(under_types)
.or_else(|| map.values().find_map(under_types)),
_ => None,
}
}
fn star_match(pattern: &str, key: &str) -> Option<String> {
let (head, tail) = pattern.split_once('*')?;
let rest = key.strip_prefix(head)?;
Some(rest.strip_suffix(tail)?.to_owned())
}