← 中文资源

简体中文版本

Semver 版本范围检查

解析语义化版本并检查比较符、脱字符、波浪号、通配符、连字符区间与或组合范围。

阅读英文原文

功能说明

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