Skip to content

Commit bbb78a3

Browse files
paulirwinclaude
andauthored
Support Java 21 switch pattern matching (#68) (#168)
Fixes #68. Record pattern support (#67) already covered most of [JEP 441](https://openjdk.org/jeps/441) — pattern labels, `when` guards, and type patterns in both switch expressions and switch statements were all prerequisites for putting record patterns in switch positions. This PR fixes the one construct that slipped through and adds end-to-end coverage for the JEP as a whole. ## The bug: `case null, default` silently dropped its default javaparser models `case null, default` as a **single null label** carrying a separate `isDefault()` flag: ``` entry type=EXPRESSION labels=1 isDefault=true label class=NullLiteralExpr text=null ``` Both switch visitors keyed off the label list alone, so the `default` half was invisible: ```java case Integer i -> "int " + i; case null, default -> "fallback"; // Java: null AND everything else ``` ```csharp int i => "int " + i, null => "fallback" // C#: only null ``` The arm ended up matching only null, so a non-null value matching no other arm threw `SwitchExpressionException` where Java returned a value. The generated code still compiled — C# only warns (CS8509) about the now-inexhaustive switch — so this failed at runtime rather than at conversion or build time. Both visitors now consult `isDefault()`. C#'s discard pattern and `default:` section already match null (verified both), so the combined form collapses cleanly onto them. ## What was already working Confirmed by running each construct through the converter and comparing against a JDK: | JEP 441 feature | Status | | --- | --- | | Type patterns in switch | ✅ Already worked (#67) | | `when` guards | ✅ Already worked (#67) | | `case null` on its own | ✅ Already worked | | `case null, default` | ❌ **Fixed here** | | Exhaustive switch over sealed types | ✅ Already worked | ## Testing `Java21SwitchPatternMatching.java` is wired into `FullIntegrationTests`, which compiles the generated C#, executes it, and asserts on runtime output. It covers the null label, both combined `case null, default` forms (expression and statement), guards, exhaustive switching over a sealed hierarchy, and type patterns over unrelated types. The expected output is the verbatim output of running the Java source under a JDK, so the test pins the conversion to Java's real behaviour. Plus 5 unit tests in `ConvertSwitchPatternTests`. I verified the two covering `case null, default` fail against the unfixed visitors and pass with the fix, so they genuinely pin the bug rather than just passing. Full suite: 335 passed, 0 failed (329 before this change). ## Two pre-existing bugs found while writing the resource Both reproduce on `master` without any of these changes, so they're left alone and worked around in the test resource. Happy to file issues: 1. **Record accessor casing.** `record Circle(int r)` keeps the lowercase property `r`, but a call to `c.r()` converts to `c.R()` — so the generated code doesn't compile. The resource uses deconstruction instead. 2. **`long` literal suffix dropped.** `Object o = 9L` converts to `object o = 9`, which boxes as `int`. In a type-pattern switch that means `case Long l` never matches and the value takes the `Integer` arm instead. The resource avoids `Long` patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5191335 commit bbb78a3

5 files changed

Lines changed: 254 additions & 2 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
namespace JavaToCSharp.Tests;
2+
3+
/// <summary>
4+
/// Tests for the Java 21 switch pattern matching labels (JEP 441) that record patterns did not
5+
/// already cover, namely the null label and its combined `case null, default` form.
6+
/// </summary>
7+
public class ConvertSwitchPatternTests
8+
{
9+
[Fact]
10+
public void Switch_Expression_Null_Label_Is_Converted_To_A_Null_Pattern()
11+
{
12+
const string javaCode = """
13+
package com.example;
14+
public class Shapes {
15+
public String test(Object obj) {
16+
return switch (obj) {
17+
case null -> "null";
18+
case String s -> s;
19+
default -> "other";
20+
};
21+
}
22+
}
23+
""";
24+
25+
var parsed = Convert(javaCode);
26+
27+
Assert.Contains("null => \"null\"", parsed);
28+
Assert.Contains("_ => \"other\"", parsed);
29+
}
30+
31+
/// <summary>
32+
/// Java models `case null, default` as a null label carrying the default flag. Dropping the
33+
/// default half would leave the arm matching only null, so a non-null value that matched no
34+
/// other arm would throw at runtime instead of taking this arm.
35+
/// </summary>
36+
[Fact]
37+
public void Switch_Expression_Null_Default_Label_Is_Converted_To_A_Discard()
38+
{
39+
const string javaCode = """
40+
package com.example;
41+
public class Shapes {
42+
public String test(Object obj) {
43+
return switch (obj) {
44+
case Integer i -> "int";
45+
case null, default -> "fallback";
46+
};
47+
}
48+
}
49+
""";
50+
51+
var parsed = Convert(javaCode);
52+
53+
Assert.Contains("_ => \"fallback\"", parsed);
54+
Assert.DoesNotContain("null => \"fallback\"", parsed);
55+
}
56+
57+
[Fact]
58+
public void Switch_Statement_Null_Default_Label_Is_Converted_To_A_Default_Section()
59+
{
60+
const string javaCode = """
61+
package com.example;
62+
public class Shapes {
63+
public String test(Object obj) {
64+
switch (obj) {
65+
case Integer i -> { return "int"; }
66+
case null, default -> { return "fallback"; }
67+
}
68+
}
69+
}
70+
""";
71+
72+
var parsed = Convert(javaCode);
73+
74+
Assert.Contains("default:", parsed);
75+
Assert.DoesNotContain("case null:", parsed);
76+
}
77+
78+
[Fact]
79+
public void Switch_Expression_Over_Unrelated_Types_Uses_Type_Patterns()
80+
{
81+
const string javaCode = """
82+
package com.example;
83+
public class Shapes {
84+
public String test(Object obj) {
85+
return switch (obj) {
86+
case Integer i -> "int " + i;
87+
case String s -> "string " + s;
88+
default -> "other";
89+
};
90+
}
91+
}
92+
""";
93+
94+
var parsed = Convert(javaCode);
95+
96+
Assert.Contains("int i =>", parsed);
97+
Assert.Contains("string s =>", parsed);
98+
}
99+
100+
/// <summary>
101+
/// C# has no exhaustiveness concept to carry over, so an exhaustive Java switch simply converts
102+
/// its arms and gains no default.
103+
/// </summary>
104+
[Fact]
105+
public void Exhaustive_Switch_Over_Sealed_Types_Does_Not_Gain_A_Default_Arm()
106+
{
107+
const string javaCode = """
108+
package com.example;
109+
public class Shapes {
110+
sealed interface Shape permits Circle, Square {}
111+
record Circle(int r) implements Shape {}
112+
record Square(int s) implements Shape {}
113+
public String test(Shape shape) {
114+
return switch (shape) {
115+
case Circle(int r) -> "circle";
116+
case Square(int s) -> "square";
117+
};
118+
}
119+
}
120+
""";
121+
122+
var parsed = Convert(javaCode, allowWarnings: true);
123+
124+
Assert.Contains("Circle (int r) =>", parsed);
125+
Assert.Contains("Square (int s) =>", parsed);
126+
Assert.DoesNotContain("_ =>", parsed);
127+
}
128+
129+
private static string Convert(string javaCode, bool allowWarnings = false)
130+
{
131+
var options = new JavaConversionOptions { IncludeComments = false };
132+
133+
options.WarningEncountered += (_, eventArgs) =>
134+
{
135+
if (!allowWarnings)
136+
{
137+
throw new InvalidOperationException($"Encountered a warning in conversion: {eventArgs.Message}");
138+
}
139+
};
140+
141+
return JavaToCSharpConverter.ConvertText(javaCode, options) ?? "";
142+
}
143+
}

JavaToCSharp.Tests/IntegrationTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ public void GeneralUnsuccessfulConversionTest(string filePath)
7474
[InlineData("Resources/Java15TextBlocks.java")]
7575
[InlineData("Resources/Java16Records.java")]
7676
[InlineData("Resources/Java21RecordPatterns.java")]
77+
// Warnings are expected: the sealed interface has no C# equivalent.
78+
[InlineData("Resources/Java21SwitchPatternMatching.java", true)]
7779
[InlineData("Resources/NewArrayLiteralBug.java")]
7880
[InlineData("Resources/OctalLiteralBug.java")]
7981
[InlineData("Resources/DeprecatedAnnotation.java")]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/// Expect:
2+
/// - 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"
3+
package example;
4+
5+
// https://openjdk.org/jeps/441
6+
7+
public class Program {
8+
// Members are declared public because Java's package-private default maps to C# private,
9+
// which is a pre-existing converter behavior unrelated to switch patterns.
10+
public sealed interface Shape permits Circle, Square {
11+
}
12+
13+
public record Circle(int r) implements Shape {
14+
}
15+
16+
public record Square(int s) implements Shape {
17+
}
18+
19+
// A standalone `case null` arm keeps null out of the default.
20+
public static String nullLabel(Object o) {
21+
return switch (o) {
22+
case null -> "was null";
23+
case String s -> "str " + s;
24+
default -> "other";
25+
};
26+
}
27+
28+
// `case null, default` binds null and everything else to a single arm.
29+
public static String nullDefault(Object o) {
30+
return switch (o) {
31+
case Integer i -> "int " + i;
32+
case null, default -> "null-or-default";
33+
};
34+
}
35+
36+
// The same combined label in a switch statement rather than an expression.
37+
public static String nullDefaultStatement(Object o) {
38+
switch (o) {
39+
case Integer i -> {
40+
return "stmt int " + i;
41+
}
42+
case null, default -> {
43+
return "stmt null-or-default";
44+
}
45+
}
46+
}
47+
48+
// Exhaustive over a sealed hierarchy, so Java needs no default arm. The bindings come from
49+
// deconstruction rather than accessor calls, which are converted separately.
50+
public static String exhaustive(Shape shape) {
51+
return switch (shape) {
52+
case Circle(int r) -> "circle " + r;
53+
case Square(int s) -> "square " + s;
54+
};
55+
}
56+
57+
// Guards select between arms that share a type pattern.
58+
public static String guarded(Shape shape) {
59+
return switch (shape) {
60+
case Circle(int r) when r < 10 -> "small circle";
61+
case Circle c -> "big circle";
62+
case Square q -> "square";
63+
};
64+
}
65+
66+
// Type patterns over unrelated types, which is the core of JEP 441.
67+
public static String byType(Object o) {
68+
return switch (o) {
69+
case Integer i -> "integer " + i;
70+
case String s -> "string " + s;
71+
case int[] arr -> "array " + arr.length;
72+
default -> "other";
73+
};
74+
}
75+
76+
public static void main(String[] args) {
77+
System.out.println(nullLabel(null));
78+
System.out.println(nullLabel("hi"));
79+
System.out.println(nullLabel(1));
80+
81+
System.out.println(nullDefault(5));
82+
System.out.println(nullDefault(null));
83+
System.out.println(nullDefault("x"));
84+
85+
System.out.println(nullDefaultStatement(5));
86+
System.out.println(nullDefaultStatement(null));
87+
System.out.println(nullDefaultStatement("x"));
88+
89+
System.out.println(exhaustive(new Circle(1)));
90+
System.out.println(exhaustive(new Square(2)));
91+
92+
System.out.println(guarded(new Circle(5)));
93+
System.out.println(guarded(new Circle(50)));
94+
System.out.println(guarded(new Square(1)));
95+
96+
System.out.println(byType(7));
97+
System.out.println(byType("hi"));
98+
System.out.println(byType(new int[] { 1, 2, 3 }));
99+
System.out.println(byType(1.5));
100+
}
101+
}

JavaToCSharp/Expressions/SwitchExpressionVisitor.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ private static PatternSyntax GetArmPatternSyntax(ConversionContext context, Swit
4747
{
4848
var labels = entry.getLabels().ToList<Expression>() ?? [];
4949

50-
if (labels.Count == 0)
50+
// `case null, default` is modelled as a null label plus the default flag, so an entry can be
51+
// the default while still having labels. C#'s discard already matches null, which makes it
52+
// the equivalent of the combined form as well as of a bare `default`.
53+
if (labels.Count == 0 || entry.isDefault())
5154
{
5255
return DiscardPattern();
5356
}

JavaToCSharp/Statements/SwitchStatementVisitor.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ public class SwitchStatementVisitor : StatementVisitor<SwitchStmt>
4242
AddImplicitBreak(syntaxes);
4343
}
4444

45-
if (labels is not { Count: > 0 })
45+
// `case null, default` is modelled as a null label plus the default flag, so an entry
46+
// can be the default while still having labels. C#'s `default` section already handles
47+
// null, so the combined form collapses onto it.
48+
if (labels is not { Count: > 0 } || cs.isDefault())
4649
{
4750
// default case
4851
if (cs.getType().Equals(SwitchEntry.Type.STATEMENT_GROUP))

0 commit comments

Comments
 (0)