Skip to content

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 →