• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import {
2    compareStringsCaseSensitive, compareValues, Comparison, Debug, emptyArray, every, isArray, map, some, trimString,
3} from "./_namespaces/ts";
4
5// https://semver.org/#spec-item-2
6// > A normal version number MUST take the form X.Y.Z where X, Y, and Z are non-negative
7// > integers, and MUST NOT contain leading zeroes. X is the major version, Y is the minor
8// > version, and Z is the patch version. Each element MUST increase numerically.
9//
10// NOTE: We differ here in that we allow X and X.Y, with missing parts having the default
11// value of `0`.
12const versionRegExp = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*)(?:\-([a-z0-9-.]+))?(?:\+([a-z0-9-.]+))?)?)?$/i;
13
14// https://semver.org/#spec-item-9
15// > A pre-release version MAY be denoted by appending a hyphen and a series of dot separated
16// > identifiers immediately following the patch version. Identifiers MUST comprise only ASCII
17// > alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty. Numeric identifiers
18// > MUST NOT include leading zeroes.
19const prereleaseRegExp = /^(?:0|[1-9]\d*|[a-z-][a-z0-9-]*)(?:\.(?:0|[1-9]\d*|[a-z-][a-z0-9-]*))*$/i;
20const prereleasePartRegExp = /^(?:0|[1-9]\d*|[a-z-][a-z0-9-]*)$/i;
21
22// https://semver.org/#spec-item-10
23// > Build metadata MAY be denoted by appending a plus sign and a series of dot separated
24// > identifiers immediately following the patch or pre-release version. Identifiers MUST
25// > comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty.
26const buildRegExp = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/i;
27const buildPartRegExp = /^[a-z0-9-]+$/i;
28
29// https://semver.org/#spec-item-9
30// > Numeric identifiers MUST NOT include leading zeroes.
31const numericIdentifierRegExp = /^(0|[1-9]\d*)$/;
32
33/**
34 * Describes a precise semantic version number, https://semver.org
35 *
36 * @internal
37 */
38export class Version {
39    static readonly zero = new Version(0, 0, 0, ["0"]);
40
41    readonly major: number;
42    readonly minor: number;
43    readonly patch: number;
44    readonly prerelease: readonly string[];
45    readonly build: readonly string[];
46
47    constructor(text: string);
48    constructor(major: number, minor?: number, patch?: number, prerelease?: string | readonly string[], build?: string | readonly string[]);
49    constructor(major: number | string, minor = 0, patch = 0, prerelease: string | readonly string[] = "", build: string | readonly string[] = "") {
50        if (typeof major === "string") {
51            const result = Debug.checkDefined(tryParseComponents(major), "Invalid version");
52            ({ major, minor, patch, prerelease, build } = result);
53        }
54
55        Debug.assert(major >= 0, "Invalid argument: major");
56        Debug.assert(minor >= 0, "Invalid argument: minor");
57        Debug.assert(patch >= 0, "Invalid argument: patch");
58
59        const prereleaseArray = prerelease ? isArray(prerelease) ? prerelease : prerelease.split(".") : emptyArray;
60        const buildArray = build ? isArray(build) ? build : build.split(".") : emptyArray;
61
62        Debug.assert(every(prereleaseArray, s => prereleasePartRegExp.test(s)), "Invalid argument: prerelease");
63        Debug.assert(every(buildArray, s => buildPartRegExp.test(s)), "Invalid argument: build");
64
65        this.major = major;
66        this.minor = minor;
67        this.patch = patch;
68        this.prerelease = prereleaseArray;
69        this.build = buildArray;
70    }
71
72    static tryParse(text: string) {
73        const result = tryParseComponents(text);
74        if (!result) return undefined;
75
76        const { major, minor, patch, prerelease, build } = result;
77        return new Version(major, minor, patch, prerelease, build);
78    }
79
80    compareTo(other: Version | undefined) {
81        // https://semver.org/#spec-item-11
82        // > Precedence is determined by the first difference when comparing each of these
83        // > identifiers from left to right as follows: Major, minor, and patch versions are
84        // > always compared numerically.
85        //
86        // https://semver.org/#spec-item-11
87        // > Precedence for two pre-release versions with the same major, minor, and patch version
88        // > MUST be determined by comparing each dot separated identifier from left to right until
89        // > a difference is found [...]
90        //
91        // https://semver.org/#spec-item-11
92        // > Build metadata does not figure into precedence
93        if (this === other) return Comparison.EqualTo;
94        if (other === undefined) return Comparison.GreaterThan;
95        return compareValues(this.major, other.major)
96            || compareValues(this.minor, other.minor)
97            || compareValues(this.patch, other.patch)
98            || comparePrereleaseIdentifiers(this.prerelease, other.prerelease);
99    }
100
101    increment(field: "major" | "minor" | "patch") {
102        switch (field) {
103            case "major": return new Version(this.major + 1, 0, 0);
104            case "minor": return new Version(this.major, this.minor + 1, 0);
105            case "patch": return new Version(this.major, this.minor, this.patch + 1);
106            default: return Debug.assertNever(field);
107        }
108    }
109
110    with(fields: { major?: number, minor?: number, patch?: number, prerelease?: string | readonly string[], build?: string | readonly string[] }) {
111        const {
112            major = this.major,
113            minor = this.minor,
114            patch = this.patch,
115            prerelease = this.prerelease,
116            build = this.build
117        } = fields;
118        return new Version(major, minor, patch, prerelease, build);
119    }
120
121    toString() {
122        let result = `${this.major}.${this.minor}.${this.patch}`;
123        if (some(this.prerelease)) result += `-${this.prerelease.join(".")}`;
124        if (some(this.build)) result += `+${this.build.join(".")}`;
125        return result;
126    }
127}
128
129function tryParseComponents(text: string) {
130    const match = versionRegExp.exec(text);
131    if (!match) return undefined;
132
133    const [, major, minor = "0", patch = "0", prerelease = "", build = ""] = match;
134    if (prerelease && !prereleaseRegExp.test(prerelease)) return undefined;
135    if (build && !buildRegExp.test(build)) return undefined;
136    return {
137        major: parseInt(major, 10),
138        minor: parseInt(minor, 10),
139        patch: parseInt(patch, 10),
140        prerelease,
141        build
142    };
143}
144
145function comparePrereleaseIdentifiers(left: readonly string[], right: readonly string[]) {
146    // https://semver.org/#spec-item-11
147    // > When major, minor, and patch are equal, a pre-release version has lower precedence
148    // > than a normal version.
149    if (left === right) return Comparison.EqualTo;
150    if (left.length === 0) return right.length === 0 ? Comparison.EqualTo : Comparison.GreaterThan;
151    if (right.length === 0) return Comparison.LessThan;
152
153    // https://semver.org/#spec-item-11
154    // > Precedence for two pre-release versions with the same major, minor, and patch version
155    // > MUST be determined by comparing each dot separated identifier from left to right until
156    // > a difference is found [...]
157    const length = Math.min(left.length, right.length);
158    for (let i = 0; i < length; i++) {
159        const leftIdentifier = left[i];
160        const rightIdentifier = right[i];
161        if (leftIdentifier === rightIdentifier) continue;
162
163        const leftIsNumeric = numericIdentifierRegExp.test(leftIdentifier);
164        const rightIsNumeric = numericIdentifierRegExp.test(rightIdentifier);
165        if (leftIsNumeric || rightIsNumeric) {
166            // https://semver.org/#spec-item-11
167            // > Numeric identifiers always have lower precedence than non-numeric identifiers.
168            if (leftIsNumeric !== rightIsNumeric) return leftIsNumeric ? Comparison.LessThan : Comparison.GreaterThan;
169
170            // https://semver.org/#spec-item-11
171            // > identifiers consisting of only digits are compared numerically
172            const result = compareValues(+leftIdentifier, +rightIdentifier);
173            if (result) return result;
174        }
175        else {
176            // https://semver.org/#spec-item-11
177            // > identifiers with letters or hyphens are compared lexically in ASCII sort order.
178            const result = compareStringsCaseSensitive(leftIdentifier, rightIdentifier);
179            if (result) return result;
180        }
181    }
182
183    // https://semver.org/#spec-item-11
184    // > A larger set of pre-release fields has a higher precedence than a smaller set, if all
185    // > of the preceding identifiers are equal.
186    return compareValues(left.length, right.length);
187}
188
189/**
190 * Describes a semantic version range, per https://github.com/npm/node-semver#ranges
191 *
192 * @internal
193 */
194export class VersionRange {
195    private _alternatives: readonly (readonly Comparator[])[];
196
197    constructor(spec: string) {
198        this._alternatives = spec ? Debug.checkDefined(parseRange(spec), "Invalid range spec.") : emptyArray;
199    }
200
201    static tryParse(text: string) {
202        const sets = parseRange(text);
203        if (sets) {
204            const range = new VersionRange("");
205            range._alternatives = sets;
206            return range;
207        }
208        return undefined;
209    }
210
211    /**
212     * Tests whether a version matches the range. This is equivalent to `satisfies(version, range, { includePrerelease: true })`.
213     * in `node-semver`.
214     */
215    test(version: Version | string) {
216        if (typeof version === "string") version = new Version(version);
217        return testDisjunction(version, this._alternatives);
218    }
219
220    toString() {
221        return formatDisjunction(this._alternatives);
222    }
223}
224
225interface Comparator {
226    readonly operator: "<" | "<=" | ">" | ">=" | "=";
227    readonly operand: Version;
228}
229
230// https://github.com/npm/node-semver#range-grammar
231//
232// range-set    ::= range ( logical-or range ) *
233// range        ::= hyphen | simple ( ' ' simple ) * | ''
234// logical-or   ::= ( ' ' ) * '||' ( ' ' ) *
235const logicalOrRegExp = /\|\|/g;
236const whitespaceRegExp = /\s+/g;
237
238// https://github.com/npm/node-semver#range-grammar
239//
240// partial      ::= xr ( '.' xr ( '.' xr qualifier ? )? )?
241// xr           ::= 'x' | 'X' | '*' | nr
242// nr           ::= '0' | ['1'-'9'] ( ['0'-'9'] ) *
243// qualifier    ::= ( '-' pre )? ( '+' build )?
244// pre          ::= parts
245// build        ::= parts
246// parts        ::= part ( '.' part ) *
247// part         ::= nr | [-0-9A-Za-z]+
248const partialRegExp = /^([xX*0]|[1-9]\d*)(?:\.([xX*0]|[1-9]\d*)(?:\.([xX*0]|[1-9]\d*)(?:-([a-z0-9-.]+))?(?:\+([a-z0-9-.]+))?)?)?$/i;
249
250// https://github.com/npm/node-semver#range-grammar
251//
252// hyphen       ::= partial ' - ' partial
253const hyphenRegExp = /^\s*([a-z0-9-+.*]+)\s+-\s+([a-z0-9-+.*]+)\s*$/i;
254
255// https://github.com/npm/node-semver#range-grammar
256//
257// simple       ::= primitive | partial | tilde | caret
258// primitive    ::= ( '<' | '>' | '>=' | '<=' | '=' ) partial
259// tilde        ::= '~' partial
260// caret        ::= '^' partial
261const rangeRegExp = /^(~|\^|<|<=|>|>=|=)?\s*([a-z0-9-+.*]+)$/i;
262
263function parseRange(text: string) {
264    const alternatives: Comparator[][] = [];
265    for (let range of trimString(text).split(logicalOrRegExp)) {
266        if (!range) continue;
267        const comparators: Comparator[] = [];
268        range = trimString(range);
269        const match = hyphenRegExp.exec(range);
270        if (match) {
271            if (!parseHyphen(match[1], match[2], comparators)) return undefined;
272        }
273        else {
274            for (const simple of range.split(whitespaceRegExp)) {
275                const match = rangeRegExp.exec(trimString(simple));
276                if (!match || !parseComparator(match[1], match[2], comparators)) return undefined;
277            }
278        }
279        alternatives.push(comparators);
280    }
281    return alternatives;
282}
283
284function parsePartial(text: string) {
285    const match = partialRegExp.exec(text);
286    if (!match) return undefined;
287
288    const [, major, minor = "*", patch = "*", prerelease, build] = match;
289    const version = new Version(
290        isWildcard(major) ? 0 : parseInt(major, 10),
291        isWildcard(major) || isWildcard(minor) ? 0 : parseInt(minor, 10),
292        isWildcard(major) || isWildcard(minor) || isWildcard(patch) ? 0 : parseInt(patch, 10),
293        prerelease,
294        build);
295
296    return { version, major, minor, patch };
297}
298
299function parseHyphen(left: string, right: string, comparators: Comparator[]) {
300    const leftResult = parsePartial(left);
301    if (!leftResult) return false;
302
303    const rightResult = parsePartial(right);
304    if (!rightResult) return false;
305
306    if (!isWildcard(leftResult.major)) {
307        comparators.push(createComparator(">=", leftResult.version));
308    }
309
310    if (!isWildcard(rightResult.major)) {
311        comparators.push(
312            isWildcard(rightResult.minor) ? createComparator("<", rightResult.version.increment("major")) :
313            isWildcard(rightResult.patch) ? createComparator("<", rightResult.version.increment("minor")) :
314            createComparator("<=", rightResult.version));
315    }
316
317    return true;
318}
319
320function parseComparator(operator: string, text: string, comparators: Comparator[]) {
321    const result = parsePartial(text);
322    if (!result) return false;
323
324    const { version, major, minor, patch } = result;
325    if (!isWildcard(major)) {
326        switch (operator) {
327            case "~":
328                comparators.push(createComparator(">=", version));
329                comparators.push(createComparator("<", version.increment(
330                    isWildcard(minor) ? "major" :
331                    "minor")));
332                break;
333            case "^":
334                comparators.push(createComparator(">=", version));
335                comparators.push(createComparator("<", version.increment(
336                    version.major > 0 || isWildcard(minor) ? "major" :
337                    version.minor > 0 || isWildcard(patch) ? "minor" :
338                    "patch")));
339                break;
340            case "<":
341            case ">=":
342                comparators.push(
343                    isWildcard(minor) || isWildcard(patch) ? createComparator(operator, version.with({ prerelease: "0" })) :
344                    createComparator(operator, version));
345                break;
346            case "<=":
347            case ">":
348                comparators.push(
349                    isWildcard(minor) ? createComparator(operator === "<=" ? "<" : ">=", version.increment("major").with({ prerelease: "0" })) :
350                    isWildcard(patch) ? createComparator(operator === "<=" ? "<" : ">=", version.increment("minor").with({ prerelease: "0" })) :
351                    createComparator(operator, version));
352                break;
353            case "=":
354            case undefined:
355                if (isWildcard(minor) || isWildcard(patch)) {
356                    comparators.push(createComparator(">=", version.with({ prerelease: "0" })));
357                    comparators.push(createComparator("<", version.increment(isWildcard(minor) ? "major" : "minor").with({ prerelease: "0" })));
358                }
359                else {
360                    comparators.push(createComparator("=", version));
361                }
362                break;
363            default:
364                // unrecognized
365                return false;
366        }
367    }
368    else if (operator === "<" || operator === ">") {
369        comparators.push(createComparator("<", Version.zero));
370    }
371
372    return true;
373}
374
375function isWildcard(part: string) {
376    return part === "*" || part === "x" || part === "X";
377}
378
379function createComparator(operator: Comparator["operator"], operand: Version) {
380    return { operator, operand };
381}
382
383function testDisjunction(version: Version, alternatives: readonly (readonly Comparator[])[]) {
384    // an empty disjunction is treated as "*" (all versions)
385    if (alternatives.length === 0) return true;
386    for (const alternative of alternatives) {
387        if (testAlternative(version, alternative)) return true;
388    }
389    return false;
390}
391
392function testAlternative(version: Version, comparators: readonly Comparator[]) {
393    for (const comparator of comparators) {
394        if (!testComparator(version, comparator.operator, comparator.operand)) return false;
395    }
396    return true;
397}
398
399function testComparator(version: Version, operator: Comparator["operator"], operand: Version) {
400    const cmp = version.compareTo(operand);
401    switch (operator) {
402        case "<": return cmp < 0;
403        case "<=": return cmp <= 0;
404        case ">": return cmp > 0;
405        case ">=": return cmp >= 0;
406        case "=": return cmp === 0;
407        default: return Debug.assertNever(operator);
408    }
409}
410
411function formatDisjunction(alternatives: readonly (readonly Comparator[])[]) {
412    return map(alternatives, formatAlternative).join(" || ") || "*";
413}
414
415function formatAlternative(comparators: readonly Comparator[]) {
416    return map(comparators, formatComparator).join(" ");
417}
418
419function formatComparator(comparator: Comparator) {
420    return `${comparator.operator}${comparator.operand}`;
421}