eslint-plugin-unicorn-x
Version:
More than 100 powerful ESLint rules
553 lines (466 loc) • 13.7 kB
JavaScript
import {
getStaticValue,
isCommaToken,
hasSideEffect,
} from '@eslint-community/eslint-utils';
import {
getParenthesizedRange,
getParenthesizedText,
needsSemicolon,
isNodeMatches,
isMethodNamed,
hasOptionalChainElement,
} from './utils/index.js';
import {removeMethodCall} from './fix/index.js';
import {isLiteral} from './ast/index.js';
import {
isMethodCall,
memberExpressionHasObject,
memberExpressionHasProperty,
callExpressionHasArguments,
callExpressionHasSpread,
} from './ast/guards.js';
const ERROR_ARRAY_FROM = 'array-from';
const ERROR_ARRAY_CONCAT = 'array-concat';
const ERROR_ARRAY_SLICE = 'array-slice';
const ERROR_ARRAY_TO_SPLICED = 'array-to-spliced';
const ERROR_STRING_SPLIT = 'string-split';
const SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE = 'argument-is-spreadable';
const SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE =
'argument-is-not-spreadable';
const SUGGESTION_CONCAT_TEST_ARGUMENT = 'test-argument';
const SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS = 'spread-all-arguments';
const SUGGESTION_USE_SPREAD = 'use-spread';
const messages = {
[ERROR_ARRAY_FROM]: 'Prefer the spread operator over `Array.from(…)`.',
[ERROR_ARRAY_CONCAT]: 'Prefer the spread operator over `Array#concat(…)`.',
[ERROR_ARRAY_SLICE]: 'Prefer the spread operator over `Array#slice()`.',
[ERROR_ARRAY_TO_SPLICED]:
'Prefer the spread operator over `Array#toSpliced()`.',
[ERROR_STRING_SPLIT]: "Prefer the spread operator over `String#split('')`.",
[SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE]: 'First argument is an `array`.',
[SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE]:
'First argument is not an `array`.',
[SUGGESTION_CONCAT_TEST_ARGUMENT]:
'Test first argument with `Array.isArray(…)`.',
[SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS]: 'Spread all unknown arguments`.',
[SUGGESTION_USE_SPREAD]: 'Use `...` operator.',
};
const ignoredSliceCallee = ['arrayBuffer', 'blob', 'buffer', 'file', 'this'];
const isArrayLiteral = (node) => node.type === 'ArrayExpression';
const isArrayLiteralHasTrailingComma = (node, sourceCode) => {
if (node.elements.length === 0) {
return false;
}
return isCommaToken(sourceCode.getLastToken(node, 1));
};
function fixConcat(node, sourceCode, fixableArguments) {
const array = node.callee.object;
const concatCallArguments = node.arguments;
const arrayParenthesizedRange = getParenthesizedRange(array, sourceCode);
const arrayIsArrayLiteral = isArrayLiteral(array);
const arrayHasTrailingComma =
arrayIsArrayLiteral && isArrayLiteralHasTrailingComma(array, sourceCode);
const getArrayLiteralElementsText = (node, keepTrailingComma) => {
if (
!keepTrailingComma &&
isArrayLiteralHasTrailingComma(node, sourceCode)
) {
const start = sourceCode.getRange(node)[0] + 1;
const [end] = sourceCode.getRange(sourceCode.getLastToken(node, 1));
return sourceCode.text.slice(start, end);
}
return sourceCode.getText(node, -1, -1);
};
const getFixedText = () => {
const nonEmptyArguments = fixableArguments.filter(
({node, isArrayLiteral}) => !isArrayLiteral || node.elements.length > 0,
);
const lastArgument = nonEmptyArguments.at(-1);
let text = nonEmptyArguments
.map(({node, isArrayLiteral, isSpreadable, testArgument}) => {
if (isArrayLiteral) {
return getArrayLiteralElementsText(node, node === lastArgument.node);
}
let text = getParenthesizedText(node, sourceCode);
if (testArgument) {
return `...(Array.isArray(${text}) ? ${text} : [${text}])`;
}
if (isSpreadable) {
text = `...${text}`;
}
return text || ' ';
})
.join(', ');
if (!text) {
return '';
}
if (arrayIsArrayLiteral) {
if (array.elements.length > 0) {
text = ` ${text}`;
if (!arrayHasTrailingComma) {
text = `,${text}`;
}
if (
arrayHasTrailingComma &&
(!lastArgument.isArrayLiteral ||
!isArrayLiteralHasTrailingComma(lastArgument.node, sourceCode))
) {
text = `${text},`;
}
}
} else {
text = `, ${text}`;
}
return text;
};
function removeArguments(fixer) {
const [firstArgument] = concatCallArguments;
const lastArgument = concatCallArguments[fixableArguments.length - 1];
const [start] = getParenthesizedRange(firstArgument, sourceCode);
let [, end] = sourceCode.getRange(
sourceCode.getTokenAfter(lastArgument, isCommaToken),
);
const textAfter = sourceCode.text.slice(end);
const [leadingSpaces] = textAfter.match(/^\s*/);
end += leadingSpaces.length;
return fixer.removeRange([start, end]);
}
return function* (fixer) {
// Fixed code always starts with `[`
if (
!arrayIsArrayLiteral &&
needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')
) {
yield fixer.insertTextBefore(node, ';');
}
if (concatCallArguments.length - fixableArguments.length === 0) {
yield* removeMethodCall(fixer, node, sourceCode);
} else {
yield removeArguments(fixer);
}
const text = getFixedText();
if (arrayIsArrayLiteral) {
const closingBracketToken = sourceCode.getLastToken(array);
yield fixer.insertTextBefore(closingBracketToken, text);
} else {
// The array is already accessing `.concat`, there should not any case need add extra `()`
yield fixer.insertTextBeforeRange(arrayParenthesizedRange, '[...');
yield fixer.insertTextAfterRange(arrayParenthesizedRange, text);
yield fixer.insertTextAfterRange(arrayParenthesizedRange, ']');
}
};
}
const getConcatArgumentSpreadable = (node, scope) => {
if (node.type === 'SpreadElement') {
return;
}
if (isArrayLiteral(node)) {
return {node, isArrayLiteral: true};
}
const result = getStaticValue(node, scope);
if (!result) {
return;
}
const isSpreadable = Array.isArray(result.value);
return {node, isSpreadable};
};
function getConcatFixableArguments(argumentsList, scope) {
const fixableArguments = [];
for (const node of argumentsList) {
const result = getConcatArgumentSpreadable(node, scope);
if (result) {
fixableArguments.push(result);
} else {
break;
}
}
return fixableArguments;
}
function fixArrayFrom(node, sourceCode) {
const [object] = node.arguments;
function getObjectText() {
if (isArrayLiteral(object)) {
return sourceCode.getText(object);
}
const [start, end] = getParenthesizedRange(object, sourceCode);
const text = sourceCode.text.slice(start, end);
return `[...${text}]`;
}
return function* (fixer) {
// Fixed code always starts with `[`
if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) {
yield fixer.insertTextBefore(node, ';');
}
const objectText = getObjectText();
yield fixer.replaceText(node, objectText);
};
}
function methodCallToSpread(node, sourceCode) {
return function* (fixer) {
// Fixed code always starts with `[`
if (needsSemicolon(sourceCode.getTokenBefore(node), sourceCode, '[')) {
yield fixer.insertTextBefore(node, ';');
}
yield fixer.insertTextBefore(node, '[...');
yield fixer.insertTextAfter(node, ']');
// The array is already accessing `.slice` or `.split`, there should not any case need add extra `()`
yield* removeMethodCall(fixer, node, sourceCode);
};
}
function isClassName(node) {
if (node.type === 'MemberExpression') {
node = node.property;
}
if (node.type !== 'Identifier') {
return false;
}
const {name} = node;
return /^[A-Z]./.test(name) && name.toUpperCase() !== name;
}
function isNotArray(node, scope) {
if (
node.type === 'TemplateLiteral' ||
node.type === 'Literal' ||
node.type === 'BinaryExpression' ||
isClassName(node) ||
// `foo.join()`
(isMethodNamed(node, 'join') && node.arguments.length <= 1)
) {
return true;
}
const staticValue = getStaticValue(node, scope);
if (staticValue && !Array.isArray(staticValue.value)) {
return true;
}
return false;
}
const checkConcatCall = (node, context) => {
const {object} = node.callee;
const scope = context.sourceCode.getScope(object);
if (isNotArray(object, scope)) {
return;
}
const staticResult = getStaticValue(object, scope);
if (staticResult && !Array.isArray(staticResult.value)) {
return;
}
const problem = {
node: node.callee.property,
messageId: ERROR_ARRAY_CONCAT,
};
const fixableArguments = getConcatFixableArguments(node.arguments, scope);
if (fixableArguments.length > 0 || node.arguments.length === 0) {
problem.fix = fixConcat(node, context.sourceCode, fixableArguments);
context.report(problem);
return;
}
const [firstArgument, ...restArguments] = node.arguments;
if (firstArgument.type === 'SpreadElement') {
context.report(problem);
return;
}
const fixableArgumentsAfterFirstArgument = getConcatFixableArguments(
restArguments,
scope,
);
const suggestions = [
{
messageId: SUGGESTION_CONCAT_ARGUMENT_IS_SPREADABLE,
isSpreadable: true,
},
{
messageId: SUGGESTION_CONCAT_ARGUMENT_IS_NOT_SPREADABLE,
isSpreadable: false,
},
];
if (!hasSideEffect(firstArgument, context.sourceCode)) {
suggestions.push({
messageId: SUGGESTION_CONCAT_TEST_ARGUMENT,
testArgument: true,
});
}
problem.suggest = suggestions.map(
({messageId, isSpreadable, testArgument}) => ({
messageId,
fix: fixConcat(
node,
context.sourceCode,
// When apply suggestion, we also merge fixable arguments after the first one
[
{
node: firstArgument,
isSpreadable,
testArgument,
},
...fixableArgumentsAfterFirstArgument,
],
),
}),
);
if (
fixableArgumentsAfterFirstArgument.length < restArguments.length &&
restArguments.every(({type}) => type !== 'SpreadElement')
) {
problem.suggest.push({
messageId: SUGGESTION_CONCAT_SPREAD_ALL_ARGUMENTS,
fix: fixConcat(
node,
context.sourceCode,
node.arguments.map(
(node) =>
getConcatArgumentSpreadable(node, scope) || {
node,
isSpreadable: true,
},
),
),
});
}
context.report(problem);
};
const checkSliceCall = (node, context) => {
if (isNodeMatches(node.callee.object, ignoredSliceCallee)) {
return;
}
const [firstArgument] = node.arguments;
if (firstArgument && !isLiteral(firstArgument, 0)) {
return;
}
context.report({
node: node.callee.property,
messageId: ERROR_ARRAY_SLICE,
fix: methodCallToSpread(node, context.sourceCode),
});
};
const checkSplitCall = (node, context) => {
const [separator] = node.arguments;
if (!isLiteral(separator, '')) {
return;
}
const string = node.callee.object;
const staticValue = getStaticValue(
string,
context.sourceCode.getScope(string),
);
let hasSameResult = false;
if (staticValue) {
const {value} = staticValue;
if (typeof value !== 'string') {
return;
}
// eslint-disable-next-line unicorn-x/prefer-spread
const resultBySplit = value.split('');
const resultBySpread = [...value];
hasSameResult =
resultBySplit.length === resultBySpread.length &&
resultBySplit.every(
(character, index) => character === resultBySpread[index],
);
}
const problem = {
node: node.callee.property,
messageId: ERROR_STRING_SPLIT,
};
if (hasSameResult) {
problem.fix = methodCallToSpread(node, context.sourceCode);
} else {
problem.suggest = [
{
messageId: SUGGESTION_USE_SPREAD,
fix: methodCallToSpread(node, context.sourceCode),
},
];
}
context.report(problem);
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = (context) => {
const {sourceCode} = context;
return {
CallExpression(node) {
if (!isMethodCall(node)) {
return;
}
const hasSpread = callExpressionHasSpread(node);
// `Array.from()`
if (
memberExpressionHasObject(node.callee, 'Array') &&
memberExpressionHasProperty(node.callee, 'from') &&
callExpressionHasArguments(node, 1) &&
!hasSpread &&
node.optional !== true &&
node.callee.optional !== true &&
// Allow `Array.from({length})`
node.arguments[0].type !== 'ObjectExpression'
) {
context.report({
node,
messageId: ERROR_ARRAY_FROM,
fix: fixArrayFrom(node, sourceCode),
});
}
// `array.concat()`
if (
memberExpressionHasProperty(node.callee, 'concat') &&
node.optional !== true &&
node.callee.optional !== true
) {
checkConcatCall(node, context);
}
// `array.slice()`
if (
memberExpressionHasProperty(node.callee, 'slice') &&
callExpressionHasArguments(node, [0, 1]) &&
!hasSpread &&
node.optional !== true &&
node.callee.optional !== true &&
!isArrayLiteral(node.callee.object) &&
!hasOptionalChainElement(node.callee.object)
) {
checkSliceCall(node, context);
}
// `array.toSpliced()`
if (
memberExpressionHasProperty(node.callee, 'toSpliced') &&
callExpressionHasArguments(node, 0) &&
!hasSpread &&
node.optional !== true &&
node.callee.optional !== true &&
node.callee.object.type !== 'ArrayExpression'
) {
context.report({
node: node.callee.property,
messageId: ERROR_ARRAY_TO_SPLICED,
fix: methodCallToSpread(node, sourceCode),
});
}
// `string.split()`
if (
memberExpressionHasProperty(node.callee, 'split') &&
callExpressionHasArguments(node, 1) &&
!hasSpread &&
node.optional !== true &&
node.callee.optional !== true
) {
checkSplitCall(node, context);
}
},
};
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description:
"Prefer the spread operator over `Array.from(…)`, `Array#concat(…)`, `Array#{slice,toSpliced}()` and `String#split('')`.",
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
messages,
},
};
export default config;