Skip to main content

vivacity_core/
constraint.rs

1//! Subset of composer/semver constraints for the platform check.
2//! Port of `VersionParser::parseConstraint(s)` (pinned source:
3//! docs/reference/SemverVersionParser.php, extracted from the 2.10.3 phar).
4//!
5//! Key rules reproduced:
6//! - `*` / `x.*`: interval `[X...-dev, X+1...-dev)`, a bare `*` matches everything;
7//! - `^X.Y.Z` / `~X.Y.Z`: lower bound `>= version-dev` (when there is no explicit
8//!   stability suffix), exclusive upper bound `< next-dev`;
9//! - `>=V` and `<V` without suffix: the bound becomes `V-dev` (`>=8.1` accepts
10//!   `8.1.0-beta1`, `<2.0` rejects `2.0.0-beta`); `>V` and `<=V` keep the
11//!   version as is;
12//! - `A - B`: `>= A-dev`; `<= B` if B has a patch/suffix, else `< next(B)-dev`;
13//! - OR on `||` or `|`, AND on commas/spaces.
14//!
15//! Outside the subset (`dev-*` branches, `as` aliases, `@stability`...):
16//! `Unsupported`, the caller treats the case as out of scope.
17
18use crate::version::{Stability, Version};
19
20#[derive(Debug, thiserror::Error, PartialEq, Eq)]
21#[error("constraint outside the supported subset: {0:?}")]
22pub struct UnsupportedConstraint(pub String);
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25enum Simple {
26    Any,
27    Cmp(Op, Version),
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum Op {
32    Eq,
33    Ne,
34    Lt,
35    Le,
36    Gt,
37    Ge,
38}
39
40/// OR groups of conjunctions of simple constraints.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Constraint {
43    groups: Vec<Vec<Simple>>,
44}
45
46impl Constraint {
47    pub fn parse(input: &str) -> Result<Self, UnsupportedConstraint> {
48        let mut groups = Vec::new();
49        for or_part in split_or(input) {
50            let or_part = or_part.trim();
51            if or_part.is_empty() {
52                return Err(UnsupportedConstraint(input.to_owned()));
53            }
54            groups.push(parse_and_group(or_part, input)?);
55        }
56        if groups.is_empty() {
57            return Err(UnsupportedConstraint(input.to_owned()));
58        }
59        Ok(Constraint { groups })
60    }
61
62    pub fn matches(&self, v: &Version) -> bool {
63        self.groups.iter().any(|group| {
64            group.iter().all(|c| match c {
65                Simple::Any => true,
66                Simple::Cmp(op, bound) => match op {
67                    Op::Eq => v == bound,
68                    Op::Ne => v != bound,
69                    Op::Lt => v < bound,
70                    Op::Le => v <= bound,
71                    Op::Gt => v > bound,
72                    Op::Ge => v >= bound,
73                },
74            })
75        })
76    }
77}
78
79fn split_or(s: &str) -> Vec<&str> {
80    // `||` first, then a single `|` (both are accepted by Composer).
81    if s.contains("||") {
82        s.split("||").collect()
83    } else if s.contains('|') {
84        s.split('|').collect()
85    } else {
86        vec![s]
87    }
88}
89
90fn parse_and_group(part: &str, original: &str) -> Result<Vec<Simple>, UnsupportedConstraint> {
91    // Tokenise on commas/spaces, then glue back "op version" and "A - B".
92    let raw: Vec<&str> = part
93        .split([',', ' ', '\t'])
94        .filter(|t| !t.is_empty())
95        .collect();
96    let mut tokens: Vec<String> = Vec::new();
97    let mut i = 0;
98    while i < raw.len() {
99        let t = raw[i];
100        if matches!(t, ">=" | ">" | "<=" | "<" | "==" | "=" | "!=" | "<>") && i + 1 < raw.len() {
101            tokens.push(format!("{}{}", t, raw[i + 1]));
102            i += 2;
103        } else if t == "-" && !tokens.is_empty() && i + 1 < raw.len() {
104            let from = tokens.pop().unwrap_or_default();
105            tokens.push(format!("{from} - {}", raw[i + 1]));
106            i += 2;
107        } else {
108            tokens.push(t.to_owned());
109            i += 1;
110        }
111    }
112
113    let mut out = Vec::new();
114    for token in tokens {
115        parse_simple(&token, original, &mut out)?;
116    }
117    if out.is_empty() {
118        return Err(UnsupportedConstraint(original.to_owned()));
119    }
120    Ok(out)
121}
122
123/// Does the version carry an explicit stability suffix (`-beta1`, `-dev`...)?
124fn has_stability_suffix(s: &str) -> bool {
125    s.chars()
126        .any(|c| !(c.is_ascii_digit() || c == '.' || c == 'v' || c == 'V'))
127}
128
129fn parse_version(s: &str, original: &str) -> Result<Version, UnsupportedConstraint> {
130    Version::parse(s).map_err(|_| UnsupportedConstraint(original.to_owned()))
131}
132
133/// Bumps the `position` component (1-based) by one and zeroes the rest, the
134/// equivalent of `manipulateVersionString(matches, position, 1)`.
135fn bump(v: &Version, position: usize) -> Version {
136    let mut parts = v.parts;
137    parts[position - 1] += 1;
138    for p in parts.iter_mut().skip(position) {
139        *p = 0;
140    }
141    Version {
142        parts,
143        stability: Stability::Dev,
144        pre_number: 0,
145    }
146}
147
148fn as_dev_floor(mut v: Version) -> Version {
149    v.stability = Stability::Dev;
150    v.pre_number = 0;
151    v
152}
153
154fn parse_simple(
155    token: &str,
156    original: &str,
157    out: &mut Vec<Simple>,
158) -> Result<(), UnsupportedConstraint> {
159    let t = token.trim();
160
161    // Hyphen range "A - B".
162    if let Some((from, to)) = t.split_once(" - ") {
163        let low = parse_version(from, original)?;
164        let low = if has_stability_suffix(from) {
165            low
166        } else {
167            as_dev_floor(low)
168        };
169        out.push(Simple::Cmp(Op::Ge, low));
170
171        let to_trim = to.trim();
172        let dotted = to_trim.trim_start_matches(['v', 'V']);
173        let numeric_parts = dotted.split('.').count();
174        let high = parse_version(to_trim, original)?;
175        if has_stability_suffix(to_trim) || numeric_parts >= 3 {
176            out.push(Simple::Cmp(Op::Le, high));
177        } else {
178            out.push(Simple::Cmp(Op::Lt, bump(&high, numeric_parts)));
179        }
180        return Ok(());
181    }
182
183    // Pure wildcards.
184    if t.chars().all(|c| matches!(c, '*' | 'x' | 'X' | '.' | 'v')) && t.contains(['*', 'x', 'X']) {
185        out.push(Simple::Any);
186        return Ok(());
187    }
188
189    // X-range "1.2.*".
190    if let Some(stem) = t.strip_suffix(".*").or_else(|| t.strip_suffix(".x")) {
191        let base = parse_version(stem, original)?;
192        if has_stability_suffix(stem) {
193            return Err(UnsupportedConstraint(original.to_owned()));
194        }
195        let position = stem.trim_start_matches(['v', 'V']).split('.').count();
196        if base.parts == [0, 0, 0, 0] {
197            out.push(Simple::Cmp(Op::Lt, bump(&base, position)));
198        } else {
199            out.push(Simple::Cmp(Op::Ge, as_dev_floor(base.clone())));
200            out.push(Simple::Cmp(Op::Lt, bump(&base, position)));
201        }
202        return Ok(());
203    }
204
205    // Caret / tilde.
206    if let Some(rest) = t.strip_prefix('^') {
207        let v = parse_version(rest, original)?;
208        let low = if has_stability_suffix(rest) {
209            v.clone()
210        } else {
211            as_dev_floor(v.clone())
212        };
213        // Caret position: first non-zero component (0.x -> minor, 0.0.x -> patch).
214        let stem = rest.split(['-', '+']).next().unwrap_or(rest);
215        let given = stem.trim_start_matches(['v', 'V']).split('.').count();
216        let position = if v.parts[0] != 0 || given < 2 {
217            1
218        } else if v.parts[1] != 0 || given < 3 {
219            2
220        } else {
221            3
222        };
223        out.push(Simple::Cmp(Op::Ge, low));
224        out.push(Simple::Cmp(Op::Lt, bump(&v, position)));
225        return Ok(());
226    }
227    if let Some(rest) = t.strip_prefix('~') {
228        if rest.starts_with('>') {
229            return Err(UnsupportedConstraint(original.to_owned()));
230        }
231        let v = parse_version(rest, original)?;
232        let low = if has_stability_suffix(rest) {
233            v.clone()
234        } else {
235            as_dev_floor(v.clone())
236        };
237        let stem = rest.split(['-', '+']).next().unwrap_or(rest);
238        let given = stem.trim_start_matches(['v', 'V']).split('.').count();
239        let position = given.clamp(1, 4).max(2) - 1; // max(1, position-1)
240        out.push(Simple::Cmp(Op::Ge, low));
241        out.push(Simple::Cmp(Op::Lt, bump(&v, position)));
242        return Ok(());
243    }
244
245    // Simple operators and exact version.
246    let (op, rest) = if let Some(r) = t.strip_prefix(">=") {
247        (Op::Ge, r)
248    } else if let Some(r) = t.strip_prefix("<=") {
249        (Op::Le, r)
250    } else if let Some(r) = t.strip_prefix("<>").or_else(|| t.strip_prefix("!=")) {
251        (Op::Ne, r)
252    } else if let Some(r) = t.strip_prefix('>') {
253        (Op::Gt, r)
254    } else if let Some(r) = t.strip_prefix('<') {
255        (Op::Lt, r)
256    } else if let Some(r) = t.strip_prefix("==").or_else(|| t.strip_prefix('=')) {
257        (Op::Eq, r)
258    } else {
259        (Op::Eq, t)
260    };
261    let rest = rest.trim();
262    let v = parse_version(rest, original)?;
263    // `<` and `>=` without an explicit suffix: bound lowered to the -dev floor.
264    let v = if matches!(op, Op::Lt | Op::Ge) && !has_stability_suffix(rest) {
265        as_dev_floor(v)
266    } else {
267        v
268    };
269    out.push(Simple::Cmp(op, v));
270    Ok(())
271}
272
273/// Shortcut: does `version` satisfy `constraint`?
274pub fn satisfies(version: &str, constraint: &str) -> Result<bool, UnsupportedConstraint> {
275    let v =
276        Version::parse(version).map_err(|_| UnsupportedConstraint(format!("version {version}")))?;
277    Ok(Constraint::parse(constraint)?.matches(&v))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn sat(v: &str, c: &str) -> bool {
285        satisfies(v, c).unwrap_or_else(|e| panic!("{e}"))
286    }
287
288    #[test]
289    fn operators_and_dev_floors() {
290        assert!(sat("8.5.10", ">=8.1"));
291        assert!(sat("8.1.0-beta1", ">=8.1")); // >= without suffix -> -dev floor
292        assert!(!sat("2.0.0-beta1", "<2.0")); // < without suffix -> -dev floor
293        assert!(sat("1.9.9", "<2.0"));
294        assert!(!sat("8.1.0-beta1", ">8.1")); // > stays on the stable version
295        assert!(sat("8.1.1", ">8.1"));
296        assert!(sat("2.0.0", "<=2.0"));
297        assert!(!sat("2.0.1", "<=2.0"));
298        assert!(sat("1.2.3", "1.2.3"));
299        assert!(!sat("1.2.3", "!=1.2.3"));
300    }
301
302    #[test]
303    fn caret_tilde_wildcards() {
304        assert!(sat("8.5.10", "^8.1"));
305        assert!(!sat("9.0.0-alpha1", "^8.1"));
306        assert!(sat("8.1.0-RC1", "^8.1")); // -dev lower bound
307        assert!(sat("0.3.7", "^0.3"));
308        assert!(!sat("0.4.0", "^0.3"));
309        assert!(!sat("0.0.4", "^0.0.3"));
310        assert!(sat("1.2.9", "~1.2.3"));
311        assert!(!sat("1.3.0", "~1.2.3"));
312        assert!(sat("1.9.0", "~1.2"));
313        assert!(!sat("2.0.0-alpha1", "~1.2"));
314        assert!(sat("123.4.5", "*"));
315        assert!(sat("1.2.9-beta1", "1.2.*"));
316        assert!(!sat("1.3.0-dev", "1.2.*"));
317    }
318
319    #[test]
320    fn and_or_and_hyphen() {
321        assert!(sat("7.4.33", "^7.4|^8.0"));
322        assert!(sat("8.0.2", "^7.4||^8.0"));
323        assert!(!sat("8.0.2", "^8.1|^7.4"));
324        assert!(sat("1.5.0", ">=1.0 <2.0"));
325        assert!(sat("1.5.0", ">=1.0,<2.0"));
326        assert!(sat("1.5.0", ">= 1.0 , < 2.0"));
327        assert!(sat("2.0.0", "1.0 - 2.0")); // widened upper bound: < 2.1-dev
328        assert!(sat("2.0.9", "1.0 - 2.0"));
329        assert!(!sat("2.1.0", "1.0 - 2.0"));
330        assert!(sat("2.0.0", "1.0.0 - 2.0.0")); // explicit patch: <= 2.0.0
331        assert!(!sat("2.0.1", "1.0.0 - 2.0.0"));
332    }
333
334    #[test]
335    fn unsupported_forms_error_out() {
336        for c in ["dev-master", "1.0 as 2.0", "@dev", "~>1.2"] {
337            assert!(
338                satisfies("1.0.0", c).is_err(),
339                "{c} should have been rejected"
340            );
341        }
342    }
343}
344
345/// Lower bound of a constraint (composer/semver's `Bound`): the smallest
346/// admitted version and its inclusivity. `None` = zero bound (`*` constraint,
347/// or an OR branch without a lower bound).
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct LowerBound {
350    pub version: Version,
351    pub inclusive: bool,
352}
353
354impl LowerBound {
355    /// `Bound::compareTo($other, '>')`: version first, then, at equal version,
356    /// an exclusive bound is "higher" than an inclusive one.
357    fn is_higher_than(&self, other: &LowerBound) -> bool {
358        match self.version.cmp(&other.version) {
359            std::cmp::Ordering::Greater => true,
360            std::cmp::Ordering::Less => false,
361            std::cmp::Ordering::Equal => !self.inclusive && other.inclusive,
362        }
363    }
364}
365
366impl Constraint {
367    pub fn lower_bound(&self) -> Option<LowerBound> {
368        let mut result: Option<LowerBound> = None;
369        for group in &self.groups {
370            // AND: the highest of the group's lower bounds.
371            let mut group_bound: Option<LowerBound> = None;
372            for c in group {
373                let candidate = match c {
374                    Simple::Cmp(Op::Ge, v) | Simple::Cmp(Op::Eq, v) => LowerBound {
375                        version: v.clone(),
376                        inclusive: true,
377                    },
378                    Simple::Cmp(Op::Gt, v) => LowerBound {
379                        version: v.clone(),
380                        inclusive: false,
381                    },
382                    _ => continue,
383                };
384                if group_bound
385                    .as_ref()
386                    .is_none_or(|g| candidate.is_higher_than(g))
387                {
388                    group_bound = Some(candidate);
389                }
390            }
391            // OR: the lowest of the group bounds; a group without a bound = zero.
392            let gb = group_bound?;
393            if result.as_ref().is_none_or(|r| r.is_higher_than(&gb)) {
394                result = Some(gb);
395            }
396        }
397        result
398    }
399}
400
401#[cfg(test)]
402mod lower_bound_tests {
403    use super::*;
404
405    fn lb(c: &str) -> Option<(String, bool)> {
406        Constraint::parse(c).expect(c).lower_bound().map(|b| {
407            (
408                format!(
409                    "{}.{}.{}.{}",
410                    b.version.parts[0], b.version.parts[1], b.version.parts[2], b.version.parts[3]
411                ),
412                b.inclusive,
413            )
414        })
415    }
416
417    #[test]
418    fn bounds() {
419        assert_eq!(lb("^8.2"), Some(("8.2.0.0".into(), true)));
420        assert_eq!(lb(">=8.1 <8.4"), Some(("8.1.0.0".into(), true)));
421        assert_eq!(lb(">8.1"), Some(("8.1.0.0".into(), false)));
422        assert_eq!(lb("^7.4 || ^8.0"), Some(("7.4.0.0".into(), true)));
423        assert_eq!(lb("*"), None);
424        assert_eq!(lb("<8.0"), None);
425        assert_eq!(lb("8.2.1"), Some(("8.2.1.0".into(), true)));
426    }
427}