Skip to main content

vivacity_resolver/
constraint.rs

1//! Port of `Composer\Semver\Constraint\*` and `VersionParser::parseConstraints`
2//! (docs/reference/resolver/semver-Constraint.php, semver-MultiConstraint.php,
3//! semver-Bound.php, semver-VersionParser.php). A constraint is a tree: leaf
4//! (operator, normalized version), conjunction/disjunction, match-all,
5//! match-none. `matches` reproduces `Constraint::matchSpecific` (and thus
6//! `CompilingMatcher::match`, which is merely a compiled form of it).
7
8use crate::phpver::{version_compare, version_compare_op};
9use crate::version::{
10    self, group, normalize, regex, VersionError, MODIFIER_REGEX, STABILITIES_REGEX,
11};
12use pcre2::bytes::Regex;
13use std::cmp::Ordering;
14use std::fmt;
15use std::sync::OnceLock;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum Op {
19    Eq,
20    Lt,
21    Le,
22    Gt,
23    Ge,
24    Ne,
25}
26
27impl Op {
28    pub fn parse(s: &str) -> Option<Op> {
29        Some(match s {
30            "=" | "==" => Op::Eq,
31            "<" => Op::Lt,
32            "<=" => Op::Le,
33            ">" => Op::Gt,
34            ">=" => Op::Ge,
35            "<>" | "!=" => Op::Ne,
36            _ => return None,
37        })
38    }
39
40    /// `$transOpInt`.
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Op::Eq => "==",
44            Op::Lt => "<",
45            Op::Le => "<=",
46            Op::Gt => ">",
47            Op::Ge => ">=",
48            Op::Ne => "!=",
49        }
50    }
51
52    /// `str_replace('=', '', op)`.
53    fn no_equal(self) -> &'static str {
54        match self {
55            Op::Eq => "",
56            Op::Lt | Op::Le => "<",
57            Op::Gt | Op::Ge => ">",
58            Op::Ne => "!",
59        }
60    }
61}
62
63/// `Bound`.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct Bound {
66    pub version: String,
67    pub inclusive: bool,
68}
69
70impl Bound {
71    pub fn zero() -> Bound {
72        Bound {
73            version: "0.0.0.0-dev".into(),
74            inclusive: true,
75        }
76    }
77    pub fn positive_infinity() -> Bound {
78        Bound {
79            version: format!("{}.0.0.0", i64::MAX),
80            inclusive: false,
81        }
82    }
83    pub fn is_zero(&self) -> bool {
84        self.version == "0.0.0.0-dev" && self.inclusive
85    }
86    pub fn is_positive_infinity(&self) -> bool {
87        self.version == format!("{}.0.0.0", i64::MAX) && !self.inclusive
88    }
89    /// `compareTo($other, '<' | '>')`.
90    pub fn compare_to(&self, other: &Bound, op: &str) -> bool {
91        if self == other {
92            return false;
93        }
94        let c = version_compare(&self.version, &other.version);
95        if c != Ordering::Equal {
96            return match op {
97                ">" => c == Ordering::Greater,
98                _ => c == Ordering::Less,
99            };
100        }
101        if op == ">" {
102            other.inclusive
103        } else {
104            !other.inclusive
105        }
106    }
107}
108
109impl fmt::Display for Bound {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(
112            f,
113            "{} [{}]",
114            self.version,
115            if self.inclusive {
116                "inclusive"
117            } else {
118                "exclusive"
119            }
120        )
121    }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125pub enum Constraint {
126    /// `Constraint($op, $version)`.
127    Single {
128        op: Op,
129        version: String,
130    },
131    /// `MultiConstraint` (at least two members).
132    Multi {
133        constraints: Vec<Constraint>,
134        conjunctive: bool,
135    },
136    MatchAll,
137    MatchNone,
138}
139
140impl Constraint {
141    pub fn new(op: Op, version: impl Into<String>) -> Constraint {
142        Constraint::Single {
143            op,
144            version: version.into(),
145        }
146    }
147
148    pub fn is_single(&self) -> bool {
149        matches!(self, Constraint::Single { .. })
150    }
151
152    /// `Constraint::versionCompare`: `dev-*` branches handled separately.
153    fn version_compare_branches(a: &str, b: &str, op: Op, compare_branches: bool) -> bool {
154        let a_branch = a.starts_with("dev-");
155        let b_branch = b.starts_with("dev-");
156        if op == Op::Ne && (a_branch || b_branch) {
157            return a != b;
158        }
159        if a_branch && b_branch {
160            return op == Op::Eq && a == b;
161        }
162        if !compare_branches && (a_branch || b_branch) {
163            return false;
164        }
165        version_compare_op(a, b, op.as_str())
166    }
167
168    /// `Constraint::matchSpecific($provider, $compareBranches)`: `self` is
169    /// the constraint, `provider` the candidate version (or another simple
170    /// constraint).
171    pub fn match_specific(&self, provider: &Constraint, compare_branches: bool) -> bool {
172        let (
173            Constraint::Single { op, version },
174            Constraint::Single {
175                op: pop,
176                version: pversion,
177            },
178        ) = (self, provider)
179        else {
180            return false;
181        };
182        let no_equal_op = op.no_equal();
183        let provider_no_equal_op = pop.no_equal();
184        let is_equal_op = *op == Op::Eq;
185        let is_non_equal_op = *op == Op::Ne;
186        let is_provider_equal_op = *pop == Op::Eq;
187        let is_provider_non_equal_op = *pop == Op::Ne;
188
189        if is_non_equal_op || is_provider_non_equal_op {
190            if is_non_equal_op
191                && !is_provider_non_equal_op
192                && !is_provider_equal_op
193                && pversion.starts_with("dev-")
194            {
195                return false;
196            }
197            if is_provider_non_equal_op
198                && !is_non_equal_op
199                && !is_equal_op
200                && version.starts_with("dev-")
201            {
202                return false;
203            }
204            if !is_equal_op && !is_provider_equal_op {
205                return true;
206            }
207            return Self::version_compare_branches(pversion, version, Op::Ne, compare_branches);
208        }
209        if *op != Op::Eq && no_equal_op == provider_no_equal_op {
210            return !(version.starts_with("dev-") || pversion.starts_with("dev-"));
211        }
212        let (version1, version2, operator) = if is_equal_op {
213            (version.as_str(), pversion.as_str(), *pop)
214        } else {
215            (pversion.as_str(), version.as_str(), *op)
216        };
217        if Self::version_compare_branches(version1, version2, operator, compare_branches) {
218            return !(pop.as_str() == provider_no_equal_op
219                && op.as_str() != no_equal_op
220                && version_compare_op(pversion, version, "=="));
221        }
222        false
223    }
224
225    /// `ConstraintInterface::matches($provider)`.
226    pub fn matches(&self, provider: &Constraint) -> bool {
227        match self {
228            Constraint::MatchAll => true,
229            Constraint::MatchNone => false,
230            Constraint::Single { .. } => match provider {
231                Constraint::Single { .. } => self.match_specific(provider, false),
232                other => other.matches(self),
233            },
234            Constraint::Multi {
235                constraints,
236                conjunctive,
237            } => {
238                if !conjunctive {
239                    return constraints.iter().any(|c| provider.matches(c));
240                }
241                if let Constraint::Multi {
242                    conjunctive: false, ..
243                } = provider
244                {
245                    return provider.matches(self);
246                }
247                constraints.iter().all(|c| provider.matches(c))
248            }
249        }
250    }
251
252    /// `CompilingMatcher::match($constraint, OP_EQ, $version)`, the form
253    /// Composer uses everywhere to test a version.
254    pub fn matches_version(&self, version: &str) -> bool {
255        self.matches(&Constraint::new(Op::Eq, version))
256    }
257
258    /// `MultiConstraint::create($constraints, $conjunctive)`.
259    pub fn create(constraints: Vec<Constraint>, conjunctive: bool) -> Constraint {
260        if constraints.is_empty() {
261            return Constraint::MatchAll;
262        }
263        if constraints.len() == 1 {
264            return constraints
265                .into_iter()
266                .next()
267                .unwrap_or(Constraint::MatchAll);
268        }
269        if let Some((optimized, conj)) = Self::optimize_constraints(&constraints, conjunctive) {
270            if optimized.len() == 1 {
271                return optimized.into_iter().next().unwrap_or(Constraint::MatchAll);
272            }
273            return Constraint::Multi {
274                constraints: optimized,
275                conjunctive: conj,
276            };
277        }
278        Constraint::Multi {
279            constraints,
280            conjunctive,
281        }
282    }
283
284    /// `MultiConstraint::optimizeConstraints`: merges `>=a <b || >=b <c`.
285    fn optimize_constraints(
286        constraints: &[Constraint],
287        conjunctive: bool,
288    ) -> Option<(Vec<Constraint>, bool)> {
289        if conjunctive {
290            return None;
291        }
292        let two_bounds = |c: &Constraint| -> Option<(String, String, Constraint, Constraint)> {
293            let Constraint::Multi {
294                constraints: parts,
295                conjunctive: true,
296            } = c
297            else {
298                return None;
299            };
300            if parts.len() != 2 {
301                return None;
302            }
303            let s0 = parts[0].to_string();
304            let s1 = parts[1].to_string();
305            if s0.starts_with(">=") && s1.starts_with('<') && !s1.starts_with("<=") {
306                Some((s0, s1, parts[0].clone(), parts[1].clone()))
307            } else {
308                None
309            }
310        };
311        let mut left = constraints[0].clone();
312        let mut merged: Vec<Constraint> = Vec::new();
313        let mut optimized = false;
314        for right in &constraints[1..] {
315            let fused = match (two_bounds(&left), two_bounds(right)) {
316                (Some((_, l1, l_lo, _)), Some((r0, _, _, r_hi))) if l1[2..] == r0[3..] => {
317                    Some(Constraint::Multi {
318                        constraints: vec![l_lo, r_hi],
319                        conjunctive: true,
320                    })
321                }
322                _ => None,
323            };
324            if let Some(f) = fused {
325                optimized = true;
326                left = f;
327            } else {
328                merged.push(left);
329                left = right.clone();
330            }
331        }
332        if optimized {
333            merged.push(left);
334            return Some((merged, false));
335        }
336        None
337    }
338
339    pub fn lower_bound(&self) -> Bound {
340        self.bounds().0
341    }
342
343    pub fn upper_bound(&self) -> Bound {
344        self.bounds().1
345    }
346
347    /// `extractBounds`.
348    fn bounds(&self) -> (Bound, Bound) {
349        match self {
350            Constraint::MatchAll => (Bound::zero(), Bound::positive_infinity()),
351            Constraint::MatchNone => {
352                let b = Bound {
353                    version: "0.0.0.0-dev".into(),
354                    inclusive: false,
355                };
356                (b.clone(), b)
357            }
358            Constraint::Single { op, version } => {
359                if version.starts_with("dev-") {
360                    return (Bound::zero(), Bound::positive_infinity());
361                }
362                let b = |inc: bool| Bound {
363                    version: version.clone(),
364                    inclusive: inc,
365                };
366                match op {
367                    Op::Eq => (b(true), b(true)),
368                    Op::Lt => (Bound::zero(), b(false)),
369                    Op::Le => (Bound::zero(), b(true)),
370                    Op::Gt => (b(false), Bound::positive_infinity()),
371                    Op::Ge => (b(true), Bound::positive_infinity()),
372                    Op::Ne => (Bound::zero(), Bound::positive_infinity()),
373                }
374            }
375            Constraint::Multi {
376                constraints,
377                conjunctive,
378            } => {
379                let mut lower: Option<Bound> = None;
380                let mut upper: Option<Bound> = None;
381                for c in constraints {
382                    let (l, u) = c.bounds();
383                    match (&lower, &upper) {
384                        (Some(cl), Some(cu)) => {
385                            if l.compare_to(cl, if *conjunctive { ">" } else { "<" }) {
386                                lower = Some(l);
387                            }
388                            if u.compare_to(cu, if *conjunctive { "<" } else { ">" }) {
389                                upper = Some(u);
390                            }
391                        }
392                        _ => {
393                            lower = Some(l);
394                            upper = Some(u);
395                        }
396                    }
397                }
398                (
399                    lower.unwrap_or_else(Bound::zero),
400                    upper.unwrap_or_else(Bound::positive_infinity),
401                )
402            }
403        }
404    }
405}
406
407impl fmt::Display for Constraint {
408    /// `__toString`: `>= 1.0.0.0`, `[>= 1.0.0.0 < 2.0.0.0-dev]`, `*`, `[]`.
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        match self {
411            Constraint::Single { op, version } => write!(f, "{} {}", op.as_str(), version),
412            Constraint::Multi {
413                constraints,
414                conjunctive,
415            } => {
416                let parts: Vec<String> = constraints.iter().map(|c| c.to_string()).collect();
417                write!(
418                    f,
419                    "[{}]",
420                    parts.join(if *conjunctive { " " } else { " || " })
421                )
422            }
423            Constraint::MatchAll => write!(f, "*"),
424            Constraint::MatchNone => write!(f, "[]"),
425        }
426    }
427}
428
429/// A parsed constraint with its original pretty string (`getPrettyString`).
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct ParsedConstraint {
432    pub constraint: Constraint,
433    pub pretty: String,
434}
435
436/// `VersionParser::parseConstraints`, memoized by text (the equivalent of
437/// the `$linkCache` of `ArrayLoader::loadPackages`, at process scope: same
438/// input -> same result, the parser is pure).
439pub fn parse_constraints(input: &str) -> Result<ParsedConstraint, VersionError> {
440    thread_local! {
441        static CACHE: std::cell::RefCell<std::collections::HashMap<String, ParsedConstraint>> =
442            std::cell::RefCell::new(std::collections::HashMap::new());
443    }
444    if let Some(hit) = CACHE.with(|c| c.borrow().get(input).cloned()) {
445        return Ok(hit);
446    }
447    let parsed = parse_constraints_uncached(input)?;
448    CACHE.with(|c| {
449        c.borrow_mut().insert(input.to_owned(), parsed.clone());
450    });
451    Ok(parsed)
452}
453
454fn parse_constraints_uncached(input: &str) -> Result<ParsedConstraint, VersionError> {
455    static OR: OnceLock<Regex> = OnceLock::new();
456    static AND: OnceLock<Regex> = OnceLock::new();
457    let pretty = input.to_owned();
458    let trimmed = input.trim();
459    let or_parts = preg_split(regex(&OR, r"\s*\|\|?\s*", false), trimmed);
460    let mut or_groups: Vec<Constraint> = Vec::new();
461    for or_part in or_parts {
462        let and_parts = preg_split(
463            regex(
464                &AND,
465                r"(?<!^|as|[=>< ,]) *(?<!-)[, ](?!-) *(?!,|as|$)",
466                false,
467            ),
468            &or_part,
469        );
470        let objects: Vec<Constraint> = if and_parts.len() > 1 {
471            let mut out = Vec::new();
472            for p in &and_parts {
473                out.extend(parse_constraint(p)?);
474            }
475            out
476        } else {
477            parse_constraint(&and_parts[0])?
478        };
479        or_groups.push(if objects.len() == 1 {
480            objects.into_iter().next().unwrap_or(Constraint::MatchAll)
481        } else {
482            Constraint::Multi {
483                constraints: objects,
484                conjunctive: true,
485            }
486        });
487    }
488    Ok(ParsedConstraint {
489        constraint: Constraint::create(or_groups, false),
490        pretty,
491    })
492}
493
494/// `preg_split` without limit or flags: pieces between matches (empty
495/// strings preserved).
496fn preg_split(re: &Regex, subject: &str) -> Vec<String> {
497    let bytes = subject.as_bytes();
498    let mut out = Vec::new();
499    let mut last = 0;
500    for m in re.find_iter(bytes).flatten() {
501        if m.start() == m.end() && m.start() == last && last == bytes.len() {
502            break;
503        }
504        out.push(subject[last..m.start()].to_owned());
505        last = m.end();
506    }
507    out.push(subject[last..].to_owned());
508    out
509}
510
511/// `manipulateVersionString($matches, $position, $increment, $pad)`.
512fn manipulate_version_string(
513    matches: &[String],
514    position: usize,
515    increment: i64,
516) -> Option<String> {
517    let mut m: Vec<String> = matches.to_vec();
518    let mut position = position;
519    let mut i = 4;
520    while i > 0 {
521        if i > position {
522            m[i] = "0".to_owned();
523        } else if i == position && increment != 0 {
524            let v: i64 = m[i].parse().unwrap_or(0) + increment;
525            if v < 0 {
526                m[i] = "0".to_owned();
527                position -= 1;
528                if i == 1 {
529                    return None;
530                }
531            } else {
532                m[i] = v.to_string();
533            }
534        }
535        i -= 1;
536    }
537    Some(format!("{}.{}.{}.{}", m[1], m[2], m[3], m[4]))
538}
539
540fn empty(s: &str) -> bool {
541    s.is_empty() || s == "0"
542}
543
544/// `VersionParser::parseConstraint` (one elementary constraint -> 1 or 2
545/// bounds).
546fn parse_constraint(input: &str) -> Result<Vec<Constraint>, VersionError> {
547    static AS: OnceLock<Regex> = OnceLock::new();
548    static STAB: OnceLock<Regex> = OnceLock::new();
549    static REF: OnceLock<Regex> = OnceLock::new();
550    static ANY: OnceLock<Regex> = OnceLock::new();
551    static TILDE: OnceLock<Regex> = OnceLock::new();
552    static CARET: OnceLock<Regex> = OnceLock::new();
553    static XRANGE: OnceLock<Regex> = OnceLock::new();
554    static HYPHEN: OnceLock<Regex> = OnceLock::new();
555    static BASIC: OnceLock<Regex> = OnceLock::new();
556    static MODIFIER_END: OnceLock<Regex> = OnceLock::new();
557
558    let mut constraint = input.to_owned();
559    let mut stability_modifier: Option<String> = None;
560
561    if let Ok(Some(caps)) =
562        regex(&AS, r"^([^,\s]++) ++as ++([^,\s]++)$", false).captures(constraint.as_bytes())
563    {
564        constraint = group(&caps, 1).to_owned();
565    }
566    if let Ok(Some(caps)) = regex(&STAB, &format!(r"^([^,\s]*?)@({STABILITIES_REGEX})$"), true)
567        .captures(constraint.as_bytes())
568    {
569        let head = group(&caps, 1).to_owned();
570        let stab = group(&caps, 2).to_owned();
571        constraint = if head.is_empty() {
572            "*".to_owned()
573        } else {
574            head
575        };
576        if stab != "stable" {
577            stability_modifier = Some(stab);
578        }
579    }
580    if let Ok(Some(caps)) =
581        regex(&REF, r"^(dev-[^,\s@]+?|[^,\s@]+?\.x-dev)#.+$", true).captures(constraint.as_bytes())
582    {
583        constraint = group(&caps, 1).to_owned();
584    }
585    if let Ok(Some(caps)) =
586        regex(&ANY, r"^(v)?[xX*](\.[xX*])*$", true).captures(constraint.as_bytes())
587    {
588        if !group(&caps, 1).is_empty() || !group(&caps, 2).is_empty() {
589            return Ok(vec![Constraint::new(Op::Ge, "0.0.0.0-dev")]);
590        }
591        return Ok(vec![Constraint::MatchAll]);
592    }
593
594    let version_regex = format!(
595        r"v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.(\d++))?(?:{MODIFIER_REGEX}|\.([xX*][.-]?dev))(?:\+[^\s]+)?"
596    );
597    let groups_of = |re: &Regex, s: &str| -> Option<Vec<String>> {
598        let caps = re.captures(s.as_bytes()).ok()??;
599        Some(
600            (0..caps.len())
601                .map(|i| group(&caps, i).to_owned())
602                .collect(),
603        )
604    };
605
606    // ~ (tilde)
607    if let Some(m) = groups_of(
608        regex(&TILDE, &format!("^~>?{version_regex}$"), true),
609        &constraint,
610    ) {
611        if constraint.starts_with("~>") {
612            return Err(VersionError(format!(
613                "Could not parse version constraint {constraint}: Invalid operator \"~>\", you probably meant to use the \"~\" operator"
614            )));
615        }
616        let at = |i: usize| m.get(i).map(String::as_str).unwrap_or("");
617        let mut position = if !at(4).is_empty() {
618            4
619        } else if !at(3).is_empty() {
620            3
621        } else if !at(2).is_empty() {
622            2
623        } else {
624            1
625        };
626        if !empty(at(8)) {
627            position += 1;
628        }
629        let mut stability_suffix = String::new();
630        if empty(at(5)) && empty(at(7)) && empty(at(8)) {
631            stability_suffix.push_str("-dev");
632        }
633        let low = normalize(&format!("{constraint}{stability_suffix}")[1..], None)?;
634        let high_position = std::cmp::max(1, position - 1);
635        let high = manipulate_version_string(&m, high_position, 1)
636            .map(|v| format!("{v}-dev"))
637            .ok_or_else(|| {
638                VersionError(format!("Could not parse version constraint {constraint}"))
639            })?;
640        return Ok(vec![
641            Constraint::new(Op::Ge, low),
642            Constraint::new(Op::Lt, high),
643        ]);
644    }
645    // ^ (caret)
646    if let Some(m) = groups_of(
647        regex(&CARET, &format!(r"^\^{version_regex}($)"), true),
648        &constraint,
649    ) {
650        let at = |i: usize| m.get(i).map(String::as_str).unwrap_or("");
651        let position = if at(1) != "0" || at(2).is_empty() {
652            1
653        } else if at(2) != "0" || at(3).is_empty() {
654            2
655        } else {
656            3
657        };
658        let mut stability_suffix = String::new();
659        if empty(at(5)) && empty(at(7)) && empty(at(8)) {
660            stability_suffix.push_str("-dev");
661        }
662        let low = normalize(&format!("{constraint}{stability_suffix}")[1..], None)?;
663        let high = manipulate_version_string(&m, position, 1)
664            .map(|v| format!("{v}-dev"))
665            .ok_or_else(|| {
666                VersionError(format!("Could not parse version constraint {constraint}"))
667            })?;
668        return Ok(vec![
669            Constraint::new(Op::Ge, low),
670            Constraint::new(Op::Lt, high),
671        ]);
672    }
673    // X ranges
674    if let Some(m) = groups_of(
675        regex(
676            &XRANGE,
677            r"^v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.[xX*])++$",
678            false,
679        ),
680        &constraint,
681    ) {
682        let at = |i: usize| m.get(i).map(String::as_str).unwrap_or("");
683        let position = if !at(3).is_empty() {
684            3
685        } else if !at(2).is_empty() {
686            2
687        } else {
688            1
689        };
690        let mut mm = m.clone();
691        while mm.len() < 5 {
692            mm.push(String::new());
693        }
694        let low = manipulate_version_string(&mm, position, 0)
695            .map(|v| format!("{v}-dev"))
696            .ok_or_else(|| {
697                VersionError(format!("Could not parse version constraint {constraint}"))
698            })?;
699        let high = manipulate_version_string(&mm, position, 1)
700            .map(|v| format!("{v}-dev"))
701            .ok_or_else(|| {
702                VersionError(format!("Could not parse version constraint {constraint}"))
703            })?;
704        if low == "0.0.0.0-dev" {
705            return Ok(vec![Constraint::new(Op::Lt, high)]);
706        }
707        return Ok(vec![
708            Constraint::new(Op::Ge, low),
709            Constraint::new(Op::Lt, high),
710        ]);
711    }
712    // hyphen range
713    let hyphen = regex(
714        &HYPHEN,
715        &format!(r"^(?P<from>{version_regex}) +- +(?P<to>{version_regex})($)"),
716        true,
717    );
718    if let Some(m) = groups_of(hyphen, &constraint) {
719        // Groups: 1 = from, 2..9 = components of from, 10 = to, 11..18 = components of to.
720        let at = |i: usize| m.get(i).map(String::as_str).unwrap_or("");
721        let mut low_suffix = String::new();
722        if empty(at(6)) && empty(at(8)) && empty(at(9)) {
723            low_suffix.push_str("-dev");
724        }
725        let low = normalize(at(1), None)?;
726        let lower = Constraint::new(Op::Ge, format!("{low}{low_suffix}"));
727        // `$empty`: "0" is not empty, "" is.
728        let php_empty = |s: &str| s.is_empty();
729        let upper = if (!php_empty(at(12)) && !php_empty(at(13)))
730            || !empty(at(15))
731            || !empty(at(17))
732            || !empty(at(18))
733        {
734            Constraint::new(Op::Le, normalize(at(10), None)?)
735        } else {
736            let high_match = vec![
737                String::new(),
738                at(11).to_owned(),
739                at(12).to_owned(),
740                at(13).to_owned(),
741                at(14).to_owned(),
742            ];
743            normalize(at(10), None)?;
744            let pos = if php_empty(at(12)) { 1 } else { 2 };
745            let high = manipulate_version_string(&high_match, pos, 1)
746                .map(|v| format!("{v}-dev"))
747                .ok_or_else(|| {
748                    VersionError(format!("Could not parse version constraint {constraint}"))
749                })?;
750            Constraint::new(Op::Lt, high)
751        };
752        return Ok(vec![lower, upper]);
753    }
754    // basic comparators
755    if let Some(m) = groups_of(
756        regex(&BASIC, r"^(<>|!=|>=?|<=?|==?)?\s*(.*)", false),
757        &constraint,
758    ) {
759        let at = |i: usize| m.get(i).map(String::as_str).unwrap_or("");
760        let op_str = at(1);
761        let raw = at(2);
762        let version = match normalize(raw, None) {
763            Ok(v) => v,
764            Err(e) => {
765                if raw.ends_with("-dev")
766                    && raw
767                        .bytes()
768                        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'/'))
769                {
770                    normalize(&format!("dev-{}", &raw[..raw.len() - 4]), None)?
771                } else {
772                    return Err(VersionError(format!(
773                        "Could not parse version constraint {constraint}: {}",
774                        e.0
775                    )));
776                }
777            }
778        };
779        let op = if op_str.is_empty() { "=" } else { op_str };
780        let mut version = version;
781        if op != "=="
782            && op != "="
783            && stability_modifier.is_some()
784            && version::parse_stability(&version) == "stable"
785        {
786            if let Some(s) = &stability_modifier {
787                version = format!("{version}-{s}");
788            }
789        } else if op == "<" || op == ">=" {
790            let modifier_end = regex(&MODIFIER_END, &format!("-{MODIFIER_REGEX}$"), false);
791            let lower = raw.to_lowercase();
792            if !modifier_end.is_match(lower.as_bytes()).unwrap_or(false) && !raw.starts_with("dev-")
793            {
794                version.push_str("-dev");
795            }
796        }
797        let op = Op::parse(op).ok_or_else(|| {
798            VersionError(format!("Could not parse version constraint {constraint}"))
799        })?;
800        return Ok(vec![Constraint::new(op, version)]);
801    }
802    Err(VersionError(format!(
803        "Could not parse version constraint {constraint}"
804    )))
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    fn p(s: &str) -> String {
812        parse_constraints(s).unwrap().constraint.to_string()
813    }
814
815    #[test]
816    fn parses_common_forms() {
817        assert_eq!(p("^1.2"), "[>= 1.2.0.0-dev < 2.0.0.0-dev]");
818        assert_eq!(p("~1.2"), "[>= 1.2.0.0-dev < 2.0.0.0-dev]");
819        assert_eq!(p("~1.2.3"), "[>= 1.2.3.0-dev < 1.3.0.0-dev]");
820        assert_eq!(p("^0.3"), "[>= 0.3.0.0-dev < 0.4.0.0-dev]");
821        assert_eq!(p("1.0.*"), "[>= 1.0.0.0-dev < 1.1.0.0-dev]");
822        assert_eq!(p("*"), "*");
823        assert_eq!(p(">=1.0"), ">= 1.0.0.0-dev");
824        assert_eq!(p("<2"), "< 2.0.0.0-dev");
825        assert_eq!(p("1.0.0"), "== 1.0.0.0");
826        assert_eq!(p("^1.0 || ^2.0"), "[>= 1.0.0.0-dev < 3.0.0.0-dev]");
827        assert_eq!(
828            p("~1.2 || ~2.0 || ~4.0"),
829            "[[>= 1.2.0.0-dev < 3.0.0.0-dev] || [>= 4.0.0.0-dev < 5.0.0.0-dev]]"
830        );
831        assert_eq!(p(">=1.0 <2.0"), "[>= 1.0.0.0-dev < 2.0.0.0-dev]");
832        assert_eq!(p("1.0 - 2.0"), "[>= 1.0.0.0-dev < 2.1.0.0-dev]");
833        assert_eq!(p("dev-main"), "== dev-main");
834        assert_eq!(p("1.x-dev"), "== 1.9999999.9999999.9999999-dev");
835        assert_eq!(p("^8.1@dev"), "[>= 8.1.0.0-dev < 9.0.0.0-dev]");
836        assert_eq!(p("!=1.0"), "!= 1.0.0.0");
837        assert_eq!(p("dev-main#abc"), "== dev-main");
838        assert_eq!(p("^1.0@beta"), "[>= 1.0.0.0-dev < 2.0.0.0-dev]");
839        assert_eq!(p("2.0.x-dev"), "== 2.0.9999999.9999999-dev");
840    }
841
842    #[test]
843    fn matching_and_bounds() {
844        let c = parse_constraints("^1.2").unwrap().constraint;
845        assert!(c.matches_version("1.5.0.0"));
846        assert!(!c.matches_version("2.0.0.0"));
847        assert!(c.matches_version("1.2.0.0-beta1"));
848        assert!(!c.matches_version("dev-main"));
849        let d = parse_constraints("dev-main").unwrap().constraint;
850        assert!(d.matches_version("dev-main"));
851        assert!(!d.matches_version("1.0.0.0"));
852        let ne = parse_constraints("!=1.0").unwrap().constraint;
853        assert!(ne.matches_version("dev-main"));
854        assert_eq!(c.lower_bound().to_string(), "1.2.0.0-dev [inclusive]");
855        assert_eq!(c.upper_bound().to_string(), "2.0.0.0-dev [exclusive]");
856    }
857}