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}