From bd18e54ccb77ecd595b4b4051c60701bcec30cbd Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Tue, 15 Apr 2025 10:59:34 -0700 Subject: [PATCH 1/5] Add the concept of constraints (#14) --- .github/workflows/check-static.yml | 2 +- Cargo.toml | 11 +- src/constraint.rs | 406 +++++++++++++++++++++++++++++ src/constraint/fallback.rs | 127 +++++++++ src/error.rs | 18 ++ src/lib.rs | 29 ++- src/locator.rs | 8 +- src/locator_package.rs | 4 +- src/locator_strict.rs | 4 +- 9 files changed, 594 insertions(+), 15 deletions(-) create mode 100644 src/constraint.rs create mode 100644 src/constraint/fallback.rs diff --git a/.github/workflows/check-static.yml b/.github/workflows/check-static.yml index 87aec52..3c5d493 100644 --- a/.github/workflows/check-static.yml +++ b/.github/workflows/check-static.yml @@ -10,4 +10,4 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo fmt --all -- --check - - run: cargo clippy --all-features --all --tests -- -D clippy::all + - run: cargo clippy --all-features --all --tests -- -D clippy::correctness diff --git a/Cargo.toml b/Cargo.toml index eaed58b..3233494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [package] name = "locator" -version = "2.3.0" -edition = "2021" +version = "2.4.0" +edition = "2024" [dependencies] alphanumeric-sort = "1.5.3" getset = "0.1.2" pretty_assertions = "1.4.0" serde = { version = "1.0.140", features = ["derive"] } -strum = { version = "0.24.1", features = ["derive"] } +strum = { version = "0.27.1", features = ["derive"] } thiserror = "1.0.31" utoipa = "4.2.3" serde_json = "1.0.95" @@ -17,11 +17,16 @@ semver = "1.0.23" bon = "2.3.0" duplicate = "2.0.0" lazy-regex = { version = "3.3.0", features = ["regex"] } +enum-assoc = "1.2.4" +unicase = "2.8.1" +lexical-sort = "0.3.1" +derive-new = "0.7.0" [dev-dependencies] assert_matches = "1.5.0" impls = "1.0.3" itertools = "0.10.5" +pretty_assertions = "1.4.0" proptest = "1.0.0" simple_test_case = "1.2.0" static_assertions = "1.1.0" diff --git a/src/constraint.rs b/src/constraint.rs new file mode 100644 index 0000000..3c40aff --- /dev/null +++ b/src/constraint.rs @@ -0,0 +1,406 @@ +use derive_new::new; +use documented::Documented; +use enum_assoc::Assoc; +use serde::{Deserialize, Serialize}; +use strum::Display; +use utoipa::ToSchema; + +use crate::{Fetcher, Revision}; + +mod fallback; + +/// Describes version constraints supported by this crate. +/// +/// Note that different fetchers may interpret these constraints in different ways- +/// for example `compatible` isn't the same in Cargo as it is in Cabal. +/// +/// # Serialization +/// +/// The default serialization for this type is for transporting _this type_; +/// it is not meant to support parsing the actual constraints in the native format +/// used by the package manager. +/// +/// # Comparison +/// +/// Compares the [`Constraint`] to the given [`Revision`] according to the rules of the provided [`Fetcher`]. +/// +/// If there are no rules for that specific fetcher, the following fallbacks take place: +/// - If both the `Constraint` and the `Revision` are parseable as semver, compare according to semver rules. +/// - Otherwise, they are coerced to an opaque string and compared according to unicode ordering rules. +/// +/// When comparing according to unicode: +/// - `Equal` and `NotEqual` compare bytewise. +/// - `Compatible` compares case insensitively, folding non-ASCII characters into their closest ASCII equivalent prior to comparing. +/// - All other variants compare lexically, folding non-ASCII characters into their closest ASCII equivalent prior to comparing. +/// +/// In practice this means that `Equal` and `NotEqual` are case-sensitive, while all other options are not; +/// this case-insensitivity does its best to preserve the spirit of this intent in the face of non-ascii inputs. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Deserialize, + Serialize, + Documented, + ToSchema, + Display, + Assoc, + new, +)] +#[schema(example = json!({ "kind": "equal", "value": "1.0.0"}))] +#[serde(rename_all = "snake_case", tag = "kind", content = "value")] +#[func(const fn revision(&self) -> &Revision)] +#[non_exhaustive] +pub enum Constraint { + /// The comparing revision must be compatible with the provided revision. + /// Note that the exact semantics of this constraint depend on the fetcher. + #[strum(to_string = "~={0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + Compatible(Revision), + + /// The comparing revision must be exactly equal to the provided revision. + #[strum(to_string = "=={0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + Equal(Revision), + + /// The comparing revision must not be exactly equal to the provided revision. + #[strum(to_string = "!={0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + NotEqual(Revision), + + /// The comparing revision must be less than the provided revision. + #[strum(to_string = "<{0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + Less(Revision), + + /// The comparing revision must be less than or equal to the provided revision. + #[strum(to_string = "<={0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + LessOrEqual(Revision), + + /// The comparing revision must be greater than the provided revision. + #[strum(to_string = ">{0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + Greater(Revision), + + /// The comparing revision must be greater than or equal to the provided revision. + #[strum(to_string = ">={0:?}")] + #[assoc(revision = &_0)] + #[new(into)] + GreaterOrEqual(Revision), +} + +impl Constraint { + /// Compare the constraint to the target revision according to the rules of the provided fetcher. + /// + /// The default if there are no additional rules specified for the fetcher is: + /// - If both versions are semver, compare according to semver rules. + /// - If not, coerce them both to an opaque string and compare according to unicode ordering rules. + /// In this instance [`Constraint::Compatible`] is a case-insensitive equality comparison. + pub fn compare(&self, fetcher: Fetcher, target: &Revision) -> bool { + match fetcher { + // If no specific comparitor is configured for this fetcher, + // compare using the generic fallback. + other => fallback::compare(self, other, target), + } + } +} + +impl From<&Constraint> for Constraint { + fn from(c: &Constraint) -> Self { + c.clone() + } +} + +impl AsRef for Constraint { + fn as_ref(&self) -> &Constraint { + self + } +} + +/// A set of [`Constraint`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Documented, ToSchema)] +#[non_exhaustive] +pub struct Constraints(Vec); + +impl Constraints { + /// Iterate over constraints in the set. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Compare the constraints to the target revision according to the rules of the provided fetcher. + /// + /// This method compares in an `AND` fashion: it only returns true if _all_ constraints + /// inside of this set compare favorably. + pub fn compare_all(&self, fetcher: Fetcher, target: &Revision) -> bool { + for constraint in self.iter() { + if !constraint.compare(fetcher, target) { + return false; + } + } + true + } + + /// Compare the constraints to the target revision according to the rules of the provided fetcher. + /// + /// This method compares in an `OR` fashion: it returns true if _any_ constraint + /// inside of this set compare favorably. + pub fn compare_any(&self, fetcher: Fetcher, target: &Revision) -> bool { + for constraint in self.iter() { + if constraint.compare(fetcher, target) { + return true; + } + } + false + } +} + +impl From for Constraints +where + I: IntoIterator, + T: Into, +{ + fn from(constraints: I) -> Self { + Self(constraints.into_iter().map(Into::into).collect()) + } +} + +impl From for Constraints { + fn from(constraint: Constraint) -> Self { + Self(vec![constraint]) + } +} + +impl From<&Constraint> for Constraints { + fn from(constraint: &Constraint) -> Self { + constraint.clone().into() + } +} + +impl AsRef for Constraints { + fn as_ref(&self) -> &Constraints { + self + } +} + +impl crate::Locator { + /// Compare the target constraints to this locator's revision, according to the rules of its fetcher. + /// + /// This method compares in an `AND` fashion: it only returns true if _all_ constraints + /// inside of this set compare favorably. + /// + /// If the locator does not have a revision component, this comparison automaticlly fails; + /// it's assumed that if there are constraints of any kind, they can't possibly be validated. + pub fn compare_all(&self, constraints: impl AsRef) -> bool { + match self.revision().as_ref() { + Some(target) => constraints.as_ref().compare_all(self.fetcher(), target), + None => false, + } + } + + /// Compare the constraints to the target revision according to the rules of the provided fetcher. + /// + /// This method compares in an `OR` fashion: it returns true if _any_ constraint + /// inside of this set compare favorably. + pub fn compare_any(&self, constraints: impl AsRef) -> bool { + match self.revision().as_ref() { + Some(target) => constraints.as_ref().compare_any(self.fetcher(), target), + None => false, + } + } + + /// Compare the constraint to the target revision according to the rules of the provided fetcher. + pub fn compare(&self, constraint: impl AsRef) -> bool { + match self.revision().as_ref() { + Some(target) => constraint.as_ref().compare(self.fetcher(), target), + None => false, + } + } +} + +impl crate::StrictLocator { + /// Compare the target constraints to this locator's revision, according to the rules of its fetcher. + /// + /// This method compares in an `AND` fashion: it only returns true if _all_ constraints + /// inside of this set compare favorably. + pub fn compare_all(&self, constraints: impl AsRef) -> bool { + let (fetcher, revision) = (self.fetcher(), self.revision()); + constraints.as_ref().compare_all(fetcher, revision) + } + + /// Compare the constraints to the target revision according to the rules of the provided fetcher. + /// + /// This method compares in an `OR` fashion: it returns true if _any_ constraint + /// inside of this set compare favorably. + pub fn compare_any(&self, constraints: impl AsRef) -> bool { + let (fetcher, revision) = (self.fetcher(), self.revision()); + constraints.as_ref().compare_any(fetcher, revision) + } + + /// Compare the constraint to the target revision according to the rules of the provided fetcher. + pub fn compare(&self, constraint: impl AsRef) -> bool { + let (fetcher, revision) = (self.fetcher(), self.revision()); + constraint.as_ref().compare(fetcher, revision) + } +} + +/// Construct a [`Constraint`], guaranteed to be valid at compile time. +/// +/// ``` +/// # use locator::{Constraint, Revision, semver::Version}; +/// let constraint = locator::constraint!(Compatible => 1, 0, 0); +/// let expected = Constraint::Compatible(Revision::Semver(Version::new(1, 0, 0))); +/// assert_eq!(constraint, expected); +/// +/// let constraint = locator::constraint!(Equal => "abcd1234"); +/// let expected = Constraint::Equal(Revision::Opaque(String::from("abcd1234"))); +/// assert_eq!(constraint, expected); +/// ``` +#[macro_export] +macro_rules! constraint { + ($variant:ident => $major:literal, $minor:literal, $patch:literal) => { + $crate::Constraint::$variant($crate::Revision::Semver($crate::semver::Version::new( + $major, $minor, $patch, + ))) + }; + ($variant:ident => $opaque:literal) => { + $crate::Constraint::$variant($crate::Revision::Opaque($opaque.into())) + }; +} + +/// Construct multiple [`Constraints`], guaranteed to be valid at compile time. +/// +/// ``` +/// # use locator::{Constraint, Constraints, Revision, semver::Version}; +/// let constraint = locator::constraints!( +/// Compatible => 1, 0, 0; +/// Compatible => 1, 1, 0; +/// ); +/// let expected = Constraints::from(vec![ +/// Constraint::Compatible(Revision::Semver(Version::new(1, 0, 0))), +/// Constraint::Compatible(Revision::Semver(Version::new(1, 1, 0))), +/// ]); +/// assert_eq!(constraint, expected); +/// +/// let constraint = locator::constraints!( +/// Equal => "abcd1234"; +/// Equal => "abcd12345"; +/// ); +/// let expected = Constraints::from(vec![ +/// Constraint::Equal(Revision::Opaque(String::from("abcd1234"))), +/// Constraint::Equal(Revision::Opaque(String::from("abcd12345"))), +/// ]); +/// assert_eq!(constraint, expected); +/// ``` +#[macro_export] +macro_rules! constraints { + ($($variant:ident => $major:literal, $minor:literal, $patch:literal);* $(;)?) => { + Constraints::from(vec![ + $( + $crate::Constraint::$variant($crate::Revision::Semver($crate::semver::Version::new( + $major, $minor, $patch, + ))) + ),* + ]) + }; + ($($variant:ident => $opaque:literal);* $(;)?) => { + Constraints::from(vec![ + $( + $crate::Constraint::$variant($crate::Revision::Opaque($opaque.into())) + ),* + ]) + }; +} + +#[cfg(test)] +mod tests { + use simple_test_case::test_case; + + use super::*; + use crate::{Locator, StrictLocator, locator, strict}; + + // Tests in this module use a fetcher that is unlikely to ever have actual comparison functionality + // so that it uses the fallback. The tests here are mostly meant to test that the fetcher + // actually uses the comparison method, not so much the comparison itself. + + #[test_case(constraint!(Compatible => 1, 2, 3), locator!(Archive, "pkg", "1.2.4"); "1.2.4_compatible_1.2.3")] + #[test_case(constraint!(Equal => 1, 2, 3), locator!(Archive, "pkg", "1.2.3"); "1.2.3_equal_1.2.3")] + #[test_case(constraint!(NotEqual => 1, 2, 3), locator!(Archive, "pkg", "1.2.4"); "1.2.4_notequal_1.2.3")] + #[test_case(constraint!(Less => 1, 2, 3), locator!(Archive, "pkg", "1.2.2"); "1.2.2_less_1.2.3")] + #[test_case(constraint!(LessOrEqual => 1, 2, 3), locator!(Archive, "pkg", "1.2.2"); "1.2.2_less_or_equal_1.2.3")] + #[test_case(constraint!(Greater => 1, 2, 3), locator!(Archive, "pkg", "1.2.4"); "1.2.4_greater_1.2.3")] + #[test_case(constraint!(GreaterOrEqual => 1, 2, 3), locator!(Archive, "pkg", "1.2.4"); "1.2.4_greater_or_equal_1.2.3")] + #[test] + fn constraint_locator(constraint: Constraint, target: Locator) { + assert!( + target.compare(&constraint), + "compare '{target}' to '{constraint}'" + ); + } + + #[test_case(constraint!(Compatible => 1, 2, 3), strict!(Archive, "pkg", "1.2.4"); "1.2.4_compatible_1.2.3")] + #[test_case(constraint!(Equal => 1, 2, 3), strict!(Archive, "pkg", "1.2.3"); "1.2.3_equal_1.2.3")] + #[test_case(constraint!(NotEqual => 1, 2, 3), strict!(Archive, "pkg", "1.2.4"); "1.2.4_notequal_1.2.3")] + #[test_case(constraint!(Less => 1, 2, 3), strict!(Archive, "pkg", "1.2.2"); "1.2.2_less_1.2.3")] + #[test_case(constraint!(LessOrEqual => 1, 2, 3), strict!(Archive, "pkg", "1.2.2"); "1.2.2_less_or_equal_1.2.3")] + #[test_case(constraint!(Greater => 1, 2, 3), strict!(Archive, "pkg", "1.2.4"); "1.2.4_greater_1.2.3")] + #[test_case(constraint!(GreaterOrEqual => 1, 2, 3), strict!(Archive, "pkg", "1.2.4"); "1.2.4_greater_or_equal_1.2.3")] + #[test] + fn constraint_strict_locator(constraint: Constraint, target: StrictLocator) { + assert!( + target.compare(&constraint), + "compare '{target}' to '{constraint}'" + ); + } + + #[test_case(constraints!(Compatible => 2, 2, 3; Compatible => 1, 2, 3), locator!(Archive, "pkg", "1.2.4"); "1.2.4_compatible_1.2.3_or_2.2.3")] + #[test_case(constraints!(Equal => "abcd"; Compatible => "abcde"), locator!(Archive, "pkg", "abcde"); "abcde_equal_abcd_or_compatible_abcde")] + #[test] + fn constraints_locator_any(constraints: Constraints, target: Locator) { + assert!( + target.compare_any(&constraints), + "compare '{target}' to '{constraints:?}'" + ); + } + + #[test_case(constraints!(Greater => 1, 2, 3; Less => 2, 0, 0), locator!(Archive, "pkg", "1.2.4"); "1.2.4_greater_1.2.3_and_less_2.0.0")] + #[test_case(constraints!(Less => "abcd"; Greater => "bbbb"), locator!(Archive, "pkg", "abce"); "abce_greater_abcd_and_less_bbbb")] + #[test] + fn constraints_locator_all(constraints: Constraints, target: Locator) { + assert!( + target.compare_all(&constraints), + "compare '{target}' to '{constraints:?}'" + ); + } + + #[test_case(constraints!(Compatible => 2, 2, 3; Compatible => 1, 2, 3), strict!(Archive, "pkg", "1.2.4"); "1.2.4_compatible_1.2.3_or_2.2.3")] + #[test_case(constraints!(Equal => "abcd"; Compatible => "abcde"), strict!(Archive, "pkg", "abcde"); "abcde_equal_abcd_or_compatible_abcde")] + #[test] + fn constraints_strict_locator_any(constraints: Constraints, target: StrictLocator) { + assert!( + target.compare_any(&constraints), + "compare '{target}' to '{constraints:?}'" + ); + } + + #[test_case(constraints!(Greater => 1, 2, 3; Less => 2, 0, 0), strict!(Archive, "pkg", "1.2.4"); "1.2.4_greater_1.2.3_and_less_2.0.0")] + #[test_case(constraints!(Less => "abcd"; Greater => "bbbb"), strict!(Archive, "pkg", "abce"); "abce_greater_abcd_and_less_bbbb")] + #[test] + fn constraints_strict_locator_all(constraints: Constraints, target: StrictLocator) { + assert!( + target.compare_all(&constraints), + "compare '{target}' to '{constraints:?}'" + ); + } +} diff --git a/src/constraint/fallback.rs b/src/constraint/fallback.rs new file mode 100644 index 0000000..32d7b77 --- /dev/null +++ b/src/constraint/fallback.rs @@ -0,0 +1,127 @@ +use std::cmp::Ordering; + +use semver::{Comparator, Op, Version}; +use unicase::UniCase; + +use crate::{Fetcher, Revision}; + +use super::Constraint; + +/// Fallback comparison when there is no fetcher-specific comparison. +/// +/// The default if there are no additional rules specified for the fetcher is: +/// - If both versions are semver, compare according to semver rules. +/// In this instance [`Constraint::Compatible`] is equivalent to a caret rule. +/// - If not, coerce them both to an opaque string and compare according to unicode ordering rules. +/// In this instance [`Constraint::Compatible`] is a case-insensitive equality comparison. +pub fn compare(constraint: &Constraint, _: Fetcher, rev: &Revision) -> bool { + if let (Revision::Semver(c), Revision::Semver(r)) = (constraint.revision(), rev) { + return match constraint { + Constraint::Compatible(_) => comparator(Op::Tilde, c).matches(r), + Constraint::Equal(_) => comparator(Op::Exact, c).matches(r), + Constraint::NotEqual(_) => !comparator(Op::Exact, c).matches(r), + Constraint::Less(_) => comparator(Op::Less, c).matches(r), + Constraint::LessOrEqual(_) => comparator(Op::LessEq, c).matches(r), + Constraint::Greater(_) => comparator(Op::Greater, c).matches(r), + Constraint::GreaterOrEqual(_) => comparator(Op::GreaterEq, c).matches(r), + }; + } + + match constraint { + Constraint::Compatible(c) => UniCase::new(c.as_str()) == UniCase::new(rev.as_str()), + _ => lexically_compare(constraint, rev), + } +} + +fn comparator(op: Op, v: &Version) -> Comparator { + Comparator { + op, + major: v.major, + minor: Some(v.minor), + patch: Some(v.patch), + pre: v.pre.clone(), + } +} + +fn ords_for(constraint: &Constraint) -> Vec { + match constraint { + Constraint::Compatible(_) => vec![Ordering::Equal], + Constraint::Equal(_) => vec![Ordering::Equal], + Constraint::NotEqual(_) => vec![Ordering::Less, Ordering::Greater], + Constraint::Less(_) => vec![Ordering::Less], + Constraint::LessOrEqual(_) => vec![Ordering::Less, Ordering::Equal], + Constraint::Greater(_) => vec![Ordering::Greater], + Constraint::GreaterOrEqual(_) => vec![Ordering::Greater, Ordering::Equal], + } +} + +fn lexically_compare(constraint: &Constraint, other: impl ToString) -> bool { + let target = constraint.revision().to_string(); + let other = other.to_string(); + for ord in ords_for(constraint) { + if lexical_sort::lexical_cmp(&target, &other) == ord { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use simple_test_case::test_case; + + use super::*; + use crate::{Revision, constraint}; + + // Using a fetcher that is unlikely to ever have actual comparison functionality. + const FETCHER: Fetcher = Fetcher::Archive; + + #[test_case(constraint!(Compatible => 1, 2, 3), Revision::from("1.2.3"), true; "1.2.3_compatible_1.2.3")] + #[test_case(constraint!(Compatible => 1, 2, 3), Revision::from("1.2.4"), true; "1.2.4_compatible_1.2.3")] + #[test_case(constraint!(Compatible => 1, 2, 3), Revision::from("2.0.0"), false; "2.0.0_not_compatible_1.2.3")] + #[test_case(constraint!(Equal => 1, 2, 3), Revision::from("1.2.3"), true; "1.2.3_equal_1.2.3")] + #[test_case(constraint!(Equal => 1, 2, 3), Revision::from("1.2.4"), false; "1.2.4_not_equal_1.2.3")] + #[test_case(constraint!(NotEqual => 1, 2, 3), Revision::from("1.2.3"), false; "1.2.3_not_notequal_1.2.3")] + #[test_case(constraint!(NotEqual => 1, 2, 3), Revision::from("1.2.4"), true; "1.2.4_notequal_1.2.3")] + #[test_case(constraint!(Less => 1, 2, 3), Revision::from("1.2.2"), true; "1.2.2_less_1.2.3")] + #[test_case(constraint!(Less => 1, 2, 3), Revision::from("1.2.3"), false; "1.2.3_not_less_1.2.3")] + #[test_case(constraint!(LessOrEqual => 1, 2, 3), Revision::from("1.2.2"), true; "1.2.2_less_or_equal_1.2.3")] + #[test_case(constraint!(LessOrEqual => 1, 2, 3), Revision::from("1.2.3"), true; "1.2.3_less_or_equal_1.2.3")] + #[test_case(constraint!(LessOrEqual => 1, 2, 3), Revision::from("1.2.4"), false; "1.2.4_not_less_or_equal_1.2.3")] + #[test_case(constraint!(Greater => 1, 2, 3), Revision::from("1.2.4"), true; "1.2.4_greater_1.2.3")] + #[test_case(constraint!(Greater => 1, 2, 3), Revision::from("1.2.3"), false; "1.2.3_not_greater_1.2.3")] + #[test_case(constraint!(GreaterOrEqual => 1, 2, 3), Revision::from("1.2.4"), true; "1.2.4_greater_or_equal_1.2.3")] + #[test_case(constraint!(GreaterOrEqual => 1, 2, 3), Revision::from("1.2.3"), true; "1.2.3_greater_or_equal_1.2.3")] + #[test_case(constraint!(GreaterOrEqual => 1, 2, 3), Revision::from("1.2.2"), false; "1.2.2_not_greater_or_equal_1.2.3")] + #[test] + fn compare_semver(constraint: Constraint, target: Revision, expected: bool) { + assert_eq!( + compare(&constraint, FETCHER, &target), + expected, + "compare '{target}' to '{constraint}', expected: {expected}" + ); + } + + #[test_case(constraint!(Compatible => "abcd"), Revision::from("AbCd"), true; "abcd_compatible_AbCd")] + #[test_case(constraint!(Compatible => "abcd"), Revision::from("AbCdE"), false; "abcd_not_compatible_AbCdE")] + #[test_case(constraint!(Equal => "abcd"), Revision::from("abcd"), true; "abcd_equal_abcd")] + #[test_case(constraint!(Equal => "abcd"), Revision::from("aBcD"), false; "abcd_not_equal_aBcD")] + #[test_case(constraint!(NotEqual => "abcd"), Revision::from("abcde"), true; "abcd_notequal_abcde")] + #[test_case(constraint!(NotEqual => "abcd"), Revision::from("abcd"), false; "abcd_not_notequal_abcd")] + #[test_case(constraint!(Less => "a"), Revision::from("b"), true; "a_less_b")] + #[test_case(constraint!(Less => "a"), Revision::from("a"), false; "a_not_less_a")] + #[test_case(constraint!(Greater => "b"), Revision::from("a"), true; "b_greater_a")] + #[test_case(constraint!(Greater => "b"), Revision::from("c"), false; "b_not_greater_c")] + #[test_case(constraint!(Less => "あ"), Revision::from("え"), true; "jp_a_less_e")] + #[test_case(constraint!(Greater => "え"), Revision::from("あ"), true; "jp_e_greater_a")] + #[test_case(constraint!(Equal => "あ"), Revision::from("あ"), true; "jp_a_equal_a")] + #[test_case(constraint!(Compatible => "Maße"), Revision::from("MASSE"), true; "gr_masse_compatible_MASSE")] + #[test] + fn compare_opaque(constraint: Constraint, target: Revision, expected: bool) { + assert_eq!( + compare(&constraint, FETCHER, &target), + expected, + "compare '{target}' to '{constraint}', expected: {expected}" + ); + } +} diff --git a/src/error.rs b/src/error.rs index 6cb1d85..6412f2a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,6 +7,10 @@ pub enum Error { /// Errors encountered while parsing a [`Locator`](crate::Locator). #[error(transparent)] Parse(#[from] ParseError), + + /// Errors encountered while parsing a [`Constraint`](crate::Constraint). + #[error(transparent)] + ParseConstraint(#[from] ConstraintParseError), } /// Errors encountered when parsing a [`Locator`](crate::Locator) from a string. @@ -82,3 +86,17 @@ pub enum PackageParseError { field: String, }, } + +/// Errors encountered when parsing a [`Revision`](crate::Revision) from a string. +#[derive(Error, Clone, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum RevisionParseError { + // No possible errors yet, but I'm sure there will be. +} + +/// Errors encountered when parsing a [`Constraint`](crate::Constraint) from a string. +#[derive(Error, Clone, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum ConstraintParseError { + // No possible errors yet, but I'm sure there will be. +} diff --git a/src/lib.rs b/src/lib.rs index da2ef64..a279b18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,12 +5,14 @@ use std::{borrow::Cow, num::ParseIntError, str::FromStr}; +use derive_new::new; use documented::Documented; use duplicate::duplicate; use serde::{Deserialize, Serialize}; use strum::{AsRefStr, Display, EnumIter, EnumString}; use utoipa::ToSchema; +mod constraint; mod error; mod locator; mod locator_package; @@ -18,10 +20,14 @@ mod locator_strict; pub use error::*; +pub use constraint::*; pub use locator::*; pub use locator_package::*; pub use locator_strict::*; +#[doc(hidden)] +pub use semver; + /// `Fetcher` identifies a supported code host protocol. #[derive( Copy, @@ -318,14 +324,16 @@ impl std::cmp::PartialOrd for Package { /// A "revision" is the version of the project in the code host. /// Some fetcher protocols (such as `apk`, `rpm-generic`, and `deb`) /// encode additional standardized information in the `Revision` of the locator. -#[derive(Clone, Eq, PartialEq, Hash, Documented, ToSchema)] +#[derive(Clone, Eq, PartialEq, Hash, Documented, ToSchema, new)] #[schema(example = json!("v1.0.0"))] pub enum Revision { /// The revision is valid semver. #[schema(value_type = String)] + #[new(into)] Semver(semver::Version), /// The revision is an opaque string. + #[new(into)] Opaque(String), } @@ -354,6 +362,14 @@ impl From<&str> for Revision { } } +impl FromStr for Revision { + type Err = RevisionParseError; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s.to_string())) + } +} + impl From<&Revision> for Revision { fn from(value: &Revision) -> Self { value.clone() @@ -371,7 +387,14 @@ impl std::fmt::Display for Revision { impl std::fmt::Debug for Revision { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self}") + if f.alternate() { + match self { + Revision::Semver(version) => write!(f, "Revision::Semver({version:?})"), + Revision::Opaque(version) => write!(f, "Revision::Opaque({version:?})"), + } + } else { + write!(f, "{self}") + } } } @@ -477,7 +500,7 @@ mod tests { #[test_case("1abc2/name", None, Package::new("1abc2/name"); "1abc2/name")] #[test_case("name/1234", None, Package::new("name/1234"); "name/1234")] #[test] - fn parse_org_package(input: &str, org: Option, package: Package) { + fn parses_org_package(input: &str, org: Option, package: Package) { let (org_id, name) = parse_org_package(input); assert_eq!(org_id, org, "'org_id' must match in '{input}'"); assert_eq!(package, name, "'package' must match in '{input}"); diff --git a/src/locator.rs b/src/locator.rs index 77e0e6b..9405b81 100644 --- a/src/locator.rs +++ b/src/locator.rs @@ -6,13 +6,13 @@ use getset::{CopyGetters, Getters}; use serde::{Deserialize, Serialize}; use serde_json::json; use utoipa::{ - openapi::{ObjectBuilder, SchemaType}, ToSchema, + openapi::{ObjectBuilder, SchemaType}, }; use crate::{ - parse_org_package, Error, Fetcher, OrgId, Package, PackageLocator, ParseError, Revision, - StrictLocator, + Error, Fetcher, OrgId, Package, PackageLocator, ParseError, Revision, StrictLocator, + parse_org_package, }; /// Convenience macro for creating a [`Locator`]. @@ -417,7 +417,7 @@ mod tests { use assert_matches::assert_matches; use impls::impls; - use itertools::{izip, Itertools}; + use itertools::{Itertools, izip}; use pretty_assertions::assert_eq; use proptest::prelude::*; use serde::Deserialize; diff --git a/src/locator_package.rs b/src/locator_package.rs index 5997251..2ab9160 100644 --- a/src/locator_package.rs +++ b/src/locator_package.rs @@ -6,8 +6,8 @@ use getset::{CopyGetters, Getters}; use serde::{Deserialize, Serialize}; use serde_json::json; use utoipa::{ - openapi::{ObjectBuilder, SchemaType}, ToSchema, + openapi::{ObjectBuilder, SchemaType}, }; use crate::{Error, Fetcher, Locator, OrgId, Package, StrictLocator}; @@ -273,7 +273,7 @@ impl<'a> ToSchema<'a> for StrictLocator { mod tests { use assert_matches::assert_matches; use impls::impls; - use itertools::{izip, Itertools}; + use itertools::{Itertools, izip}; use pretty_assertions::assert_eq; use serde::Deserialize; use static_assertions::const_assert; diff --git a/src/locator_strict.rs b/src/locator_strict.rs index 817d086..9ed02c3 100644 --- a/src/locator_strict.rs +++ b/src/locator_strict.rs @@ -6,8 +6,8 @@ use getset::{CopyGetters, Getters}; use serde::{Deserialize, Serialize}; use serde_json::json; use utoipa::{ - openapi::{ObjectBuilder, SchemaType}, ToSchema, + openapi::{ObjectBuilder, SchemaType}, }; use crate::{Error, Fetcher, Locator, OrgId, Package, PackageLocator, ParseError, Revision}; @@ -253,7 +253,7 @@ impl<'a> ToSchema<'a> for PackageLocator { mod tests { use assert_matches::assert_matches; use impls::impls; - use itertools::{izip, Itertools}; + use itertools::{Itertools, izip}; use pretty_assertions::assert_eq; use serde::Deserialize; use static_assertions::const_assert; From 4974317d401348318a2fe0c20caeda7c6d479bdc Mon Sep 17 00:00:00 2001 From: fossabot Date: Tue, 15 Apr 2025 13:35:00 -0500 Subject: [PATCH 2/5] Add license scan report and status (#12) Co-authored-by: Jessica Black --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 13d1939..88b25d2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # locator +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.amrom.workers.dev%2Ffossas%2Flocator-rs.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.amrom.workers.dev%2Ffossas%2Flocator-rs?ref=badge_shield) + This library provides the ability to parse and format "Locator" strings. FOSSA uses locators to indicate specific libraries at specific versions. @@ -30,3 +32,7 @@ npm+lodash$4.17.21 // The 'lodash' library on NPM without specifying a version. npm+lodash ``` + + +## License +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.amrom.workers.dev%2Ffossas%2Flocator-rs.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.amrom.workers.dev%2Ffossas%2Flocator-rs?ref=badge_large) \ No newline at end of file From 5f3222c4f2c9a358df197dd4a4d1b231d7ed354a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:58:22 -0700 Subject: [PATCH 3/5] Bump actions/checkout from 2 to 4 (#3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jessica Black --- .github/workflows/check-dependencies.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml index bfc2c1a..fce83d4 100644 --- a/.github/workflows/check-dependencies.yml +++ b/.github/workflows/check-dependencies.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install-latest.sh | bash" - run: fossa analyze --only-target cargo . From ea802139c7917810ad4e95780570d3f94c50793e Mon Sep 17 00:00:00 2001 From: james-fossa <167804629+james-fossa@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:35:50 -0400 Subject: [PATCH 4/5] [ANE-2371] Add Gem constraint (#16) Co-authored-by: Jessica Black --- Cargo.toml | 3 + src/constraint.rs | 6 + src/constraint/gem.rs | 471 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 src/constraint/gem.rs diff --git a/Cargo.toml b/Cargo.toml index 3233494..65c21e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ enum-assoc = "1.2.4" unicase = "2.8.1" lexical-sort = "0.3.1" derive-new = "0.7.0" +nom = "8.0.0" +derive_more = { version = "2.0.1", features = ["full"] } +tracing = "0.1.41" [dev-dependencies] assert_matches = "1.5.0" diff --git a/src/constraint.rs b/src/constraint.rs index 3c40aff..78515b0 100644 --- a/src/constraint.rs +++ b/src/constraint.rs @@ -3,11 +3,13 @@ use documented::Documented; use enum_assoc::Assoc; use serde::{Deserialize, Serialize}; use strum::Display; +use tracing::warn; use utoipa::ToSchema; use crate::{Fetcher, Revision}; mod fallback; +mod gem; /// Describes version constraints supported by this crate. /// @@ -109,6 +111,10 @@ impl Constraint { /// In this instance [`Constraint::Compatible`] is a case-insensitive equality comparison. pub fn compare(&self, fetcher: Fetcher, target: &Revision) -> bool { match fetcher { + Fetcher::Gem => gem::compare(self, Fetcher::Gem, target).unwrap_or_else(|err| { + warn!(?err, "could not compare version"); + fallback::compare(self, Fetcher::Gem, target) + }), // If no specific comparitor is configured for this fetcher, // compare using the generic fallback. other => fallback::compare(self, other, target), diff --git a/src/constraint/gem.rs b/src/constraint/gem.rs new file mode 100644 index 0000000..82463ba --- /dev/null +++ b/src/constraint/gem.rs @@ -0,0 +1,471 @@ +use std::cmp::Ordering; + +use derive_more::Display; +use nom::{ + IResult, Parser, + branch::alt, + bytes::complete::{tag, take_while1}, + character::complete::{char, digit1, multispace0}, + combinator::{eof, map_res, opt, recognize}, + error::{Error, ErrorKind}, + multi::{many1, separated_list0, separated_list1}, + sequence::{delimited, pair, terminated}, +}; +use thiserror::Error; + +use super::{Constraint, fallback}; +use crate::{Fetcher, Revision}; + +/// Gem Versions and their Comparisons: +/// +/// Per https://guides.rubygems.org/patterns/#semantic-versioning, rubygems urges semantic versioning, +/// But does not dictate semantic versioning. +/// +/// https://github.com/rubygems/rubygems/blob/master/lib/rubygems/version.rb gives us: +/// +/// > If any part contains letters (currently only a-z are supported) then +/// > that version is considered prerelease. Versions with a prerelease +/// > part in the Nth part sort less than versions with N-1 +/// > parts. Prerelease parts are sorted alphabetically using the normal +/// > Ruby string sorting rules. If a prerelease part contains both +/// > letters and numbers, it will be broken into multiple parts to +/// > provide expected sort behavior (1.0.a10 becomes 1.0.a.10, and is +/// > greater than 1.0.a9). +/// > +/// > Prereleases sort between real releases (newest to oldest): +/// > +/// > 1. 1.0 +/// > 2. 1.0.b1 +/// > 3. 1.0.a.2 +/// > 4. 0.9 +/// > +/// > If you want to specify a version restriction that includes both prereleases +/// > and regular releases of the 1.x series this is the best way: +/// > +/// > s.add_dependency 'example', '>= 1.0.0.a', '< 2.0.0' +/// +/// An Advisory Example: ruby-advisory-db/gems/activesupport/CVE-2009-3009.yml +/// +/// ```yaml +/// unaffected_versions: +/// - "< 2.0.0" +/// patched_versions: +/// - "~> 2.2.3" +/// - ">= 2.3.4" +/// ``` +/// +/// Note the "twiddle-wakka" syntax for pessimistic version matching. +/// See here: https://guides.rubygems.org/patterns/#pessimistic-version-constraint +/// So `~> 2.2.3` is equivalent to `>= 2.2.3,< 2.3.0`. +/// Leaving out the patch, `~> 2.2` is equivalent to `>= 2.2.0,<3.0.0`. +/// Finally, `~> 2` is the closest to ~=/Compatible, and is equivalent to `>= 2.0.0,<3.0.0`. +#[tracing::instrument] +pub fn compare( + constraint: &Constraint, + fetcher: Fetcher, + target: &Revision, +) -> Result { + if let (Revision::Semver(_), Revision::Semver(_)) = (constraint.revision(), target) { + Ok(fallback::compare(constraint, fetcher, target)) + } else { + let threshold = GemVersion::try_from(constraint.revision())?; + let target = GemVersion::try_from(target)?; + Ok(match constraint { + Constraint::Equal(_) => target == threshold, + Constraint::NotEqual(_) => target != threshold, + Constraint::Less(_) => target < threshold, + Constraint::LessOrEqual(_) => target <= threshold, + Constraint::Greater(_) => target > threshold, + Constraint::GreaterOrEqual(_) => target >= threshold, + Constraint::Compatible(_) => { + let mut stop_segments = threshold + .segments + .iter() + .take_while(|s| !matches!(s, Segment::Prerelease(_))) + .take(threshold.segments.len() - 1) + .cloned() + .collect::>(); + if let Some(Segment::Release(n)) = stop_segments.last_mut() { + *n += 1; + } + let stop = GemVersion { + segments: stop_segments, + }; + target >= threshold && target < stop + } + }) + } +} + +/// Parsing rubygems requirements: +/// Parses a string into a vector of constraints. +/// See [Gem::Requirement](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/requirement.rb) for more. +pub fn parse_constraints(input: &str) -> Result, GemConstraintError> { + fn operator(input: &str) -> IResult<&str, &str> { + alt(( + tag("="), + tag("!="), + tag(">="), + tag("<="), + tag("~>"), + tag(">"), + tag("<"), + )) + .parse(input) + } + + fn version_segment(input: &str) -> IResult<&str, &str> { + take_while1(|c: char| c.is_alphanumeric())(input) + } + + fn version(input: &str) -> IResult<&str, &str> { + recognize(pair( + version_segment, + opt(recognize(pair( + char('.'), + separated_list0(char('.'), version_segment), + ))), + )) + .parse(input) + } + + fn single_constraint(input: &str) -> IResult<&str, Constraint> { + let (input, (op_opt, ver)) = ( + delimited(multispace0, opt(operator), multispace0), + delimited(multispace0, version, multispace0), + ) + .parse(input)?; + + let op = op_opt.unwrap_or("="); + let rev = Revision::Opaque(ver.to_string()); + + let constraint = match op { + "=" => Constraint::Equal(rev), + "!=" => Constraint::NotEqual(rev), + ">" => Constraint::Greater(rev), + ">=" => Constraint::GreaterOrEqual(rev), + "<" => Constraint::Less(rev), + "<=" => Constraint::LessOrEqual(rev), + "~>" => Constraint::Compatible(rev), + _ => return Err(nom::Err::Error(Error::new(input, ErrorKind::Tag))), + }; + + Ok((input, constraint)) + } + + // Parse multiple comma-separated constraints + fn constraints(input: &str) -> IResult<&str, Vec> { + terminated( + separated_list1( + delimited(multispace0, char(','), multispace0), + single_constraint, + ), + eof, + ) + .parse(input) + } + + constraints(input.trim()) + .map(|(_, parsed)| parsed) + .map_err(|e| GemConstraintError::ConstraintsParseError { + constraints: input.to_string(), + message: format!("failed to parse constraint: {e:?}"), + }) +} + +/// Errors from running the gem constraint. +#[derive(Error, Clone, PartialEq, Eq, Debug, Display)] +pub enum GemConstraintError { + /// Errors from parsing Gem versions. + #[display("VersionParseError({version}, {message})")] + VersionParseError { + /// The failed-to-parse version + version: String, + + /// The underlying parse error's message + message: String, + }, + + /// Errors from parsing Gem constraints. + #[display("ConstraintsParseError({constraints}, {message})")] + ConstraintsParseError { + /// The failed-to-parse constraints string + constraints: String, + + /// The underlying parse error's message + message: String, + }, +} + +impl From for GemVersion { + fn from(version: semver::Version) -> Self { + let segments = if version.pre.is_empty() { + vec![ + Segment::Release(version.major as usize), + Segment::Release(version.minor as usize), + Segment::Release(version.patch as usize), + ] + } else { + vec![ + Segment::Release(version.major as usize), + Segment::Release(version.minor as usize), + Segment::Release(version.patch as usize), + Segment::Prerelease(version.pre.to_string()), + ] + }; + GemVersion { segments } + } +} + +impl TryFrom<&Revision> for GemVersion { + type Error = GemConstraintError; + + fn try_from(rev: &Revision) -> Result { + match rev { + Revision::Semver(semver) => Ok(GemVersion::from(semver.to_owned())), + Revision::Opaque(opaque) => GemVersion::parse(opaque.as_str()) + .map_err(|e| GemConstraintError::VersionParseError { + version: opaque.to_string(), + message: e.to_string(), + }) + .and_then(|(leftovers, v)| { + if leftovers.is_empty() { + Ok(v) + } else { + Err(GemConstraintError::VersionParseError { + version: opaque.to_string(), + message: format!("trailing characters: '{leftovers}'"), + }) + } + }), + } + } +} + +impl TryFrom for GemVersion { + type Error = GemConstraintError; + + fn try_from(value: Revision) -> Result { + match value { + Revision::Semver(version) => Ok(version.into()), + Revision::Opaque(opaque) => match GemVersion::parse(opaque.as_str()) { + Ok((_, gem_version)) => Ok(gem_version), + Err(err) => Err(GemConstraintError::VersionParseError { + version: opaque.to_string(), + message: err.to_string(), + }), + }, + } + } +} + +/// A gem/bundler version. +/// Usually but not always a sem-ver version. +/// Can also include forms like `1.2.prerelease1`, which expand to `1.2.prerelease.1` and indicate prerelease versions. +#[derive(Debug, PartialEq, Eq, Clone)] +struct GemVersion { + /// The parts of the version. Prereleases with numbers end up in multiple parts (e.g. 1.0.a2 has segments 1, 0, a, and 2). + segments: Vec, +} + +impl GemVersion { + /// Parse a rubygems version, as described by https://ruby-doc.org/stdlib-3.0.0/libdoc/rubygems/rdoc/Gem/Version.html. + pub fn parse(input: &str) -> IResult<&str, Self> { + // Usually a gem version is of the familiar form `major.minor.patch`. + // Sometimes it includes further nesting or prerelease strings a la the examples `1.0.a2` or `0.9.b.0`. + + /// Parses a numeric release segment. + fn release(input: &str) -> IResult<&str, Segment> { + map_res(digit1, |s: &str| s.parse::().map(Segment::Release)).parse(input) + } + + /// Parses an alphabetic pre-release segment. + fn prerelease(input: &str) -> IResult<&str, Segment> { + map_res( + take_while1(|c: char| c.is_alphabetic()), + |s: &str| -> std::result::Result { + Ok(Segment::Prerelease(s.to_string())) + }, + ) + .parse(input) + } + + /// Parses either kind of segment. + fn segment(input: &str) -> IResult<&str, Segment> { + alt((release, prerelease)).parse(input) + } + + /// Since segments are not reliably "."-delimted, this parses the inner segments of a verison (`a2` becomes 'a, then 2', for example.) + fn inner_segments(input: &str) -> IResult<&str, Vec> { + many1(segment).parse(input) + } + + /// Parses the segments of a gem version and flattens the result. + fn version(input: &str) -> IResult<&str, GemVersion> { + let (input, nested) = separated_list0(tag("."), inner_segments).parse(input)?; + let segments = nested.into_iter().flatten().collect(); + Ok((input, GemVersion { segments })) + } + + let input = input.trim(); + version(input) + } +} + +/// Either a number or a prerelease-string +#[derive(Clone, Debug, PartialEq, Eq)] +enum Segment { + /// A normal release. The '1' or '0' in '1.0.a2'. + Release(usize), + + /// A pre-release. The 'a' in '1.0.a2'. + Prerelease(String), +} + +impl PartialOrd for Segment { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Segment { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Segment::Release(l), Segment::Release(r)) => l.cmp(r), + (Segment::Prerelease(l), Segment::Prerelease(r)) => { + dbg!(l, r); + lexical_sort::lexical_cmp(l, r) + } + (Segment::Prerelease(_), Segment::Release(_)) => Ordering::Less, + (Segment::Release(_), Segment::Prerelease(_)) => Ordering::Greater, + } + } +} + +impl PartialOrd for GemVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for GemVersion { + fn cmp(&self, other: &Self) -> Ordering { + let max_len = self.segments.len().max(other.segments.len()); + for i in 0..max_len { + let l = self.segments.get(i); + let r = other.segments.get(i); + match (l, r) { + (Some(l), Some(r)) => { + let cmp = l.cmp(r); + if cmp != Ordering::Equal { + return cmp; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => return Ordering::Equal, + } + } + Ordering::Equal + } +} + +#[cfg(test)] +mod tests { + use simple_test_case::test_case; + + use super::*; + use crate::{Revision, constraint}; + + const FETCHER: Fetcher = Fetcher::Gem; + + #[test_case(">= 1.0.0", vec![constraint!(GreaterOrEqual => "1.0.0")]; "1.0.0_geq_1.0.0")] + #[test_case("~> 2.5", vec![constraint!(Compatible => "2.5")]; "2.5_compat_2.5")] + #[test_case("< 3.0.0", vec![constraint!(Less => "3.0.0")]; "3.0.0_lt_3.0.0")] + #[test_case(">= 1.0, < 2.0", vec![constraint!(GreaterOrEqual => "1.0"), constraint!(Less => "2.0")]; "1.0_geq_1.0_AND_lt_2.0")] + #[test_case("= 1.2.3", vec![constraint!(Equal => "1.2.3")]; "1.2.3_eq_1.2.3")] + #[test_case("!= 1.9.3", vec![constraint!(NotEqual => "1.9.3")]; "1.9.3_neq_1.9.3")] + #[test_case("~> 2.2, >= 2.2.1", vec![constraint!(Compatible => "2.2"), constraint!(GreaterOrEqual => "2.2.1")]; "2.2_compat_2.2_AND_geq_2.2.1")] + #[test_case("> 1.0.0.pre.alpha", vec![constraint!(Greater => "1.0.0.pre.alpha")]; "1.0.0.pre.alpha_gt_1.0.0.pre.alpha")] + #[test_case("~> 1.0.0.beta2", vec![constraint!(Compatible => "1.0.0.beta2")]; "1.0.0.beta2_compat_1.0.0.beta2")] + #[test_case("= 1.0.0.rc1", vec![constraint!(Equal => "1.0.0.rc1")]; "1.0.0.rc1_eq_1.0.0.rc1")] + #[test_case(">= 0.8.0, < 1.0.0.beta", vec![constraint!(GreaterOrEqual => "0.8.0"), constraint!(Less => "1.0.0.beta")]; "0.8.0_geq_0.8.0_AND_lt_1.0.0.beta")] + #[test_case("~> 3.2.0.rc3", vec![constraint!(Compatible => "3.2.0.rc3")]; "3.2.0.rc3_compat_3.2.0.rc3")] + #[test_case(">= 4.0.0.alpha, < 5", vec![constraint!(GreaterOrEqual => "4.0.0.alpha"), constraint!(Less => "5")]; "4.0.0.alpha_geq_4.0.0.alpha_AND_lt_5")] + #[test] + fn test_ruby_constraints_parsing(input: &str, expected: Vec) { + let actual = parse_constraints(input).expect("should parse constraint"); + assert_eq!(expected, actual, "compare {expected:?} with {actual:?}"); + } + + #[test_case("$%!@#"; "invalid_special_chars")] + #[test_case("1.2.3 !!"; "trailing_invalid_chars")] + #[test_case("1..2.3"; "double_dot_in_version")] + #[test_case(">>= 1.0"; "invalid_operator")] + #[test_case("~> "; "missing_version_after_operator")] + #[test_case(">= 1.0,"; "trailing_comma")] + #[test] + fn test_ruby_constraints_parsing_failure(input: &str) { + parse_constraints(input).expect_err("should not parse constraint"); + } + + #[test_case(constraint!(Greater => "b"), Revision::from("a"), false; "a_not_greater_than_b")] + #[test_case(constraint!(Compatible => "abcd"), Revision::from("AbCd"), false; "abcd_not_compatible_AbCd")] + #[test_case(constraint!(GreaterOrEqual => "1.2.3.4"), Revision::from("1.2.3.5"), true; "1.2.3.4_greater_than_1.2.3.5")] + #[test_case(constraint!(Compatible => "1.2.3.4.5"), Revision::from("1.2.3.4.5.6"), true; "1.2.3.4.5_compat_1.2.3.4.5.6")] + #[test_case(constraint!(Compatible => "1.2.3.4.5"), Revision::from("1.2.3.5"), false; "1.2.3.5_not_compat_1.2.3.4.5")] + #[test_case(constraint!(Compatible => 1, 2, 0), Revision::from("1.2.3"), true; "1.2_compat_1.2.3")] + #[test_case(constraint!(Compatible => 1, 2, 0), Revision::from("1.3.4"), false; "1.2_compat_1.3.4")] + #[test_case(constraint!(Compatible => "1.2.a.0"), Revision::from("1.2.a0"), true; "1.2.a.0_compat_1.2.a0")] + #[test_case(constraint!(GreaterOrEqual => "1.2.prerelease0"), Revision::from("1.2.prerelease1"), true; "1.2.prerelease1_greater_or_equal_1.2prerelease0")] + #[test_case(constraint!(GreaterOrEqual => "1.2.3.4"), Revision::from("1.2.3.5"), true; "1.2.3.5_greater_or_equal_1.2.3.4")] + #[test_case(constraint!(GreaterOrEqual => "1.2.a.0"), Revision::from("1.2.a0"), true; "1.2.a.0_greater_or_equal_1.2a0")] + #[test_case(constraint!(Equal => "1.2.a.0"), Revision::from("1.2.a0"), true; "1.2.a.0_equal_1.2a0")] + #[test] + fn compare_ruby_specific_oddities(constraint: Constraint, target: Revision, expected: bool) { + assert_eq!( + compare(&constraint, FETCHER, &target).expect("should not have a parse error"), + expected, + "compare '{target}' to '{constraint}', expected: {expected}" + ); + } + + #[test_case(constraint!(Equal => 1,2,3), Revision::from("1*"); "1.2.3_equal_1*")] + #[test_case(constraint!(Equal => "1.2.a.0"), Revision::from("1.2*a0"); "1.2.a.0_equal_1.2*a0")] + #[test_case(constraint!(Equal => 1,2,3), Revision::from("1*2*3"); "1.2.3_equal_1*2*3")] + #[test] + fn compare_ruby_error_cases(constraint: Constraint, target: Revision) { + assert!(compare(&constraint, FETCHER, &target).is_err()) + } + + // Testing that we produce the same outputs as our fallback for semvers. + #[test_case(constraint!(Compatible => 1, 2, 3), Revision::from("1.2.3"); "1.2.3_compatible_1.2.3")] + #[test_case(constraint!(Equal => 1, 2, 3), Revision::from("1.2.4"); "1.2.4_not_equal_1.2.3")] + #[test_case(constraint!(NotEqual => 1, 2, 3), Revision::from("1.2.3"); "1.2.3_not_notequal_1.2.3")] + #[test_case(constraint!(Less => 1, 2, 3), Revision::from("1.2.2"); "1.2.2_less_1.2.3")] + #[test_case(constraint!(LessOrEqual => 1, 2, 3), Revision::from("1.2.2"); "1.2.2_less_or_equal_1.2.3")] + #[test_case(constraint!(Greater => 1, 2, 3), Revision::from("1.2.4"); "1.2.4_greater_1.2.3")] + #[test_case(constraint!(GreaterOrEqual => 1, 2, 3), Revision::from("1.2.2"); "1.2.2_not_greater_or_equal_1.2.3")] + #[test] + fn compare_semver_acts_like_fallback(constraint: Constraint, target: Revision) { + let expected = fallback::compare(&constraint, FETCHER, &target); + assert_eq!( + compare(&constraint, FETCHER, &target).expect("should not have a parse error"), + expected, + "compare '{target}' to '{constraint}', expected: {expected}", + ); + } + + #[test_case(constraint!(Equal => "abcd"), Revision::from("aBcD"); "abcd_not_equal_aBcD")] + #[test_case(constraint!(NotEqual => "abcd"), Revision::from("abcde"); "abcd_notequal_abcde")] + #[test_case(constraint!(Less => "a"), Revision::from("a"); "a_not_less_a")] + #[test] + fn compare_opaque(constraint: Constraint, target: Revision) { + let expected = fallback::compare(&constraint, FETCHER, &target); + assert_eq!( + compare(&constraint, FETCHER, &target).expect("should not have a parse error"), + expected, + "compare '{target}' to '{constraint}', expected: {expected}" + ); + } +} From b2a5f1b7176289469805404931cee1e9fbb0abf7 Mon Sep 17 00:00:00 2001 From: james-fossa <167804629+james-fossa@users.noreply.github.com> Date: Fri, 18 Apr 2025 14:16:33 -0400 Subject: [PATCH 5/5] [ANE-2382] Add Pip constraint (#19) Co-authored-by: Jessica Black --- src/constraint.rs | 7 +- src/constraint/pip.rs | 672 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 src/constraint/pip.rs diff --git a/src/constraint.rs b/src/constraint.rs index 78515b0..3360da4 100644 --- a/src/constraint.rs +++ b/src/constraint.rs @@ -10,6 +10,7 @@ use crate::{Fetcher, Revision}; mod fallback; mod gem; +mod pip; /// Describes version constraints supported by this crate. /// @@ -112,9 +113,13 @@ impl Constraint { pub fn compare(&self, fetcher: Fetcher, target: &Revision) -> bool { match fetcher { Fetcher::Gem => gem::compare(self, Fetcher::Gem, target).unwrap_or_else(|err| { - warn!(?err, "could not compare version"); + warn!(?err, "could not compare gem version"); fallback::compare(self, Fetcher::Gem, target) }), + Fetcher::Pip => pip::compare(self, Fetcher::Pip, target).unwrap_or_else(|err| { + warn!(?err, "could not compare pip version"); + fallback::compare(self, Fetcher::Pip, target) + }), // If no specific comparitor is configured for this fetcher, // compare using the generic fallback. other => fallback::compare(self, other, target), diff --git a/src/constraint/pip.rs b/src/constraint/pip.rs new file mode 100644 index 0000000..b94058e --- /dev/null +++ b/src/constraint/pip.rs @@ -0,0 +1,672 @@ +//! # Pypi Package Version Specifiers +//! See: +//! - [Version Specifiers](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers) +//! - [Requirement Specifiers](https://pip.pypa.io/en/stable/reference/requirement-specifiers/#requirement-specifiers) +//! +//! Version Specifiers in pypi follow the format: +//! `[N!]N(.N)*[{a|alpha|b|beta|rc|c|pre|preview}N][.postN][.devN]` +//! +//! For example: +//! `1!2.3.4a5.post6.dev7` +//! +//! And are made up of these five segments: +//! - Epoch segment: N! +//! - Release segment: N(.N)* +//! - Pre-release segment: {a|alpha|b|beta|rc|c|pre|preview}N +//! - Post-release segment: .postN +//! - Development release segment: .devN +//! +//! ## Epoch +//! +//! The Epoch `N!` defaults to 0, and is rare. +//! It works to separate different regimes of versioning within a project. +//! For example, going from date-based versioning to semantic versioning. +//! `2025.01.01` from January might be older than: +//! `1.0.0`, when the project maintainers decided to switch to semantic versioning. +//! +//! But the `1.0.0` version will look older with the default epoch. +//! So, `1!1.0.0` serves to show that the new epoch has arrived, +//! and all subsequent versions will be considered newer than epoch 0 versions. +//! +//! +//! Release segments `N(.N)*` the common portion of a version. +//! A version identifier that consists solely of a release segment and optionally an epoch identifier is termed a "final release". +//! These are the most commonly thought of part of the version specifier. +//! +//! ## Pre-Release +//! +//! Pre-release segment: {a|alpha|b|beta|rc|c|pre|preview}N +//! - `a` | `alpha` are for alpha. +//! - `b` | `beta` are for beta. +//! - `rc` | `c` are for release candidate. +//! - `pre` | `preview` are also acceptable values. +//! +//! Prereleases are all considered older than final releases. +//! +//! ## Post-Release +//! +//! Post-release segment: .postN +//! - `post` is for post-release +//! - the default post-release is `.post0` +//! +//! Post-releases are considered newer than the corresponding final releases. +//! +//! ## Development Release +//! +//! Development release segment: .devN +//! - `dev` is for development release +//! - the default dev-release is `.dev0` +//! +//! Development releases are ordered by their numeric component. +//! They are newer than the post-releases of the prior version. +//! They are older than the pre-releases of their release segment's version. +//! +//! ## Normalization +//! +//! Because Python versioning practices have evolved over time, [the docs](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers) +//! prescribe the following rules for normalizing versions. +//! +//! - Version specifiers should treat versions as case-insensitive. +//! - Numeric segments should be normalized as a python int (leading zeroes removed, for example). +//! - Prereleases may have a `.`, `-`, or `_` between the release segment and the pre-release segment. +//! - Alpha, beta, c, pre, and preview are all considered pre-release segments, and map onto a, b, rc, rc, and rc respectively. +//! - Post-releases may also have the same separators that pre-releases may (or none at all). +//! - Post-releases may also be spelled `r` or `rev` instead of `post`. +//! - Dev-releases may also have the same separators that pre-releases may (or none at all). +//! - Any of these versions can begin with a `v`, which is meaningless. +//! +use std::cmp::Ordering; + +use bon::Builder; +use derive_more::Display; +use nom::{ + IResult, Parser, + branch::alt, + bytes::complete::{tag, take_while1}, + character::complete::{char, digit1, multispace0, u32}, + combinator::{eof, map_res, opt, value}, + multi::{many1, separated_list1}, + sequence::{delimited, pair, preceded, terminated}, +}; +use thiserror::Error; +use tracing::warn; + +use super::{Constraint, fallback}; +use crate::{Fetcher, Revision}; + +/// A version in pip. +/// See [Version Specifiers](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers) for more information. +#[derive(Debug, Clone, PartialEq, Eq, Builder)] +struct PipVersion { + /// The epoch of the version; most versions have an epoch of 0, which is the default. + /// After, for example, changing from a `yyyy.mm.dd` version format to a semver format, a project might increment its epoch to capture the change. + /// That would allow the project to indicate that every semver version was later than any date-marked version. + /// In a version `0!1.2.3.rc4.post5.dev6`, the epoch is 0. + epoch: u32, + + /// The release segments of the version. + /// These are what we most commonly picture when we imagine a version. + /// For example, in the version `1.2.3`, the release segments are `[1, 2, 3]`. + /// In a version `0!1.2.3.rc4.post5.dev6`, the release segments are `[1, 2, 3]`. + release_segments: Vec, + + /// An optional prerelease. + /// In a version `0!1.2.3.rc4.post5.dev6`, the prerelease is `rc 4`. + /// In a version `0!1.2.3.pre4.post5.dev6`, the prerelease is `rc 4`. + /// In a version `0!1.2.3.prerelease4.post5.dev6`, the prerelease is `rc 4`. + /// In a version `0!1.2.3.a4.post5.dev6`, the prerelease is `a 4`. + /// In a version `0!1.2.3.b4.post5.dev6`, the prerelease is `b 4`. + /// In a version `0!1.2.3.c4.post5.dev6`, the prerelease is `rc 4`. + pre_release: Option, + + /// An optional sub-patch post-release revision. + /// In a version `0!1.2.3.rc4.post5.dev6`, the post release is `5`. + /// In a version `0!1.2.3.rc4.r5.dev6`, the post release is `5`. + /// In a version `0!1.2.3.rc4.rev5.dev6`, the post release is `5`. + post_release: Option, + + /// An optional dev-release, 0 by default. + /// Dev releases are newer than the post-releases of the prior version and older than pre-releases of their version. + /// In a version `0!1.2.3.rc4.post5.dev6`, the dev release is `6`. + dev_release: Option, +} + +#[derive(Error, Clone, PartialEq, Eq, Debug)] +pub enum PipConstraintError { + #[error("parse constraints {constraints:?}: {message:?})")] + ConstraintParse { + constraints: String, + message: String, + }, + + #[error("parse version {version:?}: {message:?})")] + VersionParse { version: String, message: String }, +} + +#[tracing::instrument] +pub fn compare( + constraint: &Constraint, + fetcher: Fetcher, + target: &Revision, +) -> Result { + if let (Revision::Semver(_), Revision::Semver(_)) = (constraint.revision(), target) { + return Ok(fallback::compare(constraint, fetcher, target)); + } + let threshold = PipVersion::try_from(constraint.revision())?; + let target = PipVersion::try_from(target)?; + Ok(match constraint { + Constraint::Equal(_) => target == threshold, + Constraint::NotEqual(_) => target != threshold, + Constraint::Less(_) => target < threshold, + Constraint::LessOrEqual(_) => target <= threshold, + Constraint::Greater(_) => target > threshold, + Constraint::GreaterOrEqual(_) => target >= threshold, + Constraint::Compatible(_) => { + let threshold_segments = threshold.release_segments.clone(); + if threshold_segments.len() >= 2 { + let min_version = threshold.clone(); + + // Create a max version with one segment incremented + // For ~= 2.5, the max version would be < 3.0 + // For ~= 2.5.1, the max version would be < 2.6.0 + // For ~= 1!2.5.1, the max version would be < 1!2.6.0 + let mut max_segments = threshold_segments.clone(); + // Drop the last element if we have more than 2 segments + if max_segments.len() > 2 { + max_segments.truncate(max_segments.len() - 1); + } + // Increment the last remaining segment + if let Some(last) = max_segments.last_mut() { + *last += 1; + } + + // Create a max version with all zeros after the incremented segment + let max_version = PipVersion::builder() + .epoch(threshold.epoch) + .release_segments(max_segments) + .build(); + + target >= min_version && target < max_version + } else { + warn!("Not enough release segments for compatible operator with {threshold}"); + false + } + } + }) +} + +#[tracing::instrument] +pub fn parse_constraints(input: &str) -> Result, PipConstraintError> { + fn operator(input: &str) -> IResult<&str, &str> { + alt(( + tag("==="), + tag("=="), + tag("!="), + tag(">="), + tag("<="), + tag("~="), + tag(">"), + tag("<"), + )) + .parse(input) + } + + fn version(input: &str) -> IResult<&str, &str> { + take_while1(|c: char| c.is_alphanumeric() || ".!+-_*".contains(c)).parse(input) + } + + fn single_constraint(input: &str) -> IResult<&str, Constraint> { + let (input, (op, ver)) = pair( + delimited(multispace0, operator, multispace0), + delimited(multispace0, version, multispace0), + ) + .parse(input)?; + + let rev = Revision::Opaque(ver.to_string()); + + let constraint = match op { + // Technically, the `===` operator ought to exclude default values. + "==" | "===" => Constraint::Equal(rev), + "!=" => Constraint::NotEqual(rev), + ">" => Constraint::Greater(rev), + ">=" => Constraint::GreaterOrEqual(rev), + "<" => Constraint::Less(rev), + "<=" => Constraint::LessOrEqual(rev), + "~=" => Constraint::Compatible(rev), + _ => { + return Err(nom::Err::Failure(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))); + } + }; + + Ok((input, constraint)) + } + + fn constraints(input: &str) -> IResult<&str, Vec> { + terminated( + separated_list1( + delimited(multispace0, char(','), multispace0), + single_constraint, + ), + eof, + ) + .parse(input) + } + + constraints(input.trim()) + .map(|(_, parsed)| parsed) + .map_err(|e| PipConstraintError::ConstraintParse { + constraints: input.to_string(), + message: format!("failed to parse constraint: {e:?}"), + }) +} + +impl std::fmt::Display for PipVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}!", self.epoch)?; + let mut is_first = true; + for segment in &self.release_segments { + if is_first { + is_first = false; + write!(f, "{segment}")?; + } else { + write!(f, ".{segment}")?; + } + } + if let Some(pre_release) = &self.pre_release { + write!(f, "{}", pre_release)?; + } + if let Some(post_release) = &self.post_release { + write!(f, ".post{}", post_release)?; + } + if let Some(dev_release) = &self.dev_release { + write!(f, ".dev{}", dev_release)?; + } + Ok(()) + } +} + +/// The prerelease version. +/// `{a|alpha|b|beta|rc|c|pre|preview}N` +/// N defaults to 0 in the presence of any of these tags. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PreRelease { + /// aN | alphaN + Alpha(u32), + + /// bN |betaN + Beta(u32), + + /// rcN | cN | preN | previewN + RC(u32), +} + +impl std::fmt::Display for PreRelease { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PreRelease::Alpha(n) => write!(f, "a{}", n), + PreRelease::Beta(n) => write!(f, "b{}", n), + PreRelease::RC(n) => write!(f, "rc{}", n), + } + } +} + +impl PreRelease { + fn number(&self) -> u32 { + match self { + PreRelease::Alpha(n) => *n, + PreRelease::Beta(n) => *n, + PreRelease::RC(n) => *n, + } + } + + fn parse(input: &str) -> IResult<&str, Self> { + fn alpha(input: &str) -> IResult<&str, PreRelease> { + let (input, _) = alt((tag("alpha"), tag("a"))).parse(input)?; + let (input, number) = opt(u32).parse(input)?; + Ok((input, PreRelease::Alpha(number.unwrap_or(0)))) + } + fn beta(input: &str) -> IResult<&str, PreRelease> { + let (input, _) = alt((tag("beta"), tag("b"))).parse(input)?; + let (input, number) = opt(u32).parse(input)?; + Ok((input, PreRelease::Beta(number.unwrap_or(0)))) + } + fn rc(input: &str) -> IResult<&str, PreRelease> { + let (input, _) = alt((tag("preview"), tag("rc"), tag("c"), tag("pre"))).parse(input)?; + let (input, number) = opt(u32).parse(input)?; + Ok((input, PreRelease::RC(number.unwrap_or(0)))) + } + alt((alpha, beta, rc)).parse(input) + } +} + +impl PartialOrd for PreRelease { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PreRelease { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (PreRelease::Alpha(_), PreRelease::Beta(_)) => Ordering::Less, + (PreRelease::Alpha(_), PreRelease::RC(_)) => Ordering::Less, + (PreRelease::Beta(_), PreRelease::Alpha(_)) => Ordering::Greater, + (PreRelease::Beta(_), PreRelease::RC(_)) => Ordering::Less, + (PreRelease::RC(_), PreRelease::Alpha(_)) => Ordering::Greater, + (PreRelease::RC(_), PreRelease::Beta(_)) => Ordering::Greater, + (lhs, rhs) => lhs.number().cmp(&rhs.number()), + } + } +} + +impl TryFrom<&Revision> for PipVersion { + type Error = PipConstraintError; + + fn try_from(rev: &Revision) -> Result { + match rev { + Revision::Semver(semver) => { + let pre_release = if semver.pre.is_empty() { + None + } else { + PreRelease::parse(semver.pre.as_str()) + .map(|(_, pre)| Some(pre)) + .map_err(|e| PipConstraintError::VersionParse { + version: semver.to_string(), + message: format!("invalid pre-release version: {e:?}"), + })? + }; + let release_segments = vec![ + semver.major as u32, + semver.minor as u32, + semver.patch as u32, + ]; + let builder = PipVersion::builder() + .epoch(0) + .release_segments(release_segments); + + let mut version = if let Some(pre_release) = pre_release { + builder.pre_release(pre_release).build() + } else { + builder.build() + }; + + let pre_opt = &semver.pre; + if !pre_opt.is_empty() { + if let Ok((_, pre_release)) = PreRelease::parse(pre_opt.as_str()) { + version.pre_release = Some(pre_release); + } else { + return Err(PipConstraintError::VersionParse { + version: semver.to_string(), + message: format!("Unexpected pre-release: {}", pre_opt), + }); + } + } + + Ok(version) + } + Revision::Opaque(opaque) => { + PipVersion::parse(opaque).map_err(|e| PipConstraintError::VersionParse { + version: opaque.to_string(), + message: e.to_string(), + }) + } + } + } +} + +impl PipVersion { + /// Parses and normalizes a pip version + fn parse(version: &str) -> Result { + fn separator(input: &str) -> IResult<&str, char> { + alt((char('.'), char('-'), char('_'), value('_', tag("")))).parse(input) + } + + fn v_prefix(input: &str) -> IResult<&str, ()> { + value((), opt(tag("v"))).parse(input) + } + + fn epoch(input: &str) -> IResult<&str, u32> { + let (input, num) = map_res(digit1, |s: &str| s.parse::()).parse(input)?; + let (input, _) = char('!')(input)?; + Ok((input, num)) + } + + fn release_segment(input: &str) -> IResult<&str, u32> { + map_res(digit1, |s: &str| s.parse::()).parse(input) + } + + fn release_segments(input: &str) -> IResult<&str, Vec> { + let (input, first) = release_segment(input)?; + let (input, rest) = many1(preceded(char('.'), release_segment)).parse(input)?; + + let mut segments = vec![first]; + segments.extend(rest); + Ok((input, segments)) + } + + fn pre_release(input: &str) -> IResult<&str, PreRelease> { + preceded(opt(separator), PreRelease::parse).parse(input) + } + + fn implicit_post_release(input: &str) -> IResult<&str, u32> { + let (input, _) = char('-')(input)?; + let (input, num) = map_res(digit1, |s: &str| s.parse::()).parse(input)?; + Ok((input, num)) + } + + fn explicit_post_release(input: &str) -> IResult<&str, u32> { + let (input, _) = opt(separator).parse(input)?; + let (input, _) = alt((tag("post"), tag("r"), tag("rev"))).parse(input)?; + let (input, num) = opt(digit1).parse(input)?; + + Ok((input, num.unwrap_or("0").parse().unwrap_or(0))) + } + + fn dev_release(input: &str) -> IResult<&str, u32> { + let (input, _) = opt(separator).parse(input)?; + let (input, _) = tag("dev").parse(input)?; + let (input, num) = opt(digit1).parse(input)?; + + Ok((input, num.unwrap_or("0").parse().unwrap_or(0))) + } + + fn version_parser(input: &str) -> IResult<&str, PipVersion> { + let (input, _) = v_prefix(input)?; + let (input, epoch_opt) = opt(epoch).parse(input)?; + let (input, release_segs) = release_segments(input)?; + let (input, pre_rel) = opt(pre_release).parse(input)?; + let (input, post_rel) = + opt(alt((explicit_post_release, implicit_post_release))).parse(input)?; + let (input, dev_rel) = opt(dev_release).parse(input)?; + + Ok(( + input, + PipVersion { + epoch: epoch_opt.unwrap_or(0), + release_segments: release_segs, + pre_release: pre_rel, + post_release: post_rel, + dev_release: dev_rel, + }, + )) + } + + let input = version.trim(); + match version_parser(input) { + Ok((remaining, version)) => { + if !remaining.is_empty() { + Err(format!("Unexpected trailing text: '{}'", remaining)) + } else { + Ok(version) + } + } + Err(e) => Err(format!("Failed to parse version: {}", e)), + } + } +} + +impl PartialOrd for PipVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PipVersion { + fn cmp(&self, other: &Self) -> Ordering { + let epoch_cmp = self.epoch.cmp(&other.epoch); + if epoch_cmp != Ordering::Equal { + return epoch_cmp; + } + + let max_segments = self + .release_segments + .len() + .max(other.release_segments.len()); + for i in 0..max_segments { + let self_segment = self.release_segments.get(i).copied().unwrap_or(0); + let other_segment = other.release_segments.get(i).copied().unwrap_or(0); + let cmp = self_segment.cmp(&other_segment); + if cmp != Ordering::Equal { + return cmp; + } + } + + match (self.dev_release, other.dev_release) { + (None, Some(_)) => return Ordering::Greater, + (Some(_), None) => return Ordering::Less, + (Some(self_dev), Some(other_dev)) => { + let cmp = self_dev.cmp(&other_dev); + if cmp != Ordering::Equal { + return cmp; + } + } + (None, None) => {} + } + + match (&self.pre_release, &other.pre_release) { + (None, Some(_)) => return Ordering::Greater, + (Some(_), None) => return Ordering::Less, + (Some(self_pre), Some(other_pre)) => { + let cmp = self_pre.cmp(other_pre); + if cmp != Ordering::Equal { + return cmp; + } + } + (None, None) => {} + } + + match (self.post_release, other.post_release) { + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(self_post), Some(other_post)) => { + let cmp = self_post.cmp(&other_post); + if cmp != Ordering::Equal { + return cmp; + } + } + (None, None) => {} + } + Ordering::Equal + } +} + +#[cfg(test)] +mod tests { + use simple_test_case::test_case; + + use super::*; + use crate::{Revision, constraint}; + + const FETCHER: Fetcher = Fetcher::Pip; + + #[test_case("1!2.3.4.a5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).pre_release(PreRelease::Alpha(5)).build(); "epoch1_alpha_short")] + #[test_case("1!2.3.4.alpha5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).pre_release(PreRelease::Alpha(5)).build(); "epoch1_alpha_no_sep")] + #[test_case("1!2.3.4-alpha5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).pre_release(PreRelease::Alpha(5)).build(); "epoch1_alpha_dash")] + #[test_case("1!2.3.4-alpha", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).pre_release(PreRelease::Alpha(0)).build(); "epoch1_alpha")] + #[test_case("1!2.3.4.post5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).post_release(5).build(); "epoch1_post")] + #[test_case("1!2.3.4-post5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).post_release(5).build(); "epoch1_post_hyphen")] + #[test_case("1!2.3.4_post5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).post_release(5).build(); "epoch1_post_underscore")] + #[test_case("1.2.3", PipVersion::builder().epoch(0).release_segments(vec![1, 2, 3]).build(); "simple_version")] + #[test_case("1!2.3.4_rc5", PipVersion::builder().epoch(1).release_segments(vec![2, 3, 4]).pre_release(PreRelease::RC(5)).build(); "epoch1_prerelease")] + #[test_case("1.2.3_pre4", PipVersion::builder().epoch(0).release_segments(vec![1, 2, 3]).pre_release(PreRelease::RC(4)).build(); "prerelease")] + #[test_case("1.2.3_a", PipVersion::builder().epoch(0).release_segments(vec![1, 2, 3]).pre_release(PreRelease::Alpha(0)).build(); "implicit_prerelease")] + #[test_case("1.2.3-1", PipVersion::builder().epoch(0).release_segments(vec![1, 2, 3]).post_release(1).build(); "implicit_postrelease")] + #[test] + fn test_pip_version_parsing(input: &str, expected: PipVersion) { + let actual = PipVersion::parse(input).expect("should parse version"); + assert_eq!(expected, actual, "compare {expected:?} with {actual:?}"); + } + + #[test_case("== 1.0.0", vec![constraint!(Equal => "1.0.0")]; "1.0.0_eq_1.0.0")] + #[test_case("~= 2.5", vec![constraint!(Compatible => "2.5")]; "2.5_compat_2.5")] + #[test_case(">= 1.0, < 2.0", vec![constraint!(GreaterOrEqual => "1.0"), constraint!(Less => "2.0")]; "1.0_geq_1.0_AND_lt_2.0")] + #[test_case("!= 1.9.3", vec![constraint!(NotEqual => "1.9.3")]; "1.9.3_neq_1.9.3")] + #[test_case("> 1.0.0a1", vec![constraint!(Greater => "1.0.0a1")]; "1.0.0a1_gt_1.0.0a1")] + #[test_case("<= 2.0.0.post1", vec![constraint!(LessOrEqual => "2.0.0.post1")]; "2.0.0.post1_leq_2.0.0.post1")] + #[test_case("== 1.0.0.dev1", vec![constraint!(Equal => "1.0.0.dev1")]; "1.0.0.dev1_eq_1.0.0.dev1")] + #[test_case("=== 3.0.0", vec![constraint!(Equal => "3.0.0")]; "3.0.0_exact_eq_3.0.0")] + #[test_case(">= 1!2.0", vec![constraint!(GreaterOrEqual => "1!2.0")]; "1!2.0_geq_1!2.0")] + #[test] + fn test_pip_constraints_parsing(input: &str, expected: Vec) { + let actual = parse_constraints(input).expect("should parse constraint"); + assert_eq!(expected, actual, "compare {expected:?} with {actual:?}"); + } + + #[test_case("$%!@#"; "invalid_special_chars")] + #[test_case("1.2.3 !!"; "trailing_invalid_chars")] + #[test_case(">>= 1.0"; "invalid_operator")] + #[test_case("~= "; "missing_version_after_operator")] + #[test_case(">= 1.0,"; "trailing_comma")] + #[test] + fn test_pip_constraints_parsing_failure(input: &str) { + parse_constraints(input).expect_err("should not parse constraint"); + } + + #[test_case(constraint!(Equal => "1.0.0"), Revision::from("1.0.0"), true; "equal_versions")] + #[test_case(constraint!(Equal => "1.0.0"), Revision::from("1.0.0.post1"), false; "post_release_not_equal")] + #[test_case(constraint!(GreaterOrEqual => "1.0.0"), Revision::from("1.0.0"), true; "greater_equal_same")] + #[test_case(constraint!(GreaterOrEqual => "1.0.0"), Revision::from("0.9.0"), false; "not_greater_equal")] + #[test_case(constraint!(Less => "2.0.0"), Revision::from("1.9.9"), true; "less_than")] + #[test_case(constraint!(Less => "1.0.0"), Revision::from("1.0.0"), false; "not_less_equal")] + #[test_case(constraint!(Compatible => "1.2"), Revision::from("1.2.5"), true; "1.2_compatible_version_1.3.5")] + #[test_case(constraint!(Compatible => "1.2.3"), Revision::from("1.2.5"), true; "compatible_version")] + #[test_case(constraint!(Compatible => "1.2.3"), Revision::from("1.3.0"), false; "not_compatible_version")] + #[test_case(constraint!(Equal => "1.0.0a1"), Revision::from("1.0.0a1"), true; "prerelease_equal")] + #[test_case(constraint!(Greater => "1.0.0a1"), Revision::from("1.0.0"), true; "final_greater_than_prerelease")] + #[test_case(constraint!(Greater => "1.0.0"), Revision::from("1.0.0.post1"), true; "post_greater_than_final")] + #[test_case(constraint!(Less => "1.0.0"), Revision::from("1.0.0.dev1"), true; "dev_less_than_final")] + #[test_case(constraint!(Equal => "1!1.0.0"), Revision::from("1!1.0.0"), true; "equal_with_epoch")] + #[test_case(constraint!(Greater => "0!2.0.0"), Revision::from("1!1.0.0"), true; "greater_epoch")] + #[test] + fn test_pip_version_comparison(constraint: Constraint, target: Revision, expected: bool) { + assert_eq!( + compare(&constraint, FETCHER, &target).expect("should not have a parse error"), + expected, + "compare '{target}' to '{constraint}', expected: {expected}" + ); + } + + #[test_case("1.0.0", "1.0.0", Ordering::Equal; "equal_normal_versions")] + #[test_case("1.1.0", "1.0.0", Ordering::Greater; "greater_minor_version")] + #[test_case("1.0.0", "1.1.0", Ordering::Less; "less_minor_version")] + #[test_case("1.0.0", "1.0.0a1", Ordering::Greater; "final_greater_than_prerelease")] + #[test_case("1.0.0a1", "1.0.0b1", Ordering::Less; "alpha_less_than_beta")] + #[test_case("1.0.0b1", "1.0.0rc1", Ordering::Less; "beta_less_than_rc")] + #[test_case("1.0.0.post1", "1.0.0", Ordering::Greater; "post_greater_than_final")] + #[test_case("1.0.0.dev1", "1.0.0", Ordering::Less; "dev_less_than_final")] + #[test_case("1.0.0.dev1", "1.0.0a1", Ordering::Less; "dev_less_than_prerelease")] + #[test_case("1!1.0.0", "2.0.0", Ordering::Greater; "epoch_takes_precedence")] + #[test] + fn test_pip_version_ordering(version1: &str, version2: &str, expected: Ordering) { + let v1 = PipVersion::parse(version1).expect("valid version"); + let v2 = PipVersion::parse(version2).expect("valid version"); + assert_eq!( + v1.cmp(&v2), + expected, + "Expected {version1} to be {expected:?} {version2}" + ); + } +}