From 44d6f164845a85ff534eb865841c57b7d2b30d13 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sun, 16 Aug 2026 16:20:23 -0600 Subject: [PATCH] Support Java 21 switch pattern matching (#68) Record pattern support (#67) already covered most of JEP 441, since pattern labels, `when` guards, and type patterns in both switch expressions and switch statements were needed to place record patterns in switch positions. This fixes the one construct it missed. javaparser models `case null, default` as a single null label carrying a separate isDefault flag, and both switch visitors keyed off the label list alone. The default half was silently dropped, so the arm matched only null. A non-null value matching no other arm then threw SwitchExpressionException instead of taking the arm, having returned a value under Java. The generated code compiled, so nothing surfaced this until runtime. Both visitors now consult isDefault(). C#'s discard pattern and default section both already match null, so the combined form collapses onto them. Adds Java21SwitchPatternMatching.java to the executing integration tests, covering the null label, both combined forms, guards, exhaustive switching over a sealed hierarchy, and type patterns over unrelated types. Its expected output is the verbatim output of the Java source run under a JDK. Co-Authored-By: Claude Opus 5 (1M context) --- .../ConvertSwitchPatternTests.cs | 143 ++++++++++++++++++ JavaToCSharp.Tests/IntegrationTests.cs | 2 + .../Java21SwitchPatternMatching.java | 101 +++++++++++++ .../Expressions/SwitchExpressionVisitor.cs | 5 +- .../Statements/SwitchStatementVisitor.cs | 5 +- 5 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 JavaToCSharp.Tests/ConvertSwitchPatternTests.cs create mode 100644 JavaToCSharp.Tests/Resources/Java21SwitchPatternMatching.java 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))