UNPKG

eslint-plugin-unicorn

Version:
544 lines (473 loc) 13.2 kB
import isIdentifier from 'is-identifier'; import {findVariable} from '@eslint-community/eslint-utils'; import typedArray from './shared/typed-array.js'; import {isMethodCall, isUndefined} from './ast/index.js'; import { getBaseTypes, getTypeSymbol, isDefaultLibrarySymbol, isNullishType, isUnknownType, isNodeMatches, isNodeValueNotFunction, isParenthesized, getParenthesizedRange, getParenthesizedText, 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', ]; const typedArrayTypes = new Set(typedArray); const arrayTypeNames = new Set(['Array', 'ReadonlyArray']); const definitelyNotFunctionValueNodeTypes = new Set([ 'ArrayExpression', 'BinaryExpression', 'ClassExpression', 'Literal', 'ObjectExpression', 'TemplateLiteral', 'UnaryExpression', 'UpdateExpression', ]); function shouldReportReceiverType(type, checker, program, allowNullish) { if (isUnknownType(type)) { return true; } if (type.isUnion()) { const types = allowNullish ? type.types.filter(type => !isNullishType(type)) : type.types; return types.length > 0 && types.every(type => shouldReportReceiverType(type, checker, program, allowNullish)); } const constraint = checker.getBaseConstraintOfType(type); if (constraint && constraint !== type) { return shouldReportReceiverType(constraint, checker, program, allowNullish); } if (isNullishType(type)) { return false; } const types = type.isIntersection() ? type.types : [type]; return types.some(type => { if (isUnknownType(type)) { return true; } if (checker.isArrayType(type) || checker.isTupleType(type)) { return true; } if (getBaseTypes(type, checker).some(baseType => shouldReportReceiverType(baseType, checker, program, allowNullish))) { return true; } const symbol = getTypeSymbol(type); if (!isDefaultLibrarySymbol(symbol, program)) { return false; } const typeName = symbol.getName(); return arrayTypeNames.has(typeName) || typedArrayTypes.has(typeName); }); } function shouldReportReceiver(callExpression, context) { const {parserServices} = context.sourceCode; if (!parserServices?.program) { return true; } try { const {program} = parserServices; return shouldReportReceiverType( parserServices.getTypeAtLocation(callExpression.callee.object), program.getTypeChecker(), program, callExpression.callee.optional, ); } catch { return true; } } function getConstVariableInitializer(node, context, visitedVariables) { node = unwrapTypeScriptExpression(node); if (node?.type !== 'Identifier') { return; } const variable = findVariable(context.sourceCode.getScope(node), node); if ( !variable || visitedVariables.has(variable) || variable.defs.length !== 1 ) { return; } const [definition] = variable.defs; if ( definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator' || definition.node.id.type !== 'Identifier' || definition.node.id.name !== node.name || !definition.node.init || definition.parent.type !== 'VariableDeclaration' || definition.parent.kind !== 'const' ) { return; } visitedVariables.add(variable); return unwrapTypeScriptExpression(definition.node.init); } function isDefinitelyNotFunctionValue(node, context, visitedVariables = new Set()) { node = unwrapTypeScriptExpression(node); if (!node) { return false; } if ( definitelyNotFunctionValueNodeTypes.has(node.type) || isUndefined(node) ) { return true; } const initializer = getConstVariableInitializer(node, context, visitedVariables); return initializer ? isDefinitelyNotFunctionValue(initializer, context, visitedVariables) : false; } const isValidParameterName = isIdentifier; 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); } 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); for (let parameterLength = minParameters; 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) || !shouldReportReceiver(callExpression, context) ) { return; } const callbackArgument = callExpression.arguments[0]; const callbacks = [...getTernaryConsequentAndALternate(callbackArgument)]; const reportableCallbacks = callbacks.filter(callback => !shouldIgnoreCallback(callback, methodName, options, context)); 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;