From 600fff79cdf5d52154344a0e3a8a523e1e21c3c1 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Tue, 31 Jan 2023 21:27:40 -0500 Subject: [PATCH] refactor(semver): generalize semver related structs (#17605) - Generalizes the npm version code (ex. `NpmVersion` -> `Version`, `NpmVersionReq` -> `VersionReq`). This is a slow refactor towards extracting out this code for deno specifiers and better usage in deno_graph. - Removes `SpecifierVersionReq`. Consolidates `NpmVersionReq` and `SpecifierVersionReq` to just `VersionReq` - Removes `NpmVersionMatcher`. This now just looks at `VersionReq`. - Paves the way to allow us to create `NpmPackageReference`'s from a package.json's dependencies/dev dependencies (`VersionReq::parse_from_npm`). --- cli/main.rs | 1 + cli/npm/cache.rs | 28 +- cli/npm/mod.rs | 3 - cli/npm/registry.rs | 15 +- cli/npm/resolution/graph.rs | 73 +++--- cli/npm/resolution/mod.rs | 35 +-- cli/npm/resolution/reference.rs | 298 +++++++++++++++++++++ cli/npm/resolution/snapshot.rs | 14 +- cli/npm/resolution/specifier.rs | 310 +--------------------- cli/npm/tarball.rs | 9 +- cli/semver/mod.rs | 200 +++++++++++++++ cli/{npm/semver/mod.rs => semver/npm.rs} | 313 ++++++----------------- cli/{npm => }/semver/range.rs | 54 ++-- cli/{npm => }/semver/specifier.rs | 118 +++------ 14 files changed, 731 insertions(+), 740 deletions(-) create mode 100644 cli/npm/resolution/reference.rs create mode 100644 cli/semver/mod.rs rename cli/{npm/semver/mod.rs => semver/npm.rs} (79%) rename cli/{npm => }/semver/range.rs (91%) rename cli/{npm => }/semver/specifier.rs (72%) diff --git a/cli/main.rs b/cli/main.rs index 7504bf9417..71e2c202b7 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -18,6 +18,7 @@ mod npm; mod ops; mod proc_state; mod resolver; +mod semver; mod standalone; mod tools; mod tsc; diff --git a/cli/npm/cache.rs b/cli/npm/cache.rs index 0d07d27b26..8889759261 100644 --- a/cli/npm/cache.rs +++ b/cli/npm/cache.rs @@ -17,13 +17,13 @@ use deno_core::url::Url; use crate::args::CacheSetting; use crate::cache::DenoDir; use crate::http_util::HttpClient; +use crate::semver::Version; use crate::util::fs::canonicalize_path; use crate::util::fs::hard_link_dir_recursive; use crate::util::path::root_url_to_safe_local_dirname; use crate::util::progress_bar::ProgressBar; use super::registry::NpmPackageVersionDistInfo; -use super::semver::NpmVersion; use super::tarball::verify_and_extract_tarball; /// For some of the tests, we want downloading of packages @@ -35,7 +35,7 @@ pub fn should_sync_download() -> bool { const NPM_PACKAGE_SYNC_LOCK_FILENAME: &str = ".deno_sync_lock"; pub fn with_folder_sync_lock( - package: (&str, &NpmVersion), + package: (&str, &Version), output_folder: &Path, action: impl FnOnce() -> Result<(), AnyError>, ) -> Result<(), AnyError> { @@ -108,7 +108,7 @@ pub fn with_folder_sync_lock( pub struct NpmPackageCacheFolderId { pub name: String, - pub version: NpmVersion, + pub version: Version, /// Peer dependency resolution may require us to have duplicate copies /// of the same package. pub copy_index: usize, @@ -202,7 +202,7 @@ impl ReadonlyNpmCache { pub fn package_folder_for_name_and_version( &self, name: &str, - version: &NpmVersion, + version: &Version, registry_url: &Url, ) -> PathBuf { self @@ -305,7 +305,7 @@ impl ReadonlyNpmCache { }; Some(NpmPackageCacheFolderId { name, - version: NpmVersion::parse(version).ok()?, + version: Version::parse_from_npm(version).ok()?, copy_index, }) } @@ -357,7 +357,7 @@ impl NpmCache { /// and imports a dynamic import that imports the same package again for example. fn should_use_global_cache_for_package( &self, - package: (&str, &NpmVersion), + package: (&str, &Version), ) -> bool { self.cache_setting.should_use_for_npm_package(package.0) || !self @@ -368,7 +368,7 @@ impl NpmCache { pub async fn ensure_package( &self, - package: (&str, &NpmVersion), + package: (&str, &Version), dist: &NpmPackageVersionDistInfo, registry_url: &Url, ) -> Result<(), AnyError> { @@ -382,7 +382,7 @@ impl NpmCache { async fn ensure_package_inner( &self, - package: (&str, &NpmVersion), + package: (&str, &Version), dist: &NpmPackageVersionDistInfo, registry_url: &Url, ) -> Result<(), AnyError> { @@ -467,7 +467,7 @@ impl NpmCache { pub fn package_folder_for_name_and_version( &self, name: &str, - version: &NpmVersion, + version: &Version, registry_url: &Url, ) -> PathBuf { self.readonly.package_folder_for_name_and_version( @@ -517,7 +517,7 @@ mod test { use super::ReadonlyNpmCache; use crate::npm::cache::NpmPackageCacheFolderId; - use crate::npm::semver::NpmVersion; + use crate::semver::Version; #[test] fn should_get_package_folder() { @@ -530,7 +530,7 @@ mod test { cache.package_folder_for_id( &NpmPackageCacheFolderId { name: "json".to_string(), - version: NpmVersion::parse("1.2.5").unwrap(), + version: Version::parse_from_npm("1.2.5").unwrap(), copy_index: 0, }, ®istry_url, @@ -545,7 +545,7 @@ mod test { cache.package_folder_for_id( &NpmPackageCacheFolderId { name: "json".to_string(), - version: NpmVersion::parse("1.2.5").unwrap(), + version: Version::parse_from_npm("1.2.5").unwrap(), copy_index: 1, }, ®istry_url, @@ -560,7 +560,7 @@ mod test { cache.package_folder_for_id( &NpmPackageCacheFolderId { name: "JSON".to_string(), - version: NpmVersion::parse("2.1.5").unwrap(), + version: Version::parse_from_npm("2.1.5").unwrap(), copy_index: 0, }, ®istry_url, @@ -575,7 +575,7 @@ mod test { cache.package_folder_for_id( &NpmPackageCacheFolderId { name: "@types/JSON".to_string(), - version: NpmVersion::parse("2.1.5").unwrap(), + version: Version::parse_from_npm("2.1.5").unwrap(), copy_index: 0, }, ®istry_url, diff --git a/cli/npm/mod.rs b/cli/npm/mod.rs index 9f41e508a8..b9a4f493aa 100644 --- a/cli/npm/mod.rs +++ b/cli/npm/mod.rs @@ -4,11 +4,8 @@ mod cache; mod registry; mod resolution; mod resolvers; -mod semver; mod tarball; -#[cfg(test)] -pub use self::semver::NpmVersion; pub use cache::NpmCache; #[cfg(test)] pub use registry::NpmPackageVersionDistInfo; diff --git a/cli/npm/registry.rs b/cli/npm/registry.rs index 9598feba1f..fea6996aba 100644 --- a/cli/npm/registry.rs +++ b/cli/npm/registry.rs @@ -25,13 +25,12 @@ use serde::Serialize; use crate::args::CacheSetting; use crate::cache::CACHE_PERM; use crate::http_util::HttpClient; +use crate::semver::Version; +use crate::semver::VersionReq; use crate::util::fs::atomic_write_file; use crate::util::progress_bar::ProgressBar; use super::cache::NpmCache; -use super::resolution::NpmVersionMatcher; -use super::semver::NpmVersion; -use super::semver::NpmVersionReq; // npm registry docs: https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md @@ -61,11 +60,11 @@ pub struct NpmDependencyEntry { pub kind: NpmDependencyEntryKind, pub bare_specifier: String, pub name: String, - pub version_req: NpmVersionReq, + pub version_req: VersionReq, /// When the dependency is also marked as a peer dependency, /// use this entry to resolve the dependency when it can't /// be resolved as a peer dependency. - pub peer_dep_version_req: Option, + pub peer_dep_version_req: Option, } impl PartialOrd for NpmDependencyEntry { @@ -82,7 +81,7 @@ impl Ord for NpmDependencyEntry { Ordering::Equal => other .version_req .version_text() - .cmp(&self.version_req.version_text()), + .cmp(self.version_req.version_text()), ordering => ordering, } } @@ -129,7 +128,7 @@ impl NpmPackageVersionInfo { (entry.0.clone(), entry.1.clone()) }; let version_req = - NpmVersionReq::parse(&version_req).with_context(|| { + VersionReq::parse_from_npm(&version_req).with_context(|| { format!( "error parsing version requirement for dependency: {bare_specifier}@{version_req}" ) @@ -217,7 +216,7 @@ pub trait NpmRegistryApi: Clone + Sync + Send + 'static { fn package_version_info( &self, name: &str, - version: &NpmVersion, + version: &Version, ) -> BoxFuture<'static, Result, AnyError>> { let api = self.clone(); let name = name.to_string(); diff --git a/cli/npm/resolution/graph.rs b/cli/npm/resolution/graph.rs index e210481499..20f192fbf8 100644 --- a/cli/npm/resolution/graph.rs +++ b/cli/npm/resolution/graph.rs @@ -14,22 +14,25 @@ use deno_core::futures; use deno_core::parking_lot::Mutex; use deno_core::parking_lot::MutexGuard; use log::debug; +use once_cell::sync::Lazy; use crate::npm::cache::should_sync_download; use crate::npm::registry::NpmDependencyEntry; use crate::npm::registry::NpmDependencyEntryKind; use crate::npm::registry::NpmPackageInfo; use crate::npm::registry::NpmPackageVersionInfo; -use crate::npm::semver::NpmVersion; -use crate::npm::semver::NpmVersionReq; use crate::npm::NpmRegistryApi; +use crate::semver::Version; +use crate::semver::VersionReq; use super::snapshot::NpmResolutionSnapshot; use super::snapshot::SnapshotPackageCopyIndexResolver; use super::NpmPackageId; use super::NpmPackageReq; use super::NpmResolutionPackage; -use super::NpmVersionMatcher; + +pub static LATEST_VERSION_REQ: Lazy = + Lazy::new(|| VersionReq::parse_from_specifier("latest").unwrap()); /// A memory efficient path of visited name and versions in the graph /// which is used to detect cycles. @@ -419,11 +422,11 @@ impl<'a, TNpmRegistryApi: NpmRegistryApi> fn resolve_best_package_version_and_info<'info>( &self, - version_matcher: &impl NpmVersionMatcher, + version_req: &VersionReq, package_info: &'info NpmPackageInfo, ) -> Result, AnyError> { if let Some(version) = - self.resolve_best_package_version(package_info, version_matcher)? + self.resolve_best_package_version(package_info, version_req)? { match package_info.versions.get(&version.to_string()) { Some(version_info) => Ok(VersionAndInfo { @@ -440,20 +443,19 @@ impl<'a, TNpmRegistryApi: NpmRegistryApi> } } else { // get the information - get_resolved_package_version_and_info(version_matcher, package_info, None) + get_resolved_package_version_and_info(version_req, package_info, None) } } fn resolve_best_package_version( &self, package_info: &NpmPackageInfo, - version_matcher: &impl NpmVersionMatcher, - ) -> Result, AnyError> { - let mut maybe_best_version: Option<&NpmVersion> = None; + version_req: &VersionReq, + ) -> Result, AnyError> { + let mut maybe_best_version: Option<&Version> = None; if let Some(ids) = self.graph.packages_by_name.get(&package_info.name) { for version in ids.iter().map(|id| &id.version) { - if version_req_satisfies(version_matcher, version, package_info, None)? - { + if version_req_satisfies(version_req, version, package_info, None)? { let is_best_version = maybe_best_version .as_ref() .map(|best_version| (*best_version).cmp(version).is_lt()) @@ -478,7 +480,10 @@ impl<'a, TNpmRegistryApi: NpmRegistryApi> ) -> Result<(), AnyError> { let (_, node) = self.resolve_node_from_info( &package_req.name, - package_req, + package_req + .version_req + .as_ref() + .unwrap_or(&*LATEST_VERSION_REQ), package_info, None, )?; @@ -557,12 +562,12 @@ impl<'a, TNpmRegistryApi: NpmRegistryApi> fn resolve_node_from_info( &mut self, pkg_req_name: &str, - version_matcher: &impl NpmVersionMatcher, + version_req: &VersionReq, package_info: &NpmPackageInfo, parent_id: Option<&NpmPackageId>, ) -> Result<(NpmPackageId, Arc>), AnyError> { - let version_and_info = self - .resolve_best_package_version_and_info(version_matcher, package_info)?; + let version_and_info = + self.resolve_best_package_version_and_info(version_req, package_info)?; let id = NpmPackageId { name: package_info.name.to_string(), version: version_and_info.version.clone(), @@ -575,7 +580,7 @@ impl<'a, TNpmRegistryApi: NpmRegistryApi> None => "".to_string(), }, pkg_req_name, - version_matcher.version_text(), + version_req.version_text(), id.as_serialized(), ); let (created, node) = self.graph.get_or_create_for_id(&id); @@ -996,22 +1001,22 @@ impl<'a, TNpmRegistryApi: NpmRegistryApi> #[derive(Clone)] struct VersionAndInfo<'a> { - version: NpmVersion, + version: Version, info: &'a NpmPackageVersionInfo, } fn get_resolved_package_version_and_info<'a>( - version_matcher: &impl NpmVersionMatcher, + version_req: &VersionReq, info: &'a NpmPackageInfo, parent: Option<&NpmPackageId>, ) -> Result, AnyError> { - if let Some(tag) = version_matcher.tag() { + if let Some(tag) = version_req.tag() { tag_to_version_info(info, tag, parent) } else { let mut maybe_best_version: Option = None; for version_info in info.versions.values() { - let version = NpmVersion::parse(&version_info.version)?; - if version_matcher.matches(&version) { + let version = Version::parse_from_npm(&version_info.version)?; + if version_req.matches(&version) { let is_best_version = maybe_best_version .as_ref() .map(|best_version| best_version.version.cmp(&version).is_lt()) @@ -1042,7 +1047,7 @@ fn get_resolved_package_version_and_info<'a>( "Try retrieving the latest npm package information by running with --reload", ), info.name, - version_matcher.version_text(), + version_req.version_text(), match parent { Some(id) => format!(" as specified in {}", id.display()), None => String::new(), @@ -1053,17 +1058,17 @@ fn get_resolved_package_version_and_info<'a>( } fn version_req_satisfies( - matcher: &impl NpmVersionMatcher, - version: &NpmVersion, + version_req: &VersionReq, + version: &Version, package_info: &NpmPackageInfo, parent: Option<&NpmPackageId>, ) -> Result { - match matcher.tag() { + match version_req.tag() { Some(tag) => { let tag_version = tag_to_version_info(package_info, tag, parent)?.version; Ok(tag_version == *version) } - None => Ok(matcher.matches(version)), + None => Ok(version_req.matches(version)), } } @@ -1081,7 +1086,7 @@ fn tag_to_version_info<'a>( // explicit version. if tag == "latest" && info.name == "@types/node" { return get_resolved_package_version_and_info( - &NpmVersionReq::parse("18.0.0 - 18.11.18").unwrap(), + &VersionReq::parse_from_npm("18.0.0 - 18.11.18").unwrap(), info, parent, ); @@ -1090,7 +1095,7 @@ fn tag_to_version_info<'a>( if let Some(version) = info.dist_tags.get(tag) { match info.versions.get(version) { Some(info) => Ok(VersionAndInfo { - version: NpmVersion::parse(version)?, + version: Version::parse_from_npm(version)?, info, }), None => { @@ -1128,7 +1133,11 @@ mod test { )]), }; let result = get_resolved_package_version_and_info( - &package_ref.req, + package_ref + .req + .version_req + .as_ref() + .unwrap_or(&*LATEST_VERSION_REQ), &package_info, None, ); @@ -1157,7 +1166,11 @@ mod test { )]), }; let result = get_resolved_package_version_and_info( - &package_ref.req, + package_ref + .req + .version_req + .as_ref() + .unwrap_or(&*LATEST_VERSION_REQ), &package_info, None, ); diff --git a/cli/npm/resolution/mod.rs b/cli/npm/resolution/mod.rs index 407651ccb9..990ad8d065 100644 --- a/cli/npm/resolution/mod.rs +++ b/cli/npm/resolution/mod.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use serde::Serialize; use crate::args::Lockfile; +use crate::semver::Version; use self::graph::GraphDependencyResolver; use self::snapshot::NpmPackagesPartitioned; @@ -19,33 +20,25 @@ use super::cache::should_sync_download; use super::cache::NpmPackageCacheFolderId; use super::registry::NpmPackageVersionDistInfo; use super::registry::RealNpmRegistryApi; -use super::semver::NpmVersion; use super::NpmRegistryApi; mod graph; +mod reference; mod snapshot; mod specifier; use graph::Graph; +pub use reference::NpmPackageReference; +pub use reference::NpmPackageReq; pub use snapshot::NpmResolutionSnapshot; pub use specifier::resolve_graph_npm_info; -pub use specifier::NpmPackageReference; -pub use specifier::NpmPackageReq; - -/// The version matcher used for npm schemed urls is more strict than -/// the one used by npm packages and so we represent either via a trait. -pub trait NpmVersionMatcher { - fn tag(&self) -> Option<&str>; - fn matches(&self, version: &NpmVersion) -> bool; - fn version_text(&self) -> String; -} #[derive( Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash, Serialize, Deserialize, )] pub struct NpmPackageId { pub name: String, - pub version: NpmVersion, + pub version: Version, pub peer_dependencies: Vec, } @@ -103,14 +96,12 @@ impl NpmPackageId { if_not_empty(substring(skip_while(|c| c != '_')))(input) } - fn parse_name_and_version( - input: &str, - ) -> ParseResult<(String, NpmVersion)> { + fn parse_name_and_version(input: &str) -> ParseResult<(String, Version)> { let (input, name) = parse_name(input)?; let (input, _) = ch('@')(input)?; let at_version_input = input; let (input, version) = parse_version(input)?; - match NpmVersion::parse(version) { + match Version::parse_from_npm(version) { Ok(version) => Ok((input, (name.to_string(), version))), Err(err) => ParseError::fail(at_version_input, format!("{err:#}")), } @@ -417,30 +408,30 @@ mod tests { fn serialize_npm_package_id() { let id = NpmPackageId { name: "pkg-a".to_string(), - version: NpmVersion::parse("1.2.3").unwrap(), + version: Version::parse_from_npm("1.2.3").unwrap(), peer_dependencies: vec![ NpmPackageId { name: "pkg-b".to_string(), - version: NpmVersion::parse("3.2.1").unwrap(), + version: Version::parse_from_npm("3.2.1").unwrap(), peer_dependencies: vec![ NpmPackageId { name: "pkg-c".to_string(), - version: NpmVersion::parse("1.3.2").unwrap(), + version: Version::parse_from_npm("1.3.2").unwrap(), peer_dependencies: vec![], }, NpmPackageId { name: "pkg-d".to_string(), - version: NpmVersion::parse("2.3.4").unwrap(), + version: Version::parse_from_npm("2.3.4").unwrap(), peer_dependencies: vec![], }, ], }, NpmPackageId { name: "pkg-e".to_string(), - version: NpmVersion::parse("2.3.1").unwrap(), + version: Version::parse_from_npm("2.3.1").unwrap(), peer_dependencies: vec![NpmPackageId { name: "pkg-f".to_string(), - version: NpmVersion::parse("2.3.1").unwrap(), + version: Version::parse_from_npm("2.3.1").unwrap(), peer_dependencies: vec![], }], }, diff --git a/cli/npm/resolution/reference.rs b/cli/npm/resolution/reference.rs new file mode 100644 index 0000000000..2d34bcc34d --- /dev/null +++ b/cli/npm/resolution/reference.rs @@ -0,0 +1,298 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use deno_ast::ModuleSpecifier; +use deno_core::anyhow::bail; +use deno_core::anyhow::Context; +use deno_core::error::generic_error; +use deno_core::error::AnyError; +use serde::Deserialize; +use serde::Serialize; + +use crate::semver::VersionReq; + +/// A reference to an npm package's name, version constraint, and potential sub path. +/// +/// This contains all the information found in an npm specifier. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NpmPackageReference { + pub req: NpmPackageReq, + pub sub_path: Option, +} + +impl NpmPackageReference { + pub fn from_specifier( + specifier: &ModuleSpecifier, + ) -> Result { + Self::from_str(specifier.as_str()) + } + + pub fn from_str(specifier: &str) -> Result { + let original_text = specifier; + let specifier = match specifier.strip_prefix("npm:") { + Some(s) => { + // Strip leading slash, which might come from import map + s.strip_prefix('/').unwrap_or(s) + } + None => { + // don't allocate a string here and instead use a static string + // because this is hit a lot when a url is not an npm specifier + return Err(generic_error("Not an npm specifier")); + } + }; + let parts = specifier.split('/').collect::>(); + let name_part_len = if specifier.starts_with('@') { 2 } else { 1 }; + if parts.len() < name_part_len { + return Err(generic_error(format!("Not a valid package: {specifier}"))); + } + let name_parts = &parts[0..name_part_len]; + let req = match NpmPackageReq::parse_from_parts(name_parts) { + Ok(pkg_req) => pkg_req, + Err(err) => { + return Err(generic_error(format!( + "Invalid npm specifier '{original_text}'. {err:#}" + ))) + } + }; + let sub_path = if parts.len() == name_parts.len() { + None + } else { + let sub_path = parts[name_part_len..].join("/"); + if sub_path.is_empty() { + None + } else { + Some(sub_path) + } + }; + + if let Some(sub_path) = &sub_path { + if let Some(at_index) = sub_path.rfind('@') { + let (new_sub_path, version) = sub_path.split_at(at_index); + let msg = format!( + "Invalid package specifier 'npm:{req}/{sub_path}'. Did you mean to write 'npm:{req}{version}/{new_sub_path}'?" + ); + return Err(generic_error(msg)); + } + } + + Ok(NpmPackageReference { req, sub_path }) + } +} + +impl std::fmt::Display for NpmPackageReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(sub_path) = &self.sub_path { + write!(f, "npm:{}/{}", self.req, sub_path) + } else { + write!(f, "npm:{}", self.req) + } + } +} + +/// The name and version constraint component of an `NpmPackageReference`. +#[derive( + Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +pub struct NpmPackageReq { + pub name: String, + pub version_req: Option, +} + +impl std::fmt::Display for NpmPackageReq { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.version_req { + Some(req) => write!(f, "{}@{}", self.name, req), + None => write!(f, "{}", self.name), + } + } +} + +impl NpmPackageReq { + pub fn from_str(text: &str) -> Result { + let parts = text.split('/').collect::>(); + match NpmPackageReq::parse_from_parts(&parts) { + Ok(req) => Ok(req), + Err(err) => { + let msg = format!("Invalid npm package requirement '{text}'. {err:#}"); + Err(generic_error(msg)) + } + } + } + + fn parse_from_parts(name_parts: &[&str]) -> Result { + assert!(!name_parts.is_empty()); // this should be provided the result of a string split + let last_name_part = &name_parts[name_parts.len() - 1]; + let (name, version_req) = if let Some(at_index) = last_name_part.rfind('@') + { + let version = &last_name_part[at_index + 1..]; + let last_name_part = &last_name_part[..at_index]; + let version_req = VersionReq::parse_from_specifier(version) + .with_context(|| "Invalid version requirement.")?; + let name = if name_parts.len() == 1 { + last_name_part.to_string() + } else { + format!("{}/{}", name_parts[0], last_name_part) + }; + (name, Some(version_req)) + } else { + (name_parts.join("/"), None) + }; + if name.is_empty() { + bail!("Did not contain a package name.") + } + Ok(Self { name, version_req }) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn parse_npm_package_ref() { + assert_eq!( + NpmPackageReference::from_str("npm:@package/test").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "@package/test".to_string(), + version_req: None, + }, + sub_path: None, + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:@package/test@1").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "@package/test".to_string(), + version_req: Some(VersionReq::parse_from_specifier("1").unwrap()), + }, + sub_path: None, + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:@package/test@~1.1/sub_path").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "@package/test".to_string(), + version_req: Some(VersionReq::parse_from_specifier("~1.1").unwrap()), + }, + sub_path: Some("sub_path".to_string()), + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:@package/test/sub_path").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "@package/test".to_string(), + version_req: None, + }, + sub_path: Some("sub_path".to_string()), + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:test").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "test".to_string(), + version_req: None, + }, + sub_path: None, + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:test@^1.2").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "test".to_string(), + version_req: Some(VersionReq::parse_from_specifier("^1.2").unwrap()), + }, + sub_path: None, + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:test@~1.1/sub_path").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "test".to_string(), + version_req: Some(VersionReq::parse_from_specifier("~1.1").unwrap()), + }, + sub_path: Some("sub_path".to_string()), + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:@package/test/sub_path").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "@package/test".to_string(), + version_req: None, + }, + sub_path: Some("sub_path".to_string()), + } + ); + + assert_eq!( + NpmPackageReference::from_str("npm:@package") + .err() + .unwrap() + .to_string(), + "Not a valid package: @package" + ); + + // should parse leading slash + assert_eq!( + NpmPackageReference::from_str("npm:/@package/test/sub_path").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "@package/test".to_string(), + version_req: None, + }, + sub_path: Some("sub_path".to_string()), + } + ); + assert_eq!( + NpmPackageReference::from_str("npm:/test").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "test".to_string(), + version_req: None, + }, + sub_path: None, + } + ); + assert_eq!( + NpmPackageReference::from_str("npm:/test/").unwrap(), + NpmPackageReference { + req: NpmPackageReq { + name: "test".to_string(), + version_req: None, + }, + sub_path: None, + } + ); + + // should error for no name + assert_eq!( + NpmPackageReference::from_str("npm:/") + .err() + .unwrap() + .to_string(), + "Invalid npm specifier 'npm:/'. Did not contain a package name." + ); + assert_eq!( + NpmPackageReference::from_str("npm://test") + .err() + .unwrap() + .to_string(), + "Invalid npm specifier 'npm://test'. Did not contain a package name." + ); + } +} diff --git a/cli/npm/resolution/snapshot.rs b/cli/npm/resolution/snapshot.rs index be64ea611e..934320a1da 100644 --- a/cli/npm/resolution/snapshot.rs +++ b/cli/npm/resolution/snapshot.rs @@ -19,11 +19,11 @@ use crate::npm::cache::NpmPackageCacheFolderId; use crate::npm::registry::NpmPackageVersionDistInfo; use crate::npm::registry::NpmRegistryApi; use crate::npm::registry::RealNpmRegistryApi; +use crate::semver::VersionReq; use super::NpmPackageId; use super::NpmPackageReq; use super::NpmResolutionPackage; -use super::NpmVersionMatcher; /// Packages partitioned by if they are "copy" packages or not. pub struct NpmPackagesPartitioned { @@ -159,12 +159,8 @@ impl NpmResolutionSnapshot { // TODO(bartlomieju): this should use a reverse lookup table in the // snapshot instead of resolving best version again. - let req = NpmPackageReq { - name: name.to_string(), - version_req: None, - }; - - if let Some(id) = self.resolve_best_package_id(name, &req) { + let any_version_req = VersionReq::parse_from_npm("*").unwrap(); + if let Some(id) = self.resolve_best_package_id(name, &any_version_req) { if let Some(pkg) = self.packages.get(&id) { return Ok(pkg); } @@ -201,14 +197,14 @@ impl NpmResolutionSnapshot { pub fn resolve_best_package_id( &self, name: &str, - version_matcher: &impl NpmVersionMatcher, + version_req: &VersionReq, ) -> Option { // todo(dsherret): this is not exactly correct because some ids // will be better than others due to peer dependencies let mut maybe_best_id: Option<&NpmPackageId> = None; if let Some(ids) = self.packages_by_name.get(name) { for id in ids { - if version_matcher.matches(&id.version) { + if version_req.matches(&id.version) { let is_best_version = maybe_best_id .as_ref() .map(|best_id| best_id.version.cmp(&id.version).is_lt()) diff --git a/cli/npm/resolution/specifier.rs b/cli/npm/resolution/specifier.rs index 0aa6934727..78d313412b 100644 --- a/cli/npm/resolution/specifier.rs +++ b/cli/npm/resolution/specifier.rs @@ -6,165 +6,13 @@ use std::collections::HashSet; use std::collections::VecDeque; use deno_ast::ModuleSpecifier; -use deno_core::anyhow::Context; -use deno_core::error::generic_error; -use deno_core::error::AnyError; use deno_graph::ModuleGraph; use deno_graph::Resolved; -use serde::Deserialize; -use serde::Serialize; -use super::super::semver::NpmVersion; -use super::super::semver::SpecifierVersionReq; -use super::NpmVersionMatcher; +use crate::semver::VersionReq; -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct NpmPackageReference { - pub req: NpmPackageReq, - pub sub_path: Option, -} - -impl NpmPackageReference { - pub fn from_specifier( - specifier: &ModuleSpecifier, - ) -> Result { - Self::from_str(specifier.as_str()) - } - - pub fn from_str(specifier: &str) -> Result { - let original_text = specifier; - let specifier = match specifier.strip_prefix("npm:") { - Some(s) => { - // Strip leading slash, which might come from import map - s.strip_prefix('/').unwrap_or(s) - } - None => { - // don't allocate a string here and instead use a static string - // because this is hit a lot when a url is not an npm specifier - return Err(generic_error("Not an npm specifier")); - } - }; - let parts = specifier.split('/').collect::>(); - let name_part_len = if specifier.starts_with('@') { 2 } else { 1 }; - if parts.len() < name_part_len { - return Err(generic_error(format!("Not a valid package: {specifier}"))); - } - let name_parts = &parts[0..name_part_len]; - let last_name_part = &name_parts[name_part_len - 1]; - let (name, version_req) = if let Some(at_index) = last_name_part.rfind('@') - { - let version = &last_name_part[at_index + 1..]; - let last_name_part = &last_name_part[..at_index]; - let version_req = SpecifierVersionReq::parse(version) - .with_context(|| "Invalid version requirement.")?; - let name = if name_part_len == 1 { - last_name_part.to_string() - } else { - format!("{}/{}", name_parts[0], last_name_part) - }; - (name, Some(version_req)) - } else { - (name_parts.join("/"), None) - }; - let sub_path = if parts.len() == name_parts.len() { - None - } else { - let sub_path = parts[name_part_len..].join("/"); - if sub_path.is_empty() { - None - } else { - Some(sub_path) - } - }; - - if let Some(sub_path) = &sub_path { - if let Some(at_index) = sub_path.rfind('@') { - let (new_sub_path, version) = sub_path.split_at(at_index); - let msg = format!( - "Invalid package specifier 'npm:{name}/{sub_path}'. Did you mean to write 'npm:{name}{version}/{new_sub_path}'?" - ); - return Err(generic_error(msg)); - } - } - - if name.is_empty() { - let msg = format!( - "Invalid npm specifier '{original_text}'. Did not contain a package name." - ); - return Err(generic_error(msg)); - } - - Ok(NpmPackageReference { - req: NpmPackageReq { name, version_req }, - sub_path, - }) - } -} - -impl std::fmt::Display for NpmPackageReference { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(sub_path) = &self.sub_path { - write!(f, "npm:{}/{}", self.req, sub_path) - } else { - write!(f, "npm:{}", self.req) - } - } -} - -#[derive( - Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, -)] -pub struct NpmPackageReq { - pub name: String, - pub version_req: Option, -} - -impl std::fmt::Display for NpmPackageReq { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self.version_req { - Some(req) => write!(f, "{}@{}", self.name, req), - None => write!(f, "{}", self.name), - } - } -} - -impl NpmPackageReq { - pub fn from_str(text: &str) -> Result { - // probably should do something more targeted in the future - let reference = NpmPackageReference::from_str(&format!("npm:{text}"))?; - Ok(reference.req) - } -} - -impl NpmVersionMatcher for NpmPackageReq { - fn tag(&self) -> Option<&str> { - match &self.version_req { - Some(version_req) => version_req.tag(), - None => Some("latest"), - } - } - - fn matches(&self, version: &NpmVersion) -> bool { - match self.version_req.as_ref() { - Some(req) => { - assert_eq!(self.tag(), None); - match req.range() { - Some(range) => range.satisfies(version), - None => false, - } - } - None => version.pre.is_empty(), - } - } - - fn version_text(&self) -> String { - self - .version_req - .as_ref() - .map(|v| format!("{v}")) - .unwrap_or_else(|| "non-prerelease".to_string()) - } -} +use super::NpmPackageReference; +use super::NpmPackageReq; pub struct GraphNpmInfo { /// The order of these package requirements is the order they @@ -537,10 +385,7 @@ fn cmp_folder_specifiers(a: &ModuleSpecifier, b: &ModuleSpecifier) -> Ordering { // duplicate packages (so sort None last since it's `*`), but // mostly to create some determinism around how these are resolved. fn cmp_package_req(a: &NpmPackageReq, b: &NpmPackageReq) -> Ordering { - fn cmp_specifier_version_req( - a: &SpecifierVersionReq, - b: &SpecifierVersionReq, - ) -> Ordering { + fn cmp_specifier_version_req(a: &VersionReq, b: &VersionReq) -> Ordering { match a.tag() { Some(a_tag) => match b.tag() { Some(b_tag) => b_tag.cmp(a_tag), // sort descending @@ -581,153 +426,6 @@ mod tests { use super::*; - #[test] - fn parse_npm_package_ref() { - assert_eq!( - NpmPackageReference::from_str("npm:@package/test").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "@package/test".to_string(), - version_req: None, - }, - sub_path: None, - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:@package/test@1").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "@package/test".to_string(), - version_req: Some(SpecifierVersionReq::parse("1").unwrap()), - }, - sub_path: None, - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:@package/test@~1.1/sub_path").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "@package/test".to_string(), - version_req: Some(SpecifierVersionReq::parse("~1.1").unwrap()), - }, - sub_path: Some("sub_path".to_string()), - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:@package/test/sub_path").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "@package/test".to_string(), - version_req: None, - }, - sub_path: Some("sub_path".to_string()), - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:test").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "test".to_string(), - version_req: None, - }, - sub_path: None, - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:test@^1.2").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "test".to_string(), - version_req: Some(SpecifierVersionReq::parse("^1.2").unwrap()), - }, - sub_path: None, - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:test@~1.1/sub_path").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "test".to_string(), - version_req: Some(SpecifierVersionReq::parse("~1.1").unwrap()), - }, - sub_path: Some("sub_path".to_string()), - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:@package/test/sub_path").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "@package/test".to_string(), - version_req: None, - }, - sub_path: Some("sub_path".to_string()), - } - ); - - assert_eq!( - NpmPackageReference::from_str("npm:@package") - .err() - .unwrap() - .to_string(), - "Not a valid package: @package" - ); - - // should parse leading slash - assert_eq!( - NpmPackageReference::from_str("npm:/@package/test/sub_path").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "@package/test".to_string(), - version_req: None, - }, - sub_path: Some("sub_path".to_string()), - } - ); - assert_eq!( - NpmPackageReference::from_str("npm:/test").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "test".to_string(), - version_req: None, - }, - sub_path: None, - } - ); - assert_eq!( - NpmPackageReference::from_str("npm:/test/").unwrap(), - NpmPackageReference { - req: NpmPackageReq { - name: "test".to_string(), - version_req: None, - }, - sub_path: None, - } - ); - - // should error for no name - assert_eq!( - NpmPackageReference::from_str("npm:/") - .err() - .unwrap() - .to_string(), - "Invalid npm specifier 'npm:/'. Did not contain a package name." - ); - assert_eq!( - NpmPackageReference::from_str("npm://test") - .err() - .unwrap() - .to_string(), - "Invalid npm specifier 'npm://test'. Did not contain a package name." - ); - } - #[test] fn sorting_folder_specifiers() { fn cmp(a: &str, b: &str) -> Ordering { diff --git a/cli/npm/tarball.rs b/cli/npm/tarball.rs index 758ac3ded6..3abf4f12f4 100644 --- a/cli/npm/tarball.rs +++ b/cli/npm/tarball.rs @@ -13,10 +13,10 @@ use tar::EntryType; use super::cache::with_folder_sync_lock; use super::registry::NpmPackageVersionDistInfo; -use super::semver::NpmVersion; +use crate::semver::Version; pub fn verify_and_extract_tarball( - package: (&str, &NpmVersion), + package: (&str, &Version), data: &[u8], dist_info: &NpmPackageVersionDistInfo, output_folder: &Path, @@ -29,7 +29,7 @@ pub fn verify_and_extract_tarball( } fn verify_tarball_integrity( - package: (&str, &NpmVersion), + package: (&str, &Version), data: &[u8], npm_integrity: &str, ) -> Result<(), AnyError> { @@ -120,12 +120,11 @@ fn extract_tarball(data: &[u8], output_folder: &Path) -> Result<(), AnyError> { #[cfg(test)] mod test { use super::*; - use crate::npm::semver::NpmVersion; #[test] pub fn test_verify_tarball() { let package_name = "package".to_string(); - let package_version = NpmVersion::parse("1.0.0").unwrap(); + let package_version = Version::parse_from_npm("1.0.0").unwrap(); let package = (package_name.as_str(), &package_version); let actual_checksum = "z4phnx7vul3xvchq1m2ab9yg5aulvxxcg/spidns6c5h0ne8xyxysp+dgnkhfuwvy7kxvudbeoglodj6+sfapg=="; diff --git a/cli/semver/mod.rs b/cli/semver/mod.rs new file mode 100644 index 0000000000..4fedddf5e0 --- /dev/null +++ b/cli/semver/mod.rs @@ -0,0 +1,200 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use std::cmp::Ordering; +use std::fmt; + +use deno_core::error::AnyError; +use serde::Deserialize; +use serde::Serialize; + +mod npm; +mod range; +mod specifier; + +use self::npm::parse_npm_version_req; +pub use self::range::Partial; +pub use self::range::VersionBoundKind; +pub use self::range::VersionRange; +pub use self::range::VersionRangeSet; +pub use self::range::XRange; +use self::specifier::parse_version_req_from_specifier; + +#[derive( + Clone, Debug, PartialEq, Eq, Default, Hash, Serialize, Deserialize, +)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, + pub pre: Vec, + pub build: Vec, +} + +impl Version { + pub fn parse_from_npm(text: &str) -> Result { + npm::parse_npm_version(text) + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?; + if !self.pre.is_empty() { + write!(f, "-")?; + for (i, part) in self.pre.iter().enumerate() { + if i > 0 { + write!(f, ".")?; + } + write!(f, "{part}")?; + } + } + if !self.build.is_empty() { + write!(f, "+")?; + for (i, part) in self.build.iter().enumerate() { + if i > 0 { + write!(f, ".")?; + } + write!(f, "{part}")?; + } + } + Ok(()) + } +} + +impl std::cmp::PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + let cmp_result = self.major.cmp(&other.major); + if cmp_result != Ordering::Equal { + return cmp_result; + } + + let cmp_result = self.minor.cmp(&other.minor); + if cmp_result != Ordering::Equal { + return cmp_result; + } + + let cmp_result = self.patch.cmp(&other.patch); + if cmp_result != Ordering::Equal { + return cmp_result; + } + + // only compare the pre-release and not the build as node-semver does + if self.pre.is_empty() && other.pre.is_empty() { + Ordering::Equal + } else if !self.pre.is_empty() && other.pre.is_empty() { + Ordering::Less + } else if self.pre.is_empty() && !other.pre.is_empty() { + Ordering::Greater + } else { + let mut i = 0; + loop { + let a = self.pre.get(i); + let b = other.pre.get(i); + if a.is_none() && b.is_none() { + return Ordering::Equal; + } + + // https://github.com/npm/node-semver/blob/4907647d169948a53156502867ed679268063a9f/internal/identifiers.js + let a = match a { + Some(a) => a, + None => return Ordering::Less, + }; + let b = match b { + Some(b) => b, + None => return Ordering::Greater, + }; + + // prefer numbers + if let Ok(a_num) = a.parse::() { + if let Ok(b_num) = b.parse::() { + return a_num.cmp(&b_num); + } else { + return Ordering::Less; + } + } else if b.parse::().is_ok() { + return Ordering::Greater; + } + + let cmp_result = a.cmp(b); + if cmp_result != Ordering::Equal { + return cmp_result; + } + i += 1; + } + } + } +} + +pub(super) fn is_valid_tag(value: &str) -> bool { + // we use the same rules as npm tags + npm::is_valid_npm_tag(value) +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum RangeSetOrTag { + RangeSet(VersionRangeSet), + Tag(String), +} + +/// A version requirement found in an npm package's dependencies. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VersionReq { + raw_text: String, + inner: RangeSetOrTag, +} + +impl VersionReq { + /// Creates a version requirement without examining the raw text. + pub fn from_raw_text_and_inner( + raw_text: String, + inner: RangeSetOrTag, + ) -> Self { + Self { raw_text, inner } + } + + pub fn parse_from_specifier(specifier: &str) -> Result { + parse_version_req_from_specifier(specifier) + } + + pub fn parse_from_npm(text: &str) -> Result { + parse_npm_version_req(text) + } + + #[cfg(test)] + pub fn inner(&self) -> &RangeSetOrTag { + &self.inner + } + + pub fn tag(&self) -> Option<&str> { + match &self.inner { + RangeSetOrTag::RangeSet(_) => None, + RangeSetOrTag::Tag(tag) => Some(tag.as_str()), + } + } + + pub fn matches(&self, version: &Version) -> bool { + match &self.inner { + RangeSetOrTag::RangeSet(range_set) => range_set.satisfies(version), + RangeSetOrTag::Tag(_) => panic!( + "programming error: cannot use matches with a tag: {}", + self.raw_text + ), + } + } + + pub fn version_text(&self) -> &str { + &self.raw_text + } +} + +impl fmt::Display for VersionReq { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", &self.raw_text) + } +} diff --git a/cli/npm/semver/mod.rs b/cli/semver/npm.rs similarity index 79% rename from cli/npm/semver/mod.rs rename to cli/semver/npm.rs index b532835e6a..d95861b2c0 100644 --- a/cli/npm/semver/mod.rs +++ b/cli/semver/npm.rs @@ -1,29 +1,17 @@ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. -use std::cmp::Ordering; -use std::fmt; - use deno_core::anyhow::Context; use deno_core::error::AnyError; use monch::*; -use serde::Deserialize; -use serde::Serialize; -use crate::npm::resolution::NpmVersionMatcher; - -use self::range::Partial; -use self::range::VersionBoundKind; -use self::range::VersionRange; -use self::range::VersionRangeSet; - -use self::range::XRange; -pub use self::specifier::SpecifierVersionReq; - -mod range; -mod specifier; - -// A lot of the below is a re-implementation of parts of https://github.com/npm/node-semver -// which is Copyright (c) Isaac Z. Schlueter and Contributors (ISC License) +use super::Partial; +use super::RangeSetOrTag; +use super::Version; +use super::VersionBoundKind; +use super::VersionRange; +use super::VersionRangeSet; +use super::VersionReq; +use super::XRange; pub fn is_valid_npm_tag(value: &str) -> bool { // a valid tag is anything that doesn't get url encoded @@ -33,200 +21,46 @@ pub fn is_valid_npm_tag(value: &str) -> bool { .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')) } -#[derive( - Clone, Debug, PartialEq, Eq, Default, Hash, Serialize, Deserialize, -)] -pub struct NpmVersion { - pub major: u64, - pub minor: u64, - pub patch: u64, - pub pre: Vec, - pub build: Vec, +// A lot of the below is a re-implementation of parts of https://github.com/npm/node-semver +// which is Copyright (c) Isaac Z. Schlueter and Contributors (ISC License) + +pub fn parse_npm_version(text: &str) -> Result { + let text = text.trim(); + with_failure_handling(|input| { + let (input, _) = maybe(ch('='))(input)?; // skip leading = + let (input, _) = skip_whitespace(input)?; + let (input, _) = maybe(ch('v'))(input)?; // skip leading v + let (input, _) = skip_whitespace(input)?; + let (input, major) = nr(input)?; + let (input, _) = ch('.')(input)?; + let (input, minor) = nr(input)?; + let (input, _) = ch('.')(input)?; + let (input, patch) = nr(input)?; + let (input, q) = maybe(qualifier)(input)?; + let q = q.unwrap_or_default(); + + Ok(( + input, + Version { + major, + minor, + patch, + pre: q.pre, + build: q.build, + }, + )) + })(text) + .with_context(|| format!("Invalid npm version '{text}'.")) } -impl fmt::Display for NpmVersion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?; - if !self.pre.is_empty() { - write!(f, "-")?; - for (i, part) in self.pre.iter().enumerate() { - if i > 0 { - write!(f, ".")?; - } - write!(f, "{part}")?; - } - } - if !self.build.is_empty() { - write!(f, "+")?; - for (i, part) in self.build.iter().enumerate() { - if i > 0 { - write!(f, ".")?; - } - write!(f, "{part}")?; - } - } - Ok(()) - } -} - -impl std::cmp::PartialOrd for NpmVersion { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl std::cmp::Ord for NpmVersion { - fn cmp(&self, other: &Self) -> Ordering { - let cmp_result = self.major.cmp(&other.major); - if cmp_result != Ordering::Equal { - return cmp_result; - } - - let cmp_result = self.minor.cmp(&other.minor); - if cmp_result != Ordering::Equal { - return cmp_result; - } - - let cmp_result = self.patch.cmp(&other.patch); - if cmp_result != Ordering::Equal { - return cmp_result; - } - - // only compare the pre-release and not the build as node-semver does - if self.pre.is_empty() && other.pre.is_empty() { - Ordering::Equal - } else if !self.pre.is_empty() && other.pre.is_empty() { - Ordering::Less - } else if self.pre.is_empty() && !other.pre.is_empty() { - Ordering::Greater - } else { - let mut i = 0; - loop { - let a = self.pre.get(i); - let b = other.pre.get(i); - if a.is_none() && b.is_none() { - return Ordering::Equal; - } - - // https://github.com/npm/node-semver/blob/4907647d169948a53156502867ed679268063a9f/internal/identifiers.js - let a = match a { - Some(a) => a, - None => return Ordering::Less, - }; - let b = match b { - Some(b) => b, - None => return Ordering::Greater, - }; - - // prefer numbers - if let Ok(a_num) = a.parse::() { - if let Ok(b_num) = b.parse::() { - return a_num.cmp(&b_num); - } else { - return Ordering::Less; - } - } else if b.parse::().is_ok() { - return Ordering::Greater; - } - - let cmp_result = a.cmp(b); - if cmp_result != Ordering::Equal { - return cmp_result; - } - i += 1; - } - } - } -} - -impl NpmVersion { - pub fn parse(text: &str) -> Result { - let text = text.trim(); - with_failure_handling(parse_npm_version)(text) - .with_context(|| format!("Invalid npm version '{text}'.")) - } -} - -fn parse_npm_version(input: &str) -> ParseResult { - let (input, _) = maybe(ch('='))(input)?; // skip leading = - let (input, _) = skip_whitespace(input)?; - let (input, _) = maybe(ch('v'))(input)?; // skip leading v - let (input, _) = skip_whitespace(input)?; - let (input, major) = nr(input)?; - let (input, _) = ch('.')(input)?; - let (input, minor) = nr(input)?; - let (input, _) = ch('.')(input)?; - let (input, patch) = nr(input)?; - let (input, q) = maybe(qualifier)(input)?; - let q = q.unwrap_or_default(); - - Ok(( - input, - NpmVersion { - major, - minor, - patch, - pre: q.pre, - build: q.build, - }, - )) -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -enum NpmVersionReqInner { - RangeSet(VersionRangeSet), - Tag(String), -} - -/// A version requirement found in an npm package's dependencies. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct NpmVersionReq { - raw_text: String, - inner: NpmVersionReqInner, -} - -impl NpmVersionMatcher for NpmVersionReq { - fn tag(&self) -> Option<&str> { - match &self.inner { - NpmVersionReqInner::RangeSet(_) => None, - NpmVersionReqInner::Tag(tag) => Some(tag.as_str()), - } - } - - fn matches(&self, version: &NpmVersion) -> bool { - match &self.inner { - NpmVersionReqInner::RangeSet(range_set) => range_set.satisfies(version), - NpmVersionReqInner::Tag(_) => panic!( - "programming error: cannot use matches with a tag: {}", - self.raw_text - ), - } - } - - fn version_text(&self) -> String { - self.raw_text.clone() - } -} - -impl fmt::Display for NpmVersionReq { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", &self.raw_text) - } -} - -impl NpmVersionReq { - pub fn parse(text: &str) -> Result { - let text = text.trim(); - with_failure_handling(parse_npm_version_req)(text) - .with_context(|| format!("Invalid npm version requirement '{text}'.")) - } -} - -fn parse_npm_version_req(input: &str) -> ParseResult { - map(inner, |inner| NpmVersionReq { - raw_text: input.to_string(), - inner, - })(input) +pub fn parse_npm_version_req(text: &str) -> Result { + let text = text.trim(); + with_failure_handling(|input| { + map(inner, |inner| { + VersionReq::from_raw_text_and_inner(input.to_string(), inner) + })(input) + })(text) + .with_context(|| format!("Invalid npm version requirement '{text}'.")) } // https://github.com/npm/node-semver/tree/4907647d169948a53156502867ed679268063a9f#range-grammar @@ -248,11 +82,11 @@ fn parse_npm_version_req(input: &str) -> ParseResult { // part ::= nr | [-0-9A-Za-z]+ // range-set ::= range ( logical-or range ) * -fn inner(input: &str) -> ParseResult { +fn inner(input: &str) -> ParseResult { if input.is_empty() { return Ok(( input, - NpmVersionReqInner::RangeSet(VersionRangeSet(vec![VersionRange::all()])), + RangeSetOrTag::RangeSet(VersionRangeSet(vec![VersionRange::all()])), )); } @@ -263,10 +97,7 @@ fn inner(input: &str) -> ParseResult { match ranges.remove(0) { RangeOrInvalid::Invalid(invalid) => { if is_valid_npm_tag(invalid.text) { - return Ok(( - input, - NpmVersionReqInner::Tag(invalid.text.to_string()), - )); + return Ok((input, RangeSetOrTag::Tag(invalid.text.to_string()))); } else { return Err(invalid.failure); } @@ -282,7 +113,7 @@ fn inner(input: &str) -> ParseResult { .into_iter() .filter_map(|r| r.into_range()) .collect::>(); - Ok((input, NpmVersionReqInner::RangeSet(VersionRangeSet(ranges)))) + Ok((input, RangeSetOrTag::RangeSet(VersionRangeSet(ranges)))) } enum RangeOrInvalid<'a> { @@ -582,21 +413,21 @@ mod tests { use super::*; - struct NpmVersionReqTester(NpmVersionReq); + struct NpmVersionReqTester(VersionReq); impl NpmVersionReqTester { fn new(text: &str) -> Self { - Self(NpmVersionReq::parse(text).unwrap()) + Self(parse_npm_version_req(text).unwrap()) } fn matches(&self, version: &str) -> bool { - self.0.matches(&NpmVersion::parse(version).unwrap()) + self.0.matches(&parse_npm_version(version).unwrap()) } } #[test] pub fn npm_version_req_with_v() { - assert!(NpmVersionReq::parse("v1.0.0").is_ok()); + assert!(parse_npm_version_req("v1.0.0").is_ok()); } #[test] @@ -641,8 +472,8 @@ mod tests { #[test] pub fn npm_version_req_with_tag() { - let req = NpmVersionReq::parse("latest").unwrap(); - assert_eq!(req.inner, NpmVersionReqInner::Tag("latest".to_string())); + let req = parse_npm_version_req("latest").unwrap(); + assert_eq!(req.tag(), Some("latest")); } macro_rules! assert_cmp { @@ -660,8 +491,8 @@ mod tests { macro_rules! test_compare { ($a:expr, $b:expr, $expected:expr) => { - let a = NpmVersion::parse($a).unwrap(); - let b = NpmVersion::parse($b).unwrap(); + let a = parse_npm_version($a).unwrap(); + let b = parse_npm_version($b).unwrap(); assert_cmp!(a, b, $expected); }; } @@ -759,8 +590,8 @@ mod tests { ("1.2.3-r100", "1.2.3-R2"), ]; for (a, b) in fixtures { - let a = NpmVersion::parse(a).unwrap(); - let b = NpmVersion::parse(b).unwrap(); + let a = parse_npm_version(a).unwrap(); + let b = parse_npm_version(b).unwrap(); assert_cmp!(a, b, Ordering::Greater); assert_cmp!(b, a, Ordering::Less); assert_cmp!(a, a, Ordering::Equal); @@ -856,12 +687,14 @@ mod tests { (">=09090", ">=9090.0.0"), ]; for (range_text, expected) in fixtures { - let range = NpmVersionReq::parse(range_text).unwrap(); - let expected_range = NpmVersionReq::parse(expected).unwrap(); + let range = parse_npm_version_req(range_text).unwrap(); + let expected_range = parse_npm_version_req(expected).unwrap(); assert_eq!( - range.inner, expected_range.inner, + range.inner(), + expected_range.inner(), "failed for {} and {}", - range_text, expected + range_text, + expected ); } } @@ -980,8 +813,8 @@ mod tests { ("1.0.0-alpha.13", "1.0.0-alpha.13"), ]; for (req_text, version_text) in fixtures { - let req = NpmVersionReq::parse(req_text).unwrap(); - let version = NpmVersion::parse(version_text).unwrap(); + let req = parse_npm_version_req(req_text).unwrap(); + let version = parse_npm_version(version_text).unwrap(); assert!( req.matches(&version), "Checking {req_text} satisfies {version_text}" @@ -1077,8 +910,8 @@ mod tests { ]; for (req_text, version_text) in fixtures { - let req = NpmVersionReq::parse(req_text).unwrap(); - let version = NpmVersion::parse(version_text).unwrap(); + let req = parse_npm_version_req(req_text).unwrap(); + let version = parse_npm_version(version_text).unwrap(); assert!( !req.matches(&version), "Checking {req_text} not satisfies {version_text}" @@ -1123,8 +956,8 @@ mod tests { ]; for (req_text, version_text, satisfies) in fixtures { - let req = NpmVersionReq::parse(req_text).unwrap(); - let version = NpmVersion::parse(version_text).unwrap(); + let req = parse_npm_version_req(req_text).unwrap(); + let version = parse_npm_version(version_text).unwrap(); assert_eq!( req.matches(&version), *satisfies, @@ -1146,7 +979,7 @@ mod tests { for req_text in fixtures { // when it has no space, this is considered invalid // by node semver so we should error - assert!(NpmVersionReq::parse(req_text).is_err()); + assert!(parse_npm_version_req(req_text).is_err()); } } } diff --git a/cli/npm/semver/range.rs b/cli/semver/range.rs similarity index 91% rename from cli/npm/semver/range.rs rename to cli/semver/range.rs index 07ee2d62a6..ab202b60e1 100644 --- a/cli/npm/semver/range.rs +++ b/cli/semver/range.rs @@ -5,14 +5,14 @@ use std::cmp::Ordering; use serde::Deserialize; use serde::Serialize; -use super::NpmVersion; +use super::Version; /// Collection of ranges. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct VersionRangeSet(pub Vec); impl VersionRangeSet { - pub fn satisfies(&self, version: &NpmVersion) -> bool { + pub fn satisfies(&self, version: &Version) -> bool { self.0.iter().any(|r| r.satisfies(version)) } } @@ -24,15 +24,15 @@ pub enum RangeBound { } impl RangeBound { - pub fn inclusive(version: NpmVersion) -> Self { + pub fn inclusive(version: Version) -> Self { Self::version(VersionBoundKind::Inclusive, version) } - pub fn exclusive(version: NpmVersion) -> Self { + pub fn exclusive(version: Version) -> Self { Self::version(VersionBoundKind::Exclusive, version) } - pub fn version(kind: VersionBoundKind, version: NpmVersion) -> Self { + pub fn version(kind: VersionBoundKind, version: Version) -> Self { Self::Version(VersionBound::new(kind, version)) } @@ -79,7 +79,7 @@ impl RangeBound { pub fn has_pre_with_exact_major_minor_patch( &self, - version: &NpmVersion, + version: &Version, ) -> bool { if let RangeBound::Version(self_version) = &self { if !self_version.version.pre.is_empty() @@ -103,11 +103,11 @@ pub enum VersionBoundKind { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct VersionBound { pub kind: VersionBoundKind, - pub version: NpmVersion, + pub version: Version, } impl VersionBound { - pub fn new(kind: VersionBoundKind, version: NpmVersion) -> Self { + pub fn new(kind: VersionBoundKind, version: Version) -> Self { Self { kind, version } } } @@ -123,7 +123,7 @@ impl VersionRange { VersionRange { start: RangeBound::Version(VersionBound { kind: VersionBoundKind::Inclusive, - version: NpmVersion::default(), + version: Version::default(), }), end: RangeBound::Unbounded, } @@ -133,11 +133,11 @@ impl VersionRange { VersionRange { start: RangeBound::Version(VersionBound { kind: VersionBoundKind::Inclusive, - version: NpmVersion::default(), + version: Version::default(), }), end: RangeBound::Version(VersionBound { kind: VersionBoundKind::Exclusive, - version: NpmVersion::default(), + version: Version::default(), }), } } @@ -154,7 +154,7 @@ impl VersionRange { } } - pub fn satisfies(&self, version: &NpmVersion) -> bool { + pub fn satisfies(&self, version: &Version) -> bool { let satisfies = self.min_satisfies(version) && self.max_satisfies(version); if satisfies && !version.pre.is_empty() { // check either side of the range has a pre and same version @@ -165,7 +165,7 @@ impl VersionRange { } } - fn min_satisfies(&self, version: &NpmVersion) -> bool { + fn min_satisfies(&self, version: &Version) -> bool { match &self.start { RangeBound::Unbounded => true, RangeBound::Version(bound) => match version.cmp(&bound.version) { @@ -176,7 +176,7 @@ impl VersionRange { } } - fn max_satisfies(&self, version: &NpmVersion) -> bool { + fn max_satisfies(&self, version: &Version) -> bool { match &self.end { RangeBound::Unbounded => true, RangeBound::Version(bound) => match version.cmp(&bound.version) { @@ -219,14 +219,14 @@ impl Partial { let end = match self.major { XRange::Wildcard => return VersionRange::all(), XRange::Val(major) => match self.minor { - XRange::Wildcard => NpmVersion { + XRange::Wildcard => Version { major: major + 1, minor: 0, patch: 0, pre: Vec::new(), build: Vec::new(), }, - XRange::Val(minor) => NpmVersion { + XRange::Val(minor) => Version { major, minor: minor + 1, patch: 0, @@ -248,7 +248,7 @@ impl Partial { let end = match self.major { XRange::Wildcard => return VersionRange::all(), XRange::Val(major) => { - let next_major = NpmVersion { + let next_major = Version { major: major + 1, ..Default::default() }; @@ -258,7 +258,7 @@ impl Partial { match self.minor { XRange::Wildcard => next_major, XRange::Val(minor) => { - let next_minor = NpmVersion { + let next_minor = Version { minor: minor + 1, ..Default::default() }; @@ -267,7 +267,7 @@ impl Partial { } else { match self.patch { XRange::Wildcard => next_minor, - XRange::Val(patch) => NpmVersion { + XRange::Val(patch) => Version { patch: patch + 1, ..Default::default() }, @@ -288,7 +288,7 @@ impl Partial { } pub fn as_lower_bound(&self) -> RangeBound { - RangeBound::inclusive(NpmVersion { + RangeBound::inclusive(Version { major: match self.major { XRange::Val(val) => val, XRange::Wildcard => 0, @@ -307,7 +307,7 @@ impl Partial { } pub fn as_upper_bound(&self) -> RangeBound { - let mut end = NpmVersion::default(); + let mut end = Version::default(); let mut kind = VersionBoundKind::Inclusive; match self.patch { XRange::Wildcard => { @@ -363,7 +363,7 @@ impl Partial { } XRange::Val(val) => val, }; - let version = NpmVersion { + let version = Version { major, minor, patch, @@ -387,7 +387,7 @@ impl Partial { }, XRange::Val(major) => major, }; - let mut start = NpmVersion::default(); + let mut start = Version::default(); if start_kind == VersionBoundKind::Inclusive { start.pre = self.pre.clone(); @@ -431,7 +431,7 @@ impl Partial { }, XRange::Val(major) => major, }; - let mut end = NpmVersion { + let mut end = Version { major, ..Default::default() }; @@ -471,8 +471,8 @@ impl Partial { XRange::Wildcard => return VersionRange::all(), XRange::Val(major) => major, }; - let mut start = NpmVersion::default(); - let mut end = NpmVersion::default(); + let mut start = Version::default(); + let mut end = Version::default(); start.major = major; end.major = major; match self.patch { diff --git a/cli/npm/semver/specifier.rs b/cli/semver/specifier.rs similarity index 72% rename from cli/npm/semver/specifier.rs rename to cli/semver/specifier.rs index b12a5c3088..8edb4cddd2 100644 --- a/cli/npm/semver/specifier.rs +++ b/cli/semver/specifier.rs @@ -3,85 +3,56 @@ use deno_core::anyhow::Context; use deno_core::error::AnyError; use monch::*; -use serde::Deserialize; -use serde::Serialize; -use super::is_valid_npm_tag; use super::range::Partial; use super::range::VersionRange; +use super::range::VersionRangeSet; use super::range::XRange; +use super::RangeSetOrTag; +use super::VersionReq; -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -enum SpecifierVersionReqInner { - Range(VersionRange), - Tag(String), -} +use super::is_valid_tag; -/// Version requirement found in npm specifiers. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SpecifierVersionReq { - raw_text: String, - inner: SpecifierVersionReqInner, -} - -impl std::fmt::Display for SpecifierVersionReq { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.raw_text) - } -} - -impl SpecifierVersionReq { - pub fn parse(text: &str) -> Result { - with_failure_handling(parse_npm_specifier)(text).with_context(|| { - format!("Invalid npm specifier version requirement '{text}'.") - }) - } - - pub fn range(&self) -> Option<&VersionRange> { - match &self.inner { - SpecifierVersionReqInner::Range(range) => Some(range), - SpecifierVersionReqInner::Tag(_) => None, - } - } - - pub fn tag(&self) -> Option<&str> { - match &self.inner { - SpecifierVersionReqInner::Range(_) => None, - SpecifierVersionReqInner::Tag(tag) => Some(tag.as_str()), - } - } -} - -fn parse_npm_specifier(input: &str) -> ParseResult { - map_res(version_range, |result| { - let (new_input, range_result) = match result { - Ok((input, range)) => (input, Ok(range)), - // use an empty string because we'll consider it a tag - Err(err) => ("", Err(err)), - }; - Ok(( - new_input, - SpecifierVersionReq { - raw_text: input.to_string(), - inner: match range_result { - Ok(range) => SpecifierVersionReqInner::Range(range), - Err(err) => { - if !is_valid_npm_tag(input) { - return Err(err); - } else { - SpecifierVersionReqInner::Tag(input.to_string()) +pub fn parse_version_req_from_specifier( + text: &str, +) -> Result { + with_failure_handling(|input| { + map_res(version_range, |result| { + let (new_input, range_result) = match result { + Ok((input, range)) => (input, Ok(range)), + // use an empty string because we'll consider it a tag + Err(err) => ("", Err(err)), + }; + Ok(( + new_input, + VersionReq::from_raw_text_and_inner( + input.to_string(), + match range_result { + Ok(range) => RangeSetOrTag::RangeSet(VersionRangeSet(vec![range])), + Err(err) => { + if !is_valid_tag(input) { + return Err(err); + } else { + RangeSetOrTag::Tag(input.to_string()) + } } - } - }, - }, - )) - })(input) + }, + ), + )) + })(input) + })(text) + .with_context(|| { + format!("Invalid npm specifier version requirement '{text}'.") + }) } // Note: Although the code below looks very similar to what's used for // parsing npm version requirements, the code here is more strict // in order to not allow for people to get ridiculous when using // npm specifiers. +// +// A lot of the code below is adapted from https://github.com/npm/node-semver +// which is Copyright (c) Isaac Z. Schlueter and Contributors (ISC License) // version_range ::= partial | tilde | caret fn version_range(input: &str) -> ParseResult { @@ -198,23 +169,18 @@ fn part(input: &str) -> ParseResult<&str> { #[cfg(test)] mod tests { - use crate::npm::semver::NpmVersion; - + use super::super::Version; use super::*; - struct VersionReqTester(SpecifierVersionReq); + struct VersionReqTester(VersionReq); impl VersionReqTester { fn new(text: &str) -> Self { - Self(SpecifierVersionReq::parse(text).unwrap()) + Self(parse_version_req_from_specifier(text).unwrap()) } fn matches(&self, version: &str) -> bool { - self - .0 - .range() - .map(|r| r.satisfies(&NpmVersion::parse(version).unwrap())) - .unwrap_or(false) + self.0.matches(&Version::parse_from_npm(version).unwrap()) } } @@ -293,7 +259,7 @@ mod tests { #[test] fn parses_tag() { - let latest_tag = SpecifierVersionReq::parse("latest").unwrap(); + let latest_tag = VersionReq::parse_from_specifier("latest").unwrap(); assert_eq!(latest_tag.tag().unwrap(), "latest"); } }