eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
241 lines (200 loc) • 9.23 kB
JavaScript
import {isDirectEvalCall, isFunction} from '../ast/index.js';
import {
containsNode,
getIndentUnit,
getLineIndent,
getLinebreak,
getNegatedExpressionText,
getParenthesizedText,
getUnwrappedBranchText,
hasCommentInRange,
hasMultilineToken,
isBlockScopedDeclaration,
unwrapTypeScriptExpression,
wouldRemoveComments,
} from '../utils/index.js';
import {getPunctuatorBinaryExpressionOperatorToken} from '../utils/comparison.js';
// Shared logic for `prefer-continue` and `prefer-early-return`, which rewrite an `if` statement wrapping the remainder of a body into an early exit guard.
const lexicalDeclarationKinds = new Set(['const', 'let']);
const oppositeRelationalOperators = new Map([
['<', '>='],
['<=', '>'],
['>', '<='],
['>=', '<'],
]);
/**
Get the number of non-empty statements in the consequent of an `if` statement.
@param {import('estree').IfStatement} ifStatement
@returns {number}
*/
export const getConsequentStatementCount = ({consequent}) => {
if (consequent.type === 'EmptyStatement') {
return 0;
}
return consequent.type === 'BlockStatement'
? consequent.body.filter(({type}) => type !== 'EmptyStatement').length
: 1;
};
const isNodeInsideRange = (node, [start, end], context) => {
const [nodeStart, nodeEnd] = context.sourceCode.getRange(node);
return nodeStart >= start && nodeEnd <= end;
};
const isUnsupportedBlockScopedDeclaration = node =>
isBlockScopedDeclaration(node)
&& !(
node.type === 'VariableDeclaration'
&& lexicalDeclarationKinds.has(node.kind)
);
const hasDirectUnsupportedBlockScopedDeclaration = node =>
isUnsupportedBlockScopedDeclaration(node)
|| (
node.type === 'BlockStatement'
&& node.body.some(node => isUnsupportedBlockScopedDeclaration(node))
);
const getNegatedConditionText = (node, context) => {
if (
node.type === 'UnaryExpression'
&& node.operator === '!'
) {
const {sourceCode} = context;
const [, operatorEnd] = sourceCode.getRange(sourceCode.getFirstToken(node));
const [argumentStart] = sourceCode.getRange(node.argument);
if (hasCommentInRange(context, [operatorEnd, argumentStart])) {
return sourceCode.text.slice(operatorEnd, sourceCode.getRange(node)[1]).trim();
}
return getParenthesizedText(node.argument, context);
}
return getNegatedExpressionText(node, context);
};
const getConditionRange = (ifStatement, context) => {
const {sourceCode} = context;
const openingParenthesisToken = sourceCode.getTokenAfter(sourceCode.getFirstToken(ifStatement));
const closingParenthesisToken = sourceCode.getTokenBefore(ifStatement.consequent);
return [
sourceCode.getRange(openingParenthesisToken)[1],
sourceCode.getRange(closingParenthesisToken)[0],
];
};
const getNegatedIfConditionText = (ifStatement, context) => {
const {sourceCode} = context;
const conditionRange = getConditionRange(ifStatement, context);
// Comments in the parentheses around the test
if (wouldRemoveComments(context, conditionRange, [ifStatement.test])) {
const conditionText = sourceCode.text.slice(...conditionRange).trimStart();
return `!(${conditionText})`;
}
return getNegatedConditionText(ifStatement.test, context);
};
/**
Get the replacement for an `if` statement wrapping the remainder of a body: a negated guard that exits early, followed by the unwrapped consequent.
@param {import('estree').IfStatement} ifStatement
@param {'continue' | 'return'} exitKeyword
@param {import('eslint').Rule.RuleContext} context
@param {string} [conditionText] Override the negated condition for an editor suggestion.
@returns {string}
*/
export const getEarlyExitReplacementText = (ifStatement, exitKeyword, context, conditionText = getNegatedIfConditionText(ifStatement, context)) => {
const ifIndent = getLineIndent(ifStatement, context);
const consequentText = getUnwrappedBranchText(ifStatement.consequent, context);
const linebreak = getLinebreak(context);
return `if (${conditionText}) {${linebreak}${ifIndent}${getIndentUnit(context)}${exitKeyword};${linebreak}${ifIndent}}${linebreak}${linebreak}${consequentText}`;
};
/**
Get an early-exit editor fix using the opposite relational operator. Call only when the automatic rewrite is available. Unlike logical negation, this changes behavior for `NaN` and values coerced to `NaN`.
@param {import('estree').IfStatement} ifStatement
@param {'continue' | 'return'} exitKeyword
@param {import('eslint').Rule.RuleContext} context
@returns {Function | undefined}
*/
export const getEarlyExitOppositeComparisonFix = (ifStatement, exitKeyword, context) => {
const comparison = unwrapTypeScriptExpression(ifStatement.test);
if (comparison.type !== 'BinaryExpression' || !oppositeRelationalOperators.has(comparison.operator)) {
return;
}
const {sourceCode} = context;
const [conditionStart, conditionEnd] = getConditionRange(ifStatement, context);
const operatorToken = getPunctuatorBinaryExpressionOperatorToken(comparison, context);
const [operatorStart, operatorEnd] = sourceCode.getRange(operatorToken);
const conditionText = sourceCode.text.slice(conditionStart, operatorStart).trimStart()
+ oppositeRelationalOperators.get(comparison.operator)
+ sourceCode.text.slice(operatorEnd, conditionEnd);
return fixer => fixer.replaceText(ifStatement, getEarlyExitReplacementText(ifStatement, exitKeyword, context, conditionText));
};
const getDirectLexicalDeclarationVariables = (node, context) => {
if (node.type !== 'BlockStatement') {
return [];
}
return node.body.flatMap(node => {
if (
node.type === 'VariableDeclaration'
&& lexicalDeclarationKinds.has(node.kind)
) {
return context.sourceCode.getDeclaredVariables(node);
}
return [];
});
};
const hasReferenceToVariableName = (node, context, names) => {
const {sourceCode} = context;
const [start, end] = sourceCode.getRange(node);
const hasDefinitionInsideNode = variable => variable.identifiers.some(identifier => isNodeInsideRange(identifier, [start, end], context));
const hasReference = scope => scope.references.some(reference => {
const [referenceStart, referenceEnd] = sourceCode.getRange(reference.identifier);
return referenceStart >= start
&& referenceEnd <= end
&& names.has(reference.identifier.name)
&& !(
reference.resolved
&& hasDefinitionInsideNode(reference.resolved)
);
}) || scope.childScopes.some(scope => hasReference(scope));
return hasReference(sourceCode.getScope(node));
};
// A function body shares its scope with the parameters, so a moved declaration must not redeclare one of them. A loop body has its own scope.
const hasFunctionScopeVariable = (node, context, names) =>
isFunction(node)
&& context.sourceCode.scopeManager.acquire(node, true).variables.some(variable => names.has(variable.name));
const canSafelyMoveLexicalDeclarations = (ifStatement, context) => {
const variables = getDirectLexicalDeclarationVariables(ifStatement.consequent, context);
if (variables.length === 0) {
return true;
}
if (ifStatement.parent.body.length > 1) {
return false;
}
const names = new Set(variables.map(variable => variable.name));
return !containsNode(ifStatement.test, context, isDirectEvalCall)
&& !hasFunctionScopeVariable(ifStatement.parent.parent, context, names)
&& !hasReferenceToVariableName(ifStatement.test, context, names);
};
const hasMultilineUnbracedConsequent = (ifStatement, context) =>
ifStatement.consequent.type !== 'BlockStatement'
&& context.sourceCode.getText(ifStatement.consequent).includes('\n');
// The replacement ends with the body of a braced branch. When that body ends with a line comment, a token after the `if` statement on the same line (like the closing brace of the function in `}}`) would be swallowed by it.
const hasLineCommentBeforeSameLineToken = (ifStatement, context) => {
const {sourceCode} = context;
const {consequent} = ifStatement;
// Only a braced body moves its closing brace away, an unbraced one keeps the source layout
if (consequent.type !== 'BlockStatement') {
return false;
}
const closingBrace = sourceCode.getLastToken(consequent);
return sourceCode.getTokenBefore(closingBrace, {includeComments: true}).type === 'Line'
&& sourceCode.getLoc(sourceCode.getTokenAfter(closingBrace, {includeComments: true})).start.line === sourceCode.getLoc(closingBrace).end.line;
};
/**
Check whether an `if` statement that is the last statement of a function or loop body can be rewritten with `getEarlyExitReplacementText()` without changing behavior or losing comments. Comments after the `if` statement are not checked.
@param {import('estree').IfStatement} ifStatement
@param {import('eslint').Rule.RuleContext} context
@returns {boolean}
*/
export const canRewriteToEarlyExit = (ifStatement, context) => {
const {consequent} = ifStatement;
return !hasDirectUnsupportedBlockScopedDeclaration(consequent)
&& canSafelyMoveLexicalDeclarations(ifStatement, context)
&& !hasMultilineToken(consequent, context)
// Comments inside the `if` statement, outside the condition and the consequent
&& !wouldRemoveComments(context, ifStatement, [getConditionRange(ifStatement, context), consequent])
&& !hasMultilineUnbracedConsequent(ifStatement, context)
&& !hasLineCommentBeforeSameLineToken(ifStatement, context);
};