eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
463 lines (397 loc) • 12.5 kB
JavaScript
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;