Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions JavaToCSharp.Tests/ConvertSwitchPatternTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
namespace JavaToCSharp.Tests;

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// C# has no exhaustiveness concept to carry over, so an exhaustive Java switch simply converts
/// its arms and gains no default.
/// </summary>
[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) ?? "";
}
}
2 changes: 2 additions & 0 deletions JavaToCSharp.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
101 changes: 101 additions & 0 deletions JavaToCSharp.Tests/Resources/Java21SwitchPatternMatching.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
5 changes: 4 additions & 1 deletion JavaToCSharp/Expressions/SwitchExpressionVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ private static PatternSyntax GetArmPatternSyntax(ConversionContext context, Swit
{
var labels = entry.getLabels().ToList<Expression>() ?? [];

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();
}
Expand Down
5 changes: 4 additions & 1 deletion JavaToCSharp/Statements/SwitchStatementVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ public class SwitchStatementVisitor : StatementVisitor<SwitchStmt>
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))
Expand Down
Loading