feat(minifier): expand de morgan's optimization to allow move of ! (#25930)

Expand negation to include move of `!` operator to inner children

```js
!(a == b && c == 3 && a)
a != b || c != 3 | !a
```

delta calculation is dependent on boolean context
- if we expect truthly/falsly value negation of `!a` will be converted
to `a` (-1 delta)
- in ambiguous context `!a` will be converted to `!!a` (+1 delta)

```js
a = !!(!b || !c);
//   ^ ^     ^ -3
a = !(b || c)
//  ^ ^    ^ +1 - if we could detect if parens can be removed it would be -1
//  ^^^    ^^ -1 - if we would know that we can remove parens
```
This commit is contained in:
Armano authored and GitHub committed 2026-08-29 02:08:57 +08:00
1 parent baf4b1e00a
commit 3ed4f6ad90
6 files changed
+90 -39

No files matched your search

@@ -54,10 +54,15 @@ impl<'a> PeepholeOptimizations {
// The fold is exact and involutive: a later `minimize_not` restores
// the original chain at no cost, so shapes that consume the `!` for
// free (branch swaps, `!!` collapses) are unaffected.
//
// delta: we do not know the parent precedence, so we must conservatively
// assume negated operands may still need ().
// delta == 0 it does assume the parent will not drop parens.
Expression::LogicalExpression(logical_expr)
if Self::de_morgan_paren_delta(logical_expr).is_some_and(|delta| delta <= 0) =>
if Self::de_morgan_paren_delta(logical_expr, boolean_context)
.is_some_and(|delta| delta <= 0) =>
{
Self::de_morgan_invert_logical(logical_expr);
Self::de_morgan_invert_logical(logical_expr, ctx, boolean_context);
true
}
// "!(a, b)" => "a, !b"
@@ -97,7 +102,7 @@ impl<'a> PeepholeOptimizations {
/// Character delta from parentheses added or removed by De Morgan's law
/// (flipping `&&` <-> `||` changes which nested operands need parens), or
/// `None` if some operand cannot invert its operator in place.
fn de_morgan_paren_delta(e: &LogicalExpression<'a>) -> Option<i32> {
fn de_morgan_paren_delta(e: &LogicalExpression<'a>, boolean_context: bool) -> Option<i32> {
if !matches!(e.operator, LogicalOperator::And | LogicalOperator::Or) {
return None;
}
@@ -105,8 +110,11 @@ impl<'a> PeepholeOptimizations {
for side in [&e.left, &e.right] {
match side {
Expression::BinaryExpression(b) if b.operator.is_equality() => {}
Expression::UnaryExpression(u) if u.operator.is_not() => {
delta += if boolean_context { -1 } else { 1 }
}
Expression::LogicalExpression(child) => {
delta += Self::de_morgan_paren_delta(child)?;
delta += Self::de_morgan_paren_delta(child, boolean_context)?;
// `&&` under `||` prints bare but its inversion (`||` under
// `&&`) needs parens; the reverse drops parens.
match (e.operator, child.operator) {
@@ -115,6 +123,24 @@ impl<'a> PeepholeOptimizations {
_ => {}
}
}
Expression::Identifier(_)
| Expression::ThisExpression(_)
| Expression::NullLiteral(_)
| Expression::NumericLiteral(_)
| Expression::BigIntLiteral(_)
| Expression::RegExpLiteral(_)
| Expression::StringLiteral(_)
| Expression::TemplateLiteral(_)
| Expression::ImportMeta(_)
| Expression::Super(_)
| Expression::StaticMemberExpression(_)
| Expression::ComputedMemberExpression(_)
| Expression::CallExpression(_)
| Expression::ChainExpression(_)
| Expression::ImportExpression(_)
| Expression::NewExpression(_)
| Expression::AwaitExpression(_) => delta += 1,
Expression::BooleanLiteral(_) => {}
_ => return None,
}
}
@@ -123,23 +149,36 @@ impl<'a> PeepholeOptimizations {
/// Apply De Morgan's law in place. Only called on chains approved by
/// [`Self::de_morgan_paren_delta`].
fn de_morgan_invert_logical(e: &mut LogicalExpression<'a>) {
fn de_morgan_invert_logical(
e: &mut LogicalExpression<'a>,
ctx: &mut TraverseCtx<'a>,
boolean_context: bool,
) {
e.operator = if e.operator == LogicalOperator::And {
LogicalOperator::Or
} else {
LogicalOperator::And
};
Self::de_morgan_invert(&mut e.left);
Self::de_morgan_invert(&mut e.right);
Self::de_morgan_invert(&mut e.left, ctx, boolean_context);
Self::de_morgan_invert(&mut e.right, ctx, boolean_context);
}
fn de_morgan_invert(expr: &mut Expression<'a>) {
match expr {
Expression::BinaryExpression(e) => {
e.operator = e.operator.equality_inverse_operator().unwrap();
}
Expression::LogicalExpression(e) => Self::de_morgan_invert_logical(e),
_ => unreachable!(),
fn de_morgan_invert(
expr: &mut Expression<'a>,
ctx: &mut TraverseCtx<'a>,
boolean_context: bool,
) {
if let Expression::LogicalExpression(e) = expr
&& !e.operator.is_coalesce()
{
Self::de_morgan_invert_logical(e, ctx, boolean_context);
return;
}
if !Self::try_negate_expression(expr, ctx, boolean_context) {
ctx.replace_expression_with(expr, |expr, ctx| {
Expression::new_unary_expression(expr.span(), UnaryOperator::LogicalNot, expr, ctx)
});
}
}
}
@@ -405,14 +405,14 @@ fn js_parser_test() {
test("a = !!(b && c)", "a = !!(b && c);");
test("a = !!(b || c)", "a = !!(b || c);");
test("a = !!(b ?? c)", "a = !!(b ?? c);");
test("a = !!(!b && c)", "a = !!(!b && c);");
test("a = !!(!b || c)", "a = !!(!b || c);");
test("a = !!(!b && c)", "a = !(b || !c);");
test("a = !!(!b || c)", "a = !(b && !c);");
test("a = !!(!b ?? c)", "a = !b;");
test("a = !!(b && !c)", "a = !!(b && !c);");
test("a = !!(b || !c)", "a = !!(b || !c);");
test("a = !!(b && !c)", "a = !(!b || c);");
test("a = !!(b || !c)", "a = !(!b && c);");
test("a = !!(b ?? !c)", "a = !!(b ?? !c);");
test("a = !!(!b && !c)", "a = !b && !c;");
test("a = !!(!b || !c)", "a = !b || !c;");
test("a = !!(!b && !c)", "a = !(b || c);");
test("a = !!(!b || !c)", "a = !(b && c);");
test("a = !!(!b ?? !c)", "a = !b;");
test("a = !!(b, c)", "a = (b, !!c);");
test("a = Boolean(b); var Boolean", "a = Boolean(b);var Boolean;");
@@ -356,9 +356,8 @@ fn test_minimize_demorgan20a() {
}
#[test]
#[ignore = "TODO: De Morgan's law optimization not yet implemented"]
fn test_minimize_demorgan20b() {
test("if (0!==c || 2!==a && 1!==a) g(); else f()", "(0!==c || 2!==a && 1!==a) ? g() : f()");
test("if (0!==c || 2!==a && 1!==a) g(); else f()", "c!==0 || a!==2 && a!==1 ? g() : f()");
}
#[test]
@@ -57,6 +57,19 @@ fn minimize_nots_with_de_morgan_negative_cases() {
// Existing shapes that consume the `!` for free must not regress.
test("var v = !!(a == b || c == d);", "var v = a == b || c == d;");
test("if (!(a == b && c == d)) x(); else y();", "a != b || c != d ? x() : y();");
// mixed unary-not leaves in both boolean and value contexts.
test("if (!(!a || b)) x();", "a && !b && x();");
test_same("var v = !(!a || b);");
test("if (!(a == b || !c && d == e)) x();", "a == b || !c && d == e || x();");
// parenthesis-size guard.
test_same("if (!(a == b || fn1())) throw x;");
test_same("if (!(a == b && fn1())) throw x;");
test("var v = !!(!a || b);", "var v = !(a && !b);");
test("var v = !!(a && !b);", "var v = !(!a || b);");
test_same("if (!(a < b || !c)) throw x;");
test_same("if (!(a in b || c == d)) throw x;");
test("if (!(a === b && (a ?? b))) throw x;", "if (!(a === b && (a ?? b))) throw x;");
test("if (!(a === b && !a)) throw x;", "if (a !== b || a) throw x;");
}
#[test]
+11 -11
View File
@@ -1,27 +1,27 @@
| Oxc props | ESBuild | Oxc props | ESBuild |
Original | minified | minified | gzip | gzip | Iterations | File
-------------------------------------------------------------------------------------
72.14 kB | 22.48 kB | 23.70 kB | 8.20 kB | 8.54 kB | 2 | react.development.js
72.14 kB | 22.48 kB | 23.70 kB | 8.19 kB | 8.54 kB | 2 | react.development.js
173.90 kB | 55.53 kB | 59.82 kB | 18.78 kB | 19.33 kB | 2 | moment.js
287.63 kB | 89.02 kB | 90.07 kB | 30.87 kB | 31.95 kB | 2 | jquery.js
287.63 kB | 89.01 kB | 90.07 kB | 30.85 kB | 31.95 kB | 2 | jquery.js
342.15 kB | 114.85 kB | 118.14 kB | 42.73 kB | 44.37 kB | 2 | vue.js
342.15 kB | 114.82 kB | 118.14 kB | 42.72 kB | 44.37 kB | 2 | vue.js
544.10 kB | 69.88 kB | 72.48 kB | 25.59 kB | 26.20 kB | 2 | lodash.js
544.10 kB | 69.88 kB | 72.48 kB | 25.58 kB | 26.20 kB | 2 | lodash.js
555.77 kB | 262.77 kB | 270.13 kB | 87.67 kB | 90.80 kB | 2 | d3.js
555.77 kB | 262.76 kB | 270.13 kB | 87.66 kB | 90.80 kB | 2 | d3.js
1.01 MB | 436.06 kB | 458.89 kB | 121.79 kB | 126.71 kB | 2 | bundle.min.js
1.01 MB | 436.04 kB | 458.89 kB | 121.78 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 634.41 kB | 646.76 kB | 158.45 kB | 163.73 kB | 2 | three.js
1.25 MB | 634.41 kB | 646.76 kB | 158.44 kB | 163.73 kB | 2 | three.js
2.14 MB | 707.71 kB | 724.14 kB | 160.11 kB | 181.07 kB | 2 | victory.js
2.14 MB | 707.66 kB | 724.14 kB | 160.09 kB | 181.07 kB | 2 | victory.js
3.20 MB | 959.19 kB | 1.01 MB | 317.26 kB | 331.56 kB | 2 | echarts.js
3.20 MB | 958.93 kB | 1.01 MB | 317.09 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.12 MB | 2.31 MB | 453.48 kB | 488.28 kB | 5 | antd.js
6.69 MB | 2.12 MB | 2.31 MB | 453.44 kB | 488.28 kB | 5 | antd.js
10.95 MB | 3.31 MB | 3.49 MB | 850.30 kB | 915.50 kB | 4 | typescript.js
10.95 MB | 3.31 MB | 3.49 MB | 850.04 kB | 915.50 kB | 4 | typescript.js
@@ -5,7 +5,7 @@ checker.ts:
sys deallocs: 150
sys alloc bytes: 14996600 # 15.00 MB
sys peak growth: 10169560 # 10.17 MB
arena allocs: 144012
arena allocs: 144024
arena reallocs: 28468
arena size: 19127248 # 19.13 MB
@@ -16,7 +16,7 @@ App.tsx:
sys deallocs: 61
sys alloc bytes: 2128555 # 2.13 MB
sys peak growth: 1612427 # 1.61 MB
arena allocs: 15670
arena allocs: 15676
arena reallocs: 2721
arena size: 2880752 # 2.88 MB
@@ -38,7 +38,7 @@ pdf.mjs:
sys deallocs: 2427
sys alloc bytes: 5334247 # 5.33 MB
sys peak growth: 3693355 # 3.69 MB
arena allocs: 44802
arena allocs: 44800
arena reallocs: 7718
arena size: 6306768 # 6.31 MB
@@ -49,7 +49,7 @@ antd.js:
sys deallocs: 1038
sys alloc bytes: 29976031 # 29.98 MB
sys peak growth: 19934725 # 19.93 MB
arena allocs: 351769
arena allocs: 351753
arena reallocs: 76289
arena size: 48779400 # 48.78 MB
@@ -71,6 +71,6 @@ kitchen-sink.tsx:
sys deallocs: 1854
sys alloc bytes: 5307512 # 5.31 MB
sys peak growth: 3707425 # 3.71 MB
arena allocs: 64966
arena allocs: 64963
arena reallocs: 12252
arena size: 9394240 # 9.39 MB
arena size: 9393120 # 9.39 MB