292 lines
6.9 KiB
JavaScript
292 lines
6.9 KiB
JavaScript
import {findVariable} from '@eslint-community/eslint-utils';
|
|
import {getAvailableVariableName, isLeftHandSide} from './utils/index.js';
|
|
import {isCallOrNewExpression} from './ast/index.js';
|
|
|
|
const MESSAGE_ID = 'consistentDestructuring';
|
|
const MESSAGE_ID_SUGGEST = 'consistentDestructuringSuggest';
|
|
|
|
const isSimpleExpression = expression => {
|
|
while (expression) {
|
|
if (expression.computed) {
|
|
return false;
|
|
}
|
|
|
|
if (expression.type !== 'MemberExpression') {
|
|
break;
|
|
}
|
|
|
|
expression = expression.object;
|
|
}
|
|
|
|
return expression.type === 'Identifier'
|
|
|| expression.type === 'ThisExpression';
|
|
};
|
|
|
|
const isChildInParentScope = (child, parent) => {
|
|
while (child) {
|
|
if (child === parent) {
|
|
return true;
|
|
}
|
|
|
|
child = child.upper;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const getRootIdentifier = expression => {
|
|
while (expression?.type === 'MemberExpression') {
|
|
expression = expression.object;
|
|
}
|
|
|
|
return expression?.type === 'Identifier' ? expression : undefined;
|
|
};
|
|
|
|
const hasRestElement = pattern => {
|
|
switch (pattern?.type) {
|
|
case 'RestElement': {
|
|
return true;
|
|
}
|
|
|
|
case 'AssignmentPattern': {
|
|
return hasRestElement(pattern.left);
|
|
}
|
|
|
|
case 'ObjectPattern': {
|
|
return pattern.properties.some(property =>
|
|
hasRestElement(property.type === 'Property' ? property.value : property));
|
|
}
|
|
|
|
case 'ArrayPattern': {
|
|
return pattern.elements.some(element =>
|
|
hasRestElement(element));
|
|
}
|
|
|
|
default: {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const isIdentifierProperty = property =>
|
|
property.type === 'Property'
|
|
&& property.key.type === 'Identifier';
|
|
|
|
const hasNestedRestElement = pattern => {
|
|
switch (pattern?.type) {
|
|
case 'AssignmentPattern': {
|
|
return hasNestedRestElement(pattern.left);
|
|
}
|
|
|
|
case 'ObjectPattern': {
|
|
return pattern.properties.some(property => {
|
|
if (property.type === 'RestElement') {
|
|
return false;
|
|
}
|
|
|
|
return hasRestElement(property.value);
|
|
});
|
|
}
|
|
|
|
case 'ArrayPattern': {
|
|
return pattern.elements.some(element => {
|
|
if (!element || element.type === 'RestElement') {
|
|
return false;
|
|
}
|
|
|
|
return hasRestElement(element);
|
|
});
|
|
}
|
|
|
|
default: {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const isMemberDestructuredInNestedPatternWithRest = (objectPattern, memberName) =>
|
|
objectPattern.properties.some(property =>
|
|
isIdentifierProperty(property)
|
|
&& property.key.name === memberName
|
|
&& property.value.type !== 'Identifier'
|
|
&& hasNestedRestElement(property.value));
|
|
|
|
const isRootVariableReassigned = (declaration, memberExpressionNode, memberScope, sourceCode) => {
|
|
if (!declaration.rootVariable) {
|
|
return false;
|
|
}
|
|
|
|
const [, declarationEnd] = sourceCode.getRange(declaration.object);
|
|
const [memberStart] = sourceCode.getRange(memberExpressionNode);
|
|
|
|
return declaration.rootVariable.references.some(reference => {
|
|
if (!reference.isWrite()) {
|
|
return false;
|
|
}
|
|
|
|
const [referenceStart] = sourceCode.getRange(reference.identifier);
|
|
if (referenceStart < declarationEnd) {
|
|
return false;
|
|
}
|
|
|
|
// Be conservative: writes from other variable scopes may run before this read via calls/closures.
|
|
if (reference.from.variableScope !== memberScope.variableScope) {
|
|
return true;
|
|
}
|
|
|
|
return referenceStart <= memberStart;
|
|
});
|
|
};
|
|
|
|
/** @param {import('eslint').Rule.RuleContext} context */
|
|
const create = context => {
|
|
const {sourceCode} = context;
|
|
const declarations = new Map();
|
|
|
|
context.on('VariableDeclarator', node => {
|
|
if (!(
|
|
node.id.type === 'ObjectPattern'
|
|
&& node.init
|
|
&& node.init.type !== 'Literal'
|
|
// Ignore any complex expressions (e.g. arrays, functions)
|
|
&& isSimpleExpression(node.init)
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
const rootIdentifier = getRootIdentifier(node.init);
|
|
declarations.set(sourceCode.getText(node.init), {
|
|
scope: sourceCode.getScope(node),
|
|
object: node.init,
|
|
rootIdentifierName: rootIdentifier?.name,
|
|
rootVariable: rootIdentifier && findVariable(sourceCode.getScope(node), rootIdentifier),
|
|
objectPattern: node.id,
|
|
});
|
|
});
|
|
|
|
context.on('MemberExpression', node => {
|
|
if (
|
|
node.computed
|
|
|| (
|
|
isCallOrNewExpression(node.parent)
|
|
&& node.parent.callee === node
|
|
)
|
|
|| isLeftHandSide(node)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const declaration = declarations.get(sourceCode.getText(node.object));
|
|
|
|
if (!declaration) {
|
|
return;
|
|
}
|
|
|
|
const memberScope = sourceCode.getScope(node);
|
|
const memberRootIdentifier = getRootIdentifier(node.object);
|
|
const memberRootVariable = memberRootIdentifier && findVariable(memberScope, memberRootIdentifier);
|
|
if (
|
|
declaration.rootIdentifierName
|
|
&& memberRootIdentifier?.name === declaration.rootIdentifierName
|
|
&& memberRootVariable !== declaration.rootVariable
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (isRootVariableReassigned(declaration, node, memberScope, sourceCode)) {
|
|
return;
|
|
}
|
|
|
|
const {scope, objectPattern} = declaration;
|
|
|
|
// Property is destructured outside the current scope
|
|
if (!isChildInParentScope(memberScope, scope)) {
|
|
return;
|
|
}
|
|
|
|
const member = sourceCode.getText(node.property);
|
|
const memberDestructuredInNestedPattern = isMemberDestructuredInNestedPatternWithRest(objectPattern, member);
|
|
|
|
const destructuredProperties = objectPattern.properties.filter(property =>
|
|
isIdentifierProperty(property)
|
|
&& property.value.type === 'Identifier');
|
|
|
|
const lastProperty = objectPattern.properties.at(-1);
|
|
const hasRest = lastProperty?.type === 'RestElement';
|
|
const expression = sourceCode.getText(node);
|
|
|
|
// Member might already be destructured
|
|
const destructuredMember = destructuredProperties.find(property =>
|
|
property.key.name === member);
|
|
|
|
if (!destructuredMember) {
|
|
if (memberDestructuredInNestedPattern) {
|
|
return;
|
|
}
|
|
|
|
// Don't destructure additional members when rest is used
|
|
if (hasRest) {
|
|
return;
|
|
}
|
|
|
|
// Destructured member collides with an existing identifier
|
|
if (getAvailableVariableName(member, [memberScope]) !== member) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Don't try to fix nested member expressions
|
|
if (node.parent.type === 'MemberExpression') {
|
|
return {
|
|
node,
|
|
messageId: MESSAGE_ID,
|
|
};
|
|
}
|
|
|
|
const newMember = destructuredMember ? destructuredMember.value.name : member;
|
|
|
|
return {
|
|
node,
|
|
messageId: MESSAGE_ID,
|
|
suggest: [{
|
|
messageId: MESSAGE_ID_SUGGEST,
|
|
data: {
|
|
expression,
|
|
property: newMember,
|
|
},
|
|
* fix(fixer) {
|
|
const {properties} = objectPattern;
|
|
const lastProperty = properties.at(-1);
|
|
|
|
yield fixer.replaceText(node, newMember);
|
|
|
|
if (!destructuredMember) {
|
|
yield lastProperty
|
|
? fixer.insertTextAfter(lastProperty, `, ${newMember}`)
|
|
: fixer.replaceText(objectPattern, `{${newMember}}`);
|
|
}
|
|
},
|
|
}],
|
|
};
|
|
});
|
|
};
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
const config = {
|
|
create,
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'Use destructured variables over properties.',
|
|
recommended: false,
|
|
},
|
|
hasSuggestions: true,
|
|
messages: {
|
|
[MESSAGE_ID]: 'Use destructured variables over properties.',
|
|
[MESSAGE_ID_SUGGEST]: 'Replace `{{expression}}` with destructured property `{{property}}`.',
|
|
},
|
|
},
|
|
};
|
|
|
|
export default config;
|