From d0d80e6254b547661170cf73010823f39588a983 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Mon, 17 Aug 2026 07:25:17 -0600 Subject: [PATCH 1/2] Fix four Java <= 21 syntax conversion gaps 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) --- JavaToCSharp.Tests/ConvertJava21GapTests.cs | 241 ++++++++++++++++++ JavaToCSharp.Tests/IntegrationTests.cs | 4 + .../Resources/InstanceInitializers.java | 35 +++ .../Resources/Java16LocalRecords.java | 37 +++ .../Resources/Java8MethodReferences.java | 27 ++ .../Resources/StaticImports.java | 20 ++ JavaToCSharp/ConversionContext.cs | 13 +- .../ClassOrInterfaceDeclarationVisitor.cs | 56 ++++ .../InitializerDeclarationVisitor.cs | 22 +- .../MethodReferenceExpressionVisitor.cs | 44 +++- JavaToCSharp/Statements/StatementVisitor.cs | 3 +- .../TypeDeclarationStatementVisitor.cs | 22 ++ JavaToCSharp/UsingsHelper.cs | 14 +- 13 files changed, 511 insertions(+), 27 deletions(-) create mode 100644 JavaToCSharp.Tests/ConvertJava21GapTests.cs create mode 100644 JavaToCSharp.Tests/Resources/InstanceInitializers.java create mode 100644 JavaToCSharp.Tests/Resources/Java16LocalRecords.java create mode 100644 JavaToCSharp.Tests/Resources/Java8MethodReferences.java create mode 100644 JavaToCSharp.Tests/Resources/StaticImports.java diff --git a/JavaToCSharp.Tests/ConvertJava21GapTests.cs b/JavaToCSharp.Tests/ConvertJava21GapTests.cs new file mode 100644 index 0000000..5e77def --- /dev/null +++ b/JavaToCSharp.Tests/ConvertJava21GapTests.cs @@ -0,0 +1,241 @@ +namespace JavaToCSharp.Tests; + +/// +/// Tests for Java constructs at or below Java 21 that previously failed to convert or converted +/// into code with different runtime behavior than the Java source. +/// +public class ConvertJava21GapTests +{ + /// + /// Local records previously threw, since LocalRecordDeclarationStmt was missing from the + /// statement visitor registry, aborting conversion of the entire file. C# has no local record + /// declaration (the compiler reads one as a local function), so it is hoisted to the enclosing + /// type rather than left in the method body. + /// + [Fact] + public void Local_Record_Declaration_Is_Hoisted_To_The_Enclosing_Type() + { + const string javaCode = """ + package com.example; + public class Shapes { + public int test() { + record Point(int x, int y) {} + Point p = new Point(1, 2); + return p.x; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("record Point(int x, int y)", parsed); + + // The declaration must sit outside the method body, after the method that declared it. + var methodStart = parsed.IndexOf("public virtual int Test()", StringComparison.Ordinal); + var recordStart = parsed.IndexOf("record Point", StringComparison.Ordinal); + var methodEnd = parsed.IndexOf("return p.x;", StringComparison.Ordinal); + + Assert.True(methodStart >= 0 && recordStart > methodEnd, "The local record must be hoisted out of the method body."); + } + + /// + /// A method reference is a method group, not a call. Emitting an invocation would call the + /// method at the point of reference instead of passing it as a delegate. + /// + [Fact] + public void Method_Reference_Is_Converted_To_A_Method_Group() + { + const string javaCode = """ + package com.example; + import java.util.List; + import java.util.function.Function; + public class Shapes { + public Function test(List items) { + items.forEach(System.out::println); + return String::length; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("return string.Length;", parsed); + Assert.DoesNotContain("string.Length()", parsed); + } + + /// + /// C# has no constructor-reference syntax, so Foo::new becomes a lambda. Generic + /// arguments on the referenced type must survive the conversion. + /// + [Fact] + public void Constructor_Reference_Is_Converted_To_A_Lambda() + { + const string javaCode = """ + package com.example; + import java.util.ArrayList; + import java.util.function.Supplier; + public class Shapes { + public Supplier> test() { + return ArrayList::new; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("() => new List()", parsed); + } + + /// + /// A static import names the declaring type, so the type must be retained and the directive + /// emitted as using static. Treating it as a namespace import dropped the type name. + /// + [Fact] + public void Static_Import_Is_Converted_To_A_Using_Static() + { + const string javaCode = """ + package com.example; + import static java.lang.Math.max; + public class Shapes { + public int test(int a, int b) { + return max(a, b); + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using static Java.Lang.Math;", parsed); + } + + /// + /// A non-static import must keep converting to a plain namespace using, with the class name + /// stripped off. + /// + [Fact] + public void Non_Static_Import_Remains_A_Namespace_Using() + { + const string javaCode = """ + package com.example; + import java.util.List; + public class Shapes { + public int test(List items) { + return items.size(); + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using Java.Util;", parsed); + Assert.DoesNotContain("using static", parsed); + } + + /// + /// Java runs an instance initializer at the start of every constructor. It was previously + /// emitted as a static constructor, which runs once per type rather than once per instance. + /// + [Fact] + public void Instance_Initializer_Is_Prepended_To_Each_Constructor() + { + const string javaCode = """ + package com.example; + public class Shapes { + private int x; + { x = 42; } + public Shapes() { } + public Shapes(int y) { this.x = y; } + } + """; + + var parsed = Convert(javaCode); + + Assert.DoesNotContain("static Shapes()", parsed); + + // Both constructors run the initializer, and it runs before the constructor's own body so a + // constructor parameter still wins over the initialized value. + var body = parsed[parsed.IndexOf("public Shapes(int y)", StringComparison.Ordinal)..]; + Assert.Contains("x = 42;", body); + Assert.True( + body.IndexOf("x = 42;", StringComparison.Ordinal) < body.IndexOf("this.x = y;", StringComparison.Ordinal), + "The instance initializer must run before the constructor body."); + } + + /// + /// A constructor chaining to this(...) must not re-run the initializer, since it already + /// ran in the constructor being chained to. + /// + [Fact] + public void Instance_Initializer_Is_Not_Repeated_In_A_Chained_Constructor() + { + const string javaCode = """ + package com.example; + public class Shapes { + private int x; + { x = 42; } + public Shapes(int y) { this.x = y; } + public Shapes(String s) { this(s.length()); } + } + """; + + var parsed = Convert(javaCode); + + var chained = parsed[parsed.IndexOf("public Shapes(string s)", StringComparison.Ordinal)..]; + Assert.DoesNotContain("x = 42;", chained); + } + + /// + /// A class with an instance initializer but no declared constructor needs one synthesized, + /// otherwise the initializer would be dropped. + /// + [Fact] + public void Instance_Initializer_Without_A_Constructor_Synthesizes_One() + { + const string javaCode = """ + package com.example; + public class Shapes { + private int x; + { x = 42; } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public Shapes()", parsed); + Assert.Contains("x = 42;", parsed); + } + + /// + /// Static initializers must still become static constructors. + /// + [Fact] + public void Static_Initializer_Remains_A_Static_Constructor() + { + const string javaCode = """ + package com.example; + public class Shapes { + private static int x; + static { x = 42; } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("static Shapes()", parsed); + } + + private static string Convert(string javaCode, bool allowWarnings = false) + { + var options = new JavaConversionOptions { IncludeComments = false }; + + options.WarningEncountered += (_, eventArgs) => + { + if (!allowWarnings) + { + throw new InvalidOperationException($"Encountered a warning in conversion: {eventArgs.Message}"); + } + }; + + return JavaToCSharpConverter.ConvertText(javaCode, options) ?? ""; + } +} diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index e890a70..cab0e53 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -22,6 +22,10 @@ public class IntegrationTests(ITestOutputHelper testOutputHelper) [InlineData("Resources/Java11LambdaInference.java")] [InlineData("Resources/MultidimensionalArrays.java", true)] [InlineData("Resources/Java17SealedClasses.java", true)] + [InlineData("Resources/Java16LocalRecords.java")] + [InlineData("Resources/Java8MethodReferences.java")] + [InlineData("Resources/InstanceInitializers.java")] + [InlineData("Resources/StaticImports.java")] public void GeneralSuccessfulConversionTest(string filePath, bool allowWarnings = false) { var options = new JavaConversionOptions diff --git a/JavaToCSharp.Tests/Resources/InstanceInitializers.java b/JavaToCSharp.Tests/Resources/InstanceInitializers.java new file mode 100644 index 0000000..cec3359 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/InstanceInitializers.java @@ -0,0 +1,35 @@ +package com.example; + +import java.util.ArrayList; +import java.util.List; + +/** + * Instance initializer blocks, which Java runs at the start of every constructor that does not + * chain to another constructor of the same class. + */ +public class InstanceInitializers { + private final List names; + private int count; + + { + names = new ArrayList(); + count = 1; + } + + public InstanceInitializers() { + } + + public InstanceInitializers(String first) { + names.add(first); + count = 2; + } + + public InstanceInitializers(String first, String second) { + this(first); + names.add(second); + } + + public int getCount() { + return count; + } +} diff --git a/JavaToCSharp.Tests/Resources/Java16LocalRecords.java b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java new file mode 100644 index 0000000..d1f5cc6 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java @@ -0,0 +1,37 @@ +package com.example; + +import java.util.ArrayList; +import java.util.List; + +/** + * Java 16 local record declarations (JEP 395), which may be declared in a method body. + */ +public class Java16LocalRecords { + public int sumAreas(List pairs) { + record Rect(int width, int height) { + } + + List rects = new ArrayList(); + + for (int[] pair : pairs) { + rects.add(new Rect(pair[0], pair[1])); + } + + int total = 0; + + for (Rect rect : rects) { + total += rect.width * rect.height; + } + + return total; + } + + public String describe(int a, int b) { + record Pair(int left, int right) { + } + + Pair pair = new Pair(a, b); + + return pair.left + "," + pair.right; + } +} diff --git a/JavaToCSharp.Tests/Resources/Java8MethodReferences.java b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java new file mode 100644 index 0000000..794bdb6 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java @@ -0,0 +1,27 @@ +package com.example; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Java 8 method references (JEP 126), which map to C# method groups rather than invocations. + */ +public class Java8MethodReferences { + public void printAll(List items) { + items.forEach(System.out::println); + } + + public Function lengthOf() { + return String::length; + } + + public Supplier> newList() { + return ArrayList::new; + } + + public int count(List items) { + return items.size(); + } +} diff --git a/JavaToCSharp.Tests/Resources/StaticImports.java b/JavaToCSharp.Tests/Resources/StaticImports.java new file mode 100644 index 0000000..7a4e750 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/StaticImports.java @@ -0,0 +1,20 @@ +package com.example; + +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.util.Arrays.asList; + +import java.util.List; + +/** + * Static imports, which map to C# using static directives rather than namespace usings. + */ +public class StaticImports { + public int clamp(int value, int low, int high) { + return min(max(value, low), high); + } + + public List pair(String a, String b) { + return asList(a, b); + } +} diff --git a/JavaToCSharp/ConversionContext.cs b/JavaToCSharp/ConversionContext.cs index fbc4fca..6b2d5ac 100644 --- a/JavaToCSharp/ConversionContext.cs +++ b/JavaToCSharp/ConversionContext.cs @@ -4,7 +4,11 @@ namespace JavaToCSharp; public class ConversionContext(JavaConversionOptions options) { - public Queue PendingAnonymousTypes { get; } = new(); + /// + /// Types that must be hoisted to the enclosing type, because C# has no local equivalent of the + /// Java construct that declared them: anonymous class bodies and local records. + /// + public Queue PendingAnonymousTypes { get; } = new(); public ISet UsedAnonymousTypeNames { get; } = new HashSet(); @@ -25,6 +29,13 @@ public class ConversionContext(JavaConversionOptions options) /// internal List PendingStatements { get; } = []; + /// + /// Instance initializer blocks collected while visiting the members of the class currently being + /// converted. Java runs these at the start of every constructor, so they are prepended to each + /// constructor body by . + /// + internal List PendingInstanceInitializers { get; } = []; + /// /// The identifier that a Java yield statement should assign to, when a multi-statement /// switch expression has been lowered into a switch statement. Null when not inside such a lowering. diff --git a/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs b/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs index 0bf5595..c91ee90 100644 --- a/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs @@ -155,6 +155,51 @@ public static InterfaceDeclarationSyntax VisitInterfaceDeclaration(ConversionCon return WithSealedComment(classSyntax.WithJavaComments(context, interfaceDecl), sealedComment); } + /// + /// Prepends any collected instance initializer blocks to each constructor body, mirroring Java's + /// rule that they run at the start of every constructor. Constructors that chain to another + /// constructor of the same class via this(...) are skipped, since the initializers already + /// ran in the constructor being chained to. When the class declares no constructor at all, a + /// default one is synthesized to hold them. + /// + private static ClassDeclarationSyntax ApplyInstanceInitializers(ConversionContext context, ClassDeclarationSyntax classSyntax) + { + if (context.PendingInstanceInitializers.Count == 0) + { + return classSyntax; + } + + var initializerStatements = context.PendingInstanceInitializers + .SelectMany(block => block.Statements) + .ToArray(); + + var constructors = classSyntax.Members.OfType() + .Where(ctor => !ctor.Modifiers.Any(SyntaxKind.StaticKeyword)) + .ToList(); + + if (constructors.Count == 0) + { + return classSyntax.AddMembers( + SyntaxFactory.ConstructorDeclaration(classSyntax.Identifier.ValueText) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithBody(SyntaxFactory.Block(initializerStatements))); + } + + return classSyntax.ReplaceNodes( + constructors, + (original, _) => + { + if (original.Initializer?.ThisOrBaseKeyword.IsKind(SyntaxKind.ThisKeyword) == true) + { + return original; + } + + var body = original.Body ?? SyntaxFactory.Block(); + + return original.WithBody(body.WithStatements(body.Statements.InsertRange(0, initializerStatements))); + }); + } + public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext context, ClassOrInterfaceDeclaration classDecl, bool isNested = false) { @@ -230,6 +275,12 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con var members = classDecl.getMembers()?.ToList(); + // Instance initializers are collected per-class. Nested types are visited inline below, so + // the outer class's pending initializers are set aside to keep them from leaking into a + // nested type's constructors (and vice versa). + var outerInstanceInitializers = context.PendingInstanceInitializers.ToList(); + context.PendingInstanceInitializers.Clear(); + if (members is not null) { foreach (var member in members) @@ -268,6 +319,11 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con } } + classSyntax = ApplyInstanceInitializers(context, classSyntax); + + context.PendingInstanceInitializers.Clear(); + context.PendingInstanceInitializers.AddRange(outerInstanceInitializers); + var annotations = classDecl.getAnnotations().ToList(); if (annotations is { Count: > 0 }) diff --git a/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs b/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs index 1cbb03f..e1e0977 100644 --- a/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs @@ -1,5 +1,4 @@ -using com.github.javaparser; -using com.github.javaparser.ast.body; +using com.github.javaparser.ast.body; using com.github.javaparser.ast.type; using JavaToCSharp.Statements; using Microsoft.CodeAnalysis.CSharp; @@ -9,24 +8,27 @@ namespace JavaToCSharp.Declarations; public class InitializerDeclarationVisitor : BodyDeclarationVisitor { - public override MemberDeclarationSyntax VisitForClass( + public override MemberDeclarationSyntax? VisitForClass( ConversionContext context, ClassDeclarationSyntax classSyntax, InitializerDeclaration declaration, IReadOnlyList extends, IReadOnlyList implements) { - if (!declaration.isStatic()) - { - //throw new NotImplementedException("Support for non-static initializers is not understood or implemented"); - context.Options.Warning("Support for non-static initializers is not understood or implemented", - declaration.getBegin().FromRequiredOptional().line); - } - var block = declaration.getBody(); var blockSyntax = (BlockSyntax)new BlockStatementVisitor().Visit(context, block); + // Java runs an instance initializer block at the start of every constructor, so it cannot be + // emitted as a member on its own. Stash it for the class visitor to prepend to each + // constructor body; emitting it as a static constructor (as this once did) would both run at + // the wrong time and collide with any real static initializer. + if (!declaration.isStatic()) + { + context.PendingInstanceInitializers.Add(blockSyntax); + return null; + } + return SyntaxFactory.ConstructorDeclaration(classSyntax.Identifier.ValueText) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.StaticKeyword))) .WithBody(blockSyntax); diff --git a/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs b/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs index 27af830..41963a8 100644 --- a/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs +++ b/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs @@ -17,27 +17,43 @@ protected override ExpressionSyntax Visit(ConversionContext context, MethodRefer scopeSyntax = VisitExpression(context, scope); } - var methodName = TypeHelper.Capitalize(expr.getIdentifier()); - methodName = TypeHelper.ReplaceCommonMethodNames(methodName); - - ExpressionSyntax methodExpression; - - if (scopeSyntax is null) + // A constructor reference (`Foo::new`) has no C# method-group equivalent, so it becomes a + // lambda that news up the type instead. The scope is a type name here rather than a value, + // so it is converted as a type to keep any generic arguments (`ArrayList::new`). + if (expr.getIdentifier() == "new") { - methodExpression = SyntaxFactory.IdentifierName(methodName); - } - else - { - methodExpression = SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, scopeSyntax, SyntaxFactory.IdentifierName(methodName)); + var typeName = scope is TypeExpr typeExpr + ? TypeHelper.ConvertType(typeExpr.getType().toString()) + : scopeSyntax?.ToString() ?? "object"; + + return SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.ParseTypeName(typeName), + SyntaxFactory.ArgumentList(), + initializer: null)); } + var methodName = TypeHelper.Capitalize(expr.getIdentifier()); + methodName = TypeHelper.ReplaceCommonMethodNames(methodName); + var args = expr.getTypeArguments().FromOptional(); - if (args is null || args.size() == 0) + SimpleNameSyntax nameSyntax = args is null || args.size() == 0 + ? SyntaxFactory.IdentifierName(methodName) + : SyntaxFactory.GenericName(SyntaxFactory.Identifier(methodName)) + .WithTypeArgumentList( + SyntaxFactory.TypeArgumentList( + SyntaxFactory.SeparatedList( + (args.ToList() ?? []) + .Select(x => SyntaxFactory.ParseTypeName(TypeHelper.ConvertType(x.toString())))))); + + // A method reference is a method group, not an invocation: `String::length` is `Length`, + // not `Length()`. Wrapping it in an InvocationExpression would call the method here. + if (scopeSyntax is null) { - return SyntaxFactory.InvocationExpression(methodExpression); + return nameSyntax; } - return SyntaxFactory.InvocationExpression(methodExpression, TypeHelper.GetSyntaxFromArguments(context, args)); + return SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, scopeSyntax, nameSyntax); } } diff --git a/JavaToCSharp/Statements/StatementVisitor.cs b/JavaToCSharp/Statements/StatementVisitor.cs index 36e3737..87b003e 100644 --- a/JavaToCSharp/Statements/StatementVisitor.cs +++ b/JavaToCSharp/Statements/StatementVisitor.cs @@ -40,7 +40,8 @@ static StatementVisitor() { typeof(WhileStmt), new WhileStatementVisitor() }, { typeof(YieldStmt), new YieldStatementVisitor() }, { typeof(EmptyStmt), new EmptyStatementVisitor() }, - { typeof(LocalClassDeclarationStmt), new TypeDeclarationStatementVisitor() } + { typeof(LocalClassDeclarationStmt), new TypeDeclarationStatementVisitor() }, + { typeof(LocalRecordDeclarationStmt), new LocalRecordDeclarationStatementVisitor() } }; } diff --git a/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs b/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs index 666460a..20440c7 100644 --- a/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs +++ b/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs @@ -22,3 +22,25 @@ public class TypeDeclarationStatementVisitor : StatementVisitor +/// Handles Java 16 local record declarations (void m() { record R(int a) {} }). +/// +/// +/// C# has no local record declaration: the compiler parses record R(int a); in a method body +/// as a local function, so the record cannot stay where Java declared it. It is instead hoisted to +/// the enclosing type, reusing the same queue that 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. +/// +public class LocalRecordDeclarationStatementVisitor : StatementVisitor +{ + public override StatementSyntax? Visit(ConversionContext context, LocalRecordDeclarationStmt statement) + { + var recordSyntax = RecordDeclarationVisitor.VisitRecordDeclaration(context, statement.getRecordDeclaration(), true); + + context.PendingAnonymousTypes.Enqueue(recordSyntax); + + return null; + } +} diff --git a/JavaToCSharp/UsingsHelper.cs b/JavaToCSharp/UsingsHelper.cs index f358633..575c8a2 100644 --- a/JavaToCSharp/UsingsHelper.cs +++ b/JavaToCSharp/UsingsHelper.cs @@ -22,7 +22,14 @@ public static IEnumerable GetUsings(ConversionContext cont var importNameWithoutClassName = lastPartStartIndex == -1 ? importName : importName[..lastPartStartIndex]; - var nameSpace = TypeHelper.Capitalize(importNameWithoutClassName); + + // A member-specific static import (`import static java.util.Arrays.asList`) ends in the + // member name, so stripping the last segment leaves the declaring type that `using + // static` needs. An on-demand static import (`import static java.util.Arrays.*`) already + // ends in the type, so it is used as-is. + var isStatic = import.isStatic(); + var nameSpace = TypeHelper.Capitalize( + isStatic && import.isAsterisk() ? importName : importNameWithoutClassName); // Override namespace if a non empty mapping is found (mapping to empty string removes the import) if (options.SyntaxMappings.ImportMappings.TryGetValue(importName, out var mappedNamespace)) @@ -36,6 +43,11 @@ public static IEnumerable GetUsings(ConversionContext cont var usingSyntax = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(nameSpace)); + if (isStatic) + { + usingSyntax = usingSyntax.WithStaticKeyword(SyntaxFactory.Token(SyntaxKind.StaticKeyword)); + } + if (context.Options.IncludeComments) { usingSyntax = CommentsHelper.AddUsingComments(usingSyntax, import); From 4db76ac0f69cb1e235a64e1d332e8d2fcda2cf23 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Mon, 17 Aug 2026 10:47:39 -0600 Subject: [PATCH 2/2] Assert on generated output in the integration tests 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) --- JavaToCSharp.Tests/ConvertJava21GapTests.cs | 25 ++++++ JavaToCSharp.Tests/IntegrationTests.cs | 7 +- .../Resources/InstanceInitializers.java | 90 +++++++++++++------ .../Resources/Java16LocalRecords.java | 49 +++++----- .../Resources/Java8MethodReferences.java | 36 ++++++-- .../Resources/StaticImports.java | 51 ++++++++--- 6 files changed, 187 insertions(+), 71 deletions(-) diff --git a/JavaToCSharp.Tests/ConvertJava21GapTests.cs b/JavaToCSharp.Tests/ConvertJava21GapTests.cs index 5e77def..d5cb94d 100644 --- a/JavaToCSharp.Tests/ConvertJava21GapTests.cs +++ b/JavaToCSharp.Tests/ConvertJava21GapTests.cs @@ -224,6 +224,31 @@ public class Shapes { Assert.Contains("static Shapes()", parsed); } + /// + /// Asserts on the converted form of every method reference kind in the integration resource. + /// That resource is conversion-only, since java.util.function has no BCL delegate mapping and + /// so its output cannot be compiled and run by the integration harness. + /// + [Fact] + public void Method_Reference_Resource_Converts_Every_Reference_Kind() + { + var parsed = Convert(File.ReadAllText("Resources/Java8MethodReferences.java")); + + // Instance method of a type, and of a particular object. + Assert.Contains("return string.Length;", parsed); + Assert.Contains("return this.Upper;", parsed); + + // Static method. + Assert.Contains("return Java8MethodReferences.Add;", parsed); + + // Constructor reference, keeping the generic argument. + Assert.Contains("() => new List()", parsed); + + // No method reference may become an invocation. + Assert.DoesNotContain("string.Length()", parsed); + Assert.DoesNotContain("Java8MethodReferences.Add()", parsed); + } + private static string Convert(string javaCode, bool allowWarnings = false) { var options = new JavaConversionOptions { IncludeComments = false }; diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index cab0e53..58dfd83 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -22,10 +22,8 @@ public class IntegrationTests(ITestOutputHelper testOutputHelper) [InlineData("Resources/Java11LambdaInference.java")] [InlineData("Resources/MultidimensionalArrays.java", true)] [InlineData("Resources/Java17SealedClasses.java", true)] - [InlineData("Resources/Java16LocalRecords.java")] + // Conversion-only: java.util.function has no BCL delegate mapping, so the output cannot be run. [InlineData("Resources/Java8MethodReferences.java")] - [InlineData("Resources/InstanceInitializers.java")] - [InlineData("Resources/StaticImports.java")] public void GeneralSuccessfulConversionTest(string filePath, bool allowWarnings = false) { var options = new JavaConversionOptions @@ -86,6 +84,9 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/BooleanArrays.java")] [InlineData("Resources/BinaryLiterals.java")] [InlineData("Resources/NestedEnumStaticUsing.java")] + [InlineData("Resources/Java16LocalRecords.java")] + [InlineData("Resources/InstanceInitializers.java")] + [InlineData("Resources/StaticImports.java")] public void FullIntegrationTests(string filePath, bool allowWarnings = false) { var options = new JavaConversionOptions diff --git a/JavaToCSharp.Tests/Resources/InstanceInitializers.java b/JavaToCSharp.Tests/Resources/InstanceInitializers.java index cec3359..289346b 100644 --- a/JavaToCSharp.Tests/Resources/InstanceInitializers.java +++ b/JavaToCSharp.Tests/Resources/InstanceInitializers.java @@ -1,35 +1,75 @@ -package com.example; - -import java.util.ArrayList; -import java.util.List; - -/** - * Instance initializer blocks, which Java runs at the start of every constructor that does not - * chain to another constructor of the same class. - */ -public class InstanceInitializers { - private final List names; - private int count; - - { - names = new ArrayList(); - count = 1; +/// Expect: +/// - output: "a: count=1 tag=init\nb: count=2 tag=x\nc: count=2 tag=x-y\nd: count=1 tag=init\nstatic=7\n" +package example; + +// Instance initializer blocks run at the start of every constructor that does not chain to another +// constructor of the same class. A constructor that chains via this(...) must not re-run them. +public class Program { + static int staticValue; + + static { + staticValue = 7; } - public InstanceInitializers() { + public static class Counter { + public String tag; + public int count; + + // Two separate initializer blocks, to verify both run and in declaration order. + { + tag = "init"; + } + + { + count = 1; + } + + public Counter() { + } + + public Counter(String first) { + tag = first; + count = 2; + } + + // Chains to Counter(String), so the initializers already ran there and must not run again. + public Counter(String first, String second) { + this(first); + tag = tag + "-" + second; + } } - public InstanceInitializers(String first) { - names.add(first); - count = 2; + // A class with an initializer but no declared constructor needs one synthesized, or the + // initializer would be dropped entirely. + public static class Implicit { + public String tag; + public int count; + + { + tag = "init"; + count = 1; + } } - public InstanceInitializers(String first, String second) { - this(first); - names.add(second); + public static void describe(String label, int count, String tag) { + System.out.println(label + ": count=" + count + " tag=" + tag); } - public int getCount() { - return count; + public static void main(String[] args) { + Counter a = new Counter(); + describe("a", a.count, a.tag); + + Counter b = new Counter("x"); + describe("b", b.count, b.tag); + + // count stays 2 rather than resetting to 1, because the chained constructor did not re-run + // the initializer. + Counter c = new Counter("x", "y"); + describe("c", c.count, c.tag); + + Implicit d = new Implicit(); + describe("d", d.count, d.tag); + + System.out.println("static=" + staticValue); } } diff --git a/JavaToCSharp.Tests/Resources/Java16LocalRecords.java b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java index d1f5cc6..0473a53 100644 --- a/JavaToCSharp.Tests/Resources/Java16LocalRecords.java +++ b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java @@ -1,37 +1,36 @@ -package com.example; - -import java.util.ArrayList; -import java.util.List; - -/** - * Java 16 local record declarations (JEP 395), which may be declared in a method body. - */ -public class Java16LocalRecords { - public int sumAreas(List pairs) { - record Rect(int width, int height) { - } - - List rects = new ArrayList(); - - for (int[] pair : pairs) { - rects.add(new Rect(pair[0], pair[1])); +/// Expect: +/// - output: "area=6\npair=1,2\nsame=True\nnested=9\n" +package example; + +// Java 16 local records (JEP 395) may be declared in a method body. C# has no local record +// declaration, so they are hoisted to the enclosing type. +public class Program { + public static int scale(int value) { + // A local record in a second method, to verify each is hoisted independently. + record Factor(int amount) { } - int total = 0; + Factor f = new Factor(3); + return value * f.amount; + } - for (Rect rect : rects) { - total += rect.width * rect.height; + public static void main(String[] args) { + record Rect(int width, int height) { } - return total; - } + Rect r = new Rect(2, 3); + System.out.println("area=" + (r.width * r.height)); - public String describe(int a, int b) { + // A second local record in the same method, to verify both are hoisted. record Pair(int left, int right) { } - Pair pair = new Pair(a, b); + Pair p = new Pair(1, 2); + System.out.println("pair=" + p.left + "," + p.right); + + // Records have value equality in both languages. + System.out.println("same=" + p.equals(new Pair(1, 2))); - return pair.left + "," + pair.right; + System.out.println("nested=" + scale(3)); } } diff --git a/JavaToCSharp.Tests/Resources/Java8MethodReferences.java b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java index 794bdb6..30c5744 100644 --- a/JavaToCSharp.Tests/Resources/Java8MethodReferences.java +++ b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java @@ -1,27 +1,51 @@ -package com.example; +package example; import java.util.ArrayList; import java.util.List; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; /** - * Java 8 method references (JEP 126), which map to C# method groups rather than invocations. + * Java 8 method references (JEP 126). + * + *

These convert to C# method groups rather than invocations. This file is a conversion-only + * test: java.util.function has no mapping to BCL delegate types, so the generated C# cannot be + * compiled and run by the integration harness. See ConvertJava21GapTests for the assertions on + * the generated syntax. */ public class Java8MethodReferences { - public void printAll(List items) { - items.forEach(System.out::println); + public static int add(int a, int b) { + return a + b; + } + + public String value; + + public String upper() { + return value.toUpperCase(); } + // Reference to an instance method of a type, applied to a supplied receiver. public Function lengthOf() { return String::length; } + // Constructor reference, which has no C# equivalent and becomes a lambda. public Supplier> newList() { return ArrayList::new; } - public int count(List items) { - return items.size(); + // Reference to a static method. + public BiFunction adder() { + return Java8MethodReferences::add; + } + + // Reference to an instance method of a particular object. + public Supplier upperOf() { + return this::upper; + } + + public void printAll(List items) { + items.forEach(System.out::println); } } diff --git a/JavaToCSharp.Tests/Resources/StaticImports.java b/JavaToCSharp.Tests/Resources/StaticImports.java index 7a4e750..00c8c4c 100644 --- a/JavaToCSharp.Tests/Resources/StaticImports.java +++ b/JavaToCSharp.Tests/Resources/StaticImports.java @@ -1,20 +1,47 @@ -package com.example; +/// Expect: +/// - output: "clamped=5\nmax=9\ndoubled=8\nsingle=3\n" +package example; -import static java.lang.Math.max; -import static java.lang.Math.min; -import static java.util.Arrays.asList; +import static example.MathHelpers.doubled; +import static example.MathHelpers.max; +import static example.MathHelpers.min; +import static example.Constants.*; -import java.util.List; +// A static import names the declaring type, so it must become `using static`. Treating it as a +// namespace import stripped the type off and left the imported members unresolvable. +// +// java.lang.Math is deliberately not used here: it maps to Java.Lang.Math, which does not exist in +// the BCL, so the generated code could not be compiled and run by this harness. +class MathHelpers { + public static int max(int a, int b) { + return a > b ? a : b; + } + + public static int min(int a, int b) { + return a < b ? a : b; + } -/** - * Static imports, which map to C# using static directives rather than namespace usings. - */ -public class StaticImports { - public int clamp(int value, int low, int high) { + public static int doubled(int a) { + return a * 2; + } +} + +class Constants { + public static final int SINGLE = 3; +} + +public class Program { + public static int clamp(int value, int low, int high) { return min(max(value, low), high); } - public List pair(String a, String b) { - return asList(a, b); + public static void main(String[] args) { + System.out.println("clamped=" + clamp(12, 1, 5)); + System.out.println("max=" + max(9, 3)); + System.out.println("doubled=" + doubled(4)); + + // An on-demand static import (`import static example.Constants.*`) already names the type, + // so nothing is stripped from it. + System.out.println("single=" + SINGLE); } }