Skip to content

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 →