SimplifyBooleanExpressionVisitor: do not drop operands with side effects - #8447
Open
martinfrancois wants to merge 3 commits into
Open
Conversation
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.
3 tasks
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.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested review order: 6 of 52 (Score: 8)
Review first: openrewrite/rewrite-static-analysis#969
What's changed?
SimplifyBooleanExpressionVisitornow applies a boolean identity only when the operand it deletes can be dropped unobserved.Rewriting
x && falsetofalse, orx && xtox, 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:effect() && falsefalse-effect()never runsnext().value && next().valuenext().value- one call instead of twoarr[5] && falsefalse- noArrayIndexOutOfBoundsExceptionif (false && o instanceof String s) { s.length(); }if (false) { s.length(); }- does not compileif (o is String && false) { o.length }(Kotlin)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 || xandx.equals(x). It is a recursive allow list - literals, identifiers, parentheses, field access, non-modifying unaries,instanceofwithout a pattern, ternaries, and binary operators other than+,/and%(string concatenation can call a user definedtoString();/and%can throwArithmeticException). Method calls, array access, casts,new, assignments and reads ofvolatilevariables are rejected.String#isEmpty()andString#equals(Object)are the only invocations accepted, because the visitor already folds both andStringis final, so the only effect either can have is aNullPointerExceptionon a null receiver.Field access and
instanceofare accepted in Java sources only: in the other languages that inherit this visitora.bis a property read that may run a getter, and a type test such as Kotlin'so is Stringnarrows 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
declaresPatternVariableguards that case separately.What's your motivation?
Recipe:
SimplifyBooleanExpressionVisitorthrough its consuming recipes.Before
Actual after the recipe
Expected after the recipe
(unchanged)
This visitor has no recipe id of its own.
InvertConditionandRemoveObjectsIsNullhere, andSimplifyBooleanExpression,SimplifyConstantIfBranchExecutionandSystemGetSecurityManagerToNullin 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.DefiniteAssignment1.javaatb545bc4ePackerImpl.javaataa3f9deaIn 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(...) || trueassertion 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() && falseon a nullsstill folds tofalse, losing theNullPointerException, because those twoStringcalls are on the allow list.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.mayHaveSideEffectsin rewrite-static-analysis would give both repositories one rule. I tried it and did not keep it: it treatsa.isEmpty()as impure, so the visitor stops folding it and the existingsimplifyLiteralNullrows fail, and it treats array access, casts andvolatilereads as pure, which are exactly the cases this change is about. Switching later means replacing the body ofisEvaluationFreeOfObservableEffectsand 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-groovyandrewrite-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
./gradlew buildlocally, and committed any resulting changes torecipes.csv