UNPKG

eslint-plugin-unicorn

Version:
386 lines (332 loc) • 11.9 kB
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;