UNPKG

eslint-plugin-unicorn

Version:
340 lines (306 loc) • 8.57 kB
import {isCommaToken} from '@eslint-community/eslint-utils'; import {isIdentifierNamed, isMethodCall} from './ast/index.js'; import { containsSuspensionPoint, getCommentSafeProblem, getConciseArrowBodyText, getNextStatement, getOnlyExpression, hasTypeArguments, getParenthesizedRange, getParenthesizedText, isArray, isGlobalNameAvailable, isParenthesized, wouldRemoveComments, } from './utils/index.js'; import { getEmptyArrayDeclarator, getForOfBinding, getVariableTargetText, referencesVariable, } from './shared/for-of-collection-loop.js'; const MESSAGE_ID_ERROR = 'prefer-array-from-map/error'; const MESSAGE_ID_SUGGESTION = 'prefer-array-from-map/suggestion'; const messages = { [MESSAGE_ID_ERROR]: 'Pass the mapping function to `Array.from()` directly.', [MESSAGE_ID_SUGGESTION]: 'Use the `Array.from()` mapping function argument.', }; const isArrowFunctionWithSupportedParameters = node => ( node.type === 'ArrowFunctionExpression' && node.params.length <= 2 && node.params.every(parameter => parameter.type !== 'RestElement') ); const isFlatCallWithDefaultDepth = node => ( isMethodCall(node, { method: 'flat', optionalCall: false, optionalMember: false, }) && ( node.arguments.length === 0 || ( node.arguments.length === 1 && node.arguments[0].type === 'Literal' && node.arguments[0].raw === '1' ) ) ); const isFollowedByDefaultFlatCall = node => ( node.parent?.type === 'MemberExpression' && node.parent.object === node && node.parent.parent?.type === 'CallExpression' && node.parent.parent.callee === node.parent && isFlatCallWithDefaultDepth(node.parent.parent) ); const getArrayFromText = ({ iterable, parameters, body, context, }) => `Array.from(${getParenthesizedText(iterable, context)}, ${parameters} => ${getConciseArrowBodyText(body, context)})`; const getPushReplacement = ({ expression, binding, loop, arrayName, variable, context, }) => { const {sourceCode} = context; if ( !isMethodCall(expression, { method: 'push', argumentsLength: 1, optionalCall: false, optionalMember: false, }) || !isIdentifierNamed(expression.callee.object, arrayName) || referencesVariable(variable, expression.arguments[0], context) || containsSuspensionPoint(expression.arguments[0], sourceCode.visitorKeys) ) { return; } const [pushedNode] = expression.arguments; if (!binding.index) { if (isIdentifierNamed(pushedNode, binding.element.name)) { return; } return getArrayFromText({ iterable: loop.right, parameters: sourceCode.getText(binding.element), body: pushedNode, context, }); } if (!isMethodCall(loop.right, { method: 'entries', argumentsLength: 0, optionalCall: false, optionalMember: false, })) { return; } const entriesReceiver = loop.right.callee.object; return isArray(entriesReceiver, context) ? getArrayFromText({ iterable: entriesReceiver, parameters: `(${sourceCode.getText(binding.element)}, ${sourceCode.getText(binding.index)})`, body: pushedNode, context, }) : getArrayFromText({ iterable: loop.right, parameters: `([${sourceCode.getText(binding.index)}, ${sourceCode.getText(binding.element)}])`, body: pushedNode, context, }); }; const getAssignmentReplacement = ({ expression, binding, loop, arrayName, variable, context, }) => { if ( !binding.index || !isMethodCall(loop.right, { method: 'entries', argumentsLength: 0, optionalCall: false, optionalMember: false, }) || !isArray(loop.right.callee.object, context) || expression.type !== 'AssignmentExpression' || expression.operator !== '=' || expression.left.type !== 'MemberExpression' || !expression.left.computed || !isIdentifierNamed(expression.left.object, arrayName) || !isIdentifierNamed(expression.left.property, binding.index.name) || referencesVariable(variable, expression.right, context) || containsSuspensionPoint(expression.right, context.sourceCode.visitorKeys) ) { return; } const {sourceCode} = context; return getArrayFromText({ iterable: loop.right.callee.object, parameters: `(${sourceCode.getText(binding.element)}, ${sourceCode.getText(binding.index)})`, body: expression.right, context, }); }; const getLoopProblem = (declaration, context) => { const declarator = getEmptyArrayDeclarator(declaration); if (!declarator || !isGlobalNameAvailable('Array', declaration, context)) { return; } const loop = getNextStatement(declaration, context); if (loop?.type !== 'ForOfStatement' || loop.await) { return; } const expression = getOnlyExpression(loop.body); if (!expression) { return; } const binding = getForOfBinding(loop); if (!binding) { return; } const {sourceCode} = context; const arrayName = declarator.id.name; const variable = sourceCode.getDeclaredVariables(declarator)[0]; if ( binding.element.name === arrayName || binding.index?.name === arrayName || referencesVariable(variable, loop.right, context) ) { return; } const replacement = getPushReplacement({ expression, binding, loop, arrayName, variable, context, }) ?? getAssignmentReplacement({ expression, binding, loop, arrayName, variable, context, }); if (!replacement) { return; } const replaceRange = [ sourceCode.getRange(declaration)[0], sourceCode.getRange(loop)[1], ]; return getCommentSafeProblem(context, { node: loop, messageId: MESSAGE_ID_ERROR, fix: fixer => fixer.replaceTextRange( replaceRange, `${declaration.kind} ${getVariableTargetText(declarator, context)} = ${replacement};`, ), }, replaceRange); }; const getMapArgumentsFix = (arrayFromCall, mapCall, context) => { // The rewrite closes the `Array.from()` argument list and opens the `.map()` one, so it can not run when either call is wrapped in parentheses of its own. The `.map()` side is its callee, that is where the parentheses the fix removes sit. if ( isParenthesized(arrayFromCall, context) || isParenthesized(mapCall.callee, context) ) { return; } const {sourceCode} = context; const arrayFromClosingParenthesis = sourceCode.getLastToken(arrayFromCall); const tokenBeforeArrayFromClosingParenthesis = sourceCode.getTokenBefore(arrayFromClosingParenthesis); const mapClosingParenthesis = sourceCode.getLastToken(mapCall); const tokenBeforeMapClosingParenthesis = sourceCode.getTokenBefore(mapClosingParenthesis); const [mapArgumentsStart] = getParenthesizedRange(mapCall.arguments[0], context); const insertStart = isCommaToken(tokenBeforeArrayFromClosingParenthesis) ? sourceCode.getRange(tokenBeforeArrayFromClosingParenthesis)[0] : sourceCode.getRange(arrayFromClosingParenthesis)[0]; const insertRange = [insertStart, mapArgumentsStart]; if (wouldRemoveComments(context, insertRange)) { return; } return function * (fixer) { yield fixer.replaceTextRange(insertRange, ', '); if (isCommaToken(tokenBeforeMapClosingParenthesis)) { yield fixer.removeRange(sourceCode.getRange(tokenBeforeMapClosingParenthesis)); } }; }; /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { context.on('VariableDeclaration', declaration => getLoopProblem(declaration, context)); context.on('CallExpression', mapCall => { if (!( isMethodCall(mapCall, { method: 'map', minimumArguments: 1, maximumArguments: 2, optionalCall: false, optionalMember: false, }) && !hasTypeArguments(mapCall) && isMethodCall(mapCall.callee.object, { object: 'Array', method: 'from', argumentsLength: 1, optionalCall: false, optionalMember: false, }) && !hasTypeArguments(mapCall.callee.object) && context.sourceCode.isGlobalReference(mapCall.callee.object.callee.object) && !isFollowedByDefaultFlatCall(mapCall) && isArrowFunctionWithSupportedParameters(mapCall.arguments[0]) )) { return; } const arrayFromCall = mapCall.callee.object; const fix = getMapArgumentsFix(arrayFromCall, mapCall, context); const problem = { node: mapCall.callee.property, messageId: MESSAGE_ID_ERROR, }; if (fix) { problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION, fix, }, ]; } return problem; }); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer using the `Array.from()` mapping function argument.', recommended: 'unopinionated', }, fixable: 'code', hasSuggestions: true, messages, languages: [ 'js/js', ], }, }; export default config;