mirror of
https://github.com/Kir-Antipov/mc-publish.git
synced 2024-11-22 00:11:02 -05:00
Presented ability to parse and use version ranges
This commit is contained in:
parent
4bbd4cc2d9
commit
5e957f45ba
2 changed files with 512 additions and 0 deletions
250
src/utils/versioning/version-range.ts
Normal file
250
src/utils/versioning/version-range.ts
Normal file
|
@ -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<string>): 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 = /(?<from_bracket>\[|\()\s*(?<from>[^,\s]+)?\s*,\s*(?<to>[^,\s\])]+)?\s*(?<to_bracket>\]|\))/;
|
||||
|
||||
/**
|
||||
* 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<string>): 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;
|
||||
}
|
||||
}
|
262
tests/unit/utils/versioning/version-range.spec.ts
Normal file
262
tests/unit/utils/versioning/version-range.spec.ts
Normal file
|
@ -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");
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue