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;
+ }
}