Semver Checker — Rust source
Parse, compare, and validate Semantic Versioning 2.0.0 strings. Check which of two versions is greater (with full prerelease precedence), test whether a version satisfies an npm-style range (^, ~, comparators, hyphen, ||), and bump major/minor/patch/prerelease. Runs 100% client-side.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! semver — Rust polyglot port.
//!
//! Language: Rust
//! CosmoDev polyglot showcase port of the "semver" tool.
//! Ported from src/lib/semver.ts — display source, part of CosmoDev's
//! polyglot tool pages.
//!
//! Implements Semantic Versioning 2.0.0 (semver.org): parsing, precedence
//! comparison (including prerelease ordering), npm-style range satisfaction
//! (^, ~, comparators, *, AND, ||, hyphen ranges), and version bumping.
//! Fully deterministic: every function depends only on its inputs.
//!
//! The Rust port deliberately uses no external crates (the `regex` crate is not
//! stdlib). Parsing is a small hand-written recursive-descent scanner over the
//! byte stream — a natural fit for Rust's byte-slicing style and a fair show of
//! the language for the polyglot reference surface.
//!
//! The public surface mirrors the TypeScript reference: `parse_semver`,
//! `format`, `compare`, `satisfies`, `bump`.
// Semver numbers can be large in pathological inputs; we use i64 and accept
// that absurdly large numeric fields (> ~9.2e18) fail to parse. No real-world
// version approaches this bound.
/// A parsed semantic version per semver 2.0.0.
///
/// `prerelease` and `build` hold the dot-separated identifiers verbatim; build
/// metadata is informational only and never affects precedence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Semver {
pub major: i64,
pub minor: i64,
pub patch: i64,
pub prerelease: Vec<String>,
pub build: Vec<String>,
}
// ─── Parsing ───────────────────────────────────────────────────────────────
/// Parse a strict semver string. A leading `v`/`V` is tolerated (the common
/// `v1.2.3` shorthand). Returns `None` when the input is not valid semver.
pub fn parse_semver(input: &str) -> Option<Semver> {
let trimmed = input.trim();
// Strip a SINGLE leading 'v'/'V' (matches the TS regex /^[vV]/).
let s = trimmed.strip_prefix(['v', 'V']).unwrap_or(trimmed);
// major . minor . patch
let (major, rest) = parse_num(s)?;
let rest = rest.strip_prefix('.')?;
let (minor, rest) = parse_num(rest)?;
let rest = rest.strip_prefix('.')?;
let (patch, mut rest) = parse_num(rest)?;
// Optional prerelease: "-{ident}(\.{ident})*".
let mut prerelease = Vec::new();
if let Some(after_dash) = rest.strip_prefix('-') {
let (pre, after_pre) = parse_dot_list(after_dash, validate_ident)?;
prerelease = pre;
rest = after_pre;
}
// Optional build metadata: "+{run}(\.{run})*". Build identifiers are just
// runs of ident-bytes — no leading-zero restriction applies to them.
let mut build = Vec::new();
if let Some(after_plus) = rest.strip_prefix('+') {
let (bld, after_bld) = parse_dot_list(after_plus, |_| true)?;
build = bld;
rest = after_bld;
}
// Anything left over means the input had trailing garbage.
if !rest.is_empty() {
return None;
}
Some(Semver { major, minor, patch, prerelease, build })
}
/// Parse a numeric field: `0` or `[1-9][0-9]*`. Returns the value and the
/// unparsed remainder. The slice indexing is safe: we only advance over ASCII
/// digit bytes, which are always valid UTF-8 boundaries.
fn parse_num(input: &str) -> Option<(i64, &str)> {
let b = input.as_bytes();
if b.is_empty() {
return None;
}
if b[0] == b'0' {
return Some((0, &input[1..]));
}
if !(b'1'..=b'9').contains(&b[0]) {
return None;
}
let mut end = 1;
while end < b.len() && b[end].is_ascii_digit() {
end += 1;
}
let n: i64 = input[..end].parse().ok()?;
Some((n, &input[end..]))
}
/// Parse a dot-separated list of identifier runs, applying `validate` to each.
/// Stops at the first byte that is not an ident char (e.g. `+` or end of input).
fn parse_dot_list<F>(input: &str, validate: F) -> Option<(Vec<String>, &str)>
where
F: Fn(&str) -> bool,
{
let mut rest = input;
let mut out = Vec::new();
loop {
let (ident, after) = parse_ident_run(rest)?;
if !validate(ident) {
return None;
}
out.push(ident.to_string());
rest = after;
match rest.strip_prefix('.') {
Some(after_dot) => rest = after_dot,
None => break,
}
}
Some((out, rest))
}
/// Consume a maximal run of ident bytes ([0-9a-zA-Z-]).
fn parse_ident_run(input: &str) -> Option<(&str, &str)> {
let b = input.as_bytes();
let mut end = 0;
while end < b.len() && is_ident_byte(b[end]) {
end += 1;
}
if end == 0 {
return None;
}
Some((&input[..end], &input[end..]))
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_digit() || b.is_ascii_alphabetic() || b == b'-'
}
/// Validate a single prerelease identifier against the IDENT grammar:
/// `0 | [1-9][0-9]* | [0-9]*[a-zA-Z-][0-9a-zA-Z-]*`.
/// A pure-digit run must not have a leading zero (except the single digit "0").
/// Identifiers containing any letter or hyphen are always valid.
fn validate_ident(s: &str) -> bool {
let b = s.as_bytes();
if b.is_empty() {
return false;
}
if !b.iter().all(|&c| is_ident_byte(c)) {
return false;
}
if b.iter().all(|&c| c.is_ascii_digit()) {
// Pure-digit: valid only as exactly "0" or starting with a non-zero digit.
return b.len() == 1 || b[0] != b'0';
}
true
}
/// Render a Semver back to its canonical string form.
pub fn format(s: &Semver) -> String {
let mut out = format!("{}.{}.{}", s.major, s.minor, s.patch);
if !s.prerelease.is_empty() {
out.push('-');
out.push_str(&s.prerelease.join("."));
}
if !s.build.is_empty() {
out.push('+');
out.push_str(&s.build.join("."));
}
out
}
// ─── Precedence comparison ─────────────────────────────────────────────────
/// Compare two prerelease identifiers. Per semver: numeric identifiers always
/// rank lower than alphanumeric; numerics compare by integer value; alphanumerics
/// compare lexicographically.
fn cmp_ident(x: &str, y: &str) -> i64 {
let xn = x.bytes().all(|b| b.is_ascii_digit());
let yn = y.bytes().all(|b| b.is_ascii_digit());
if xn && yn {
return cmp_decimal_string(x, y);
}
if xn {
return -1; // numeric always lower than alphanumeric
}
if yn {
return 1;
}
match x.cmp(y) {
std::cmp::Ordering::Less => -1,
std::cmp::Ordering::Equal => 0,
std::cmp::Ordering::Greater => 1,
}
}
/// Compare two canonical (no leading zero) non-negative decimal strings by
/// magnitude — longer is larger, equal length is lexical. Avoids any i64
/// overflow on absurdly long numeric identifiers, and matches integer ordering
/// for every realistic value.
fn cmp_decimal_string(a: &str, b: &str) -> i64 {
if a.len() != b.len() {
return if a.len() < b.len() { -1 } else { 1 };
}
match a.cmp(b) {
std::cmp::Ordering::Less => -1,
std::cmp::Ordering::Equal => 0,
std::cmp::Ordering::Greater => 1,
}
}
/// Compare two prerelease identifier lists per semver precedence. A release
/// with NO prerelease has HIGHER precedence than one with a prerelease
/// (so 1.0.0 > 1.0.0-alpha). When shared identifiers are all equal, the longer
/// set of fields wins.
fn cmp_prerelease(a: &[String], b: &[String]) -> i64 {
if a.is_empty() && b.is_empty() {
return 0;
}
if a.is_empty() {
return 1; // no prerelease > prerelease
}
if b.is_empty() {
return -1;
}
let n = a.len().min(b.len());
for i in 0..n {
let c = cmp_ident(&a[i], &b[i]);
if c != 0 {
return c;
}
}
// All shared identifiers equal → a larger set of fields wins.
match a.len().cmp(&b.len()) {
std::cmp::Ordering::Less => -1,
std::cmp::Ordering::Equal => 0,
std::cmp::Ordering::Greater => 1,
}
}
/// Compare two semvers by precedence. Build metadata is ignored.
/// Returns -1 if a < b, 0 if equal, 1 if a > b.
pub fn compare(a: &Semver, b: &Semver) -> i64 {
if a.major != b.major {
return if a.major < b.major { -1 } else { 1 };
}
if a.minor != b.minor {
return if a.minor < b.minor { -1 } else { 1 };
}
if a.patch != b.patch {
return if a.patch < b.patch { -1 } else { 1 };
}
let c = cmp_prerelease(&a.prerelease, &b.prerelease);
if c < 0 {
-1
} else if c > 0 {
1
} else {
0
}
}
// ─── Range satisfaction (npm-style) ────────────────────────────────────────
/// A partial version for ranges. `None` means "wildcard" — the field was either
/// absent ("1.2") or explicit ("1.2.x").
#[derive(Clone, Copy, Default)]
struct RangeVer {
major: Option<i64>,
minor: Option<i64>,
patch: Option<i64>,
}
/// A single atomic comparator's operator.
#[derive(Clone, Copy, PartialEq)]
enum Op {
Ge, // >=
Gt, // >
Le, // <=
Lt, // <
Eq, // = or bare
}
/// A single atomic constraint: an operator and a full version.
#[derive(Clone)]
struct Test {
op: Op,
v: Semver,
}
impl Test {
fn ge(v: Semver) -> Self { Test { op: Op::Ge, v } }
fn gt(v: Semver) -> Self { Test { op: Op::Gt, v } }
fn lt(v: Semver) -> Self { Test { op: Op::Lt, v } }
fn le(v: Semver) -> Self { Test { op: Op::Le, v } }
fn eq(v: Semver) -> Self { Test { op: Op::Eq, v } }
}
fn sem(major: i64, minor: i64, patch: i64) -> Semver {
Semver { major, minor, patch, prerelease: Vec::new(), build: Vec::new() }
}
/// Parse a (possibly partial) range version: "1", "1.2", "1.2.3", "1.x", "*".
/// Returns `None` when a component is present but malformed.
fn parse_range_ver(input: &str) -> Option<RangeVer> {
let trimmed = input.trim();
let t = trimmed.strip_prefix(['v', 'V']).unwrap_or(trimmed);
if t.is_empty() || t == "*" || t == "x" || t == "X" {
return Some(RangeVer::default()); // all wildcards
}
let parts: Vec<&str> = t.split('.').collect();
if parts.len() > 3 {
return None;
}
// Decode one component: wildcard (None), a numeric value, or invalid (None
// at the outer level via `?`).
let part = |p: &str| -> Option<Option<i64>> {
if p == "x" || p == "X" || p == "*" {
return Some(None); // wildcard
}
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
return None; // invalid component
}
let n: i64 = p.parse().ok()?;
Some(Some(n))
};
let major = part(parts[0])?;
let minor = if parts.len() >= 2 { part(parts[1])? } else { None };
let patch = if parts.len() >= 3 { part(parts[2])? } else { None };
// Wildcards cascade downward: "1.x" becomes {1, None, None}.
if major.is_none() {
return Some(RangeVer::default());
}
if minor.is_none() {
return Some(RangeVer { major, minor: None, patch: None });
}
Some(RangeVer { major, minor, patch })
}
/// Expand a plain comparator (>=, >, <=, <, =/bare) against a (possibly partial)
/// range version into the list of tests that must all hold. A bare "1.2"
/// desugars to ">=1.2.0 <1.3.0"; a wildcard matches anything.
fn range_ver_tests(op: Op, rv: RangeVer) -> Vec<Test> {
let Some(m) = rv.major else { return Vec::new() }; // wildcard → matches anything
let minor = rv.minor;
let patch = rv.patch;
match op {
Op::Eq => {
// Also covers "bare".
match (minor, patch) {
(Some(mn), Some(pa)) => vec![Test::eq(sem(m, mn, pa))],
(Some(mn), None) => vec![Test::ge(sem(m, mn, 0)), Test::lt(sem(m, mn + 1, 0))],
_ => vec![Test::ge(sem(m, 0, 0)), Test::lt(sem(m + 1, 0, 0))],
}
}
Op::Ge => match minor {
Some(mn) => match patch {
Some(pa) => vec![Test::ge(sem(m, mn, pa))],
None => vec![Test::ge(sem(m, mn, 0))],
},
None => vec![Test::ge(sem(m, 0, 0))],
},
Op::Gt => match minor {
Some(mn) => match patch {
Some(pa) => vec![Test::gt(sem(m, mn, pa))],
None => vec![Test::ge(sem(m, mn + 1, 0))],
},
None => vec![Test::ge(sem(m + 1, 0, 0))],
},
Op::Le => match minor {
Some(mn) => match patch {
Some(pa) => vec![Test::le(sem(m, mn, pa))],
None => vec![Test::lt(sem(m, mn + 1, 0))],
},
None => vec![Test::lt(sem(m + 1, 0, 0))],
},
Op::Lt => match minor {
Some(mn) => match patch {
Some(pa) => vec![Test::lt(sem(m, mn, pa))],
None => vec![Test::lt(sem(m, mn, 0))],
},
None => vec![Test::lt(sem(m, 0, 0))],
},
}
}
/// Caret (^) range: compatible-with, never breaking the left-most non-zero
/// component. ^1.2.3 → >=1.2.3 <2.0.0; ^0.2.3 → >=0.2.3 <0.3.0;
/// ^0.0.3 → >=0.0.3 <0.0.4.
fn caret_tests(rv: RangeVer) -> Vec<Test> {
let Some(m) = rv.major else { return Vec::new() };
let mn = rv.minor.unwrap_or(0);
let pa = rv.patch.unwrap_or(0);
let lo = Test::ge(sem(m, mn, pa));
// The upper bound protects the left-most NON-zero component.
let hi = if rv.minor.is_none() {
Test::lt(sem(m + 1, 0, 0)) // ^1 → <2.0.0, ^0 → <1.0.0
} else if rv.patch.is_none() {
if m > 0 {
Test::lt(sem(m + 1, 0, 0))
} else {
Test::lt(sem(0, mn + 1, 0)) // ^0.2 → <0.3.0
}
} else if m > 0 {
Test::lt(sem(m + 1, 0, 0))
} else if mn > 0 {
Test::lt(sem(0, mn + 1, 0))
} else {
Test::lt(sem(0, 0, pa + 1)) // ^0.0.3 → <0.0.4
};
vec![lo, hi]
}
/// Tilde (~) range: patch-level changes only (or minor-level for partials).
/// ~1.2.3 → >=1.2.3 <1.3.0; ~1 → >=1.0.0 <2.0.0.
fn tilde_tests(rv: RangeVer) -> Vec<Test> {
let Some(m) = rv.major else { return Vec::new() };
let mn = rv.minor.unwrap_or(0);
let pa = rv.patch.unwrap_or(0);
let lo = Test::ge(sem(m, mn, pa));
let hi = if rv.minor.is_none() {
Test::lt(sem(m + 1, 0, 0))
} else {
Test::lt(sem(m, mn + 1, 0))
};
vec![lo, hi]
}
/// Parse a single comparator token into a list of tests (all must hold).
/// Returns `None` when the token is unparseable.
fn parse_comparator(token: &str) -> Option<Vec<Test>> {
let t = token.trim();
if t.is_empty() || t == "*" {
return Some(Vec::new());
}
if let Some(rest) = t.strip_prefix('^') {
let rv = parse_range_ver(rest)?;
return Some(caret_tests(rv));
}
if let Some(rest) = t.strip_prefix('~') {
let rv = parse_range_ver(rest)?;
return Some(tilde_tests(rv));
}
// Detect a two-char operator first so ">=" is not misread as ">".
let (op, rest) = if let Some(r) = t.strip_prefix(">=") {
(Op::Ge, r)
} else if let Some(r) = t.strip_prefix("<=") {
(Op::Le, r)
} else if let Some(r) = t.strip_prefix(">") {
(Op::Gt, r)
} else if let Some(r) = t.strip_prefix("<") {
(Op::Lt, r)
} else if let Some(r) = t.strip_prefix("=") {
(Op::Eq, r)
} else {
(Op::Eq, t) // bare version acts as "="
};
let rv = parse_range_ver(rest)?;
Some(range_ver_tests(op, rv))
}
/// Evaluate a single test against a concrete version.
fn check(test: &Test, v: &Semver) -> bool {
let c = compare(v, &test.v);
match test.op {
Op::Gt => c > 0,
Op::Ge => c >= 0,
Op::Lt => c < 0,
Op::Le => c <= 0,
Op::Eq => c == 0,
}
}
/// Evaluate one AND-clause (already split from `||`).
fn clause_matches(v: &Semver, clause: &str) -> bool {
let c = clause.trim();
if c.is_empty() || c == "*" {
return true;
}
// Hyphen range: "1.2.3 - 2.3.4" → >=lower <=upper (partials apply).
// Only triggered when there is exactly one " - " separator, so multiple
// dashes fall through to ordinary token handling.
let hyphen_parts = split_all_hyphen(c);
if hyphen_parts.len() == 2 {
let lo = match parse_range_ver(&hyphen_parts[0]) {
Some(x) => x,
None => return false,
};
let hi = match parse_range_ver(&hyphen_parts[1]) {
Some(x) => x,
None => return false,
};
let mut tests = range_ver_tests(Op::Ge, lo);
tests.extend(range_ver_tests(Op::Le, hi));
return tests.iter().all(|t| check(t, v));
}
// Space-separated comparators form an AND; an invalid comparator makes the
// whole clause unsatisfiable.
let mut tests: Vec<Test> = Vec::new();
for tok in c.split_whitespace() {
match parse_comparator(tok) {
Some(ts) => tests.extend(ts),
None => return false,
}
}
if tests.is_empty() {
return true;
}
tests.iter().all(|t| check(t, v))
}
/// Split on every " - " (whitespace-dash-whitespace) occurrence. Mirrors the
/// TS regex `/\s+-\s+/` split: the dash must have whitespace on both sides, so
/// prerelease dashes (`1.0.0-alpha-1`) are never mistaken for a range dash.
fn split_all_hyphen(s: &str) -> Vec<String> {
let bytes = s.as_bytes();
let mut parts = Vec::new();
let mut last = 0usize;
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] != b'-' {
i += 1;
continue;
}
// Walk backwards over the run of whitespace preceding the dash.
let mut start = i;
while start > 0 && is_ws_byte(bytes[start - 1]) {
start -= 1;
}
if start == i {
i += 1;
continue; // no whitespace before → not a range dash
}
// Walk forwards over the run of whitespace following the dash.
let mut end = i + 1;
while end < bytes.len() && is_ws_byte(bytes[end]) {
end += 1;
}
if end == i + 1 {
i += 1;
continue; // no whitespace after → not a range dash
}
parts.push(s[last..start].to_string());
last = end;
i = end;
}
parts.push(s[last..].to_string());
parts
}
fn is_ws_byte(b: u8) -> bool {
// Match JS \s for the ASCII whitespace bytes relevant to ranges.
matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C)
}
/// Does `version` satisfy the npm-style `range`? Supports `^`, `~`,
/// comparators (`>=`, `<=`, `>`, `<`, `=`), `*`, partials ("1.2", "1"), hyphen
/// ranges ("1.2.3 - 2.3.4"), space-separated AND, and `||` OR. An invalid
/// version or wholly-unparseable range yields `false`; `*`/empty matches all.
pub fn satisfies(version: &str, range: &str) -> bool {
let v = match parse_semver(version) {
Some(v) => v,
None => return false,
};
range.split("||").any(|clause| clause_matches(&v, clause))
}
/// Bump a version by `kind`. major/minor/patch drop any prerelease and produce
/// a clean release; prerelease bumps the trailing numeric prerelease identifier
/// (appending "-0" when there is none, or ".1" when the tail is non-numeric).
/// Invalid input is returned unchanged.
pub fn bump(v: &str, kind: &str) -> String {
let s = match parse_semver(v) {
Some(s) => s,
None => return v.to_string(),
};
let (major, minor, patch) = (s.major, s.minor, s.patch);
match kind {
"major" => format!("{}.0.0", major + 1),
"minor" => format!("{}.{}.0", major, minor + 1),
"patch" => format!("{}.{}.{}", major, minor, patch + 1),
"prerelease" => {
if s.prerelease.is_empty() {
return format!("{}.{}.{}-0", major, minor, patch + 1);
}
let last = &s.prerelease[s.prerelease.len() - 1];
if last.bytes().all(|b| b.is_ascii_digit()) {
// Increment the trailing numeric prerelease identifier.
let mut next = s.prerelease.clone();
next[next.len() - 1] = inc_numeric(last);
format!("{}.{}.{}-{}", major, minor, patch, next.join("."))
} else {
// Non-numeric tail: append a fresh ".1" identifier.
let mut extended = s.prerelease.clone();
extended.push("1".to_string());
format!("{}.{}.{}-{}", major, minor, patch, extended.join("."))
}
}
_ => v.to_string(), // unknown kind → unchanged
}
}
/// Increment a canonical (no leading zero) decimal string by one, without
/// touching i64 — so even an absurdly long counter stays correct.
fn inc_numeric(s: &str) -> String {
let mut digits: Vec<u8> = s.bytes().collect();
let mut i = digits.len() as isize - 1;
loop {
if i < 0 {
digits.insert(0, b'1'); // carried past the front (e.g. "99" → "100")
break;
}
if digits[i as usize] == b'9' {
digits[i as usize] = b'0';
i -= 1;
} else {
digits[i as usize] += 1;
break;
}
}
// digits are all ASCII; the conversion is infallible.
String::from_utf8(digits).unwrap_or_else(|_| s.to_string())
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →