eslint-plugin-de-morgan
Version:
ESLint plugin for transforming negated boolean expressions via De Morgan’s laws
161 lines (160 loc) • 6.1 kB
JavaScript
import { isLogicalExpression } from './is-logical-expression.js'
import { isBinaryExpression } from './is-binary-expression.js'
import { parenthesize } from './parenthesize.js'
import { isBoolean } from './is-boolean.js'
/**
* Toggles the negation of the given expression. The function applies the
* following strategies in order:
*
* 1. If the expression is a `BinaryExpression` with a supported binary operator
* (`===`, `!==`, `==`, or `!=`), it returns the expression with the operator
* toggled.
* 2. If the expression is a boolean literal, it returns the toggled boolean
* literal.
* 3. If the expression is a `UnaryExpression` (i.e. Already negated), it will
* return the expression without the leading '!'.
* 4. Otherwise, it returns the expression with a '!' prepended..
*
* When `canStripNegation` is false, an already negated expression keeps an
* exact double negation (`!x` becomes `!!x`) instead of being stripped to the
* bare operand. Stripping is only truthiness-preserving, so it is unsafe in
* positions where the produced value itself is observable.
*
* @param node - The expression node to toggle negation on.
* @param context - The ESLint rule context.
* @param canStripNegation - Whether a leading '!' may be stripped from an
* already negated expression.
* @returns The toggled expression.
*/
function toggleNegation(node, context, canStripNegation = true) {
let toggleStrategies = [
{
transformer: toggleBinaryExpression,
predicate: isBinaryExpression,
},
{
transformer: toggleBooleanLiteral,
predicate: isBoolean,
},
{
transformer: toggleLogicalExpression,
predicate: isLogicalExpression,
},
]
for (let { transformer, predicate } of toggleStrategies) {
if (predicate(node)) {
return transformer(node, context)
}
}
return toggleUnaryExpression(node, context, canStripNegation)
}
/**
* Toggles the operator in a BinaryExpression. Only equality operators (`===`,
* `!==`, `==`, and `!=`) are exact logical complements, so only they are
* toggled, e.g. `a === b` becomes `a !== b`. The toggle replaces just the
* operator token inside the node's original source text, so parentheses and
* comments around the operands are preserved. Every other binary expression is
* wrapped in `!(…)` instead — relational operators like `<` cannot be toggled
* because both `a < b` and `a >= b` are false when an operand is NaN.
*
* @param node - The binary expression ESLint node.
* @param context - The ESLint rule context.
* @returns The toggled expression.
*/
function toggleBinaryExpression(node, context) {
let text = context.sourceCode.getText(node)
let toggledOperator = {
'===': '!==',
'!==': '===',
'==': '!=',
'!=': '==',
}[node.operator]
if (!toggledOperator) {
return `!(${text})`
}
let operatorToken = context.sourceCode.getTokenAfter(node.left, {
filter: token => token.value === node.operator,
})
if (!operatorToken || !node.range) {
return `!(${text})`
}
let [nodeStart] = node.range
let [tokenStart, tokenEnd] = operatorToken.range
return (
text.slice(0, tokenStart - nodeStart) +
toggledOperator +
text.slice(tokenEnd - nodeStart)
)
}
/**
* Toggles the negation of the given expression. If the expression starts with a
* '!', this function removes the leading '!' and returns the rest of the
* expression. Otherwise, it prepends a '!' to the expression. Expressions that
* bind looser than the unary '!' operator (assignments, ternaries, sequences,
* arrow functions, and `yield`) are wrapped in `!(…)` instead, since prepending
* a bare '!' would re-associate them and change the meaning or produce invalid
* code. When `canStripNegation` is false, a leading '!' is never stripped and
* an exact double negation is emitted instead, so the result stays a strict
* boolean. This function does not assume that the given expression is
* necessarily a UnaryExpression; it simply toggles the presence of a leading
* '!' in the source text.
*
* @param node - The ESLint expression node.
* @param context - The ESLint rule context.
* @param canStripNegation - Whether a leading '!' may be stripped.
* @returns The expression with toggled negation.
*/
function toggleUnaryExpression(node, context, canStripNegation) {
let content = context.sourceCode.getText(node).trim()
if (
/* @__PURE__ */ new Set([
'ArrowFunctionExpression',
'ConditionalExpression',
'AssignmentExpression',
'SequenceExpression',
'YieldExpression',
]).has(node.type)
) {
return `!(${content})`
}
if (!canStripNegation) {
return `!${content}`
}
return toggleCode(content)
}
/**
* Toggles the negation of a logical expression. The expression is wrapped in
* parentheses if it is not already parenthesized, and the leading '!' is
* toggled, e.g. `a && b` becomes `!(a && b)`.
*
* @param node - The logical expression ESLint node.
* @param context - The ESLint rule context.
* @returns The toggled expression.
*/
function toggleLogicalExpression(node, context) {
return toggleCode(parenthesize(context.sourceCode.getText(node).trim()))
}
/**
* Toggles the leading exclamation mark in the given code string. If the
* provided code string starts with an exclamation mark ('!'), this function
* removes it and returns the resulting string. Otherwise, it prepends an
* exclamation mark to the string and returns it.
*
* @param code - The code string to toggle the exclamation mark on.
* @returns The code string with the toggled exclamation mark.
*/
function toggleCode(code) {
return code.startsWith('!') ? code.replace(/^!/u, '') : `!${code}`
}
/**
* Toggles a boolean literal. If the literal's value is boolean, returns the
* toggled boolean literal as a string: `true` becomes `"false"` and `false`
* becomes `"true"`.
*
* @param node - The ESLint literal node.
* @returns The toggled boolean literal.
*/
function toggleBooleanLiteral(node) {
return node.value ? 'false' : 'true'
}
export { toggleNegation }