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: ®ex::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}