Fold rebuild reassignments into initializers and reclaim final locals - #156
Merged
Merged
Conversation
`MigrateMapperSettersToBuilder` previously left a TODO comment on any setter whose receiver was a `final` local, because the rebuild-assignment rewrite introduced by #153 requires the receiver to be reassignable. Issue #155 hits this exact case: `final ObjectMapper mapper = initObjectMapper();` followed by two `mapper.disable(...)` calls in a helper method. When the local is not referenced inside any lambda or anonymous-class body in its scope, stripping `final` is behavior-preserving: the only new reassignment is the one the recipe itself introduces. If the local is captured, un-finalizing (and reassigning) would break Java's effective-final requirement, so those cases still fall back to the TODO. - `findLocalDeclaration` factored out of `isReassignableLocal` and reused by the new `reclaimableFinalLocalDeclarationId` helper. - `isReferencedInsideCapture` walks the enclosing method body and returns true if the identifier is used under any `J.Lambda` or `J.NewClass` with a body. - `unfinalizeDeclaration` post-pass strips the `final` modifier from the matched `J.VariableDeclarations`, preserving the declaration's leading whitespace when `final` was the first modifier. - Final fields keep the existing TODO — this change is scoped to locals.
… adjacent When a rebuild reassignment is the statement immediately following its declaration (i.e. no read of the local sits between them), fold the two into a single `T mapper = <init>.rebuild()...build();` declaration. This produces the one-line shape when the shape allows it, and — critically — lets the reclaim-final-local path preserve `final` rather than strip it, since the declaration is initialized once and never reassigned. - New `foldRebuildIntoInitializer` post-pass runs after `coalesceRebuildAssignments` and before `unfinalizeDeclarations`. - Ordering matters, so all three post-passes are now scheduled from `visitBlock`; `visitMethodInvocation` records reclaim candidates in a per-visitor `Set<UUID>` instead of scheduling `doAfterVisit` directly. - `unfinalizeDeclarations` checks whether the declaration's initializer already contains a `.rebuild()` chain — if so, the fold consumed the reassignment and `final` is preserved. - The fold intentionally requires the reassignment to be the *next* statement. Any intervening read (as in `trailingSetterAfterGapOnLocal`) prevents the fold, and the two-statement form + `final`-strip apply.
Contributor
Author
|
Trimming comments now |
Address review feedback on the reclaim-final-local post-passes: - isReferencedInsideCapture: use the TreeVisitor.reduce(tree, Set) idiom already used elsewhere in this file instead of a boolean[] holder. - unfinalizeDeclarations: strip the final modifier via ListUtils.map/mapFirst, dropping the manual index scan and ArrayList copy. - foldRebuildIntoInitializer: fold adjacent pairs with an index-based ListUtils.map (null drops the consumed tail), removing the merged list and changed flag. Folds never overlap since a reassignment tail is never a declaration head.
timtebeek
approved these changes
Jul 6, 2026
timtebeek
left a comment
Member
There was a problem hiding this comment.
Nice to see this expanded once again, thanks!
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.
Fixes #155.
Two related improvements to
MigrateMapperSettersToBuilderfor mappers configured in a helper method away from their construction site.1. Fold
mapper = mapper.rebuild()...build();into the initializer when adjacentWhen a rebuild reassignment is the statement immediately following the declaration, fold the two into a single-statement form. The issue #155 shape:
now migrates to:
The fold intentionally requires the reassignment to be the very next statement — any intervening read of the local (as in the existing
trailingSetterAfterGapOnLocalshape, whereSystem.out.println(mapper)sits between the setters) prevents the fold and preserves observable semantics.2. Reclaim
finallocals so the rebuild rewrite appliesBefore this PR,
finallocals were unconditionally skipped by the rebuild-assignment path added in #153 — the receiver has to be reassignable — so #155's exact shape (final ObjectMapper mapper = ...;followed by two configuration calls) got the TODO fallback and shipped broken code once theSerializationFeature→DateTimeFeaturerename ran. Now:finalis preserved.finalmodifier so the rebuild reassignment is legal.How
foldRebuildIntoInitializerpost-pass: walks each block, and when it seesT name = <init>;immediately followed byname = name.rebuild()....build();, merges them intoT name = <init>.rebuild()....build();. Uses the existingRebuildParts/tryParseRebuildAssignmenthelpers to reconstruct the chain rooted at the initializer.foldRebuildIntoInitializerruns aftercoalesceRebuildAssignments(so consecutive rebuild reassignments become one chain before folding) and beforeunfinalizeDeclarations. All three post-passes are now scheduled fromvisitBlockso ordering is explicit;visitMethodInvocationrecords reclaim candidates in a per-visitorSet<UUID>instead of schedulingdoAfterVisitdirectly.unfinalizeDeclarationsinspects each candidate declaration and only stripsfinalwhen the initializer doesn't already contain a.rebuild()chain — i.e. only when the fold couldn't consume the reassignment.findLocalDeclarationis factored out ofisReassignableLocaland reused byreclaimableFinalLocalDeclarationId.isReferencedInsideCapturewalks the enclosing method body for lambda/anonymous-class capture; those decls stayfinaland get the TODO.Test plan
./gradlew test— full suite passes, including five new cases underRewriteAsRebuild:finalLocalIsUnfinalizedAndRewritten— final local with an intervening read: two-statement form,finalstripped.finalLocalFromMethodCallCoalescesRebuildChain— the issue Jackson 2 to 3 migration leaves broken code when ObjectMapper is created in one method and configured in another #155 reproducer: coalesce + fold +finalpreserved.nonFinalLocalFromMethodCallFoldsIntoInitializer— proves the fold applies to non-final locals too.finalLocalCapturedByLambdaKeepsTodoComment— capture guard forSupplier<JsonMapper>lambdas.finalLocalCapturedByAnonymousClassKeepsTodoComment— capture guard for anonymous inner classes.trailingSetterAfterGapOnLocalstill asserts the two-statement form (intervening read prevents fold).finalFieldStillGetsTodoCommentunchanged: final fields intentionally excluded.