eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
623 lines (533 loc) • 21.3 kB
JavaScript
import {findVariable, isCommaToken} from '@eslint-community/eslint-utils';
import reservedIdentifiers from 'reserved-identifiers';
import {isInTypeQuery} from './ast/index.js';
import {getArgumentRemovalRange, removeObjectProperty, replaceReferenceIdentifier} from './fix/index.js';
import {
getIndentUnit,
getLinebreak,
getLineIndent,
getOutermostTypeScriptExpression,
getParenthesizedRange,
getPrimitiveLiteralValue,
hasCommentInRange,
hasJavaScriptTypeAnnotations,
isShorthandPropertyValue,
isTypeScriptFile,
needsSemicolon,
shouldAddParenthesesToMemberExpressionObject,
trackLocalFunctionCalls,
unwrapTypeScriptExpression,
withTypeInformation,
} from './utils/index.js';
const messages = {
'same-value': 'Parameter `{{name}}` receives the same value at every call.',
'always-default': 'Parameter `{{name}}` always receives its default value.',
'always-undefined': 'Parameter `{{name}}` is always `undefined`.',
'remove-parameter': 'Remove parameter `{{name}}` and its arguments (requires type review).',
};
const reserved = reservedIdentifiers();
const isInside = (node, parent, context) => {
const {sourceCode} = context;
const [start, end] = sourceCode.getRange(node);
const [parentStart, parentEnd] = sourceCode.getRange(parent);
return start >= parentStart && end <= parentEnd;
};
const hasWrites = variable => variable.references.some(reference => !reference.init && reference.isWrite());
const isRuntimeReference = reference =>
reference.isValueReference !== false
&& !isInTypeQuery(reference.identifier);
const getVariable = (node, context) => findVariable(context.sourceCode.getScope(node), node);
const isOuterBinding = (variable, parameter, context) => findVariable(context.sourceCode.getScope(parameter.identifier).upper, variable.name) === variable;
const undefinedValue = {kind: 'primitive', value: undefined};
function getPrimitiveValue(node) {
const value = getPrimitiveLiteralValue(node);
if (value !== undefined) {
return {kind: 'primitive', value, node};
}
if (node.type === 'UnaryExpression') {
const argument = unwrapTypeScriptExpression(node.argument);
if (argument.type === 'Literal' && node.operator === 'void' && argument.value === 0) {
return {...undefinedValue, node};
}
}
}
function getValue(node, context) {
if (!node) {
return undefinedValue;
}
node = unwrapTypeScriptExpression(node);
const primitive = getPrimitiveValue(node);
if (primitive) {
return primitive;
}
if (node.type !== 'Identifier') {
return;
}
const variable = getVariable(node, context);
if (!variable || hasWrites(variable)) {
return;
}
if (variable.defs.length === 0) {
if (variable.name === 'undefined') {
return {...undefinedValue, node};
}
if (variable.writeable === false) {
return {kind: 'binding', variable, node};
}
return;
}
const [definition] = variable.defs;
if (variable.defs.length !== 1
|| definition.kind === 'var'
|| definition.node.declare
|| definition.parent?.declare
|| definition.node.type === 'TSDeclareFunction'
|| !['Variable', 'FunctionName', 'ClassName', 'Parameter', 'CatchClause'].includes(definition.type)) {
return;
}
return {kind: 'binding', variable, node};
}
const isUndefined = value => value?.kind === 'primitive' && value.value === undefined;
const isSameValue = (first, second) => first?.kind === second?.kind && (
first.kind === 'binding' ? first.variable === second.variable : Object.is(first.value, second.value)
);
function getPropertyName(property) {
if (property.type !== 'Property') {
return;
}
if (!property.computed && property.key.type === 'Identifier') {
return property.key.name;
}
if (property.key.type === 'Literal' && (typeof property.key.value === 'string' || typeof property.key.value === 'number')) {
return String(property.key.value);
}
}
function getProperties(node) {
const properties = new Map();
for (const property of node.properties) {
if (property.type === 'RestElement' && node.type === 'ObjectPattern') {
continue;
}
const name = getPropertyName(property);
if (name === undefined || name === '__proto__' || properties.has(name) || property.method || property.kind !== 'init') {
return;
}
properties.set(name, property);
}
return properties;
}
function getPredicateParameterNames(functionNode, context) {
const names = new Set();
const returnType = functionNode.returnType?.typeAnnotation;
if (returnType?.type === 'TSTypePredicate') {
names.add(returnType.parameterName.name);
}
withTypeInformation(functionNode, context, ({type, checker}) => {
const typeScriptNode = context.sourceCode.parserServices.esTreeNodeToTSNodeMap.get(functionNode);
const signature = checker.getSignatureFromDeclaration(typeScriptNode);
if (signature) {
// TypeScript can count `this` in inferred predicate indexes and escape predicate names, so match declaration predicates by both names.
const parameterName = checker.getTypePredicateOfSignature(signature)?.parameterName;
names.add(parameterName);
names.add(signature.parameters.find(parameter => parameter.escapedName === parameterName)?.name);
}
const signatures = [...type.getCallSignatures()];
if (functionNode.type === 'ArrowFunctionExpression'
|| (functionNode.type === 'FunctionExpression' && functionNode.parent.type !== 'MethodDefinition' && !functionNode.parent.method)) {
signatures.push(...checker.getContextualType(typeScriptNode)?.getCallSignatures() ?? []);
}
const parameters = functionNode.params.filter(parameter => parameter.type !== 'Identifier' || parameter.name !== 'this');
for (const callableSignature of signatures) {
if (callableSignature.declaration === typeScriptNode) {
continue;
}
const predicate = checker.getTypePredicateOfSignature(callableSignature);
const parameter = parameters[predicate?.parameterIndex];
const pattern = parameter?.type === 'AssignmentPattern' ? parameter.left : parameter;
if (pattern?.type === 'Identifier') {
names.add(pattern.name);
}
}
});
return names;
}
function * getParameters(functionNode, context) {
const predicateParameterNames = getPredicateParameterNames(functionNode, context);
let index = 0;
for (const parameter of functionNode.params) {
if (parameter.type === 'Identifier' && parameter.name === 'this') {
continue;
}
const position = index++;
const pattern = parameter.type === 'AssignmentPattern' ? parameter.left : parameter;
const defaultNode = parameter.type === 'AssignmentPattern' ? parameter.right : undefined;
if (pattern.type === 'Identifier') {
if (predicateParameterNames.has(pattern.name)) {
continue;
}
yield {
node: parameter, identifier: pattern, variable: getVariable(pattern, context), index: position, defaultNode,
};
continue;
}
if (pattern.type !== 'ObjectPattern' || (defaultNode && (defaultNode.type !== 'ObjectExpression' || defaultNode.properties.length > 0))) {
continue;
}
const properties = getProperties(pattern);
if (!properties) {
continue;
}
for (const [name, property] of properties) {
const {value} = property;
const identifier = value.type === 'AssignmentPattern' ? value.left : value;
if (identifier.type === 'Identifier') {
yield {
node: property,
identifier,
variable: getVariable(identifier, context),
index: position,
propertyName: name,
defaultNode: value.type === 'AssignmentPattern' ? value.right : undefined,
parameter,
};
}
}
}
}
function getArguments(target, parameter, context) {
const arguments_ = [];
for (const call of target.calls) {
if (call.arguments.slice(0, parameter.index + 1).some(argument => argument.type === 'SpreadElement')) {
return;
}
const argument = call.arguments[parameter.index];
if (parameter.propertyName !== undefined) {
let object = unwrapTypeScriptExpression(argument);
if ((!object || isUndefined(getValue(object, context))) && parameter.parameter.type === 'AssignmentPattern') {
object = parameter.parameter.right;
}
if (object?.type !== 'ObjectExpression') {
return;
}
const properties = getProperties(object);
if (!properties) {
return;
}
const property = properties.get(parameter.propertyName);
// A missing own property can still be inherited from Object.prototype.
if (!property && Object.hasOwn(Object.prototype, parameter.propertyName)) {
return;
}
arguments_.push({node: property, value: getValue(property?.value, context)});
continue;
}
const unwrapped = unwrapTypeScriptExpression(argument);
if (unwrapped?.type === 'Identifier' && isInside(call, target.node, context) && getVariable(unwrapped, context) === parameter.variable) {
if (hasWrites(parameter.variable)) {
return;
}
arguments_.push({node: argument, forwarding: true});
continue;
}
arguments_.push({node: argument, value: getValue(argument, context)});
}
return arguments_;
}
function getParameterValue(parameter, arguments_, context) {
const incoming = arguments_.filter(argument => !argument.forwarding);
if (incoming.some(argument => !argument.value)) {
return;
}
const defaultValue = parameter.defaultNode ? getValue(parameter.defaultNode, context) : undefinedValue;
if (incoming.every(argument => isUndefined(argument.value))) {
if (arguments_.some(argument => argument.forwarding)
&& (!defaultValue || (defaultValue.kind === 'binding' && !isOuterBinding(defaultValue.variable, parameter, context)))) {
return;
}
return {messageId: parameter.defaultNode ? 'always-default' : 'always-undefined', value: defaultValue, arguments_};
}
const values = incoming.map(argument => isUndefined(argument.value) ? defaultValue : argument.value);
const [value] = values;
if (!value || values.some(other => !other || !isSameValue(value, other))) {
return;
}
if (value.kind === 'binding' && !isOuterBinding(value.variable, parameter, context)) {
return;
}
return {messageId: parameter.defaultNode && defaultValue && isSameValue(value, defaultValue) ? 'always-default' : 'same-value', value, arguments_};
}
function getParameterRemoval(parameter, context) {
const {sourceCode} = context;
const functionNode = parameter.node.parent;
if (functionNode.type === 'ArrowFunctionExpression' && functionNode.params.length === 1 && sourceCode.getTokenAfter(parameter.node).value === '=>') {
return {removalRange: sourceCode.getRange(parameter.node), replacement: '()'};
}
const parameters = functionNode.params;
const index = parameters.indexOf(parameter.node);
let [start, end] = sourceCode.getRange(parameter.node);
if (index < parameters.length - 1) {
const comma = sourceCode.getTokenAfter(parameter.node);
[end] = sourceCode.getRange(sourceCode.getTokenAfter(comma, {includeComments: true}));
} else if (index > 0) {
[start] = sourceCode.getRange(sourceCode.getTokenBefore(parameter.node));
} else {
const token = sourceCode.getTokenAfter(parameter.node);
if (isCommaToken(token)) {
[, end] = sourceCode.getRange(token);
}
}
return {removalRange: [start, end], replacement: ''};
}
function getLocalDefault(parameter, target, arguments_, context) {
const defaultNode = unwrapTypeScriptExpression(parameter.defaultNode);
const {node} = target;
if (
!defaultNode
|| defaultNode.type !== 'Identifier'
|| parameter.propertyName !== undefined
|| reserved.has(parameter.identifier.name)
|| node.async
|| node.generator
|| node.params.at(-1) !== parameter.node
|| arguments_.some(argument => argument.forwarding)
|| (node.body.type !== 'BlockStatement' && arguments_.some(argument => argument.node && isInside(argument.node, node.body, context)))
|| parameter.variable.defs.length !== 1
) {
return;
}
const variable = getVariable(defaultNode, context);
if (
!variable
|| variable === parameter.variable
|| variable.defs.length !== 1
|| variable.defs[0].type !== 'Parameter'
|| variable.defs[0].node !== node
|| findVariable(context.sourceCode.getScope(node.body), variable.name) !== variable
) {
return;
}
return `${hasWrites(parameter.variable) ? 'let' : 'const'} ${parameter.identifier.name} = ${context.sourceCode.getText(parameter.defaultNode)};`;
}
function getReplacementText(identifier, value, context) {
const expression = getOutermostTypeScriptExpression(identifier);
let text;
if (isUndefined(value)) {
text = '(void 0)';
} else {
text = value.kind === 'binding' ? value.variable.name : context.sourceCode.getText(value.node);
}
if (
value.kind === 'primitive'
&& value.node
&& ((value.node.type === 'UnaryExpression' && !isUndefined(value))
|| (expression.parent.type === 'MemberExpression' && expression.parent.object === expression && shouldAddParenthesesToMemberExpressionObject(value.node, context))
|| (expression.parent.type === 'NewExpression' && expression.parent.callee === expression)
|| (expression.parent.type === 'ExpressionStatement' && typeof value.value === 'string'))
) {
text = `(${text})`;
}
const {sourceCode} = context;
const statement = sourceCode.getAncestors(identifier).findLast(ancestor => ancestor.type === 'ExpressionStatement');
if (
statement
&& sourceCode.getRange(sourceCode.getFirstToken(statement))[0] === sourceCode.getRange(identifier)[0]
&& needsSemicolon(sourceCode.getTokenBefore(identifier), context, text)
) {
text = `;${text}`;
}
return text;
}
function canInlineValue(parameter, result, context) {
const {sourceCode} = context;
const {value, arguments_} = result;
if (!value || hasWrites(parameter.variable) || parameter.variable.references.some(({identifier}) =>
identifier.type === 'JSXIdentifier' || (identifier.name === '__proto__' && isShorthandPropertyValue(identifier)))) {
return false;
}
if (value.kind !== 'binding') {
// Legacy literals from non-strict scopes can become invalid in strict code.
return !value.node || sourceCode.getScope(value.node).isStrict || parameter.variable.references.every(reference => !sourceCode.getScope(reference.identifier).isStrict);
}
if (value.variable.scope.type === 'global' || reserved.has(value.variable.name)) {
return false;
}
// Keep binding-valued defaults in parameter scope; only the local-default transformation can move them.
if (result.messageId === 'always-default') {
return false;
}
const definition = value.variable.defs[0];
// Parameter and catch bindings may be uninitialized, and parameters can change through a mapped arguments object.
if (['Parameter', 'CatchClause'].includes(definition.type)) {
return false;
}
if (definition.type === 'Variable' && !definition.node.init) {
return false;
}
// A declaration in another switch case may not have been initialized.
if ((definition.type === 'Variable' || definition.type === 'ClassName')
&& (value.variable.scope.type === 'switch' || arguments_.some(argument => argument.node && (
sourceCode.getScope(argument.node).variableScope !== value.variable.scope.variableScope
|| sourceCode.getRange(definition.node)[1] > sourceCode.getRange(argument.node)[0]
)))) {
return false;
}
for (const reference of parameter.variable.references) {
const resolved = findVariable(sourceCode.getScope(reference.identifier), value.variable.name);
if (resolved !== value.variable && resolved !== parameter.variable) {
return false;
}
}
return true;
}
function getFix(parameter, result, target, context) {
const {sourceCode} = context;
const {value, messageId, arguments_} = result;
if (
parameter.variable.defs.length !== 1
|| parameter.variable.references.some(reference => !isRuntimeReference(reference))
) {
return;
}
// Keep parameter-initializer dependencies intact, including their temporal dead zones.
if (parameter.variable.references.some(reference => !reference.init
&& !isInside(reference.identifier, target.node.body, context)
&& !isInside(reference.identifier, parameter.node, context))) {
return;
}
const localDefault = messageId === 'always-default' ? getLocalDefault(parameter, target, arguments_, context) : undefined;
if (!localDefault && !canInlineValue(parameter, result, context)) {
return;
}
const removals = arguments_.map(argument => argument.node).filter(node => node && !isInside(node, parameter.node, context));
const ranges = parameter.propertyName === undefined
? [getParameterRemoval(parameter, context), ...removals.map(node => ({removalRange: getArgumentRemovalRange(node, context), replacement: ''}))]
: [];
if (
hasCommentInRange(context, [sourceCode.getRange(target.node)[0], sourceCode.getRange(target.node.body)[0]])
|| (localDefault && sourceCode.getCommentsInside(target.node.body).length > 0)
|| [...target.calls].some(call => sourceCode.getCommentsInside(call).length > 0)
|| ranges.some(({removalRange}) => hasCommentInRange(context, removalRange))
) {
return;
}
if (!localDefault && parameter.variable.references.some(reference => {
const {parent} = getOutermostTypeScriptExpression(reference.identifier);
return parent.type === 'UnaryExpression' && parent.operator === 'delete';
})) {
return;
}
return function * (fixer) {
if (parameter.propertyName === undefined) {
for (const {removalRange, replacement} of ranges) {
yield fixer.replaceTextRange(removalRange, replacement);
}
} else {
yield removeObjectProperty(fixer, parameter.node, context);
for (const node of removals) {
yield removeObjectProperty(fixer, node, context);
}
}
if (localDefault) {
const {body} = target.node;
const linebreak = getLinebreak(context);
const indent = getLineIndent(target.node, context);
const bodyIndent = indent + getIndentUnit(context);
if (body.type !== 'BlockStatement') {
const expression = sourceCode.text.slice(...getParenthesizedRange(body, context));
yield fixer.replaceTextRange(getParenthesizedRange(body, context), `{${linebreak}${bodyIndent}${localDefault}${linebreak}${bodyIndent}return ${expression};${linebreak}${indent}}`);
return;
}
const directives = body.body.filter(statement => statement.directive);
const anchor = directives.at(-1) ?? sourceCode.getFirstToken(body);
const nextToken = sourceCode.getTokenAfter(anchor);
const gap = sourceCode.text.slice(sourceCode.getRange(anchor)[1], sourceCode.getRange(nextToken)[0]);
const suffix = /[\n\r]/.test(gap) ? '' : linebreak + (nextToken.value === '}' ? indent : bodyIndent);
yield fixer.insertTextAfter(anchor, `${linebreak}${bodyIndent}${localDefault}${suffix}`);
return;
}
for (const reference of parameter.variable.references) {
const {identifier} = reference;
if (reference.init || isInside(identifier, parameter.node, context) || removals.some(node => isInside(identifier, node, context))) {
continue;
}
yield replaceReferenceIdentifier(identifier, getReplacementText(identifier, value, context), context, fixer);
}
};
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
const {sourceCode} = context;
const getTargets = trackLocalFunctionCalls(context);
// Changing signatures or inferred values can introduce type checking errors without changing runtime behavior, so these fixes require type review.
const needsTypeReview = isTypeScriptFile(context.filename)
|| Boolean(sourceCode.parserServices.esTreeNodeToTSNodeMap)
|| hasJavaScriptTypeAnnotations(context);
context.onExit('Program', function * () {
for (const target of getTargets()) {
const {node} = target;
const scope = sourceCode.scopeManager.acquire(node, true);
const argumentsVariable = scope?.set.get('arguments');
if (
node.params.some(parameter => parameter.decorators?.length > 0)
|| argumentsVariable?.references.length > 0
|| [...target.calls].filter(call => !isInside(call, node, context)).length < context.options[0].minimumCallCount
) {
continue;
}
let hasFix = false;
for (const parameter of getParameters(node, context)) {
if (!parameter.variable) {
continue;
}
const arguments_ = getArguments(target, parameter, context);
const result = arguments_ && getParameterValue(parameter, arguments_, context);
if (!result) {
continue;
}
const fix = hasFix ? undefined : getFix(parameter, result, target, context);
hasFix ||= Boolean(fix) && !needsTypeReview;
yield {
node: parameter.identifier,
messageId: result.messageId,
data: {name: parameter.identifier.name},
fix: needsTypeReview ? undefined : fix,
suggest: needsTypeReview && fix
? [{messageId: 'remove-parameter', data: {name: parameter.identifier.name}, fix}]
: undefined,
};
}
}
});
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow parameters that receive the same value at every call.',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
schema: [{
type: 'object',
properties: {
minimumCallCount: {
type: 'integer',
minimum: 1,
description: 'Minimum number of external call sites required to check a function.',
},
},
additionalProperties: false,
}],
defaultOptions: [{minimumCallCount: 2}],
messages,
languages: ['js/js'],
},
};
export default config;