UNPKG

eslint-plugin-unicorn

Version:
270 lines (235 loc) • 8.21 kB
import {isReferenceIdentifier} from './ast/index.js'; import { containsNode, getCommentSafeProblem, getLogicalExpressionChildText, getPreviousNode, isParenthesized, isProcessExitCall, isTypeScriptExpressionWrapper, unwrapTypeScriptExpression, } from './utils/index.js'; const MESSAGE_ID = 'prefer-combined-guards'; const messages = { [MESSAGE_ID]: 'Combine this guard with the previous one using `||`.', }; const exitStatementTypes = new Set([ 'ReturnStatement', 'ThrowStatement', 'BreakStatement', 'ContinueStatement', ]); function isSimpleCondition(node, allowLogicalOr = true) { node = unwrapTypeScriptExpression(node); switch (node.type) { case 'LogicalExpression': { return allowLogicalOr && node.operator === '||' && isSimpleCondition(node.left) && isSimpleCondition(node.right); } case 'UnaryExpression': case 'AwaitExpression': { return isSimpleCondition(node.argument, false); } case 'ConditionalExpression': case 'AssignmentExpression': case 'SequenceExpression': case 'ArrowFunctionExpression': case 'YieldExpression': { return false; } default: { return true; } } } function getGuardStatements(node, context, checkMultiStatementBodies) { if (node?.type !== 'IfStatement' || node.alternate) { return; } const {consequent} = node; const statements = consequent.type === 'BlockStatement' ? consequent.body : [consequent]; if ( statements.length === 0 || (statements.length > 1 && !checkMultiStatementBodies) ) { return; } const exit = statements.at(-1); if ( exitStatementTypes.has(exit.type) || ( exit.type === 'ExpressionStatement' && isProcessExitCall(exit.expression, context) ) ) { return statements; } } const getExitText = (node, sourceCode) => { const value = node.expression ?? node.argument ?? node.label; return value ? sourceCode.getText(value) : ''; }; const containsTaggedTemplate = (node, context) => containsNode(node, context, childNode => childNode.type === 'TaggedTemplateExpression'); // Autofix passes can expose isolated parser programs without reliable reference types. const hasFullTypeInformation = parserServices => Boolean(parserServices?.program && !parserServices.program.getCompilerOptions().noResolve); const isExitUnsafeToCombine = (node, context) => { const expressions = node.type === 'ExpressionStatement' ? node.expression.arguments : [node.argument].filter(Boolean); const {parserServices} = context.sourceCode; return expressions.some(expression => containsTaggedTemplate(expression, context) || ( parserServices?.esTreeNodeToTSNodeMap && !hasFullTypeInformation(parserServices) && containsNode(expression, context, childNode => (isReferenceIdentifier(childNode) && childNode.parent.type !== 'TSTypeReference') || childNode.type === 'ThisExpression' || childNode.type === 'Super' || childNode.type === 'JSXIdentifier' || childNode.type === 'JSXMemberExpression') ), ); }; const canCombineBodies = (previousStatements, statements, context) => previousStatements.length === statements.length && statements.slice(0, -1).every((statement, index) => context.sourceCode.getText(statement) === context.sourceCode.getText(previousStatements[index]) && !containsTaggedTemplate(statement, context)); // Non-identifier node types that TypeScript can narrow. const referenceTypes = new Set([ 'MemberExpression', 'ThisExpression', 'JSXMemberExpression', ]); function hasSameReferenceTypes(previousNode, node, parserServices, visitorKeys) { if ( ( isReferenceIdentifier(node) || referenceTypes.has(node.type) || (node.type === 'JSXIdentifier' && node.parent.type !== 'JSXAttribute') ) && parserServices.getTypeAtLocation(previousNode) !== parserServices.getTypeAtLocation(node) ) { return false; } // Custom parsers can produce node types without visitor keys /* node:coverage ignore next */ for (const key of visitorKeys[node.type] ?? []) { const children = [node[key]].flat(); const previousChildren = [previousNode[key]].flat(); for (const [index, child] of children.entries()) { if (child?.type && !hasSameReferenceTypes(previousChildren[index], child, parserServices, visitorKeys)) { return false; } } } return true; } /* Statements, including the exit, may depend on each guard's TypeScript narrowing. The combined body sees the union of both narrowed types, so with full type information, combine them only when every reference has the same type in both bodies. Without type information, only single-statement bodies with reference-free exits are allowed. */ function isNarrowingPreserved(previousStatements, statements, sourceCode) { const {parserServices, visitorKeys} = sourceCode; if (!parserServices?.esTreeNodeToTSNodeMap) { return true; } if (!hasFullTypeInformation(parserServices)) { return statements.length === 1; } return statements.every((statement, index) => hasSameReferenceTypes(previousStatements[index], statement, parserServices, visitorKeys)); } function getConditionText(node, property, context) { if ( isTypeScriptExpressionWrapper(node) && !isParenthesized(node, context) ) { return `(${context.sourceCode.getText(node)})`; } return getLogicalExpressionChildText(node, context, {operator: '||', property}); } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const {sourceCode} = context; const {checkCompoundConditions, checkMultiStatementBodies} = context.options[0]; context.on('IfStatement', node => { const statements = getGuardStatements(node, context, checkMultiStatementBodies); if (!statements) { return; } const previousNode = getPreviousNode(node, context); const previousStatements = getGuardStatements(previousNode, context, checkMultiStatementBodies); if (!previousStatements) { return; } const exit = statements.at(-1); const previousExit = previousStatements.at(-1); if ( previousExit.type !== exit.type // Preserve significant whitespace, including ASI inside returned functions. || getExitText(previousExit, sourceCode) !== getExitText(exit, sourceCode) || isExitUnsafeToCombine(exit, context) || !canCombineBodies(previousStatements, statements, context) || (!checkCompoundConditions && (!isSimpleCondition(previousNode.test) || !isSimpleCondition(node.test))) ) { return; } const range = [sourceCode.getRange(previousNode)[0], sourceCode.getRange(node)[1]]; // Withhold fixes for comments that describe distinct exit reasons. const firstLeadingComment = sourceCode.getCommentsBefore(previousNode)[0]; const commentRange = [firstLeadingComment ? sourceCode.getRange(firstLeadingComment)[0] : range[0], range[1]]; if (!isNarrowingPreserved(previousStatements, statements, sourceCode)) { return; } return getCommentSafeProblem(context, { node, messageId: MESSAGE_ID, * fix(fixer) { const left = getConditionText(previousNode.test, 'left', context); const right = getConditionText(node.test, 'right', context); const closingParenthesis = sourceCode.getTokenBefore(node.consequent); // Retain the second body and its trailing boundary to preserve semicolon insertion. const suffix = sourceCode.text.slice(sourceCode.getRange(closingParenthesis)[1], range[1]); yield fixer.replaceTextRange(range, `if (${left} || ${right})${suffix}`); }, }, commentRange); }); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer combining consecutive guards with identical exit statements.', recommended: true, }, fixable: 'code', schema: [{ type: 'object', properties: { checkCompoundConditions: { type: 'boolean', description: 'Check guards with compound conditions.', }, checkMultiStatementBodies: { type: 'boolean', description: 'Check guards whose bodies run the same statements before the exit.', }, }, additionalProperties: false, }], defaultOptions: [{checkCompoundConditions: false, checkMultiStatementBodies: false}], messages, languages: [ 'js/js', ], }, }; export default config;