UNPKG

eslint-plugin-unicorn

Version:
317 lines (272 loc) 8.53 kB
import {hasSideEffect} from '@eslint-community/eslint-utils'; import {isEmptyArrayExpression, isMethodCall} from './ast/index.js'; import { getParenthesizedText, isKnownNonArray, isParenthesized, isSameIdentifier, isTypeScriptExpressionWrapper, isTypeScriptFile, shouldAddParenthesesToMemberExpressionObject, wouldRemoveComments, } from './utils/index.js'; const MESSAGE_ID = 'no-unnecessary-array-flat-map'; const SUGGESTION_ID_FILTER_MAP = 'no-unnecessary-array-flat-map/filter-map-suggestion'; const messages = { [MESSAGE_ID]: 'Prefer `.{{method}}(…)` over `.flatMap(…)` for this single-item array callback.', [SUGGESTION_ID_FILTER_MAP]: 'Replace `.flatMap(…)` with `.filter(…).map(…)`.', }; const arrowBodyNeedsParenthesesTypes = new Set([ 'ObjectExpression', 'SequenceExpression', ]); const hasTypeArguments = node => Boolean(node.typeArguments || node.typeParameters); const isFilterCallExpression = node => isMethodCall(node, { method: 'filter', argumentsLength: 1, optionalCall: false, optionalMember: false, computed: false, }); const isTypeScriptVueSfc = sourceCode => { const documentFragment = sourceCode.parserServices.getDocumentFragment?.(); return documentFragment?.children.some(node => node.type === 'VElement' && node.name === 'script' && node.startTag.attributes.some(attribute => !attribute.directive && attribute.key.name === 'lang' && (attribute.value?.value === 'ts' || attribute.value?.value === 'tsx'), ), ) ?? false; }; const isSimpleSingleParameterArrowCallback = node => node.type === 'ArrowFunctionExpression' && !node.async && !node.returnType && !node.typeParameters && node.params.length === 1 && node.params[0].type === 'Identifier' && !node.params[0].optional && node.body.type !== 'BlockStatement'; const getSingleArrayElement = node => { if ( node.type !== 'ArrayExpression' || node.elements.length !== 1 || !node.elements[0] || node.elements[0].type === 'SpreadElement' ) { return; } return node.elements[0]; }; function shouldParenthesizeArrowBody(node, context) { if (isParenthesized(node, context)) { return false; } if (arrowBodyNeedsParenthesesTypes.has(node.type)) { return true; } return isTypeScriptExpressionWrapper(node) && shouldParenthesizeArrowBody(node.expression, context); } function getCallbackResult(callback) { const directElement = getSingleArrayElement(callback.body); if (directElement) { return { type: 'map', element: directElement, arrayExpression: callback.body, }; } if (callback.body.type !== 'ConditionalExpression') { return; } const {test, consequent, alternate} = callback.body; const element = getSingleArrayElement(consequent); if (!element || !isEmptyArrayExpression(alternate)) { return; } return { type: 'conditional', test, element, arrayExpression: consequent, }; } function getArrowBodyText(node, context) { if (isParenthesized(node, context)) { return getParenthesizedText(node, context); } const text = context.sourceCode.getText(node); return shouldParenthesizeArrowBody(node, context) ? `(${text})` : text; } function getMemberExpressionObjectText(node, context) { if (node.type === 'Super') { return 'super'; } if (isParenthesized(node, context)) { return getParenthesizedText(node, context); } const text = context.sourceCode.getText(node); return shouldAddParenthesesToMemberExpressionObject(node, context) ? `(${text})` : text; } function getArrowParameterText(callback, context) { const parameterText = context.sourceCode.getText(callback.params[0]); return callback.params[0].typeAnnotation ? `(${parameterText})` : parameterText; } function getFilterMapSuggestion(flatMapCallExpression, callback, callbackResult, context) { if ( hasTypeArguments(flatMapCallExpression) || callback.params[0].typeAnnotation || wouldRemoveComments(context, flatMapCallExpression, [ flatMapCallExpression.callee.object, callbackResult.test, callbackResult.element, ]) || hasSideEffect(callbackResult.test, context.sourceCode) || hasSideEffect(callbackResult.element, context.sourceCode) ) { return; } const arrayText = getMemberExpressionObjectText(flatMapCallExpression.callee.object, context); const parameterText = getArrowParameterText(callback, context); const testText = getArrowBodyText(callbackResult.test, context); const elementText = getArrowBodyText(callbackResult.element, context); return { messageId: SUGGESTION_ID_FILTER_MAP, fix: fixer => fixer.replaceText( flatMapCallExpression, `${arrayText}.filter(${parameterText} => ${testText}).map(${parameterText} => ${elementText})`, ), }; } function getProblemForFilterFlatMap(flatMapCallExpression, callbackResult, context) { const filterCallExpression = flatMapCallExpression.callee.object; if (!isFilterCallExpression(filterCallExpression)) { return; } const problem = { node: flatMapCallExpression.callee.property, messageId: MESSAGE_ID, data: {method: 'map'}, }; if ( hasTypeArguments(filterCallExpression) || hasTypeArguments(flatMapCallExpression) || isKnownNonArray(filterCallExpression.callee.object, context) || wouldRemoveComments(context, callbackResult.arrayExpression, [callbackResult.element]) ) { return problem; } return { ...problem, * fix(fixer) { yield fixer.replaceText(flatMapCallExpression.callee.property, 'map'); yield fixer.replaceText(callbackResult.arrayExpression, getArrowBodyText(callbackResult.element, context)); }, }; } function getProblemForConditionalFlatMap(flatMapCallExpression, callback, callbackResult, context) { const method = isSameIdentifier(callbackResult.element, callback.params[0]) ? 'filter' : 'filter().map'; const problem = { node: flatMapCallExpression.callee.property, messageId: MESSAGE_ID, data: {method}, }; if (method === 'filter') { if ( hasTypeArguments(flatMapCallExpression) || hasSideEffect(callbackResult.test, context.sourceCode) || wouldRemoveComments(context, flatMapCallExpression, [ flatMapCallExpression.callee.object, callback.params[0], callbackResult.test, ]) ) { return problem; } const arrayText = getMemberExpressionObjectText(flatMapCallExpression.callee.object, context); const parameterText = getArrowParameterText(callback, context); const testText = getArrowBodyText(callbackResult.test, context); return { ...problem, fix: fixer => fixer.replaceText( flatMapCallExpression, `${arrayText}.filter(${parameterText} => ${testText})`, ), }; } const suggestion = getFilterMapSuggestion(flatMapCallExpression, callback, callbackResult, context); return suggestion ? { ...problem, suggest: [suggestion], } : problem; } function getProblem(flatMapCallExpression, context, isTypeScript) { if ( !isMethodCall(flatMapCallExpression, { method: 'flatMap', argumentsLength: 1, optionalCall: false, optionalMember: false, computed: false, }) || isKnownNonArray(flatMapCallExpression.callee.object, context) ) { return; } const filterCallExpression = flatMapCallExpression.callee.object; if ( isFilterCallExpression(filterCallExpression) && isKnownNonArray(filterCallExpression.callee.object, context) ) { return; } const [callback] = flatMapCallExpression.arguments; if (!isSimpleSingleParameterArrowCallback(callback)) { return; } const callbackResult = getCallbackResult(callback); if (!callbackResult) { return; } if (callbackResult.type === 'map') { return getProblemForFilterFlatMap(flatMapCallExpression, callbackResult, context) ?? { node: flatMapCallExpression.callee.property, messageId: MESSAGE_ID, data: {method: 'map'}, }; } if (isTypeScript) { return; } return getProblemForConditionalFlatMap(flatMapCallExpression, callback, callbackResult, context); } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const isTypeScript = isTypeScriptFile(context.physicalFilename) || isTypeScriptVueSfc(context.sourceCode); context.on('CallExpression', callExpression => getProblem(callExpression, context, isTypeScript)); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Disallow `Array#flatMap()` callbacks that only wrap a single item.', recommended: true, }, fixable: 'code', hasSuggestions: true, schema: [], messages, languages: [ 'js/js', ], }, }; export default config;