eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
390 lines (337 loc) • 11.9 kB
JavaScript
import {findVariable, getPropertyName} from '@eslint-community/eslint-utils';
import {isCallExpression, isMemberExpression, isMethodCall} from './ast/index.js';
import {isLazyIteratorHelperCall} from './shared/iterator-helpers.js';
import {
isArray,
isSet,
isTypeScriptExpressionWrapper,
isValueNotUsable,
} from './utils/index.js';
import {createTypeCheckers} from './utils/type-helpers.js';
const MESSAGE_ID = 'no-unused-builtin-method-return';
const messages = {
[MESSAGE_ID]: 'Do not ignore the return value of `.{{method}}(…)`.',
};
// This list is the implementation contract. We intentionally exclude `toString()` and `toLocaleString()` because they exist on almost every object, and tracking them by method name creates too many non-array false positives.
const arrayMethods = new Set([
'at',
'concat',
'entries',
'every',
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'flat',
'flatMap',
'includes',
'indexOf',
'join',
'keys',
'lastIndexOf',
'map',
// Using `.some()` as a short-circuiting `forEach()` alternative is an anti-pattern.
'some',
'slice',
'toReversed',
'toSorted',
'toSpliced',
'values',
'with',
]);
const setMethods = new Set([
'has',
'union',
'intersection',
'difference',
'symmetricDifference',
'isSubsetOf',
'isSupersetOf',
'isDisjointFrom',
]);
const temporalTypes = [
['Instant', ['add', 'subtract', 'round']],
['ZonedDateTime', ['add', 'subtract', 'with', 'round']],
['PlainDate', ['add', 'subtract', 'with']],
['PlainTime', ['add', 'subtract', 'with', 'round']],
['PlainDateTime', ['add', 'subtract', 'with', 'round']],
['PlainYearMonth', ['add', 'subtract', 'with']],
['PlainMonthDay', ['with']],
['Duration', ['add', 'subtract', 'with', 'round']],
];
const temporalMethodCheckers = new Map(
[...new Set(temporalTypes.flatMap(([, methods]) => methods))].map(method => {
const typeNames = temporalTypes.filter(([, methods]) => methods.includes(method)).map(([name]) => name);
const {isTarget} = createTypeCheckers({
targetTypeNames: new Set(typeNames.map(name => `Temporal.${name}`)),
isTargetNode(node) {
const constructor = node.type === 'NewExpression'
? node.callee
: isMethodCall(node, {method: 'from', optionalCall: false, optionalMember: false}) && node.callee.object;
return isMemberExpression(constructor, {object: 'Temporal', properties: typeNames, optional: false});
},
});
return [method, isTarget];
}),
);
const methods = new Set([...arrayMethods, ...setMethods, ...temporalMethodCheckers.keys()]);
// New coverage only trusts direct constructors, factories, and explicit types after resolving simple bindings. Do not infer method-chain return types.
const isKnownReceiver = (node, method, context) => {
if (setMethods.has(method)) {
return isSet(node, context);
}
if (method === 'with' && isArray(node, context)) {
return true;
}
return temporalMethodCheckers.get(method)?.(node, context) ?? false;
};
const pascalCaseNamePattern = /^\p{Uppercase_Letter}/v;
const uncertainValue = Symbol('uncertainValue');
const nonArrayFactoryFunctionNames = [
'BigInt',
'Boolean',
'Number',
'RegExp',
'String',
'Symbol',
];
const isPascalCaseIdentifier = node =>
node.type === 'Identifier'
&& pascalCaseNamePattern.test(node.name);
const isGlobalIdentifier = (node, name, context) =>
node.type === 'Identifier'
&& node.name === name
&& context.sourceCode.isGlobalReference(node);
const isUndefined = (node, context) =>
isGlobalIdentifier(node, 'undefined', context);
// Treat every construction as non-array unless it uses the global `Array` identifier.
const isKnownNonArrayConstruction = (node, context) =>
node.type === 'NewExpression'
&& !isGlobalIdentifier(node.callee, 'Array', context);
const isKnownNonArrayFactoryCall = (node, context) =>
isCallExpression(node, nonArrayFactoryFunctionNames)
&& context.sourceCode.isGlobalReference(node.callee);
const isDefinitelyNonArrayExpression = (node, context) =>
isUndefined(node, context)
|| node.type === 'ObjectExpression'
|| node.type === 'Literal'
|| node.type === 'BinaryExpression'
|| node.type === 'TemplateLiteral'
|| node.type === 'ArrowFunctionExpression'
|| node.type === 'FunctionExpression'
|| node.type === 'ClassExpression'
|| isKnownNonArrayConstruction(node, context)
|| isKnownNonArrayFactoryCall(node, context);
function hasEarlierWrite(variable, node, context) {
const [nodeStart] = context.sourceCode.getRange(node);
return variable.references.some(reference => !reference.init && reference.isWrite() && context.sourceCode.getRange(reference.identifier)[0] < nodeStart);
}
function getVariableValue(node, context, isSupportedType) {
const variable = findVariable(context.sourceCode.getScope(node), node);
if (!variable || variable.defs.length === 0) {
return;
}
if (variable.defs.length !== 1) {
return uncertainValue;
}
// Supported variable inference boundary:
// - exactly one binding definition
// - unannotated or explicitly supported plain parameters
// - explicitly supported typed variables
// - a `VariableDeclarator` whose id is the same identifier we are resolving
// - the original declarator initializer for unannotated variables only
//
// Unsupported on purpose:
// - any destructuring, including destructuring with defaults
// - any explicit unsupported or unresolved type annotation
// - any write before the call site
// - parameter defaults, rest parameters, `for…of`, catch bindings, and other non-declarator bindings
// - control-flow-sensitive value tracking
//
// This is intentionally extremely small.
// The rule only trusts a variable declarator initializer when the binding has not been written before the call site.
// Everything else stays unresolved on purpose.
if (hasEarlierWrite(variable, node, context)) {
return uncertainValue;
}
const [definition] = variable.defs;
if (
definition.type === 'Parameter'
&& definition.node.params?.includes(definition.name)
) {
return definition.name.optional
|| (definition.name.typeAnnotation && !isSupportedType(definition.name.typeAnnotation))
? uncertainValue
: undefined;
}
if (
definition.type === 'Variable'
&& definition.node.type === 'VariableDeclarator'
&& definition.node.id.type === 'Identifier'
&& definition.node.id.name === node.name
&& definition.parent.type === 'VariableDeclaration'
) {
const {typeAnnotation} = definition.node.id;
if (typeAnnotation) {
return isSupportedType(typeAnnotation) ? undefined : uncertainValue;
}
return definition.node.init ?? uncertainValue;
}
return uncertainValue;
}
function resolveReceiver(node, context, isSupportedType, visitedNodes = new Set()) {
if (!node || node === uncertainValue) {
return node;
}
if (visitedNodes.has(node)) {
return node;
}
visitedNodes.add(node);
if (node.type === 'Identifier') {
const value = getVariableValue(node, context, isSupportedType);
if (value === uncertainValue) {
return value;
}
return value === undefined ? node : resolveReceiver(value, context, isSupportedType, visitedNodes);
}
// Transparent wrappers that do not change the receiver's runtime value.
if (
['ChainExpression', 'TSNonNullExpression', 'TSSatisfiesExpression'].includes(node.type)
) {
return resolveReceiver(node.expression, context, isSupportedType, visitedNodes);
}
if (node.type === 'MemberExpression') {
return uncertainValue;
}
if (node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion') {
return isSupportedType(node) ? node : uncertainValue;
}
// Supported receiver inference boundary:
// - direct receiver expressions that need no value-flow inference, such as `[]` or `getValues()`
// - trivial identifier aliases to that same initializer, like `const alias = values`
//
// Unsupported on purpose:
// - any destructuring, including destructuring with defaults
// - any member/property receiver, including `wrapper.items`, `alias.items`, and `this.items`
// - any object-property, class-field, or `this`-based inference
// - any write before the call site
// - any "latest value" reconstruction after assignments
//
// This comment is intentionally blunt because this boundary is the feature.
// The rule is not a general value tracker anymore.
// If a case requires following properties, destructuring, or writes, we leave it unresolved.
return node;
}
const isObviouslyNonArrayReceiver = (resolvedReceiver, context) =>
isDefinitelyNonArrayExpression(resolvedReceiver, context)
|| (isPascalCaseIdentifier(resolvedReceiver) && !isArray(resolvedReceiver, context));
const isExpectCall = node =>
isCallExpression(node, 'expect')
|| isMethodCall(node, {
object: 'expect',
methods: ['element', 'poll', 'soft'],
});
const shouldSkipReceiver = (node, method, context) => {
const requiresKnownReceiver = setMethods.has(method) || temporalMethodCheckers.has(method);
const isSupportedType = requiresKnownReceiver
? node => isKnownReceiver(node, method, context)
: node => isArray(node, context);
const resolvedReceiver = resolveReceiver(node, context, isSupportedType);
if (resolvedReceiver === uncertainValue) {
return true;
}
if (requiresKnownReceiver) {
return !isSupportedType(resolvedReceiver);
}
if (isExpectCall(resolvedReceiver)) {
return true;
}
if (method === 'values') {
return !isArray(resolvedReceiver, context);
}
return isObviouslyNonArrayReceiver(resolvedReceiver, context);
};
const getTrackedMethodName = (node, context) =>
node.callee.type === 'MemberExpression'
? getPropertyName(node.callee, context.sourceCode.getScope(node.callee))
: undefined;
// Supported discarded-value boundary:
// - direct unused expressions handled by `isValueNotUsable()`
// - `await foo.map()` when the awaited expression is itself directly discarded
// - TypeScript assertion wrappers around that same direct discard site
// - direct `for` init/update expressions like `for (foo.map(); ; )` and `for (; ; foo.map())`
//
// Unsupported on purpose:
// - comparison wrappers, including Yoda comparisons, are intentionally left out
// - comma-expression wrappers
// - logical wrappers like `condition && foo.map()`
// - conditional wrappers like `condition ? foo.map() : other()`
// - any other parent-expression pattern not listed above
//
// The rule stops after this short fixed wrapper list.
// We intentionally do not keep climbing through arbitrary parent expressions just to catch one more nested discard shape.
const isDiscardedExpression = node => {
while (true) {
if (isValueNotUsable(node)) {
return true;
}
const {parent} = node;
if (
parent.type === 'ForStatement'
&& (parent.init === node || parent.update === node)
) {
return true;
}
if (
parent.type !== 'ChainExpression'
&& parent.type !== 'AwaitExpression'
&& !isTypeScriptExpressionWrapper(parent)
) {
return false;
}
node = parent;
}
};
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
context.on('CallExpression', node => {
const method = getTrackedMethodName(node, context);
// `no-unused-iterator-helper` owns lazy helpers because `void` does not consume them, while this rule accepts `void` as an explicit discard.
if (
!methods.has(method)
|| !isDiscardedExpression(node)
|| isLazyIteratorHelperCall(node, context)
|| shouldSkipReceiver(node.callee.object, method, context)
) {
return;
}
return {
node: node.callee.property,
messageId: MESSAGE_ID,
data: {
method,
},
};
});
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow ignoring the return value of selected built-in methods.',
recommended: 'unopinionated',
},
messages,
languages: [
'js/js',
],
},
};
export default config;