From b6fd8ac8f276d1339efb876b957f4c7582be6726 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sun, 16 Aug 2026 07:32:58 -0600 Subject: [PATCH 1/2] Support Java 16 record classes (#65) Java records are converted to C# positional records: components become positional parameters, `implements` becomes the base list, and members are converted through the existing class member pipeline. Previously a top-level record was silently dropped, because the type dispatch in JavaToCSharpConverter had no fallback branch, and a nested record threw from BodyDeclarationVisitor's exact-type lookup. Add an `else` branch so any future unsupported type declaration warns instead of disappearing. Record component names are kept verbatim rather than capitalized. Bodies refer to components directly (`x + y`) and those references are not rewritten, so renaming the components would emit code that does not compile. Three Java constructs have no positional-record equivalent in C# and are skipped with a warning rather than emitting code that fails to compile: - Compact constructors, which have no C# counterpart. - Explicit canonical constructors, which collide with the generated primary constructor (CS0111). - Explicit component accessors, which collide with the generated property (CS0102). Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/ConvertRecordTests.cs | 183 +++++++++++++++++ JavaToCSharp.Tests/IntegrationTests.cs | 1 + .../Resources/Java16Records.java | 48 +++++ .../Declarations/BodyDeclarationVisitor.cs | 1 + .../Declarations/RecordDeclarationVisitor.cs | 190 ++++++++++++++++++ JavaToCSharp/JavaToCSharpConverter.cs | 10 + 6 files changed, 433 insertions(+) create mode 100644 JavaToCSharp.Tests/ConvertRecordTests.cs create mode 100644 JavaToCSharp.Tests/Resources/Java16Records.java create mode 100644 JavaToCSharp/Declarations/RecordDeclarationVisitor.cs diff --git a/JavaToCSharp.Tests/ConvertRecordTests.cs b/JavaToCSharp.Tests/ConvertRecordTests.cs new file mode 100644 index 0000000..021587c --- /dev/null +++ b/JavaToCSharp.Tests/ConvertRecordTests.cs @@ -0,0 +1,183 @@ +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_Warns_And_Is_Not_Ported() + { + 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(); + Convert(javaCode, NewOptions(warnings)); + + Assert.Contains(warnings, w => w.Contains("Compact constructor")); + } + + [Fact] + public void Canonical_Constructor_Warns_Because_It_Conflicts_With_Primary_Constructor() + { + 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.Contains(warnings, w => w.Contains("Canonical constructor")); + // Emitting it would duplicate the generated primary constructor (CS0111), so the record + // is left with no body at all. + Assert.Contains("public record 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..ce51289 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java16Records.java @@ -0,0 +1,48 @@ +/// Expect: +/// - output: "1, 2\n3\nsame=True\ndiff=False\norigin=0\nCircle r=2\nlabel=P\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) { + } + + 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); + } +} 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..eb8662b --- /dev/null +++ b/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs @@ -0,0 +1,190 @@ +using com.github.javaparser; +using com.github.javaparser.ast; +using com.github.javaparser.ast.body; +using com.github.javaparser.ast.type; +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# positional record parameters. 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); + + if (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() ?? []; + + var compactConstructors = recordDecl.getCompactConstructors().ToList() ?? []; + + foreach (var compactCtor in compactConstructors) + { + context.Options.Warning( + $"Compact constructor in record {name} was not ported; its validation and normalization logic must be applied manually.", + compactCtor.getBegin().FromRequiredOptional().line); + } + + foreach (var member in members) + { + if (member is CompactConstructorDeclaration) + { + // Warned about above; there is no C# equivalent of a compact constructor. + 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; + } + + // An explicit canonical constructor collides with the positional record's primary + // constructor (CS0111), and an explicit accessor collides with its property (CS0102). + if (member is ConstructorDeclaration ctorDecl && IsCanonicalConstructor(ctorDecl, components)) + { + context.Options.Warning( + $"Canonical constructor in record {name} was not ported because it conflicts with the generated primary constructor.", + ctorDecl.getBegin().FromRequiredOptional().line); + continue; + } + + 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 positional record with no body needs a terminating semicolon rather than braces. + 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); + } + + /// + /// 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) From c48702eb3f2ecc6cc0ed87e553028b7e1c98fc5a Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sun, 16 Aug 2026 07:58:04 -0600 Subject: [PATCH 2/2] Lower Java record compact constructors (#65) A compact constructor's body runs against the constructor parameters and may reassign them, with the component fields assigned from those parameters afterwards. Verified against Java: given `y = y * 2` in the body, the stored component holds the doubled value, so the body cannot be lowered to property initializers, which would capture the original arguments. Emit such records in non-positional form instead: explicit `{ get; init; }` properties plus a constructor holding the converted compact body followed by the component assignments. Value equality, `ToString`, and `with` still come from the record itself; only the implicit `Deconstruct` is lost, and record deconstruction is not converted today. An explicit canonical constructor needs the same non-positional form to avoid colliding with the generated primary constructor, and already assigns the components itself, so it is now ported rather than dropped with a warning. Java forbids declaring both a compact and a canonical constructor, so at most one is ever present. The integration fixture executes the converted code and its output matches that of the equivalent Java program, including the reassigned component. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/ConvertRecordTests.cs | 45 +++++-- .../Resources/Java16Records.java | 23 +++- .../Declarations/RecordDeclarationVisitor.cs | 120 +++++++++++++++--- 3 files changed, 158 insertions(+), 30 deletions(-) diff --git a/JavaToCSharp.Tests/ConvertRecordTests.cs b/JavaToCSharp.Tests/ConvertRecordTests.cs index 021587c..998e341 100644 --- a/JavaToCSharp.Tests/ConvertRecordTests.cs +++ b/JavaToCSharp.Tests/ConvertRecordTests.cs @@ -105,7 +105,7 @@ public record Point(int x, int y) { } [Fact] - public void Compact_Constructor_Warns_And_Is_Not_Ported() + public void Compact_Constructor_Is_Lowered_To_An_Explicit_Constructor() { const string javaCode = """ package com.example; @@ -117,13 +117,41 @@ public Point { """; var warnings = new List(); - Convert(javaCode, NewOptions(warnings)); + var parsed = Convert(javaCode, NewOptions(warnings)); - Assert.Contains(warnings, w => w.Contains("Compact constructor")); + 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 Canonical_Constructor_Warns_Because_It_Conflicts_With_Primary_Constructor() + 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; @@ -135,10 +163,11 @@ public record Point(int x, int y) { var warnings = new List(); var parsed = Convert(javaCode, NewOptions(warnings)); - Assert.Contains(warnings, w => w.Contains("Canonical constructor")); - // Emitting it would duplicate the generated primary constructor (CS0111), so the record - // is left with no body at all. - Assert.Contains("public record Point(int x, int y);", parsed); + 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] diff --git a/JavaToCSharp.Tests/Resources/Java16Records.java b/JavaToCSharp.Tests/Resources/Java16Records.java index ce51289..f024d98 100644 --- a/JavaToCSharp.Tests/Resources/Java16Records.java +++ b/JavaToCSharp.Tests/Resources/Java16Records.java @@ -1,5 +1,5 @@ /// Expect: -/// - output: "1, 2\n3\nsame=True\ndiff=False\norigin=0\nCircle r=2\nlabel=P\n" +/// - 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 @@ -28,6 +28,17 @@ public int sum() { 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); @@ -44,5 +55,15 @@ public static void main(String[] args) { 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/RecordDeclarationVisitor.cs b/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs index eb8662b..11dffcd 100644 --- a/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/RecordDeclarationVisitor.cs @@ -1,7 +1,9 @@ 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; @@ -60,13 +62,29 @@ public static RecordDeclarationSyntax VisitRecordDeclaration(ConversionContext c if (mods.Contains(Modifier.Keyword.PUBLIC)) recordSyntax = recordSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); - // Java record components become C# positional record parameters. Component names are kept - // verbatim rather than capitalized: bodies within the record refer to components directly + // 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); - if (components.Count > 0) + // 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()))) @@ -90,20 +108,24 @@ public static RecordDeclarationSyntax VisitRecordDeclaration(ConversionContext c var members = recordDecl.getMembers()?.ToList() ?? []; - var compactConstructors = recordDecl.getCompactConstructors().ToList() ?? []; - - foreach (var compactCtor in compactConstructors) + if (!isPositional) { - context.Options.Warning( - $"Compact constructor in record {name} was not ported; its validation and normalization logic must be applied manually.", - compactCtor.getBegin().FromRequiredOptional().line); + 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) { - // Warned about above; there is no C# equivalent of a compact constructor. + // Lowered into an explicit constructor above. continue; } @@ -121,16 +143,8 @@ public static RecordDeclarationSyntax VisitRecordDeclaration(ConversionContext c continue; } - // An explicit canonical constructor collides with the positional record's primary - // constructor (CS0111), and an explicit accessor collides with its property (CS0102). - if (member is ConstructorDeclaration ctorDecl && IsCanonicalConstructor(ctorDecl, components)) - { - context.Options.Warning( - $"Canonical constructor in record {name} was not ported because it conflicts with the generated primary constructor.", - ctorDecl.getBegin().FromRequiredOptional().line); - 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( @@ -154,7 +168,7 @@ public static RecordDeclarationSyntax VisitRecordDeclaration(ConversionContext c } } - // A positional record with no body needs a terminating semicolon rather than braces. + // 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)) @@ -163,6 +177,70 @@ public static RecordDeclarationSyntax VisitRecordDeclaration(ConversionContext c 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#