Skip to content

UnwrapElseAfterReturn: only unwrap when else declarations do not collide - #979

Draft
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/unwrap-else-after-return-variable-collisions
Draft

UnwrapElseAfterReturn: only unwrap when else declarations do not collide#979
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/unwrap-else-after-return-variable-collisions

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Suggested review order: 23 of 52 (Score: 5)
Review first: openrewrite/rewrite-migrate-java#1193

What's changed?

UnwrapElseAfterReturn now checks, before it moves the statements of an else body 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 the if as 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, the if and its else are left exactly as they are. Otherwise the recipe unwraps the else as 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 else body 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 value variables are in separate scopes:

void plain(boolean stop) {
    if (stop) {
        return;
    } else {
        int value = 1;
        System.out.println(value);
    }
    int value = 2;
    System.out.println(value);
}

Actual after the recipe

The moved declaration and the later declaration now occupy the same scope:

void plain(boolean stop) {
    if (stop) {
        return;
    }
    int value = 1;
    System.out.println(value);
    int value = 2;
    System.out.println(value);
}
error: variable value is already defined in method plain(boolean)

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:

class Test {
    int value;

    int sameType(boolean stop) {
        if (stop) {
            return -1;
        } else {
            int value = 1;
            System.out.println(value);
        }
        return value;
    }
}

Actual after the recipe

The moved local variable now captures the final reference, so the method returns the local value 1 instead of the field:

class Test {
    int value;

    int sameType(boolean stop) {
        if (stop) {
            return -1;
        }
        int value = 1;
        System.out.println(value);
        return value;
    }
}

Expected after the recipe

(unchanged)

The recipe silently changes the method to return the local value 1 instead of the field.

Before this change, the recipe did not inspect names declared by the moved statements or references after the if. Both plain else blocks and else if chains are affected. Reproduced on 2.40.0 and on current main.

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:

  • The check compares names and does not work out which scope each later name belongs to, so it also blocks some safe moves. For example, a local class declared after the if with a field named like a variable declared in the else body: 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.
  • Every instanceof pattern variable declared anywhere inside the else body 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 the s in if (!(o instanceof String s)) { return; }, which stays in scope for the rest of the enclosing block, but not for the s in if (o instanceof String s) { ... }, which is in scope only inside that if statement. The check does not tell the two apart, so an else body of { if (o instanceof String s) { ... } } blocks the unwrap when a later statement in the same block declares String s, even though the move would have compiled.
  • A comment written before the else keyword is still dropped. That happens on main too.

Have you considered any alternatives or workarounds?

The alternative to leaving the if and its else as written is to remove the else anyway and wrap the moved statements in a bare block, { ... }, placed after the if, which keeps their names out of the enclosing block. That would be a small change inside flatten, the private method that already exists in this recipe and that builds the list of statements replacing the if; it would decide when to add the bare block from the same collidesWithLaterScope check 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:

  • doNotUnwrapWhenElseDeclarationCollidesWithLaterLocalVariable
  • doNotUnwrapWhenElseDeclarationCollidesWithLaterPatternVariable
  • doNotUnwrapWhenElseDeclarationCollidesWithNestedScope
  • doNotUnwrapWhenElseDeclarationShadowsNameUsedLater
  • doNotUnwrapWhenElseLocalClassCollidesWithLaterLocalClass
  • doNotUnwrapWhenEscapedPatternVariableCollidesWithLaterDeclaration
  • unwrapOnlyWhenDeconstructionPatternBindingsDoNotCollide

The 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

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.
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