UnwrapElseAfterReturn: only unwrap when else declarations do not collide - #979
Draft
martinfrancois wants to merge 3 commits into
Draft
Conversation
Statements hoisted out of an else block move into the enclosing block, where the names they declare stay in scope to the end of that block. The recipe hoisted them unconditionally, so an else block declaring a name that a later statement declares again produced two declarations of the same name in one block, which Java forbids for method locals and local classes, and the output no longer compiled. A hoisted name could also capture a later unqualified use that had resolved to a field or a statically imported member, silently changing semantics. Both flattening branches, the plain else and the innermost else of an else-if chain, now inspect the statements that follow the if and leave the else in place when a hoisted name would collide with or capture one of them. The names considered are declared variables and local types plus every instanceof pattern variable anywhere inside a hoisted statement, because flow scoping (JLS 6.3.2) can carry a pattern variable past its own statement once that statement sits directly in the enclosing block. Not every pattern variable escapes that way, so the check errs toward keeping the else block and gives up a few rewrites that would have been safe. Names declared in a nested scope inside the else, a for loop variable for example, are not hoisted and still permit unwrapping. No existing test expectation changed.
This was referenced Aug 11, 2026
martinfrancois
marked this pull request as draft
August 17, 2026 08:08
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: 23 of 52 (Score: 5)
Review first: openrewrite/rewrite-migrate-java#1193
What's changed?
UnwrapElseAfterReturnnow checks, before it moves the statements of anelsebody out into the enclosing block, whether that move changes how a name resolves further down that block. It collects every name the moved statements would add to the enclosing block, then looks at the statements that follow theifas written. If any of those declares one of those names, or uses one of them in a position where a newly visible local variable or local class captures it, theifand itselseare left exactly as they are. Otherwise the recipe unwraps theelseas it did before. The check is a new private method of the visitor,collidesWithLaterScope.What's your motivation?
Recipe:
org.openrewrite.staticanalysis.UnwrapElseAfterReturn.Moving an
elsebody into its enclosing block widens the scope of every name declared in that body. This causes two distinct defects: the generated source fails to compile when a later local declaration has the same name, or it compiles with different behavior when the moved local captures a later field or statically imported member reference.Case 1: later local declaration
Before
This source compiles because the two
valuevariables are in separate scopes:Actual after the recipe
The moved declaration and the later declaration now occupy the same scope:
Expected after the recipe
(unchanged)The recipe turns compiling code into non-compiling code because Java forbids declaring a local variable or local class inside the scope of another local variable or local class with the same name (JLS 6.4).
Case 2: later field reference
Before
The final
return value;reads the field:Actual after the recipe
The moved local variable now captures the final reference, so the method returns the local value
1instead of the field:Expected after the recipe
(unchanged)The recipe silently changes the method to return the local value
1instead of the field.Before this change, the recipe did not inspect names declared by the moved statements or references after the
if. Both plainelseblocks andelse ifchains are affected. Reproduced on 2.40.0 and on currentmain.Anything in particular you'd like reviewers to focus on?
No existing test expectation changed; the one line of existing test code this change edits is the class level
@SuppressWarnings("ConstantConditions"), widened to@SuppressWarnings({"ConstantConditions", "unused"}), because the new tests declare locals that are never read. Everything else in the test file is added lines, the new tests and the imports they need.Three limitations:
ifwith a field named like a variable declared in theelsebody: that field is a member of the local class and cannot be captured by the moved local variable, but the matching name blocks the move anyway.instanceofpattern variable declared anywhere inside theelsebody is treated as if its scope reached the end of the enclosing block. Under the flow scoping rules of JLS 6.3.2 that holds for thesinif (!(o instanceof String s)) { return; }, which stays in scope for the rest of the enclosing block, but not for thesinif (o instanceof String s) { ... }, which is in scope only inside thatifstatement. The check does not tell the two apart, so anelsebody of{ if (o instanceof String s) { ... } }blocks the unwrap when a later statement in the same block declaresString s, even though the move would have compiled.elsekeyword is still dropped. That happens onmaintoo.Have you considered any alternatives or workarounds?
The alternative to leaving the
ifand itselseas written is to remove theelseanyway and wrap the moved statements in a bare block,{ ... }, placed after theif, which keeps their names out of the enclosing block. That would be a small change insideflatten, the private method that already exists in this recipe and that builds the list of statements replacing theif; it would decide when to add the bare block from the samecollidesWithLaterScopecheck this branch adds. I did not pick it because a bare block adds nesting back, which is the opposite of what the recipe is for, but I will make that change if you prefer it.Any additional context
This change adds 11 tests to
UnwrapElseAfterReturnTest. Without the code change in this pull request, these 7 tests fail:doNotUnwrapWhenElseDeclarationCollidesWithLaterLocalVariabledoNotUnwrapWhenElseDeclarationCollidesWithLaterPatternVariabledoNotUnwrapWhenElseDeclarationCollidesWithNestedScopedoNotUnwrapWhenElseDeclarationShadowsNameUsedLaterdoNotUnwrapWhenElseLocalClassCollidesWithLaterLocalClassdoNotUnwrapWhenEscapedPatternVariableCollidesWithLaterDeclarationunwrapOnlyWhenDeconstructionPatternBindingsDoNotCollideThe other 4 tests pass either way. Each covers a safe move that this branch still performs, so the new check does not block it. They do not prove that the check is never too broad; the first 2 limitations above describe where it is.
This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.
I ran the formatter with the repository's
.editorconfig. It also wanted to re-indent lines that this change does not touch, so I left those alone and kept the diff limited to this change.Checklist
./gradlew buildlocally, and committed any resulting changes torecipes.csv