diff --git a/JavaToCSharp.Tests/ConvertSwitchPatternTests.cs b/JavaToCSharp.Tests/ConvertSwitchPatternTests.cs
new file mode 100644
index 0000000..089f982
--- /dev/null
+++ b/JavaToCSharp.Tests/ConvertSwitchPatternTests.cs
@@ -0,0 +1,143 @@
+namespace JavaToCSharp.Tests;
+
+///
+/// Tests for the Java 21 switch pattern matching labels (JEP 441) that record patterns did not
+/// already cover, namely the null label and its combined `case null, default` form.
+///
+public class ConvertSwitchPatternTests
+{
+ [Fact]
+ public void Switch_Expression_Null_Label_Is_Converted_To_A_Null_Pattern()
+ {
+ const string javaCode = """
+ package com.example;
+ public class Shapes {
+ public String test(Object obj) {
+ return switch (obj) {
+ case null -> "null";
+ case String s -> s;
+ default -> "other";
+ };
+ }
+ }
+ """;
+
+ var parsed = Convert(javaCode);
+
+ Assert.Contains("null => \"null\"", parsed);
+ Assert.Contains("_ => \"other\"", parsed);
+ }
+
+ ///
+ /// Java models `case null, default` as a null label carrying the default flag. Dropping the
+ /// default half would leave the arm matching only null, so a non-null value that matched no
+ /// other arm would throw at runtime instead of taking this arm.
+ ///
+ [Fact]
+ public void Switch_Expression_Null_Default_Label_Is_Converted_To_A_Discard()
+ {
+ const string javaCode = """
+ package com.example;
+ public class Shapes {
+ public String test(Object obj) {
+ return switch (obj) {
+ case Integer i -> "int";
+ case null, default -> "fallback";
+ };
+ }
+ }
+ """;
+
+ var parsed = Convert(javaCode);
+
+ Assert.Contains("_ => \"fallback\"", parsed);
+ Assert.DoesNotContain("null => \"fallback\"", parsed);
+ }
+
+ [Fact]
+ public void Switch_Statement_Null_Default_Label_Is_Converted_To_A_Default_Section()
+ {
+ const string javaCode = """
+ package com.example;
+ public class Shapes {
+ public String test(Object obj) {
+ switch (obj) {
+ case Integer i -> { return "int"; }
+ case null, default -> { return "fallback"; }
+ }
+ }
+ }
+ """;
+
+ var parsed = Convert(javaCode);
+
+ Assert.Contains("default:", parsed);
+ Assert.DoesNotContain("case null:", parsed);
+ }
+
+ [Fact]
+ public void Switch_Expression_Over_Unrelated_Types_Uses_Type_Patterns()
+ {
+ const string javaCode = """
+ package com.example;
+ public class Shapes {
+ public String test(Object obj) {
+ return switch (obj) {
+ case Integer i -> "int " + i;
+ case String s -> "string " + s;
+ default -> "other";
+ };
+ }
+ }
+ """;
+
+ var parsed = Convert(javaCode);
+
+ Assert.Contains("int i =>", parsed);
+ Assert.Contains("string s =>", parsed);
+ }
+
+ ///
+ /// C# has no exhaustiveness concept to carry over, so an exhaustive Java switch simply converts
+ /// its arms and gains no default.
+ ///
+ [Fact]
+ public void Exhaustive_Switch_Over_Sealed_Types_Does_Not_Gain_A_Default_Arm()
+ {
+ const string javaCode = """
+ package com.example;
+ public class Shapes {
+ sealed interface Shape permits Circle, Square {}
+ record Circle(int r) implements Shape {}
+ record Square(int s) implements Shape {}
+ public String test(Shape shape) {
+ return switch (shape) {
+ case Circle(int r) -> "circle";
+ case Square(int s) -> "square";
+ };
+ }
+ }
+ """;
+
+ var parsed = Convert(javaCode, allowWarnings: true);
+
+ Assert.Contains("Circle (int r) =>", parsed);
+ Assert.Contains("Square (int s) =>", parsed);
+ Assert.DoesNotContain("_ =>", 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 ce82723..e890a70 100644
--- a/JavaToCSharp.Tests/IntegrationTests.cs
+++ b/JavaToCSharp.Tests/IntegrationTests.cs
@@ -74,6 +74,8 @@ public void GeneralUnsuccessfulConversionTest(string filePath)
[InlineData("Resources/Java15TextBlocks.java")]
[InlineData("Resources/Java16Records.java")]
[InlineData("Resources/Java21RecordPatterns.java")]
+ // Warnings are expected: the sealed interface has no C# equivalent.
+ [InlineData("Resources/Java21SwitchPatternMatching.java", true)]
[InlineData("Resources/NewArrayLiteralBug.java")]
[InlineData("Resources/OctalLiteralBug.java")]
[InlineData("Resources/DeprecatedAnnotation.java")]
diff --git a/JavaToCSharp.Tests/Resources/Java21SwitchPatternMatching.java b/JavaToCSharp.Tests/Resources/Java21SwitchPatternMatching.java
new file mode 100644
index 0000000..0ca0d26
--- /dev/null
+++ b/JavaToCSharp.Tests/Resources/Java21SwitchPatternMatching.java
@@ -0,0 +1,101 @@
+/// Expect:
+/// - output: "was null\nstr hi\nother\nint 5\nnull-or-default\nnull-or-default\nstmt int 5\nstmt null-or-default\nstmt null-or-default\ncircle 1\nsquare 2\nsmall circle\nbig circle\nsquare\ninteger 7\nstring hi\narray 3\nother\n"
+package example;
+
+// https://openjdk.org/jeps/441
+
+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 switch patterns.
+ public sealed interface Shape permits Circle, Square {
+ }
+
+ public record Circle(int r) implements Shape {
+ }
+
+ public record Square(int s) implements Shape {
+ }
+
+ // A standalone `case null` arm keeps null out of the default.
+ public static String nullLabel(Object o) {
+ return switch (o) {
+ case null -> "was null";
+ case String s -> "str " + s;
+ default -> "other";
+ };
+ }
+
+ // `case null, default` binds null and everything else to a single arm.
+ public static String nullDefault(Object o) {
+ return switch (o) {
+ case Integer i -> "int " + i;
+ case null, default -> "null-or-default";
+ };
+ }
+
+ // The same combined label in a switch statement rather than an expression.
+ public static String nullDefaultStatement(Object o) {
+ switch (o) {
+ case Integer i -> {
+ return "stmt int " + i;
+ }
+ case null, default -> {
+ return "stmt null-or-default";
+ }
+ }
+ }
+
+ // Exhaustive over a sealed hierarchy, so Java needs no default arm. The bindings come from
+ // deconstruction rather than accessor calls, which are converted separately.
+ public static String exhaustive(Shape shape) {
+ return switch (shape) {
+ case Circle(int r) -> "circle " + r;
+ case Square(int s) -> "square " + s;
+ };
+ }
+
+ // Guards select between arms that share a type pattern.
+ public static String guarded(Shape shape) {
+ return switch (shape) {
+ case Circle(int r) when r < 10 -> "small circle";
+ case Circle c -> "big circle";
+ case Square q -> "square";
+ };
+ }
+
+ // Type patterns over unrelated types, which is the core of JEP 441.
+ public static String byType(Object o) {
+ return switch (o) {
+ case Integer i -> "integer " + i;
+ case String s -> "string " + s;
+ case int[] arr -> "array " + arr.length;
+ default -> "other";
+ };
+ }
+
+ public static void main(String[] args) {
+ System.out.println(nullLabel(null));
+ System.out.println(nullLabel("hi"));
+ System.out.println(nullLabel(1));
+
+ System.out.println(nullDefault(5));
+ System.out.println(nullDefault(null));
+ System.out.println(nullDefault("x"));
+
+ System.out.println(nullDefaultStatement(5));
+ System.out.println(nullDefaultStatement(null));
+ System.out.println(nullDefaultStatement("x"));
+
+ System.out.println(exhaustive(new Circle(1)));
+ System.out.println(exhaustive(new Square(2)));
+
+ System.out.println(guarded(new Circle(5)));
+ System.out.println(guarded(new Circle(50)));
+ System.out.println(guarded(new Square(1)));
+
+ System.out.println(byType(7));
+ System.out.println(byType("hi"));
+ System.out.println(byType(new int[] { 1, 2, 3 }));
+ System.out.println(byType(1.5));
+ }
+}
diff --git a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs
index 0dfb8d7..0e32c1a 100644
--- a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs
+++ b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs
@@ -47,7 +47,10 @@ private static PatternSyntax GetArmPatternSyntax(ConversionContext context, Swit
{
var labels = entry.getLabels().ToList() ?? [];
- if (labels.Count == 0)
+ // `case null, default` is modelled as a null label plus the default flag, so an entry can be
+ // the default while still having labels. C#'s discard already matches null, which makes it
+ // the equivalent of the combined form as well as of a bare `default`.
+ if (labels.Count == 0 || entry.isDefault())
{
return DiscardPattern();
}
diff --git a/JavaToCSharp/Statements/SwitchStatementVisitor.cs b/JavaToCSharp/Statements/SwitchStatementVisitor.cs
index 04a1d2a..860351e 100644
--- a/JavaToCSharp/Statements/SwitchStatementVisitor.cs
+++ b/JavaToCSharp/Statements/SwitchStatementVisitor.cs
@@ -42,7 +42,10 @@ public class SwitchStatementVisitor : StatementVisitor
AddImplicitBreak(syntaxes);
}
- if (labels is not { Count: > 0 })
+ // `case null, default` is modelled as a null label plus the default flag, so an entry
+ // can be the default while still having labels. C#'s `default` section already handles
+ // null, so the combined form collapses onto it.
+ if (labels is not { Count: > 0 } || cs.isDefault())
{
// default case
if (cs.getType().Equals(SwitchEntry.Type.STATEMENT_GROUP))