100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
const DIGITS = /^\d+$/;
|
|
|
|
/**
|
|
* Compare two version strings.
|
|
*
|
|
* Canonical comparator that supersedes the historical in-repo copies
|
|
* (update-cli, hackage scraper, release scripts):
|
|
* - inputs are trimmed and at most one leading `v`/`V` is stripped
|
|
* - dot-separated segments are compared numerically, missing trailing
|
|
* segments count as 0, so `1.2` === `1.2.0` and any segment count works
|
|
* - a SemVer-2.0 prerelease suffix sorts before the plain release
|
|
* (`1.0.0-beta` < `1.0.0`); prerelease identifiers follow SemVer order
|
|
* (numeric < alphanumeric, numeric compared by value, alphanumeric
|
|
* compared lexically, longer sets of equal fields win)
|
|
* - SemVer build metadata begins at the first `+` and does not participate
|
|
* in precedence; it is stripped before core/prerelease parsing
|
|
* - malformed numeric segments compare as 0 (`1.2.x` === `1.2.0`)
|
|
* - never throws; returns only -1 | 0 | 1
|
|
*/
|
|
export function compareVersions(a: string, b: string): number {
|
|
const pa = parseVersion(a);
|
|
const pb = parseVersion(b);
|
|
|
|
const core = compareNumericParts(pa.core, pb.core);
|
|
if (core !== 0) return core;
|
|
|
|
return comparePrerelease(pa.prerelease, pb.prerelease);
|
|
}
|
|
|
|
interface ParsedVersion {
|
|
core: string[];
|
|
prerelease: string[] | null;
|
|
}
|
|
|
|
function parseVersion(version: string): ParsedVersion {
|
|
const trimmed = version.trim();
|
|
const stripped = trimmed.startsWith("v") || trimmed.startsWith("V") ? trimmed.slice(1) : trimmed;
|
|
const plusIndex = stripped.indexOf("+");
|
|
const withoutBuild = plusIndex === -1 ? stripped : stripped.slice(0, plusIndex);
|
|
const dashIndex = withoutBuild.indexOf("-");
|
|
if (dashIndex === -1) {
|
|
return { core: withoutBuild.split("."), prerelease: null };
|
|
}
|
|
return {
|
|
core: withoutBuild.slice(0, dashIndex).split("."),
|
|
prerelease: withoutBuild.slice(dashIndex + 1).split("."),
|
|
};
|
|
}
|
|
|
|
/** Compare dot-separated numeric segments; missing/malformed segments count as 0. */
|
|
function compareNumericParts(a: string[], b: string[]): number {
|
|
const length = Math.max(a.length, b.length);
|
|
for (let i = 0; i < length; i++) {
|
|
// Missing or malformed segments compare as 0.
|
|
const sa = a[i];
|
|
const sb = b[i];
|
|
const result = compareDigits(
|
|
sa !== undefined && DIGITS.test(sa) ? sa : "0",
|
|
sb !== undefined && DIGITS.test(sb) ? sb : "0",
|
|
);
|
|
if (result !== 0) return result;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** Exact integer comparison of digit strings, avoiding float overflow. */
|
|
function compareDigits(a: string, b: string): number {
|
|
const na = a.replace(/^0+/, "") || "0";
|
|
const nb = b.replace(/^0+/, "") || "0";
|
|
if (na.length !== nb.length) return na.length < nb.length ? -1 : 1;
|
|
if (na < nb) return -1;
|
|
if (na > nb) return 1;
|
|
return 0;
|
|
}
|
|
|
|
/** SemVer-2.0 prerelease ordering; null means a plain release, which wins. */
|
|
function comparePrerelease(a: string[] | null, b: string[] | null): number {
|
|
if (a === null || b === null) {
|
|
return a === b ? 0 : a === null ? 1 : -1;
|
|
}
|
|
const length = Math.max(a.length, b.length);
|
|
for (let i = 0; i < length; i++) {
|
|
const ia = a[i];
|
|
const ib = b[i];
|
|
if (ia === undefined) return -1;
|
|
if (ib === undefined) return 1;
|
|
const aNumeric = DIGITS.test(ia);
|
|
const bNumeric = DIGITS.test(ib);
|
|
if (aNumeric && bNumeric) {
|
|
const result = compareDigits(ia, ib);
|
|
if (result !== 0) return result;
|
|
} else if (aNumeric !== bNumeric) {
|
|
return aNumeric ? -1 : 1;
|
|
} else if (ia !== ib) {
|
|
return ia < ib ? -1 : 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|