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))