Skip to content

Add rewrite-java-next module for non-LTS JDK support#7719

Merged
timtebeek merged 12 commits into
mainfrom
tim/rewrite-java-next
May 20, 2026
Merged

Add rewrite-java-next module for non-LTS JDK support#7719
timtebeek merged 12 commits into
mainfrom
tim/rewrite-java-next

Conversation

@timtebeek
Copy link
Copy Markdown
Member

@timtebeek timtebeek commented May 18, 2026

Summary

Test plan

  • ./gradlew :rewrite-java-next:test :rewrite-java-next:compatibilityTest on a JDK 26 toolchain
  • ./gradlew :rewrite-java-25:test :rewrite-java-25:compatibilityTest still green on JDK 25 (no regression in the LTS module)
  • ./gradlew :rewrite-java:test validates the dispatcher change in JavaParser

Introduces a new `rewrite-java-next` module copied from `rewrite-java-25`,
intended as a moving target for the latest non-LTS JDK releases (initially
26; will advance to 27/28 as those ship, then be renamed to `rewrite-java-29`
once 29 LTS is released).

Bundles the JDK 26 parser fixes from #7716 and #7717 into this new module
rather than backporting them into `rewrite-java-25`:

- Implicit lambda parameter type: handle the zero-width `JCErroneous` vartype
  that JDK 26 synthesizes at the parameter name's position (previously
  detected only via `NOPOS`).
- Annotation `value=` shorthand: handle the synthesized `JCAssign` carrying
  a zero-width range at the rhs's start position.
- Enum-constant identifier: skip the synthesized class identifier on JDK 26
  using `endPos > startPos` instead of `endPos >= 0`.

`JavaParser` now dispatches to `JavaNextParser` for JDK 26+, falling through
to `Java25Parser` for 25, etc. Three new test classes cover the JDK 26
regressions referenced in #7554.
@timtebeek timtebeek requested a review from shanman190 May 18, 2026 10:16
@timtebeek
Copy link
Copy Markdown
Member Author

This finally adopts the style discussed with Shannon when we started rewrite-java-25 and dropped the backports that made the parsers hard to reason about and maintain. rewrite-java-next would be used for projects running on Java 26 or newer, and continuously updated as new versions come out. We still need the matching changes downstream in the Maven and Gradle plugins and the CLI.

The annotation and lambda tests duplicated existing TCK coverage
(AnnotationTest.annotationWithDefaultArgument /
LambdaTest.untypedLambdaParameter) — those already exercise the JDK 26
fix once rewrite-java-next:compatibilityTest runs on JDK 26.

The two enum cases with non-literal arguments (method call, string
concat) are genuinely new ground, so move them to EnumTest in the TCK
where they run against every parser module.
@timtebeek timtebeek moved this from In Progress to Ready to Review in OpenRewrite May 18, 2026
timtebeek added 3 commits May 18, 2026 13:31
The default Gradle test logging only writes failure detail to the HTML
report, which isn't preserved as a CI artifact. Since this module is the
only one exercising the TCK on the latest non-LTS JDK, individual
failures need to be visible directly in the CI log to diagnose
JDK-version-specific parser issues.
CI surfaced two classes of follow-up failures from JDK 26's switch from
NOPOS to zero-width source ranges for synthesized AST nodes:

1. `var` / `val` local declarations (and record patterns,
   try-with-resources, unnamed variables) were rendering the inferred
   type instead of the source's `var` keyword. The existing
   `endPos(vartype) < 0` guard in visitVariables didn't fire on JDK 26.
   Factor the dual-shape (NOPOS or zero-width) check into a shared
   `isSynthesizedVarType` helper and apply it to both the lambda-parameter
   and the local-declaration branches.

2. Single-element annotation shorthand on record components (e.g.
   `@Select("native") String value`) was producing J.Erroneous nodes.
   On JDK 26 the JCAssign sometimes carries the rhs's full range rather
   than a zero-width one, so checking the JCAssign's own positions
   missed those cases. Switch `isSyntheticValueAssignment` to inspect
   the lhs (the `value=` identifier) instead — the lhs is always
   synthesized regardless of how javac chose to position the assignment.
Position-based detection of the synthesized `value=` JCAssign worked for
annotations on classes/methods (zero-width range on JDK 26, NOPOS on JDK
≤ 25) but not for annotations on record components, where JDK 26
apparently encodes the synthesized assignment to overlap with the rhs's
range. Switch to source-text detection: a real `value = "x"` assignment
shows an `=` in source between the annotation's `(` and the rhs;
shorthand `("x")` does not. Falls back to the prior position-based
check if the rhs's start sits behind the cursor (unrecoverable position
data).

Fixes the four remaining RecordTest annotation failures on JDK 26
(annotationsAndRecords, typeParameterAnnotation,
recordWithMultipleAnnotations, differentLiteralTypesAnnotation).
@timtebeek
Copy link
Copy Markdown
Member Author

timtebeek commented May 18, 2026

Parser visitor diff: ReloadableJavaNextParserVisitor vs ReloadableJava25ParserVisitor

Updated after diagnosing the final failures locally on JDK 26.

Of the 10 source files in rewrite-java-next/src/main, 9 are byte-identical to their rewrite-java-25 counterparts after the Java25 → JavaNext rename. Only ReloadableJavaNextParserVisitor.java diverges. All divergences exist to handle a single JDK 26 javac behavior change: synthesized AST nodes that previously carried NOPOS (-1) source positions now carry zero-width positions instead.

Four call-site changes

visitAnnotation — single-element annotation shorthand @Ann("foo")

- if (arg instanceof JCAssign) {
-     if (endPos(arg) < 0) {
-         expressions = singletonList(convert(((JCAssign) arg).rhs, t -> sourceBefore(")")));
-     } else {
-         expressions = singletonList(convert(arg, t -> sourceBefore(")")));
-     }
+ if (arg instanceof JCAssign && isSyntheticValueAssignment((JCAssign) arg)) {
+     expressions = singletonList(convert(((JCAssign) arg).rhs, t -> sourceBefore(")")));
  } else {
      expressions = singletonList(convert(arg, t -> sourceBefore(")")));
  }

visitLiteral — synthesized literal values (e.g. propagated annotation arguments on record components)

  int endPos = endPos(node);
  Object value = node.getValue();
- if (endPos == Position.NOPOS) {
+ int startPos = ((JCLiteral) node).getStartPosition();
+ if (endPos == Position.NOPOS || endPos <= startPos) {
      if (typeMapping.primitive(((JCLiteral) node).typetag) == JavaType.Primitive.String) {
          int quote = source.startsWith("\"\"\"", cursor) ? 3 : 1;
          int elementLength = quote == 3 ? source.indexOf("\"\"\"", cursor + quote) - cursor - quote : value.toString().length();
          endPos = cursor + quote + elementLength + quote;
      } else {
          endPos = indexOf(source, cursor,
                  ch -> Character.isWhitespace(ch) || ",;)]}+-*/%=!<>&|^?:.".indexOf(ch) != -1);
      }
+     startPos = cursor;
  }
  cursor(endPos);
- String valueSource = source.substring(((JCLiteral) node).getStartPosition(), endPos);
+ String valueSource = source.substring(startPos, endPos);

visitNewClass — enum-constant synthesized class identifier

- // for enum definitions with anonymous class initializers, endPos of node identifier will be -1
- TypeTree clazz = endPos(node.getIdentifier()) >= 0 ? convert(node.getIdentifier()) : null;
+ // For enum constants, the identifier is synthesized: on JDK ≤ 25 with endPos < 0,
+ // on JDK 26+ as a zero-width node at the enum-constant's own name position.
+ JCTree ident = (JCTree) node.getIdentifier();
+ TypeTree clazz = endPos(ident) > ident.getStartPosition() ? convert(node.getIdentifier()) : null;

visitVariablesvar/val local declarations and lambda parameters

- if (vartype == null) {
+ if (vartype == null || isImplicitLambdaParameterType(node, vartype)) {
      typeExpr = null;
- } else if (endPos(vartype) < 0) {
-     if ((node.sym.flags() & Flags.PARAMETER) > 0) {
-         typeExpr = null;
-     } else {
-         Space space = whitespace();
-         boolean lombokVal = source.startsWith("val", cursor);
-         cursor += 3;
-         typeExpr = new J.Identifier(..., lombokVal ? "val" : "var", ...);
-     }
+ } else if (isSynthesizedVarType(vartype)) {
+     // `var` / `val` local declaration (and any non-parameter context where javac
+     // synthesizes the vartype, e.g. record patterns, try-with-resources, unnamed variables).
+     Space space = whitespace();
+     boolean lombokVal = source.startsWith("val", cursor);
+     cursor += 3;
+     typeExpr = new J.Identifier(..., lombokVal ? "val" : "var", ...);
  } else if (vartype instanceof JCArrayTypeTree) { ... }

Three new helper methods

private boolean isSyntheticValueAssignment(JCAssign arg) {
    // Detect via the source itself: a real `value = "x"` shows `=` between
    // the annotation's `(` and the rhs's start; shorthand `("x")` does not.
    // Position-based detection isn't reliable — JDK 26's encoding of the
    // synthesized JCAssign varies by context (zero-width on classes/methods,
    // overlapping with the rhs on record-component annotations).
    int rhsStart = ((JCTree) arg.rhs).getStartPosition();
    if (rhsStart < cursor || rhsStart > source.length()) {
        return endPos(arg) <= arg.getStartPosition();
    }
    return source.substring(cursor, rhsStart).indexOf('=') < 0;
}

private boolean isImplicitLambdaParameterType(JCVariableDecl node, JCExpression vartype) {
    if ((node.sym.flags() & Flags.PARAMETER) == 0) {
        return false;
    }
    return isSynthesizedVarType(vartype);
}

private boolean isSynthesizedVarType(JCExpression vartype) {
    // JDK ≤ 25: synthesized vartype has NOPOS, so endPos == -1.
    // JDK 26+: synthesized vartype is a zero-width node at the variable name's position.
    return endPos(vartype) <= vartype.getStartPosition();
}

Why each fix is needed

Affected TCK tests on JDK 26 (before fixes) Cause Fix
AnnotationTest.annotationWithDefaultArgument Synthesized value= JCAssign no longer has NOPOS; old endPos(arg) < 0 guard missed it, so the parser rendered both the synthesized value identifier and the rhs (producing valueecked") isSyntheticValueAssignment — source-text based
RecordTest.{annotationsAndRecords, typeParameterAnnotation, recordWithMultipleAnnotations, differentLiteralTypesAnnotation} Propagated JCLiteral values inside record-component annotations carry zero-width ranges at startPos on JDK 26 (not NOPOS, not their real range); cursor(endPos) set cursor to its own position, leaving literal text unconsumed in source, and convertAll wrapped the otherwise-correct J.Literal in J.Erroneous whose printed text was the literal itself (hence the misleading "native", "A", "a", "b" in failure output) extended synthesized-literal detection in visitLiteral: endPos == NOPOS || endPos <= startPos
EnumTest.enumWithParameters (plus the new enumConstantWith{MethodCall,StringConcat}Argument cases added to the TCK) Synthesized class identifier on enum constants is now zero-width instead of NOPOS; old endPos >= 0 guard let it through and visitIdentifier advanced the cursor past the args endPos > startPos instead of endPos >= 0
LambdaTest.untypedLambdaParameter and any other implicit lambda parameter Synthesized vartype for an implicit lambda parameter is now a zero-width JCErroneous at the parameter name; old endPos(vartype) < 0 didn't fire and convert(vartype) failed the cast to TypeTree isImplicitLambdaParameterType via isSynthesizedVarType
VariableDeclarationsTest.{typeOnVarKeyword, string, finalVar, unknownVar, implicitlyDeclaredLocalVariable}, RecordPatternMatchingTest.shouldParseRecordPatternWithVarKeyword, TypeCastTest.intersectionCastAssignedToVar, TypeUtilsTest.{typeToString2, intersectionTypes}, LombokTest.{val, var}, UnnamedVariableTest.{unnamedVariableInTryWithResources, unnamedVariableInLocalDeclaration, unnamedVariableInForLoop, unnamedVariableWithSideEffects} Same dual-shape issue as the lambda parameter case, but for var/val local declarations (the original PR fix only covered the PARAMETER-flagged branch) extract isSynthesizedVarType and apply it to both branches

Build config

rewrite-java-next/build.gradle.kts differs from rewrite-java-25/build.gradle.kts only in the toolchain bump (JDK 25 → 26) and a testLogging { events("failed") } block that surfaces TCK failure detail in CI logs — added because this module is the only one exercising the TCK on the latest non-LTS JDK and the HTML report isn't preserved as a CI artifact.

timtebeek added 4 commits May 18, 2026 14:54
The 4 remaining JDK 26 RecordTest failures had a different root cause
from the synthesized-position issues fixed earlier: the canonical
constructor parameter annotations (synthesized copies of the
record-component annotations) on JDK 26 now carry real source positions
identical to the originals.

In the record-annotation merging logic these copies were put into the
same per-component map keyed by .pos AFTER the originals from
rc.getOriginalAnnos() — silently overwriting the authoritative versions
with copies that have a structurally different (often JCErroneous)
rhs. On JDK ≤ 25 the copies carried NOPOS (-1) so they collided
harmlessly at a single sentinel key and the originals survived.

Switch to putIfAbsent semantics for the canonical-constructor merge so
the originals win at any shared position.
The 4 remaining RecordTest annotation failures had a different root
cause from what the prior commits hypothesized. On JDK 26, javac wraps
the argument expressions of record-component annotations in JCErroneous
nodes — but the wrapping is purely structural; the single child is the
real expression with correct positions, types, and value.

The previous `putIfAbsent` experiment (reverted in this commit) showed
that the JCErroneous wrapping is inherent to the annotation as javac
stores it, not a consequence of which merge source wins (canonical
constructor parameter vs. `rc.getOriginalAnnos()`). Both views share
the same JCAnnotation references, so the JCErroneous is observable
regardless of which one we pick.

Have `visitErroneous` transparently delegate to the wrapped expression
when there's exactly one child that's a JCExpression — the wrapper has
no semantic meaning, and producing a J.Erroneous for it would trip the
LST validation "erroneous nodes" check.
The previous visitErroneous-unwrap fix didn't help — the same RecordTest
annotation failures persist. Encode the JCErroneous's children count,
child class names, child positions, and source content into the
J.Erroneous text so the next CI failure output surfaces the actual
shape javac is producing on JDK 26. To be reverted once the root cause
is understood.
Root cause of the four remaining RecordTest annotation failures: when
javac propagates a record-component annotation to the canonical
constructor parameter on JDK 26, the propagated JCLiteral (the value
inside e.g. `@Select("native")`) is reported with a zero-width
source range at its start position — not the literal's real range and
not NOPOS. The existing `endPos == Position.NOPOS` guard in
visitLiteral missed this, so cursor(endPos) set the cursor to its own
current position, leaving the literal text unconsumed in source. The
outer convertAll then saw non-whitespace in the suffix and wrapped the
resulting J.Literal in J.Erroneous (whose printed text was the literal
itself — hence the misleading `"native"`, "A", "a", "b"
contents in the test failures).

Fix: extend the synthesized-literal detection to also fire when
`endPos <= startPos` (matching the pattern used elsewhere for
synthesized vartypes, value= assignments, and enum-constant
identifiers), recompute the end position via cursor + value length +
quotes, and use the (now-correct) cursor as the start for valueSource.

Revert the two unhelpful changes from prior iterations: the
visitErroneous unwrap (never reached — the JCErroneous wasn't actually
present in the AST) and the diagnostic instrumentation in
getErroneous.

Verified locally on JDK 26: `:rewrite-java-next:compatibilityTest`
passes the full TCK; `:rewrite-java-25:compatibilityTest` still
passes on JDK 25.
@timtebeek timtebeek merged commit fd49c93 into main May 20, 2026
1 check passed
@timtebeek timtebeek deleted the tim/rewrite-java-next branch May 20, 2026 10:06
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite May 20, 2026
timtebeek added a commit that referenced this pull request May 20, 2026
Same root cause as the EOL Docker images workflow: rewrite-java-next
requires a JDK 26 toolchain at Gradle configuration time. The last
scheduled run on 2026-05-19 succeeded because rewrite-java-next was
added on 2026-05-20 (#7719); the next Tuesday run would fail.
timtebeek added a commit that referenced this pull request May 20, 2026
* Provide JDK 26 toolchain for EOL Docker images workflow

The rewrite-java-next module requires a JDK 26 toolchain at Gradle
configuration time. Without it, the syncEolImages task fails before
it can run.

* Provide JDK 26 toolchain for Gradle wrapper update workflow

Same root cause as the EOL Docker images workflow: rewrite-java-next
requires a JDK 26 toolchain at Gradle configuration time. The last
scheduled run on 2026-05-19 succeeded because rewrite-java-next was
added on 2026-05-20 (#7719); the next Tuesday run would fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants