eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
386 lines (332 loc) • 11.9 kB
JavaScript
import {isCommaToken, isArrowToken, isClosingParenToken} from '@eslint-community/eslint-utils';
import {isMethodCall, isEmptyObjectExpression} from './ast/index.js';
import {removeStatement, removeParentheses} from './fix/index.js';
import {
getNextNode,
getOnlyExpression,
getParentheses,
getParenthesizedRange,
getParenthesizedText,
hasCommentInRange,
hasTypeArguments,
isNodeMatchesNameOrPath,
isSameIdentifier,
needsSemicolon,
} from './utils/index.js';
import {isCallExpression} from './ast/call-or-new-expression.js';
import {
getForOfDeclarationPattern,
isPairPattern,
referencesVariable,
} from './shared/for-of-collection-loop.js';
const MESSAGE_ID_REDUCE = 'reduce';
const MESSAGE_ID_FUNCTION = 'function';
const MESSAGE_ID_LOOP = 'loop';
const messages = {
[MESSAGE_ID_REDUCE]: 'Prefer `Object.fromEntries()` over `Array#reduce()`.',
[MESSAGE_ID_FUNCTION]: 'Prefer `Object.fromEntries()` over `{{functionName}}()`.',
[MESSAGE_ID_LOOP]: 'Prefer `Object.fromEntries()` over a `for-of` loop.',
};
const isArrowFunctionCallback = node =>
node.type === 'ArrowFunctionExpression'
&& !node.async
&& node.params.length > 0
&& node.params[0].type === 'Identifier';
const isProperty = node =>
node.type === 'Property'
&& node.kind === 'init'
&& !node.method;
// A non-computed `__proto__: value` (or `'__proto__': value`) in an object literal sets the prototype, while `Object.fromEntries()` creates an own property.
const isPrototypeProperty = node =>
isProperty(node) && !node.computed && (node.key.name ?? node.key.value) === '__proto__';
const getOnlyLoopAssignmentExpression = loop => {
const expression = getOnlyExpression(loop.body);
if (expression?.type === 'AssignmentExpression') {
return expression;
}
};
const getForOfPairPattern = node => {
const pattern = getForOfDeclarationPattern(node);
if (pattern && isPairPattern(pattern)) {
return pattern;
}
};
const hasCommentInLoopFixRange = (declaration, loop, context) => {
const {sourceCode} = context;
const [start] = sourceCode.getRange(declaration);
const nextToken = sourceCode.getTokenAfter(loop);
const end = nextToken ? sourceCode.getRange(nextToken)[0] : sourceCode.text.length;
return hasCommentInRange(context, [start, end]);
};
// - `pairs.reduce(…, {})`
// - `pairs.reduce(…, Object.create(null))`
const isArrayReduceWithEmptyObject = node =>
isMethodCall(node, {
method: 'reduce',
argumentsLength: 2,
optionalCall: false,
optionalMember: false,
})
// Only `{}`, `Object.fromEntries()` always creates an object with `Object.prototype`, so a null-prototype accumulator like `Object.create(null)` cannot be replaced by it
&& isEmptyObjectExpression(node.arguments[1]);
const fixableArrayReduceCases = [
{
test: callExpression =>
isArrayReduceWithEmptyObject(callExpression)
// `() => Object.assign(object, {key})`
&& isArrowFunctionCallback(callExpression.arguments[0])
&& isMethodCall(callExpression.arguments[0].body, {
object: 'Object',
method: 'assign',
argumentsLength: 2,
optionalCall: false,
optionalMember: false,
})
&& callExpression.arguments[0].body.arguments[1].type === 'ObjectExpression'
&& callExpression.arguments[0].body.arguments[1].properties.length === 1
&& isProperty(callExpression.arguments[0].body.arguments[1].properties[0])
&& !isPrototypeProperty(callExpression.arguments[0].body.arguments[1].properties[0])
&& isSameIdentifier(callExpression.arguments[0].params[0], callExpression.arguments[0].body.arguments[0]),
getProperty: callback => callback.body.arguments[1].properties[0],
},
{
test: callExpression =>
isArrayReduceWithEmptyObject(callExpression)
// `() => ({...object, key})`
&& isArrowFunctionCallback(callExpression.arguments[0])
&& callExpression.arguments[0].body.type === 'ObjectExpression'
&& callExpression.arguments[0].body.properties.length === 2
&& callExpression.arguments[0].body.properties[0].type === 'SpreadElement'
&& isProperty(callExpression.arguments[0].body.properties[1])
&& !isPrototypeProperty(callExpression.arguments[0].body.properties[1])
&& isSameIdentifier(callExpression.arguments[0].params[0], callExpression.arguments[0].body.properties[0].argument),
getProperty: callback => callback.body.properties[1],
},
];
// `_.fromPairs(pairs)`
const lodashFromPairsFunctions = [
'_.fromPairs',
'lodash.fromPairs',
];
function fixReduceAssignOrSpread({context, callExpression, property}) {
const {sourceCode} = context;
// The initial value, with its parentheses and the comma before it
const getInitObjectRange = () => {
const initObject = callExpression.arguments[1];
const [, end] = getParenthesizedRange(initObject, context);
const [commaStart] = sourceCode.getRange(sourceCode.getTokenBefore(getParentheses(initObject, context)[0] ?? initObject));
return [commaStart, end];
};
function * removeFirstParameter(fixer) {
const parameters = callExpression.arguments[0].params;
const [firstParameter] = parameters;
const tokenAfter = sourceCode.getTokenAfter(firstParameter);
// Remove the comma and the gap after it, so `fn(a, b)` becomes `fn(b)` rather than `fn( b)`
if (isCommaToken(tokenAfter)) {
const [start] = sourceCode.getRange(sourceCode.getTokenAfter(tokenAfter));
yield fixer.removeRange([sourceCode.getRange(tokenAfter)[0], start]);
}
let shouldAddParentheses = false;
if (parameters.length === 1) {
const arrowToken = sourceCode.getTokenAfter(firstParameter, isArrowToken);
const tokenBeforeArrowToken = sourceCode.getTokenBefore(arrowToken);
if (!isClosingParenToken(tokenBeforeArrowToken)) {
shouldAddParentheses = true;
}
}
yield fixer.replaceText(firstParameter, shouldAddParentheses ? '()' : '');
}
const getKeyValueText = () => {
const {key, value} = property;
let keyText = getParenthesizedText(key, context);
const valueText = getParenthesizedText(value, context);
if (!property.computed && key.type === 'Identifier') {
keyText = `'${keyText}'`;
}
return {keyText, valueText};
};
function * replaceFunctionBody(fixer) {
const functionBody = callExpression.arguments[0].body;
const {keyText, valueText} = getKeyValueText();
yield fixer.replaceText(functionBody, `[${keyText}, ${valueText}]`);
yield removeParentheses(functionBody, fixer, context);
}
return function * (fixer, {abort}) {
// Rebuilding the callback body would drop any comment inside the callback.
if (sourceCode.getCommentsInside(callExpression.arguments[0]).length > 0) {
return abort();
}
// The initial value is removed along with the comma before it, which would drop a comment placed between them.
const initObjectRange = getInitObjectRange();
if (hasCommentInRange(context, initObjectRange)) {
return abort();
}
// Wrap `array.reduce()` with `Object.fromEntries()`
yield fixer.insertTextBefore(callExpression, 'Object.fromEntries(');
yield fixer.insertTextAfter(callExpression, ')');
// Switch `.reduce` to `.map`
yield fixer.replaceText(callExpression.callee.property, 'map');
// Remove empty object
yield fixer.removeRange(initObjectRange);
// Remove the first parameter
yield removeFirstParameter(fixer);
// Replace function body
yield replaceFunctionBody(fixer);
};
}
const matchesForOfLoop = (id, loopLeft, assignmentExpression) => {
if (
assignmentExpression.operator !== '='
|| assignmentExpression.left.type !== 'MemberExpression'
|| !assignmentExpression.left.computed
) {
return false;
}
const {object, property} = assignmentExpression.left;
const [key, value] = loopLeft.elements;
return isSameIdentifier(id, object)
&& isSameIdentifier(key, property)
&& isSameIdentifier(value, assignmentExpression.right);
};
const getForOfLoopProblem = (declaration, context) => {
if (
declaration.declarations.length !== 1
|| declaration.declarations[0].id.type !== 'Identifier'
|| !declaration.declarations[0].init
// Only `{}`, see `isArrayReduceWithEmptyObject`
|| !isEmptyObjectExpression(declaration.declarations[0].init)
) {
return;
}
const [declarator] = declaration.declarations;
const {id, init} = declarator;
const loop = getNextNode(declaration, context);
if (loop?.type !== 'ForOfStatement' || loop.await) {
return;
}
const loopLeft = getForOfPairPattern(loop.left);
if (!loopLeft || loopLeft.elements.some(element => isSameIdentifier(element, id))) {
return;
}
const variable = context.sourceCode.getDeclaredVariables(declarator)[0];
if (
variable
&& referencesVariable(variable, loop.right, context)
) {
return;
}
const assignmentExpression = getOnlyLoopAssignmentExpression(loop);
if (
!assignmentExpression
|| !matchesForOfLoop(id, loopLeft, assignmentExpression)
) {
return;
}
return {
node: loop,
messageId: MESSAGE_ID_LOOP,
* fix(fixer, {abort}) {
if (hasCommentInLoopFixRange(declaration, loop, context)) {
return abort();
}
// The `for…of` statement disappears, so the declaration becomes an expression statement the next statement could continue
const tokenAfter = context.sourceCode.getTokenAfter(loop);
if (
tokenAfter
&& needsSemicolon(context.sourceCode.getLastToken(declaration), context, tokenAfter.value)
) {
yield fixer.insertTextAfter(declaration, ';');
}
yield fixer.replaceText(init, `Object.fromEntries(${getParenthesizedText(loop.right, context)})`);
yield removeStatement(loop, context, fixer);
},
};
};
/**
@param {import('eslint').Rule.RuleContext} context
*/
function create(context) {
const {sourceCode} = context;
const {functions: configFunctions} = context.options[0];
const functions = [...configFunctions, ...lodashFromPairsFunctions];
context.on('VariableDeclaration', declaration => getForOfLoopProblem(declaration, context));
context.on('CallExpression', function * (callExpression) {
for (const {test, getProperty} of fixableArrayReduceCases) {
if (!test(callExpression)) {
continue;
}
const [callbackFunction] = callExpression.arguments;
const [firstParameter] = callbackFunction.params;
const variables = sourceCode.getDeclaredVariables(callbackFunction);
const firstParameterVariable = variables.find(variable => variable.identifiers.length === 1 && variable.identifiers[0] === firstParameter);
if (!firstParameterVariable || firstParameterVariable.references.length !== 1) {
continue;
}
const problem = {
node: callExpression.callee.property,
messageId: MESSAGE_ID_REDUCE,
};
if (!hasTypeArguments(callExpression)) {
problem.fix = fixReduceAssignOrSpread({
context,
callExpression,
property: getProperty(callbackFunction),
});
}
yield problem;
}
if (!isCallExpression(callExpression, {
argumentsLength: 1,
optional: false,
})) {
return;
}
const functionNode = callExpression.callee;
for (const nameOrPath of functions) {
const functionName = nameOrPath.trim();
if (isNodeMatchesNameOrPath(functionNode, functionName)) {
yield {
node: functionNode,
messageId: MESSAGE_ID_FUNCTION,
data: {functionName},
fix: fixer => fixer.replaceText(functionNode, 'Object.fromEntries'),
};
}
}
});
}
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
functions: {
type: 'array',
items: {type: 'string'},
uniqueItems: true,
description: 'Additional functions to check.',
},
},
},
];
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer using `Object.fromEntries(…)` to transform a list of key-value pairs into an object.',
recommended: 'unopinionated',
},
fixable: 'code',
schema,
defaultOptions: [{functions: []}],
messages,
languages: [
'js/js',
],
},
};
export default config;