Files
oh-my-pi/packages/utils/src/version.ts
T
2026-08-05 02:48:59 +09:00

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;
}