diff --git a/src/utils/versioning/version-range.ts b/src/utils/versioning/version-range.ts new file mode 100644 index 0000000..cd479a0 --- /dev/null +++ b/src/utils/versioning/version-range.ts @@ -0,0 +1,250 @@ +import { asArray } from "@/utils/collections"; +import { Range, SemVer } from "semver"; +import { Version, parseVersion } from "./version"; + +/** + * Represents a version range, which is a set of constraints on a version number. + * + * This interface provides methods to check if a version is included in the range and + * to format the range into a string representation. + */ +export interface VersionRange { + /** + * Determines if the given version is included in the range. + * + * @param version - The version to check. + * + * @returns `true` if the version is included in the range; otherwise, `false`. + */ + includes(version: string | Version): boolean; + + /** + * Formats the range into a string representation. + * + * @returns The formatted string representation of the range. + */ + format(): string; + + /** + * Returns the original string representation of the range. + * + * @returns The original string representation of the range. + */ + toString(): string; +} + +/** + * Parses a string or a collection of strings and returns into a version range. + * + * @param range - The string or a collection of strings to be parsed. + * + * @returns The parsed {@link VersionRange} instance, or `undefined` if the input is invalid. + */ +export function parseVersionRange(range: string | Iterable): VersionRange | undefined { + return SemVerVersionRange.parse(range); +} + +/** + * Returns a version range that includes any version. + * + * @param range - An optional string representing the range. + * + * @returns The version range that includes any version. + */ +export function anyVersionRange(range?: string): VersionRange { + return SemVerVersionRange.any(range); +} + +/** + * Returns a version range that includes no versions. + * + * @param range - An optional string representing the range. + * + * @returns The version range that includes no versions. + */ +export function noneVersionRange(range?: string): VersionRange { + return SemVerVersionRange.none(range); +} + +/** + * Regular expression for matching interval-like expressions in version range strings. + */ +const INTERVAL_LIKE_REGEX = /(?:\[|\()[^\])]+(?:\]|\))/g; + +/** + * Converts a mixed version range string into a semver-compatible version range string. + * + * @param range - The mixed version range string. + * + * @returns The semver-compatible version range string. + */ +function mixedToSemver(range: string): string { + return range.replace(INTERVAL_LIKE_REGEX, intervalToSemver); +} + +/** + * Regular expression for matching interval expressions in version range strings. + */ +const INTERVAL_REGEX = /(?\[|\()\s*(?[^,\s]+)?\s*,\s*(?[^,\s\])]+)?\s*(?\]|\))/; + +/** + * Converts an interval expression into a semver-compatible range expression. + * + * @param range - The interval expression. + * + * @returns The semver-compatible range expression. + */ +function intervalToSemver(range: string): string { + const match = range.match(INTERVAL_REGEX); + if (!match) { + return ""; + } + + const fromOperator = match.groups.from_bracket === "[" ? ">=" : ">"; + const from = match.groups.from; + const toOperator = match.groups.to_bracket === "]" ? "<=" : "<"; + const to = match.groups.to; + if (!from && !to) { + return "*"; + } + + if (!from) { + return `${toOperator}${to}`; + } + + if (!to) { + return `${fromOperator}${from}`; + } + + return `${fromOperator}${from} ${toOperator}${to}`; +} + +/** + * Regular expression for matching semver-like tags in version strings with optional patch version. + */ +const SEMVER_OPTIONAL_PATCH_REGEX = /(\d+\.\d+)(\.\d+|\.[Xx])?([\w\-.+]*)/g; + +/** + * Ensures that a semver string has a patch version, adding ".0" if it is missing. + * + * @param semver - The semver string. + * + * @returns The semver string with a patch version. + */ +function fixMissingPatchVersion(semver: string): string { + return semver.replace(SEMVER_OPTIONAL_PATCH_REGEX, (match, before, patch, after) => { + return patch ? match : `${before}.0${after}`; + }); +} + +/** + * Represents a version range compliant with the Semantic Versioning specification. + */ +class SemVerVersionRange implements VersionRange { + /** + * Represents a range that includes any version. + */ + private static readonly ANY = new SemVerVersionRange(new Range("*"), "*"); + + /** + * Represents a range that includes no versions. + */ + private static readonly NONE = new SemVerVersionRange(new Range("<0.0.0")); + + /** + * The semver-compliant range object. + */ + private readonly _semver: Range; + + /** + * The original version range string. + */ + private readonly _range: string; + + /** + * Constructs a new {@link SemVerVersionRange} instance. + * + * @param semver - The semver-compliant range object. + * @param range - The original version range string. + */ + constructor(semver: Range, range?: string) { + this._semver = semver; + this._range = range ?? semver.format(); + } + + /** + * Returns a version range that includes any version. + * + * @param range - An optional string representing the range. + * + * @returns The version range that includes any version. + */ + static any(range?: string): SemVerVersionRange { + if (!range || range === SemVerVersionRange.ANY._range) { + return SemVerVersionRange.ANY; + } + + return new SemVerVersionRange(SemVerVersionRange.ANY._semver, range); + } + + /** + * Returns a version range that includes no versions. + * + * @param range - An optional string representing the range. + * + * @returns The version range that includes no versions. + */ + static none(range?: string): SemVerVersionRange { + if (!range || range === SemVerVersionRange.NONE._range) { + return SemVerVersionRange.NONE; + } + + return new SemVerVersionRange(SemVerVersionRange.NONE._semver, range); + } + + /** + * Parses a string or a collection of strings and returns into a version range. + * + * @param range - The string or a collection of strings to be parsed. + * + * @returns The parsed {@link SemVerVersionRange} instance, or `undefined` if the input is invalid. + */ + static parse(range: string | Iterable): SemVerVersionRange | undefined { + const ranges = (typeof range === "string" ? [range] : asArray(range)).map(x => x.trim()); + const mixedRange = ranges.join(" || "); + const semverRange = ranges.map(mixedToSemver).map(fixMissingPatchVersion).join(" || "); + + try { + const parsedSemverRange = new Range(semverRange, { includePrerelease: true }); + return new SemVerVersionRange(parsedSemverRange, mixedRange); + } catch { + return undefined; + } + } + + /** + * @inheritdoc + */ + includes(version: string | Version): boolean { + if (typeof version === "string") { + version = parseVersion(version); + } + + const internalSemVer = (version as { _semver?: SemVer })?._semver; + return this._semver.test(internalSemVer || version.format()); + } + + /** + * @inheritdoc + */ + format(): string { + return this._semver.format(); + } + + /** + * @inheritdoc + */ + toString(): string { + return this._range; + } +} diff --git a/tests/unit/utils/versioning/version-range.spec.ts b/tests/unit/utils/versioning/version-range.spec.ts new file mode 100644 index 0000000..443d0eb --- /dev/null +++ b/tests/unit/utils/versioning/version-range.spec.ts @@ -0,0 +1,262 @@ +import { + parseVersionRange, + anyVersionRange, + noneVersionRange, +} from "@/utils/versioning/version-range"; + +describe("parseVersionRange", () => { + test("parses a semver string correctly", () => { + const versionRange = parseVersionRange(">=1.2.3 <2.0.0"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe(">=1.2.3 <2.0.0"); + + expect(versionRange.includes("1.2.3")).toBe(true); + expect(versionRange.includes("1.2.4")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + expect(versionRange.includes("1.0.0")).toBe(false); + expect(versionRange.includes("1.2.2")).toBe(false); + expect(versionRange.includes("2.0.1")).toBe(false); + }); + + test("parses an interval notation string correctly", () => { + const versionRange = parseVersionRange("[1.2.3,2.0.0)"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("[1.2.3,2.0.0)"); + + expect(versionRange.includes("1.2.3")).toBe(true); + expect(versionRange.includes("1.2.4")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + expect(versionRange.includes("1.0.0")).toBe(false); + expect(versionRange.includes("1.2.2")).toBe(false); + expect(versionRange.includes("2.0.1")).toBe(false); + }); + + test("parses a string that mixes semver and interval notation correctly", () => { + const versionRange = parseVersionRange(">=1.2.3 <2.0.0 || [2.0.0,3.0.0)"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe(">=1.2.3 <2.0.0 || [2.0.0,3.0.0)"); + + expect(versionRange.includes("1.2.3")).toBe(true); + expect(versionRange.includes("1.2.4")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(true); + expect(versionRange.includes("2.0.1")).toBe(true); + expect(versionRange.includes("2.9999.9999")).toBe(true); + expect(versionRange.includes("1.0.0")).toBe(false); + expect(versionRange.includes("1.2.2")).toBe(false); + expect(versionRange.includes("3.0.0")).toBe(false); + expect(versionRange.includes("3.0.1")).toBe(false); + }); + + test("handles an array of semver strings correctly", () => { + const versionRange = parseVersionRange(["1.0.0", "2.0.0"]); + + expect(versionRange).toBeDefined(); + expect(versionRange.includes("1.0.0")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(true); + expect(versionRange.includes("0.0.0")).toBe(false); + expect(versionRange.includes("3.0.0")).toBe(false); + }); + + test("handles an array of interval strings correctly", () => { + const versionRange = parseVersionRange(["[1.0.0,2.0.0)", "[3.0.0,4.0.0)"]); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("[1.0.0,2.0.0) || [3.0.0,4.0.0)"); + + expect(versionRange.includes("0.0.9999")).toBe(false); + expect(versionRange.includes("1.0.0")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + expect(versionRange.includes("3.0.0")).toBe(true); + expect(versionRange.includes("3.9999.9999")).toBe(true); + expect(versionRange.includes("4.0.0")).toBe(false); + expect(versionRange.includes("4.0.1")).toBe(false); + }); + + test("handles an array of mixed semver and interval notations correctly", () => { + const versionRange = parseVersionRange(["1.0.0", "[2.0.0,3.0.0)"]); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("1.0.0 || [2.0.0,3.0.0)"); + + expect(versionRange.includes("1.0.0")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(true); + expect(versionRange.includes("2.9999.9999")).toBe(true); + expect(versionRange.includes("3.0.0")).toBe(false); + }); + + test("handles missing patch numbers in semver notation", () => { + const versionRange = parseVersionRange(">=1.2 <2.0"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe(">=1.2 <2.0"); + + expect(versionRange.includes("1.1.9999")).toBe(false); + expect(versionRange.includes("1.2.0")).toBe(true); + expect(versionRange.includes("1.2")).toBe(true); + expect(versionRange.includes("1.2.1")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + }); + + test("handles missing patch numbers in interval notation", () => { + const versionRange = parseVersionRange("[1.2,2.0)"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("[1.2,2.0)"); + + expect(versionRange.includes("1.1.9999")).toBe(false); + expect(versionRange.includes("1.2.0")).toBe(true); + expect(versionRange.includes("1.2")).toBe(true); + expect(versionRange.includes("1.2.1")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + }); + + test("handles missing patch numbers in pre-release semver notation", () => { + const versionRange = parseVersionRange(">=1.2-alpha.1 <2.0-beta.2"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe(">=1.2-alpha.1 <2.0-beta.2"); + + expect(versionRange.includes("1.1.9999")).toBe(false); + expect(versionRange.includes("1.2.0")).toBe(true); + expect(versionRange.includes("1.2")).toBe(true); + expect(versionRange.includes("1.2.1")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + }); + + test("handles missing patch numbers in pre-release interval notation", () => { + const versionRange = parseVersionRange("[1.2-alpha.1,2.0-beta.2)"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("[1.2-alpha.1,2.0-beta.2)"); + + expect(versionRange.includes("1.1.9999")).toBe(false); + expect(versionRange.includes("1.2.0")).toBe(true); + expect(versionRange.includes("1.2")).toBe(true); + expect(versionRange.includes("1.2.1")).toBe(true); + expect(versionRange.includes("1.9999.9999")).toBe(true); + expect(versionRange.includes("2.0.0")).toBe(false); + }); + + test("handles missing patch numbers in mixed semver and interval notations", () => { + const versionRange = parseVersionRange("1.2-alpha.1 || [2.0-beta.2,3.0.0)"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("1.2-alpha.1 || [2.0-beta.2,3.0.0)"); + + expect(versionRange.includes("1.2")).toBe(false); + expect(versionRange.includes("1.2.0")).toBe(false); + expect(versionRange.includes("1.2-alpha.1")).toBe(true); + expect(versionRange.includes("1.3")).toBe(false); + expect(versionRange.includes("1.9999.999")).toBe(false); + expect(versionRange.includes("2.0.0")).toBe(true); + expect(versionRange.includes("2.9999.9999")).toBe(true); + }); + + test("handles missing patch numbers in an array of mixed semver and interval notations", () => { + const versionRange = parseVersionRange(["1.2-alpha.1", "[2.0-beta.2,3.0)"]); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("1.2-alpha.1 || [2.0-beta.2,3.0)"); + + expect(versionRange.includes("1.2")).toBe(false); + expect(versionRange.includes("1.2.0")).toBe(false); + expect(versionRange.includes("1.2-alpha.1")).toBe(true); + expect(versionRange.includes("1.3")).toBe(false); + expect(versionRange.includes("1.9999.999")).toBe(false); + expect(versionRange.includes("2.0.0")).toBe(true); + expect(versionRange.includes("2.9999.9999")).toBe(true); + }); + + test("parses an empty string as a version range that includes any version", () => { + const versionRange = parseVersionRange(""); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe(""); + expect(versionRange.includes("0.0.1")).toBe(true); + expect(versionRange.includes("9999.9999.9999")).toBe(true); + }); + + test("parses '*' as a version range that includes any version", () => { + const versionRange = parseVersionRange("*"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("*"); + expect(versionRange.includes("0.0.1")).toBe(true); + expect(versionRange.includes("9999.9999.9999")).toBe(true); + }); + + test("parses '(,)' as a version range that includes any version", () => { + const versionRange = parseVersionRange("(,)"); + + expect(versionRange).toBeDefined(); + expect(versionRange.toString()).toBe("(,)"); + expect(versionRange.includes("0.0.1")).toBe(true); + expect(versionRange.includes("9999.9999.9999")).toBe(true); + }); +}); + +describe("anyVersionRange", () => { + test("returns a version range that includes any version", () => { + const versionRange = anyVersionRange(); + + expect(versionRange.includes("0.0.1")).toBe(true); + expect(versionRange.includes("9999.9999.9999")).toBe(true); + }); + + test("returns a custom version range that includes any version", () => { + const versionRange = anyVersionRange("Includes any version"); + + expect(versionRange.includes("0.0.1")).toBe(true); + expect(versionRange.includes("9999.9999.9999")).toBe(true); + expect(versionRange.toString()).toBe("Includes any version"); + }); +}); + +describe("noneVersionRange", () => { + test("returns a version range that includes no versions", () => { + const versionRange = noneVersionRange(); + + expect(versionRange.includes("0.0.1")).toBe(false); + expect(versionRange.includes("9999.9999.9999")).toBe(false); + }); + + test("returns a custom version range that includes no versions", () => { + const versionRange = noneVersionRange("Includes no versions"); + + expect(versionRange.includes("0.0.1")).toBe(false); + expect(versionRange.includes("9999.9999.9999")).toBe(false); + expect(versionRange.toString()).toBe("Includes no versions"); + }); +}); + +describe("VersionRange", () => { + test("checks if a version is included in the range correctly", () => { + expect(parseVersionRange("1.0.0").includes("1.0.0")).toBe(true); + expect(parseVersionRange("1.0.0-alpha.1").includes("1.0.0-alpha.1")).toBe(true); + expect(parseVersionRange("1.0").includes("1.0.0")).toBe(true); + expect(parseVersionRange("1.0-alpha.1").includes("1.0.0-alpha.1")).toBe(true); + }); + + test("formats correctly", () => { + expect(parseVersionRange("1.0.0").format()).toBe("1.0.0"); + expect(parseVersionRange("1.0.0-alpha.1").format()).toBe("1.0.0-alpha.1"); + expect(parseVersionRange("1.0").format()).toBe("1.0.0"); + expect(parseVersionRange("1.0-alpha.1").format()).toBe("1.0.0-alpha.1"); + }); + + test("toString returns the original string representation", () => { + expect(parseVersionRange("1.0.0").toString()).toBe("1.0.0"); + expect(parseVersionRange("1.0").toString()).toBe("1.0"); + expect(parseVersionRange("1.0.0-alpha.1").toString()).toBe("1.0.0-alpha.1"); + expect(parseVersionRange("1.0-alpha.1").toString()).toBe("1.0-alpha.1"); + }); +});