功能说明
parseSemver 解析主版本、次版本、补丁与预发布标识,compareSemver 按语义化版本规则排序,satisfiesRange 判断版本是否满足范围。范围支持大于、小于、等于、脱字符、波浪号、通配符、部分版本和双重或组合。适合插件兼容性检查、依赖预检、发布流水线门禁和运行时版本校验。
关键代码
比较函数先逐段比较数字,再按预发布规则处理:无预发布标识的版本高于有标识者;数字段按数值比较,字母段按字典序,数字段低于字母段。范围先按双竖线拆分或分支,再按空白拆分比较符。部分版本用可选的 major、minor、patch 表示,比较时按下界处理,脱字符与波浪号分别计算不同的上界。
使用注意
实现不包含完整的预发布过滤规则,带预发布标识的版本只要落在数值区间内就会通过,而 npm 的 semver 通常要求范围显式包含预发布。构建元数据会被忽略,这是语义化版本规范的要求。通配符与部分版本的上界处理针对常见写法,复杂范围建议与官方 semver 包对比测试。范围中的空格不能省略运算符与版本之间的分隔歧义。
验证方式
测试主版本排序、预发布优先级与构建元数据忽略。覆盖 >=1.2.3、<2.0.0、^1.2.3、^0.2.3、^0.0.3、~1.2.3、1.2.x、1.x、*、1.2.3 - 2.0.0 与 || 组合。验证部分版本与上界边界,测试非法版本、空范围、未知运算符会抛错,并对比常见范围在边界版本上的结果。
代码示例
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));
});
}