Skip to main content

composer_semver/
version.rs

1//! Composer-flavored version: parse, normalize, compare.
2//!
3//! Closely mirrors `Composer\Semver\VersionParser::normalize` and
4//! `Comparator::compare` from `composer/semver` (commit
5//! `09af5e85b5f1380e4e098dde28950e2549cba4ed`, the version the
6//! Layer 1 conformance fixtures in `tests/data/conformance.json`
7//! were generated from). The PHP algorithm is two anchored regexes
8//! (classical + date) plus a modifier tail; we keep the same shape
9//! to stay auditable against the source.
10//!
11//! Two flavors share one type:
12//!
13//! - **Numeric** versions: the body is 1-N digit segments. Segments
14//!   are stored as strings so the canonical normalized output
15//!   preserves leading zeros (`00.01.03.04` → `00.01.03.04`).
16//!   Comparison uses the numeric value of each segment.
17//! - **Branch** versions: `dev-feature-foo`. The body isn't numeric
18//!   and they live in their own ordering space — bare branches sort
19//!   below all numerics; the special "default" branch names
20//!   (`master`, `main`, `default`, `trunk`) are normalized to
21//!   numeric `9999999.9999999.9999999.9999999-dev` so they sort at
22//!   the top.
23
24use crate::stability::Stability;
25use regex::Regex;
26use std::cmp::Ordering;
27use std::sync::OnceLock;
28
29#[derive(Debug, Clone, Eq, PartialEq, Hash)]
30pub struct Version {
31    pub kind: VersionKind,
32    /// Canonical normalized form. Always equal to what Composer's
33    /// `VersionParser::normalize` returns for the same input.
34    pub normalized: String,
35}
36
37#[derive(Debug, Clone, Eq, PartialEq, Hash)]
38pub enum VersionKind {
39    /// Numeric version: 1+ decimal segments + a stability suffix.
40    Numeric {
41        /// Segments preserved verbatim for Display fidelity (leading
42        /// zeros, etc.).
43        segments_raw: Vec<String>,
44        suffix: Suffix,
45    },
46    /// Branch-only version, name verbatim (case-preserved except
47    /// for the special "default" branches that always lower-case).
48    Branch(String),
49}
50
51/// Stability suffix on a numeric version. Composer's enum has more
52/// variants than [`Stability`] (which is the top-level keyword for
53/// `minimum-stability`) — `patch` is not its own `Stability` but
54/// appears in normalized strings as `-patchN`. `Dev` here means a
55/// bare `-dev`; `PrereleaseDev` is the `-RC1-dev` hybrid.
56#[derive(Debug, Clone, Eq, PartialEq, Hash)]
57pub enum Suffix {
58    /// No suffix.
59    Stable,
60    /// `-patchN`. Still stable in Composer's stability model.
61    Patch(u16),
62    /// `-RC<num>`, `-beta<num>`, `-alpha<num>`. `num` is the
63    /// canonical rendered number tail (`""` for bare `-beta`, `"0"`
64    /// for `-beta0`/`-beta.0`, `"3.1"` for `-alpha.3.1`, `"2.1-3"`
65    /// for `-alpha-2.1-3`). The string is appended verbatim after
66    /// the stability keyword.
67    Prerelease { stability: Stability, num: String },
68    /// Bare `-dev`.
69    Dev,
70    /// Prerelease followed by `-dev` (`-RC1-dev`, etc.).
71    PrereleaseDev { stability: Stability, num: String },
72    /// `-patch<num>-dev` hybrid (`1.0.0.pl3-dev` → `1.0.0.0-patch3-dev`).
73    PatchDev(u16),
74}
75
76impl Suffix {
77    pub fn stability(&self) -> Stability {
78        match self {
79            Self::Stable | Self::Patch(_) => Stability::Stable,
80            Self::Prerelease { stability, .. } => *stability,
81            Self::Dev | Self::PrereleaseDev { .. } | Self::PatchDev(_) => Stability::Dev,
82        }
83    }
84}
85
86#[derive(Debug, Clone, Eq, PartialEq)]
87pub enum ParseError {
88    Invalid(String),
89}
90
91impl std::fmt::Display for ParseError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Invalid(s) => write!(f, "invalid version: {s:?}"),
95        }
96    }
97}
98
99impl std::error::Error for ParseError {}
100
101// ---- regex bank --------------------------------------------------------
102//
103// One-time compiled regexes mirroring Composer's source. The modifier
104// regex is composed inline into the classical/date patterns so the
105// full input is anchored end-to-end.
106
107fn re_modifier_str() -> &'static str {
108    // From Composer: optional stability keyword + optional number(s)
109    // + optional `-dev` tail. The `+?` on the keyword group makes
110    // the number-only sub-group non-greedy so `-1` after the keyword
111    // doesn't get eaten by the number group when no keyword appears.
112    //
113    // We accept the keyword aliases Composer does:
114    //   stable, RC, beta/b, alpha/a, patch/pl/p, dev
115    //
116    // The capture groups are:
117    //   2 = keyword (lower-cased by us at use site)
118    //   3 = numeric tail attached to the keyword
119    //   4 = trailing `-dev` (when keyword is set)
120    // The leading "([._-]?)?" separator is gobbled silently.
121    // Keywords intentionally exclude `dev` — `dev` is only matched
122    // by the trailing `([.-]?dev)?` group, never as a standalone
123    // keyword. (Adding `dev` here would steal `1-2_dev` into group 1.)
124    r"(?:[._-]?(?:(stable|RC|beta|b|alpha|a|patch|pl|p)((?:[.-]?\d+)*)?)?)?([.-]?dev)?"
125}
126
127fn re_classical() -> &'static Regex {
128    static R: OnceLock<Regex> = OnceLock::new();
129    R.get_or_init(|| {
130        let pat = format!(
131            r"^(?i)v?(\d{{1,5}})(\.\d+)?(\.\d+)?(\.\d+)?{}$",
132            re_modifier_str()
133        );
134        Regex::new(&pat).unwrap()
135    })
136}
137
138fn re_date() -> &'static Regex {
139    static R: OnceLock<Regex> = OnceLock::new();
140    R.get_or_init(|| {
141        // Year + 1-6 two-digit segments + 0..N trailing 1-3-digit
142        // segments. The trailing `*` (not `?`) is what lets
143        // `20230131.0.0` and `2010-01-02-10-20-30.0.3` match — the
144        // tail can grow indefinitely with short numeric chunks.
145        // {0,2} (not `*`) bounds the trailing tail. Matches the
146        // upstream regex verbatim — see composer/semver
147        // commit 09af5e8 src/VersionParser.php.
148        let pat = format!(
149            r"^(?i)v?(\d{{4}}(?:[.:\-_]?\d{{2}}){{1,6}}(?:[.:\-_]?\d{{1,3}}){{0,2}}){}$",
150            re_modifier_str()
151        );
152        Regex::new(&pat).unwrap()
153    })
154}
155
156fn re_branch_alias() -> &'static Regex {
157    static R: OnceLock<Regex> = OnceLock::new();
158    R.get_or_init(|| Regex::new(r"^v?(\d+)(\.(?:\d+|[xX*]))*-dev$").unwrap())
159}
160
161fn re_normalize_branch() -> &'static Regex {
162    static R: OnceLock<Regex> = OnceLock::new();
163    R.get_or_init(|| Regex::new(r"^(\d+)(\.(?:\d+|[xX*]))*$").unwrap())
164}
165
166fn re_parse_stability() -> &'static Regex {
167    static R: OnceLock<Regex> = OnceLock::new();
168    R.get_or_init(|| {
169        let pat = format!(r"(?i){}(?:\+.*)?$", re_modifier_str());
170        Regex::new(&pat).unwrap()
171    })
172}
173
174/// True iff `s` matches the Composer branch-alias form
175/// `Nx-dev` / `N.x-dev` / `N.M.x-dev` (etc.). These appear both as
176/// versions (Packagist's dev document lists them) and as constraints
177/// in `composer.json` (`"phpmd/phpmd": "3.x-dev"`). The constraint
178/// parser routes the form to an `==` against the same string
179/// re-parsed as a `Version`.
180pub fn is_branch_alias(s: &str) -> bool {
181    re_branch_alias().is_match(s.trim())
182}
183
184/// Strip a trailing `@stable`/`@dev`/etc. stability flag. Composer
185/// accepts these on package require strings (e.g.
186/// `name@dev`); `normalize` drops them before further parsing.
187fn strip_at_stability(s: &str) -> String {
188    static R: OnceLock<Regex> = OnceLock::new();
189    let r = R.get_or_init(|| Regex::new(r"@(?i)(stable|RC|beta|alpha|dev)$").unwrap());
190    r.replace(s, "").into_owned()
191}
192
193fn re_dev_prefix() -> &'static Regex {
194    static R: OnceLock<Regex> = OnceLock::new();
195    R.get_or_init(|| Regex::new(r"^(?i)dev-(.+)$").unwrap())
196}
197
198// ---- parse / normalize --------------------------------------------------
199
200impl Version {
201    /// Parse a Composer version string into its normalized form.
202    /// Equivalent to PHP's `VersionParser::normalize($s)`.
203    ///
204    /// # Panics
205    ///
206    /// Doesn't, despite the internal `unwrap`s on `caps.get(N)`:
207    /// every group those reach is statically guaranteed to capture
208    /// by the regex shape (`\d{1,5}` at position 1 etc.). The
209    /// compile-time regex constructors also `.unwrap()` — those run
210    /// once at first call and would only fire if the regex literal
211    /// itself were ill-formed (caught at test time).
212    pub fn parse(s: &str) -> Result<Self, ParseError> {
213        let raw = s.trim();
214        if raw.is_empty() {
215            return Err(ParseError::Invalid(s.to_owned()));
216        }
217
218        // Strip `#<commit-ref>` suffix (any version may carry one).
219        let cleaned = match raw.split_once('#') {
220            Some((before, _)) => before,
221            None => raw,
222        };
223        let cleaned = cleaned.trim();
224
225        // `1.0 as 2.0` — keep the left side and re-normalize.
226        if let Some((left, _right)) = split_aliased(cleaned) {
227            return Self::parse(left);
228        }
229
230        // `name@stability` suffix — Composer accepts this on the
231        // composer.json side as an inline stability flag, but
232        // normalize strips it before further processing.
233        let cleaned = strip_at_stability(cleaned);
234
235        // Bare default-branch names (`master`, `trunk`, `default`)
236        // get a `dev-` prefix and the dev-prefix code path takes
237        // over. The wider set (`main`, `latest`, `head`) is NOT
238        // promoted by Composer's `normalize`; only the three legacy
239        // names. Their max-version-dev treatment lives in
240        // `normalize_default_branch` for use by sort, not parse.
241        let cleaned = match cleaned.as_str() {
242            "master" | "trunk" | "default" => format!("dev-{cleaned}"),
243            _ => cleaned,
244        };
245
246        // Branch alias forms `1.x-dev` / `1.2.x-dev` → numeric
247        // 9999999-padded.
248        if let Some(v) = parse_branch_alias(&cleaned) {
249            return Ok(v);
250        }
251
252        // Strip `+build-metadata` for numeric forms — but only when
253        // the tail is non-whitespace (`1.0.0+foo bar` has a space
254        // inside the metadata, which Composer rejects), and only
255        // when the leading body isn't a `dev-` branch (those keep
256        // their `+` verbatim — `dev-feature+issue-1`).
257        let numeric_candidate = if cleaned.to_ascii_lowercase().starts_with("dev-") {
258            cleaned.clone()
259        } else if let Some(idx) = cleaned.find('+') {
260            let tail = &cleaned[idx + 1..];
261            if tail.is_empty() || tail.chars().any(char::is_whitespace) {
262                cleaned.clone()
263            } else {
264                cleaned[..idx].to_owned()
265            }
266        } else {
267            cleaned.clone()
268        };
269
270        // Classical numeric: 1-4 dot-separated segments with optional
271        // modifier tail.
272        if let Some(caps) = re_classical().captures(&numeric_candidate) {
273            let segs = collect_classical_segments(&caps);
274            let suffix = parse_modifier(
275                caps.get(5).map_or("", |m| m.as_str()),
276                caps.get(6).map_or("", |m| m.as_str()),
277                caps.get(7).map_or("", |m| m.as_str()),
278            )?;
279            let normalized = render_numeric(&segs, &suffix);
280            return Ok(Version {
281                kind: VersionKind::Numeric {
282                    segments_raw: segs,
283                    suffix,
284                },
285                normalized,
286            });
287        }
288
289        // Date-shaped: `YYYYMMDD`, `YYYY.MM.DD`, etc.
290        if let Some(caps) = re_date().captures(&numeric_candidate) {
291            let body = caps.get(1).unwrap().as_str();
292            let segs = split_date_segments(body);
293            let suffix = parse_modifier(
294                caps.get(2).map_or("", |m| m.as_str()),
295                caps.get(3).map_or("", |m| m.as_str()),
296                caps.get(4).map_or("", |m| m.as_str()),
297            )?;
298            let normalized = render_numeric(&segs, &suffix);
299            return Ok(Version {
300                kind: VersionKind::Numeric {
301                    segments_raw: segs,
302                    suffix,
303                },
304                normalized,
305            });
306        }
307
308        // Explicit `dev-<branchname>` (anything not caught above).
309        // Use the original `cleaned` here so the `+metadata` we
310        // stripped from the numeric candidate doesn't leak through.
311        if let Some(caps) = re_dev_prefix().captures(&cleaned) {
312            let body = caps.get(1).unwrap().as_str();
313            return Ok(Version {
314                kind: VersionKind::Branch(body.to_owned()),
315                normalized: format!("dev-{body}"),
316            });
317        }
318
319        Err(ParseError::Invalid(s.to_owned()))
320    }
321
322    /// Composer's `parseNumericAliasPrefix($branch)`. Given a branch
323    /// name like `1.x-dev` returns `Some("1.")`; for non-aliases
324    /// returns `None`.
325    pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
326        // Three accepted shapes per Composer:
327        //   1.x-dev  → 1.
328        //   1.2-dev  → 1.2.
329        //   1.2.x-dev → 1.2.
330        //   1-dev    → 1.
331        //   dev-master → None
332        let stripped = strip_v_prefix(branch);
333        let dev_body = stripped.strip_suffix("-dev")?;
334        // Reject anything with a non-numeric/non-wildcard segment.
335        let parts: Vec<&str> = dev_body.split('.').collect();
336        let mut prefix_parts: Vec<&str> = Vec::with_capacity(parts.len());
337        let mut saw_wildcard = false;
338        for p in &parts {
339            if matches!(*p, "x" | "X" | "*") {
340                saw_wildcard = true;
341                break;
342            }
343            if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() {
344                prefix_parts.push(p);
345            } else {
346                return None;
347            }
348        }
349        // `1.2.x-dev`: trailing wildcard → keep numeric prefix and a
350        //   trailing dot.
351        // `1.2-dev` / `1-dev`: no wildcard → still acceptable, full
352        //   numeric becomes the prefix.
353        let mut out = prefix_parts.join(".");
354        if !out.is_empty() {
355            out.push('.');
356        } else if !saw_wildcard {
357            // `-dev` alone with no numeric body is not a valid alias.
358            return None;
359        }
360        Some(out)
361    }
362
363    /// Composer's `normalizeBranch($branch)` — distinct from
364    /// `normalize` in that the input is a branch name (no `dev-`
365    /// prefix). Numeric-wildcard branches normalize to the same
366    /// 9999999-padded form `parse` would produce; everything else
367    /// becomes `dev-<name>`.
368    pub fn normalize_branch(branch: &str) -> String {
369        let stripped = strip_v_prefix(branch);
370        if re_normalize_branch().is_match(stripped) {
371            let mut segs_raw: Vec<String> = Vec::new();
372            for part in stripped.split('.') {
373                if matches!(part, "x" | "X" | "*") {
374                    segs_raw.push("9999999".to_owned());
375                } else {
376                    segs_raw.push(part.to_owned());
377                }
378            }
379            while segs_raw.len() < 4 {
380                segs_raw.push("9999999".to_owned());
381            }
382            return render_numeric(&segs_raw, &Suffix::Dev);
383        }
384        format!("dev-{stripped}")
385    }
386
387    /// Composer's `parseStability($version)`. Mirrors the upstream
388    /// algorithm directly: strip `#commit`, fast-path `dev-` prefix
389    /// and `-dev` suffix, then run the modifier regex against the
390    /// lower-cased input and read the keyword + dev capture from
391    /// the match.
392    pub fn parse_stability(version: &str) -> Stability {
393        let raw = version.trim();
394        let cleaned = match raw.split_once('#') {
395            Some((before, _)) => before,
396            None => raw,
397        };
398        let cleaned = cleaned.trim();
399        if cleaned.to_ascii_lowercase().starts_with("dev-")
400            || cleaned.to_ascii_lowercase().ends_with("-dev")
401        {
402            return Stability::Dev;
403        }
404        // Anchored-at-end modifier match (lowercased) — same shape
405        // as `VersionParser::parseStability`.
406        let lower = cleaned.to_ascii_lowercase();
407        if let Some(caps) = re_parse_stability().captures(&lower) {
408            // Group 3 is the trailing `-dev` capture.
409            if caps.get(3).is_some_and(|m| !m.as_str().is_empty()) {
410                return Stability::Dev;
411            }
412            // Group 1 is the stability keyword.
413            if let Some(kw) = caps.get(1) {
414                let s = kw.as_str();
415                if !s.is_empty() {
416                    return match s {
417                        "beta" | "b" => Stability::Beta,
418                        "alpha" | "a" => Stability::Alpha,
419                        "rc" => Stability::Rc,
420                        _ => Stability::Stable,
421                    };
422                }
423            }
424        }
425        Stability::Stable
426    }
427}
428
429impl std::fmt::Display for Version {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.write_str(&self.normalized)
432    }
433}
434
435/// Composer comparison operator used by [`Version::compare`]. The
436/// Comparator semantics differ from a total Ord (which we also
437/// provide for pubgrub's sake) — for two *different* bare dev
438/// branches, *every* operator returns `false` because Composer
439/// considers them incomparable.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub enum CmpOp {
442    Gt,
443    Ge,
444    Lt,
445    Le,
446    Eq,
447    Ne,
448}
449
450impl Version {
451    /// Composer's `Package::getStability()` applied to a parsed
452    /// `Version`. Branch versions are always `Dev`; numeric versions
453    /// inherit their `Suffix`'s stability (`Stable` for bare,
454    /// `Patch(_)`; `Dev` for bare `-dev`; the prerelease keyword
455    /// otherwise). This is the granular stability used to filter
456    /// candidates against `minimum-stability` and per-package
457    /// stability flags.
458    pub fn stability(&self) -> Stability {
459        match &self.kind {
460            VersionKind::Numeric { suffix, .. } => suffix.stability(),
461            VersionKind::Branch(_) => Stability::Dev,
462        }
463    }
464
465    /// Composer's comparator. Returns `false` for every op when the
466    /// two operands are bare dev branches with different names; `==`
467    /// is `true` only when both are bare branches with the same
468    /// name. Numeric-vs-Branch comparisons use the standard Ord
469    /// (Branch < Numeric).
470    pub fn compare(&self, op: CmpOp, other: &Version) -> bool {
471        if let (VersionKind::Branch(a), VersionKind::Branch(b)) = (&self.kind, &other.kind) {
472            return match (op, a == b) {
473                (CmpOp::Eq, same) => same,
474                (CmpOp::Ne, same) => !same,
475                (CmpOp::Ge | CmpOp::Le, true) => true,
476                _ => false,
477            };
478        }
479        let ord = self.cmp(other);
480        match op {
481            CmpOp::Gt => ord.is_gt(),
482            CmpOp::Ge => ord.is_ge(),
483            CmpOp::Lt => ord.is_lt(),
484            CmpOp::Le => ord.is_le(),
485            CmpOp::Eq => ord.is_eq(),
486            CmpOp::Ne => !ord.is_eq(),
487        }
488    }
489
490    /// Composer's `normalizeDefaultBranch`. Called by sort (NOT by
491    /// `normalize`/`parse`) to massage `dev-master`, `dev-default`,
492    /// `dev-trunk` into `9999999-dev` so they sort above numeric
493    /// versions. Other dev branches pass through unchanged.
494    pub fn normalize_default_branch(normalized: &str) -> String {
495        match normalized {
496            "dev-master" | "dev-default" | "dev-trunk" => "9999999-dev".to_owned(),
497            other => other.to_owned(),
498        }
499    }
500
501    /// Construct a Version with the same numeric body as `self` but
502    /// the given suffix. Returns `None` for [`VersionKind::Branch`]
503    /// (a branch ref has no numeric body to attach a suffix to).
504    ///
505    /// Used by the Composer-to-pubgrub `Ranges<Version>` conversion
506    /// to synthesize boundary markers: `<X` becomes
507    /// `strictly_lower_than(X.with_suffix(Suffix::Dev))` so that
508    /// every prerelease of `X` (which sorts ≥ `X-dev`) is excluded.
509    pub fn with_suffix(&self, suffix: Suffix) -> Option<Version> {
510        match &self.kind {
511            VersionKind::Numeric { segments_raw, .. } => {
512                let normalized = render_numeric(segments_raw, &suffix);
513                Some(Version {
514                    kind: VersionKind::Numeric {
515                        segments_raw: segments_raw.clone(),
516                        suffix,
517                    },
518                    normalized,
519                })
520            }
521            VersionKind::Branch(_) => None,
522        }
523    }
524}
525
526// ---- ordering -----------------------------------------------------------
527
528impl PartialOrd for Version {
529    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
530        Some(self.cmp(other))
531    }
532}
533
534impl Ord for Version {
535    fn cmp(&self, other: &Self) -> Ordering {
536        match (&self.kind, &other.kind) {
537            (
538                VersionKind::Numeric {
539                    segments_raw: a,
540                    suffix: sa,
541                },
542                VersionKind::Numeric {
543                    segments_raw: b,
544                    suffix: sb,
545                },
546            ) => cmp_segments(a, b).then_with(|| suffix_cmp(sa, sb)),
547            (VersionKind::Branch(_), VersionKind::Numeric { .. }) => Ordering::Less,
548            (VersionKind::Numeric { .. }, VersionKind::Branch(_)) => Ordering::Greater,
549            // Two bare-branch versions compare as Equal — Composer
550            // doesn't impose a lex order on dev branches (per the
551            // `sortProvider` fixture: `dev-foo > dev-bar` is false,
552            // and stable sorting preserves input order for the
553            // equal-class).
554            (VersionKind::Branch(_), VersionKind::Branch(_)) => Ordering::Equal,
555        }
556    }
557}
558
559fn cmp_segments(a: &[String], b: &[String]) -> Ordering {
560    let len = a.len().max(b.len());
561    for i in 0..len {
562        let va = segment_value(a.get(i));
563        let vb = segment_value(b.get(i));
564        match va.cmp(&vb) {
565            Ordering::Equal => {}
566            non_eq => return non_eq,
567        }
568    }
569    Ordering::Equal
570}
571
572/// Numeric value of a version segment for comparison. Missing segments
573/// are 0. A segment that is all digits but overflows `u64` saturates to
574/// `u64::MAX` so an absurdly long number still sorts *above* normal
575/// versions rather than collapsing to 0 (which would make e.g.
576/// `1.<huge>.0` compare equal to `1.0.0`).
577fn segment_value(s: Option<&String>) -> u64 {
578    match s {
579        None => 0,
580        Some(s) => s.parse::<u64>().unwrap_or_else(|_| {
581            if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
582                u64::MAX
583            } else {
584                0
585            }
586        }),
587    }
588}
589
590fn suffix_cmp(a: &Suffix, b: &Suffix) -> Ordering {
591    suffix_rank(a).cmp(&suffix_rank(b))
592}
593
594/// (tier, n). Higher tier = newer. `n` is the leading-int from the
595/// stability tail (e.g. `"2.1-3"` → 2).
596fn suffix_rank(s: &Suffix) -> (u8, u16) {
597    let leading_int = |num: &str| -> u16 {
598        let mut s = String::new();
599        for ch in num.chars() {
600            if ch.is_ascii_digit() {
601                s.push(ch);
602            } else {
603                break;
604            }
605        }
606        s.parse().unwrap_or(0)
607    };
608    match s {
609        Suffix::Dev => (0, 0),
610        Suffix::PrereleaseDev {
611            stability: Stability::Alpha,
612            num,
613        } => (1, leading_int(num)),
614        Suffix::Prerelease {
615            stability: Stability::Alpha,
616            num,
617        } => (2, leading_int(num)),
618        Suffix::PrereleaseDev {
619            stability: Stability::Beta,
620            num,
621        } => (3, leading_int(num)),
622        Suffix::Prerelease {
623            stability: Stability::Beta,
624            num,
625        } => (4, leading_int(num)),
626        Suffix::PrereleaseDev {
627            stability: Stability::Rc,
628            num,
629        } => (5, leading_int(num)),
630        Suffix::Prerelease {
631            stability: Stability::Rc,
632            num,
633        } => (6, leading_int(num)),
634        Suffix::PatchDev(n) => (0, *n),
635        Suffix::Stable => (7, 0),
636        Suffix::Patch(n) => (7, *n),
637        Suffix::Prerelease {
638            stability: Stability::Dev | Stability::Stable,
639            num,
640        } => (0, leading_int(num)),
641        Suffix::PrereleaseDev {
642            stability: Stability::Dev | Stability::Stable,
643            num,
644        } => (0, leading_int(num)),
645    }
646}
647
648// ---- helpers ------------------------------------------------------------
649
650fn strip_v_prefix(s: &str) -> &str {
651    s.strip_prefix(['v', 'V']).unwrap_or(s)
652}
653
654/// Split a `... as ...` alias form into (left, right). Returns `None`
655/// when the input has no alias.
656fn split_aliased(s: &str) -> Option<(&str, &str)> {
657    static R: OnceLock<Regex> = OnceLock::new();
658    let r = R.get_or_init(|| Regex::new(r"^([^,\s]+)\s+as\s+([^,\s]+)$").unwrap());
659    let caps = r.captures(s)?;
660    Some((caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()))
661}
662
663/// Collect classical-regex capture segments into a 4-segment Vec<String>,
664/// padding with `"0"` for missing positions.
665fn collect_classical_segments(caps: &regex::Captures) -> Vec<String> {
666    let s1 = caps.get(1).unwrap().as_str().to_owned();
667    let s2 = caps.get(2).map_or_else(
668        || "0".to_owned(),
669        |m| m.as_str().trim_start_matches('.').to_owned(),
670    );
671    let s3 = caps.get(3).map_or_else(
672        || "0".to_owned(),
673        |m| m.as_str().trim_start_matches('.').to_owned(),
674    );
675    let s4 = caps.get(4).map_or_else(
676        || "0".to_owned(),
677        |m| m.as_str().trim_start_matches('.').to_owned(),
678    );
679    vec![s1, s2, s3, s4]
680}
681
682/// Split a date-body string on any non-digit separator into segments
683/// (preserves leading zeros).
684fn split_date_segments(body: &str) -> Vec<String> {
685    let mut out: Vec<String> = Vec::new();
686    let mut cur = String::new();
687    for ch in body.chars() {
688        if ch.is_ascii_digit() {
689            cur.push(ch);
690        } else if !cur.is_empty() {
691            out.push(std::mem::take(&mut cur));
692        }
693    }
694    if !cur.is_empty() {
695        out.push(cur);
696    }
697    out
698}
699
700fn render_numeric(segments: &[String], suffix: &Suffix) -> String {
701    let mut out = segments.join(".");
702    match suffix {
703        Suffix::Stable => {}
704        Suffix::Patch(n) => {
705            out.push_str("-patch");
706            out.push_str(&n.to_string());
707        }
708        Suffix::Prerelease { stability, num } => {
709            out.push('-');
710            out.push_str(stability.as_str());
711            out.push_str(num);
712        }
713        Suffix::Dev => out.push_str("-dev"),
714        Suffix::PrereleaseDev { stability, num } => {
715            out.push('-');
716            out.push_str(stability.as_str());
717            out.push_str(num);
718            out.push_str("-dev");
719        }
720        Suffix::PatchDev(n) => {
721            out.push_str("-patch");
722            out.push_str(&n.to_string());
723            out.push_str("-dev");
724        }
725    }
726    out
727}
728
729/// Combine the three modifier-regex captures into a [`Suffix`].
730/// `explicit` flag tracks whether a number was actually written in
731/// the source (so `-beta.0` keeps the `0`, while `-beta` drops it).
732fn parse_modifier(keyword: &str, number_tail: &str, dev_tail: &str) -> Result<Suffix, ParseError> {
733    let kw_present = !keyword.is_empty();
734    let dev_present = !dev_tail.is_empty();
735    if !kw_present && !dev_present {
736        return Ok(Suffix::Stable);
737    }
738    if !kw_present && dev_present {
739        return Ok(Suffix::Dev);
740    }
741    let (stab, is_patch) = match keyword.to_ascii_lowercase().as_str() {
742        "stable" => {
743            return if dev_present {
744                Ok(Suffix::Dev)
745            } else {
746                Ok(Suffix::Stable)
747            };
748        }
749        "rc" => (Some(Stability::Rc), false),
750        "beta" | "b" => (Some(Stability::Beta), false),
751        "alpha" | "a" => (Some(Stability::Alpha), false),
752        "patch" | "pl" | "p" => (None, true),
753        _ => return Err(ParseError::Invalid(keyword.to_owned())),
754    };
755
756    let num = canonical_number_tail(number_tail);
757
758    if is_patch {
759        let n: u16 = num.parse().unwrap_or(0);
760        return Ok(if dev_present {
761            Suffix::PatchDev(n)
762        } else {
763            Suffix::Patch(n)
764        });
765    }
766    let stab = stab.unwrap();
767    Ok(if dev_present {
768        Suffix::PrereleaseDev {
769            stability: stab,
770            num,
771        }
772    } else {
773        Suffix::Prerelease {
774            stability: stab,
775            num,
776        }
777    })
778}
779
780/// Convert a raw number tail (`""`, `"5"`, `".0"`, `"-2.1-3"`,
781/// `".3.1"`) into the canonical Composer rendering — strip a single
782/// leading separator (`.`, `-`, `_`) before the first digit, leave
783/// internal separators alone. Empty input returns the empty string.
784fn canonical_number_tail(t: &str) -> String {
785    let mut out = String::new();
786    let mut leading_stripped = false;
787    let mut chars = t.chars().peekable();
788    while let Some(&ch) = chars.peek() {
789        if !leading_stripped && matches!(ch, '.' | '-' | '_') {
790            chars.next();
791            leading_stripped = true;
792            continue;
793        }
794        if ch.is_ascii_digit() {
795            out.push(ch);
796            leading_stripped = true; // any leading separator opportunity over
797            chars.next();
798        } else {
799            // After the first digit, allow internal `.` / `-` /
800            // digits. Composer canonicalizes `_` separators inside
801            // the tail to … well, the fixture doesn't include such
802            // cases inside the modifier so we mirror the few that
803            // appear by passing them through.
804            if !out.is_empty() && matches!(ch, '.' | '-') {
805                out.push(ch);
806                chars.next();
807            } else {
808                break;
809            }
810        }
811    }
812    out
813}
814
815fn parse_branch_alias(s: &str) -> Option<Version> {
816    let m = re_branch_alias().captures(s)?;
817    let whole = m.get(0).unwrap().as_str();
818    let body = strip_v_prefix(whole).strip_suffix("-dev").unwrap();
819    let mut segs_raw: Vec<String> = Vec::new();
820    let mut had_wildcard = false;
821    for part in body.split('.') {
822        if matches!(part, "x" | "X" | "*") {
823            had_wildcard = true;
824            segs_raw.push("9999999".to_owned());
825        } else if part.chars().all(|c| c.is_ascii_digit()) && !part.is_empty() {
826            segs_raw.push(part.to_owned());
827        } else {
828            return None;
829        }
830    }
831    if !had_wildcard {
832        // No wildcard → not a branch alias; let the numeric parser
833        // handle `1.0-dev` etc.
834        return None;
835    }
836    while segs_raw.len() < 4 {
837        segs_raw.push("9999999".to_owned());
838    }
839    let normalized = render_numeric(&segs_raw, &Suffix::Dev);
840    Some(Version {
841        kind: VersionKind::Numeric {
842            segments_raw: segs_raw,
843            suffix: Suffix::Dev,
844        },
845        normalized,
846    })
847}