import { isString } from '@sindresorhus/is'; import { coerceArray } from '../../../util/array.ts'; import { regEx } from '../../../util/regex.ts'; export const TokenType = { Number: 1, String: 2, }; interface Token { type: number; val: string | number; } function iterateChars( str: string, cb: (p: string | null, n: string | null) => void, ): void { let prev = null; let next = null; for (let i = 0; i < str.length; i += 1) { next = str.charAt(i); cb(prev, next); prev = next; } cb(prev, null); } function isSeparator(char: string): boolean { return regEx(/^[-._+]$/i).test(char); } function isDigit(char: string): boolean { return regEx(/^\d$/).test(char); } function isLetter(char: string): boolean { return !isSeparator(char) && !isDigit(char); } function isTransition(prevChar: string, nextChar: string): boolean { return ( (isDigit(prevChar) && isLetter(nextChar)) || (isLetter(prevChar) && isDigit(nextChar)) ); } export function tokenize(versionStr: string): Token[] | null { let result: Token[] | null = []; let currentVal = ''; function yieldToken(): void { if (result) { const val = currentVal; if (regEx(/^\d+$/).test(val)) { result.push({ type: TokenType.Number, val: parseInt(val, 10), }); } else { result.push({ type: TokenType.String, val, }); } } } iterateChars(versionStr, (prevChar, nextChar) => { if (nextChar === null) { yieldToken(); } else if (isSeparator(nextChar)) { if (prevChar && !isSeparator(prevChar)) { yieldToken(); currentVal = ''; } else { result = null; } } else if (prevChar !== null && isTransition(prevChar, nextChar)) { yieldToken(); currentVal = nextChar; } else { currentVal = currentVal.concat(nextChar); } }); return result; } export const QualifierRank = { Dev: -1, Default: 0, RC: 1, Snapshot: 2, Final: 3, GA: 4, Release: 5, SP: 6, } as const; export function qualifierRank(input: string): number { const val = input.toLowerCase(); if (val === 'dev') { return QualifierRank.Dev; } if (val === 'rc' || val === 'cr') { return QualifierRank.RC; } if (val === 'snapshot') { return QualifierRank.Snapshot; } if (val === 'ga') { return QualifierRank.GA; } if (val === 'final') { return QualifierRank.Final; } if (val === 'release' || val === 'latest' || val === 'sr') { return QualifierRank.Release; } if (val === 'sp') { return QualifierRank.SP; } return QualifierRank.Default; } function stringTokenCmp(left: string, right: string): number { const leftRank = qualifierRank(left); const rightRank = qualifierRank(right); if (leftRank === 0 && rightRank === 0) { if (left < right) { return -1; } if (left > right) { return 1; } } else { if (leftRank < rightRank) { return -1; } if (leftRank > rightRank) { return 1; } } return 0; } function tokenCmp(left: Token | null, right: Token | null): number { if (left === null) { if (right?.type === TokenType.String) { return 1; } return -1; } if (right === null) { if (left.type === TokenType.String) { return -1; } return 1; } if (left.type === TokenType.Number && right.type === TokenType.Number) { if (left.val < right.val) { return -1; } if (left.val > right.val) { return 1; } } else if (isString(left.val) && isString(right.val)) { return stringTokenCmp(left.val, right.val); } else if (right.type === TokenType.Number) { return -1; } else if (left.type === TokenType.Number) { return 1; } return 0; } export function compare(left: string, right: string): number { const leftTokens = coerceArray(tokenize(left)); const rightTokens = coerceArray(tokenize(right)); const length = Math.max(leftTokens.length, rightTokens.length); for (let idx = 0; idx < length; idx += 1) { const leftToken = leftTokens[idx] || null; const rightToken = rightTokens[idx] || null; const cmpResult = tokenCmp(leftToken, rightToken); if (cmpResult !== 0) { return cmpResult; } } return 0; } export function parse(input: string): Token[] | null { if (!input) { return null; } if (!regEx(/^[-._+a-zA-Z0-9]+$/i).test(input)) { return null; } if (regEx(/^latest\.?/i).test(input)) { return null; } const tokens = tokenize(input); // istanbul ignore if: should not happen if (!tokens?.length) { return null; } return tokens; } export function isVersion(input: string): boolean { return !!parse(input); } interface PrefixRange { tokens: Token[]; } type RangeBound = 'inclusive' | 'exclusive'; interface MavenBasedRange { leftBound: RangeBound; leftBoundStr: string; leftVal: string | null; separator: string; rightBound: RangeBound; rightBoundStr: string; rightVal: string | null; // The existence of preferredVal implies the "strictly" keyword "!!" // leading up to preferredVal: "!![preferred version]" preferredVal: string | null; } export function parsePrefixRange(input: string): PrefixRange | null { if (!input) { return null; } if (input.trim() === '+') { return { tokens: [] }; } const postfixRegex = regEx(/[^-._+][-._]\+$/); if (postfixRegex.test(input)) { const prefixValue = input.replace(regEx(/[-._]\+$/), ''); const tokens = tokenize(prefixValue); if (tokens) { return { tokens }; } } return null; } const mavenBasedRangeRegex = regEx( /^(?[[\](]\s*)(?[-._+a-zA-Z0-9]*?)(?\s*,\s*)(?[-._+a-zA-Z0-9]*?)(?\s*[[\])])(?:!!(?[-._+a-zA-Z0-9]+))?$/, ); export function parseMavenBasedRange(input: string): MavenBasedRange | null { if (!input) { return null; } const matchGroups = mavenBasedRangeRegex.exec(input)?.groups; if (!matchGroups) { return null; } const { leftBoundStr, separator, rightBoundStr } = matchGroups; const leftVal = matchGroups.leftVal || null; const rightVal = matchGroups.rightVal || null; const preferredVal = matchGroups.preferredVal || null; const isVersionLeft = isString(leftVal) && isVersion(leftVal); const isVersionRight = isString(rightVal) && isVersion(rightVal); if ( (leftVal !== null && !isVersionLeft) || (rightVal !== null && !isVersionRight) ) { return null; } if ( isVersionLeft && isVersionRight && leftVal && rightVal && compare(leftVal, rightVal) === 1 ) { return null; } const leftBound = leftBoundStr.trim() === '[' ? 'inclusive' : 'exclusive'; const rightBound = rightBoundStr.trim() === ']' ? 'inclusive' : 'exclusive'; return { leftBound, leftBoundStr, leftVal, separator, rightBound, rightBoundStr, rightVal, preferredVal, }; } interface SingleVersionRange { val: string; } const singleVersionRangeRegex = regEx(/^\[\s*(?[-._+a-zA-Z0-9]*?)\s*\]$/); export function parseSingleVersionRange( input: string, ): SingleVersionRange | null { const matchGroups = singleVersionRangeRegex.exec(input)?.groups; if (!matchGroups) { return null; } const { val } = matchGroups; if (!isVersion(val)) { return null; } return { val }; } export function isValid(str: string): boolean { if (!str) { return false; } return ( isVersion(str) || !!parsePrefixRange(str) || !!parseMavenBasedRange(str) || !!parseSingleVersionRange(str) ); }