diff --git a/JavaToCSharp.Tests/ConvertRecordTests.cs b/JavaToCSharp.Tests/ConvertRecordTests.cs new file mode 100644 index 0000000..998e341 --- /dev/null +++ b/JavaToCSharp.Tests/ConvertRecordTests.cs @@ -0,0 +1,212 @@ +namespace JavaToCSharp.Tests; + +public class ConvertRecordTests +{ + [Fact] + public void Record_Is_Converted_To_Positional_Record() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) {} + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public record Point(int x, int y)", parsed); + } + + [Fact] + public void Record_Component_Names_Are_Preserved() + { + // Bodies reference components directly (`x + y`) and those references are not rewritten, + // so renaming the components would produce code that does not compile. + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public int sum() { return x + y; } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("(int x, int y)", parsed); + Assert.Contains("return x + y;", parsed); + } + + [Fact] + public void Record_Implements_Interface() + { + const string javaCode = """ + package com.example; + public record Circle(int radius) implements Shape {} + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public record Circle(int radius) : Shape", parsed); + } + + [Fact] + public void Generic_Record_Emits_Type_Parameters() + { + const string javaCode = """ + package com.example; + public record Labeled(String label, T value) {} + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public record Labeled(string label, T value)", parsed); + } + + [Fact] + public void Nested_Record_Is_Converted() + { + const string javaCode = """ + package com.example; + public class Holder { + public record Inner(int a) {} + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public record Inner(int a)", parsed); + } + + [Fact] + public void Record_Static_Member_Is_Converted() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public static final Point ORIGIN = new Point(0, 0); + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public static readonly Point ORIGIN", parsed); + } + + [Fact] + public void Secondary_Constructor_Delegates_To_Primary_Constructor() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public Point(int v) { this(v, v); } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public Point(int v) : this(v, v)", parsed); + } + + [Fact] + public void Compact_Constructor_Is_Lowered_To_An_Explicit_Constructor() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public Point { + if (x < 0) throw new IllegalArgumentException("neg"); + } + } + """; + + var warnings = new List(); + var parsed = Convert(javaCode, NewOptions(warnings)); + + Assert.Empty(warnings); + // The record drops its positional parameter list in favor of explicit properties. + Assert.DoesNotContain("record Point(", parsed); + Assert.Contains("public int x { get; init; }", parsed); + Assert.Contains("public Point(int x, int y)", parsed); + Assert.Contains("throw new ArgumentException(\"neg\")", parsed); + } + + [Fact] + public void Compact_Constructor_Assigns_Components_After_Its_Body() + { + // Java runs the compact body against the parameters and assigns the fields afterwards, so + // a reassignment in the body must be reflected in the stored component value. + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public Point { + y = y * 2; + } + } + """; + + var parsed = Convert(javaCode); + + var bodyIndex = parsed.IndexOf("y = y * 2;", StringComparison.Ordinal); + var assignIndex = parsed.IndexOf("this.y = y;", StringComparison.Ordinal); + + Assert.True(bodyIndex >= 0, "compact constructor body should be emitted"); + Assert.True(assignIndex > bodyIndex, "component assignment should follow the compact body"); + } + + [Fact] + public void Canonical_Constructor_Is_Ported_Using_Explicit_Properties() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public Point(int x, int y) { this.x = x; this.y = y; } + } + """; + + var warnings = new List(); + var parsed = Convert(javaCode, NewOptions(warnings)); + + Assert.Empty(warnings); + // A positional record would reject a same-signature constructor (CS0111). + Assert.DoesNotContain("record Point(", parsed); + Assert.Contains("public int x { get; init; }", parsed); + Assert.Contains("public Point(int x, int y)", parsed); + } + + [Fact] + public void Explicit_Accessor_Warns_Because_It_Conflicts_With_Generated_Property() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) { + public int x() { return Math.abs(x); } + } + """; + + var warnings = new List(); + var parsed = Convert(javaCode, NewOptions(warnings)); + + Assert.Contains(warnings, w => w.Contains("Accessor `x()`")); + Assert.DoesNotContain("int X()", parsed); + } + + [Fact] + public void Record_Without_Members_Ends_With_Semicolon() + { + const string javaCode = """ + package com.example; + public record Point(int x, int y) {} + """; + + var parsed = Convert(javaCode); + + Assert.Contains("public record Point(int x, int y);", parsed); + } + + private static JavaConversionOptions NewOptions(List? warnings = null) + { + var options = new JavaConversionOptions { IncludeComments = false }; + options.WarningEncountered += (_, eventArgs) => warnings?.Add(eventArgs.Message); + return options; + } + + private static string Convert(string javaCode, JavaConversionOptions? options = null) + => JavaToCSharpConverter.ConvertText(javaCode, options ?? NewOptions()) ?? ""; +} diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index c9663f0..780df29 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -71,6 +71,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/Java14SwitchExpressionsYieldReturn.java", true)] [InlineData("Resources/Java14SwitchExpressionsYieldAssign.java", true)] [InlineData("Resources/Java15TextBlocks.java")] + [InlineData("Resources/Java16Records.java")] [InlineData("Resources/NewArrayLiteralBug.java")] [InlineData("Resources/OctalLiteralBug.java")] [InlineData("Resources/DeprecatedAnnotation.java")] diff --git a/JavaToCSharp.Tests/Resources/Java16Records.java b/JavaToCSharp.Tests/Resources/Java16Records.java new file mode 100644 index 0000000..f024d98 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java16Records.java @@ -0,0 +1,69 @@ +/// Expect: +/// - output: "1, 2\n3\nsame=True\ndiff=False\norigin=0\nCircle r=2\nlabel=P\nrange=1..50\ncaught=yes\n" +package example; + +// https://docs.oracle.com/en/java/javase/16/language/records.html + +interface Shape { + public String describe(); +} + +record Circle(int radius) implements Shape { + public String describe() { + return "Circle r=" + radius; + } +} + +public class Program { + // Members are declared public because Java's package-private default maps to C# private, + // which is a pre-existing converter behavior unrelated to records. + public record Point(int x, int y) { + public static final Point ORIGIN = new Point(0, 0); + + public int sum() { + return x + y; + } + } + + public record Labeled(String label, T value) { + } + + // The compact constructor validates and normalizes the components. Its body runs against the + // parameters, and the components are assigned from them afterwards. + public record Range(int low, int high) { + public Range { + if (low > high) { + throw new IllegalArgumentException("low > high"); + } + high = high * 10; + } + } + + public static void main(String[] args) { + Point p = new Point(1, 2); + System.out.println(p.x + ", " + p.y); + System.out.println(p.sum()); + + // Records have value equality in both languages. + System.out.println("same=" + p.equals(new Point(1, 2))); + System.out.println("diff=" + p.equals(new Point(3, 4))); + + System.out.println("origin=" + Point.ORIGIN.sum()); + + Shape s = new Circle(2); + System.out.println(s.describe()); + + Labeled labeled = new Labeled("P", 42); + System.out.println("label=" + labeled.label); + + Range r = new Range(1, 5); + System.out.println("range=" + r.low + ".." + r.high); + + try { + new Range(9, 2); + System.out.println("caught=no"); + } catch (IllegalArgumentException e) { + System.out.println("caught=yes"); + } + } +} diff --git a/JavaToCSharp/Declarations/BodyDeclarationVisitor.cs b/JavaToCSharp/Declarations/BodyDeclarationVisitor.cs index a840de3..294fbf2 100644 --- a/JavaToCSharp/Declarations/BodyDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/BodyDeclarationVisitor.cs @@ -47,6 +47,7 @@ static BodyDeclarationVisitor() { typeof(InitializerDeclaration), new InitializerDeclarationVisitor() }, { typeof(ClassOrInterfaceDeclaration), new ClassOrInterfaceDeclarationVisitor() }, { typeof(AnnotationDeclaration), new AnnotationDeclarationVisitor() }, + { typeof(RecordDeclaration), new RecordDeclarationVisitor() }, }; } diff --git a/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs b/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs new file mode 100644 index 0000000..11dffcd --- /dev/null +++ b/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs @@ -0,0 +1,268 @@ +using com.github.javaparser; +using com.github.javaparser.ast; +using com.github.javaparser.ast.body; +using com.github.javaparser.ast.stmt; +using com.github.javaparser.ast.type; +using JavaToCSharp.Statements; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace JavaToCSharp.Declarations; + +public class RecordDeclarationVisitor : BodyDeclarationVisitor +{ + public override MemberDeclarationSyntax VisitForClass( + ConversionContext context, + ClassDeclarationSyntax classSyntax, + RecordDeclaration declaration, + IReadOnlyList extends, + IReadOnlyList implements) + { + return VisitRecordDeclaration(context, declaration, true); + } + + public override MemberDeclarationSyntax VisitForInterface(ConversionContext context, + InterfaceDeclarationSyntax interfaceSyntax, RecordDeclaration declaration) + { + return VisitRecordDeclaration(context, declaration, true); + } + + public static RecordDeclarationSyntax VisitRecordDeclaration(ConversionContext context, + RecordDeclaration recordDecl, bool isNested = false) + { + string name = recordDecl.getNameAsString(); + + if (!isNested) + { + context.RootTypeName = name; + } + + context.LastTypeName = name; + + var recordSyntax = SyntaxFactory.RecordDeclaration( + SyntaxFactory.Token(SyntaxKind.RecordKeyword), + name); + + var typeParams = recordDecl.getTypeParameters().ToList(); + + if (typeParams is { Count: > 0 }) + { + recordSyntax = recordSyntax.AddTypeParameterListParameters(typeParams + .Select(i => SyntaxFactory.TypeParameter(i.getNameAsString())).ToArray()); + recordSyntax = recordSyntax.AddConstraintClauses(TypeHelper.GetTypeParameterListConstraints(typeParams).ToArray()); + } + + var mods = recordDecl.getModifiers().ToModifierKeywordSet(); + + if (mods.Contains(Modifier.Keyword.PRIVATE)) + recordSyntax = recordSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PrivateKeyword)); + if (mods.Contains(Modifier.Keyword.PROTECTED)) + recordSyntax = recordSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.ProtectedKeyword)); + if (mods.Contains(Modifier.Keyword.PUBLIC)) + recordSyntax = recordSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); + + // Java record components become C# record properties. Component names are kept verbatim + // rather than capitalized: bodies within the record refer to components directly + // (e.g. `x + y`), and those references are not rewritten, so renaming would break them. + var components = recordDecl.getParameters().ToList() ?? []; + var componentNames = components.Select(i => i.getNameAsString()).ToHashSet(StringComparer.Ordinal); + + // A compact constructor's body runs against the constructor parameters and may reassign + // them, with the component fields assigned from those parameters afterwards. A positional + // record cannot express that, so the record is emitted in non-positional form: explicit + // properties plus a constructor holding the compact body and the trailing assignments. + var compactConstructors = recordDecl.getCompactConstructors().ToList() ?? []; + var compactCtor = compactConstructors.FirstOrDefault(); + + // An explicit canonical constructor assigns the component fields itself, but it still + // cannot coexist with a generated primary constructor, so it needs the same treatment. + // Java forbids declaring both forms, so at most one of these is present. + var canonicalCtor = recordDecl.getMembers()?.ToList()? + .OfType() + .FirstOrDefault(i => IsCanonicalConstructor(i, components)); + + var isPositional = compactCtor is null && canonicalCtor is null; + + if (isPositional && components.Count > 0) + { + var paramSyntaxes = components.Select(i => + SyntaxFactory.Parameter(SyntaxFactory.ParseToken(TypeHelper.EscapeIdentifier(i.getNameAsString()))) + .WithType(SyntaxFactory.ParseTypeName(TypeHelper.ConvertTypeOf(i)))) + .ToArray(); + + recordSyntax = recordSyntax.AddParameterListParameters(paramSyntaxes); + } + + // Java records cannot extend, so only implemented types contribute to the base list. + var implements = recordDecl.getImplementedTypes().ToList() ?? []; + + foreach (var implement in implements) + { + recordSyntax = recordSyntax.AddBaseListTypes(SyntaxFactory.SimpleBaseType(TypeHelper.GetSyntaxFromType(implement))); + } + + // Members are converted through the shared class pipeline, which only reads the type's + // identifier and modifiers, so a stand-in class declaration carries enough context. + var memberHostSyntax = SyntaxFactory.ClassDeclaration(name).WithModifiers(recordSyntax.Modifiers); + + var members = recordDecl.getMembers()?.ToList() ?? []; + + if (!isPositional) + { + foreach (var component in components) + { + recordSyntax = recordSyntax.AddMembers(BuildComponentProperty(component)); + } + + if (compactCtor is not null) + { + recordSyntax = recordSyntax.AddMembers(BuildLoweredCompactConstructor(context, name, compactCtor, components)); + } + } + + foreach (var member in members) + { + if (member is CompactConstructorDeclaration) + { + // Lowered into an explicit constructor above. + continue; + } + + if (member is RecordDeclaration childRecord) + { + recordSyntax = recordSyntax.AddMembers(VisitRecordDeclaration(context, childRecord, true)); + continue; + } + + if (member is ClassOrInterfaceDeclaration childType) + { + recordSyntax = recordSyntax.AddMembers(childType.isInterface() + ? ClassOrInterfaceDeclarationVisitor.VisitInterfaceDeclaration(context, childType, true) + : ClassOrInterfaceDeclarationVisitor.VisitClassDeclaration(context, childType, true)); + continue; + } + + // The component properties are emitted explicitly whenever the record is not + // positional, so an explicit accessor would be a duplicate member (CS0102). + if (member is MethodDeclaration methodDecl && IsExplicitAccessor(methodDecl, componentNames)) + { + context.Options.Warning( + $"Accessor `{methodDecl.getNameAsString()}()` in record {name} was not ported because it conflicts with the generated property.", + methodDecl.getBegin().FromRequiredOptional().line); + continue; + } + + var syntax = VisitBodyDeclarationForClass(context, memberHostSyntax, member, [], implements); + var memberWithComments = syntax?.WithJavaComments(context, member); + + if (memberWithComments is not null) + { + recordSyntax = recordSyntax.AddMembers(memberWithComments); + } + + while (context.PendingAnonymousTypes.Count > 0) + { + var anon = context.PendingAnonymousTypes.Dequeue(); + recordSyntax = recordSyntax.AddMembers(anon); + } + } + + // A record with no members needs a terminating semicolon rather than an empty body. + recordSyntax = recordSyntax.Members.Count == 0 + ? recordSyntax.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)) + : recordSyntax.WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)); + + return recordSyntax.WithJavaComments(context, recordDecl); + } + + /// + /// Builds the `public T name { get; init; }` property that stands in for a record component + /// when the record cannot be emitted in positional form. + /// + private static PropertyDeclarationSyntax BuildComponentProperty(Parameter component) + => SyntaxFactory.PropertyDeclaration( + SyntaxFactory.ParseTypeName(TypeHelper.ConvertTypeOf(component)), + SyntaxFactory.ParseToken(TypeHelper.EscapeIdentifier(component.getNameAsString()))) + .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)) + .AddAccessorListAccessors( + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))); + + /// + /// Lowers a Java compact constructor into an explicit C# constructor. The compact body runs + /// first, against the parameters, and the component properties are assigned from those + /// parameters afterwards, preserving any reassignment the body performed. + /// + private static ConstructorDeclarationSyntax BuildLoweredCompactConstructor( + ConversionContext context, + string name, + CompactConstructorDeclaration compactCtor, + IReadOnlyList components) + { + var ctorSyntax = SyntaxFactory.ConstructorDeclaration(name).WithLeadingNewLines(); + + var mods = compactCtor.getModifiers().ToModifierKeywordSet(); + + // A compact constructor is the canonical constructor, so it must be at least as accessible + // as the record itself; Java requires public when the record is public. + if (mods.Contains(Modifier.Keyword.PROTECTED)) + ctorSyntax = ctorSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.ProtectedKeyword)); + else if (mods.Contains(Modifier.Keyword.PRIVATE)) + ctorSyntax = ctorSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PrivateKeyword)); + else + ctorSyntax = ctorSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); + + ctorSyntax = ctorSyntax.AddParameterListParameters(components.Select(i => + SyntaxFactory.Parameter(SyntaxFactory.ParseToken(TypeHelper.EscapeIdentifier(i.getNameAsString()))) + .WithType(SyntaxFactory.ParseTypeName(TypeHelper.ConvertTypeOf(i)))) + .ToArray()); + + var bodyStatements = StatementVisitor.VisitStatements(context, + compactCtor.getBody().getStatements().ToList()); + + var assignments = components.Select(i => + { + var identifier = TypeHelper.EscapeIdentifier(i.getNameAsString()); + + return (StatementSyntax)SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.ThisExpression(), + SyntaxFactory.IdentifierName(identifier)), + SyntaxFactory.IdentifierName(identifier))); + }); + + return ctorSyntax.AddBodyStatements([.. bodyStatements, .. assignments]); + } + + /// + /// Determines whether a constructor is the record's canonical constructor, i.e. one whose + /// parameter types match the record components in order. Such a constructor has no C# + /// equivalent on a positional record, which already generates it. + /// + private static bool IsCanonicalConstructor(ConstructorDeclaration ctorDecl, IReadOnlyList components) + { + var parameters = ctorDecl.getParameters().ToList() ?? []; + + if (parameters.Count != components.Count) + { + return false; + } + + return !parameters.Where((t, i) => + !string.Equals(t.getType().toString(), components[i].getType().toString(), StringComparison.Ordinal)).Any(); + } + + /// + /// Determines whether a method is an explicit override of a record component accessor, i.e. a + /// no-argument method named after one of the components. + /// + private static bool IsExplicitAccessor(MethodDeclaration methodDecl, ISet componentNames) + => methodDecl.getParameters().size() == 0 && componentNames.Contains(methodDecl.getNameAsString()); +} diff --git a/JavaToCSharp/JavaToCSharpConverter.cs b/JavaToCSharp/JavaToCSharpConverter.cs index eba282d..c983210 100644 --- a/JavaToCSharp/JavaToCSharpConverter.cs +++ b/JavaToCSharp/JavaToCSharpConverter.cs @@ -95,6 +95,16 @@ public static class JavaToCSharpConverter var enumSyntax = EnumDeclarationVisitor.VisitEnumDeclaration(context, enumType); rootMembers.Add(enumSyntax.NormalizeWhitespace().WithTrailingNewLines()); } + else if (type is RecordDeclaration recordType) + { + var recordSyntax = RecordDeclarationVisitor.VisitRecordDeclaration(context, recordType); + rootMembers.Add(recordSyntax.NormalizeWhitespace().WithTrailingNewLines()); + } + else + { + options.Warning($"Unsupported type declaration `{type.getNameAsString()}` of type `{type.GetType()}` was not converted.", + type.getBegin().FromRequiredOptional().line); + } } if (rootMembers.Count > 1)