Skip to main content

vivacity_resolver/
phpver.rs

1//! Exact port of PHP's `version_compare()` (ext/standard/versioning.c):
2//! canonicalization (`-`, `_`, `+` -> `.`, a `.` inserted at every
3//! digit/non-digit transition), then component-by-component comparison; the
4//! special forms are ordered `dev < alpha = a < beta = b < RC = rc < # < pl
5//! = p`, a number counting as `#`. This is the ordering that
6//! `Constraint::versionCompare`, the bounds and version sorting rely on.
7
8use std::cmp::Ordering;
9
10fn is_special(c: u8) -> bool {
11    matches!(c, b'-' | b'_' | b'+')
12}
13
14/// `php_canonicalize_version`: the first character is copied as is, then
15/// `-`/`_`/`+` and any non-alphanumeric become `.` (never doubled), and a
16/// `.` is inserted at every digit <-> non-digit transition.
17pub fn canonicalize(version: &str) -> String {
18    let mut out = Vec::new();
19    canonicalize_into(version, &mut out);
20    String::from_utf8_lossy(&out).into_owned()
21}
22
23/// `canonicalize` into a reusable buffer (cleared first).
24fn canonicalize_into(version: &str, out: &mut Vec<u8>) {
25    out.clear();
26    let bytes = version.as_bytes();
27    let Some((&first, rest)) = bytes.split_first() else {
28        return;
29    };
30    let is_dig = |c: u8| c.is_ascii_digit();
31    let is_ndig = |c: u8| !c.is_ascii_digit() && c != b'.';
32    out.reserve(bytes.len() * 2);
33    out.push(first);
34    let mut lp = first;
35    for &c in rest {
36        if is_special(c) {
37            if out.last() != Some(&b'.') {
38                out.push(b'.');
39            }
40        } else if (is_ndig(lp) && is_dig(c)) || (is_dig(lp) && is_ndig(c)) {
41            if out.last() != Some(&b'.') {
42                out.push(b'.');
43            }
44            out.push(c);
45        } else if !c.is_ascii_alphanumeric() {
46            if out.last() != Some(&b'.') {
47                out.push(b'.');
48            }
49        } else {
50            out.push(c);
51        }
52        lp = c;
53    }
54}
55
56/// `compare_special_version_forms`: rank by prefix, -1 if unknown.
57fn special_rank(form: &str) -> i32 {
58    const FORMS: &[(&str, i32)] = &[
59        ("dev", 0),
60        ("alpha", 1),
61        ("a", 1),
62        ("beta", 2),
63        ("b", 2),
64        ("RC", 3),
65        ("rc", 3),
66        ("#", 4),
67        ("pl", 5),
68        ("p", 5),
69    ];
70    for (name, order) in FORMS {
71        if form.starts_with(name) {
72            return *order;
73        }
74    }
75    -1
76}
77
78fn compare_special(a: &str, b: &str) -> Ordering {
79    special_rank(a).cmp(&special_rank(b))
80}
81
82fn parse_num(s: &str) -> i64 {
83    // strtol: numeric prefix, 0 otherwise (saturating like strtol on long).
84    let mut n: i64 = 0;
85    for c in s.bytes().take_while(u8::is_ascii_digit) {
86        n = n.saturating_mul(10).saturating_add(i64::from(c - b'0'));
87    }
88    n
89}
90
91/// String of the form `\d+(\.\d+)*`: canonicalization is the identity and
92/// the comparison is purely numeric per component.
93fn is_plain_dotted(s: &str) -> bool {
94    let b = s.as_bytes();
95    !b.is_empty()
96        && b[0].is_ascii_digit()
97        && b[b.len() - 1].is_ascii_digit()
98        && b.iter().all(|c| c.is_ascii_digit() || *c == b'.')
99        && !b.windows(2).any(|w| w == b"..")
100}
101
102fn starts_digit(s: &str) -> bool {
103    s.bytes().next().is_some_and(|b| b.is_ascii_digit())
104}
105
106/// `php_version_compare($a, $b)` on already canonicalized strings.
107fn compare_canonical(a: &str, b: &str) -> Ordering {
108    if a.is_empty() || b.is_empty() {
109        return match (a.is_empty(), b.is_empty()) {
110            (true, true) => Ordering::Equal,
111            (true, false) => Ordering::Less,
112            (false, true) => Ordering::Greater,
113            _ => Ordering::Equal,
114        };
115    }
116    let mut pa = a.split('.').peekable();
117    let mut pb = b.split('.').peekable();
118    let mut result = Ordering::Equal;
119    loop {
120        match (pa.next(), pb.next()) {
121            (Some(x), Some(y)) => {
122                result = match (starts_digit(x), starts_digit(y)) {
123                    (true, true) => parse_num(x).cmp(&parse_num(y)),
124                    (false, false) => compare_special(x, y),
125                    (true, false) => compare_special("#N#", y),
126                    (false, true) => compare_special(x, "#N#"),
127                };
128                if result != Ordering::Equal {
129                    return result;
130                }
131            }
132            (Some(x), None) => {
133                return if starts_digit(x) {
134                    Ordering::Greater
135                } else {
136                    compare_canonical(x, "#N#")
137                };
138            }
139            (None, Some(y)) => {
140                return if starts_digit(y) {
141                    Ordering::Less
142                } else {
143                    compare_canonical("#N#", y)
144                };
145            }
146            (None, None) => return result,
147        }
148    }
149}
150
151/// `version_compare($a, $b)`: -1 / 0 / 1.
152pub fn version_compare(a: &str, b: &str) -> Ordering {
153    if is_plain_dotted(a) && is_plain_dotted(b) {
154        // Allocation-free fast path: same rules (a missing component
155        // against a number -> smaller).
156        let mut pa = a.split('.');
157        let mut pb = b.split('.');
158        loop {
159            match (pa.next(), pb.next()) {
160                (Some(x), Some(y)) => {
161                    let c = parse_num(x).cmp(&parse_num(y));
162                    if c != Ordering::Equal {
163                        return c;
164                    }
165                }
166                (Some(_), None) => return Ordering::Greater,
167                (None, Some(_)) => return Ordering::Less,
168                (None, None) => return Ordering::Equal,
169            }
170        }
171    }
172    thread_local! {
173        static BUFS: std::cell::RefCell<(Vec<u8>, Vec<u8>)> = const { std::cell::RefCell::new((Vec::new(), Vec::new())) };
174    }
175    BUFS.with(|bufs| {
176        let mut bufs = bufs.borrow_mut();
177        let (ca, cb) = &mut *bufs;
178        canonicalize_into(a, ca);
179        canonicalize_into(b, cb);
180        // Inputs are ASCII in practice; a non-ASCII byte keeps its position.
181        compare_canonical(&String::from_utf8_lossy(ca), &String::from_utf8_lossy(cb))
182    })
183}
184
185/// `version_compare($a, $b, $op)`.
186pub fn version_compare_op(a: &str, b: &str, op: &str) -> bool {
187    let c = version_compare(a, b);
188    match op {
189        "<" | "lt" => c == Ordering::Less,
190        "<=" | "le" => c != Ordering::Greater,
191        ">" | "gt" => c == Ordering::Greater,
192        ">=" | "ge" => c != Ordering::Less,
193        "==" | "=" | "eq" => c == Ordering::Equal,
194        "!=" | "<>" | "ne" => c != Ordering::Equal,
195        _ => false,
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn canonical_forms() {
205        assert_eq!(canonicalize("1.0.0-dev"), "1.0.0.dev");
206        assert_eq!(canonicalize("1.0RC1"), "1.0.RC.1");
207        assert_eq!(canonicalize("1.0.0.0-beta2"), "1.0.0.0.beta.2");
208        assert_eq!(canonicalize("9999999-dev"), "9999999.dev");
209        assert_eq!(canonicalize("dev-main"), "dev.main");
210    }
211
212    #[test]
213    fn known_orderings() {
214        assert_eq!(version_compare("1.0.0.0-dev", "1.0.0.0"), Ordering::Less);
215        assert_eq!(
216            version_compare("1.0.0.0-alpha1", "1.0.0.0-beta1"),
217            Ordering::Less
218        );
219        assert_eq!(version_compare("1.0.0.0-RC1", "1.0.0.0"), Ordering::Less);
220        assert_eq!(version_compare("1.0.0.0", "1.0.0.0-patch1"), Ordering::Less);
221        assert_eq!(version_compare("1.0.0.0", "1.0.0"), Ordering::Greater);
222        assert_eq!(version_compare("1.0.0", "1.0.0.0"), Ordering::Less);
223        assert_eq!(version_compare("2.0.0.0", "10.0.0.0"), Ordering::Less);
224        assert_eq!(
225            version_compare("1.9999999.9999999.9999999-dev", "2.0.0.0-dev"),
226            Ordering::Less
227        );
228        assert_eq!(
229            version_compare("1.0.0.0-dev", "1.0.0.0-alpha"),
230            Ordering::Less
231        );
232        assert_eq!(
233            version_compare("1.0.0.0-b", "1.0.0.0-beta1"),
234            Ordering::Less
235        );
236    }
237}