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 →