use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use super::{validate_npm_name, validate_npm_version}; /// Failures reading npm package manifests. /// Resolution failures live in the resolver's own error type. #[derive(Clone, Debug, thiserror::Error)] pub enum PackageError { #[error("npm package.json is missing its name")] NpmPackageJsonMissingName, #[error("npm package.json is missing its version")] NpmPackageJsonMissingVersion, #[error("npm package.json does not match its archive identity")] NpmPackageJsonIdentityMismatch, #[error("Unsupported package type: {other}")] UnsupportedPackageType { other: String }, #[error("npm dependencies must be an object")] NpmDependenciesNotObject, #[error("npm dependency {key:?} must be a version range")] NpmDependencyNotRange { key: String }, #[error("npm optional dependency {key:?} must be a version range")] NpmOptionalDependencyNotRange { key: String }, #[error("npm dependency {key:?} has an invalid alias")] NpmDependencyInvalidAlias { key: String }, #[error("npm dependency {key:?} has an invalid version range")] NpmDependencyInvalidRange { key: String }, #[error("Invalid npm os field: {detail}")] InvalidNpmOsField { detail: String }, #[error("Invalid npm cpu field: {detail}")] InvalidNpmCpuField { detail: String }, #[error("npm optionalDependencies must be an object")] NpmOptionalDependenciesNotObject, #[error("npm peerDependencies must be an object")] NpmPeerDependenciesNotObject, #[error("npm peer dependency {key:?} must be a version range")] NpmPeerDependencyNotRange { key: String }, #[error("npm {key:?} must be a path string")] NpmMustBePathString { key: String }, #[error("npm dependency {key:?} has an empty alias")] NpmDependencyEmptyAlias { key: String }, #[error("npm dependency {key:?} alias must not have a subpath")] NpmDependencyAliasSubpath { key: String }, #[error("npm peer dependency {key:?} uses unsupported protocol {protocol:?}")] NpmPeerDependencyProtocol { key: String, protocol: String }, #[error("npm exports mixes subpath and condition keys")] NpmExportsMixesKeys, #[error("Invalid npm exports field")] InvalidNpmExportsField, #[error("npm imports must be an object")] NpmImportsNotObject, #[error("npm imports keys must start with '#'")] NpmImportsKeyPrefix, #[error("npm platform entries must be strings")] NpmPlatformEntriesNotStrings, #[error("npm platform conditions must be a string or array")] NpmPlatformConditionsType, #[error("npm platform conditions must not be empty")] NpmPlatformConditionsEmpty, #[error("npm platform exclusion must name a platform")] NpmPlatformExclusionUnnamed, #[error("Invalid npm export subpath: {subpath}")] InvalidNpmExportSubpath { subpath: String }, #[error("npm export \".\" is blocked")] NpmExportRootBlocked, #[error("Invalid npm export pattern: {key}")] InvalidNpmExportPattern { key: String }, #[error("npm export {subpath:?} is blocked")] NpmExportBlocked { subpath: String }, #[error("npm export target escapes its package: {target:?}")] NpmExportTargetEscapes { target: String }, #[error("Invalid npm exports entry")] InvalidNpmExportsEntry, #[error("invalid npm condition key: {key}")] InvalidNpmConditionKey { key: String }, #[error("Invalid npm imports pattern: {key}")] InvalidNpmImportsPattern { key: String }, #[error("npm import {specifier:?} is blocked")] NpmImportBlocked { specifier: String }, #[error("npm import is blocked")] NpmImportBlockedBare, #[error("npm imports target escapes its package: {target:?}")] NpmImportsTargetEscapes { target: String }, #[error("npm imports mixes subpath and condition keys")] NpmImportsMixesKeys, #[error("Invalid npm imports entry")] InvalidNpmImportsEntry, #[error("Invalid npm imports target")] InvalidNpmImportsTarget, #[error("npm imports target escapes its package")] NpmImportsTargetEscapesBare, #[error("npm imports target {target:?} is not a declared dependency")] NpmImportsTargetUndeclared { target: String }, #[error("Invalid package type in {path}")] InvalidPackageTypeIn { path: String }, #[error("Unsupported package type in {path}: {text}")] UnsupportedPackageTypeIn { path: String, text: String }, #[error("{detail}")] NpmUnsupportedModule { detail: String }, #[error("Invalid nested npm package.json {path}: {source}")] InvalidNestedNpmPackageJson { path: String, source: Arc, }, #[error(transparent)] Npm(Box), } impl From for PackageError { fn from(error: super::NpmError) -> Self { Self::Npm(Box::new(error)) } } /// Parsed view of one published `package.json`. The ordered document is kept /// out of this struct: resolution reads `exports`/`imports` from the verified /// bytes through [`NpmManifest::exports_raw`]/[`imports_raw`] owners instead. #[derive(Clone, Debug, PartialEq)] pub struct NpmManifest { pub name: String, pub version: String, pub package_type: NpmPackageType, pub main: Option, pub module: Option, pub types: Option, pub typings: Option, pub types_versions: Option, pub exports_raw: Option, pub imports_raw: Option, pub dependencies: BTreeMap, pub peers: BTreeMap, pub os: Option, pub cpu: Option, /// Bundler-only hints, kept raw so malformed values never break execution. pub browser: Option, pub side_effects: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NpmPackageType { Module, CommonJs, } #[derive(Clone, Debug, PartialEq)] pub struct NpmDep { pub kind: NpmDepKind, pub optional: bool, } #[derive(Clone, Debug, PartialEq)] pub enum NpmDepKind { Registry { request: String }, Alias { target: String }, Unsupported { protocol: String }, } #[derive(Clone, Debug, PartialEq)] pub struct NpmPeer { pub range: String, pub optional: bool, } #[derive(Clone, Debug, PartialEq)] pub struct PlatformRule { pub allow: Vec, pub deny: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NpmModuleKind { Esm, TypeScript, Declaration, CommonJs, Json, } #[derive(Clone, Debug)] pub enum NpmUnsupported { Native { path: String }, Extension { path: String }, } impl std::fmt::Display for NpmUnsupported { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Native { path } => write!(f, "Native addons are not supported: {path}"), Self::Extension { path } => write!(f, "Unsupported module extension: {path}"), } } } impl std::error::Error for NpmUnsupported {} pub fn parse_npm_manifest( name: &str, version: &str, value: &serde_json::Value, ) -> Result { let published_name = value .get("name") .and_then(|value| value.as_str()) .ok_or(PackageError::NpmPackageJsonMissingName)?; let published_version = value .get("version") .and_then(|value| value.as_str()) .ok_or(PackageError::NpmPackageJsonMissingVersion)?; if published_name != name || published_version != version { return Err(PackageError::NpmPackageJsonIdentityMismatch); } validate_npm_name(published_name)?; validate_npm_version(published_version)?; let package_type = match value.get("type").and_then(|value| value.as_str()) { None => NpmPackageType::CommonJs, Some("module") => NpmPackageType::Module, Some("commonjs") => NpmPackageType::CommonJs, Some(other) => { return Err(PackageError::UnsupportedPackageType { other: other.to_owned() }); } }; let main = read_entry_string(value, "main")?; let module = read_entry_string(value, "module")?; let types = value.get("types").cloned(); let typings = value.get("typings").cloned(); let types_versions = value.get("typesVersions").cloned(); let exports_raw = value.get("exports").cloned(); if let Some(exports) = &exports_raw { validate_exports_shape(exports)?; } let imports_raw = value.get("imports").cloned(); if let Some(imports) = &imports_raw { validate_imports_shape(imports)?; } let mut dependencies = BTreeMap::new(); if let Some(table) = value.get("dependencies") { let table = table .as_object() .ok_or(PackageError::NpmDependenciesNotObject)?; for (key, spec) in table { let spec = spec .as_str() .ok_or_else(|| PackageError::NpmDependencyNotRange { key: key.to_owned() })?; dependencies.insert( key.clone(), NpmDep { kind: parse_dep_spec(key, spec)?, optional: false, }, ); } } if let Some(table) = value.get("optionalDependencies") { let table = table .as_object() .ok_or(PackageError::NpmOptionalDependenciesNotObject)?; for (key, spec) in table { let spec = spec.as_str() .ok_or_else(|| PackageError::NpmOptionalDependencyNotRange { key: key.to_owned(), })?; dependencies.insert( key.clone(), NpmDep { kind: parse_dep_spec(key, spec)?, optional: true, }, ); } } let mut peers = BTreeMap::new(); if let Some(table) = value.get("peerDependencies") { let table = table .as_object() .ok_or(PackageError::NpmPeerDependenciesNotObject)?; let meta = value .get("peerDependenciesMeta") .and_then(|value| value.as_object()); for (key, range) in table { let range = range .as_str() .ok_or_else(|| PackageError::NpmPeerDependencyNotRange { key: key.to_owned() })?; // Peer ranges stay textual until selection; only the protocol shape // is rejected here so later chunks see the declared constraint. peer_range_supported(key, range)?; let optional = meta .and_then(|meta| meta.get(key)) .and_then(|entry| entry.get("optional")) .and_then(|value| value.as_bool()) .unwrap_or(false); peers.insert( key.clone(), NpmPeer { range: range.to_owned(), optional, }, ); } } let os = value .get("os") .map(parse_platform_rule) .transpose() .map_err(|error| PackageError::InvalidNpmOsField { detail: error.to_string() })?; let cpu = value .get("cpu") .map(parse_platform_rule) .transpose() .map_err(|error| PackageError::InvalidNpmCpuField { detail: error.to_string() })?; Ok(NpmManifest { name: name.to_owned(), version: version.to_owned(), package_type, main, module, types, typings, types_versions, exports_raw, imports_raw, dependencies, peers, os, cpu, browser: value.get("browser").cloned(), side_effects: value.get("sideEffects").cloned(), }) } /// Whether `sideEffects` declares side effects for a package-relative file, /// matched like Rolldown does. Unknown shapes and invalid patterns are `None`. pub fn declared_side_effects(field: &serde_json::Value, rel: &str) -> Option { let patterns = match field { serde_json::Value::Bool(value) => return Some(*value), serde_json::Value::String(_) => std::slice::from_ref(field), serde_json::Value::Array(patterns) => patterns.as_slice(), _ => return None, }; let mut matched = false; for pattern in patterns { let pattern = pattern.as_str()?; let trimmed = pattern.trim_start_matches("./"); let mut glob = if trimmed.len() == pattern.len() && trimmed.contains('/') { trimmed.to_owned() } else { format!("**/{trimmed}") }; // The field is an allowlist: a leading `!` is a literal file name. if glob.starts_with('!') { glob.insert(0, '\\'); } fast_glob::validate(&glob).ok()?; matched |= fast_glob::glob_match(&glob, rel); } Some(matched) } fn read_entry_string(value: &serde_json::Value, key: &str) -> Result, PackageError> { match value.get(key) { None => Ok(None), // Subpath-only packages use main: false alongside explicit exports. Some(serde_json::Value::Bool(false)) if key == "main" => Ok(None), Some(entry) => entry .as_str() .map(|entry| Some(entry.to_owned())) .ok_or_else(|| PackageError::NpmMustBePathString { key: key.to_owned() }), } } pub fn parse_dep_spec(key: &str, spec: &str) -> Result { if let Some(target) = spec.strip_prefix("npm:") { if target.is_empty() { return Err(PackageError::NpmDependencyEmptyAlias { key: key.to_owned() }); } let reference = super::parse_npm_request(&format!("npm:{target}")) .map_err(|_| PackageError::NpmDependencyInvalidAlias { key: key.to_owned() })?; if reference.subpath.is_some() { return Err(PackageError::NpmDependencyAliasSubpath { key: key.to_owned() }); } return Ok(NpmDepKind::Alias { target: reference.request_key() }); } if let Some((protocol, _)) = spec.split_once(':') && !protocol.contains('/') && !protocol.contains('@') { return Ok(NpmDepKind::Unsupported { protocol: protocol.to_owned() }); } let request = if spec.is_empty() || spec == "*" { format!("npm:{key}") } else { format!("npm:{key}@{spec}") }; super::parse_npm_request(&request) .map_err(|_| PackageError::NpmDependencyInvalidRange { key: key.to_owned() })?; Ok(NpmDepKind::Registry { request }) } fn peer_range_supported(key: &str, range: &str) -> Result<(), PackageError> { if range.is_empty() { return Err(PackageError::NpmPeerDependencyNotRange { key: key.to_owned() }); } if let Some((protocol, _)) = range.split_once(':') && !protocol.contains('/') && !protocol.contains('@') { return Err(PackageError::NpmPeerDependencyProtocol { key: key.to_owned(), protocol: protocol.to_owned(), }); } Ok(()) } fn validate_exports_shape(exports: &serde_json::Value) -> Result<(), PackageError> { match exports { serde_json::Value::Null | serde_json::Value::String(_) => Ok(()), serde_json::Value::Array(_) => Ok(()), serde_json::Value::Object(map) => { let subpaths = map.keys().filter(|key| key.starts_with('.')).count(); if subpaths > 0 && subpaths != map.len() { return Err(PackageError::NpmExportsMixesKeys); } Ok(()) } _ => Err(PackageError::InvalidNpmExportsField), } } fn validate_imports_shape(imports: &serde_json::Value) -> Result<(), PackageError> { let map = imports .as_object() .ok_or(PackageError::NpmImportsNotObject)?; for key in map.keys() { if !key.starts_with('#') { return Err(PackageError::NpmImportsKeyPrefix); } } Ok(()) } fn parse_platform_rule(value: &serde_json::Value) -> Result { let entries: Vec = match value { serde_json::Value::String(entry) => vec![entry.clone()], serde_json::Value::Array(entries) => entries .iter() .map(|entry| { entry .as_str() .map(str::to_owned) .ok_or(PackageError::NpmPlatformEntriesNotStrings) }) .collect::>()?, _ => return Err(PackageError::NpmPlatformConditionsType), }; if entries.is_empty() { return Err(PackageError::NpmPlatformConditionsEmpty); } let mut allow = Vec::new(); let mut deny = Vec::new(); for entry in entries { if let Some(excluded) = entry.strip_prefix('!') { if excluded.is_empty() { return Err(PackageError::NpmPlatformExclusionUnnamed); } deny.push(excluded.to_owned()); } else { allow.push(entry); } } Ok(PlatformRule { allow, deny }) } pub fn platform_satisfied(rule: Option<&PlatformRule>, host: &str) -> bool { let Some(rule) = rule else { return true; }; if rule.deny.iter().any(|entry| entry == host) { return false; } if rule.allow.is_empty() { return true; } rule.allow.iter().any(|entry| entry == host) } pub fn npm_host_os() -> &'static str { match std::env::consts::OS { "macos" => "darwin", "windows" => "win32", other => other, } } pub fn npm_host_arch() -> &'static str { match std::env::consts::ARCH { "x86_64" => "x64", "aarch64" => "arm64", "x86" => "ia32", other => other, } } /// Ordered condition set for this runtime. `node`, `require`, and `browser` /// are deliberately absent: only ESM graphs resolve here. pub fn npm_conditions() -> [&'static str; 4] { ["avril", "import", "module", "default"] } /// Ordered condition set for synchronous `require` graphs. `import`, /// `module`, and `node` are deliberately absent: `require` selects its own /// branch independently from `import`. pub fn npm_conditions_require() -> [&'static str; 3] { ["avril", "require", "default"] } /// Resolve `exports` for one subpath (`"."` or `"./…"`). `Ok(None)` means no /// entry matched; an exports map that is present never falls back to private /// files, so callers must turn `None` into an error. pub fn resolve_exports_target( exports: &serde_json::Value, subpath: &str, conditions: &[&str], ) -> Result, PackageError> { if !subpath.starts_with("./") && subpath != "." { return Err(PackageError::InvalidNpmExportSubpath { subpath: subpath.to_owned() }); } match exports { serde_json::Value::Null => Ok(None), serde_json::Value::String(target) => { if subpath != "." { return Ok(None); } Ok(Some(target.clone())) } serde_json::Value::Array(_) => { if subpath != "." { return Ok(None); } match resolve_match(exports, conditions, None, true)? { Match::Hit(resolved) => Ok(Some(resolved)), // Unreachable: array nulls skip inside resolve_match. Match::Blocked => Err(PackageError::NpmExportRootBlocked), Match::Miss => Ok(None), } } serde_json::Value::Object(map) => { let subpaths = map.keys().filter(|key| key.starts_with('.')).count(); if subpaths > 0 && subpaths != map.len() { return Err(PackageError::NpmExportsMixesKeys); } if subpaths > 0 { if let Some(target) = map.get(subpath) { return match resolve_match(target, conditions, None, true)? { Match::Hit(resolved) => Ok(Some(resolved)), Match::Blocked => { Err(PackageError::NpmExportBlocked { subpath: subpath.to_owned() }) } Match::Miss => Ok(None), }; } let mut best: Option<(usize, usize, &str)> = None; for key in map.keys() { let Some((prefix, suffix)) = key.split_once('*') else { continue; }; if key[prefix.len() + 1..].contains('*') { return Err(PackageError::InvalidNpmExportPattern { key: key.to_owned() }); } if !subpath.starts_with(prefix) || !subpath.ends_with(suffix) { continue; } if prefix.len() + suffix.len() > subpath.len() { continue; } let candidate = (prefix.len(), suffix.len(), key.as_str()); if best.is_none_or(|current| { candidate.0 > current.0 || (candidate.0 == current.0 && candidate.1 > current.1) }) { best = Some(candidate); } } let Some((_, _, key)) = best else { return Ok(None); }; let (prefix, suffix) = key.split_once('*').unwrap(); let captured = &subpath[prefix.len()..subpath.len() - suffix.len()]; match resolve_match(&map[key], conditions, Some(captured), true)? { Match::Hit(resolved) => Ok(Some(resolved)), Match::Blocked => { Err(PackageError::NpmExportBlocked { subpath: subpath.to_owned() }) } Match::Miss => Ok(None), } } else if subpath != "." { Ok(None) } else { match resolve_match(exports, conditions, None, true)? { Match::Hit(resolved) => Ok(Some(resolved)), Match::Blocked => Err(PackageError::NpmExportRootBlocked), Match::Miss => Ok(None), } } } _ => Err(PackageError::InvalidNpmExportsField), } } enum Match { Hit(String), Blocked, Miss, } /// Target shape validation during selection so arrays can skip bad entries /// instead of failing the whole list at the final caller. fn validate_export_target(target: &str) -> Result<(), PackageError> { let rel = target .strip_prefix("./") .ok_or_else(|| PackageError::NpmExportTargetEscapes { target: target.to_owned() })?; super::validate_npm_relpath(rel)?; if rel.split('/').any(|part| part == "node_modules") { return Err(PackageError::NpmExportTargetEscapes { target: target.to_owned() }); } Ok(()) } /// Evaluate one exports value. Conditions follow the package document's own /// key order against the active set; a directly selected `null` blocks /// distinctly instead of falling through, while arrays skip misses. fn resolve_match( value: &serde_json::Value, conditions: &[&str], captured: Option<&str>, strict: bool, ) -> Result { let substitute = |target: &str| match captured { Some(part) => target.replace('*', part), None => target.to_owned(), }; match value { serde_json::Value::Null => Ok(Match::Blocked), serde_json::Value::String(target) => { let target = substitute(target); match validate_export_target(&target) { Ok(()) => Ok(Match::Hit(target)), Err(_) if !strict => Ok(Match::Miss), Err(error) => Err(error), } } serde_json::Value::Array(targets) => { for target in targets { match resolve_match(target, conditions, captured, false)? { Match::Hit(resolved) => return Ok(Match::Hit(resolved)), Match::Blocked => continue, Match::Miss => continue, } } Ok(Match::Miss) } serde_json::Value::Object(map) => { for (key, target) in map { if key.starts_with('.') { return Err(PackageError::NpmExportsMixesKeys); } if key.is_empty() || key.starts_with('#') || key.bytes().all(|byte| byte.is_ascii_digit()) { return Err(PackageError::InvalidNpmConditionKey { key: key.to_owned() }); } if !conditions.contains(&key.as_str()) { continue; } match resolve_match(target, conditions, captured, strict)? { Match::Hit(resolved) => return Ok(Match::Hit(resolved)), Match::Blocked => return Ok(Match::Blocked), Match::Miss => continue, } } Ok(Match::Miss) } _ => Err(PackageError::InvalidNpmExportsEntry), } } pub enum NpmImportsTarget { External { request: String }, File { path: String }, } /// Resolve an `#…` specifier through the owning package's `imports` map. /// Bare-word targets must name a declared dependency of the same package. pub fn resolve_imports_target( imports: &serde_json::Value, specifier: &str, conditions: &[&str], declared: &BTreeSet, ) -> Result, PackageError> { let map = imports .as_object() .ok_or(PackageError::NpmImportsNotObject)?; if let Some(target) = map.get(specifier) { return imports_value_target(target, conditions, declared); } let mut best: Option<(usize, usize, &str)> = None; for key in map.keys() { if !key.starts_with('#') { return Err(PackageError::NpmImportsKeyPrefix); } let Some((prefix, suffix)) = key.split_once('*') else { continue; }; if key[prefix.len() + 1..].contains('*') { return Err(PackageError::InvalidNpmImportsPattern { key: key.to_owned() }); } if !specifier.starts_with(prefix) || !specifier.ends_with(suffix) { continue; } if prefix.len() + suffix.len() > specifier.len() { continue; } let candidate = (prefix.len(), suffix.len(), key.as_str()); if best.is_none_or(|current| { candidate.0 > current.0 || (candidate.0 == current.0 && candidate.1 > current.1) }) { best = Some(candidate); } } let Some((_, _, key)) = best else { return Ok(None); }; let (prefix, suffix) = key.split_once('*').unwrap(); let captured = &specifier[prefix.len()..specifier.len() - suffix.len()]; match imports_match(&map[key], conditions, Some(captured), declared, true)? { ImportsMatch::Hit(target) => Ok(Some(target)), ImportsMatch::Blocked => { Err(PackageError::NpmImportBlocked { specifier: specifier.to_owned() }) } ImportsMatch::Miss => Ok(None), } } enum ImportsMatch { Hit(NpmImportsTarget), Blocked, Miss, } fn imports_value_target( value: &serde_json::Value, conditions: &[&str], declared: &BTreeSet, ) -> Result, PackageError> { match imports_match(value, conditions, None, declared, true)? { ImportsMatch::Hit(target) => Ok(Some(target)), ImportsMatch::Blocked => Err(PackageError::NpmImportBlockedBare), ImportsMatch::Miss => Ok(None), } } fn validate_imports_file(target: &str) -> Result<(), PackageError> { let rel = target .strip_prefix("./") .ok_or_else(|| PackageError::NpmImportsTargetEscapes { target: target.to_owned() })?; super::validate_npm_relpath(rel)?; if rel.split('/').any(|part| part == "node_modules") { return Err(PackageError::NpmImportsTargetEscapes { target: target.to_owned() }); } Ok(()) } fn imports_match( value: &serde_json::Value, conditions: &[&str], captured: Option<&str>, declared: &BTreeSet, strict: bool, ) -> Result { let substitute = |target: &str| match captured { Some(part) => target.replace('*', part), None => target.to_owned(), }; match value { serde_json::Value::Null => Ok(ImportsMatch::Blocked), serde_json::Value::String(target) => { let target = substitute(target); if target.starts_with('.') { match validate_imports_file(&target) { Ok(()) => {} Err(_) if !strict => return Ok(ImportsMatch::Miss), Err(error) => return Err(error), } } imports_string_target(&target, declared) .map(|hit| hit.map_or(ImportsMatch::Miss, ImportsMatch::Hit)) } serde_json::Value::Array(targets) => { for target in targets { match imports_match(target, conditions, captured, declared, false)? { ImportsMatch::Hit(resolved) => return Ok(ImportsMatch::Hit(resolved)), ImportsMatch::Blocked => continue, ImportsMatch::Miss => continue, } } Ok(ImportsMatch::Miss) } serde_json::Value::Object(map) => { for (key, target) in map { if key.starts_with('.') || key.starts_with('#') { return Err(PackageError::NpmImportsMixesKeys); } if key.is_empty() || key.bytes().all(|byte| byte.is_ascii_digit()) { return Err(PackageError::InvalidNpmConditionKey { key: key.to_owned() }); } if !conditions.contains(&key.as_str()) { continue; } match imports_match(target, conditions, captured, declared, strict)? { ImportsMatch::Hit(resolved) => return Ok(ImportsMatch::Hit(resolved)), ImportsMatch::Blocked => return Ok(ImportsMatch::Blocked), ImportsMatch::Miss => continue, } } Ok(ImportsMatch::Miss) } _ => Err(PackageError::InvalidNpmImportsEntry), } } fn imports_string_target( target: &str, declared: &BTreeSet, ) -> Result, PackageError> { if let Some(path) = target.strip_prefix("./") { if path.is_empty() { return Err(PackageError::InvalidNpmImportsTarget); } return Ok(Some(NpmImportsTarget::File { path: path.to_owned() })); } if target.starts_with("../") || target == ".." { return Err(PackageError::NpmImportsTargetEscapesBare); } let (name, _) = split_bare(target); if !declared.contains(name) { return Err(PackageError::NpmImportsTargetUndeclared { target: target.to_owned() }); } Ok(Some(NpmImportsTarget::External { request: format!("npm:{target}"), })) } pub fn types_package_name(name: &str) -> String { match name.strip_prefix('@') { Some(scoped) => format!("@types/{}", scoped.replace('/', "__")), None => format!("@types/{name}"), } } /// Split a bare specifier into its package name and trailing subpath. pub fn split_bare(specifier: &str) -> (&str, Option<&str>) { if let Some(rest) = specifier.strip_prefix('@') { match rest.split_once('/') { None => (specifier, None), Some((scope, tail)) => match tail.split_once('/') { None => (specifier, None), Some((leaf, sub)) => { let name = &specifier[..scope.len() + leaf.len() + 2]; (name, Some(sub)) } }, } } else { match specifier.split_once('/') { None => (specifier, None), Some((name, sub)) => (name, Some(sub)), } } } /// Classify one manifest-relative path for execution. ESM and /// project-pipeline TypeScript stay executable via `import`; CommonJS and /// JSON are executable via `require` (and CJS additionally via ESM wrappers). /// Declarations stay resolvable through the editor path instead. Explicit /// extensions never consult package metadata. pub fn classify_npm_module>( path: &str, package_type: impl FnOnce() -> Result, ) -> Result { if path.ends_with(".d.ts") || path.ends_with(".d.mts") || path.ends_with(".d.cts") { return Ok(NpmModuleKind::Declaration); } if let Some(name) = path.rsplit('/').next() && let Some(extension) = name.rsplit('.').next() && name.contains('.') { match extension { "mjs" | "mts" => return Ok(NpmModuleKind::Esm), "cjs" => return Ok(NpmModuleKind::CommonJs), // Explicit `.cts` transpiles TypeScript while preserving CJS. "cts" => return Ok(NpmModuleKind::CommonJs), "ts" | "tsx" | "jsx" => return Ok(NpmModuleKind::TypeScript), "js" => {} "json" => return Ok(NpmModuleKind::Json), "node" => { return Err(NpmUnsupported::Native { path: path.to_owned() }.into()); } _ => { return Err(NpmUnsupported::Extension { path: path.to_owned() }.into()); } } } match package_type()? { NpmPackageType::Module => Ok(NpmModuleKind::Esm), // `.js` follows the nearest `type` boundary; explicit `.cjs`/`.cts` // above already returned CommonJS. NpmPackageType::CommonJs => Ok(NpmModuleKind::CommonJs), } } #[derive(Debug)] pub(crate) struct NpmPackageScope { pub package_type: Option, pub main: Option, } pub(crate) fn parse_npm_scope(bytes: &[u8], path: &str) -> Result { let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|source| { PackageError::InvalidNestedNpmPackageJson { path: path.to_owned(), source: Arc::new(source), } })?; let package_type = match value.get("type") { None => None, Some(entry) => { let text = entry .as_str() .ok_or_else(|| PackageError::InvalidPackageTypeIn { path: path.to_owned() })?; Some(match text { "module" => NpmPackageType::Module, "commonjs" => NpmPackageType::CommonJs, _ => { return Err(PackageError::UnsupportedPackageTypeIn { path: path.to_owned(), text: text.to_owned(), }); } }) } }; Ok(NpmPackageScope { package_type, main: value .get("main") .and_then(|entry| entry.as_str()) .map(str::to_owned), }) } #[cfg(test)] mod tests { use super::*; #[test] fn subpath_only_packages_may_disable_main() { let manifest = parse_npm_manifest( "subpaths", "1.0.0", &serde_json::json!({ "name": "subpaths", "version": "1.0.0", "main": false, "exports": { "./value": "./value.js" } }), ) .unwrap(); assert_eq!( resolve_exports_target( manifest.exports_raw.as_ref().unwrap(), "./value", &conditions() ) .unwrap(), Some("./value.js".into()) ); assert_eq!( resolve_exports_target(manifest.exports_raw.as_ref().unwrap(), ".", &conditions()) .unwrap(), None ); } #[test] fn side_effects_match_like_rolldown_and_fail_open() { let field = |text: &str| serde_json::from_str::(text).unwrap(); for (value, rel, expected) in [ ("false", "index.js", Some(false)), ("\"./init.js\"", "init.js", Some(true)), ("[\"./{init,other}.js\"]", "init.js", Some(true)), ("[\"./{init,other}.js\"]", "lib/third.js", Some(false)), ("[\"./lib/[a-c].js\"]", "lib/b.js", Some(true)), ("[\"*.css\"]", "deep/style.css", Some(true)), ("[\"src/**/*.js\"]", "src/a/b.js", Some(true)), ("[\"!literal.js\"]", "!literal.js", Some(true)), ("[\"./unclosed{\"]", "unclosed", None), ("[\"./unclosed[\"]", "unclosed", None), ("[42]", "index.js", None), ("{}", "index.js", None), ] { assert_eq!( declared_side_effects(&field(value), rel), expected, "{value} {rel}" ); } } fn conditions() -> [&'static str; 4] { npm_conditions() } #[test] fn conditions_follow_package_document_order() { let exports: serde_json::Value = serde_json::from_str("{\"default\": \"./x.js\", \"import\": \"./y.mjs\"}").unwrap(); assert_eq!( resolve_exports_target(&exports, ".", &conditions()).unwrap(), Some("./x.js".into()) ); let exports: serde_json::Value = serde_json::from_str("{\"avril\": \"./a.js\", \"import\": \"./y.mjs\"}").unwrap(); assert_eq!( resolve_exports_target(&exports, ".", &conditions()).unwrap(), Some("./a.js".into()) ); } #[test] fn arrays_validate_each_target_and_fall_back() { let exports: serde_json::Value = serde_json::from_str("{\".\": [\"./../bad.js\", \"./good.js\"]}").unwrap(); assert_eq!( resolve_exports_target(&exports, ".", &conditions()).unwrap(), Some("./good.js".into()) ); let exports: serde_json::Value = serde_json::from_str("{\".\": {\"0\": \"./x.js\", \"default\": \"./d.js\"}}").unwrap(); assert!( resolve_exports_target(&exports, ".", &conditions()) .unwrap_err() .to_string() .contains("condition") ); } #[test] fn unknown_conditions_fall_through_to_default() { let exports: serde_json::Value = serde_json::from_str( "{\".\": {\"future-condition\": \"./u.js\", \"default\": \"./d.js\"}}", ) .unwrap(); assert_eq!( resolve_exports_target(&exports, ".", &conditions()).unwrap(), Some("./d.js".into()) ); } #[test] fn longest_pattern_prefix_wins_ties_by_suffix() { let exports: serde_json::Value = serde_json::from_str( "{\"./features/*.js\": \"./a/*.js\", \"./features/x/*.js\": \"./b/*.js\"}", ) .unwrap(); assert_eq!( resolve_exports_target(&exports, "./features/x/y.js", &conditions()).unwrap(), Some("./b/y.js".into()) ); } #[test] fn mixed_exports_are_rejected() { let exports: serde_json::Value = serde_json::from_str("{\".\": \"./x.js\", \"import\": \"./y.mjs\"}").unwrap(); assert!( resolve_exports_target(&exports, ".", &conditions()) .unwrap_err() .to_string() .contains("mixes") ); } #[test] fn platform_negations_behave() { let rule = parse_platform_rule(&serde_json::Value::String("!arm64".into())).unwrap(); assert!(platform_satisfied(Some(&rule), "x64")); assert!(!platform_satisfied(Some(&rule), "arm64")); } }