Semver Range Checker
Parse semantic versions and test ranges with comparators, caret, tilde, wildcards, hyphen ranges and OR unions.
Code
export interface SemVer {
major: number;
minor: number;
patch: number;
prerelease: string[];
}
interface PartialVersion {
major?: number;
minor?: number;
patch?: number;
prerelease: string[];
}
export function parseSemver(input: string): SemVer {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(input.trim());
if (!match) throw new SyntaxError("invalid semver: " + input);
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ? match[4].split(".") : [],
};
}
export function compareSemver(a: SemVer, b: SemVer): number {
for (const key of ["major", "minor", "patch"] as const) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
}
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
if (a.prerelease.length === 0) return 1;
if (b.prerelease.length === 0) return -1;
const length = Math.max(a.prerelease.length, b.prerelease.length);
for (let index = 0; index < length; index += 1) {
const left = a.prerelease[index];
const right = b.prerelease[index];
if (left === undefined) return -1;
if (right === undefined) return 1;
if (left === right) continue;
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) return Number(left) < Number(right) ? -1 : 1;
if (leftNumeric) return -1;
if (rightNumeric) return 1;
return left < right ? -1 : 1;
}
return 0;
}
function parsePartial(raw: string): PartialVersion {
if (raw === "*" || raw.toLowerCase() === "x") return { prerelease: [] };
const match = /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(raw);
if (!match) throw new SyntaxError("invalid version: " + raw);
const toNumber = (token: string | undefined): number | undefined => {
if (token === undefined) return undefined;
return /^[xX*]$/.test(token) ? undefined : Number(token);
};
return {
major: toNumber(match[1]),
minor: toNumber(match[2]),
patch: toNumber(match[3]),
prerelease: match[4] ? match[4].split(".") : [],
};
}
function floorVersion(partial: PartialVersion): SemVer {
return {
major: partial.major ?? 0,
minor: partial.minor ?? 0,
patch: partial.patch ?? 0,
prerelease: partial.prerelease,
};
}
function compareToPartial(version: SemVer, partial: PartialVersion): number {
if (partial.major === undefined) return 0;
if (version.major !== partial.major) return version.major < partial.major ? -1 : 1;
if (partial.minor === undefined) return 0;
if (version.minor !== partial.minor) return version.minor < partial.minor ? -1 : 1;
if (partial.patch === undefined) return 0;
return compareSemver(version, floorVersion(partial));
}
function satisfiesComparator(version: SemVer, comparator: string): boolean {
const match = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(comparator);
if (!match) throw new SyntaxError("invalid comparator: " + comparator);
const operator = match[1] ?? "=";
const partial = parsePartial(match[2]!);
if (partial.major === undefined) return operator !== "<" && operator !== ">";
const lower = floorVersion(partial);
switch (operator) {
case "=":
return compareToPartial(version, partial) === 0;
case ">=":
return compareToPartial(version, partial) >= 0;
case "<":
return compareToPartial(version, partial) < 0;
case "<=": {
if (partial.minor === undefined) return version.major <= partial.major;
if (partial.patch === undefined) {
return version.major < partial.major
|| (version.major === partial.major && version.minor <= partial.minor);
}
return compareSemver(version, lower) <= 0;
}
case ">": {
if (partial.minor === undefined) return version.major > partial.major;
if (partial.patch === undefined) {
return version.major > partial.major
|| (version.major === partial.major && version.minor > partial.minor);
}
return compareSemver(version, lower) > 0;
}
case "^": {
if (compareSemver(version, lower) < 0) return false;
if (partial.major > 0) return version.major < partial.major + 1;
if (partial.minor === undefined) return version.major < 1;
if (partial.minor > 0) return version.major === 0 && version.minor < partial.minor + 1;
if (partial.patch === undefined) return version.major === 0 && version.minor < 1;
return version.major === 0 && version.minor === 0 && version.patch < partial.patch + 1;
}
case "~": {
if (compareSemver(version, lower) < 0) return false;
if (partial.minor === undefined) return version.major < partial.major + 1;
return version.major === partial.major && version.minor < partial.minor + 1;
}
default:
throw new SyntaxError("unsupported comparator: " + operator);
}
}
export function satisfiesRange(version: string, range: string): boolean {
if (typeof range !== "string" || range.trim() === "") {
throw new SyntaxError("range must be a non-empty string");
}
const parsed = parseSemver(version);
return range.split("||").some((part) => {
const trimmed = part.trim();
const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(trimmed);
let comparators: string[];
if (hyphen) {
comparators = [">=" + hyphen[1], "<=" + hyphen[2]];
} else {
const tokens = trimmed.split(/\s+/).filter(Boolean);
comparators = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!;
if (/^(>=|<=|>|<|=|\^|~)$/.test(token) && index + 1 < tokens.length) {
comparators.push(token + tokens[index + 1]);
index += 1;
} else {
comparators.push(token);
}
}
}
if (comparators.length === 0) throw new SyntaxError("empty comparator set");
return comparators.every((comparator) => satisfiesComparator(parsed, comparator));
});
}Report a problem with this exampleOpens a pre-filled issue on GitHub.