diff --git a/JavaToCSharp.Tests/ConvertRecordPatternTests.cs b/JavaToCSharp.Tests/ConvertRecordPatternTests.cs new file mode 100644 index 0000000..aca680f --- /dev/null +++ b/JavaToCSharp.Tests/ConvertRecordPatternTests.cs @@ -0,0 +1,234 @@ +namespace JavaToCSharp.Tests; + +/// +/// Tests for Java 21 record patterns (JEP 440), which convert to C# positional patterns. +/// +public class ConvertRecordPatternTests +{ + [Fact] + public void InstanceOf_Record_Pattern_Is_Converted_To_Positional_Pattern() + { + const string javaCode = """ + package com.example; + public class Shapes { + record Point(int x, int y) {} + public boolean test(Object obj) { + return obj instanceof Point(int x, int y) && x > y; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("obj is Point (int x, int y)", parsed); + } + + [Fact] + public void InstanceOf_Type_Pattern_Binds_The_Variable() + { + const string javaCode = """ + package com.example; + public class Shapes { + public String test(Object obj) { + if (obj instanceof String s) { + return s; + } + return ""; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("obj is string s", parsed); + } + + [Fact] + public void InstanceOf_Without_Pattern_Remains_A_Plain_Type_Test() + { + const string javaCode = """ + package com.example; + public class Shapes { + public boolean test(Object obj) { + return obj instanceof String; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("obj is string", parsed); + Assert.DoesNotContain("is string s", parsed); + } + + [Fact] + public void Nested_Record_Pattern_Is_Converted() + { + const string javaCode = """ + package com.example; + public class Shapes { + record Point(int x, int y) {} + record Line(Point start, Point end) {} + public boolean test(Object obj) { + return obj instanceof Line(Point(var ax, var ay), Point end); + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("obj is Line (Point (var ax, var ay), Point end)", parsed); + } + + [Fact] + public void Switch_Expression_Record_Pattern_Is_Converted() + { + const string javaCode = """ + package com.example; + public class Shapes { + record Point(int x, int y) {} + public String test(Object obj) { + return switch (obj) { + case Point(int x, int y) -> "point"; + default -> "other"; + }; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("Point (int x, int y) =>", parsed); + } + + [Fact] + public void Switch_Expression_Guard_Is_Converted_To_When_Clause() + { + const string javaCode = """ + package com.example; + public class Shapes { + record Point(int x, int y) {} + public String test(Object obj) { + return switch (obj) { + case Point(int x, int y) when x > y -> "wide"; + default -> "other"; + }; + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("when x > y =>", parsed); + } + + [Fact] + public void Switch_Statement_Record_Pattern_Is_Converted_With_Guard() + { + const string javaCode = """ + package com.example; + public class Shapes { + record Point(int x, int y) {} + public String test(Object obj) { + switch (obj) { + case Point(int x, int y) when x > y: + return "wide"; + default: + return "other"; + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("case Point (int x, int y)when x > y:", parsed); + } + + /// + /// Java's arrow form never falls through, but C# requires each switch section to end with a + /// jump statement, so the conversion has to supply the break that Java leaves implicit. + /// + [Fact] + public void Arrow_Switch_Statement_Cases_Get_An_Implicit_Break() + { + const string javaCode = """ + package com.example; + public class Shapes { + record Point(int x, int y) {} + public void test(Object obj) { + switch (obj) { + case Point(int x, int y) -> System.out.println("point"); + default -> System.out.println("other"); + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("case Point (int x, int y):", parsed); + // Both the pattern case and the default case need a break. + Assert.Equal(2, parsed.Split("break;").Length - 1); + } + + /// + /// A break after a return would be unreachable, which C# rejects as an error. + /// + [Fact] + public void Cases_Ending_In_A_Jump_Statement_Do_Not_Get_An_Extra_Break() + { + const string javaCode = """ + package com.example; + public class Shapes { + public String test(Object obj) { + switch (obj) { + case String s -> { return s; } + default -> { return "other"; } + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.DoesNotContain("break;", parsed); + } + + /// + /// Colon-form entries keep Java's explicit fallthrough semantics, so no break is invented. + /// + [Fact] + public void Colon_Switch_Statement_Fallthrough_Is_Preserved() + { + const string javaCode = """ + package com.example; + public class Shapes { + public String test(int i) { + switch (i) { + case 1: + case 2: + return "low"; + default: + return "high"; + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("case 1:", parsed); + Assert.Contains("case 2:", parsed); + Assert.DoesNotContain("break;", parsed); + } + + private static string Convert(string javaCode) + { + var options = new JavaConversionOptions { IncludeComments = false }; + options.WarningEncountered += (_, eventArgs) + => 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 f80d70d..ce82723 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -73,6 +73,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/Java14SwitchExpressionsYieldAssign.java", true)] [InlineData("Resources/Java15TextBlocks.java")] [InlineData("Resources/Java16Records.java")] + [InlineData("Resources/Java21RecordPatterns.java")] [InlineData("Resources/NewArrayLiteralBug.java")] [InlineData("Resources/OctalLiteralBug.java")] [InlineData("Resources/DeprecatedAnnotation.java")] diff --git a/JavaToCSharp.Tests/Resources/Java21RecordPatterns.java b/JavaToCSharp.Tests/Resources/Java21RecordPatterns.java new file mode 100644 index 0000000..2485468 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java21RecordPatterns.java @@ -0,0 +1,98 @@ +/// Expect: +/// - output: "point 1,2\nline 0,0\nstring hi\nunknown\nwide point\npoint 1,2\nline\nstring hi\nunknown\narrow point\narrow line\narrow unknown\ncolon point\ncolon hi\ncolon unknown\nnested 4\n" +package example; + +// https://openjdk.org/jeps/440 + +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 record patterns. + public record Point(int x, int y) { + } + + public record Line(Point start, Point end) { + } + + // instanceof with type and record patterns, including a nested deconstruction. + public static String describeInstanceOf(Object obj) { + if (obj instanceof Point(int x, int y)) { + return "point " + x + "," + y; + } + + if (obj instanceof Line(Point(var ax, var ay), Point end)) { + return "line " + ax + "," + ay; + } + + if (obj instanceof String s) { + return "string " + s; + } + + return "unknown"; + } + + // Switch expression with pattern labels and a guard. + public static String describeSwitchExpression(Object obj) { + return switch (obj) { + case Point(int x, int y) when x > y -> "wide point"; + case Point(int x, int y) -> "point " + x + "," + y; + case Line(Point start, Point end) -> "line"; + case String s -> "string " + s; + default -> "unknown"; + }; + } + + // Arrow-form switch statement: each case has an implicit break in Java. + public static String describeArrowSwitch(Object obj) { + String result; + + switch (obj) { + case Point(int x, int y) -> result = "arrow point"; + case Line(Point start, Point end) -> result = "arrow line"; + default -> result = "arrow unknown"; + } + + return result; + } + + // Colon-form switch statement with pattern labels. + public static String describeColonSwitch(Object obj) { + switch (obj) { + case Point(int x, int y): + return "colon point"; + case String s: + return "colon " + s; + default: + return "colon unknown"; + } + } + + public static void main(String[] args) { + Point point = new Point(1, 2); + Line line = new Line(new Point(0, 0), new Point(4, 5)); + + System.out.println(describeInstanceOf(point)); + System.out.println(describeInstanceOf(line)); + System.out.println(describeInstanceOf("hi")); + System.out.println(describeInstanceOf(42)); + + // The guard selects the first arm only when x > y. + System.out.println(describeSwitchExpression(new Point(9, 1))); + System.out.println(describeSwitchExpression(point)); + System.out.println(describeSwitchExpression(line)); + System.out.println(describeSwitchExpression("hi")); + System.out.println(describeSwitchExpression(42)); + + System.out.println(describeArrowSwitch(point)); + System.out.println(describeArrowSwitch(line)); + System.out.println(describeArrowSwitch(42)); + + System.out.println(describeColonSwitch(point)); + System.out.println(describeColonSwitch("hi")); + System.out.println(describeColonSwitch(42)); + + // Bindings from a nested pattern are usable in the matched branch. + if (line instanceof Line(Point(var ax, var ay), Point(var bx, var by))) { + System.out.println("nested " + (bx - ax)); + } + } +} diff --git a/JavaToCSharp/Expressions/InstanceOfExpressionVisitor.cs b/JavaToCSharp/Expressions/InstanceOfExpressionVisitor.cs index 044f8d0..94d4413 100644 --- a/JavaToCSharp/Expressions/InstanceOfExpressionVisitor.cs +++ b/JavaToCSharp/Expressions/InstanceOfExpressionVisitor.cs @@ -16,6 +16,14 @@ public class InstanceOfExpressionVisitor : ExpressionVisitor return null; } + // Java 16+ allows `x instanceof Shape s` and Java 21 `x instanceof Point(int a, int b)`. + // Both become C# `is` patterns; without a pattern this is a plain type test. + if (expr.getPattern().FromOptional() is { } pattern + && PatternExpressionVisitor.ConvertPattern(context, pattern) is { } patternSyntax) + { + return SyntaxFactory.IsPatternExpression(exprSyntax, patternSyntax); + } + var type = TypeHelper.ConvertTypeOf(expr); return SyntaxFactory.BinaryExpression(SyntaxKind.IsExpression, exprSyntax, SyntaxFactory.IdentifierName(type)); diff --git a/JavaToCSharp/Expressions/PatternExpressionVisitor.cs b/JavaToCSharp/Expressions/PatternExpressionVisitor.cs new file mode 100644 index 0000000..a5f7967 --- /dev/null +++ b/JavaToCSharp/Expressions/PatternExpressionVisitor.cs @@ -0,0 +1,68 @@ +using com.github.javaparser.ast.expr; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace JavaToCSharp.Expressions; + +/// +/// Converts Java pattern expressions (JEP 440/441) into C# patterns. +/// +/// +/// Java's type patterns (Shape s) map onto C# declaration patterns, and Java's record +/// deconstruction patterns (Point(int x, int y)) map onto C# positional patterns. +/// The shapes line up closely enough that the conversion is structural, but see +/// for the one case where they diverge. +/// +public static class PatternExpressionVisitor +{ + /// + /// Converts a Java pattern into the equivalent C# pattern, or returns null if the + /// pattern kind is not recognized. + /// + public static PatternSyntax? ConvertPattern(ConversionContext context, PatternExpr pattern) => + pattern switch + { + RecordPatternExpr recordPattern => ConvertRecordPattern(context, recordPattern), + TypePatternExpr typePattern => ConvertTypePattern(typePattern), + _ => null + }; + + /// + /// Converts a type pattern such as String s into a C# declaration pattern. + /// + private static PatternSyntax ConvertTypePattern(TypePatternExpr pattern) => + DeclarationPattern( + IdentifierName(TypeHelper.ConvertTypeOf(pattern)), + SingleVariableDesignation(Identifier(pattern.getNameAsString()))); + + /// + /// Converts a record deconstruction pattern such as Point(int x, int y) into a C# + /// positional pattern. + /// + private static PatternSyntax ConvertRecordPattern(ConversionContext context, RecordPatternExpr pattern) + { + var subpatterns = new List(); + + foreach (var component in pattern.getPatternList().ToList() ?? []) + { + // A nested `var x` component parses as a type pattern whose type is `var`. C# spells the + // equivalent as a plain variable designation, since `var x` is not valid as a subpattern type. + if (component is TypePatternExpr { } typeComponent && TypeHelper.ConvertTypeOf(typeComponent) == "var") + { + subpatterns.Add(Subpattern(VarPattern(SingleVariableDesignation(Identifier(typeComponent.getNameAsString()))))); + continue; + } + + if (ConvertPattern(context, component) is not { } converted) + { + return RecursivePattern(); + } + + subpatterns.Add(Subpattern(converted)); + } + + return RecursivePattern() + .WithType(IdentifierName(TypeHelper.ConvertTypeOf(pattern))) + .WithPositionalPatternClause(PositionalPatternClause(SeparatedList(subpatterns))); + } +} diff --git a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs index 1a11764..0dfb8d7 100644 --- a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs +++ b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs @@ -28,10 +28,19 @@ private SwitchExpressionArmSyntax Visit(ConversionContext context, SwitchEntry e var pattern = GetArmPatternSyntax(context, entry); var expr = GetArmExpressionSyntax(context, entry); - return SwitchExpressionArm( + var arm = SwitchExpressionArm( pattern, expr ); + + // Java 21 `case Foo f when cond ->` maps directly onto a C# `when` clause. + if (entry.getGuard().FromOptional() is { } guard + && VisitExpression(context, guard) is { } guardSyntax) + { + arm = arm.WithWhenClause(WhenClause(guardSyntax)); + } + + return arm; } private static PatternSyntax GetArmPatternSyntax(ConversionContext context, SwitchEntry entry) @@ -47,6 +56,16 @@ private static PatternSyntax GetArmPatternSyntax(ConversionContext context, Swit foreach (var label in labels) { + // Java 21 pattern labels (`case Point(int x, int y)`) are already patterns; everything + // else is a constant label and needs wrapping. + if (label is PatternExpr patternLabel) + { + patterns.Add(PatternExpressionVisitor.ConvertPattern(context, patternLabel) + ?? throw new InvalidOperationException( + $"Unsupported switch pattern label `{patternLabel}`")); + continue; + } + if (VisitExpression(context, label) is not ExpressionSyntax labelExpr) { throw new InvalidOperationException("Switch expression label must contain an expression"); diff --git a/JavaToCSharp/JavaToCSharpConverter.cs b/JavaToCSharp/JavaToCSharpConverter.cs index c983210..20e9409 100644 --- a/JavaToCSharp/JavaToCSharpConverter.cs +++ b/JavaToCSharp/JavaToCSharpConverter.cs @@ -28,7 +28,7 @@ public static class JavaToCSharpConverter context.ConversionStateChanged(ConversionState.ParsingJavaAst); var parser = new JavaParser(); - parser.getParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17); + parser.getParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21); var parsed = parser.parse(wrapper); diff --git a/JavaToCSharp/Statements/SwitchStatementVisitor.cs b/JavaToCSharp/Statements/SwitchStatementVisitor.cs index 21612f2..04a1d2a 100644 --- a/JavaToCSharp/Statements/SwitchStatementVisitor.cs +++ b/JavaToCSharp/Statements/SwitchStatementVisitor.cs @@ -34,20 +34,20 @@ public class SwitchStatementVisitor : StatementVisitor var statements = cs.getStatements().ToList(); var syntaxes = VisitStatements(context, statements); + // Java's arrow form (`case X ->`) never falls through, but C# still requires each + // section to end with a jump statement, so an implicit break is added. Colon-form + // entries keep Java's explicit fallthrough and are left alone. + if (!cs.getType().Equals(SwitchEntry.Type.STATEMENT_GROUP)) + { + AddImplicitBreak(syntaxes); + } + if (labels is not { Count: > 0 }) { // default case - var hasBreakStmt = false; - foreach (var syntax in syntaxes) + if (cs.getType().Equals(SwitchEntry.Type.STATEMENT_GROUP)) { - if (syntax.Kind() == SyntaxKind.BreakStatement) - { - hasBreakStmt = true; - } - } - if (!hasBreakStmt) - { - syntaxes.Add(SyntaxFactory.BreakStatement()); + AddImplicitBreak(syntaxes); } var defaultSyntax = SyntaxFactory.SwitchSection( @@ -57,10 +57,14 @@ public class SwitchStatementVisitor : StatementVisitor } else { + // A guard applies to the entry as a whole, so it is attached to each of its labels. + var guardSyntax = cs.getGuard().FromOptional() is { } guard + ? ExpressionVisitor.VisitExpression(context, guard) + : null; + var labelSyntaxes = labels - .Select(i => ExpressionVisitor.VisitExpression(context, i)) - .OfType() - .Select(SwitchLabelSyntax (i) => SyntaxFactory.CaseSwitchLabel(i)); + .Select(SwitchLabelSyntax? (label) => ConvertLabel(context, label, guardSyntax)) + .OfType(); var caseSyntax = SyntaxFactory.SwitchSection( SyntaxFactory.List(labelSyntaxes.ToList()), @@ -71,4 +75,60 @@ public class SwitchStatementVisitor : StatementVisitor return SyntaxFactory.SwitchStatement(selectorSyntax, SyntaxFactory.List(caseSyntaxes)); } + + /// + /// Appends a break to a switch section unless it already ends with a statement that + /// transfers control, which would make the added break unreachable. + /// + private static void AddImplicitBreak(List syntaxes) + { + if (syntaxes.Count > 0 && TransfersControl(syntaxes[^1])) + { + return; + } + + syntaxes.Add(SyntaxFactory.BreakStatement()); + } + + /// + /// Determines whether a statement unconditionally transfers control out of a switch section. + /// + private static bool TransfersControl(StatementSyntax statement) => + statement switch + { + // The arrow form's braces become a block, so the jump is one level down. + BlockSyntax block => block.Statements.Count > 0 && TransfersControl(block.Statements[^1]), + _ => statement.Kind() is SyntaxKind.BreakStatement + or SyntaxKind.ReturnStatement + or SyntaxKind.ThrowStatement + or SyntaxKind.ContinueStatement + or SyntaxKind.GotoStatement + }; + + /// + /// Converts a single Java case label, which may be a Java 21 pattern, into a C# switch label. + /// + private static SwitchLabelSyntax? ConvertLabel( + ConversionContext context, + Expression label, + ExpressionSyntax? guardSyntax) + { + if (label is PatternExpr patternLabel) + { + if (PatternExpressionVisitor.ConvertPattern(context, patternLabel) is not { } patternSyntax) + { + return null; + } + + var caseLabel = SyntaxFactory.CasePatternSwitchLabel(patternSyntax, SyntaxFactory.Token(SyntaxKind.ColonToken)); + + return guardSyntax is null + ? caseLabel + : caseLabel.WithWhenClause(SyntaxFactory.WhenClause(guardSyntax)); + } + + return ExpressionVisitor.VisitExpression(context, label) is { } labelExpr + ? SyntaxFactory.CaseSwitchLabel(labelExpr) + : null; + } }