import { DeepMap } from 'deep-equality-data-structures' import * as P from './exver' /** * Compile-time utility type that validates a version string literal conforms to semver format. * * Resolves to `unknown` if valid, `never` if invalid. Used with {@link testTypeVersion}. * * @example * ```ts * type Valid = ValidateVersion<"1.2.3"> // unknown (valid) * type Invalid = ValidateVersion<"-3"> // never (invalid) * ``` */ // prettier-ignore export type ValidateVersion = T extends `-${infer A}` ? never : T extends `${infer A}-${string}` ? ValidateVersion : T extends `${bigint}` ? unknown : T extends `${bigint}.${infer A}` ? ValidateVersion : never /** * Compile-time utility type that validates an extended version string literal. * * Extended versions have the format `upstream:downstream` or `#flavor:upstream:downstream`. * * @example * ```ts * type Valid = ValidateExVer<"1.2.3:0"> // valid * type Flavored = ValidateExVer<"#bitcoin:1.0:0"> // valid * type Bad = ValidateExVer<"1.2-3"> // never (invalid) * ``` */ // prettier-ignore export type ValidateExVer = T extends `#${string}:${infer A}:${infer B}` ? ValidateVersion & ValidateVersion : T extends `${infer A}:${infer B}` ? ValidateVersion & ValidateVersion : never /** * Validates a tuple of extended version string literals at compile time. * * @example * ```ts * type Valid = ValidateExVers<["1.0:0", "2.0:0"]> // valid * ``` */ // prettier-ignore export type ValidateExVers = T extends [] ? unknown[] : T extends [infer A, ...infer B] ? ValidateExVer & ValidateExVers : never[] type Anchor = { type: 'Anchor' operator: P.CmpOp version: ExtendedVersion } type And = { type: 'And' left: VersionRange right: VersionRange } type Or = { type: 'Or' left: VersionRange right: VersionRange } type Not = { type: 'Not' value: VersionRange } type Flavor = { type: 'Flavor' flavor: string | null } type FlavorNot = { type: 'FlavorNot' flavors: Set } type FlavorAtom = Flavor | FlavorNot /** * Splits a number line of versions in half, so that every possible semver is either to the left or right. * The `side` field handles inclusively. * * # Example * Consider the version `1.2.3`. For side=-1 the version point is like `1.2.2.999*.999*.**` (that is, 1.2.3.0.0.** is greater) and * for side=+1 the point is like `1.2.3.0.0.**.1` (that is, 1.2.3.0.0.** is less). */ type VersionRangePoint = { upstream: Version downstream: Version side: -1 | 1 } function compareVersionRangePoints( a: VersionRangePoint, b: VersionRangePoint, ): -1 | 0 | 1 { let up = a.upstream.compareForSort(b.upstream) if (up != 0) { return up } let down = a.upstream.compareForSort(b.upstream) if (down != 0) { return down } if (a.side < b.side) { return -1 } else if (a.side > b.side) { return 1 } else { return 0 } } function adjacentVersionRangePoints( a: VersionRangePoint, b: VersionRangePoint, ): boolean { let up = a.upstream.compareForSort(b.upstream) if (up != 0) { return false } let down = a.upstream.compareForSort(b.upstream) if (down != 0) { return false } return a.side == -1 && b.side == 1 } function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null { if (a.type == 'Flavor') { if (b.type == 'Flavor') { if (a.flavor == b.flavor) { return a } else { return null } } else { if (b.flavors.has(a.flavor)) { return null } else { return a } } } else { if (b.type == 'Flavor') { if (a.flavors.has(b.flavor)) { return null } else { return b } } else { // TODO: use Set.union if targeting esnext or later return { type: 'FlavorNot', flavors: new Set([...a.flavors, ...b.flavors]), } } } } /** * Truth tables for version numbers and flavors. For each flavor we need a separate table, which * is quite straightforward. But in order to exhaustively enumerate the boolean values of every * combination of flavors and versions we also need tables for flavor negations. */ type VersionRangeTables = DeepMap | boolean /** * A truth table for version numbers. This is easiest to picture as a number line, cut up into * ranges of versions between version points. */ class VersionRangeTable { private constructor( protected points: Array, protected values: boolean[], ) {} static zip( a: VersionRangeTable, b: VersionRangeTable, func: (a: boolean, b: boolean) => boolean, ): VersionRangeTable { let c = new VersionRangeTable([], []) let i = 0 let j = 0 while (true) { let next = func(a.values[i], b.values[j]) if (c.values.length > 0 && c.values[c.values.length - 1] == next) { // collapse automatically c.points.pop() } else { c.values.push(next) } // which point do we step over? if (i == a.points.length) { if (j == b.points.length) { // just added the last segment, no point to jump over return c } else { // i has reach the end, step over j c.points.push(b.points[j]) j += 1 } } else { if (j == b.points.length) { // j has reached the end, step over i c.points.push(a.points[i]) i += 1 } else { // depends on which of the next two points is lower switch (compareVersionRangePoints(a.points[i], b.points[j])) { case -1: // i is the lower point c.points.push(a.points[i]) i += 1 break case 1: // j is the lower point c.points.push(b.points[j]) j += 1 break default: // step over both c.points.push(a.points[i]) i += 1 j += 1 break } } } } } /** * Creates a version table which is `true` for the given flavor, and `false` for any other flavor. */ static eqFlavor(flavor: string | null): VersionRangeTables { return new DeepMap([ [ { type: 'Flavor', flavor } as FlavorAtom, new VersionRangeTable([], [true]), ], // make sure the truth table is exhaustive, or `not` will not work properly. [ { type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom, new VersionRangeTable([], [false]), ], ]) } /** * Creates a version table with exactly two ranges (to the left and right of the given point) and with `false` for any other flavor. * This is easiest to understand by looking at `VersionRange.tables`. */ static cmpPoint( flavor: string | null, point: VersionRangePoint, left: boolean, right: boolean, ): VersionRangeTables { return new DeepMap([ [ { type: 'Flavor', flavor } as FlavorAtom, new VersionRangeTable([point], [left, right]), ], // make sure the truth table is exhaustive, or `not` will not work properly. [ { type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom, new VersionRangeTable([], [false]), ], ]) } /** * Helper for `cmpPoint`. */ static cmp( version: ExtendedVersion, side: -1 | 1, left: boolean, right: boolean, ): VersionRangeTables { return VersionRangeTable.cmpPoint( version.flavor, { upstream: version.upstream, downstream: version.downstream, side }, left, right, ) } static not(tables: VersionRangeTables) { if (tables === true || tables === false) { return !tables } // because tables are always exhaustive, we can simply invert each range for (let [f, t] of tables) { for (let i = 0; i < t.values.length; i++) { t.values[i] = !t.values[i] } } return tables } static and( a_tables: VersionRangeTables, b_tables: VersionRangeTables, ): VersionRangeTables { if (a_tables === true) { return b_tables } if (b_tables === true) { return a_tables } if (a_tables === false || b_tables == false) { return false } let c_tables: VersionRangeTables = true for (let [f_a, a] of a_tables) { for (let [f_b, b] of b_tables) { let flavor = flavorAnd(f_a, f_b) if (flavor == null) { continue } let c = VersionRangeTable.zip(a, b, (a, b) => a && b) if (c_tables === true) { c_tables = new DeepMap() } let prev_c = c_tables.get(flavor) if (prev_c == null) { c_tables.set(flavor, c) } else { c_tables.set( flavor, VersionRangeTable.zip(c, prev_c, (a, b) => a || b), ) } } } return c_tables } static or(...in_tables: VersionRangeTables[]): VersionRangeTables { let out_tables: VersionRangeTables = false for (let tables of in_tables) { if (tables === false) { continue } if (tables === true) { return true } if (out_tables === false) { out_tables = new DeepMap() } for (let [flavor, table] of tables) { let prev = out_tables.get(flavor) if (prev == null) { out_tables.set(flavor, table) } else { out_tables.set( flavor, VersionRangeTable.zip(table, prev, (a, b) => a || b), ) } } } return out_tables } /** * If this is true for all versions or false for all versions, returen that value. Otherwise return null. */ static collapse(tables: VersionRangeTables): boolean | null { if (tables === true || tables === false) { return tables } else { let found = null for (let table of tables.values()) { for (let x of table.values) { if (found == null) { found = x } else if (found != x) { return null } } } return found } } /** * Expresses this truth table as a series of version range operators. * https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms */ static minterms(tables: VersionRangeTables): VersionRange { let collapse = VersionRangeTable.collapse(tables) if (tables === true || collapse === true) { return VersionRange.any() } if (tables == false || collapse === false) { return VersionRange.none() } let sum_terms: VersionRange[] = [] for (let [flavor, table] of tables) { let cmp_flavor = null if (flavor.type == 'Flavor') { cmp_flavor = flavor.flavor } for (let i = 0; i < table.values.length; i++) { let term: VersionRange[] = [] if (!table.values[i]) { continue } if (flavor.type == 'FlavorNot') { for (let not_flavor of flavor.flavors) { term.push(VersionRange.flavor(not_flavor).not()) } } let p = null let q = null if (i > 0) { p = table.points[i - 1] } if (i < table.points.length) { q = table.points[i] } if (p != null && q != null && adjacentVersionRangePoints(p, q)) { term.push( VersionRange.anchor( '=', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream), ), ) } else { if (p != null && p.side < 0) { term.push( VersionRange.anchor( '>=', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream), ), ) } if (p != null && p.side >= 0) { term.push( VersionRange.anchor( '>', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream), ), ) } if (q != null && q.side < 0) { term.push( VersionRange.anchor( '<', new ExtendedVersion(cmp_flavor, q.upstream, q.downstream), ), ) } if (q != null && q.side >= 0) { term.push( VersionRange.anchor( '<=', new ExtendedVersion(cmp_flavor, q.upstream, q.downstream), ), ) } } if (term.length == 0) { term.push(VersionRange.flavor(cmp_flavor)) } sum_terms.push(VersionRange.and(...term)) } } return VersionRange.or(...sum_terms) } } /** * Represents a parsed version range expression used to match against {@link Version} or {@link ExtendedVersion} values. * * Version ranges support standard comparison operators (`=`, `>`, `<`, `>=`, `<=`, `!=`), * caret (`^`) and tilde (`~`) ranges, boolean logic (`&&`, `||`, `!`), and flavor matching (`#flavor`). * * @example * ```ts * const range = VersionRange.parse(">=1.0.0:0 && <2.0.0:0") * const version = ExtendedVersion.parse("1.5.0:0") * console.log(range.satisfiedBy(version)) // true * * // Combine ranges with boolean logic * const combined = VersionRange.and( * VersionRange.parse(">=1.0:0"), * VersionRange.parse("<3.0:0"), * ) * * // Match a specific flavor * const flavored = VersionRange.parse("#bitcoin") * ``` */ export class VersionRange { constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {} toStringParens(parent: 'And' | 'Or' | 'Not') { let needs = true switch (this.atom.type) { case 'And': case 'Or': needs = parent != this.atom.type break case 'Anchor': case 'Any': case 'None': needs = parent == 'Not' break case 'Not': case 'Flavor': needs = false break } if (needs) { return '(' + this.toString() + ')' } else { return this.toString() } } /** Serializes this version range back to its canonical string representation. */ toString(): string { switch (this.atom.type) { case 'Anchor': return `${this.atom.operator}${this.atom.version}` case 'And': return `${this.atom.left.toStringParens(this.atom.type)} && ${this.atom.right.toStringParens(this.atom.type)}` case 'Or': return `${this.atom.left.toStringParens(this.atom.type)} || ${this.atom.right.toStringParens(this.atom.type)}` case 'Not': return `!${this.atom.value.toStringParens(this.atom.type)}` case 'Flavor': return this.atom.flavor == null ? `#` : `#${this.atom.flavor}` case 'Any': return '*' case 'None': return '!' } } private static parseAtom(atom: P.VersionRangeAtom): VersionRange { switch (atom.type) { case 'Not': return new VersionRange({ type: 'Not', value: VersionRange.parseAtom(atom.value), }) case 'Parens': return VersionRange.parseRange(atom.expr) case 'Anchor': return new VersionRange({ type: 'Anchor', operator: atom.operator || '^', version: new ExtendedVersion( atom.version.flavor, new Version( atom.version.upstream.number, atom.version.upstream.prerelease, ), new Version( atom.version.downstream.number, atom.version.downstream.prerelease, ), ), }) case 'Flavor': return VersionRange.flavor(atom.flavor) default: return new VersionRange(atom) } } private static parseRange(range: P.VersionRange): VersionRange { let result = VersionRange.parseAtom(range[0]) for (const next of range[1]) { switch (next[1]?.[0]) { case '||': result = new VersionRange({ type: 'Or', left: result, right: VersionRange.parseAtom(next[2]), }) break case '&&': default: result = new VersionRange({ type: 'And', left: result, right: VersionRange.parseAtom(next[2]), }) break } } return result } /** * Parses a version range string into a `VersionRange`. * * @param range - A version range expression, e.g. `">=1.0.0:0 && <2.0.0:0"`, `"^1.2:0"`, `"*"` * @returns The parsed `VersionRange` * @throws If the string is not a valid version range expression */ static parse(range: string): VersionRange { return VersionRange.parseRange( P.parse(range, { startRule: 'VersionRange' }), ) } /** * Creates a version range from a comparison operator and an {@link ExtendedVersion}. * * @param operator - One of `"="`, `">"`, `"<"`, `">="`, `"<="`, `"!="`, `"^"`, `"~"` * @param version - The version to compare against */ static anchor(operator: P.CmpOp, version: ExtendedVersion) { return new VersionRange({ type: 'Anchor', operator, version }) } /** * Creates a version range that matches only versions with the specified flavor. * * @param flavor - The flavor string to match, or `null` for the default (unflavored) variant */ static flavor(flavor: string | null) { return new VersionRange({ type: 'Flavor', flavor }) } /** * Parses a legacy "emver" format version range string. * * @param range - A version range in the legacy emver format * @returns The parsed `VersionRange` */ static parseEmver(range: string): VersionRange { return VersionRange.parseRange( P.parse(range, { startRule: 'EmverVersionRange' }), ) } /** Returns the intersection of this range with another (logical AND). */ and(right: VersionRange) { return new VersionRange({ type: 'And', left: this, right }) } /** Returns the union of this range with another (logical OR). */ or(right: VersionRange) { return new VersionRange({ type: 'Or', left: this, right }) } /** Returns the negation of this range (logical NOT). */ not() { return new VersionRange({ type: 'Not', value: this }) } /** * Returns the logical AND (intersection) of multiple version ranges. * Short-circuits on `none()` and skips `any()`. */ static and(...xs: Array) { let y = VersionRange.any() for (let x of xs) { if (x.atom.type == 'Any') { continue } if (x.atom.type == 'None') { return x } if (y.atom.type == 'Any') { y = x } else { y = new VersionRange({ type: 'And', left: y, right: x }) } } return y } /** * Returns the logical OR (union) of multiple version ranges. * Short-circuits on `any()` and skips `none()`. */ static or(...xs: Array) { let y = VersionRange.none() for (let x of xs) { if (x.atom.type == 'None') { continue } if (x.atom.type == 'Any') { return x } if (y.atom.type == 'None') { y = x } else { y = new VersionRange({ type: 'Or', left: y, right: x }) } } return y } /** Returns a version range that matches all versions (wildcard `*`). */ static any() { return new VersionRange({ type: 'Any' }) } /** Returns a version range that matches no versions (`!`). */ static none() { return new VersionRange({ type: 'None' }) } /** * Returns `true` if the given version satisfies this range. * * @param version - A {@link Version} or {@link ExtendedVersion} to test */ satisfiedBy(version: Version | ExtendedVersion) { return version.satisfies(this) } tables(): VersionRangeTables { switch (this.atom.type) { case 'Anchor': switch (this.atom.operator) { case '=': // `=1.2.3` is equivalent to `>=1.2.3 && <=1.2.4 && #flavor` return VersionRangeTable.and( VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.cmp(this.atom.version, 1, true, false), ) case '>': return VersionRangeTable.cmp(this.atom.version, 1, false, true) case '<': return VersionRangeTable.cmp(this.atom.version, -1, true, false) case '>=': return VersionRangeTable.cmp(this.atom.version, -1, false, true) case '<=': return VersionRangeTable.cmp(this.atom.version, 1, true, false) case '!=': // `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)` // **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor` return VersionRangeTable.not( VersionRangeTable.and( VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.cmp(this.atom.version, 1, true, false), ), ) case '^': // `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor` return VersionRangeTable.and( VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.cmp( this.atom.version.incrementMajor(), -1, true, false, ), ) case '~': // `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor` return VersionRangeTable.and( VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.cmp( this.atom.version.incrementMinor(), -1, true, false, ), ) } case 'Flavor': return VersionRangeTable.eqFlavor(this.atom.flavor) case 'Not': return VersionRangeTable.not(this.atom.value.tables()) case 'And': return VersionRangeTable.and( this.atom.left.tables(), this.atom.right.tables(), ) case 'Or': return VersionRangeTable.or( this.atom.left.tables(), this.atom.right.tables(), ) case 'Any': return true case 'None': return false } } /** Returns `true` if any version exists that could satisfy this range. */ satisfiable(): boolean { return VersionRangeTable.collapse(this.tables()) !== false } /** Returns `true` if this range and `other` share at least one satisfying version. */ intersects(other: VersionRange): boolean { return VersionRange.and(this, other).satisfiable() } /** * Returns a canonical (simplified) form of this range using minterm expansion. * Useful for normalizing complex boolean expressions into a minimal representation. */ normalize(): VersionRange { return VersionRangeTable.minterms(this.tables()) } } /** * Represents a semantic version number with numeric segments and optional prerelease identifiers. * * Follows semver precedence rules: numeric segments are compared left-to-right, * and a version with prerelease identifiers has lower precedence than the same version without. * * @example * ```ts * const v = Version.parse("1.2.3") * console.log(v.toString()) // "1.2.3" * console.log(v.compare(Version.parse("1.3.0"))) // "less" * * const pre = Version.parse("2.0.0-beta.1") * console.log(pre.compare(Version.parse("2.0.0"))) // "less" (prerelease < release) * ``` */ export class Version { constructor( /** The numeric version segments (e.g. `[1, 2, 3]` for `"1.2.3"`). */ public number: number[], /** Optional prerelease identifiers (e.g. `["beta", 1]` for `"-beta.1"`). */ public prerelease: (string | number)[], ) {} /** Serializes this version to its string form (e.g. `"1.2.3"` or `"1.0.0-beta.1"`). */ toString(): string { return `${this.number.join('.')}${this.prerelease.length > 0 ? `-${this.prerelease.join('.')}` : ''}` } /** * Compares this version against another using semver precedence rules. * * @param other - The version to compare against * @returns `'greater'`, `'equal'`, or `'less'` */ compare(other: Version): 'greater' | 'equal' | 'less' { const numLen = Math.max(this.number.length, other.number.length) for (let i = 0; i < numLen; i++) { if ((this.number[i] || 0) > (other.number[i] || 0)) { return 'greater' } else if ((this.number[i] || 0) < (other.number[i] || 0)) { return 'less' } } if (this.prerelease.length === 0 && other.prerelease.length !== 0) { return 'greater' } else if (this.prerelease.length !== 0 && other.prerelease.length === 0) { return 'less' } const prereleaseLen = Math.max( this.prerelease.length, other.prerelease.length, ) for (let i = 0; i < prereleaseLen; i++) { if (typeof this.prerelease[i] === typeof other.prerelease[i]) { if (this.prerelease[i] > other.prerelease[i]) { return 'greater' } else if (this.prerelease[i] < other.prerelease[i]) { return 'less' } } else { switch (`${typeof this.prerelease[1]}:${typeof other.prerelease[i]}`) { case 'number:string': return 'less' case 'string:number': return 'greater' case 'number:undefined': case 'string:undefined': return 'greater' case 'undefined:number': case 'undefined:string': return 'less' } } } return 'equal' } /** * Compares two versions, returning a numeric value suitable for use with `Array.sort()`. * * @returns `-1` if less, `0` if equal, `1` if greater */ compareForSort(other: Version): -1 | 0 | 1 { switch (this.compare(other)) { case 'greater': return 1 case 'equal': return 0 case 'less': return -1 } } /** * Parses a version string into a `Version` instance. * * @param version - A semver-compatible string, e.g. `"1.2.3"` or `"1.0.0-beta.1"` * @throws If the string is not a valid version */ static parse(version: string): Version { const parsed = P.parse(version, { startRule: 'Version' }) return new Version(parsed.number, parsed.prerelease) } /** * Returns `true` if this version satisfies the given {@link VersionRange}. * Internally treats this as an unflavored {@link ExtendedVersion} with downstream `0`. */ satisfies(versionRange: VersionRange): boolean { return new ExtendedVersion(null, this, new Version([0], [])).satisfies( versionRange, ) } } /** * Represents an extended version with an optional flavor, an upstream version, and a downstream version. * * The format is `#flavor:upstream:downstream` (e.g. `#bitcoin:1.2.3:0`) or `upstream:downstream` * for unflavored versions. Flavors allow multiple variants of a package to coexist. * * - **flavor**: An optional string identifier for the variant (e.g. `"bitcoin"`, `"litecoin"`) * - **upstream**: The version of the upstream software being packaged * - **downstream**: The version of the StartOS packaging itself * * Versions with different flavors are incomparable (comparison returns `null`). * * @example * ```ts * const v = ExtendedVersion.parse("#bitcoin:1.2.3:0") * console.log(v.flavor) // "bitcoin" * console.log(v.upstream) // Version { number: [1, 2, 3] } * console.log(v.downstream) // Version { number: [0] } * console.log(v.toString()) // "#bitcoin:1.2.3:0" * * const range = VersionRange.parse(">=1.0.0:0") * console.log(v.satisfies(range)) // true * ``` */ export class ExtendedVersion { constructor( /** The flavor identifier (e.g. `"bitcoin"`), or `null` for unflavored versions. */ public flavor: string | null, /** The upstream software version. */ public upstream: Version, /** The downstream packaging version. */ public downstream: Version, ) {} /** Serializes this extended version to its string form (e.g. `"#bitcoin:1.2.3:0"` or `"1.0.0:1"`). */ toString(): string { return `${this.flavor ? `#${this.flavor}:` : ''}${this.upstream.toString()}:${this.downstream.toString()}` } /** * Compares this extended version against another. * * @returns `'greater'`, `'equal'`, `'less'`, or `null` if the flavors differ (incomparable) */ compare(other: ExtendedVersion): 'greater' | 'equal' | 'less' | null { if (this.flavor !== other.flavor) { return null } const upstreamCmp = this.upstream.compare(other.upstream) if (upstreamCmp !== 'equal') { return upstreamCmp } return this.downstream.compare(other.downstream) } /** * Lexicographic comparison — compares flavors alphabetically first, then versions. * Unlike {@link compare}, this never returns `null`: different flavors are ordered alphabetically. */ compareLexicographic(other: ExtendedVersion): 'greater' | 'equal' | 'less' { if ((this.flavor || '') > (other.flavor || '')) { return 'greater' } else if ((this.flavor || '') > (other.flavor || '')) { return 'less' } else { return this.compare(other)! } } /** * Returns a numeric comparison result suitable for use with `Array.sort()`. * Uses lexicographic ordering (flavors sorted alphabetically, then by version). */ compareForSort(other: ExtendedVersion): 1 | 0 | -1 { switch (this.compareLexicographic(other)) { case 'greater': return 1 case 'equal': return 0 case 'less': return -1 } } /** Returns `true` if this version is strictly greater than `other`. Returns `false` if flavors differ. */ greaterThan(other: ExtendedVersion): boolean { return this.compare(other) === 'greater' } /** Returns `true` if this version is greater than or equal to `other`. Returns `false` if flavors differ. */ greaterThanOrEqual(other: ExtendedVersion): boolean { return ['greater', 'equal'].includes(this.compare(other) as string) } /** Returns `true` if this version equals `other` (same flavor, upstream, and downstream). */ equals(other: ExtendedVersion): boolean { return this.compare(other) === 'equal' } /** Returns `true` if this version is strictly less than `other`. Returns `false` if flavors differ. */ lessThan(other: ExtendedVersion): boolean { return this.compare(other) === 'less' } /** Returns `true` if this version is less than or equal to `other`. Returns `false` if flavors differ. */ lessThanOrEqual(other: ExtendedVersion): boolean { return ['less', 'equal'].includes(this.compare(other) as string) } /** * Parses an extended version string into an `ExtendedVersion`. * * @param extendedVersion - A string like `"1.2.3:0"` or `"#bitcoin:1.0.0:0"` * @throws If the string is not a valid extended version */ static parse(extendedVersion: string): ExtendedVersion { const parsed = P.parse(extendedVersion, { startRule: 'ExtendedVersion' }) return new ExtendedVersion( parsed.flavor || null, new Version(parsed.upstream.number, parsed.upstream.prerelease), new Version(parsed.downstream.number, parsed.downstream.prerelease), ) } /** * Parses a legacy "emver" format extended version string. * * @param extendedVersion - A version string in the legacy emver format * @throws If the string is not a valid emver version (error message includes the input string) */ static parseEmver(extendedVersion: string): ExtendedVersion { try { const parsed = P.parse(extendedVersion, { startRule: 'Emver' }) return new ExtendedVersion( parsed.flavor || null, new Version(parsed.upstream.number, parsed.upstream.prerelease), new Version(parsed.downstream.number, parsed.downstream.prerelease), ) } catch (e) { if (e instanceof Error) { e.message += ` (${extendedVersion})` } throw e } } /** * Returns an ExtendedVersion with the Upstream major version version incremented by 1 * and sets subsequent digits to zero. * If no non-zero upstream digit can be found the last upstream digit will be incremented. */ incrementMajor(): ExtendedVersion { const majorIdx = this.upstream.number.findIndex((num: number) => num !== 0) const majorNumber = this.upstream.number.map((num, idx): number => { if (idx > majorIdx) { return 0 } else if (idx === majorIdx) { return num + 1 } return num }) const incrementedUpstream = new Version(majorNumber, []) const updatedDownstream = new Version([0], []) return new ExtendedVersion( this.flavor, incrementedUpstream, updatedDownstream, ) } /** * Returns an ExtendedVersion with the Upstream minor version version incremented by 1 * also sets subsequent digits to zero. * If no non-zero upstream digit can be found the last digit will be incremented. */ incrementMinor(): ExtendedVersion { const majorIdx = this.upstream.number.findIndex((num: number) => num !== 0) let minorIdx = majorIdx === -1 ? majorIdx : majorIdx + 1 const majorNumber = this.upstream.number.map((num, idx): number => { if (idx > minorIdx) { return 0 } else if (idx === minorIdx) { return num + 1 } return num }) const incrementedUpstream = new Version(majorNumber, []) const updatedDownstream = new Version([0], []) return new ExtendedVersion( this.flavor, incrementedUpstream, updatedDownstream, ) } /** * Returns a boolean indicating whether a given version satisfies the VersionRange * !( >= 1:1 <= 2:2) || <=#bitcoin:1.2.0-alpha:0 */ satisfies(versionRange: VersionRange): boolean { switch (versionRange.atom.type) { case 'Anchor': const otherVersion = versionRange.atom.version switch (versionRange.atom.operator) { case '=': return this.equals(otherVersion) case '>': return this.greaterThan(otherVersion) case '<': return this.lessThan(otherVersion) case '>=': return this.greaterThanOrEqual(otherVersion) case '<=': return this.lessThanOrEqual(otherVersion) case '!=': return !this.equals(otherVersion) case '^': const nextMajor = versionRange.atom.version.incrementMajor() if ( this.greaterThanOrEqual(otherVersion) && this.lessThan(nextMajor) ) { return true } else { return false } case '~': const nextMinor = versionRange.atom.version.incrementMinor() if ( this.greaterThanOrEqual(otherVersion) && this.lessThan(nextMinor) ) { return true } else { return false } } case 'Flavor': return versionRange.atom.flavor == this.flavor case 'And': return ( this.satisfies(versionRange.atom.left) && this.satisfies(versionRange.atom.right) ) case 'Or': return ( this.satisfies(versionRange.atom.left) || this.satisfies(versionRange.atom.right) ) case 'Not': return !this.satisfies(versionRange.atom.value) case 'Any': return true case 'None': return false } } } /** * Compile-time type-checking helper that validates an extended version string literal. * If the string is invalid, TypeScript will report a type error at the call site. * * @example * ```ts * testTypeExVer("1.2.3:0") // compiles * testTypeExVer("#bitcoin:1.0:0") // compiles * testTypeExVer("invalid") // type error * ``` */ export const testTypeExVer = (t: T & ValidateExVer) => t /** * Compile-time type-checking helper that validates a version string literal. * If the string is invalid, TypeScript will report a type error at the call site. * * @example * ```ts * testTypeVersion("1.2.3") // compiles * testTypeVersion("-3") // type error * ``` */ export const testTypeVersion = (t: T & ValidateVersion) => t function tests() { testTypeVersion('1.2.3') testTypeVersion('1') testTypeVersion('12.34.56') testTypeVersion('1.2-3') testTypeVersion('1-3') testTypeVersion('1-alpha') // @ts-expect-error testTypeVersion('-3') // @ts-expect-error testTypeVersion('1.2.3:1') // @ts-expect-error testTypeVersion('#cat:1:1') testTypeExVer('1.2.3:1.2.3') testTypeExVer('1.2.3.4.5.6.7.8.9.0:1') testTypeExVer('100:1') testTypeExVer('#cat:1:1') testTypeExVer('1.2.3.4.5.6.7.8.9.11.22.33:1') testTypeExVer('1-0:1') testTypeExVer('1-0:1') // @ts-expect-error testTypeExVer('1.2-3') // @ts-expect-error testTypeExVer('1-3') // @ts-expect-error testTypeExVer('1.2.3.4.5.6.7.8.9.0.10:1' as string) // @ts-expect-error testTypeExVer('1.-2:1') // @ts-expect-error testTypeExVer('1..2.3:3') }