Skip to content

SimplifyBooleanExpressionVisitor: do not drop operands with side effects - #8447

Open
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/simplify-boolean-expression-preserve-evaluation
Open

SimplifyBooleanExpressionVisitor: do not drop operands with side effects#8447
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/simplify-boolean-expression-preserve-evaluation

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Suggested review order: 6 of 52 (Score: 8)
Review first: openrewrite/rewrite-static-analysis#969

What's changed?

SimplifyBooleanExpressionVisitor now applies a boolean identity only when the operand it deletes can be dropped unobserved.

Rewriting x && false to false, or x && x to x, preserves the value of an expression but deletes the evaluation of an operand. On main that happens by tree shape alone, so evaluation a program can observe disappears:

Input Output on main
effect() && false false - effect() never runs
next().value && next().value next().value - one call instead of two
arr[5] && false false - no ArrayIndexOutOfBoundsException
if (false && o instanceof String s) { s.length(); } if (false) { s.length(); } - does not compile
if (o is String && false) { o.length } (Kotlin) drops the smart cast - does not compile

With this change the visitor rewrites none of them.

A new predicate, isEvaluationFreeOfObservableEffects, gates the five places a simplification drops an evaluation or folds two into one: x && false, x || true, x && x, x || x and x.equals(x). It is a recursive allow list - literals, identifiers, parentheses, field access, non-modifying unaries, instanceof without a pattern, ternaries, and binary operators other than +, / and % (string concatenation can call a user defined toString(); / and % can throw ArithmeticException). Method calls, array access, casts, new, assignments and reads of volatile variables are rejected. String#isEmpty() and String#equals(Object) are the only invocations accepted, because the visitor already folds both and String is final, so the only effect either can have is a NullPointerException on a null receiver.

Field access and instanceof are accepted in Java sources only: in the other languages that inherit this visitor a.b is a property read that may run a getter, and a type test such as Kotlin's o is String narrows the operand for the code it guards.

A short circuited operand is never evaluated, but it can still declare a pattern variable the surrounding code reads, so declaresPatternVariable guards that case separately.

What's your motivation?

Recipe: SimplifyBooleanExpressionVisitor through its consuming recipes.

Before

case 0: yield (x = 1) == 1 || true;

Actual after the recipe

case 0: yield true;

Expected after the recipe

(unchanged)

This visitor has no recipe id of its own. InvertCondition and RemoveObjectsIsNull here, and SimplifyBooleanExpression, SimplifyConstantIfBranchExecution and SystemGetSecurityManagerToNull in rewrite-static-analysis and rewrite-migrate-java, all inherit its behaviour, in every language that extends the Java LST. Compiled and run with Java 21, the inputs above and main's output for them behave differently; the last two do not compile at all.

Confirmed real-world executions

Both executions used org.openrewrite.recipe:rewrite-static-analysis:2.41.0.

Project Stars on 2026-08-16 Location
OpenJDK 23,233 DefiniteAssignment1.java at b545bc4e
OpenJDK 8 Updates 304 PackerImpl.java at aa3f9dea

In current OpenJDK, the released recipe changed 11 occurrences and removed assignments that the compiler regression test reads later. The generated source fails definite-assignment checks. In OpenJDK 8 Updates, it deletes the side-effecting retainAll(...) || true assertion expression and emits invalid Java.

Anything in particular you'd like reviewers to focus on?

No existing expectation changed: both touched test files gain lines only. Two gaps remain, and main behaves the same way in both, so neither is a regression:

  • s.isEmpty() && false on a null s still folds to false, losing the NullPointerException, because those two String calls are on the allow list.
  • Outside Java a bare identifier, as in flag && false, is still dropped. Rejecting unattributed identifiers as well would stop almost all simplification in the languages that do not attribute them.

Have you considered any alternatives or workarounds?

A deny list mirroring SideEffects.mayHaveSideEffects in rewrite-static-analysis would give both repositories one rule. I tried it and did not keep it: it treats a.isEmpty() as impure, so the visitor stops folding it and the existing simplifyLiteralNull rows fail, and it treats array access, casts and volatile reads as pure, which are exactly the cases this change is about. Switching later means replacing the body of isEvaluationFreeOfObservableEffects and nothing else, since every call site goes through it.

Any additional context

Pre-existing tests changed: None.

Adds 10 tests across rewrite-java-test, rewrite-groovy and rewrite-kotlin. Seven cover evaluation that must now be kept and fail without the code change; the other three pin simplifications this change keeps performing.

This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.

Checklist

Boolean identities preserve the resulting value, not the evaluation
that produced it. This visitor applied them by tree shape alone, so
it dropped or de-duplicated evaluation that can mutate state, throw,
block, synchronize, perform I/O or return a changing value:
effect() && false folded to false, effect() || true to true, and
next().value && next().value and nextString().equals(nextString())
each lost one of two evaluations. SimplifyBooleanExpression and
SimplifyConstantIfBranchExecution reuse this visitor and inherited
the same behavior.

Gate every identity that drops or de-duplicates an operand on a single
conservative purity and repeatability predicate. It accepts an allow
list of node kinds only, rejects volatile reads, array access and
casts, and among invocations allows only String#isEmpty() and
String#equals(Object), both of which this visitor already constant
folds.

For review: a short-circuited operand is still dropped, except when it
declares a pattern variable that the surrounding code reads, since
dropping it would delete the declaration. The predicate also keeps
eliding a few effects that were not preserved before it existed
either, chiefly NullPointerException from a null receiver or from
unboxing; its javadoc lists them. Outside Java only qualified field
reads are guarded, because a property read there can run a getter, so
a bare flag && false still simplifies exactly as it did before.
A Kotlin `is` never sets a pattern but still narrows the operand for the guarded code, so dropping it produced source that no longer compiles.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants