aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-semver
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-23 14:31:35 +0900
committernsfisis <nsfisis@gmail.com>2026-02-23 14:31:35 +0900
commit6cf5f6363f416b558753d44bee9ec536ddf7c06c (patch)
tree08d22fde8f4c92938629026de35f880e9375daff /crates/mozart-semver
parentd9cc83f4e775ed7f59cf21f350b2140c29cf6b07 (diff)
downloadphp-mozart-6cf5f6363f416b558753d44bee9ec536ddf7c06c.tar.gz
php-mozart-6cf5f6363f416b558753d44bee9ec536ddf7c06c.tar.zst
php-mozart-6cf5f6363f416b558753d44bee9ec536ddf7c06c.zip
fix(semver): handle partial upper bounds in hyphen ranges per Composer spec
`parse_hyphen_range` was using `<=` for all upper bounds, but Composer treats partial versions (1-2 segments) differently: `8.1 - 8.5` should mean `>=8.1.0 <8.6.0-dev`, not `>=8.1.0 <=8.5.0`. This caused constraints like `php 8.1 - 8.5` to reject PHP 8.5.3. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-semver')
-rw-r--r--crates/mozart-semver/src/lib.rs96
1 files changed, 89 insertions, 7 deletions
diff --git a/crates/mozart-semver/src/lib.rs b/crates/mozart-semver/src/lib.rs
index 6377785..29df538 100644
--- a/crates/mozart-semver/src/lib.rs
+++ b/crates/mozart-semver/src/lib.rs
@@ -668,6 +668,14 @@ fn parse_wildcard(s: &str) -> Result<VersionConstraint, String> {
}
/// Parse `1.0 - 2.0` hyphen range.
+///
+/// Follows Composer semantics:
+/// - If the upper bound is a **full** version (3+ numeric segments or has a
+/// pre-release suffix), the upper constraint is `<=upper`.
+/// - If the upper bound is **partial** (1 or 2 numeric segments without a
+/// pre-release suffix), the next significant release is computed and the
+/// upper constraint becomes `< next-dev`. For example `8.5` → `< 8.6.0-dev`,
+/// `2` → `< 3.0.0-dev`.
fn parse_hyphen_range(s: &str) -> Result<VersionConstraint, String> {
let parts: Vec<&str> = s.splitn(2, " - ").collect();
if parts.len() != 2 {
@@ -675,14 +683,66 @@ fn parse_hyphen_range(s: &str) -> Result<VersionConstraint, String> {
}
let lower_v = Version::parse_for_constraint(parts[0].trim())?;
- let upper_v = Version::parse_for_constraint(parts[1].trim())?;
+
+ let upper_raw = parts[1].trim();
+ let upper_constraint = hyphen_upper_bound(upper_raw)?;
Ok(VersionConstraint::And(vec![
VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)),
- VersionConstraint::Single(Constraint::LessThanOrEqual(upper_v)),
+ upper_constraint,
]))
}
+/// Compute the upper-bound constraint for a hyphen range.
+fn hyphen_upper_bound(raw: &str) -> Result<VersionConstraint, String> {
+ // Strip leading 'v'/'V' for segment counting.
+ let stripped = raw
+ .strip_prefix('v')
+ .or_else(|| raw.strip_prefix('V'))
+ .unwrap_or(raw);
+
+ // Separate numeric part from any pre-release suffix (e.g. "1.2.3-beta1").
+ let (numeric_part, has_pre_release) =
+ match stripped.find(|c: char| c == '-' && !c.is_ascii_digit()) {
+ Some(_) => {
+ // There's a '-' that is NOT inside the " - " separator (already split).
+ // If it looks like a pre-release suffix, treat as full version.
+ let has_suffix = stripped.contains('-') && {
+ let after_dash = &stripped[stripped.find('-').unwrap() + 1..];
+ after_dash.chars().next().is_some_and(|c| c.is_alphabetic())
+ };
+ (stripped.split('-').next().unwrap_or(stripped), has_suffix)
+ }
+ None => (stripped, false),
+ };
+
+ let segments: Vec<&str> = numeric_part.split('.').collect();
+ let segment_count = segments.len();
+
+ if has_pre_release || segment_count >= 3 {
+ // Full version → inclusive upper bound.
+ let upper_v = Version::parse_for_constraint(raw)?;
+ return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(
+ upper_v,
+ )));
+ }
+
+ // Partial version → exclusive upper bound at the next significant release.
+ let upper_v = Version::parse_for_constraint(raw)?;
+ let next = match segment_count {
+ 1 => {
+ // "2" → < 3.0.0.0-dev
+ Version::dev_boundary(upper_v.major + 1, 0, 0, 0)
+ }
+ _ => {
+ // "2.3" → < 2.4.0.0-dev
+ Version::dev_boundary(upper_v.major, upper_v.minor + 1, 0, 0)
+ }
+ };
+
+ Ok(VersionConstraint::Single(Constraint::LessThan(next)))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -965,10 +1025,12 @@ mod tests {
#[test]
fn test_parse_hyphen_range() {
+ // "1.0 - 2.0" → >=1.0.0.0 <2.1.0.0-dev (upper is partial)
let c = VersionConstraint::parse("1.0 - 2.0").unwrap();
assert!(c.matches(&Version::parse("1.0.0").unwrap()));
assert!(c.matches(&Version::parse("1.5.0").unwrap()));
assert!(c.matches(&Version::parse("2.0.0").unwrap()));
+ assert!(c.matches(&Version::parse("2.0.5").unwrap()));
assert!(!c.matches(&Version::parse("0.9.0").unwrap()));
assert!(!c.matches(&Version::parse("2.1.0").unwrap()));
}
@@ -1483,9 +1545,11 @@ mod tests {
#[test]
fn test_hyphen_range_partial_to() {
- // "1.0 - 2.0": upper = <=2.0.0 (inclusive)
+ // "1.0 - 2.0": upper is partial → <2.1.0-dev (includes all 2.0.x)
assert!(satisfies("1.0 - 2.0", "2.0.0"));
- assert!(!satisfies("1.0 - 2.0", "2.0.1"));
+ assert!(satisfies("1.0 - 2.0", "2.0.1"));
+ assert!(satisfies("1.0 - 2.0", "2.0.99"));
+ assert!(!satisfies("1.0 - 2.0", "2.1.0"));
}
#[test]
@@ -1956,10 +2020,28 @@ mod tests {
#[test]
fn test_hyphen_range_partial_upper_two_segment() {
- // "1.0 - 2": upper is <=2.0.0 (parse "2" → 2.0.0.0, inclusive)
+ // "1.0 - 2": upper is partial (1 segment) → <3.0.0.0-dev
assert!(satisfies("1.0 - 2", "2.0.0"));
- assert!(!satisfies("1.0 - 2", "2.0.1"));
- assert!(!satisfies("1.0 - 2", "2.1.0"));
+ assert!(satisfies("1.0 - 2", "2.0.1"));
+ assert!(satisfies("1.0 - 2", "2.1.0"));
+ assert!(satisfies("1.0 - 2", "2.99.99"));
+ assert!(!satisfies("1.0 - 2", "3.0.0"));
+ }
+
+ #[test]
+ fn test_hyphen_range_php_version_constraint() {
+ // "8.1 - 8.5" as used by nette/schema → >=8.1.0.0 <8.6.0.0-dev
+ assert!(satisfies("8.1 - 8.5", "8.1.0"));
+ assert!(satisfies("8.1 - 8.5", "8.3.0"));
+ assert!(satisfies("8.1 - 8.5", "8.5.0"));
+ assert!(satisfies("8.1 - 8.5", "8.5.3"));
+ assert!(!satisfies("8.1 - 8.5", "8.0.99"));
+ assert!(!satisfies("8.1 - 8.5", "8.6.0"));
+ assert!(!satisfies("8.1 - 8.5", "9.0.0"));
+
+ // Full upper bound: "1.0.0 - 2.0.0" → >=1.0.0 <=2.0.0
+ assert!(satisfies("1.0.0 - 2.0.0", "2.0.0"));
+ assert!(!satisfies("1.0.0 - 2.0.0", "2.0.1"));
}
#[test]