Semver Checker — JavaScript 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 JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure logic for the Semver Checker - JavaScript polyglot port.
//
// Language: JavaScript
// 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. No
// external dependencies - stdlib only.
'use strict';
// ─── Semver model ────────────────────────────────────────────────────────────
/**
* @typedef {Object} Semver
* @property {number} major
* @property {number} minor
* @property {number} patch
* @property {string[]} prerelease Prerelease identifiers, e.g. ['alpha','1'].
* @property {string[]} build Build-metadata identifiers (ignored for precedence).
*/
// ─── Parsing ─────────────────────────────────────────────────────────────────
//
// Regex pieces mirror the semver-2.0.0 ABNF. Numeric fields forbid leading
// zeros (0|[1-9]\d*); identifiers allow alphanumerics and hyphens.
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*)';
// Strict semver 2.0.0 core. A leading 'v'/'V' and surrounding whitespace are
// stripped before matching, to tolerate the common `v1.2.3` shorthand.
const SEMVER_RE = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}(?:-${PRE})?(?:\\+${BUILD})?$`);
/**
* Parse a strict semver string. An optional leading `v`/`V` is tolerated.
* Returns null when the string is not valid semver.
* @param {string} v
* @returns {Semver | null}
*/
function parseSemver(v) {
const t = v.trim().replace(/^[vV]/, '');
const m = t.match(SEMVER_RE);
if (!m) return null;
return {
major: parseInt(m[1], 10),
minor: parseInt(m[2], 10),
patch: parseInt(m[3], 10),
prerelease: m[4] ? m[4].split('.') : [],
build: m[5] ? m[5].split('.') : [],
};
}
/**
* Render a Semver back to its canonical string form.
* @param {Semver} s
* @returns {string}
*/
function format(s) {
let out = `${s.major}.${s.minor}.${s.patch}`;
if (s.prerelease.length) out += `-${s.prerelease.join('.')}`;
if (s.build.length) out += `+${s.build.join('.')}`;
return out;
}
// ─── Precedence comparison ───────────────────────────────────────────────────
/**
* Compare two prerelease identifiers. Per semver: numeric identifiers always
* rank lower than alphanumeric; numerics compare by integer value; alphanumerics
* lexicographically by ASCII.
* @param {string} x
* @param {string} y
* @returns {-1 | 0 | 1}
*/
function cmpIdent(x, y) {
const xn = /^[0-9]+$/.test(x);
const yn = /^[0-9]+$/.test(y);
if (xn && yn) {
const a = parseInt(x, 10);
const b = parseInt(y, 10);
return a < b ? -1 : a > b ? 1 : 0;
}
if (xn) return -1; // numeric always lower than alphanumeric
if (yn) return 1;
return x < y ? -1 : x > y ? 1 : 0;
}
/**
* Compare two prerelease arrays per semver precedence. A release with NO
* prerelease has HIGHER precedence than one with a prerelease (1.0.0 > 1.0.0-alpha).
* @param {string[]} a
* @param {string[]} b
* @returns {-1 | 0 | 1}
*/
function cmpPrerelease(a, b) {
if (a.length === 0 && b.length === 0) return 0;
if (a.length === 0) return 1; // no prerelease > prerelease
if (b.length === 0) return -1;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const c = cmpIdent(a[i], b[i]);
if (c !== 0) return c;
}
// All shared identifiers equal → a larger set of fields wins.
return a.length < b.length ? -1 : a.length > b.length ? 1 : 0;
}
/**
* Compare two semvers by precedence. Build metadata is ignored.
* @param {Semver} a
* @param {Semver} b
* @returns {-1 | 0 | 1} -1 if a<b, 0 if equal, 1 if a>b.
*/
function compare(a, b) {
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
const c = cmpPrerelease(a.prerelease, b.prerelease);
return c < 0 ? -1 : c > 0 ? 1 : 0;
}
// ─── Range satisfaction (npm-style) ──────────────────────────────────────────
// A partial version for ranges. `null` means "wildcard" - the field was either
// absent (`1.2`) or explicit (`1.2.x`).
/**
* @typedef {Object} RangeVer
* @property {number | null} major
* @property {number | null} minor
* @property {number | null} patch
*/
// A single atomic comparator: an operator and a (full) version.
/** @typedef {{op: ('>='|'>'|'<='|'<'|'='), v: Semver}} Test */
const sem = (major, minor, patch) => ({ major, minor, patch, prerelease: [], build: [] });
const ge = (v) => ({ op: '>=', v });
const gt = (v) => ({ op: '>', v });
const lt = (v) => ({ op: '<', v });
const le = (v) => ({ op: '<=', v });
const eq = (v) => ({ op: '=', v });
/**
* Parse a (possibly partial) range version: `1`, `1.2`, `1.2.3`, `1.x`, `*`.
* @param {string} s
* @returns {RangeVer | null}
*/
function parseRangeVer(s) {
const t = s.trim().replace(/^[vV]/, '');
if (t === '' || t === '*' || t === 'x' || t === 'X') {
return { major: null, minor: null, patch: null };
}
const parts = t.split('.');
if (parts.length > 3) return null;
// Returns null (wildcard), a non-negative integer, or `true` (= invalid).
const part = (p) => {
if (p === 'x' || p === 'X' || p === '*') return null;
if (/^\d+$/.test(p)) return parseInt(p, 10);
return true; // invalid
};
const major = part(parts[0]);
if (major === true) return null;
const minor = parts.length >= 2 ? part(parts[1]) : null;
if (minor === true) return null;
const patch = parts.length >= 3 ? part(parts[2]) : null;
if (patch === true) return null;
// Wildcards cascade downward: `1.x` becomes {1, null, null}.
if (major === null) return { major: null, minor: null, patch: null };
if (minor === null) return { major, minor: null, patch: null };
return { major, minor, patch };
}
/**
* Build the test list for a plain comparator (`>=`, `>`, `<=`, `<`, `=`/bare).
* A bare `1.2` desugars to `>=1.2.0 <1.3.0`; a bare `1` desugars to
* `>=1.0.0 <2.0.0` - i.e. partial versions act as ranges.
* @param {Op | 'bare'} op
* @param {RangeVer} rv
* @returns {Test[]}
*/
function rangeVerTests(op, rv) {
if (rv.major === null) return []; // wildcard → matches anything
const 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))];
}
}
/**
* 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`.
* @param {RangeVer} rv
* @returns {Test[]}
*/
function caretTests(rv) {
if (rv.major === null) return [];
const M = rv.major;
const lo = ge(sem(M, rv.minor ?? 0, rv.patch ?? 0));
let hi;
if (rv.minor === null) {
hi = lt(sem(M + 1, 0, 0)); // ^1 → <2.0.0, ^0 → <1.0.0
} else if (rv.patch === null) {
hi = M > 0 ? lt(sem(M + 1, 0, 0)) : lt(sem(0, rv.minor + 1, 0)); // ^0.2 → <0.3.0
} else {
hi = M > 0
? lt(sem(M + 1, 0, 0))
: rv.minor > 0
? lt(sem(0, rv.minor + 1, 0))
: lt(sem(0, 0, rv.patch + 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`.
* @param {RangeVer} rv
* @returns {Test[]}
*/
function tildeTests(rv) {
if (rv.major === null) return [];
const M = rv.major;
const lo = ge(sem(M, rv.minor ?? 0, rv.patch ?? 0));
const hi = rv.minor === null ? lt(sem(M + 1, 0, 0)) : lt(sem(M, rv.minor + 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.
* @param {string} token
* @returns {Test[] | null}
*/
function parseComparator(token) {
const t = token.trim();
if (t === '' || t === '*') return [];
if (t[0] === '^') {
const rv = parseRangeVer(t.slice(1));
return rv ? caretTests(rv) : null;
}
if (t[0] === '~') {
const rv = parseRangeVer(t.slice(1));
return rv ? tildeTests(rv) : null;
}
let op = 'bare';
let rest = t;
if (t.startsWith('>=')) { op = '>='; rest = t.slice(2); }
else if (t.startsWith('<=')) { op = '<='; rest = t.slice(2); }
else if (t.startsWith('>')) { op = '>'; rest = t.slice(1); }
else if (t.startsWith('<')) { op = '<'; rest = t.slice(1); }
else if (t.startsWith('=')) { op = '='; rest = t.slice(1); }
const rv = parseRangeVer(rest);
return rv ? rangeVerTests(op, rv) : null;
}
/** Evaluate a single test against a concrete version. */
function check(test, v) {
const c = compare(v, test.v);
switch (test.op) {
case '>': return c > 0;
case '>=': return c >= 0;
case '<': return c < 0;
case '<=': return c <= 0;
case '=': return c === 0;
}
}
/**
* Evaluate one AND-clause (already split from `||`).
* @param {Semver} v
* @param {string} clause
* @returns {boolean}
*/
function clauseMatches(v, clause) {
const c = clause.trim();
if (c === '' || c === '*') return true;
// Hyphen range: "1.2.3 - 2.3.4" → >=lower <=upper (partials apply).
if (/\s+-\s+/.test(c)) {
const parts = c.split(/\s+-\s+/);
if (parts.length === 2) {
const lo = parseRangeVer(parts[0]);
const hi = parseRangeVer(parts[1]);
if (!lo || !hi) return false;
const tests = [...rangeVerTests('>=', lo), ...rangeVerTests('<=', hi)];
return tests.every((t) => check(t, v));
}
}
const tokens = c.split(/\s+/).filter((t) => t.length > 0);
const tests = [];
for (const tok of tokens) {
const ts = parseComparator(tok);
if (ts === null) return false; // invalid comparator → clause unsatisfiable
tests.push(...ts);
}
return tests.length === 0 ? true : tests.every((t) => check(t, v));
}
/**
* 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.
* @param {string} version
* @param {string} range
* @returns {boolean}
*/
function satisfies(version, range) {
const v = parseSemver(version);
if (!v) return false;
return range.split('||').some((clause) => clauseMatches(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` / `.1` when there is none / a non-numeric tail).
* Invalid input is returned unchanged.
* @param {string} v
* @param {'major' | 'minor' | 'patch' | 'prerelease'} kind
* @returns {string}
*/
function bump(v, kind) {
const s = parseSemver(v);
if (!s) return v;
const { major, minor, patch, prerelease } = s;
switch (kind) {
case 'major': return `${major + 1}.0.0`;
case 'minor': return `${major}.${minor + 1}.0`;
case 'patch': return `${major}.${minor}.${patch + 1}`;
case 'prerelease': {
if (prerelease.length === 0) return `${major}.${minor}.${patch + 1}-0`;
const last = prerelease[prerelease.length - 1];
if (/^[0-9]+$/.test(last)) {
const next = [...prerelease];
next[next.length - 1] = String(parseInt(last, 10) + 1);
return `${major}.${minor}.${patch}-${next.join('.')}`;
}
return `${major}.${minor}.${patch}-${[...prerelease, '1'].join('.')}`;
}
}
}
// Export the public surface for module consumers (showcase only; not executed
// in-place on the tool page).
module.exports = {
parseSemver,
format,
compare,
satisfies,
bump,
};
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 →