Skip to main content

composer_semver/
constraint.rs

1//! Composer-flavored constraint: parse + `matches`.
2//!
3//! Mirrors `Composer\Semver\VersionParser::parseConstraints` and
4//! `Constraint::matches` from `composer/semver` (commit
5//! `09af5e85b5f1380e4e098dde28950e2549cba4ed`, the version the
6//! conformance fixtures were generated from).
7//!
8//! Grammar (informally):
9//!
10//! ```text
11//! constraint := alt ("||" alt)*
12//! alt        := atom (sep atom)*      -- sep is whitespace or ","
13//! atom       := "*" | "x"
14//!             | partial "-" partial         -- hyphenated range
15//!             | caret_or_tilde version
16//!             | op version                  -- >, >=, <, <=, =, ==, !=
17//!             | partial_or_wildcard         -- 1, 1.2, 1.2.3, 1.2.*, 2.x.x
18//! ```
19//!
20//! Each atom canonicalizes into an `And` of [`Op { op, version }`]
21//! atoms; multi-atom intersections are `And`; `||`-joined groups are
22//! `Or`. Matching is then a straight tree walk against the candidate
23//! version using [`Version::compare`].
24
25use crate::bound::Bound;
26use crate::version::{
27    CmpOp, Suffix, Version, VersionKind, is_branch_alias as composer_semver_is_branch_alias,
28};
29use regex::Regex;
30use std::sync::OnceLock;
31
32#[derive(Debug, Clone, Eq, PartialEq)]
33pub enum Constraint {
34    /// `*` / `x` — matches everything.
35    Any,
36    /// `op X.Y.Z` — atomic operator clause. `explicit_lower_bound` is
37    /// true when this clause is a `>=`/`>` whose version was written
38    /// in full (`^1.2.3`, `>=1.2.3`) rather than synthesized from a
39    /// partial expansion (`1` → `>=1.0.0`). Composer admits same-numeric
40    /// prereleases at the lower bound only in the explicit case.
41    Op {
42        op: CmpOp,
43        version: Version,
44        explicit_lower_bound: bool,
45    },
46    /// Whitespace-or-comma-joined intersection.
47    And(Vec<Constraint>),
48    /// `||`-joined union.
49    Or(Vec<Constraint>),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum ParseError {
54    Invalid(String),
55}
56
57impl std::fmt::Display for ParseError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Invalid(s) => write!(f, "invalid constraint: {s:?}"),
61        }
62    }
63}
64
65impl std::error::Error for ParseError {}
66
67impl Constraint {
68    /// Parse a Composer constraint string.
69    ///
70    /// # Panics
71    ///
72    /// Doesn't: the internal `.unwrap()`s reach `parsed.into_iter().next()`
73    /// only when `parsed.len() == 1` was just checked.
74    pub fn parse(input: &str) -> Result<Self, ParseError> {
75        let trimmed = input.trim();
76        if trimmed.is_empty() {
77            return Err(ParseError::Invalid(input.to_owned()));
78        }
79        // Split on `||` first.
80        let alts: Vec<&str> = re_or_split()
81            .split(trimmed)
82            .map(str::trim)
83            .filter(|s| !s.is_empty())
84            .collect();
85        if alts.is_empty() {
86            return Err(ParseError::Invalid(input.to_owned()));
87        }
88        let parsed: Vec<Constraint> = alts
89            .into_iter()
90            .map(parse_intersection)
91            .collect::<Result<_, _>>()?;
92        Ok(if parsed.len() == 1 {
93            parsed.into_iter().next().unwrap()
94        } else {
95            Self::Or(parsed)
96        })
97    }
98
99    /// Return whether `version` satisfies this constraint.
100    ///
101    /// Differs from `version.compare(op, target)` in the
102    /// prerelease-vs-stable edge case: when `version` is a prerelease
103    /// (e.g. `1.2.3-beta`) and `target` is the stable form with the
104    /// same numeric body (`1.2.3`), Composer's constraint engine
105    /// treats them as `Equal` rather than the normal "prerelease <
106    /// stable" ordering. This is what makes `1.2.3-beta` satisfy
107    /// `^1.2.3` and *not* satisfy `<1.2.3`.
108    pub fn matches(&self, version: &Version) -> bool {
109        match self {
110            Self::Any => true,
111            Self::Op {
112                op,
113                version: target,
114                explicit_lower_bound,
115            } => matches_atom(version, *op, target, *explicit_lower_bound),
116            Self::And(items) => items.iter().all(|c| c.matches(version)),
117            Self::Or(items) => items.iter().any(|c| c.matches(version)),
118        }
119    }
120
121    /// The lowest version this constraint admits, as a [`Bound`]. Port
122    /// of `Constraint::getLowerBound` + `MultiConstraint::extractBounds`
123    /// (commit `09af5e8`):
124    ///
125    /// - atomic `==`/`>=` → inclusive bound at the version; `>` →
126    ///   exclusive; `<`/`<=`/`!=` → [`Bound::zero`]; a `dev-` branch
127    ///   reference → `Bound::zero` (Composer's `strpos(... 'dev-') === 0`
128    ///   short-circuit).
129    /// - `And` (conjunctive) → the *greatest* of the members' lower
130    ///   bounds; `Or` (disjunctive) → the *least*.
131    ///
132    /// `Any` (`*` / `x`) is `Bound::zero`, matching
133    /// `MatchAllConstraint`.
134    #[must_use]
135    pub fn lower_bound(&self) -> Bound {
136        match self {
137            Self::Any => Bound::zero(),
138            Self::Op { op, version, .. } => {
139                if version.normalized.starts_with("dev-") {
140                    return Bound::zero();
141                }
142                match op {
143                    CmpOp::Eq | CmpOp::Ge => Bound::new(version.normalized.clone(), true),
144                    CmpOp::Gt => Bound::new(version.normalized.clone(), false),
145                    CmpOp::Lt | CmpOp::Le | CmpOp::Ne => Bound::zero(),
146                }
147            }
148            Self::And(items) => fold_bound(items, true, Constraint::lower_bound),
149            Self::Or(items) => fold_bound(items, false, Constraint::lower_bound),
150        }
151    }
152
153    /// The highest version this constraint admits, as a [`Bound`].
154    /// Mirror of [`Constraint::lower_bound`] on the upper side:
155    /// `==`/`<=` → inclusive; `<` → exclusive; `>`/`>=`/`!=`/`*` →
156    /// [`Bound::positive_infinity`]. `And` → the *least* of the members'
157    /// upper bounds; `Or` → the *greatest*.
158    #[must_use]
159    pub fn upper_bound(&self) -> Bound {
160        match self {
161            Self::Any => Bound::positive_infinity(),
162            Self::Op { op, version, .. } => {
163                if version.normalized.starts_with("dev-") {
164                    return Bound::positive_infinity();
165                }
166                match op {
167                    CmpOp::Eq | CmpOp::Le => Bound::new(version.normalized.clone(), true),
168                    CmpOp::Lt => Bound::new(version.normalized.clone(), false),
169                    CmpOp::Gt | CmpOp::Ge | CmpOp::Ne => Bound::positive_infinity(),
170                }
171            }
172            // For the upper fold the direction flips: conjunctive keeps
173            // the smaller (`'<'`), disjunctive the larger (`'>'`).
174            Self::And(items) => fold_bound(items, false, Constraint::upper_bound),
175            Self::Or(items) => fold_bound(items, true, Constraint::upper_bound),
176        }
177    }
178
179    /// Whether `self` and `other` admit at least one common version —
180    /// i.e. their version intervals overlap. Used by the autoloader's
181    /// `platform_check.php` generator to honor `replace`/`provide` of an
182    /// extension (Composer's `$provided->matches($link->getConstraint())`).
183    ///
184    /// `Or` is the union of its members, so it intersects `other` iff
185    /// any member does. Everything else reduces to a single contiguous
186    /// interval, so the two overlap iff `max(lowers) <= min(uppers)`
187    /// (with endpoint inclusivity deciding the touching case).
188    #[must_use]
189    pub fn intersects(&self, other: &Constraint) -> bool {
190        match (self, other) {
191            (Self::Or(items), _) => items.iter().any(|c| c.intersects(other)),
192            (_, Self::Or(items)) => items.iter().any(|c| self.intersects(c)),
193            (Self::Any, _) | (_, Self::Any) => true,
194            _ => intervals_overlap(self, other),
195        }
196    }
197}
198
199/// Fold a member list into a single bound. `keep_greater` selects the
200/// max (`true`) or the min (`false`) under [`Bound::compare_to`], which
201/// is exactly what `MultiConstraint::extractBounds` does for the
202/// conjunctive/disjunctive cases. `extract` pulls the per-member bound
203/// (lower or upper).
204fn fold_bound(
205    items: &[Constraint],
206    keep_greater: bool,
207    extract: fn(&Constraint) -> Bound,
208) -> Bound {
209    let mut acc: Option<Bound> = None;
210    for c in items {
211        let b = extract(c);
212        match &acc {
213            None => acc = Some(b),
214            Some(cur) => {
215                if b.compare_to(cur, keep_greater) {
216                    acc = Some(b);
217                }
218            }
219        }
220    }
221    acc.unwrap_or_else(Bound::zero)
222}
223
224/// Overlap test for two single-interval constraints: the effective
225/// lower bound is the greater of the two lowers, the effective upper is
226/// the lesser of the two uppers, and the interval is non-empty iff the
227/// lower sits below the upper (or they touch and both endpoints are
228/// inclusive).
229fn intervals_overlap(a: &Constraint, b: &Constraint) -> bool {
230    let la = a.lower_bound();
231    let lb = b.lower_bound();
232    let ua = a.upper_bound();
233    let ub = b.upper_bound();
234    let lower = if la.compare_to(&lb, true) { la } else { lb };
235    let upper = if ua.compare_to(&ub, false) { ua } else { ub };
236
237    if lower.is_zero() || upper.is_positive_infinity() {
238        return true;
239    }
240    if lower.is_positive_infinity() {
241        return false;
242    }
243    // Both bounds are concrete versions: the interval is non-empty when
244    // `lower < upper`, or when they coincide and neither endpoint
245    // excludes the shared version.
246    match lower.version_cmp(&upper) {
247        std::cmp::Ordering::Less => true,
248        std::cmp::Ordering::Greater => false,
249        std::cmp::Ordering::Equal => lower.is_inclusive() && upper.is_inclusive(),
250    }
251}
252
253fn matches_atom(candidate: &Version, op: CmpOp, target: &Version, explicit_lower: bool) -> bool {
254    if same_numeric_prerelease_vs_stable(candidate, target) {
255        // Upper-bound ops (`<`, `<=`) always treat the prerelease as
256        // "equal to" the stable so it sits at the boundary; the
257        // candidate consequently doesn't satisfy a strict `<`.
258        // Lower-bound ops (`>=`, `>`) only honor that equality when
259        // the constraint was written with a full version (a partial
260        // expansion `1` → `>=1.0.0` should reject `1.0.0-beta`).
261        // `==`/`!=` follow the same "treat as equal" path because
262        // Composer's matchSpecific does too.
263        let admit_as_equal = match op {
264            CmpOp::Lt | CmpOp::Le | CmpOp::Eq | CmpOp::Ne => true,
265            CmpOp::Gt | CmpOp::Ge => explicit_lower,
266        };
267        if admit_as_equal {
268            return matches!(op, CmpOp::Eq | CmpOp::Ge | CmpOp::Le);
269        }
270    }
271    candidate.compare(op, target)
272}
273
274/// True iff `a` and `b` are both numeric, have the same numeric
275/// segments, and exactly one is a prerelease (Prerelease /
276/// `PrereleaseDev` / `PatchDev` / Dev) while the other is Stable.
277fn same_numeric_prerelease_vs_stable(a: &Version, b: &Version) -> bool {
278    let (
279        VersionKind::Numeric {
280            segments_raw: sa,
281            suffix: suf_a,
282        },
283        VersionKind::Numeric {
284            segments_raw: sb,
285            suffix: suf_b,
286        },
287    ) = (&a.kind, &b.kind)
288    else {
289        return false;
290    };
291    if !segments_equal_numerically(sa, sb) {
292        return false;
293    }
294    is_prerelease(suf_a) ^ is_prerelease(suf_b)
295}
296
297fn is_prerelease(s: &Suffix) -> bool {
298    matches!(
299        s,
300        Suffix::Prerelease { .. }
301            | Suffix::PrereleaseDev { .. }
302            | Suffix::PatchDev(_)
303            | Suffix::Dev
304    )
305}
306
307fn segments_equal_numerically(a: &[String], b: &[String]) -> bool {
308    let len = a.len().max(b.len());
309    for i in 0..len {
310        let va: u64 = a.get(i).and_then(|s| s.parse().ok()).unwrap_or(0);
311        let vb: u64 = b.get(i).and_then(|s| s.parse().ok()).unwrap_or(0);
312        if va != vb {
313            return false;
314        }
315    }
316    true
317}
318
319// ---- top-level splitters -----------------------------------------------
320
321fn re_or_split() -> &'static Regex {
322    static R: OnceLock<Regex> = OnceLock::new();
323    R.get_or_init(|| Regex::new(r"\s*\|\|?\s*").unwrap())
324}
325
326/// One `||`-alt: a whitespace-or-comma-separated list of atoms,
327/// possibly with a hyphenated range form (`A - B`) inside. Returns
328/// the intersection as an [`And`] (or the bare atom if there's only
329/// one).
330fn parse_intersection(s: &str) -> Result<Constraint, ParseError> {
331    // Handle hyphenated range `A - B` first — the `-` is whitespace-
332    // delimited (`A-B` without spaces would be a single token).
333    if let Some(range) = parse_hyphen_range(s)? {
334        return Ok(range);
335    }
336    let tokens = tokenize_intersection(s);
337    let parsed: Vec<Constraint> = tokens
338        .iter()
339        .map(|t| parse_atom(t))
340        .collect::<Result<_, _>>()?;
341    if parsed.is_empty() {
342        return Err(ParseError::Invalid(s.to_owned()));
343    }
344    Ok(if parsed.len() == 1 {
345        parsed.into_iter().next().unwrap()
346    } else {
347        Constraint::And(parsed)
348    })
349}
350
351/// Tokenize an intersection: split on whitespace OR `,`, but keep
352/// operator + version pairs glued together (`>= 1.2.3` becomes one
353/// token `>=1.2.3`).
354fn tokenize_intersection(s: &str) -> Vec<String> {
355    let mut out: Vec<String> = Vec::new();
356    let mut cur = String::new();
357    let mut chars = s.chars().peekable();
358    while let Some(ch) = chars.next() {
359        if ch == ',' || ch.is_whitespace() {
360            // If we're mid-operator (e.g. just saw `>=`), absorb the
361            // whitespace and continue building the token from the
362            // next non-space chars. We detect this by checking if
363            // `cur` is a pure-operator prefix.
364            if !cur.is_empty() && is_pending_operator(&cur) {
365                // Skip whitespace and continue with the version.
366                while let Some(&peek) = chars.peek() {
367                    if peek.is_whitespace() {
368                        chars.next();
369                    } else {
370                        break;
371                    }
372                }
373                continue;
374            }
375            if !cur.is_empty() {
376                out.push(std::mem::take(&mut cur));
377            }
378            continue;
379        }
380        cur.push(ch);
381    }
382    if !cur.is_empty() {
383        out.push(cur);
384    }
385    out
386}
387
388/// True when the current accumulator looks like an operator prefix
389/// that's waiting for its version operand. Catches `>=`, `<=`, `>`,
390/// `<`, `=`, `==`, `!=`, `<>`.
391fn is_pending_operator(s: &str) -> bool {
392    matches!(s, ">" | ">=" | "<" | "<=" | "=" | "==" | "!=" | "<>")
393}
394
395// ---- hyphenated range --------------------------------------------------
396
397fn re_hyphen() -> &'static Regex {
398    static R: OnceLock<Regex> = OnceLock::new();
399    R.get_or_init(|| {
400        // `(A) ... - ... (B)` — the dash must have whitespace on at
401        // least one side (so `2.4.0-alpha` is not split). Build
402        // metadata after either side is allowed (we strip it on the
403        // partial parse).
404        Regex::new(r"^(?P<lo>[^\s]+)\s+-\s+(?P<hi>[^\s]+)$").unwrap()
405    })
406}
407
408fn parse_hyphen_range(s: &str) -> Result<Option<Constraint>, ParseError> {
409    let Some(caps) = re_hyphen().captures(s.trim()) else {
410        return Ok(None);
411    };
412    let lo_str = caps.name("lo").unwrap().as_str();
413    let hi_str = caps.name("hi").unwrap().as_str();
414    let lo = expand_partial_lower(lo_str)?;
415    let hi = expand_partial_upper(hi_str)?;
416    Ok(Some(Constraint::And(vec![
417        Constraint::Op {
418            op: CmpOp::Ge,
419            version: lo,
420            explicit_lower_bound: false,
421        },
422        Constraint::Op {
423            op: CmpOp::Le,
424            version: hi,
425            explicit_lower_bound: false,
426        },
427    ])))
428}
429
430/// Expand a partial version (`1.2` / `1` / `1.2.3`) for the lower
431/// bound of a hyphenated range — missing segments become `0`.
432fn expand_partial_lower(s: &str) -> Result<Version, ParseError> {
433    // Strip build metadata; doesn't affect comparison.
434    let cleaned = s.split_once('+').map_or(s, |(left, _)| left);
435    let padded = pad_partial(cleaned);
436    Version::parse(&padded).map_err(|e| ParseError::Invalid(e.to_string()))
437}
438
439/// Expand a partial version for the upper bound — missing segments
440/// become `9999999` so the comparison `<=` reaches every patch in
441/// the named minor / major.
442fn expand_partial_upper(s: &str) -> Result<Version, ParseError> {
443    let cleaned = s.split_once('+').map_or(s, |(left, _)| left);
444    // Composer's `1.0 - 2.0` is `>=1.0.0, <2.1` — partials on the
445    // upper bound expand to "anything in the named scope". For our
446    // representation we keep the inclusive form by parsing with the
447    // 9999999 pads.
448    let segs = split_partial_segments(cleaned);
449    if segs.is_empty() {
450        return Err(ParseError::Invalid(s.to_owned()));
451    }
452    let padded = pad_segments_with(segs, "9999999", 4);
453    let candidate = padded.join(".");
454    Version::parse(&candidate).map_err(|e| ParseError::Invalid(e.to_string()))
455}
456
457// ---- atom parsing ------------------------------------------------------
458
459fn parse_atom(token: &str) -> Result<Constraint, ParseError> {
460    let s = token.trim();
461    if s.is_empty() {
462        return Err(ParseError::Invalid(token.to_owned()));
463    }
464    // Strip any `#<commit-ref>` suffix Composer accepts on any
465    // constraint atom (`"acme/foo": "3.x-dev#abc1234"`). For
466    // resolution purposes the ref is purely informational — the
467    // installer uses it to pin a specific commit of the branch, but
468    // the matcher behaves the same as without it.
469    let s = s.split_once('#').map_or(s, |(left, _)| left).trim_end();
470    // `*` / `x` / `X`
471    if matches!(s, "*" | "x" | "X") {
472        return Ok(Constraint::Any);
473    }
474
475    // Branch references appear in Composer constraints in two
476    // related shapes (cross-referenced against Composer's
477    // `Composer\Semver\VersionParser::parseConstraint` "Basic
478    // Comparators" fallback + `parseStability`):
479    //   1. `Nx-dev` / `N.x-dev` / `N.M.x-dev` — numeric-flavor
480    //      branch alias (e.g. `3.x-dev`). `Version::parse`
481    //      normalizes this to `N.9999999.9999999.9999999-dev`.
482    //   2. `dev-<branch-name>` — bare branch reference (e.g.
483    //      `dev-main`, `dev-feature/foo`). `Version::parse` keeps
484    //      this as `VersionKind::Branch("<name>")`.
485    // Both map to an `==` constraint against the same string
486    // re-parsed as a Version — matches Composer's "operator `=`,
487    // version normalized" handling. Composer matches `dev-`
488    // case-insensitively (`stripos`/`/i` regex) so we do too.
489    // Marked explicit-lower-bound so any same-numeric-prerelease
490    // comparison falls through to standard ordering.
491    let is_dev_branch = s.len() >= 4 && s.as_bytes()[..4].eq_ignore_ascii_case(b"dev-");
492    if composer_semver_is_branch_alias(s) || is_dev_branch {
493        let v = Version::parse(s).map_err(|e| ParseError::Invalid(format!("{token}: {e}")))?;
494        return Ok(Constraint::Op {
495            op: CmpOp::Eq,
496            version: v,
497            explicit_lower_bound: true,
498        });
499    }
500
501    // Caret: `^X.Y.Z`
502    if let Some(rest) = s.strip_prefix('^') {
503        return parse_caret(rest.trim());
504    }
505    // Tilde: `~X.Y.Z`
506    if let Some(rest) = s.strip_prefix('~') {
507        return parse_tilde(rest.trim());
508    }
509
510    // Operators: `>=`, `<=`, `>`, `<`, `=`, `==`, `!=`, `<>`.
511    for prefix in &[">=", "<=", "==", "!=", "<>", ">", "<", "="] {
512        if let Some(rest) = s.strip_prefix(*prefix) {
513            let op = match *prefix {
514                ">=" => CmpOp::Ge,
515                "<=" => CmpOp::Le,
516                ">" => CmpOp::Gt,
517                "<" => CmpOp::Lt,
518                "==" | "=" => CmpOp::Eq,
519                "!=" | "<>" => CmpOp::Ne,
520                _ => unreachable!(),
521            };
522            let v = Version::parse(rest.trim())
523                .map_err(|e| ParseError::Invalid(format!("{token}: {e}")))?;
524            // Explicit lower bound iff the user wrote `>=`/`>` with
525            // all three numeric segments. Three+ written segments
526            // implies an intentional pre-release boundary; fewer
527            // implies partial expansion semantics.
528            let explicit_lower_bound =
529                matches!(op, CmpOp::Ge | CmpOp::Gt) && wrote_full_version(rest.trim());
530            return Ok(Constraint::Op {
531                op,
532                version: v,
533                explicit_lower_bound,
534            });
535        }
536    }
537
538    // Wildcard inside a numeric expression: `1.2.*`, `2.x.x`,
539    // `1.2.X`.
540    if contains_wildcard(s) {
541        return parse_wildcard(s);
542    }
543
544    // Bare partial or full version: `1.2.3` → ==, `1.2` → range
545    // covering all of 1.2, `1` → range covering all of 1.
546    match parse_partial_or_exact(s) {
547        Ok(c) => Ok(c),
548        Err(e) => {
549            // Composer's `parseConstraint` "Basic Comparators"
550            // fallback: if normalize fails AND the constraint ends
551            // in `-dev` AND its body is "name-safe"
552            // (`[0-9a-zA-Z-./]+`), retry as `dev-<body>`. This is
553            // what makes `master-dev` work as a synonym for
554            // `dev-master`. Only the constraint path does this —
555            // `Version::normalize` itself rejects these.
556            if let Some(body) = strip_dev_suffix(s) {
557                if is_name_safe(body) {
558                    if let Ok(v) = Version::parse(&format!("dev-{body}")) {
559                        return Ok(Constraint::Op {
560                            op: CmpOp::Eq,
561                            version: v,
562                            explicit_lower_bound: true,
563                        });
564                    }
565                }
566            }
567            Err(e)
568        }
569    }
570}
571
572fn strip_dev_suffix(s: &str) -> Option<&str> {
573    if s.len() >= 5 && s.as_bytes()[s.len() - 4..].eq_ignore_ascii_case(b"-dev") {
574        Some(&s[..s.len() - 4])
575    } else {
576        None
577    }
578}
579
580/// Composer's `{^[0-9a-zA-Z-./]+$}` gate on the `<name>-dev` recovery.
581fn is_name_safe(s: &str) -> bool {
582    !s.is_empty()
583        && s.bytes()
584            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'/'))
585}
586
587fn contains_wildcard(s: &str) -> bool {
588    s.split('.').any(|p| matches!(p, "*" | "x" | "X"))
589}
590
591fn parse_caret(rest: &str) -> Result<Constraint, ParseError> {
592    // The upper bound bumps the first element that is either non-zero
593    // OR the last one the user actually wrote — Composer's
594    // `parseCaretConstraint` position rule (VersionParser):
595    //
596    //   `^1.2.3` / `^1` / `^1.2` → `<2.0.0`   (major ≥ 1 → bump major)
597    //   `^0`                     → `<1.0.0`   (minor unwritten → bump major)
598    //   `^0.3` / `^0.3.0`        → `<0.4.0`   (minor non-zero → bump minor)
599    //   `^0.0`                   → `<0.1.0`   (patch unwritten → bump minor)
600    //   `^0.0.3`                 → `<0.0.4`   (patch non-zero → bump patch)
601    //
602    // The subtlety is the all-zeros prefix: which element is "fluid"
603    // depends on how many were specified, not just on their values.
604    // Treating bare `^0` as `^0.0.0` (the previous behavior) yielded
605    // `<0.0.1`, which excludes every real 0.x release — exactly what
606    // broke `openai-php/client: "^0"` in the Mage-OS graph.
607    let segs = split_partial_numeric(rest)?;
608    if segs.is_empty() {
609        return Err(ParseError::Invalid(rest.to_owned()));
610    }
611    let major: u32 = segs[0].parse().unwrap_or(0);
612    let minor: u32 = segs.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
613    let minor_specified = segs.len() >= 2;
614    let patch_specified = segs.len() >= 3;
615    let lower = Version::parse(rest)
616        .or_else(|_| Version::parse(&pad_partial(rest)))
617        .map_err(|e| ParseError::Invalid(format!("^{rest}: {e}")))?;
618    let upper_str = if major > 0 || !minor_specified {
619        format!("{}.0.0.0", major + 1)
620    } else if minor > 0 || !patch_specified {
621        format!("0.{}.0.0", minor + 1)
622    } else {
623        // `^0.0.Z` → `>=0.0.Z, <0.0.Z+1` (only the patch is fluid).
624        let patch: u32 = segs.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
625        format!("0.0.{}.0", patch + 1)
626    };
627    let upper = Version::parse(&upper_str)
628        .map_err(|e| ParseError::Invalid(format!("^{rest} upper: {e}")))?;
629    // The caret's lower bound is "explicit" when the user wrote a
630    // full X.Y.Z — that's when prereleases of X.Y.Z are admissible.
631    let explicit_lower = wrote_full_version(rest);
632    Ok(Constraint::And(vec![
633        Constraint::Op {
634            op: CmpOp::Ge,
635            version: lower,
636            explicit_lower_bound: explicit_lower,
637        },
638        Constraint::Op {
639            op: CmpOp::Lt,
640            version: upper,
641            explicit_lower_bound: false,
642        },
643    ]))
644}
645
646fn parse_tilde(rest: &str) -> Result<Constraint, ParseError> {
647    // `~X.Y.Z[-stab]` → `>=X.Y.Z[-stab], <X.Y+1.0`
648    // `~X.Y`         → `>=X.Y.0, <X+1.0.0`     (major-floor)
649    // `~X`           → `>=X.0.0, <X+1.0.0`
650    let segs = split_partial_numeric(rest)?;
651    if segs.is_empty() {
652        return Err(ParseError::Invalid(rest.to_owned()));
653    }
654    let lower = Version::parse(rest)
655        .or_else(|_| Version::parse(&pad_partial(rest)))
656        .map_err(|e| ParseError::Invalid(format!("~{rest}: {e}")))?;
657    let upper_str = match segs.len() {
658        1 => {
659            let major: u32 = segs[0].parse().unwrap_or(0);
660            format!("{}.0.0.0", major + 1)
661        }
662        2 => {
663            let major: u32 = segs[0].parse().unwrap_or(0);
664            format!("{}.0.0.0", major + 1)
665        }
666        _ => {
667            let major: u32 = segs[0].parse().unwrap_or(0);
668            let minor: u32 = segs[1].parse().unwrap_or(0);
669            format!("{}.{}.0.0", major, minor + 1)
670        }
671    };
672    let upper = Version::parse(&upper_str)
673        .map_err(|e| ParseError::Invalid(format!("~{rest} upper: {e}")))?;
674    let explicit_lower = wrote_full_version(rest);
675    Ok(Constraint::And(vec![
676        Constraint::Op {
677            op: CmpOp::Ge,
678            version: lower,
679            explicit_lower_bound: explicit_lower,
680        },
681        Constraint::Op {
682            op: CmpOp::Lt,
683            version: upper,
684            explicit_lower_bound: false,
685        },
686    ]))
687}
688
689fn parse_wildcard(s: &str) -> Result<Constraint, ParseError> {
690    // `1.2.*` → `>=1.2.0, <1.3.0`
691    // `2.*.*` → `>=2.0.0, <3.0.0` (wildcard at position 1)
692    // `2.x.x` → same as above
693    let parts: Vec<&str> = s.split('.').collect();
694    // The position of the FIRST wildcard determines the floor.
695    let first_wild = parts
696        .iter()
697        .position(|p| matches!(*p, "*" | "x" | "X"))
698        .ok_or_else(|| ParseError::Invalid(s.to_owned()))?;
699    let numeric_prefix: Vec<u32> = parts[..first_wild]
700        .iter()
701        .map(|p| p.parse().unwrap_or(0))
702        .collect();
703    let lower_segs: Vec<String> = numeric_prefix
704        .iter()
705        .map(u32::to_string)
706        .chain(std::iter::repeat_n(
707            "0".to_owned(),
708            4usize.saturating_sub(first_wild),
709        ))
710        .collect();
711    let lower = Version::parse(&lower_segs.join("."))
712        .map_err(|e| ParseError::Invalid(format!("{s} lower: {e}")))?;
713
714    let mut upper_segs = numeric_prefix.clone();
715    if first_wild == 0 {
716        // `*` alone → Any.
717        return Ok(Constraint::Any);
718    }
719    *upper_segs.last_mut().unwrap() += 1;
720    let upper_str: String = upper_segs
721        .iter()
722        .map(u32::to_string)
723        .chain(std::iter::repeat_n(
724            "0".to_owned(),
725            4usize.saturating_sub(upper_segs.len()),
726        ))
727        .collect::<Vec<_>>()
728        .join(".");
729    let upper =
730        Version::parse(&upper_str).map_err(|e| ParseError::Invalid(format!("{s} upper: {e}")))?;
731    Ok(Constraint::And(vec![
732        Constraint::Op {
733            op: CmpOp::Ge,
734            version: lower,
735            explicit_lower_bound: false,
736        },
737        Constraint::Op {
738            op: CmpOp::Lt,
739            version: upper,
740            explicit_lower_bound: false,
741        },
742    ]))
743}
744
745fn parse_partial_or_exact(s: &str) -> Result<Constraint, ParseError> {
746    // Strip build metadata (`+...`) — Composer accepts it on
747    // constraint atoms and ignores it for matching.
748    let cleaned = s.split_once('+').map_or(s, |(left, _)| left);
749    let segs = split_partial_numeric(cleaned)?;
750    if segs.len() >= 3 {
751        // Fully qualified → exact match (==). Exact equality with a
752        // full version is intentionally "explicit" too — the user
753        // pinned the version on the nose.
754        let v = Version::parse(cleaned).map_err(|e| ParseError::Invalid(format!("{s}: {e}")))?;
755        return Ok(Constraint::Op {
756            op: CmpOp::Eq,
757            version: v,
758            explicit_lower_bound: true,
759        });
760    }
761    // Partial — expand into a wildcard-style range.
762    // `1.2` → `>=1.2.0, <1.3.0`
763    // `1`   → `>=1.0.0, <2.0.0`
764    let major: u32 = segs[0].parse().unwrap_or(0);
765    let lower_str = pad_partial(cleaned);
766    let lower =
767        Version::parse(&lower_str).map_err(|e| ParseError::Invalid(format!("{s} lower: {e}")))?;
768    let upper_str = if segs.len() == 1 {
769        format!("{}.0.0.0", major + 1)
770    } else {
771        let minor: u32 = segs[1].parse().unwrap_or(0);
772        format!("{}.{}.0.0", major, minor + 1)
773    };
774    let upper =
775        Version::parse(&upper_str).map_err(|e| ParseError::Invalid(format!("{s} upper: {e}")))?;
776    Ok(Constraint::And(vec![
777        Constraint::Op {
778            op: CmpOp::Ge,
779            version: lower,
780            explicit_lower_bound: false,
781        },
782        Constraint::Op {
783            op: CmpOp::Lt,
784            version: upper,
785            explicit_lower_bound: false,
786        },
787    ]))
788}
789
790// ---- helpers -----------------------------------------------------------
791
792/// True iff `s` looks like a full Composer version (three or more
793/// numeric segments before any stability/build tail). Used to decide
794/// whether a constraint's lower bound is "explicit" enough to admit
795/// same-numeric prereleases.
796fn wrote_full_version(s: &str) -> bool {
797    let s = s.trim();
798    let s = s.strip_prefix(['v', 'V']).unwrap_or(s);
799    let body = s.split_once(['-', '+']).map_or(s, |(left, _)| left);
800    let segs: Vec<&str> = body.split('.').filter(|p| !p.is_empty()).collect();
801    segs.len() >= 3 && segs.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
802}
803
804fn split_partial_numeric(s: &str) -> Result<Vec<String>, ParseError> {
805    let cleaned = s
806        .strip_prefix(['v', 'V'])
807        .unwrap_or(s)
808        .split_once('-')
809        .map_or(s.strip_prefix(['v', 'V']).unwrap_or(s), |(left, _)| left);
810    let segs: Vec<String> = cleaned
811        .split('.')
812        .filter(|p| !p.is_empty())
813        .map(str::to_owned)
814        .collect();
815    if segs.is_empty() {
816        return Err(ParseError::Invalid(s.to_owned()));
817    }
818    // Ensure leading segment is numeric (rejects garbage like
819    // `>=abc`).
820    if !segs[0].chars().all(|c| c.is_ascii_digit()) {
821        return Err(ParseError::Invalid(s.to_owned()));
822    }
823    Ok(segs)
824}
825
826fn split_partial_segments(s: &str) -> Vec<String> {
827    let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
828    let cleaned = stripped.split_once('-').map_or(stripped, |(left, _)| left);
829    cleaned
830        .split('.')
831        .filter(|p| !p.is_empty())
832        .map(str::to_owned)
833        .collect()
834}
835
836/// Pad a partial numeric to 4 segments with `"0"` and reattach any
837/// stability suffix.
838fn pad_partial(s: &str) -> String {
839    let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
840    let (body, tail) = match stripped.split_once('-') {
841        Some((b, t)) => (b, format!("-{t}")),
842        None => (stripped, String::new()),
843    };
844    let segs: Vec<&str> = body.split('.').filter(|p| !p.is_empty()).collect();
845    let mut padded: Vec<String> = segs.iter().map(|p| (*p).to_owned()).collect();
846    while padded.len() < 4 {
847        padded.push("0".to_owned());
848    }
849    format!("{}{}", padded.join("."), tail)
850}
851
852fn pad_segments_with(mut segs: Vec<String>, pad: &str, target: usize) -> Vec<String> {
853    while segs.len() < target {
854        segs.push(pad.to_owned());
855    }
856    segs
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862
863    /// Caret on all-zero / partial-zero versions follows Composer's
864    /// position rule: the bumped element depends on what was written,
865    /// not just on the values. Regression for `openai-php/client: "^0"`
866    /// in the Mage-OS graph, which the old `^0 == ^0.0.0` shortcut
867    /// turned into `<0.0.1` — excluding every published 0.x release.
868    #[test]
869    fn caret_zero_partial_versions_match_composer() {
870        // `^0` admits the whole 0.x line, up to (not incl.) 1.0.0.
871        let c = Constraint::parse("^0").unwrap();
872        assert!(c.matches(&Version::parse("0.0.1").unwrap()));
873        assert!(c.matches(&Version::parse("0.19.2").unwrap()));
874        assert!(!c.matches(&Version::parse("1.0.0").unwrap()));
875
876        // `^0.0` → `<0.1.0` (patch unwritten → bump minor).
877        let c = Constraint::parse("^0.0").unwrap();
878        assert!(c.matches(&Version::parse("0.0.9").unwrap()));
879        assert!(!c.matches(&Version::parse("0.1.0").unwrap()));
880
881        // `^0.3` → `<0.4.0` (minor non-zero → bump minor).
882        let c = Constraint::parse("^0.3").unwrap();
883        assert!(c.matches(&Version::parse("0.3.7").unwrap()));
884        assert!(!c.matches(&Version::parse("0.4.0").unwrap()));
885
886        // `^0.0.3` → `<0.0.4` (patch non-zero → bump patch).
887        let c = Constraint::parse("^0.0.3").unwrap();
888        assert!(c.matches(&Version::parse("0.0.3").unwrap()));
889        assert!(!c.matches(&Version::parse("0.0.4").unwrap()));
890
891        // `^1` is unchanged: `<2.0.0`.
892        let c = Constraint::parse("^1").unwrap();
893        assert!(c.matches(&Version::parse("1.9.9").unwrap()));
894        assert!(!c.matches(&Version::parse("2.0.0").unwrap()));
895    }
896
897    /// Composer accepts `Nx-dev` / `N.x-dev` / `N.M.x-dev` as
898    /// constraint strings meaning "exactly the named dev branch."
899    /// They parse to an `==` constraint against the same string
900    /// re-parsed as a Version (which already canonicalizes
901    /// `3.x-dev` into `3.9999999.9999999.9999999-dev`).
902    #[test]
903    fn nx_dev_constraint_matches_same_branch_version() {
904        let c = Constraint::parse("3.x-dev").unwrap();
905        let v = Version::parse("3.x-dev").unwrap();
906        assert!(c.matches(&v), "got constraint {c:?}");
907    }
908
909    #[test]
910    fn nx_dev_constraint_rejects_other_major_branch() {
911        let c = Constraint::parse("3.x-dev").unwrap();
912        let other = Version::parse("2.x-dev").unwrap();
913        assert!(!c.matches(&other), "got constraint {c:?}");
914    }
915
916    #[test]
917    fn nx_dev_constraint_rejects_stable_release() {
918        // `3.x-dev` is the exact dev branch; a stable `3.0.0` does
919        // not satisfy it.
920        let c = Constraint::parse("3.x-dev").unwrap();
921        let stable = Version::parse("3.0.0").unwrap();
922        assert!(!c.matches(&stable), "got constraint {c:?}");
923    }
924
925    #[test]
926    fn n_dot_x_dev_handles_two_segment_form() {
927        let c = Constraint::parse("1.x-dev").unwrap();
928        let v = Version::parse("1.x-dev").unwrap();
929        assert!(c.matches(&v));
930    }
931
932    #[test]
933    fn n_dot_m_dot_x_dev_handles_three_segment_form() {
934        // `1.0.x-dev` parses to `1.0.9999999.9999999-dev`.
935        let c = Constraint::parse("1.0.x-dev").unwrap();
936        let v = Version::parse("1.0.x-dev").unwrap();
937        assert!(c.matches(&v));
938    }
939
940    #[test]
941    fn dev_branch_constraint_matches_named_branch() {
942        // `"dep": "dev-main"` is the bare branch form — pinned to
943        // the named branch. Parses to `==` against
944        // `Version::Branch("main")`.
945        let c = Constraint::parse("dev-main").unwrap();
946        let v = Version::parse("dev-main").unwrap();
947        assert!(c.matches(&v), "constraint {c:?} should match {v:?}");
948    }
949
950    #[test]
951    fn dev_branch_constraint_rejects_other_branches() {
952        let c = Constraint::parse("dev-main").unwrap();
953        let other = Version::parse("dev-feature-x").unwrap();
954        assert!(!c.matches(&other));
955    }
956
957    #[test]
958    fn name_dash_dev_suffix_is_synonym_for_dev_dash_name() {
959        // Composer's `parseConstraint` (NOT `normalize`) recovers
960        // `master-dev` as a synonym for `dev-master` via the
961        // catch-block fallback. Char class: `[0-9a-zA-Z-./]+`.
962        let c = Constraint::parse("master-dev").unwrap();
963        let v = Version::parse("dev-master").unwrap();
964        assert!(c.matches(&v), "constraint {c:?} should match {v:?}");
965    }
966
967    #[test]
968    fn name_dash_dev_rejects_unsafe_chars() {
969        // Bodies with characters outside Composer's recovery
970        // char-class (`[0-9a-zA-Z-./]+`) must still fail.
971        assert!(Constraint::parse("foo bar-dev").is_err());
972        assert!(Constraint::parse("1.0.0<1.0.5-dev").is_err());
973    }
974
975    #[test]
976    fn name_dash_dev_does_not_swallow_numeric_dev_versions() {
977        // `1.0.0-dev` is a Dev-stability classical version handled
978        // by `parse_partial_or_exact`, not the recovery path.
979        let c = Constraint::parse("1.0.0-dev").unwrap();
980        let v = Version::parse("1.0.0-dev").unwrap();
981        assert!(c.matches(&v));
982    }
983
984    // ---- lower_bound / intersects (platform_check substrate) ----------
985
986    fn lb(s: &str) -> (String, bool) {
987        let b = Constraint::parse(s).unwrap().lower_bound();
988        (b.version().to_owned(), b.is_inclusive())
989    }
990
991    #[test]
992    fn lower_bound_atomic_ops() {
993        assert_eq!(lb(">=8.1"), ("8.1.0.0".to_owned(), true));
994        assert_eq!(lb(">8.0"), ("8.0.0.0".to_owned(), false));
995        assert_eq!(lb("8.1.2"), ("8.1.2.0".to_owned(), true)); // == form
996        // Upper-only / negation constraints floor at zero.
997        assert!(Constraint::parse("<8.0").unwrap().lower_bound().is_zero());
998        assert!(Constraint::parse("!=8.0").unwrap().lower_bound().is_zero());
999        assert!(Constraint::parse("*").unwrap().lower_bound().is_zero());
1000    }
1001
1002    #[test]
1003    fn lower_bound_caret_tilde_take_the_floor() {
1004        // Caret/tilde are conjunctive ranges; the lower bound is the
1005        // written floor, inclusive. Matches Composer's platform check
1006        // emitting `PHP_VERSION_ID >= 80100` for `^8.1`.
1007        assert_eq!(lb("^8.1"), ("8.1.0.0".to_owned(), true));
1008        assert_eq!(lb("~8.2.3"), ("8.2.3.0".to_owned(), true));
1009        assert_eq!(lb("^8"), ("8.0.0.0".to_owned(), true));
1010    }
1011
1012    #[test]
1013    fn lower_bound_conjunction_takes_the_greater() {
1014        // `>=7.4 <8.3` → floor is 7.4 (the `<8.3` clause contributes a
1015        // zero lower bound, which loses).
1016        assert_eq!(lb(">=7.4 <8.3"), ("7.4.0.0".to_owned(), true));
1017    }
1018
1019    #[test]
1020    fn lower_bound_disjunction_takes_the_least() {
1021        // `^7.4 || ^8.0` → floor is the lower alternative, 7.4.
1022        assert_eq!(lb("^7.4 || ^8.0"), ("7.4.0.0".to_owned(), true));
1023    }
1024
1025    #[test]
1026    fn intersects_any_provider_matches_everything() {
1027        // A polyfill that `provide`s `ext-x: *` covers any requirement.
1028        let provided = Constraint::parse("*").unwrap();
1029        assert!(provided.intersects(&Constraint::parse("^2.0").unwrap()));
1030        assert!(provided.intersects(&Constraint::parse("*").unwrap()));
1031    }
1032
1033    #[test]
1034    fn intersects_overlapping_and_disjoint_ranges() {
1035        let p = Constraint::parse("^1.0").unwrap();
1036        assert!(p.intersects(&Constraint::parse(">=1.5").unwrap())); // overlap
1037        assert!(!p.intersects(&Constraint::parse("^2.0").unwrap())); // disjoint
1038        // Touching at an inclusive/exclusive boundary: `<2.0` and
1039        // `>=2.0` do not overlap (`^1.0` upper is exclusive 2.0).
1040        assert!(!p.intersects(&Constraint::parse(">=2.0").unwrap()));
1041    }
1042
1043    #[test]
1044    fn intersects_disjunction_member_overlap() {
1045        let provided = Constraint::parse("^1.0 || ^3.0").unwrap();
1046        assert!(provided.intersects(&Constraint::parse("^3.1").unwrap()));
1047        assert!(!provided.intersects(&Constraint::parse("^2.0").unwrap()));
1048    }
1049
1050    #[test]
1051    fn dev_branch_constraint_handles_slashed_branch_name() {
1052        // Composer accepts branch names with slashes
1053        // (e.g. `dev-fix/some-bug`). Parses through Version::parse,
1054        // which preserves the branch body verbatim.
1055        let c = Constraint::parse("dev-fix/some-bug").unwrap();
1056        let v = Version::parse("dev-fix/some-bug").unwrap();
1057        assert!(c.matches(&v));
1058    }
1059}