eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
501 lines (437 loc) • 13.6 kB
JavaScript
import identifierRegex from 'identifier-regex';
import {findVariable} from '@eslint-community/eslint-utils';
import {isFunction, isMethodCall, isUndefined} from './ast/index.js';
import {
getConstVariableInitializer,
isNodeMatches,
isNodeValueNotFunction,
isParenthesized,
getParenthesizedRange,
getParenthesizedText,
isKnownNonIndexedCollection,
shouldAddParenthesesToCallExpressionCallee,
singular,
unwrapTypeScriptExpression,
} from './utils/index.js';
const ERROR_WITH_NAME_MESSAGE_ID = 'error-with-name';
const ERROR_WITHOUT_NAME_MESSAGE_ID = 'error-without-name';
const REPLACE_WITH_NAME_MESSAGE_ID = 'replace-with-name';
const REPLACE_WITHOUT_NAME_MESSAGE_ID = 'replace-without-name';
const messages = {
[ERROR_WITH_NAME_MESSAGE_ID]: 'Do not pass function `{{name}}` directly to `.{{method}}(…)`.',
[ERROR_WITHOUT_NAME_MESSAGE_ID]: 'Do not pass function directly to `.{{method}}(…)`.',
[REPLACE_WITH_NAME_MESSAGE_ID]: 'Replace function `{{name}}` with `… => {{name}}({{parameters}})`.',
[REPLACE_WITHOUT_NAME_MESSAGE_ID]: 'Replace function with `… => …({{parameters}})`.',
};
const isAwaitExpressionArgument = node => node.parent.type === 'AwaitExpression' && node.parent.argument === node;
const iteratorMethods = new Map([
{
method: 'every',
ignore: [
'Boolean',
],
},
{
method: 'filter',
shouldIgnoreCallExpression: node => (node.callee.object.type === 'Identifier' && node.callee.object.name === 'Vue'),
ignore: [
'Boolean',
],
},
{
method: 'find',
ignore: [
'Boolean',
],
},
{
method: 'findLast',
ignore: [
'Boolean',
],
},
{
method: 'findIndex',
ignore: [
'Boolean',
],
},
{
method: 'findLastIndex',
ignore: [
'Boolean',
],
},
{
method: 'flatMap',
},
{
method: 'forEach',
returnsUndefined: true,
},
{
method: 'map',
shouldIgnoreCallExpression: node => (node.callee.object.type === 'Identifier' && node.callee.object.name === 'types'),
ignore: [
'String',
'Number',
'BigInt',
'Boolean',
'Symbol',
],
},
{
method: 'reduce',
parameters: [
'accumulator',
'element',
'index',
'array',
],
minParameters: 2,
},
{
method: 'reduceRight',
parameters: [
'accumulator',
'element',
'index',
'array',
],
minParameters: 2,
},
{
method: 'some',
ignore: [
'Boolean',
],
},
].map(({
method,
parameters = ['element', 'index', 'array'],
ignore = [],
minParameters = 1,
returnsUndefined = false,
shouldIgnoreCallExpression,
}) => [method, {
minParameters,
parameters,
returnsUndefined,
shouldIgnoreCallExpression(callExpression, ignoredCallees) {
if (
method !== 'reduce'
&& method !== 'reduceRight'
&& isAwaitExpressionArgument(callExpression)
) {
return true;
}
if (isNodeMatches(callExpression.callee.object, ignoredCallees)) {
return true;
}
if (
callExpression.callee.object.type === 'CallExpression'
&& isNodeMatches(callExpression.callee.object.callee, ignoredCallees)
) {
return true;
}
return shouldIgnoreCallExpression?.(callExpression) ?? false;
},
shouldIgnoreCallback: callback => Boolean(callback.type === 'Identifier' && ignore.includes(callback.name)),
}]));
const defaultIgnoredCallees = [
// https://bluebirdjs.com/docs/api/promise.map.html
'Promise',
'React.Children',
'Children',
'lodash',
'underscore',
'_',
'Async',
'async',
'this',
'$',
'jQuery',
];
/*
A class that extends `Array` is a valid receiver, and a locally declared class that shadows a built-in name is not, so the class syntax and heritage are both worth resolving. A union is skipped as soon as one member is a non-array, since the call may land on that member, whose same-named method takes a callback of its own shape. Nullish members are dropped first, because a nullish receiver throws before the callback matters either way.
*/
const receiverTypeOptions = {
allowNullishInMixedUnion: true,
checkClassHeritage: true,
checkClassSyntax: true,
treatMixedUnionAsNonTarget: true,
};
const definitelyNotFunctionValueNodeTypes = new Set([
'ArrayExpression',
'BinaryExpression',
'ClassExpression',
'Literal',
'ObjectExpression',
'TemplateLiteral',
'UnaryExpression',
'UpdateExpression',
]);
function isDefinitelyNotFunctionValue(node, context, visitedInitializers = new Set()) {
node = unwrapTypeScriptExpression(node);
if (
definitelyNotFunctionValueNodeTypes.has(node.type)
|| isUndefined(node)
) {
return true;
}
const initializer = getConstVariableInitializer(node, context);
if (!initializer || visitedInitializers.has(initializer)) {
return false;
}
visitedInitializers.add(initializer);
return isDefinitelyNotFunctionValue(initializer, context, visitedInitializers);
}
const identifierNameRegex = identifierRegex();
const isValidParameterName = name => identifierNameRegex.test(name);
function getSuggestionParameters(callExpression, callback, parameters) {
if (callback.type !== 'Identifier') {
return parameters;
}
let suggestionParameters = parameters;
const {object} = callExpression.callee;
if (object.type === 'Identifier') {
const elementName = singular(object.name);
if (elementName) {
suggestionParameters = parameters.map(parameter => {
let replacement;
if (parameter === 'element') {
replacement = elementName;
} else if (parameter === 'array') {
replacement = object.name;
}
return replacement
&& !parameters.includes(replacement)
&& replacement !== callback.name
&& isValidParameterName(replacement)
? replacement
: parameter;
});
}
}
return suggestionParameters.map(parameter => parameter === callback.name ? `${parameter}_` : parameter);
}
// Every suggestion forwards the same argument names to a different depth, and forwarding fewer arguments than the callback declares hands it `undefined` where it expected a value. So the depth has to start at the parameter count of the declaration, when there is one to read.
function getDeclaration(callback, context) {
if (callback.type === 'Identifier') {
const [definition] = findVariable(context.sourceCode.getScope(callback), callback)?.defs ?? [];
if (definition?.type === 'FunctionName') {
return definition.node;
}
return definition?.type === 'Variable' ? definition.node.init : undefined;
}
if (callback.type !== 'MemberExpression' || callback.computed || callback.property.type !== 'Identifier') {
return;
}
// `object.method`, where `object` is bound to an object literal in the same scope
const objectExpression = unwrapTypeScriptExpression(getConstVariableInitializer(unwrapTypeScriptExpression(callback.object), context));
if (objectExpression?.type !== 'ObjectExpression') {
return;
}
return objectExpression.properties.find(property =>
property.type === 'Property'
&& !property.computed
&& property.key.type === 'Identifier'
&& property.key.name === callback.property.name)?.value;
}
function getDeclaredParameterCount(callback, context) {
const declaration = getDeclaration(callback, context);
if (!declaration || !isFunction(declaration)) {
return;
}
// A rest parameter collects however many arguments it is given, so it needs all of them
return declaration.params.at(-1)?.type === 'RestElement' ? Infinity : declaration.params.length;
}
function getProblem(context, node, callExpression, options) {
const {type} = node;
const name = type === 'Identifier' ? node.name : '';
const problem = {
node,
messageId: name ? ERROR_WITH_NAME_MESSAGE_ID : ERROR_WITHOUT_NAME_MESSAGE_ID,
data: {
name,
method: callExpression.callee.property.name,
},
};
if (node.type === 'YieldExpression' || node.type === 'AwaitExpression') {
return problem;
}
problem.suggest = [];
const {minParameters, returnsUndefined} = options;
const parameters = getSuggestionParameters(callExpression, node, options.parameters);
// A callback that declares more parameters than the method supplies still only receives what the method passes, so the depth is capped by the method rather than by the declaration.
const declaredParameterCount = getDeclaredParameterCount(node, context) ?? minParameters;
const shallowestParameterLength = Math.min(Math.max(minParameters, declaredParameterCount), parameters.length);
for (let parameterLength = shallowestParameterLength; parameterLength <= parameters.length; parameterLength++) {
const suggestionParameters = parameters.slice(0, parameterLength).join(', ');
const suggest = {
messageId: name ? REPLACE_WITH_NAME_MESSAGE_ID : REPLACE_WITHOUT_NAME_MESSAGE_ID,
data: {
name,
parameters: suggestionParameters,
},
fix(fixer) {
let text = getParenthesizedText(node, context);
if (
!isParenthesized(node, context)
&& shouldAddParenthesesToCallExpressionCallee(node)
) {
text = `(${text})`;
}
return fixer.replaceTextRange(
getParenthesizedRange(node, context),
returnsUndefined
? `(${suggestionParameters}) => { ${text}(${suggestionParameters}); }`
: `(${suggestionParameters}) => ${text}(${suggestionParameters})`,
);
},
};
problem.suggest.push(suggest);
}
return problem;
}
function * getTernaryConsequentAndALternate(node) {
if (node.type === 'ConditionalExpression') {
yield * getTernaryConsequentAndALternate(node.consequent);
yield * getTernaryConsequentAndALternate(node.alternate);
return;
}
yield node;
}
// These methods have dedicated type-predicate overloads in TypeScript's lib files.
// Wrapping a type guard can lose narrowing, so direct references should be allowed here.
const methodsWithTypePredicateOverloads = new Set([
'every',
'filter',
'find',
'findLast',
]);
function hasTypePredicateReturnType(node) {
return node.returnType?.typeAnnotation?.type === 'TSTypePredicate';
}
function hasTypePredicateFunctionType(node) {
return node.typeAnnotation?.typeAnnotation?.returnType?.typeAnnotation?.type === 'TSTypePredicate';
}
function isTypePredicateCallback(callback, context) {
if (callback.type !== 'Identifier') {
return false;
}
// Keep this local and syntax-based. Imported/member expressions need type-aware linting.
const variable = findVariable(context.sourceCode.getScope(callback), callback);
const definition = variable?.defs[0];
if (!definition) {
return false;
}
if (definition.type === 'FunctionName') {
return hasTypePredicateReturnType(definition.node);
}
// Imported callbacks may be type guards, but we can't inspect their predicate return
// type without type-aware linting. Be conservative on methods with predicate overloads.
if (definition.type === 'ImportBinding') {
return true;
}
if (definition.type === 'Parameter') {
return hasTypePredicateFunctionType(definition.name);
}
if (definition.type === 'Variable') {
if (hasTypePredicateFunctionType(definition.node.id)) {
return true;
}
const {init} = definition.node;
return init
&& (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')
&& hasTypePredicateReturnType(init);
}
return false;
}
function shouldIgnoreCallback(callback, methodName, options, context) {
return callback.type === 'FunctionExpression'
|| callback.type === 'ArrowFunctionExpression'
// Ignore all `CallExpression`s, including `function.bind()`
|| callback.type === 'CallExpression'
|| options.shouldIgnoreCallback(callback)
|| isDefinitelyNotFunctionValue(callback, context)
|| isNodeValueNotFunction(callback)
|| (methodsWithTypePredicateOverloads.has(methodName) && isTypePredicateCallback(callback, context));
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const {ignore} = context.options[0];
const ignoredCallees = [...defaultIgnoredCallees, ...ignore];
context.on('CallExpression', function * (callExpression) {
if (
!isMethodCall(callExpression, {
minimumArguments: 1,
maximumArguments: 2,
optionalCall: false,
computed: false,
})
|| callExpression.callee.property.type !== 'Identifier'
) {
return;
}
const methodNode = callExpression.callee.property;
const methodName = methodNode.name;
if (!iteratorMethods.has(methodName)) {
return;
}
const options = iteratorMethods.get(methodName);
if (options.shouldIgnoreCallExpression(callExpression, ignoredCallees)) {
return;
}
const callbackArgument = callExpression.arguments[0];
const callbacks = [...getTernaryConsequentAndALternate(callbackArgument)];
const reportableCallbacks = callbacks.filter(callback => !shouldIgnoreCallback(callback, methodName, options, context));
// Resolving the receiver type is expensive, so it runs last, after an inline callback has already been ruled out
if (
reportableCallbacks.length === 0
|| isKnownNonIndexedCollection(callExpression.callee.object, context, receiverTypeOptions)
) {
return;
}
for (const callback of reportableCallbacks) {
yield getProblem(context, callback, callExpression, options);
}
});
};
const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
ignore: {
type: 'array',
uniqueItems: true,
items: {type: 'string'},
description: 'Callees to ignore.',
},
},
},
];
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'problem',
docs: {
description: 'Prevent passing a function reference directly to iterator methods.',
recommended: true,
},
hasSuggestions: true,
schema,
defaultOptions: [{ignore: []}],
messages,
languages: [
'js/js',
],
},
};
export default config;