/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/package_json/lib.rs
1 521 строка
45 KB
Bartek Iwańczuk
feat(install): warn on package.json engines mismatch (#34225)
17 июн 2026, 13:07
Не верифицирован
17 июн 2026, 13:07
b40113d
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. #![deny(clippy::print_stderr)] #![deny(clippy::print_stdout)] #![deny(clippy::unused_async)] #![deny(clippy::unnecessary_wraps)] use std::collections::BTreeMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use boxed_error::Boxed; use deno_error::JsError; use deno_semver::StackString; use deno_semver::Version; use deno_semver::VersionReq; use deno_semver::npm::NpmVersionReqParseError; use deno_semver::package::PackageReq; use indexmap::IndexMap; use serde::Serialize; use serde_json::Map; use serde_json::Value; use sys_traits::FsRead; use thiserror::Error; use url::Url; #[allow(clippy::disallowed_types, reason = "arc wrapper type")] pub type PackageJsonRc = deno_maybe_sync::MaybeArc<PackageJson>; #[allow(clippy::disallowed_types, reason = "arc wrapper type")] pub type PackageJsonDepsRc = deno_maybe_sync::MaybeArc<PackageJsonDeps>; #[allow(clippy::disallowed_types, reason = "once lock wrapper type")] type PackageJsonDepsRcCell = deno_maybe_sync::MaybeOnceLock<PackageJsonDepsRc>; pub enum PackageJsonCacheResult { Hit(Option<PackageJsonRc>), NotCached, } pub trait PackageJsonCache { fn get(&self, path: &Path) -> PackageJsonCacheResult; fn set(&self, path: PathBuf, package_json: Option<PackageJsonRc>); } #[derive(Debug, Clone)] pub enum PackageJsonBins { Directory(PathBuf), Bins(BTreeMap<String, PathBuf>), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum EngineMismatchKind { /// The current runtime version does not satisfy the declared range. Unsatisfied, /// The declared range itself failed to parse as an npm version requirement. InvalidRequirement, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct EngineMismatch { /// The engine key from the `engines` object (e.g. "node" or "deno"). pub engine: String, /// The version requirement string as written in package.json. pub required: String, /// The version of the engine as reported by the current runtime. pub actual: String, pub kind: EngineMismatchKind, } /// The value of the `sideEffects` field in a `package.json`. /// /// See https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free /// for details on how this field is interpreted by bundlers. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(untagged)] pub enum PackageJsonSideEffects { /// `false` means the package has no side effects; /// `true` (or omitted) means every file may have side effects. Bool(bool), /// A list of glob patterns matching files that have side effects. /// All other files in the package are treated as side-effect-free. Patterns(Vec<String>), } /// An entry in the object form of `package.json`'s `browser` field. /// /// A string value remaps the key to a different specifier; `false` marks the /// key as disabled (bundlers should substitute an empty module). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(untagged)] pub enum BrowserMapEntry { Replace(String), Disabled, } #[derive(Debug, Clone, Error, JsError, PartialEq, Eq)] #[class(generic)] #[error("'{}' did not have a name", pkg_json_path.display())] pub struct MissingPkgJsonNameError { pkg_json_path: PathBuf, } #[derive(Debug, Clone, JsError, PartialEq, Eq, Boxed)] pub struct PackageJsonDepValueParseError( pub Box<PackageJsonDepValueParseErrorKind>, ); #[derive(Debug, Error, Clone, JsError, PartialEq, Eq)] pub enum PackageJsonDepValueParseErrorKind { #[class(inherit)] #[error(transparent)] VersionReq(#[from] NpmVersionReqParseError), #[class(type)] #[error("Not implemented scheme '{scheme}'")] Unsupported { scheme: String }, #[class(inherit)] #[error(transparent)] JsrRequiresScope(#[from] JsrDepPackageParseError), #[class(type)] #[error("Package name must not be empty")] EmptyName, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum PackageJsonDepWorkspaceReq { /// "workspace:~" Tilde, /// "workspace:^" Caret, /// "workspace:x.y.z", "workspace:*", "workspace:^x.y.z" VersionReq(VersionReq), } /// Error returned when a JSR specifier doesn't have a valid `@scope/name` /// format. #[derive(Debug, Clone, Error, JsError, PartialEq, Eq)] #[class(type)] #[error("JSR package name '{name}' requires a scope (e.g. @scope/name)")] pub struct JsrDepPackageParseError { pub name: String, } /// Parses a JSR specifier value (the part after the `jsr:` prefix) into an /// npm-style package name and version string. /// /// If the value starts with `@`, it's parsed as `@scope/name[@version]`. /// Otherwise, `fallback_name` is used as the JSR package name and the /// value is treated as a version string. pub fn parse_jsr_dep_value<'a>( fallback_name: &'a str, jsr_value: &'a str, ) -> Result<(StackString, &'a str), JsrDepPackageParseError> { let (jsr_name, version_str) = if jsr_value.starts_with('@') { if let Some((name, version)) = jsr_value.rsplit_once('@') { if name.is_empty() { (jsr_value, "*") } else { (name, version) } } else { (jsr_value, "*") } } else if let Some((name, version)) = jsr_value.split_once('@') { // unscoped name with version, e.g. "test@*" (name, version) } else { // bare version string, e.g. "^1" — derive name from key (fallback_name, jsr_value) }; let Some((scope, name)) = jsr_name .strip_prefix('@') .and_then(|rest| rest.split_once('/')) else { return Err(JsrDepPackageParseError { name: jsr_name.to_string(), }); }; let npm_name = capacity_builder::StringBuilder::<StackString>::build(|builder| { builder.append("@jsr/"); builder.append(scope); builder.append("__"); builder.append(name); }) .unwrap(); Ok((npm_name, version_str)) } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum PackageJsonDepValue { File(String), Req(PackageReq), Workspace { /// Target workspace package name for pnpm-style aliased deps /// (`workspace:<name>@<range>`). `None` means resolve using the /// dependency key. name: Option<StackString>, version_req: PackageJsonDepWorkspaceReq, }, Catalog(String), } impl PackageJsonDepValue { pub fn parse( key: &str, value: &str, ) -> Result<Self, PackageJsonDepValueParseError> { fn from_name_and_version_req( name: StackString, version_req: &str, ) -> Result<PackageJsonDepValue, PackageJsonDepValueParseError> { if name.is_empty() { return Err(PackageJsonDepValueParseErrorKind::EmptyName.into_box()); } match VersionReq::parse_from_npm(version_req) { Ok(version_req) => { Ok(PackageJsonDepValue::Req(PackageReq { name, version_req })) } Err(err) => { Err(PackageJsonDepValueParseErrorKind::VersionReq(err).into_box()) } } } if key.is_empty() { return Err(PackageJsonDepValueParseErrorKind::EmptyName.into_box()); } if let Some((scheme, value)) = value.split_once(':') { match scheme { "file" => Ok(Self::File(value.to_string())), "jsr" => { let (npm_name, version_req) = parse_jsr_dep_value(key, value) .map_err(|e| { PackageJsonDepValueParseErrorKind::JsrRequiresScope(e).into_box() })?; from_name_and_version_req(npm_name, version_req) } "npm" => { if let Some((name, version)) = value.rsplit_once('@') { // if empty, then the name was scoped and there's no version if name.is_empty() { from_name_and_version_req(value.into(), "*") } else { from_name_and_version_req(name.into(), version) } } else { from_name_and_version_req(value.into(), "*") } } "catalog" => { let name = if value.is_empty() || value == "default" { "default".to_string() } else { value.to_string() }; Ok(Self::Catalog(name)) } "workspace" => { let (name, version_req) = parse_workspace_dep_value(value)?; Ok(Self::Workspace { name, version_req }) } scheme => Err( PackageJsonDepValueParseErrorKind::Unsupported { scheme: scheme.to_string(), } .into_box(), ), } } else { from_name_and_version_req(key.into(), value) } } } /// Parses the part after `workspace:` in a package.json dependency. /// /// Besides the bare requirement forms (`workspace:*`, `workspace:~`, /// `workspace:^`, `workspace:1.2.3`), this also supports the pnpm-style alias /// form `workspace:<name>@<range>` (e.g. `workspace:foo@*` or /// `workspace:@scope/pkg@^1.2`), where the dependency is imported under its key /// but resolves to the workspace member named `<name>`. fn parse_workspace_dep_value( value: &str, ) -> Result< (Option<StackString>, PackageJsonDepWorkspaceReq), PackageJsonDepValueParseError, > { fn parse_workspace_req( range: &str, ) -> Result<PackageJsonDepWorkspaceReq, PackageJsonDepValueParseError> { Ok(match range { "~" => PackageJsonDepWorkspaceReq::Tilde, "^" => PackageJsonDepWorkspaceReq::Caret, _ => PackageJsonDepWorkspaceReq::VersionReq(VersionReq::parse_from_npm( range, )?), }) } // Bare requirement forms (`*`, `~`, `^`, an explicit range) never contain an // `@`, so try interpreting the whole value as one first. match parse_workspace_req(value) { Ok(version_req) => Ok((None, version_req)), Err(bare_err) => { // Otherwise this may be the pnpm-style alias form // `workspace:<name>@<range>`. The name may be scoped (`@scope/name`), so // split on the last `@`. Both the name and range must be non-empty; // degenerate forms like `workspace:foo@` or `workspace:@scope/pkg` (no // range) are rejected with the original requirement parse error. if let Some((name, range)) = value.rsplit_once('@') && !name.is_empty() && !range.is_empty() { Ok((Some(name.into()), parse_workspace_req(range)?)) } else { Err(bare_err) } } } } pub type PackageJsonDepsMap = IndexMap< StackString, Result<PackageJsonDepValue, PackageJsonDepValueParseError>, >; #[derive(Debug, Clone)] pub struct PackageJsonDeps { pub dependencies: PackageJsonDepsMap, pub dev_dependencies: PackageJsonDepsMap, } impl PackageJsonDeps { /// Gets a package.json dependency entry by alias. pub fn get( &self, alias: &str, ) -> Option<&Result<PackageJsonDepValue, PackageJsonDepValueParseError>> { self .dependencies .get(alias) .or_else(|| self.dev_dependencies.get(alias)) } } #[derive(Debug, Error, JsError)] pub enum PackageJsonLoadError { #[class(inherit)] #[error("Failed reading '{}'.", .path.display())] Io { path: PathBuf, #[source] #[inherit] source: std::io::Error, }, #[class(inherit)] #[error("Malformed package.json '{}'.", .path.display())] Deserialize { path: PathBuf, #[source] #[inherit] source: serde_json::Error, }, #[error( "\"exports\" cannot contain some keys starting with '.' and some not.\nThe exports object must either be an object of package subpath keys\nor an object of main entry condition name keys only." )] #[class(type)] InvalidExports, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PackageJson { pub exports: Option<Map<String, Value>>, pub imports: Option<Map<String, Value>>, pub bin: Option<Value>, pub main: Option<String>, pub module: Option<String>, pub browser: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub browser_map: Option<IndexMap<String, BrowserMapEntry>>, pub name: Option<String>, pub version: Option<String>, #[serde(skip)] pub path: PathBuf, #[serde(rename = "type")] pub typ: String, pub types: Option<String>, pub types_versions: Option<Map<String, Value>>, pub dependencies: Option<IndexMap<String, String>>, pub bundle_dependencies: Option<Vec<String>>, pub dev_dependencies: Option<IndexMap<String, String>>, pub peer_dependencies: Option<IndexMap<String, String>>, pub peer_dependencies_meta: Option<Value>, pub optional_dependencies: Option<IndexMap<String, String>>, pub directories: Option<Map<String, Value>>, pub scripts: Option<IndexMap<String, String>>, #[serde(skip_serializing_if = "Option::is_none")] pub config: Option<Map<String, Value>>, pub workspaces: Option<Vec<String>>, pub os: Option<Vec<String>>, pub cpu: Option<Vec<String>>, #[serde(skip_serializing_if = "Option::is_none")] pub engines: Option<IndexMap<String, String>>, #[serde(skip_serializing_if = "Option::is_none")] pub overrides: Option<Map<String, Value>>, #[serde(skip_serializing_if = "Option::is_none")] pub catalog: Option<IndexMap<String, String>>, #[serde(skip_serializing_if = "Option::is_none")] pub catalogs: Option<IndexMap<String, IndexMap<String, String>>>, #[serde(skip_serializing_if = "Option::is_none")] pub side_effects: Option<PackageJsonSideEffects>, #[serde(skip_serializing)] resolved_deps: PackageJsonDepsRcCell, } impl PackageJson { pub fn load_from_path( sys: &impl FsRead, maybe_cache: Option<&dyn PackageJsonCache>, path: &Path, ) -> Result<Option<PackageJsonRc>, PackageJsonLoadError> { let cache_entry = maybe_cache .map(|c| c.get(path)) .unwrap_or(PackageJsonCacheResult::NotCached); match cache_entry { PackageJsonCacheResult::Hit(item) => Ok(item), PackageJsonCacheResult::NotCached => { match sys.fs_read_to_string_lossy(path) { Ok(file_text) => { let pkg_json = PackageJson::load_from_string(path.to_path_buf(), &file_text)?; let pkg_json = deno_maybe_sync::new_rc(pkg_json); if let Some(cache) = maybe_cache { cache.set(path.to_path_buf(), Some(pkg_json.clone())); } Ok(Some(pkg_json)) } Err(err) if err.kind() == ErrorKind::NotFound => { if let Some(cache) = maybe_cache { cache.set(path.to_path_buf(), None); } Ok(None) } Err(err) => Err(PackageJsonLoadError::Io { path: path.to_path_buf(), source: err, }), } } } } pub fn load_from_string( path: PathBuf, source: &str, ) -> Result<PackageJson, PackageJsonLoadError> { if source.trim().is_empty() { return Ok(PackageJson { path, main: None, name: None, version: None, module: None, browser: None, browser_map: None, typ: "none".to_string(), types: None, types_versions: None, exports: None, imports: None, bin: None, dependencies: None, bundle_dependencies: None, dev_dependencies: None, peer_dependencies: None, peer_dependencies_meta: None, optional_dependencies: None, directories: None, scripts: None, config: None, workspaces: None, os: None, cpu: None, engines: None, overrides: None, catalog: None, catalogs: None, side_effects: None, resolved_deps: Default::default(), }); } let package_json: Value = serde_json::from_str(source).map_err(|err| { PackageJsonLoadError::Deserialize { path: path.clone(), source: err, } })?; Self::load_from_value(path, package_json) } pub fn load_from_value( path: PathBuf, package_json: serde_json::Value, ) -> Result<PackageJson, PackageJsonLoadError> { fn parse_string_map( value: serde_json::Value, ) -> Option<IndexMap<String, String>> { if let Value::Object(map) = value { let mut result = IndexMap::with_capacity(map.len()); for (k, v) in map { if let Some(v) = map_string(v) { result.insert(k, v); } } Some(result) } else { None } } fn map_object(value: serde_json::Value) -> Option<Map<String, Value>> { match value { Value::Object(v) => Some(v), _ => None, } } fn map_string(value: serde_json::Value) -> Option<String> { match value { Value::String(v) => Some(v), Value::Number(v) => Some(v.to_string()), _ => None, } } fn map_array(value: serde_json::Value) -> Option<Vec<Value>> { match value { Value::Array(v) => Some(v), _ => None, } } fn parse_string_array(value: serde_json::Value) -> Option<Vec<String>> { let value = map_array(value)?; let mut result = Vec::with_capacity(value.len()); for v in value { if let Some(v) = map_string(v) { result.push(v); } } Some(result) } let mut package_json = match package_json { Value::Object(o) => o, _ => Default::default(), }; let imports_val = package_json.remove("imports"); let main_val = package_json.remove("main"); let module_val = package_json.remove("module"); let browser_val = package_json.remove("browser"); let name_val = package_json.remove("name"); let version_val = package_json.remove("version"); let type_val = package_json.remove("type"); let bin = package_json.remove("bin"); let exports = package_json .remove("exports") .map(|exports| { if is_conditional_exports_main_sugar(&exports)? { let mut map = Map::new(); map.insert(".".to_string(), exports); Ok::<_, PackageJsonLoadError>(Some(map)) } else { Ok(map_object(exports)) } }) .transpose()? .flatten(); let imports = imports_val.and_then(map_object); let main = main_val.and_then(map_string); let name = name_val.and_then(map_string); let version = version_val.and_then(map_string); let module = module_val.and_then(map_string); let (browser, browser_map) = match browser_val { Some(Value::String(s)) => (Some(s), None), Some(Value::Object(map)) => { let mut entries = IndexMap::with_capacity(map.len()); for (k, v) in map { match v { Value::String(s) => { entries.insert(k, BrowserMapEntry::Replace(s)); } Value::Bool(false) => { entries.insert(k, BrowserMapEntry::Disabled); } _ => {} } } if entries.is_empty() { (None, None) } else { (None, Some(entries)) } } _ => (None, None), }; let dependencies = package_json .remove("dependencies") .and_then(parse_string_map); let dev_dependencies = package_json .remove("devDependencies") .and_then(parse_string_map); let bundle_dependencies = package_json .remove("bundleDependencies") .or_else(|| package_json.remove("bundledDependencies")) .and_then(parse_string_array); let peer_dependencies = package_json .remove("peerDependencies") .and_then(parse_string_map); let peer_dependencies_meta = package_json.remove("peerDependenciesMeta"); let optional_dependencies = package_json .remove("optionalDependencies") .and_then(parse_string_map); let directories: Option<Map<String, Value>> = package_json.remove("directories").and_then(map_object); let scripts: Option<IndexMap<String, String>> = package_json.remove("scripts").and_then(parse_string_map); let config = package_json.remove("config").and_then(map_object); // Ignore unknown types for forwards compatibility let typ = if let Some(t) = type_val { if let Some(t) = t.as_str() { if t != "module" && t != "commonjs" { "none".to_string() } else { t.to_string() } } else { "none".to_string() } } else { "none".to_string() }; // for typescript, it looks for "typings" first, then "types" let types = package_json .remove("typings") .or_else(|| package_json.remove("types")) .and_then(map_string); let types_versions = package_json.remove("typesVersions").and_then(map_object); // workspaces can be either an array of globs or an object with // "packages" (array) and optionally "catalog"/"catalogs" sub-fields // (Bun/Yarn object form). let (workspaces, ws_catalog, ws_catalogs) = match package_json.remove("workspaces") { Some(Value::Array(arr)) => { (parse_string_array(Value::Array(arr)), None, None) } Some(Value::Object(mut obj)) => { let pkgs = obj.remove("packages").and_then(parse_string_array); let cat = obj.remove("catalog").and_then(parse_string_map); let cats = obj.remove("catalogs").and_then(|v| { if let Value::Object(map) = v { let mut result = IndexMap::with_capacity(map.len()); for (k, v) in map { if let Some(inner) = parse_string_map(v) { result.insert(k, inner); } } Some(result) } else { None } }); (pkgs, cat, cats) } _ => (None, None, None), }; let os = package_json.remove("os").and_then(parse_string_array); let cpu = package_json.remove("cpu").and_then(parse_string_array); let engines = package_json.remove("engines").and_then(parse_string_map); let overrides = package_json.remove("overrides").and_then(map_object); let side_effects = package_json.remove("sideEffects").and_then(|v| match v { Value::Bool(b) => Some(PackageJsonSideEffects::Bool(b)), Value::Array(_) => { parse_string_array(v).map(PackageJsonSideEffects::Patterns) } _ => None, }); // Top-level catalog/catalogs take precedence; fall back to those // extracted from the workspaces object form. let catalog = package_json .remove("catalog") .and_then(parse_string_map) .or(ws_catalog); let catalogs = package_json .remove("catalogs") .and_then(|v| { if let Value::Object(map) = v { let mut result = IndexMap::with_capacity(map.len()); for (k, v) in map { if let Some(inner) = parse_string_map(v) { result.insert(k, inner); } } Some(result) } else { None } }) .or(ws_catalogs); Ok(PackageJson { path, main, name, version, module, browser, browser_map, typ, types, types_versions, exports, imports, bin, dependencies, dev_dependencies, bundle_dependencies, peer_dependencies, peer_dependencies_meta, optional_dependencies, directories, scripts, config, workspaces, os, cpu, engines, overrides, catalog, catalogs, side_effects, resolved_deps: Default::default(), }) } pub fn specifier(&self) -> Url { deno_path_util::url_from_file_path(&self.path).unwrap() } pub fn dir_path(&self) -> &Path { self.path.parent().unwrap() } /// Resolve the package.json's dependencies. pub fn resolve_local_package_json_deps(&self) -> &PackageJsonDepsRc { fn get_map(deps: Option<&IndexMap<String, String>>) -> PackageJsonDepsMap { let Some(deps) = deps else { return Default::default(); }; let mut result = IndexMap::with_capacity(deps.len()); for (key, value) in deps { result .entry(StackString::from(key.as_str())) .or_insert_with(|| PackageJsonDepValue::parse(key, value)); } result } self.resolved_deps.get_or_init(|| { PackageJsonDepsRc::new(PackageJsonDeps { dependencies: get_map(self.dependencies.as_ref()), dev_dependencies: get_map(self.dev_dependencies.as_ref()), }) }) } /// Check the `engines` field of this package.json against the given runtime /// versions. Returns one entry per declared engine that is either /// unsatisfied or has an unparseable version requirement. /// /// Only the `node` and `deno` keys are checked. Other entries (`npm`, /// `yarn`, `pnpm`, ...) are intentionally ignored — Deno cannot reason /// about external package manager versions. pub fn check_engines( &self, deno_version: &str, node_version: &str, ) -> Vec<EngineMismatch> { let Some(engines) = self.engines.as_ref() else { return Vec::new(); }; let mut mismatches = Vec::new(); for (engine, version_req) in engines { let actual = match engine.as_str() { "node" => node_version, "deno" => deno_version, _ => continue, }; let parsed_req = match VersionReq::parse_from_npm(version_req) { // `parse_from_npm` accepts bare dist-tags (e.g. "next"). Those don't // make sense for an engine constraint, so flag them as invalid // instead of letting `matches` panic later on. Ok(req) if req.tag().is_none() => req, _ => { mismatches.push(EngineMismatch { engine: engine.clone(), required: version_req.clone(), actual: actual.to_string(), kind: EngineMismatchKind::InvalidRequirement, }); continue; } }; let parsed_actual = match Version::parse_from_npm( actual.strip_prefix('v').unwrap_or(actual), ) { Ok(v) => v, Err(_) => continue, }; if !parsed_req.matches(&parsed_actual) { mismatches.push(EngineMismatch { engine: engine.clone(), required: version_req.clone(), actual: actual.to_string(), kind: EngineMismatchKind::Unsatisfied, }); } } mismatches } pub fn resolve_default_bin_name( &self, ) -> Result<&str, MissingPkgJsonNameError> { let Some(name) = &self.name else { return Err(MissingPkgJsonNameError { pkg_json_path: self.path.clone(), }); }; let name = name.split("/").last().unwrap(); Ok(name) } pub fn resolve_bins( &self, ) -> Result<PackageJsonBins, MissingPkgJsonNameError> { match &self.bin { Some(Value::String(path)) => { let name = self.resolve_default_bin_name()?; Ok(PackageJsonBins::Bins(BTreeMap::from([( name.to_string(), self.dir_path().join(path), )]))) } Some(Value::Object(o)) => Ok(PackageJsonBins::Bins( o.iter() .filter_map(|(key, value)| { let Value::String(path) = value else { return None; }; Some((key.clone(), self.dir_path().join(path))) }) .collect::<BTreeMap<_, _>>(), )), _ => { let bin_directory = self.directories.as_ref().and_then(|d| d.get("bin")); match bin_directory { Some(Value::String(bin_dir)) => { let bin_dir = self.dir_path().join(bin_dir); Ok(PackageJsonBins::Directory(bin_dir)) } _ => Ok(PackageJsonBins::Bins(Default::default())), } } } } } fn is_conditional_exports_main_sugar( exports: &Value, ) -> Result<bool, PackageJsonLoadError> { if exports.is_string() || exports.is_array() { return Ok(true); } if exports.is_null() || !exports.is_object() { return Ok(false); } let exports_obj = exports.as_object().unwrap(); let mut is_conditional_sugar = false; let mut i = 0; for key in exports_obj.keys() { let cur_is_conditional_sugar = key.is_empty() || !key.starts_with('.'); if i == 0 { is_conditional_sugar = cur_is_conditional_sugar; i += 1; } else if is_conditional_sugar != cur_is_conditional_sugar { return Err(PackageJsonLoadError::InvalidExports); } } Ok(is_conditional_sugar) } #[cfg(test)] mod test { use std::error::Error; use std::path::PathBuf; use pretty_assertions::assert_eq; use super::*; #[test] fn null_exports_should_not_crash() { let package_json = PackageJson::load_from_string( PathBuf::from("/package.json"), r#"{ "exports": null }"#, ) .unwrap(); assert!(package_json.exports.is_none()); } fn get_local_package_json_version_reqs_for_tests( package_json: &PackageJson, ) -> IndexMap< String, Result<PackageJsonDepValue, PackageJsonDepValueParseErrorKind>, > { let deps = package_json.resolve_local_package_json_deps(); deps .dependencies .clone() .into_iter() .chain(deps.dev_dependencies.clone()) .map(|(k, v)| { ( k.to_string(), match v { Ok(v) => Ok(v), Err(err) => Err(err.into_kind()), }, ) }) .collect::<IndexMap<_, _>>() } #[test] fn test_get_local_package_json_version_reqs() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([ ("test".to_string(), "^1.2".to_string()), ("other".to_string(), "npm:package@~1.3".to_string()), ])); package_json.dev_dependencies = Some(IndexMap::from([ ("package_b".to_string(), "~2.2".to_string()), ("other".to_string(), "^3.2".to_string()), ])); let deps = package_json.resolve_local_package_json_deps(); assert_eq!( deps .dependencies .clone() .into_iter() .map(|d| (d.0, d.1.unwrap())) .collect::<Vec<_>>(), Vec::from([ ( "test".into(), PackageJsonDepValue::Req(PackageReq::from_str("test@^1.2").unwrap()) ), ( "other".into(), PackageJsonDepValue::Req( PackageReq::from_str("package@~1.3").unwrap() ) ), ]) ); assert_eq!( deps .dev_dependencies .clone() .into_iter() .map(|d| (d.0, d.1.unwrap())) .collect::<Vec<_>>(), Vec::from([ ( "package_b".into(), PackageJsonDepValue::Req( PackageReq::from_str("package_b@~2.2").unwrap() ) ), ( "other".into(), PackageJsonDepValue::Req(PackageReq::from_str("other@^3.2").unwrap()) ), ]) ); } #[test] fn test_get_local_package_json_version_reqs_empty_name() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([ ("".to_string(), ".".to_string()), ("npm-empty".to_string(), "npm:".to_string()), ("ok".to_string(), "^1".to_string()), ])); let map = get_local_package_json_version_reqs_for_tests(&package_json); assert_eq!( map.get("").unwrap().as_ref().unwrap_err(), &PackageJsonDepValueParseErrorKind::EmptyName, ); assert_eq!( map.get("npm-empty").unwrap().as_ref().unwrap_err(), &PackageJsonDepValueParseErrorKind::EmptyName, ); assert!(map.get("ok").unwrap().is_ok()); } #[test] fn test_get_local_package_json_version_reqs_errors_non_npm_specifier() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([( "test".to_string(), "%*(#$%()".to_string(), )])); let map = get_local_package_json_version_reqs_for_tests(&package_json); assert_eq!(map.len(), 1); let err = map.get("test").unwrap().as_ref().unwrap_err(); assert_eq!(format!("{}", err), "Invalid version requirement"); assert_eq!( format!("{}", err.source().unwrap()), concat!("Unexpected character.\n", " %*(#$%()\n", " ~") ); } #[test] fn test_get_local_package_json_version_reqs_workspace_alias_degenerate() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([ // valid alias form for reference ("ok".to_string(), "workspace:foo@*".to_string()), // empty range after the alias `@` ("empty-range".to_string(), "workspace:foo@".to_string()), // scoped name with no range at all ("no-range".to_string(), "workspace:@scope/pkg".to_string()), ])); let map = get_local_package_json_version_reqs_for_tests(&package_json); assert_eq!( map.get("ok").unwrap().as_ref().unwrap(), &PackageJsonDepValue::Workspace { name: Some("foo".into()), version_req: PackageJsonDepWorkspaceReq::VersionReq( VersionReq::parse_from_npm("*").unwrap() ) } ); // both degenerate forms are rejected rather than silently accepted assert_eq!( format!("{}", map.get("empty-range").unwrap().as_ref().unwrap_err()), "Invalid version requirement" ); assert_eq!( format!("{}", map.get("no-range").unwrap().as_ref().unwrap_err()), "Invalid version requirement" ); } #[test] fn test_get_local_package_json_version_reqs_range() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([( "test".to_string(), "1.x - 1.3".to_string(), )])); let map = get_local_package_json_version_reqs_for_tests(&package_json); assert_eq!( map, IndexMap::from([( "test".to_string(), Ok(PackageJsonDepValue::Req(PackageReq { name: "test".into(), version_req: VersionReq::parse_from_npm("1.x - 1.3").unwrap() })) )]) ); } #[test] fn test_get_local_package_json_version_reqs_jsr() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([ ("@denotest/foo".to_string(), "jsr:^1.2".to_string()), ("@std/path2".to_string(), "jsr:@std/path@1".to_string()), ("@std/fs".to_string(), "jsr:@std/fs".to_string()), ("no-scope".to_string(), "jsr:*".to_string()), ("no-scope2".to_string(), "jsr:test@*".to_string()), ("@denotest/tag".to_string(), "jsr:future-tag".to_string()), ])); let map = get_local_package_json_version_reqs_for_tests(&package_json); assert_eq!( map, IndexMap::from([ ( "@denotest/foo".to_string(), Ok(PackageJsonDepValue::Req(PackageReq { name: "@jsr/denotest__foo".into(), version_req: VersionReq::parse_from_specifier("^1.2").unwrap() })) ), ( "@std/path2".to_string(), Ok(PackageJsonDepValue::Req(PackageReq { name: "@jsr/std__path".into(), version_req: VersionReq::parse_from_specifier("1").unwrap() })) ), ( "@std/fs".to_string(), Ok(PackageJsonDepValue::Req(PackageReq { name: "@jsr/std__fs".into(), version_req: VersionReq::parse_from_specifier("*").unwrap() })) ), ( "no-scope".to_string(), Err(PackageJsonDepValueParseErrorKind::JsrRequiresScope( JsrDepPackageParseError { name: "no-scope".to_string() } )) ), ( "no-scope2".to_string(), Err(PackageJsonDepValueParseErrorKind::JsrRequiresScope( JsrDepPackageParseError { name: "test".to_string() } )) ), ( "@denotest/tag".to_string(), Ok(PackageJsonDepValue::Req(PackageReq { name: "@jsr/denotest__tag".into(), version_req: VersionReq::parse_from_specifier("future-tag") .unwrap() })) ), ]) ); } #[test] fn test_get_local_package_json_version_reqs_skips_certain_specifiers() { let mut package_json = PackageJson::load_from_string(PathBuf::from("/package.json"), "{}") .unwrap(); package_json.dependencies = Some(IndexMap::from([ ("test".to_string(), "1".to_string()), ( "work-test-version-req".to_string(), "workspace:1.1.1".to_string(), ), ("work-test-star".to_string(), "workspace:*".to_string()), ("work-test-tilde".to_string(), "workspace:~".to_string()), ("work-test-caret".to_string(), "workspace:^".to_string()), ( "work-test-alias-star".to_string(), "workspace:@scope/pkg@*".to_string(), ), ( "work-test-alias-tilde".to_string(), "workspace:other@~".to_string(), ), ( "work-test-alias-caret".to_string(), "workspace:other@^".to_string(), ), ( "work-test-alias-version".to_string(), "workspace:other@1.1.1".to_string(), ), ("catalog-test".to_string(), "catalog:".to_string()), ( "catalog-default-test".to_string(), "catalog:default".to_string(), ), ( "catalog-named-test".to_string(), "catalog:react18".to_string(), ), ("file-test".to_string(), "file:something".to_string()), ("git-test".to_string(), "git:something".to_string()), ("http-test".to_string(), "http://something".to_string()), ("https-test".to_string(), "https://something".to_string()), ])); let result = get_local_package_json_version_reqs_for_tests(&package_json); assert_eq!( result, IndexMap::from([ ( "test".to_string(), Ok(PackageJsonDepValue::Req( PackageReq::from_str("test@1").unwrap() )) ), ( "work-test-star".to_string(), Ok(PackageJsonDepValue::Workspace { name: None, version_req: PackageJsonDepWorkspaceReq::VersionReq( VersionReq::parse_from_npm("*").unwrap() ) }) ), ( "work-test-version-req".to_string(), Ok(PackageJsonDepValue::Workspace { name: None, version_req: PackageJsonDepWorkspaceReq::VersionReq( VersionReq::parse_from_npm("1.1.1").unwrap() ) }) ), ( "work-test-tilde".to_string(), Ok(PackageJsonDepValue::Workspace { name: None, version_req: PackageJsonDepWorkspaceReq::Tilde }) ), ( "work-test-caret".to_string(), Ok(PackageJsonDepValue::Workspace { name: None, version_req: PackageJsonDepWorkspaceReq::Caret }) ), ( "work-test-alias-star".to_string(), Ok(PackageJsonDepValue::Workspace { name: Some("@scope/pkg".into()), version_req: PackageJsonDepWorkspaceReq::VersionReq( VersionReq::parse_from_npm("*").unwrap() ) }) ), ( "work-test-alias-tilde".to_string(), Ok(PackageJsonDepValue::Workspace { name: Some("other".into()), version_req: PackageJsonDepWorkspaceReq::Tilde }) ), ( "work-test-alias-caret".to_string(), Ok(PackageJsonDepValue::Workspace { name: Some("other".into()), version_req: PackageJsonDepWorkspaceReq::Caret }) ), ( "work-test-alias-version".to_string(), Ok(PackageJsonDepValue::Workspace { name: Some("other".into()), version_req: PackageJsonDepWorkspaceReq::VersionReq( VersionReq::parse_from_npm("1.1.1").unwrap() ) }) ), ( "catalog-test".to_string(), Ok(PackageJsonDepValue::Catalog("default".to_string())), ), ( "catalog-default-test".to_string(), Ok(PackageJsonDepValue::Catalog("default".to_string())), ), ( "catalog-named-test".to_string(), Ok(PackageJsonDepValue::Catalog("react18".to_string())), ), ( "file-test".to_string(), Ok(PackageJsonDepValue::File("something".to_string())), ), ( "git-test".to_string(), Err(PackageJsonDepValueParseErrorKind::Unsupported { scheme: "git".to_string() }), ), ( "http-test".to_string(), Err(PackageJsonDepValueParseErrorKind::Unsupported { scheme: "http".to_string() }), ), ( "https-test".to_string(), Err(PackageJsonDepValueParseErrorKind::Unsupported { scheme: "https".to_string() }), ), ]) ); } #[test] fn test_deserialize_serialize() { let json_value = serde_json::json!({ "name": "test", "version": "1", "exports": { ".": "./main.js", }, "bin": "./main.js", "types": "./types.d.ts", "typesVersions": { "<4.0": { "index.d.ts": ["index.v3.d.ts"] } }, "imports": { "#test": "./main.js", }, "main": "./main.js", "module": "./module.js", "browser": "./browser.js", "type": "module", "dependencies": { "name": "1.2", }, "directories": { "bin": "./bin", }, "devDependencies": { "name": "1.2", }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", }, "workspaces": ["asdf", "asdf2"], "cpu": ["x86_64"], "os": ["win32"], "optionalDependencies": { "optional": "1.1" }, "bundleDependencies": [ "name" ], "peerDependencies": { "peer": "1.0" }, "peerDependenciesMeta": { "peer": { "optional": true } }, "engines": { "node": ">=18", "deno": "^2" }, }); let package_json = PackageJson::load_from_value( PathBuf::from("/package.json"), json_value.clone(), ) .unwrap(); let serialized_value = serde_json::to_value(&package_json).unwrap(); assert_eq!(serialized_value, json_value); } // https://github.com/denoland/deno/issues/26031 #[test] fn test_exports_error() { let json_value = serde_json::json!({ "name": "test", "version": "1", "exports": { ".": "./a", "a": "./a" }, }); assert!(matches!( PackageJson::load_from_value( PathBuf::from("/package.json"), json_value.clone(), ), Err(PackageJsonLoadError::InvalidExports) )); } #[test] fn test_engines_parsing_and_check() { let json_value = serde_json::json!({ "engines": { "node": ">=18.0.0", "deno": "^2.0.0", "npm": ">=9", }, }); let pj = PackageJson::load_from_value(PathBuf::from("/package.json"), json_value) .unwrap(); let engines = pj.engines.as_ref().unwrap(); assert_eq!(engines.get("node").unwrap(), ">=18.0.0"); assert_eq!(engines.get("deno").unwrap(), "^2.0.0"); assert_eq!(engines.get("npm").unwrap(), ">=9"); // satisfied for both engines we look at let mismatches = pj.check_engines("2.1.0", "20.0.0"); assert!( mismatches.is_empty(), "expected no mismatches: {mismatches:?}" ); // node too old; deno still satisfied; npm is ignored let mismatches = pj.check_engines("2.1.0", "16.0.0"); assert_eq!(mismatches.len(), 1); assert_eq!(mismatches[0].engine, "node"); assert_eq!(mismatches[0].kind, EngineMismatchKind::Unsatisfied); assert_eq!(mismatches[0].required, ">=18.0.0"); assert_eq!(mismatches[0].actual, "16.0.0"); // tolerate a leading `v` on the runtime version (matches // `process.version` format) let mismatches = pj.check_engines("2.1.0", "v20.0.0"); assert!(mismatches.is_empty()); // unparseable range surfaces as InvalidRequirement, doesn't panic let pj = PackageJson::load_from_value( PathBuf::from("/package.json"), serde_json::json!({ "engines": { "deno": "not-a-range" }, }), ) .unwrap(); let mismatches = pj.check_engines("2.0.0", "20.0.0"); assert_eq!(mismatches.len(), 1); assert_eq!(mismatches[0].kind, EngineMismatchKind::InvalidRequirement); // absent engines field → no mismatches let pj = PackageJson::load_from_value( PathBuf::from("/package.json"), serde_json::json!({ "name": "x" }), ) .unwrap(); assert!(pj.engines.is_none()); assert!(pj.check_engines("2.0.0", "20.0.0").is_empty()); } #[test] fn test_workspaces_object_form_catalog() { let json_value = serde_json::json!({ "workspaces": { "packages": ["packages/*"], "catalog": { "@types/bun": "1.3.12", "@types/node": "22.13.9" } } }); let pj = PackageJson::load_from_value(PathBuf::from("/package.json"), json_value) .unwrap(); assert_eq!(pj.workspaces, Some(vec!["packages/*".to_string()])); let catalog = pj.catalog.unwrap(); assert_eq!(catalog.get("@types/bun").unwrap(), "1.3.12"); assert_eq!(catalog.get("@types/node").unwrap(), "22.13.9"); } #[test] fn test_workspaces_object_form_top_level_catalog_takes_precedence() { let json_value = serde_json::json!({ "catalog": { "foo": "1.0.0" }, "workspaces": { "packages": ["packages/*"], "catalog": { "foo": "2.0.0" } } }); let pj = PackageJson::load_from_value(PathBuf::from("/package.json"), json_value) .unwrap(); // Top-level catalog should win let catalog = pj.catalog.unwrap(); assert_eq!(catalog.get("foo").unwrap(), "1.0.0"); } }