Skip to main content

vivacity_resolver/
version.rs

1//! Port of `Composer\Semver\VersionParser` (docs/reference/resolver/
2//! semver-VersionParser.php): `normalize`, `normalizeBranch`,
3//! `parseStability`, `parseNumericAliasPrefix`, with the same PCRE
4//! expressions (pcre2), so that every string is accepted or rejected exactly
5//! as Composer does.
6
7use pcre2::bytes::{Regex, RegexBuilder};
8use std::sync::OnceLock;
9
10pub const MODIFIER_REGEX: &str =
11    r"[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*+)?)?([.-]?dev)?";
12pub const STABILITIES_REGEX: &str = "stable|RC|beta|alpha|dev";
13pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
14
15#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
16#[error("{0}")]
17pub struct VersionError(pub String);
18
19/// PCRE regex compiled once; `caseless` = the `i` modifier.
20pub(crate) fn regex(
21    cell: &'static OnceLock<Regex>,
22    pattern: &str,
23    caseless: bool,
24) -> &'static Regex {
25    cell.get_or_init(|| {
26        RegexBuilder::new()
27            .caseless(caseless)
28            .build(pattern)
29            .unwrap_or_else(|e| panic!("regex `{pattern}`: {e}"))
30    })
31}
32
33/// Captured group (`""` if absent), like `$matches[$i]` in PHP.
34pub(crate) fn group<'a>(caps: &pcre2::bytes::Captures<'a>, i: usize) -> &'a str {
35    caps.get(i)
36        .map(|m| std::str::from_utf8(m.as_bytes()).unwrap_or(""))
37        .unwrap_or("")
38}
39
40/// `VersionParser::parseStability`.
41pub fn parse_stability(version: &str) -> &'static str {
42    // Fast path: a purely numeric version (`1.2.3.0`) is stable, no
43    // modifier can appear in it.
44    if !version.is_empty() && version.bytes().all(|c| c.is_ascii_digit() || c == b'.') {
45        return "stable";
46    }
47    static HASH: OnceLock<Regex> = OnceLock::new();
48    static MOD: OnceLock<Regex> = OnceLock::new();
49    let hash = regex(&HASH, r"#.+$", false);
50    let version: String = match hash.find(version.as_bytes()).ok().flatten() {
51        Some(m) => version[..m.start()].to_owned(),
52        None => version.to_owned(),
53    };
54    if version.starts_with("dev-") || version.ends_with("-dev") {
55        return "dev";
56    }
57    let re = regex(&MOD, &format!("{MODIFIER_REGEX}(?:\\+.*)?$"), true);
58    let lower = version.to_lowercase();
59    if let Ok(Some(caps)) = re.captures(lower.as_bytes()) {
60        if !group(&caps, 3).is_empty() {
61            return "dev";
62        }
63        match group(&caps, 1) {
64            "beta" | "b" => return "beta",
65            "alpha" | "a" => return "alpha",
66            "rc" => return "RC",
67            _ => {}
68        }
69    }
70    "stable"
71}
72
73/// `BasePackage::STABILITIES`: numeric rank of a stability.
74pub fn stability_rank(stability: &str) -> i32 {
75    match stability {
76        "stable" => 0,
77        "RC" => 5,
78        "beta" => 10,
79        "alpha" => 15,
80        "dev" => 20,
81        _ => 0,
82    }
83}
84
85fn expand_stability(stability: &str) -> String {
86    match stability.to_lowercase().as_str() {
87        "a" => "alpha".to_owned(),
88        "b" => "beta".to_owned(),
89        "p" | "pl" => "patch".to_owned(),
90        "rc" => "RC".to_owned(),
91        other => other.to_owned(),
92    }
93}
94
95/// `VersionParser::normalize($version, $fullVersion)`.
96pub fn normalize(version: &str, full_version: Option<&str>) -> Result<String, VersionError> {
97    static AS: OnceLock<Regex> = OnceLock::new();
98    static AT: OnceLock<Regex> = OnceLock::new();
99    static PLUS: OnceLock<Regex> = OnceLock::new();
100    static NUM: OnceLock<Regex> = OnceLock::new();
101    static DATE: OnceLock<Regex> = OnceLock::new();
102    static DEV: OnceLock<Regex> = OnceLock::new();
103
104    let mut version = version.trim().to_owned();
105    let orig = version.clone();
106    let full_version = full_version
107        .map(str::to_owned)
108        .unwrap_or_else(|| version.clone());
109
110    if let Ok(Some(caps)) =
111        regex(&AS, r"^([^,\s]++) ++as ++([^,\s]++)$", false).captures(version.as_bytes())
112    {
113        version = group(&caps, 1).to_owned();
114    }
115    if let Ok(Some(m)) =
116        regex(&AT, &format!("@(?:{STABILITIES_REGEX})$"), true).find(version.as_bytes())
117    {
118        version.truncate(m.start());
119    }
120    if matches!(version.as_str(), "master" | "trunk" | "default") {
121        version = format!("dev-{version}");
122    }
123    if version.len() >= 4 && version[..4].eq_ignore_ascii_case("dev-") {
124        return Ok(format!("dev-{}", &version[4..]));
125    }
126    if let Ok(Some(caps)) =
127        regex(&PLUS, r"^([^,\s+]++)\+[^\s]++$", false).captures(version.as_bytes())
128    {
129        version = group(&caps, 1).to_owned();
130    }
131
132    let num = regex(
133        &NUM,
134        &format!(r"^v?(\d{{1,5}}+)(\.\d++)?(\.\d++)?(\.\d++)?{MODIFIER_REGEX}$"),
135        true,
136    );
137    let date = regex(
138        &DATE,
139        &format!(
140            r"^v?(\d{{4}}(?:[.:-]?\d{{2}}){{1,6}}(?:[.:-]?\d{{1,3}}){{0,2}}){MODIFIER_REGEX}$"
141        ),
142        true,
143    );
144    // Groups are copied (Vec<String>): `version` is reassigned afterwards.
145    let capture_groups = |re: &Regex, subject: &str| -> Option<Vec<String>> {
146        let caps = re.captures(subject.as_bytes()).ok()??;
147        Some(
148            (0..caps.len())
149                .map(|i| group(&caps, i).to_owned())
150                .collect(),
151        )
152    };
153    let mut index: Option<usize> = None;
154    let mut groups: Vec<String> = Vec::new();
155    if let Some(g) = capture_groups(num, &version) {
156        let or0 = |s: &str| {
157            if s.is_empty() {
158                ".0".to_owned()
159            } else {
160                s.to_owned()
161            }
162        };
163        version = format!("{}{}{}{}", g[1], or0(&g[2]), or0(&g[3]), or0(&g[4]));
164        index = Some(5);
165        groups = g;
166    } else if let Some(g) = capture_groups(date, &version) {
167        version = g[1]
168            .chars()
169            .map(|c| if c.is_ascii_digit() { c } else { '.' })
170            .collect();
171        index = Some(2);
172        groups = g;
173    }
174    if let Some(index) = index {
175        let at = |i: usize| groups.get(i).map(String::as_str).unwrap_or("");
176        let stab = at(index);
177        if !stab.is_empty() {
178            if stab == "stable" {
179                return Ok(version);
180            }
181            let numbers = at(index + 1).trim_start_matches(['.', '-']);
182            version = format!("{version}-{}{numbers}", expand_stability(stab));
183        }
184        if !at(index + 2).is_empty() {
185            version.push_str("-dev");
186        }
187        return Ok(version);
188    }
189
190    if let Ok(Some(caps)) = regex(&DEV, r"(.*?)[.-]?dev$", true).captures(version.as_bytes()) {
191        let normalized = normalize_branch(group(&caps, 1));
192        if !normalized.contains("dev-") {
193            return Ok(normalized);
194        }
195    }
196
197    let mut extra = String::new();
198    let quoted = preg_quote(&version);
199    let as_alias = RegexBuilder::new()
200        .build(&format!(" +as +{quoted}(?:@(?:{STABILITIES_REGEX}))?$"))
201        .ok()
202        .and_then(|r| r.is_match(full_version.as_bytes()).ok())
203        .unwrap_or(false);
204    let as_source = RegexBuilder::new()
205        .build(&format!("^{quoted}(?:@(?:{STABILITIES_REGEX}))? +as +"))
206        .ok()
207        .and_then(|r| r.is_match(full_version.as_bytes()).ok())
208        .unwrap_or(false);
209    if as_alias {
210        extra = format!(" in \"{full_version}\", the alias must be an exact version");
211    } else if as_source {
212        extra = format!(
213            " in \"{full_version}\", the alias source must be an exact version, if it is a branch name you should prefix it with dev-"
214        );
215    }
216    Err(VersionError(format!(
217        "Invalid version string \"{orig}\"{extra}"
218    )))
219}
220
221/// `preg_quote` (without delimiter).
222pub fn preg_quote(s: &str) -> String {
223    let mut out = String::with_capacity(s.len());
224    for c in s.chars() {
225        if c == '\0' {
226            out.push_str("\\000");
227            continue;
228        }
229        if ".\\+*?[^]$(){}=!<>|:-#".contains(c) {
230            out.push('\\');
231        }
232        out.push(c);
233    }
234    out
235}
236
237/// `VersionParser::normalizeBranch`.
238pub fn normalize_branch(name: &str) -> String {
239    static RE: OnceLock<Regex> = OnceLock::new();
240    let name = name.trim();
241    let re = regex(
242        &RE,
243        r"^v?(\d++)(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?$",
244        true,
245    );
246    if let Ok(Some(caps)) = re.captures(name.as_bytes()) {
247        let mut version = String::new();
248        for i in 1..5 {
249            let g = group(&caps, i);
250            if caps.get(i).is_some() {
251                version.push_str(&g.replace(['*', 'X'], "x"));
252            } else {
253                version.push_str(".x");
254            }
255        }
256        return format!("{}-dev", version.replace('x', "9999999"));
257    }
258    format!("dev-{name}")
259}
260
261/// `VersionParser::parseNumericAliasPrefix`.
262pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
263    static RE: OnceLock<Regex> = OnceLock::new();
264    let re = regex(&RE, r"^(?P<version>(\d++\.)*\d++)(?:\.x)?-dev$", true);
265    let caps = re.captures(branch.as_bytes()).ok()??;
266    Some(format!("{}.", group(&caps, 1)))
267}
268
269/// `VersionParser::normalizeDefaultBranch`.
270pub fn normalize_default_branch(name: &str) -> String {
271    if matches!(name, "dev-master" | "dev-default" | "dev-trunk") {
272        DEFAULT_BRANCH_ALIAS.to_owned()
273    } else {
274        name.to_owned()
275    }
276}
277
278/// `VersionParser::parseNameVersionPairs`: `a/b:^1`, `a/b=^1`, `a/b ^1`
279/// (two arguments, unless the second looks like a package name).
280pub fn parse_name_version_pairs(pairs: &[String]) -> Vec<(String, Option<String>)> {
281    static SPLIT: OnceLock<Regex> = OnceLock::new();
282    static WILDCARD: OnceLock<Regex> = OnceLock::new();
283    let split = regex(&SPLIT, r"^([^=: ]+)[=: ](.*)$", false);
284    let wildcard = regex(&WILDCARD, r"(?<=[a-z0-9_/-])\*|\*(?=[a-z0-9_/-])", true);
285    let mut result = Vec::new();
286    let mut i = 0;
287    while i < pairs.len() {
288        let trimmed = pairs[i].trim();
289        let mut pair = match split.captures(trimmed.as_bytes()) {
290            Ok(Some(c)) => {
291                let g = |n: usize| {
292                    c.get(n)
293                        .and_then(|m| std::str::from_utf8(m.as_bytes()).ok())
294                        .unwrap_or("")
295                };
296                format!("{} {}", g(1), g(2))
297            }
298            _ => trimmed.to_owned(),
299        };
300        if !pair.contains(' ') {
301            if let Some(next) = pairs.get(i + 1) {
302                if !next.contains('/')
303                    && !wildcard.is_match(next.as_bytes()).unwrap_or(false)
304                    && !crate::platform::is_platform_package(next)
305                {
306                    pair.push(' ');
307                    pair.push_str(next);
308                    i += 1;
309                }
310            }
311        }
312        match pair.find(' ') {
313            Some(pos) if pos > 0 => {
314                result.push((pair[..pos].to_owned(), Some(pair[pos + 1..].to_owned())));
315            }
316            _ => result.push((pair, None)),
317        }
318        i += 1;
319    }
320    result
321}
322
323/// `{^\d+(\.\d+)?$}`: an "overly strict" constraint (`1` or `1.2`) that
324/// `require` warns about.
325pub fn looks_too_strict(constraint: &str) -> bool {
326    static RE: OnceLock<Regex> = OnceLock::new();
327    regex(&RE, r"^\d+(\.\d+)?$", false)
328        .is_match(constraint.as_bytes())
329        .unwrap_or(false)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn normalizes_like_composer() {
338        assert_eq!(normalize("1.0", None).unwrap(), "1.0.0.0");
339        assert_eq!(normalize("v1.2.3-beta2", None).unwrap(), "1.2.3.0-beta2");
340        assert_eq!(normalize("1.0.0RC1", None).unwrap(), "1.0.0.0-RC1");
341        assert_eq!(normalize("dev-main", None).unwrap(), "dev-main");
342        assert_eq!(normalize("master", None).unwrap(), "dev-master");
343        assert_eq!(
344            normalize("1.x-dev", None).unwrap(),
345            "1.9999999.9999999.9999999-dev"
346        );
347        assert_eq!(
348            normalize("2.0.x-dev", None).unwrap(),
349            "2.0.9999999.9999999-dev"
350        );
351        assert_eq!(normalize("1.0.0+build.1", None).unwrap(), "1.0.0.0");
352        assert_eq!(normalize("1.0.0-dev", None).unwrap(), "1.0.0.0-dev");
353        assert_eq!(normalize("2020-01-02", None).unwrap(), "2020.01.02");
354        assert_eq!(normalize("1.0@dev", None).unwrap(), "1.0.0.0");
355        assert_eq!(normalize("dev-main as 1.0", None).unwrap(), "dev-main");
356        assert_eq!(normalize("1.0.0-p1", None).unwrap(), "1.0.0.0-patch1");
357        assert!(normalize("nope", None).is_err());
358        assert_eq!(parse_stability("1.0.0-RC1"), "RC");
359        assert_eq!(parse_stability("1.0.0-beta"), "beta");
360        assert_eq!(parse_stability("dev-main"), "dev");
361        assert_eq!(parse_stability("1.x-dev"), "dev");
362        assert_eq!(parse_stability("1.0.0"), "stable");
363        assert_eq!(parse_stability("1.0.0-alpha1#abc"), "alpha");
364        assert_eq!(normalize_branch("2.2"), "2.2.9999999.9999999-dev");
365        assert_eq!(parse_numeric_alias_prefix("1.2.x-dev"), Some("1.2.".into()));
366        assert_eq!(parse_numeric_alias_prefix("dev-main"), None);
367    }
368
369    #[test]
370    fn name_version_pairs_follow_composer() {
371        let pairs = |v: &[&str]| {
372            parse_name_version_pairs(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>())
373        };
374        assert_eq!(
375            pairs(&["a/b:^1.0"]),
376            vec![("a/b".into(), Some("^1.0".into()))]
377        );
378        assert_eq!(
379            pairs(&["a/b=^1.0"]),
380            vec![("a/b".into(), Some("^1.0".into()))]
381        );
382        assert_eq!(
383            pairs(&["a/b", "^1.0"]),
384            vec![("a/b".into(), Some("^1.0".into()))]
385        );
386        assert_eq!(
387            pairs(&["a/b", "c/d"]),
388            vec![("a/b".into(), None), ("c/d".into(), None)]
389        );
390        assert_eq!(
391            pairs(&["a/b", "ext-json"]),
392            vec![("a/b".into(), None), ("ext-json".into(), None)]
393        );
394        assert_eq!(
395            pairs(&["a/b", "c/*"]),
396            vec![("a/b".into(), None), ("c/*".into(), None)]
397        );
398        assert_eq!(
399            pairs(&["a/b:dev-main as 1.x-dev"]),
400            vec![("a/b".into(), Some("dev-main as 1.x-dev".into()))]
401        );
402    }
403}