Skip to content

Fix four Java <= 21 syntax conversion gaps - #172

Merged
paulirwin merged 2 commits into
masterfrom
fix/java-21-gaps
Aug 17, 2026
Merged

Fix four Java <= 21 syntax conversion gaps#172
paulirwin merged 2 commits into
masterfrom
fix/java-21-gaps

Conversation

@paulirwin

Copy link
Copy Markdown
Owner

Fixes four Java syntax constructs at or below Java 21 that either failed to convert or produced code with different runtime behavior than the Java source. Labeled break/continue is tracked separately.

Local records (Java 16)

Previously threw InvalidOperationException and aborted conversion of the entire file, since LocalRecordDeclarationStmt was missing from the statement visitor registry.

C# has no local record declaration — the compiler parses record R(int a); in a method body as a local function, which I confirmed against the compiler directly (error CS8112 / CS0246). The record is therefore hoisted to the enclosing type, reusing the queue that already lifts anonymous class bodies. This widens the record's scope, which is harmless unless the enclosing type already declares a member of the same name.

Method references

Emitted as invocations rather than method groups, so String::length became string.Length() — calling the method at the point of reference instead of passing it as a delegate.

-Function<string, int> f = string.Length();
+Function<string, int> f = string.Length;

Constructor references have no C# equivalent, so ArrayList<String>::new becomes () => new List<string>(), preserving generic arguments.

Static imports

import.isStatic() was never checked, so import static java.lang.Math.max became a plain namespace using with the declaring type stripped off, leaving the imported members unresolvable.

-using Java.Lang;
+using static Java.Lang.Math;

An on-demand static import (import static Foo.*) already ends in the type, so it is used as-is. Non-static imports are unchanged.

Instance initializer blocks

Emitted as a static constructor, which runs once per type rather than once per instance, and collided with any real static initializer (two static Foo() declarations).

They are now prepended to each constructor body, matching Java's semantics:

  • Skipped for constructors chaining to this(...), since the initializer already ran in the constructor being chained to.
  • A class declaring an initializer but no constructor gets one synthesized.
  • Nested types save/restore the pending list so initializers cannot leak across type boundaries.
  • Static initializers still become static constructors.

Testing

  • 9 new unit tests in ConvertJava21GapTests.cs covering each fix, including the negative cases (non-static imports stay namespace usings; static initializers stay static constructors; chained constructors do not re-run the initializer).
  • 4 new integration resources wired into IntegrationTests, all converting warning-free.
  • Generated C# for the new resources was compile-checked with the real compiler, not just inspected.
  • Full suite: 348 passing, 0 failing.

Notes

Two pre-existing issues were observed while testing and deliberately left out of scope: record accessor methods (rect.width()) convert to a call on a property, and methods inside a record are emitted with an invalid virtual modifier. Both affect member-level records equally and are unrelated to these fixes.

🤖 Generated with Claude Code

paulirwin and others added 2 commits August 17, 2026 07:25
Local records (Java 16) previously threw InvalidOperationException and
aborted conversion of the whole file, since LocalRecordDeclarationStmt was
missing from the statement visitor registry. C# has no local record
declaration -- the compiler parses `record R(int a);` in a method body as a
local function -- so the record is hoisted to the enclosing type, reusing the
queue that already lifts anonymous class bodies.

Method references were emitted as invocations, so `String::length` became
`string.Length()`, calling the method instead of passing it as a delegate.
They now convert to method groups. Constructor references have no C#
equivalent and become a lambda, preserving any generic arguments.

Static imports were converted to plain namespace usings with the declaring
type stripped off, so the imported members did not resolve. They now emit
`using static`, keeping the type.

Instance initializer blocks were emitted as a static constructor, which runs
once per type rather than once per instance and collided with any real static
initializer. They are now prepended to each constructor body, matching Java's
semantics, and skipped for constructors chaining to `this(...)` since the
initializer already ran there. A class with an initializer but no declared
constructor gets one synthesized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four new resources were registered in GeneralSuccessfulConversionTest,
which only asserts that conversion returned non-null without warnings. That
passes even when the emitted C# is wrong, so it would not catch a regression
in any of the fixes it was meant to cover.

Three of them are rewritten as example.Program with an `/// - output:`
expectation and moved to FullIntegrationTests, which compiles the generated
C# with Roslyn, invokes Main, and asserts on captured stdout. Verified by
mutation: breaking the `this(...)` chaining guard now fails on the program's
actual output.

The resources avoid types the harness cannot resolve, since it references
only System.Private.CoreLib, System.Console, System.Linq and System.Runtime.
Notably java.lang.Math maps to Java.Lang.Math, which does not exist in the
BCL, so StaticImports imports from types declared in the file instead.

Java8MethodReferences stays conversion-only: java.util.function has no BCL
delegate mapping, and C# cannot assign a method group to an interface, so the
output cannot be compiled and run. It instead gets explicit assertions on the
converted syntax for all four method reference kinds, including that none of
them became an invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paulirwin
paulirwin enabled auto-merge (squash) August 17, 2026 16:50
@paulirwin
paulirwin merged commit 7cdf291 into master Aug 17, 2026
5 checks passed
@paulirwin
paulirwin deleted the fix/java-21-gaps branch August 17, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant