Files
renovate/lib/modules/versioning/gradle/compare.ts
Sebastian Poxhofer 97327c3d28 refactor(linters): add prefer-coerce-array rule (#45492)
* refactor(linters): add prefer-coerce-array rule

Refs #45217

`x ?? []` and `coerceArray(x)` say the same thing, but only the second
one is searchable and consistent. The rule flags a `??` whose default is
a bare `[]` literal; a non-empty default or an asserted `([] as Foo[])`
still passes.

Applying it across `lib/` surfaced three spots the helper cannot cover,
which keep `?? []` with a disable comment or a narrower fix:

- `package-files.ts` destructures a tuple, which `coerceArray()` widens
- `generate.spec.ts` defaults `matchAll()`, an iterator rather than an array
- `mise/upgradeable-tooling.ts` declares a `Record` return, so its
  `?? []` fallback was wrong to begin with and becomes `coerceObject()`

* refactor(platform/github): hoist the GHE version header lookup

`Object.entries(...).find()` returns a tuple, and defaulting it with
`?? []` only to destructure the second element reads as an array
default when it is not one. Name the found entry instead, which drops
the `?? []` rather than routing a tuple through `coerceArray()`.
2026-08-28 09:27:58 +00:00

348 lines
7.5 KiB
TypeScript

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(
/^(?<leftBoundStr>[[\](]\s*)(?<leftVal>[-._+a-zA-Z0-9]*?)(?<separator>\s*,\s*)(?<rightVal>[-._+a-zA-Z0-9]*?)(?<rightBoundStr>\s*[[\])])(?:!!(?<preferredVal>[-._+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*(?<val>[-._+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)
);
}