import { gte, lt, lte, satisfies } from '@renovatebot/pep440'; import { parse as parseRange } from '@renovatebot/pep440/lib/specifier.js'; import { parse as parseVersion } from '@renovatebot/pep440/lib/version.js'; import { isTruthy } from '@sindresorhus/is'; import { logger } from '../../../logger/index.ts'; import { coerceArray } from '../../../util/array.ts'; import { regEx } from '../../../util/regex.ts'; import type { NewValueConfig } from '../types.ts'; const UserPolicyPrecisionMap = { Major: 0, Minor: 1, Micro: 2, Bug: 3, None: Infinity, } as const; const PrecisionUserPolicyMap = { 0: 'Major', 1: 'Minor', 2: 'Micro', 3: 'Bug', } as const; type UserPolicy = (typeof UserPolicyPrecisionMap)[keyof typeof UserPolicyPrecisionMap]; /** * Calculate current update range precision. * @param ranges A {@link Range} consists of current range * @returns A {@link UserPolicy} */ function getRangePrecision(ranges: Range[]): UserPolicy { const bound = coerceArray( parseVersion((ranges[1] || ranges[0]).version)?.release, ); let rangePrecision = -1; // range is defined by a single bound. // ie. <1.2.2.3, // >=7 if (ranges.length === 1) { rangePrecision = bound.length - 1; } // Range is defined by both upper and lower bounds. if (ranges.length === 2) { const lowerBound = coerceArray(parseVersion(ranges[0].version)?.release); rangePrecision = bound.findIndex((el, index) => el > lowerBound[index]); } // Tune down Major precision if followed by a zero if ( rangePrecision === UserPolicyPrecisionMap.Major && rangePrecision + 1 < bound.length && bound[rangePrecision + 1] === 0 ) { rangePrecision++; } // Could not calculate user precision // Default to the smallest possible // istanbul ignore next if (rangePrecision === -1) { rangePrecision = bound.length - 1; } const key = PrecisionUserPolicyMap[ rangePrecision as keyof typeof PrecisionUserPolicyMap ]; return UserPolicyPrecisionMap[key as keyof typeof UserPolicyPrecisionMap]; } /** * @param policy Required range precision * @param newVersion The newly accepted version * @param baseVersion Optional Current upper bound * @returns A string represents a future version upper bound. */ function getFutureVersion( policy: UserPolicy, newVersion: string, baseVersion?: string, ): number[] { const toRelease = coerceArray(parseVersion(newVersion)?.release); const baseRelease = coerceArray( parseVersion(baseVersion ?? newVersion)?.release, ); return baseRelease.map((_, index) => { const toPart = toRelease[index] ?? 0; if (index < policy) { return toPart; } if (index === policy) { return toPart + (baseVersion === undefined ? 0 : 1); } return 0; }); } interface Range { operator: string; prefix?: string; version: string; } export function getPinnedValue(newVersion: string): string { return `==${newVersion}`; } export function getNewValue({ currentValue, rangeStrategy, currentVersion, newVersion, isReplacement, }: NewValueConfig): string | null { let ranges: Range[]; let updatedRange: (string | null)[]; // no symbol: accept only that specific version specified if (currentValue === currentVersion || isReplacement) { return newVersion; } // Handle bare versions (e.g., "v0.7.15") that don't strictly equal // currentVersion due to normalization, treating them as pinned while // preserving the v-prefix. if (parseVersion(currentValue)) { const vPrefix = regEx(/^(?[vV])/).exec(currentValue); if (vPrefix) { return `${vPrefix.groups!.prefix}${newVersion}`; } return newVersion; } try { ranges = parseCurrentRange(currentValue); if (!ranges.length) { // an empty string is an allowed value for PEP440 range // it means get any version logger.warn({ currentValue }, 'Empty currentValue'); return currentValue; } } catch (err) { logger.warn({ currentValue, err }, 'Unexpected range error'); return null; } // return early if newVersion is excluded from range if ( ranges.some( (range) => range.operator === '!=' && range.version === newVersion, ) ) { logger.debug( `Cannot calculate new value as the newVersion:\`${newVersion}\` is excluded from range: \`${currentValue}\``, ); return null; } switch (rangeStrategy) { case 'auto': case 'replace': updatedRange = handleReplaceStrategy( { currentValue, rangeStrategy, currentVersion, newVersion, }, ranges, ); break; case 'widen': updatedRange = handleWidenStrategy( { currentValue, rangeStrategy, currentVersion, newVersion, }, ranges, ); break; case 'bump': updatedRange = handleBumpStrategy( { currentValue, rangeStrategy, currentVersion, newVersion, }, ranges, ); break; default: // Unsupported rangeStrategy // Valid rangeStrategy values are: bump, extend, pin, replace. // https://docs.renovatebot.com/modules/versioning/#pep440-versioning logger.debug( `Unsupported rangeStrategy: ${rangeStrategy}. Using "replace" instead.`, ); return getNewValue({ currentValue, rangeStrategy: 'replace', currentVersion, newVersion, }); } let result = updatedRange.filter(isTruthy).join(', '); if (result.includes(', ') && !currentValue.includes(', ')) { result = result.replace(regEx(/, /g), ','); } const checkedResult = checkRangeAndRemoveUnnecessaryRangeLimit( result, newVersion, ); // istanbul ignore if if (!satisfies(newVersion, checkedResult)) { // we failed at creating the range logger.warn( { result, newVersion, currentValue }, 'pep440: failed to calculate newValue', ); return null; } return checkedResult; } export function isLessThanRange(input: string, range: string): boolean { try { let invertResult = true; const results = range .split(',') .map((x) => x .replace(regEx(/\s*/g), '') .split(regEx(/(?~=|==|!=|<=|>=|<|>|===)/)) .slice(1), ) .map(([op, version]) => { if (['!=', '<=', '<'].includes(op)) { return true; } invertResult = false; if (['~=', '==', '>=', '==='].includes(op)) { return lt(input, version); } if (op === '>') { return lte(input, version); } // istanbul ignore next return false; }); const result = results.every((res) => res === true); return invertResult ? !result : result; } catch /* istanbul ignore next */ { return false; } } function parseCurrentRange(currentValue: string): Range[] { const ranges: Range[] = parseRange(currentValue); if (!ranges) { throw new TypeError('Invalid pep440 currentValue'); } if (ranges.some((range) => range.operator === '===')) { // the operator "===" is used for legacy non PEP440 versions throw new TypeError('PEP440 arbitrary equality (===) not supported'); } return ranges; } function handleLowerBound(range: Range, newVersion: string): string | null { // used to mark minimum supported version // lower the bound if the new version is lower than current range if (['>', '>='].includes(range.operator)) { if (lte(newVersion, range.version)) { // this looks like a rollback return `>=${newVersion}`; } // otherwise, treat it same as exclude return range.operator + range.version; } // istanbul ignore next return null; } function handleUpperBound(range: Range, newVersion: string): string | null { // this is used to exclude future versions if (range.operator === '<') { // if newVersion is that future version if (gte(newVersion, range.version)) { // now here things get tricky // we calculate the new future version const precision = getRangePrecision([range]); const futureVersion = getFutureVersion( precision, newVersion, range.version, ); return range.operator + futureVersion.join('.'); } // newVersion is in range, for other than "replace" strategies return range.operator + range.version; } // istanbul ignore next return null; } function updateRangeValue( { currentValue, newVersion }: NewValueConfig, range: Range, ): string | null { // used to exclude versions, // we assume that's for a good reason if (range.operator === '!=') { return range.operator + range.version; } // keep the .* suffix if (range.prefix) { const futureVersion = getFutureVersion( UserPolicyPrecisionMap.None, newVersion, range.version, ).join('.'); return `${range.operator}${futureVersion}.*`; } if (range.operator === '~=') { const baseVersion = coerceArray(parseVersion(range.version)?.release); const futureVersion = coerceArray(parseVersion(newVersion)?.release); const baseLen = baseVersion.length; const newVerLen = futureVersion.length; // trim redundant trailing version specifiers if (baseLen < newVerLen) { return ( range.operator + futureVersion.slice(0, baseVersion.length).join('.') ); } // pad with specifiers if (baseLen > newVerLen) { for (let i = baseLen - newVerLen - 1; i >= 0; i--) { futureVersion.push(0); } return range.operator + futureVersion.join('.'); } return range.operator + newVersion; } if (['==', '<='].includes(range.operator)) { if (lte(newVersion, range.version)) { return range.operator + range.version; } return range.operator + newVersion; } let output = handleUpperBound(range, newVersion); if (output) { // manged to update upperbound // no need to try anything else return output; } output = handleLowerBound(range, newVersion); if (output) { return output; } // unless PEP440 changes, this won't happen // istanbul ignore next logger.error( { newVersion, currentValue, range }, 'pep440: failed to process range', ); // istanbul ignore next return null; } /** * Checks for zero specifiers. * returns true if one of the bounds' length is < 3. * @param ranges A {@link Range} array. * @returns A boolean value * Used mainly for cosmetics for the rez versioning syntax. */ function hasZeroSpecifier(ranges: Range[]): boolean { return ranges.some((range) => { const release = parseVersion(range.version)?.release; return release && release.length < 3; }); } function trimTrailingZeros(numbers: number[]): number[] { let i = numbers.length - 1; while (numbers[i] === 0) { i--; } return numbers.slice(0, i + 1); } function divideCompatibleReleaseRange(currentRange: Range): Range[] { const currentVersionUpperBound = currentRange.version .split('.') .map((num) => parseInt(num, 10)); if (currentVersionUpperBound.length > 1) { currentVersionUpperBound.splice(-1); } currentVersionUpperBound[currentVersionUpperBound.length - 1] += 1; return [ { operator: '>=', version: currentRange.version }, { operator: '<', version: currentVersionUpperBound.join('.'), }, ]; } function handleWidenStrategy( { currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig, ranges: Range[], ): (string | null)[] { // newVersion is within range if (satisfies(newVersion, currentValue)) { return [currentValue]; } let rangePrecision = getRangePrecision(ranges); const trimZeros = hasZeroSpecifier(ranges); let newRanges: Range[] = []; if (ranges.length === 1 && ranges[0].operator === '~=') { // If range operator is '~=', divide the range into its logical equivalent. // Example: ~=1.0 --> >=1.0,<2 newRanges = divideCompatibleReleaseRange(ranges[0]); } else { newRanges = ranges; } return newRanges.map((range) => { // newVersion is over the upper bound if (range.operator === '<' && gte(newVersion, range.version)) { const upperBound = coerceArray(parseVersion(range.version)?.release); const len = upperBound.length; // Match the precision of the smallest specifier if other than 0 if (upperBound[len - 1] !== 0) { const key = PrecisionUserPolicyMap[ (len - 1) as keyof typeof PrecisionUserPolicyMap ]; rangePrecision = UserPolicyPrecisionMap[key as keyof typeof UserPolicyPrecisionMap]; } let futureVersion = getFutureVersion( rangePrecision, newVersion, range.version, ); if (trimZeros) { futureVersion = trimTrailingZeros(futureVersion); } return range.operator + futureVersion.join('.'); } // default return updateRangeValue( { currentValue, rangeStrategy, currentVersion, newVersion, }, range, ); }); } function handleReplaceStrategy( { currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig, ranges: Range[], ): (string | null)[] { // newVersion is within range if (satisfies(newVersion, currentValue)) { return [currentValue]; } const trimZeros = hasZeroSpecifier(ranges); return ranges.map((range) => { // newVersion is over the upper bound if (range.operator === '<' && gte(newVersion, range.version)) { const rangePrecision = getRangePrecision(ranges); let futureVersion = getFutureVersion( rangePrecision, newVersion, range.version, ); if (trimZeros) { futureVersion = trimTrailingZeros(futureVersion); } return range.operator + futureVersion.join('.'); } // handle lower bound if (['>', '>='].includes(range.operator)) { if (lte(newVersion, range.version)) { // this looks like a rollback return `>=${newVersion}`; } // update the lower bound to reflect the accepted new version const lowerBound = coerceArray(parseVersion(range.version)?.release); const rangePrecision = lowerBound.length - 1; let newBase = getFutureVersion(rangePrecision, newVersion); if (trimZeros) { newBase = trimTrailingZeros(newBase); } // trim last element of the newBase when new accepted version is out of range. // example: let new bound be >8.2.5 & newVersion be 8.2.5 // return value will be: >8.2 if ( range.operator === '>' && newVersion === newBase.join('.') && newBase.length > 1 ) { newBase.pop(); } return range.operator + newBase.join('.'); } // default return updateRangeValue( { currentValue, rangeStrategy, currentVersion, newVersion, }, range, ); }); } function handleBumpStrategy( { currentValue, rangeStrategy, currentVersion, newVersion }: NewValueConfig, ranges: Range[], ): (string | null)[] { return ranges.map((range) => { // bump lower bound to current new version if (range.operator === '>=') { return range.operator + newVersion; } return updateRangeValue( { currentValue, rangeStrategy, currentVersion, newVersion, }, range, ); }); } export function checkRangeAndRemoveUnnecessaryRangeLimit( rangeInput: string, newVersion: string, ): string { let newRange: string = rangeInput; if (rangeInput.includes(',')) { const newRes = rangeInput.split(','); if ( newRes[0].includes('.*') && newRes[0].includes('==') && newRes[1].includes('>=') && satisfies(newVersion, newRes[0]) ) { newRange = newRes[0]; } } else { return rangeInput; } return newRange; }