Skip to content

Semver Checker — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.

<?php
/**
 * semver — PHP polyglot port.
 *
 * Language: PHP
 * 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. Stdlib
 * only — no Composer dependencies.
 *
 * The public surface mirrors the TypeScript reference: parse_semver, format,
 * compare, satisfies, bump.
 */

declare(strict_types=1);

namespace CosmoDev\Semver;

/**
 * A parsed semantic version.
 *
 * $prerelease and $build are 0..N dot-separated identifiers stored verbatim;
 * build metadata is informational only and never affects precedence.
 */
final class Semver
{
    public function __construct(
        public int $major,
        public int $minor,
        public int $patch,
        public array $prerelease = [],
        public array $build = [],
    ) {}
}

// ─── Parsing ───────────────────────────────────────────────────────────────

/**
 * Grammar fragments mirror the semver-2.0.0 ABNF. Numeric fields forbid leading
 * zeros (0|[1-9]\d*); identifiers allow alphanumerics and hyphens. PCRE
 * supports (?:...) and \d, both used below.
 */
const IDENT  = '(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)';
const PRE    = '(' . IDENT . '(?:\.' . IDENT . ')*)';
const BUILD  = '([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)';
const NUM    = '(0|[1-9]\d*)';
// Anchored strict core. A leading 'v'/'V' and surrounding whitespace are
// stripped before matching, to tolerate the common v1.2.3 shorthand.
const SEMVER_RE = '/^' . NUM . '\.' . NUM . '\.' . NUM . '(?:-' . PRE . ')?(?:\+' . BUILD . ')?$/';

/**
 * Parse a strict semver string. A leading 'v'/'V' is tolerated.
 * Returns null when the string is not valid semver.
 */
function parse_semver(string $v): ?Semver
{
    $t = strip_v($v);
    if (preg_match(SEMVER_RE, $t, $m) !== 1) {
        return null;
    }
    // $m[1..3] are the numeric fields; $m[4] the whole prerelease; $m[5] the
    // whole build. Absent groups are simply unset (PHP omits them).
    return new Semver(
        major: (int) $m[1],
        minor: (int) $m[2],
        patch: (int) $m[3],
        prerelease: isset($m[4]) ? explode('.', $m[4]) : [],
        build: isset($m[5]) ? explode('.', $m[5]) : [],
    );
}

/** Render a Semver back to its canonical string form. */
function format(Semver $s): string
{
    $out = "{$s->major}.{$s->minor}.{$s->patch}";
    if ($s->prerelease) {
        $out .= '-' . implode('.', $s->prerelease);
    }
    if ($s->build) {
        $out .= '+' . implode('.', $s->build);
    }
    return $out;
}

// ─── Precedence comparison ─────────────────────────────────────────────────

/**
 * Compare two prerelease identifiers. Per semver: numeric identifiers always
 * rank lower than alphanumeric; numerics compare by integer value; alphanumerics
 * compare lexicographically.
 */
function cmp_ident(string $x, string $y): int
{
    $xn = ctype_digit($x);
    $yn = ctype_digit($y);
    if ($xn && $yn) {
        $a = (int) $x;
        $b = (int) $y;
        return $a <=> $b; // spaceship: -1 / 0 / 1
    }
    if ($xn) return -1; // numeric always lower than alphanumeric
    if ($yn) return 1;
    return $x <=> $y;
}

/**
 * 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.
 */
function cmp_prerelease(array $a, array $b): int
{
    if (!$a && !$b) return 0;
    if (!$a) return 1;  // no prerelease > prerelease
    if (!$b) return -1;
    $n = min(count($a), count($b));
    for ($i = 0; $i < $n; $i++) {
        $c = cmp_ident($a[$i], $b[$i]);
        if ($c !== 0) return $c;
    }
    // All shared identifiers equal → a larger set of fields wins.
    return count($a) <=> count($b);
}

/**
 * Compare two semvers by precedence. Build metadata is ignored.
 * Returns -1 if a < b, 0 if equal, 1 if a > b.
 */
function compare(Semver $a, Semver $b): int
{
    if ($a->major !== $b->major) return $a->major <=> $b->major;
    if ($a->minor !== $b->minor) return $a->minor <=> $b->minor;
    if ($a->patch !== $b->patch) return $a->patch <=> $b->patch;
    return cmp_prerelease($a->prerelease, $b->prerelease);
}

// ─── Range satisfaction (npm-style) ────────────────────────────────────────

/**
 * A partial version for ranges. A null field means "wildcard" — the field was
 * either absent ("1.2") or explicit ("1.2.x").
 */
final class RangeVer
{
    public function __construct(
        public ?int $major = null,
        public ?int $minor = null,
        public ?int $patch = null,
    ) {}
}

/** A single atomic comparator: an operator and a (full) version. */
final class Test
{
    public function __construct(
        public string $op, // '>=', '>', '<=', '<', '='
        public Semver $v,
    ) {}
}

function sem(int $major, int $minor, int $patch): Semver
{
    return new Semver($major, $minor, $patch);
}
function ge(Semver $v): Test { return new Test('>=', $v); }
function gt(Semver $v): Test { return new Test('>',  $v); }
function lt(Semver $v): Test { return new Test('<',  $v); }
function le(Semver $v): Test { return new Test('<=', $v); }
function eq(Semver $v): Test { return new Test('=',  $v); }

/**
 * Decode one component of a range version. Returns:
 *   - null  for a wildcard (x/X/*),
 *   - an int for an all-digits component,
 *   - false when the component is malformed (the sentinel).
 */
function range_part(string $p): int|null|false
{
    if ($p === 'x' || $p === 'X' || $p === '*') {
        return null; // wildcard
    }
    if ($p === '' || !ctype_digit($p)) {
        return false; // invalid
    }
    return (int) $p;
}

/**
 * Parse a (possibly partial) range version: "1", "1.2", "1.2.3", "1.x", "*".
 * Returns null when a component is present but malformed.
 */
function parse_range_ver(string $s): ?RangeVer
{
    $t = strip_v($s);
    if ($t === '' || $t === '*' || $t === 'x' || $t === 'X') {
        return new RangeVer(); // all wildcards
    }
    $parts = explode('.', $t);
    if (count($parts) > 3) {
        return null;
    }

    $major = range_part($parts[0]);
    if ($major === false) return null;
    $minor = count($parts) >= 2 ? range_part($parts[1]) : null;
    if ($minor === false) return null;
    $patch = count($parts) >= 3 ? range_part($parts[2]) : null;
    if ($patch === false) return null;

    // Wildcards cascade downward: "1.x" becomes {1, null, null}.
    if ($major === null) return new RangeVer();
    if ($minor === null) return new RangeVer($major);
    return new RangeVer($major, $minor, $patch);
}

/**
 * Build the test list for a plain comparator (>=, >, <=, <, =/bare). A bare
 * "1.2" desugars to ">=1.2.0 <1.3.0" — partial versions act as ranges. A
 * wildcard matches anything.
 * @param string $op one of '>=', '>', '<=', '<', '=', 'bare'
 */
function range_ver_tests(string $op, RangeVer $rv): array
{
    if ($rv->major === null) {
        return []; // wildcard → matches anything
    }
    $M = $rv->major;
    switch ($op) {
        case '=':
        case 'bare':
            if ($rv->minor === null) return [ge(sem($M, 0, 0)), lt(sem($M + 1, 0, 0))];
            if ($rv->patch === null) return [ge(sem($M, $rv->minor, 0)), lt(sem($M, $rv->minor + 1, 0))];
            return [eq(sem($M, $rv->minor, $rv->patch))];
        case '>=':
            if ($rv->minor === null) return [ge(sem($M, 0, 0))];
            if ($rv->patch === null) return [ge(sem($M, $rv->minor, 0))];
            return [ge(sem($M, $rv->minor, $rv->patch))];
        case '>':
            if ($rv->minor === null) return [ge(sem($M + 1, 0, 0))];
            if ($rv->patch === null) return [ge(sem($M, $rv->minor + 1, 0))];
            return [gt(sem($M, $rv->minor, $rv->patch))];
        case '<=':
            if ($rv->minor === null) return [lt(sem($M + 1, 0, 0))];
            if ($rv->patch === null) return [lt(sem($M, $rv->minor + 1, 0))];
            return [le(sem($M, $rv->minor, $rv->patch))];
        case '<':
            if ($rv->minor === null) return [lt(sem($M, 0, 0))];
            if ($rv->patch === null) return [lt(sem($M, $rv->minor, 0))];
            return [lt(sem($M, $rv->minor, $rv->patch))];
    }
    return [];
}

/**
 * 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.
 */
function caret_tests(RangeVer $rv): array
{
    if ($rv->major === null) return [];
    $M = $rv->major;
    $mn = $rv->minor ?? 0;
    $pa = $rv->patch ?? 0;
    $lo = ge(sem($M, $mn, $pa));
    if ($rv->minor === null) {
        $hi = lt(sem($M + 1, 0, 0)); // ^1 → <2.0.0, ^0 → <1.0.0
    } elseif ($rv->patch === null) {
        $hi = $M > 0 ? lt(sem($M + 1, 0, 0)) : lt(sem(0, $mn + 1, 0)); // ^0.2 → <0.3.0
    } else {
        if ($M > 0) {
            $hi = lt(sem($M + 1, 0, 0));
        } elseif ($mn > 0) {
            $hi = lt(sem(0, $mn + 1, 0));
        } else {
            $hi = lt(sem(0, 0, $pa + 1)); // ^0.0.3 → <0.0.4
        }
    }
    return [$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.
 */
function tilde_tests(RangeVer $rv): array
{
    if ($rv->major === null) return [];
    $M = $rv->major;
    $mn = $rv->minor ?? 0;
    $pa = $rv->patch ?? 0;
    $lo = ge(sem($M, $mn, $pa));
    $hi = $rv->minor === null ? lt(sem($M + 1, 0, 0)) : lt(sem($M, $mn + 1, 0));
    return [$lo, $hi];
}

/**
 * Parse a single comparator token into a list of tests (all must hold).
 * Returns null when the token is unparseable.
 */
function parse_comparator(string $token): ?array
{
    $t = trim($token);
    if ($t === '' || $t === '*') return [];
    if ($t[0] === '^') {
        $rv = parse_range_ver(substr($t, 1));
        return $rv ? caret_tests($rv) : null;
    }
    if ($t[0] === '~') {
        $rv = parse_range_ver(substr($t, 1));
        return $rv ? tilde_tests($rv) : null;
    }
    // Detect a two-char operator first so ">=" is not misread as ">".
    $op = 'bare';
    $rest = $t;
    if (str_starts_with($t, '>='))      { $op = '>='; $rest = substr($t, 2); }
    elseif (str_starts_with($t, '<='))  { $op = '<='; $rest = substr($t, 2); }
    elseif (str_starts_with($t, '>'))   { $op = '>';  $rest = substr($t, 1); }
    elseif (str_starts_with($t, '<'))   { $op = '<';  $rest = substr($t, 1); }
    elseif (str_starts_with($t, '='))   { $op = '=';  $rest = substr($t, 1); }
    $rv = parse_range_ver($rest);
    return $rv ? range_ver_tests($op, $rv) : null;
}

/** Evaluate a single test against a concrete version. */
function check(Test $test, Semver $v): bool
{
    $c = compare($v, $test->v);
    return match ($test->op) {
        '>'  => $c > 0,
        '>=' => $c >= 0,
        '<'  => $c < 0,
        '<=' => $c <= 0,
        '='  => $c === 0,
    };
}

/**
 * Evaluate one AND-clause (already split from "||").
 */
function clause_matches(Semver $v, string $clause): bool
{
    $c = trim($clause);
    if ($c === '' || $c === '*') return true;

    // Hyphen range: "1.2.3 - 2.3.4" → >=lower <=upper (partials apply).
    // The dash must have whitespace on BOTH sides so prerelease dashes are safe.
    $parts = split_hyphen($c);
    if ($parts !== null && count($parts) === 2) {
        $lo = parse_range_ver($parts[0]);
        $hi = parse_range_ver($parts[1]);
        if (!$lo || !$hi) return false;
        $tests = array_merge(range_ver_tests('>=', $lo), range_ver_tests('<=', $hi));
        foreach ($tests as $t) {
            if (!check($t, $v)) return false;
        }
        return true;
    }

    // Space-separated comparators form an AND; an invalid comparator makes the
    // whole clause unsatisfiable.
    $tests = [];
    foreach (preg_split('/\s+/', $c) ?: [] as $tok) {
        if ($tok === '') continue;
        $ts = parse_comparator($tok);
        if ($ts === null) return false;
        array_push($tests, ...$ts);
    }
    if (!$tests) return true;
    foreach ($tests as $t) {
        if (!check($t, $v)) return false;
    }
    return true;
}

/**
 * Split a hyphen range "a - b" on its " - " separator (whitespace on both
 * sides). Returns null when no whitespace-flanked dash is present.
 */
function split_hyphen(string $s): ?array
{
    $bytes = unpack('c*', $s);
    $n = count($bytes);
    for ($i = 1; $i <= $n; $i++) {
        if ($bytes[$i] !== ord('-')) {
            continue;
        }
        // Walk backwards over the run of whitespace preceding the dash.
        $start = $i;
        while ($start > 1 && is_ws_byte($bytes[$start - 1])) {
            $start--;
        }
        if ($start === $i) {
            continue; // no whitespace before → not a hyphen-range dash
        }
        // Walk forwards over the run of whitespace following the dash.
        $end = $i + 1;
        while ($end <= $n && is_ws_byte($bytes[$end])) {
            $end++;
        }
        if ($end === $i + 1) {
            continue; // no whitespace after → not a hyphen-range dash
        }
        // unpack produces 1-based indices; convert back to PHP's 0-based offsets.
        return [substr($s, 0, $start - 1), substr($s, $end - 1)];
    }
    return null;
}

function is_ws_byte(int $b): bool
{
    // Match JS \s for the ASCII whitespace bytes relevant to ranges.
    return $b === 32 /* space */ || $b === 9 /* \t */ || $b === 10 /* \n */
        || $b === 13 /* \r */ || $b === 11 /* \v */ || $b === 12 /* \f */;
}

/**
 * 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 a
 * wholly-unparseable range yields false; "*" / empty matches all.
 */
function satisfies(string $version, string $range): bool
{
    $v = parse_semver($version);
    if ($v === null) {
        return false;
    }
    foreach (explode('||', $range) as $clause) {
        if (clause_matches($v, $clause)) {
            return true;
        }
    }
    return false;
}

/**
 * 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.
 *
 * @param string $kind one of 'major', 'minor', 'patch', 'prerelease'
 */
function bump(string $v, string $kind): string
{
    $s = parse_semver($v);
    if ($s === null) {
        return $v;
    }
    switch ($kind) {
        case 'major':
            return ($s->major + 1) . '.0.0';
        case 'minor':
            return "{$s->major}." . ($s->minor + 1) . '.0';
        case 'patch':
            return "{$s->major}.{$s->minor}." . ($s->patch + 1);
        case 'prerelease':
            if (!$s->prerelease) {
                return "{$s->major}.{$s->minor}." . ($s->patch + 1) . '-0';
            }
            $last = $s->prerelease[count($s->prerelease) - 1];
            if (ctype_digit($last)) {
                $next = $s->prerelease; // copy
                $next[count($next) - 1] = (string) ((int) $last + 1);
                return "{$s->major}.{$s->minor}.{$s->patch}-" . implode('.', $next);
            }
            // Non-numeric tail: append a fresh ".1" identifier.
            $extended = array_merge($s->prerelease, ['1']);
            return "{$s->major}.{$s->minor}.{$s->patch}-" . implode('.', $extended);
    }
    return $v; // unknown kind → unchanged
}

// ─── Internal helper ───────────────────────────────────────────────────────

/** Trim whitespace and strip a single optional leading 'v'/'V'. */
function strip_v(string $v): string
{
    $t = trim($v);
    if ($t !== '' && ($t[0] === 'v' || $t[0] === 'V')) {
        $t = substr($t, 1);
    }
    return $t;
}

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 →