UNPKG

eslint-plugin-unicorn

Version:
463 lines (397 loc) 12.5 kB
import {hasSideEffect} from '@eslint-community/eslint-utils'; import { isLiteral, isMemberExpression, isMethodCall, isNewExpression, } from './ast/index.js'; import {fixSpaceAroundKeyword} from './fix/index.js'; import { getParenthesizedText, isBuiltinSet, isGlobalIdentifier, isParenthesized, isSameReference, isTypeScriptExpressionWrapper, needsSemicolon, shouldAddParenthesesToMemberExpressionObject, } from './utils/index.js'; const MESSAGE_ID_UNION = 'prefer-set-methods/union'; const MESSAGE_ID_INTERSECTION = 'prefer-set-methods/intersection'; const MESSAGE_ID_INTERSECTION_SUGGESTION = 'prefer-set-methods/intersection-suggestion'; const MESSAGE_ID_DIFFERENCE = 'prefer-set-methods/difference'; const MESSAGE_ID_DIFFERENCE_SUGGESTION = 'prefer-set-methods/difference-suggestion'; const MESSAGE_ID_SUBSET = 'prefer-set-methods/is-subset-of'; const MESSAGE_ID_DISJOINT = 'prefer-set-methods/is-disjoint-from'; const messages = { [MESSAGE_ID_UNION]: 'Use `Set#union()` instead of spreading Sets into a new Set.', [MESSAGE_ID_INTERSECTION]: 'Use `Set#intersection()` instead of filtering by `Set#has()`.', [MESSAGE_ID_INTERSECTION_SUGGESTION]: 'Use `Set#intersection()`.', [MESSAGE_ID_DIFFERENCE]: 'Use `Set#difference()` instead of filtering by `Set#has()`.', [MESSAGE_ID_DIFFERENCE_SUGGESTION]: 'Use `Set#difference()`.', [MESSAGE_ID_SUBSET]: 'Use `Set#isSubsetOf()` to check whether a Set is a subset of another Set.', [MESSAGE_ID_DISJOINT]: 'Use `Set#isDisjointFrom()` to check whether Sets have any elements in common.', }; const predicateMethodsByArrayMethod = { every: { intersection: 'isSubsetOf', difference: 'isDisjointFrom', }, some: { intersection: 'isDisjointFrom', difference: 'isSubsetOf', }, }; const isGlobalSetConstructor = (node, context) => isNewExpression(node, { name: 'Set', argumentsLength: 1, }) && isGlobalIdentifier(node.callee, context); const getMemberObjectText = (node, context) => { const text = getParenthesizedText(node, context); return shouldAddParenthesesToMemberExpressionObject(node, context) && !isParenthesized(node, context) ? `(${text})` : text; }; const isFirstTokenOfExpressionStatement = (node, context) => { let currentNode = node; while (currentNode.parent) { if (currentNode.parent.type === 'ExpressionStatement') { return context.sourceCode.getRange(context.sourceCode.getFirstToken(currentNode.parent.expression))[0] === context.sourceCode.getRange(context.sourceCode.getFirstToken(node))[0]; } currentNode = currentNode.parent; } return false; }; const addSemicolonIfNeeded = (node, text, context) => isFirstTokenOfExpressionStatement(node, context) && needsSemicolon(context.sourceCode.getTokenBefore(node), context, text) ? `;${text}` : text; const isTransparentWrapperOf = (parent, node) => ( parent.type === 'ParenthesizedExpression' || isTypeScriptExpressionWrapper(parent) ) && parent.expression === node; const unwrapTransparentExpression = node => { while ( node?.type === 'ParenthesizedExpression' || isTypeScriptExpressionWrapper(node) ) { node = node.expression; } return node; }; const getNodeAfterTransparentWrappers = node => { while (node.parent && isTransparentWrapperOf(node.parent, node)) { node = node.parent; } return node; }; const isMemberObjectAfterTransparentWrappers = node => { node = getNodeAfterTransparentWrappers(node); return node.parent?.type === 'MemberExpression' && node.parent.object === node; }; const isCallOrNewExpressionPartAfterTransparentWrappers = node => { node = getNodeAfterTransparentWrappers(node); const {parent} = node; if ( parent?.type !== 'CallExpression' && parent?.type !== 'NewExpression' ) { return false; } return parent.callee === node || parent.arguments.includes(node); }; const isSetSpreadElement = (node, context) => node?.type === 'SpreadElement' && isBuiltinSet(node.argument, context); const isSafeUnionOperand = (node, context) => ( isNewExpression(node, {name: 'Set', argumentsLength: 0}) && isGlobalIdentifier(node.callee, context) ) || !hasSideEffect(node, context.sourceCode, {considerGetters: true}); const isSetSpreadArray = (node, context) => node.type === 'ArrayExpression' && node.elements.length >= 2 && node.elements.every(element => isSetSpreadElement(element, context) && isSafeUnionOperand(element.argument, context), ); const getUnionReplacement = (arrayExpression, context) => { const spreadArguments = arrayExpression.elements.map(element => element.argument); const [firstArgument, ...remainingArguments] = spreadArguments; let text = getMemberObjectText(firstArgument, context); for (const argument of remainingArguments) { text += `.union(${getParenthesizedText(argument, context)})`; } return text; }; const getUnionProblem = (node, context) => { if ( !isGlobalSetConstructor(node, context) || context.sourceCode.getCommentsInside(node).length > 0 ) { return; } const [argument] = node.arguments; if (!isSetSpreadArray(argument, context)) { return; } return { node, messageId: MESSAGE_ID_UNION, fix: fixer => fixer.replaceText(node, addSemicolonIfNeeded(node, getUnionReplacement(argument, context), context)), }; }; const getSingleSpreadSetArgument = (node, context) => { if ( node.type !== 'ArrayExpression' || node.elements.length !== 1 || !isSetSpreadElement(node.elements[0], context) ) { return; } return node.elements[0].argument; }; const isSetCallback = (node, context) => node.type === 'ArrowFunctionExpression' && !node.async && node.params.length === 1 && node.params[0].type === 'Identifier' && context.sourceCode.getDeclaredVariables(node)[0].references.length === 1; const getSetHasCallObject = (node, parameter, context) => { if ( !isMethodCall(node, { method: 'has', argumentsLength: 1, optionalCall: false, optionalMember: false, }) || !isSameReference(node.arguments[0], parameter) || isSameReference(node.callee.object, parameter) || !isBuiltinSet(node.callee.object, context) || hasSideEffect(node.callee.object, context.sourceCode, {considerGetters: true}) ) { return; } return node.callee.object; }; const getSetOperation = (node, parameter, context) => { const intersectionSet = getSetHasCallObject(node, parameter, context); if (intersectionSet) { return { method: 'intersection', messageId: MESSAGE_ID_INTERSECTION, suggestionMessageId: MESSAGE_ID_INTERSECTION_SUGGESTION, otherSet: intersectionSet, }; } if ( node.type !== 'UnaryExpression' || node.operator !== '!' || !node.prefix ) { return; } const differenceSet = getSetHasCallObject(node.argument, parameter, context); if (!differenceSet) { return; } return { method: 'difference', messageId: MESSAGE_ID_DIFFERENCE, suggestionMessageId: MESSAGE_ID_DIFFERENCE_SUGGESTION, otherSet: differenceSet, }; }; const getSetOperationReplacement = (filterCall, context) => { if (!isMethodCall(filterCall, { method: 'filter', argumentsLength: 1, optionalCall: false, optionalMember: false, })) { return; } const set = getSingleSpreadSetArgument(filterCall.callee.object, context); if (!set) { return; } const [callback] = filterCall.arguments; if (!isSetCallback(callback, context)) { return; } const operation = getSetOperation(callback.body, callback.params[0], context); if (!operation) { return; } return { messageId: operation.messageId, suggestionMessageId: operation.suggestionMessageId, replacement: `${getMemberObjectText(set, context)}.${operation.method}(${getParenthesizedText(operation.otherSet, context)})`, }; }; const getSetOperationProblem = (node, replacementNode, context) => { if ( context.sourceCode.getCommentsInside(replacementNode).length > 0 || isMemberObjectAfterTransparentWrappers(node) || (node === replacementNode && isTypeScriptExpressionWrapper(node.parent)) ) { return; } const operation = getSetOperationReplacement(node, context); if (!operation) { return; } return { node: replacementNode, messageId: operation.messageId, suggest: [ { messageId: operation.suggestionMessageId, * fix(fixer) { yield fixer.replaceText(replacementNode, addSemicolonIfNeeded(replacementNode, operation.replacement, context)); yield fixSpaceAroundKeyword(fixer, replacementNode, context); }, }, ], }; }; const getSetPredicateProblem = (node, {set, otherSet, method, negated}, context) => { if (context.sourceCode.getCommentsInside(node).length > 0) { return; } let replacement = `${getMemberObjectText(set, context)}.${method}(${getParenthesizedText(otherSet, context)})`; if (negated) { replacement = `!${replacement}`; const {parent} = node; if ( !isParenthesized(node, context) && ( (parent.type === 'MemberExpression' && parent.object === node) || ((parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === node) || (parent.type === 'TaggedTemplateExpression' && parent.tag === node) || (parent.type === 'BinaryExpression' && parent.operator === '**' && parent.left === node) || parent.type === 'TSNonNullExpression' ) ) { replacement = `(${replacement})`; } } return { node, messageId: method === 'isSubsetOf' ? MESSAGE_ID_SUBSET : MESSAGE_ID_DISJOINT, * fix(fixer) { yield fixer.replaceText(node, addSemicolonIfNeeded(node, replacement, context)); yield fixSpaceAroundKeyword(fixer, node, context); }, }; }; const getArrayPredicateProblem = (node, context) => { if (!isMethodCall(node, { methods: ['every', 'some'], argumentsLength: 1, optionalCall: false, optionalMember: false, })) { return; } const set = getSingleSpreadSetArgument(node.callee.object, context); const [callback] = node.arguments; if ( !set || !isSetCallback(callback, context) ) { return; } const operation = getSetOperation(callback.body, callback.params[0], context); if (!operation) { return; } const arrayMethod = node.callee.property.name; return getSetPredicateProblem(node, { set, otherSet: operation.otherSet, method: predicateMethodsByArrayMethod[arrayMethod][operation.method], negated: arrayMethod === 'some', }, context); }; const getSetSizeComparisonProblem = (node, context) => { const isStrictEqualityComparison = node.operator === '===' || node.operator === '!=='; const isPositiveSizeComparison = ( (node.operator === '>' && isLiteral(node.right, 0)) || (node.operator === '<' && isLiteral(node.left, 0)) ); if (!isStrictEqualityComparison && !isPositiveSizeComparison) { return; } const size = isLiteral(node.right, 0) ? node.left : node.right; const zero = size === node.left ? node.right : node.left; if ( !isLiteral(zero, 0) || !isMemberExpression(size, {property: 'size', optional: false}) || !isMethodCall(size.object, { methods: ['intersection', 'difference'], argumentsLength: 1, optionalCall: false, optionalMember: false, }) ) { return; } const {callee} = size.object; const [otherSet] = size.object.arguments; const set = callee.object; if (!isBuiltinSet(set, context) || !isBuiltinSet(otherSet, context)) { return; } return getSetPredicateProblem(node, { set, otherSet, method: callee.property.name === 'intersection' ? 'isDisjointFrom' : 'isSubsetOf', negated: node.operator !== '===', }, context); }; /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { context.on('NewExpression', node => { const unionProblem = getUnionProblem(node, context); if (unionProblem) { return unionProblem; } if (!isGlobalSetConstructor(node, context)) { return; } const [argument] = node.arguments; return getSetOperationProblem(unwrapTransparentExpression(argument), node, context); }); context.on('CallExpression', node => { const predicateProblem = getArrayPredicateProblem(node, context); if (predicateProblem) { return predicateProblem; } if (isCallOrNewExpressionPartAfterTransparentWrappers(node)) { return; } return getSetOperationProblem(node, node, context); }); context.on('BinaryExpression', node => getSetSizeComparisonProblem(node, context)); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer `Set` methods for Set operations.', recommended: true, }, fixable: 'code', hasSuggestions: true, messages, languages: [ 'js/js', ], }, }; export default config;