Skip to main content

lex_syntax/
semver.rs

1//! Minimal semver **constraint** matching for `{ registry = …, version =
2//! "…" }` dependency resolution (#893).
3//!
4//! Not a full semver implementation — no pre-release or build-metadata
5//! ordering — but enough to resolve `^`, `~`, exact, and comparator
6//! (`>= > <= <`) constraints against a set of published
7//! `MAJOR.MINOR.PATCH` release versions. Resolution is **deterministic**:
8//! [`best_match`] always returns the *highest* satisfying version, so a
9//! `lex.lock` pins a reproducible choice.
10//!
11//! Caret/tilde follow Cargo's rules (the left-most non-zero component is
12//! the "fixed" one for `^`; `~` fixes down to the last given component).
13
14/// A `(major, minor, patch)` version.
15pub type Version = (u64, u64, u64);
16
17/// Parse a full `MAJOR.MINOR.PATCH` (a leading `v` allowed). Exactly
18/// three components, unlike [`parse_partial`].
19pub fn parse_exact(v: &str) -> Option<Version> {
20    let (ver, given) = parse_partial(v)?;
21    if given == 3 {
22        Some(ver)
23    } else {
24        None
25    }
26}
27
28/// Parse `MAJOR[.MINOR[.PATCH]]` (leading `v` allowed); omitted
29/// components default to 0. Returns the version and how many components
30/// were actually written (1..=3) — caret/tilde need that.
31fn parse_partial(s: &str) -> Option<(Version, u8)> {
32    let s = s.trim().trim_start_matches('v').trim();
33    if s.is_empty() {
34        return None;
35    }
36    let mut parts = s.split('.');
37    let major: u64 = parts.next()?.trim().parse().ok()?;
38    let (minor, has_minor) = match parts.next() {
39        Some(m) => (m.trim().parse().ok()?, true),
40        None => (0, false),
41    };
42    let (patch, has_patch) = match parts.next() {
43        Some(p) => (p.trim().parse().ok()?, true),
44        None => (0, false),
45    };
46    if parts.next().is_some() {
47        return None; // more than three components
48    }
49    Some(((major, minor, patch), 1 + has_minor as u8 + has_patch as u8))
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum Cmp {
54    Ge,
55    Gt,
56    Le,
57    Lt,
58}
59
60#[derive(Debug, Clone)]
61enum Constraint {
62    /// `*`, `latest`, or empty — any released version.
63    Any,
64    /// `1.2.3` — exactly this version.
65    Exact(Version),
66    /// `^X.Y.Z` — `>= v` and `< caret_upper(v, given)`.
67    Caret(Version, Version),
68    /// `~X.Y.Z` — `>= v` and `< tilde_upper(v, given)`.
69    Tilde(Version, Version),
70    /// A single comparator, e.g. `>=1.0`.
71    Compare(Cmp, Version),
72}
73
74/// The exclusive upper bound of a caret constraint: increment the
75/// left-most non-zero component (rest zeroed); if every given component
76/// is zero, increment the last *given* component.
77fn caret_upper((maj, min, pat): Version, given: u8) -> Version {
78    if maj > 0 {
79        (maj + 1, 0, 0)
80    } else if min > 0 {
81        (0, min + 1, 0)
82    } else if pat > 0 {
83        (0, 0, pat + 1)
84    } else {
85        match given {
86            1 => (1, 0, 0),
87            2 => (0, 1, 0),
88            _ => (0, 0, 1),
89        }
90    }
91}
92
93/// The exclusive upper bound of a tilde constraint: minor given ⇒ fix the
94/// minor (`< maj.(min+1).0`); only major given ⇒ fix the major.
95fn tilde_upper((maj, min, _pat): Version, given: u8) -> Version {
96    if given >= 2 {
97        (maj, min + 1, 0)
98    } else {
99        (maj + 1, 0, 0)
100    }
101}
102
103fn parse_constraint(s: &str) -> Option<Constraint> {
104    let s = s.trim();
105    if s.is_empty() || s == "*" || s.eq_ignore_ascii_case("latest") {
106        return Some(Constraint::Any);
107    }
108    if let Some(rest) = s.strip_prefix('^') {
109        let (v, given) = parse_partial(rest)?;
110        return Some(Constraint::Caret(v, caret_upper(v, given)));
111    }
112    if let Some(rest) = s.strip_prefix('~') {
113        let (v, given) = parse_partial(rest)?;
114        return Some(Constraint::Tilde(v, tilde_upper(v, given)));
115    }
116    for (pfx, cmp) in [(">=", Cmp::Ge), ("<=", Cmp::Le), (">", Cmp::Gt), ("<", Cmp::Lt)] {
117        if let Some(rest) = s.strip_prefix(pfx) {
118            return Some(Constraint::Compare(cmp, parse_partial(rest)?.0));
119        }
120    }
121    // A bare version with `=` is exact; a bare `1.2` is treated as `^1.2`
122    // (Cargo's default), which is the friendlier "compatible" reading.
123    if let Some(rest) = s.strip_prefix('=') {
124        return Some(Constraint::Exact(parse_partial(rest)?.0));
125    }
126    let (v, given) = parse_partial(s)?;
127    if given == 3 {
128        Some(Constraint::Exact(v))
129    } else {
130        Some(Constraint::Caret(v, caret_upper(v, given)))
131    }
132}
133
134/// Does `version` (a `MAJOR.MINOR.PATCH` string) satisfy `constraint`?
135/// An unparseable version never matches; an unparseable constraint
136/// matches nothing (the caller should surface "cannot resolve" rather
137/// than guess).
138pub fn satisfies(constraint: &str, version: &str) -> bool {
139    let (Some(c), Some(v)) = (parse_constraint(constraint), parse_exact(version)) else {
140        return false;
141    };
142    match c {
143        Constraint::Any => true,
144        Constraint::Exact(e) => v == e,
145        Constraint::Caret(lo, hi) | Constraint::Tilde(lo, hi) => v >= lo && v < hi,
146        Constraint::Compare(Cmp::Ge, r) => v >= r,
147        Constraint::Compare(Cmp::Gt, r) => v > r,
148        Constraint::Compare(Cmp::Le, r) => v <= r,
149        Constraint::Compare(Cmp::Lt, r) => v < r,
150    }
151}
152
153/// The highest of `versions` that satisfies `constraint`, or `None` if
154/// none do. Deterministic — the resolver records this exact version in
155/// the lock file.
156pub fn best_match<'a>(constraint: &str, versions: &'a [String]) -> Option<&'a str> {
157    versions
158        .iter()
159        .filter(|v| satisfies(constraint, v))
160        .max_by_key(|v| parse_exact(v).unwrap_or((0, 0, 0)))
161        .map(|s| s.as_str())
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn exact() {
170        assert!(satisfies("1.2.3", "1.2.3"));
171        assert!(satisfies("=1.2.3", "1.2.3"));
172        assert!(!satisfies("1.2.3", "1.2.4"));
173    }
174
175    #[test]
176    fn caret_major() {
177        assert!(satisfies("^1.2.3", "1.2.3"));
178        assert!(satisfies("^1.2.3", "1.9.0"));
179        assert!(!satisfies("^1.2.3", "2.0.0"));
180        assert!(!satisfies("^1.2.3", "1.2.2"));
181        // partial: ^1.2 == >=1.2.0 <2.0.0
182        assert!(satisfies("^1.2", "1.2.0"));
183        assert!(satisfies("^1.2", "1.5.9"));
184        assert!(!satisfies("^1.2", "2.0.0"));
185        assert!(!satisfies("^1.2", "1.1.9"));
186        // ^1 == >=1.0.0 <2.0.0
187        assert!(satisfies("^1", "1.0.0") && satisfies("^1", "1.9.9") && !satisfies("^1", "2.0.0"));
188        // bare 1.2 defaults to ^1.2
189        assert!(satisfies("1.2", "1.4.0") && !satisfies("1.2", "2.0.0"));
190    }
191
192    #[test]
193    fn caret_zero() {
194        // ^0.2.3 == >=0.2.3 <0.3.0
195        assert!(satisfies("^0.2.3", "0.2.3") && satisfies("^0.2.3", "0.2.9"));
196        assert!(!satisfies("^0.2.3", "0.3.0") && !satisfies("^0.2.3", "0.2.2"));
197        // ^0.0.3 == >=0.0.3 <0.0.4
198        assert!(satisfies("^0.0.3", "0.0.3") && !satisfies("^0.0.3", "0.0.4"));
199        // ^0.2 == >=0.2.0 <0.3.0
200        assert!(satisfies("^0.2", "0.2.5") && !satisfies("^0.2", "0.3.0"));
201        // ^0 == >=0.0.0 <1.0.0
202        assert!(satisfies("^0", "0.9.9") && !satisfies("^0", "1.0.0"));
203    }
204
205    #[test]
206    fn tilde() {
207        // ~1.2.3 == >=1.2.3 <1.3.0
208        assert!(satisfies("~1.2.3", "1.2.9") && !satisfies("~1.2.3", "1.3.0"));
209        // ~1.2 == >=1.2.0 <1.3.0
210        assert!(satisfies("~1.2", "1.2.9") && !satisfies("~1.2", "1.3.0"));
211        // ~1 == >=1.0.0 <2.0.0
212        assert!(satisfies("~1", "1.9.9") && !satisfies("~1", "2.0.0"));
213    }
214
215    #[test]
216    fn comparators_and_any() {
217        assert!(satisfies(">=1.0", "1.0.0") && satisfies(">=1.0", "9.9.9") && !satisfies(">=1.0", "0.9.9"));
218        assert!(satisfies(">1.0", "1.0.1") && !satisfies(">1.0", "1.0.0"));
219        assert!(satisfies("<2.0", "1.9.9") && !satisfies("<2.0", "2.0.0"));
220        assert!(satisfies("<=1.5", "1.5.0") && !satisfies("<=1.5", "1.5.1"));
221        for c in ["*", "latest", ""] {
222            assert!(satisfies(c, "3.1.4"), "{c} should match anything");
223        }
224    }
225
226    #[test]
227    fn best_match_picks_highest() {
228        let vs: Vec<String> = ["1.0.0", "1.2.0", "1.4.9", "2.0.0", "1.4.2"]
229            .iter().map(|s| s.to_string()).collect();
230        assert_eq!(best_match("^1.2", &vs), Some("1.4.9"));
231        assert_eq!(best_match("~1.4", &vs), Some("1.4.9"));
232        assert_eq!(best_match(">=1.0", &vs), Some("2.0.0"));
233        assert_eq!(best_match("^3", &vs), None);
234        assert_eq!(best_match("1.4.2", &vs), Some("1.4.2"));
235    }
236
237    #[test]
238    fn unparseable_never_matches() {
239        assert!(!satisfies("^1.2.3", "not-a-version"));
240        assert!(!satisfies("garbage", "1.2.3"));
241    }
242}