diff --git a/JavaToCSharp.Tests/ConvertJava21GapTests.cs b/JavaToCSharp.Tests/ConvertJava21GapTests.cs new file mode 100644 index 0000000..d5cb94d --- /dev/null +++ b/JavaToCSharp.Tests/ConvertJava21GapTests.cs @@ -0,0 +1,266 @@ +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); + } + + /// + /// 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 }; + + 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..58dfd83 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -22,6 +22,8 @@ public class IntegrationTests(ITestOutputHelper testOutputHelper) [InlineData("Resources/Java11LambdaInference.java")] [InlineData("Resources/MultidimensionalArrays.java", true)] [InlineData("Resources/Java17SealedClasses.java", true)] + // Conversion-only: java.util.function has no BCL delegate mapping, so the output cannot be run. + [InlineData("Resources/Java8MethodReferences.java")] public void GeneralSuccessfulConversionTest(string filePath, bool allowWarnings = false) { var options = new JavaConversionOptions @@ -82,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 new file mode 100644 index 0000000..289346b --- /dev/null +++ b/JavaToCSharp.Tests/Resources/InstanceInitializers.java @@ -0,0 +1,75 @@ +/// 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 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; + } + } + + // 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 static void describe(String label, int count, String tag) { + System.out.println(label + ": count=" + count + " tag=" + tag); + } + + 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 new file mode 100644 index 0000000..0473a53 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java @@ -0,0 +1,36 @@ +/// 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) { + } + + Factor f = new Factor(3); + return value * f.amount; + } + + public static void main(String[] args) { + record Rect(int width, int height) { + } + + Rect r = new Rect(2, 3); + System.out.println("area=" + (r.width * r.height)); + + // A second local record in the same method, to verify both are hoisted. + record Pair(int left, int right) { + } + + 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))); + + System.out.println("nested=" + scale(3)); + } +} diff --git a/JavaToCSharp.Tests/Resources/Java8MethodReferences.java b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java new file mode 100644 index 0000000..30c5744 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java @@ -0,0 +1,51 @@ +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). + * + *

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 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; + } + + // 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 new file mode 100644 index 0000000..00c8c4c --- /dev/null +++ b/JavaToCSharp.Tests/Resources/StaticImports.java @@ -0,0 +1,47 @@ +/// Expect: +/// - output: "clamped=5\nmax=9\ndoubled=8\nsingle=3\n" +package example; + +import static example.MathHelpers.doubled; +import static example.MathHelpers.max; +import static example.MathHelpers.min; +import static example.Constants.*; + +// 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; + } + + 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 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); + } +} 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);