mirror of https://github.com/renovatebot/renovate
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import is from '@sindresorhus/is';
|
|
import semver from 'semver';
|
|
import { parseRange } from 'semver-utils';
|
|
import { logger } from '../../../logger';
|
|
import type { RangeStrategy } from '../../../types/versioning';
|
|
import { regEx } from '../../../util/regex';
|
|
import { api as npm } from '../npm';
|
|
import type { NewValueConfig, VersioningApi } from '../types';
|
|
|
|
export const id = 'composer';
|
|
export const displayName = 'Composer';
|
|
export const urls = [
|
|
'https://getcomposer.org/doc/articles/versions.md',
|
|
'https://packagist.org/packages/composer/semver',
|
|
'https://madewithlove.be/tilde-and-caret-constraints/',
|
|
'https://semver.mwl.be',
|
|
];
|
|
export const supportsRanges = true;
|
|
export const supportedRangeStrategies: RangeStrategy[] = [
|
|
'bump',
|
|
'widen',
|
|
'pin',
|
|
'replace',
|
|
'update-lockfile',
|
|
];
|
|
|
|
function getVersionParts(input: string): [string, string] {
|
|
const versionParts = input.split('-');
|
|
if (versionParts.length === 1) {
|
|
return [input, ''];
|
|
}
|
|
|
|
return [versionParts[0], '-' + versionParts[1]];
|
|
}
|
|
|
|
function padZeroes(input: string): string {
|
|
const [output, stability] = getVersionParts(input);
|
|
|
|
const sections = output.split('.');
|
|
while (sections.length < 3) {
|
|
sections.push('0');
|
|
}
|
|
return sections.join('.') + stability;
|
|
}
|
|
|
|
function convertStabilityModifier(input: string): string {
|
|
// Handle stability modifiers.
|
|
const versionParts = input.split('@');
|
|
if (versionParts.length === 1) {
|
|
return input;
|
|
}
|
|
|
|
// 1.0@beta2 to 1.0-beta.2
|
|
const stability = versionParts[1].replace(
|
|
regEx(/(?:^|\s)(beta|alpha|rc)([1-9][0-9]*)(?: |$)/gi),
|
|
'$1.$2',
|
|
);
|
|
|
|
// If there is a stability part, npm semver expects the version
|
|
// to be full
|
|
return padZeroes(versionParts[0]) + '-' + stability;
|
|
}
|
|
|
|
function normalizeVersion(input: string): string {
|
|
let output = input;
|
|
output = output.replace(regEx(/(^|>|>=|\^|~)v/i), '$1');
|
|
return convertStabilityModifier(output);
|
|
}
|
|
|
|
/**
|
|
* @param versions Version list in any format, it recognizes the specific patch format x.x.x-pXX
|
|
* @param range Range in composer format
|
|
* @param minMode If true, it will calculate minSatisfyingVersion, if false, it calculates the maxSatisfyingVersion
|
|
* @returns min or max satisfyingVersion from the input
|
|
*/
|
|
function calculateSatisfyingVersionIntenal(
|
|
versions: string[],
|
|
range: string,
|
|
minMode: boolean,
|
|
): string | null {
|
|
// Because composer -p versions are considered stable, we have to remove the suffix for the npm.XXX functions.
|
|
const versionsMapped = versions.map((x) => {
|
|
return {
|
|
origianl: x,
|
|
cleaned: removeComposerSpecificPatchPart(x),
|
|
npmVariant: composer2npm(removeComposerSpecificPatchPart(x)[0]),
|
|
};
|
|
});
|
|
|
|
const npmVersions = versionsMapped.map((x) => x.npmVariant);
|
|
const npmVersion = minMode
|
|
? npm.minSatisfyingVersion(npmVersions, composer2npm(range))
|
|
: npm.getSatisfyingVersion(npmVersions, composer2npm(range));
|
|
|
|
if (!npmVersion) {
|
|
return null;
|
|
}
|
|
|
|
// After we find the npm versions, we select from them back in the mapping the possible patches.
|
|
const candidates = versionsMapped
|
|
.filter((x) => x.npmVariant === npmVersion)
|
|
.sort((a, b) => (minMode ? 1 : -1) * sortVersions(a.origianl, b.origianl));
|
|
|
|
return candidates[0].origianl;
|
|
}
|
|
|
|
/**
|
|
* @param intput Version in any format, it recognizes the specific patch format x.x.x-pXX
|
|
* @returns If input contains the specific patch, it returns the input with removed the patch and true, otherwise it retunrs the same string and false.
|
|
*/
|
|
function removeComposerSpecificPatchPart(input: string): [string, boolean] {
|
|
// the regex is based on the original from composer implementation https://github.com/composer/semver/blob/fa1ec24f0ab1efe642671ec15c51a3ab879f59bf/src/VersionParser.php#L137
|
|
const pattern = /^v?\d+(\.\d+(\.\d+(\.\d+)?)?)?(?<suffix>-p[1-9]\d*)$/gi;
|
|
const match = pattern.exec(input);
|
|
|
|
return match
|
|
? [input.replace(match.groups!.suffix, ''), true]
|
|
: [input, false];
|
|
}
|
|
|
|
function composer2npm(input: string): string {
|
|
return input
|
|
.split(regEx(/\s*\|\|?\s*/g))
|
|
.map((part): string => {
|
|
const cleanInput = normalizeVersion(part);
|
|
if (npm.isVersion(cleanInput)) {
|
|
return cleanInput;
|
|
}
|
|
if (npm.isVersion(padZeroes(cleanInput))) {
|
|
return padZeroes(cleanInput);
|
|
}
|
|
const [versionId, stability] = getVersionParts(cleanInput);
|
|
let output = versionId;
|
|
|
|
// ~4 to ^4 and ~4.1 to ^4.1
|
|
output = output.replace(
|
|
regEx(/(?:^|\s)~([1-9][0-9]*(?:\.[0-9]*)?)(?: |$)/g),
|
|
'^$1',
|
|
);
|
|
// ~0.4 to >=0.4 <1
|
|
output = output.replace(
|
|
regEx(/(?:^|\s)~(0\.[1-9][0-9]*)(?: |$)/g),
|
|
'>=$1 <1',
|
|
);
|
|
|
|
// add extra digits to <8-DEV and <8.0-DEV
|
|
output = output
|
|
.replace(regEx(/^(<\d+(\.\d+)?)$/g), '$1.0')
|
|
.replace(regEx(/^(<\d+(\.\d+)?)$/g), '$1.0');
|
|
|
|
return output + stability;
|
|
})
|
|
.map((part) => part.replace(/([a-z])([0-9])/gi, '$1.$2'))
|
|
.join(' || ');
|
|
}
|
|
|
|
function equals(a: string, b: string): boolean {
|
|
return npm.equals(composer2npm(a), composer2npm(b));
|
|
}
|
|
|
|
function getMajor(version: string): number | null {
|
|
const semverVersion = semver.coerce(composer2npm(version));
|
|
return semverVersion ? npm.getMajor(semverVersion) : null;
|
|
}
|
|
|
|
function getMinor(version: string): number | null {
|
|
const semverVersion = semver.coerce(composer2npm(version));
|
|
return semverVersion ? npm.getMinor(semverVersion) : null;
|
|
}
|
|
|
|
function getPatch(version: string): number | null {
|
|
const semverVersion = semver.coerce(composer2npm(version));
|
|
|
|
// This returns only the numbers without the optional `-pXX` patch version supported by composer. Fixing that would require a bigger
|
|
// refactoring, because the API supports only numbers.
|
|
return semverVersion ? npm.getPatch(semverVersion) : null;
|
|
}
|
|
|
|
function isGreaterThan(a: string, b: string): boolean {
|
|
return sortVersions(a, b) === 1;
|
|
}
|
|
|
|
function isLessThanRange(version: string, range: string): boolean {
|
|
return !!npm.isLessThanRange?.(composer2npm(version), composer2npm(range));
|
|
}
|
|
|
|
function isSingleVersion(input: string): boolean {
|
|
return !!input && npm.isSingleVersion(composer2npm(input));
|
|
}
|
|
|
|
function isStable(version: string): boolean {
|
|
if (version) {
|
|
// Composer considers patches `-pXX` as stable: https://github.com/composer/semver/blob/fa1ec24f0ab1efe642671ec15c51a3ab879f59bf/src/VersionParser.php#L568 but npm not.
|
|
// In order to be able to use the standard npm.isStable function, we remove the potential patch version for the check.
|
|
const [withoutPatch] = removeComposerSpecificPatchPart(version);
|
|
return npm.isStable(composer2npm(withoutPatch));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function isValid(input: string): boolean {
|
|
return !!input && npm.isValid(composer2npm(input));
|
|
}
|
|
|
|
export function isVersion(input: string): boolean {
|
|
return !!input && npm.isVersion(composer2npm(input));
|
|
}
|
|
|
|
function matches(version: string, range: string): boolean {
|
|
return npm.matches(composer2npm(version), composer2npm(range));
|
|
}
|
|
|
|
function getSatisfyingVersion(
|
|
versions: string[],
|
|
range: string,
|
|
): string | null {
|
|
return calculateSatisfyingVersionIntenal(versions, range, false);
|
|
}
|
|
|
|
function minSatisfyingVersion(
|
|
versions: string[],
|
|
range: string,
|
|
): string | null {
|
|
return calculateSatisfyingVersionIntenal(versions, range, true);
|
|
}
|
|
|
|
function subset(subRange: string, superRange: string): boolean | undefined {
|
|
try {
|
|
return npm.subset!(composer2npm(subRange), composer2npm(superRange));
|
|
} catch (err) {
|
|
logger.trace({ err }, 'composer.subset error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getNewValue({
|
|
currentValue,
|
|
rangeStrategy,
|
|
currentVersion,
|
|
newVersion,
|
|
}: NewValueConfig): string | null {
|
|
if (rangeStrategy === 'pin') {
|
|
return newVersion;
|
|
}
|
|
if (rangeStrategy === 'update-lockfile') {
|
|
if (matches(newVersion, currentValue)) {
|
|
return currentValue;
|
|
}
|
|
return getNewValue({
|
|
currentValue,
|
|
rangeStrategy: 'replace',
|
|
currentVersion,
|
|
newVersion,
|
|
});
|
|
}
|
|
const currentMajor = currentVersion ? getMajor(currentVersion) : null;
|
|
const toMajor = getMajor(newVersion);
|
|
const toMinor = getMinor(newVersion);
|
|
let newValue: string | null = null;
|
|
if (isVersion(currentValue)) {
|
|
newValue = newVersion;
|
|
} else if (regEx(/^[~^](0\.[1-9][0-9]*)$/).test(currentValue)) {
|
|
const operator = currentValue.substring(0, 1);
|
|
// handle ~0.4 case first
|
|
if (toMajor === 0) {
|
|
// TODO: types (#22198)
|
|
newValue = `${operator}0.${toMinor!}`;
|
|
} else {
|
|
// TODO: types (#22198)
|
|
newValue = `${operator}${toMajor!}.0`;
|
|
}
|
|
} else if (regEx(/^[~^]([0-9]*)$/).test(currentValue)) {
|
|
// handle ~4 case
|
|
const operator = currentValue.substring(0, 1);
|
|
// TODO: types (#22198)
|
|
newValue = `${operator}${toMajor!}`;
|
|
} else if (
|
|
toMajor &&
|
|
regEx(/^[~^]([0-9]*(?:\.[0-9]*)?)$/).test(currentValue)
|
|
) {
|
|
const operator = currentValue.substring(0, 1);
|
|
if (rangeStrategy === 'bump') {
|
|
newValue = `${operator}${newVersion}`;
|
|
} else if (
|
|
(is.number(currentMajor) && toMajor > currentMajor) ||
|
|
!toMinor
|
|
) {
|
|
// handle ~4.1 case
|
|
newValue = `${operator}${toMajor}.0`;
|
|
} else {
|
|
newValue = `${operator}${toMajor}.${toMinor}`;
|
|
}
|
|
} else if (
|
|
currentVersion &&
|
|
npm.isVersion(padZeroes(normalizeVersion(newVersion))) &&
|
|
npm.isValid(normalizeVersion(currentValue)) &&
|
|
composer2npm(currentValue) === normalizeVersion(currentValue)
|
|
) {
|
|
newValue = npm.getNewValue({
|
|
currentValue: normalizeVersion(currentValue),
|
|
rangeStrategy,
|
|
currentVersion: padZeroes(normalizeVersion(currentVersion)),
|
|
newVersion: padZeroes(normalizeVersion(newVersion)),
|
|
});
|
|
}
|
|
|
|
if (rangeStrategy === 'widen' && matches(newVersion, currentValue)) {
|
|
newValue = currentValue;
|
|
} else {
|
|
const hasOr = currentValue.includes(' || ');
|
|
if (hasOr || rangeStrategy === 'widen') {
|
|
const splitValues = currentValue.split('||');
|
|
const lastValue = splitValues[splitValues.length - 1];
|
|
const replacementValue = getNewValue({
|
|
currentValue: lastValue.trim(),
|
|
rangeStrategy: 'replace',
|
|
currentVersion,
|
|
newVersion,
|
|
});
|
|
if (rangeStrategy === 'replace') {
|
|
newValue = replacementValue;
|
|
} else if (replacementValue) {
|
|
const parsedRange = parseRange(replacementValue);
|
|
const element = parsedRange[parsedRange.length - 1];
|
|
if (element.operator?.startsWith('<')) {
|
|
const splitCurrent = currentValue.split(element.operator);
|
|
splitCurrent.pop();
|
|
newValue = splitCurrent.join(element.operator) + replacementValue;
|
|
} else {
|
|
newValue = currentValue + ' || ' + replacementValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!newValue) {
|
|
logger.warn(
|
|
{ currentValue, rangeStrategy, currentVersion, newVersion },
|
|
'Unsupported composer value',
|
|
);
|
|
newValue = newVersion;
|
|
}
|
|
if (currentValue.split('.')[0].includes('v')) {
|
|
newValue = newValue.replace(regEx(/([0-9])/), 'v$1');
|
|
}
|
|
|
|
// Preserve original min-stability specifier
|
|
if (currentValue.includes('@')) {
|
|
newValue += '@' + currentValue.split('@')[1];
|
|
}
|
|
|
|
return newValue;
|
|
}
|
|
|
|
function sortVersions(a: string, b: string): number {
|
|
const [aWithoutPatch, aContainsPatch] = removeComposerSpecificPatchPart(a);
|
|
const [bWithoutPatch, bContainsPatch] = removeComposerSpecificPatchPart(b);
|
|
|
|
if (aContainsPatch === bContainsPatch) {
|
|
// If both [a and b] contain patch version or both [a and b] do not contain patch version, then npm comparison deliveres correct results
|
|
return npm.sortVersions(composer2npm(a), composer2npm(b));
|
|
} else if (
|
|
npm.equals(composer2npm(aWithoutPatch), composer2npm(bWithoutPatch))
|
|
) {
|
|
// If only one [a or b] contains patch version and the parts without patch versions are equal, then the version with patch is greater (this is the case where npm comparison fails)
|
|
return aContainsPatch ? 1 : -1;
|
|
} else {
|
|
// All other cases can be compared correctly by npm
|
|
return npm.sortVersions(composer2npm(a), composer2npm(b));
|
|
}
|
|
}
|
|
|
|
function isCompatible(version: string): boolean {
|
|
return isVersion(version);
|
|
}
|
|
|
|
export const api: VersioningApi = {
|
|
equals,
|
|
getMajor,
|
|
getMinor,
|
|
getPatch,
|
|
isCompatible,
|
|
isGreaterThan,
|
|
isLessThanRange,
|
|
isSingleVersion,
|
|
isStable,
|
|
isValid,
|
|
isVersion,
|
|
matches,
|
|
getSatisfyingVersion,
|
|
minSatisfyingVersion,
|
|
getNewValue,
|
|
sortVersions,
|
|
subset,
|
|
};
|
|
export default api;
|