From ebef9cdabc03ee27ea15c0a29dbdd9d9c3f7972e Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sat, 15 Aug 2026 21:27:45 -0600 Subject: [PATCH 1/2] Support Java 14 multi-statement switch expressions (#107) Java 14 switch expressions may use `yield` to produce a value from a block of statements. C# switch expression arms permit only a single expression, so these could not be converted and threw at conversion time. Rather than emitting IIFE-style lambdas with a `Func` cast that the user must fix up by hand, lower multi-statement switch expressions into plain switch statements that assign to a target: int numLetters; switch (day) { case MONDAY: case FRIDAY: Console.WriteLine(6); numLetters = 6; break; default: throw new InvalidOperationException(...); } This produces code that compiles as-is and reads like a human wrote it. A warning is emitted whenever the shape changes. Lowering requires emitting more than one statement, which the `StatementSyntax? Visit(...)` contract cannot express, so add `ConversionContext.PendingStatements`. Statement visitors push statements that must precede the current one, and `VisitStatements` drains them. The drain is scoped by a depth watermark so that nested statement lists (switch arm blocks) do not consume statements belonging to an outer list. Supported positions are those where the switch expression is the whole initializer, assigned value, or returned value. Elsewhere there is nowhere to hoist the statements to, and conversion fails with a message explaining the limitation. For `return`, the temporary is typed from the enclosing method's declared return type, since `var` requires an initializer. Also register a visitor for `YieldStmt`, which was previously unhandled entirely, and fix an index-out-of-range in `SwitchExpressionVisitor` where a fallthrough label with no statements read `statements[0]` after only guarding against counts greater than one. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/IntegrationTests.cs | 14 +- .../Java14SwitchExpressionsYield.java | 60 +++++++ .../Java14SwitchExpressionsYieldAssign.java | 35 +++++ .../Java14SwitchExpressionsYieldReturn.java | 30 ++++ .../SwitchExpressionLoweringTests.cs | 57 +++++++ JavaToCSharp/ConversionContext.cs | 20 +++ .../Expressions/SwitchExpressionVisitor.cs | 12 +- .../Statements/ExpressionStatementVisitor.cs | 53 ++++++- .../Statements/ReturnStatementVisitor.cs | 51 +++++- JavaToCSharp/Statements/StatementVisitor.cs | 38 ++++- .../Statements/SwitchExpressionLowering.cs | 146 ++++++++++++++++++ .../Statements/YieldStatementVisitor.cs | 37 +++++ 12 files changed, 540 insertions(+), 13 deletions(-) create mode 100644 JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYield.java create mode 100644 JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldAssign.java create mode 100644 JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldReturn.java create mode 100644 JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs create mode 100644 JavaToCSharp/Statements/SwitchExpressionLowering.cs create mode 100644 JavaToCSharp/Statements/YieldStatementVisitor.cs diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index 8d121e16..c9663f0f 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -67,6 +67,9 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/Java9PrivateInterfaceMethods.java")] [InlineData("Resources/Java10TypeInference.java")] [InlineData("Resources/Java14SwitchExpressions.java")] + [InlineData("Resources/Java14SwitchExpressionsYield.java", true)] + [InlineData("Resources/Java14SwitchExpressionsYieldReturn.java", true)] + [InlineData("Resources/Java14SwitchExpressionsYieldAssign.java", true)] [InlineData("Resources/Java15TextBlocks.java")] [InlineData("Resources/NewArrayLiteralBug.java")] [InlineData("Resources/OctalLiteralBug.java")] @@ -74,7 +77,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/BooleanArrays.java")] [InlineData("Resources/BinaryLiterals.java")] [InlineData("Resources/NestedEnumStaticUsing.java")] - public void FullIntegrationTests(string filePath) + public void FullIntegrationTests(string filePath, bool allowWarnings = false) { var options = new JavaConversionOptions { @@ -84,8 +87,13 @@ public void FullIntegrationTests(string filePath) options.AddUsing("System"); - options.WarningEncountered += (_, eventArgs) - => throw new InvalidOperationException($"Encountered a warning in conversion: {eventArgs.Message}"); + options.WarningEncountered += (_, eventArgs) => + { + if (!allowWarnings) + { + throw new InvalidOperationException($"Encountered a warning in conversion: {eventArgs.Message}"); + } + }; var javaText = File.ReadAllText(filePath); diff --git a/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYield.java b/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYield.java new file mode 100644 index 00000000..1f838e0e --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYield.java @@ -0,0 +1,60 @@ +/// Expect: +/// - output: "9\n9\n8\n" +package example; + +// https://docs.oracle.com/en/java/javase/14/language/switch-expressions.html#GUID-BA4F63E3-4823-43C6-A5F3-BAA4A2EF3ADC__GUID-4900EB1C-3832-4CB8-ACAE-A87675260B75 + +enum Day { SUNDAY, MONDAY, TUESDAY, + WEDNESDAY, THURSDAY, FRIDAY, SATURDAY; } + +public class Program { + public static void main(String[] args) { + Day day = Day.WEDNESDAY; + + // colon form with yield + int numLetters = switch (day) { + case MONDAY: + case FRIDAY: + case SUNDAY: + System.out.println(6); + yield 6; + case TUESDAY: + System.out.println(7); + yield 7; + case THURSDAY: + case SATURDAY: + System.out.println(8); + yield 8; + case WEDNESDAY: + yield 9; + default: + throw new IllegalStateException("Invalid day: " + day); + }; + System.out.println(numLetters); + + // arrow form with a block body and yield + int arrowLetters = switch (day) { + case MONDAY, FRIDAY, SUNDAY -> 6; + case TUESDAY -> 7; + case THURSDAY, SATURDAY -> 8; + case WEDNESDAY -> { + int nine = 9; + yield nine; + } + default -> throw new IllegalStateException("Invalid day: " + day); + }; + System.out.println(arrowLetters); + + // arrow form with a block body, yield, and multiple statements + Day saturday = Day.SATURDAY; + int blockLetters = switch (saturday) { + case WEDNESDAY -> 9; + default -> { + int eight = 4; + eight = eight * 2; + yield eight; + } + }; + System.out.println(blockLetters); + } +} diff --git a/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldAssign.java b/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldAssign.java new file mode 100644 index 00000000..41436bd0 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldAssign.java @@ -0,0 +1,35 @@ +/// Expect: +/// - output: "20\n99\n" +package example; + +enum Mode { ON, OFF; } + +public class Program { + public static void main(String[] args) { + int result = 0; + Mode mode = Mode.ON; + + // assignment to an already-declared variable + result = switch (mode) { + case ON: { + int doubled = 10 * 2; + yield doubled; + } + default: + yield -1; + }; + System.out.println(result); + + // reassignment, exercising that the lowering can run more than once + mode = Mode.OFF; + result = switch (mode) { + case ON: + yield 1; + default: { + int big = 100; + yield big - 1; + } + }; + System.out.println(result); + } +} diff --git a/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldReturn.java b/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldReturn.java new file mode 100644 index 00000000..c110f6f3 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java14SwitchExpressionsYieldReturn.java @@ -0,0 +1,30 @@ +/// Expect: +/// - output: "7\n42\n11\n" +package example; + +enum Size { SMALL, MEDIUM, LARGE; } + +public class Program { + // switch expression yielded directly from a return statement + static int describe(Size size) { + return switch (size) { + case SMALL: { + int base = 3; + yield base + 4; + } + case MEDIUM: + yield 42; + default: { + int a = 5; + int b = 6; + yield a + b; + } + }; + } + + public static void main(String[] args) { + System.out.println(describe(Size.SMALL)); + System.out.println(describe(Size.MEDIUM)); + System.out.println(describe(Size.LARGE)); + } +} diff --git a/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs b/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs new file mode 100644 index 00000000..c14f3f99 --- /dev/null +++ b/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs @@ -0,0 +1,57 @@ +using com.github.javaparser; +using com.github.javaparser.ast.expr; +using JavaToCSharp.Expressions; + +namespace JavaToCSharp.Tests; + +public class SwitchExpressionLoweringTests +{ + /// + /// Single-expression arms must keep using C# switch expressions rather than being lowered, + /// so that existing conversions are unaffected. + /// + [Theory] + [InlineData("switch (x) { case 1 -> 10; default -> 20; }")] + [InlineData("switch (x) { case 1 -> 10; default -> throw new RuntimeException(); }")] + // NOTE: the colon/yield form cannot be parsed by parseExpression, only in statement context, + // so it is covered by the integration tests rather than here. + public void SingleExpressionArms_ConvertToSwitchExpression(string javaExpr) + { + var csharp = Convert(javaExpr); + + Assert.Contains("switch", csharp); + Assert.Contains("=>", csharp); + } + + /// + /// Arms with more than one statement cannot be represented as a C# switch expression arm. + /// These are lowered by the statement visitors, so converting the expression alone throws. + /// + [Theory] + [InlineData("switch (x) { case 1 -> { int a = 1; yield a; } default -> 20; }")] + public void MultiStatementArms_AreNotConvertibleAsExpression(string javaExpr) + { + Assert.ThrowsAny(() => Convert(javaExpr)); + } + + /// + /// A label with no statements falls through to the next label. This previously crashed with an + /// index-out-of-range because the arm body was read before checking that one existed. + /// + [Fact] + public void FallthroughLabels_DoNotCrash() + { + var csharp = Convert("switch (x) { case 1, 2 -> 10; default -> 20; }"); + + Assert.Contains("=>", csharp); + } + + private static string? Convert(string javaExpr) + { + var parseResult = new JavaParser().parseExpression(javaExpr); + var parsedExpr = parseResult.getResult().FromRequiredOptional(); + var context = new ConversionContext(new JavaConversionOptions()); + + return ExpressionVisitor.VisitExpression(context, parsedExpr)?.ToString(); + } +} diff --git a/JavaToCSharp/ConversionContext.cs b/JavaToCSharp/ConversionContext.cs index 1360bba8..fbc4fcac 100644 --- a/JavaToCSharp/ConversionContext.cs +++ b/JavaToCSharp/ConversionContext.cs @@ -18,6 +18,26 @@ public class ConversionContext(JavaConversionOptions options) public string? LastTypeName { get; set; } + /// + /// Statements that must be emitted immediately before the statement currently being visited. + /// Used to lower constructs that cannot be expressed as a single statement, such as multi-statement + /// switch expressions. Drained by . + /// + internal List PendingStatements { get; } = []; + + /// + /// The identifier that a Java yield statement should assign to, when a multi-statement + /// switch expression has been lowered into a switch statement. Null when not inside such a lowering. + /// + internal string? YieldTarget { get; set; } + + private int _uniqueLocalCounter; + + /// + /// Creates a local variable name that will not collide with other generated locals. + /// + internal string CreateUniqueLocalName(string prefix) => $"__{prefix}{_uniqueLocalCounter++}"; + /// /// Records the new conversion state and raises . /// diff --git a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs index 5087195f..2a98fcb1 100644 --- a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs +++ b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs @@ -72,15 +72,23 @@ private static ExpressionSyntax GetArmExpressionSyntax(ConversionContext context { var statements = entry.getStatements().ToList() ?? []; - if (statements.Count > 1) + if (statements.Count != 1) { - throw new InvalidOperationException("Switch expressions with multiple statements are not supported"); + // Multi-statement arms are lowered into switch statements by the statement visitors, + // which is only possible when the switch expression is the whole initializer, assigned + // value, or returned value. Anywhere else there is nowhere to put the extra statements. + throw new InvalidOperationException( + "Switch expressions with multiple statements are only supported when directly assigned to a variable or returned"); } var armExpr = statements[0] switch { ThrowStmt throwStmt => throwStmt.getExpression(), ExpressionStmt exprStmt => exprStmt.getExpression(), + // A yield or block here means the arm needs lowering, which is only possible when the + // switch expression is directly assigned or returned rather than nested in another expression. + YieldStmt or BlockStmt => throw new InvalidOperationException( + "Switch expressions with multiple statements are only supported when directly assigned to a variable or returned"), _ => throw new InvalidOperationException("Only throw and expression statements are supported in switch expressions") }; diff --git a/JavaToCSharp/Statements/ExpressionStatementVisitor.cs b/JavaToCSharp/Statements/ExpressionStatementVisitor.cs index 12635f6e..ab9dfdfb 100644 --- a/JavaToCSharp/Statements/ExpressionStatementVisitor.cs +++ b/JavaToCSharp/Statements/ExpressionStatementVisitor.cs @@ -1,4 +1,5 @@ -using com.github.javaparser.ast.body; +using com.github.javaparser; +using com.github.javaparser.ast.body; using com.github.javaparser.ast.expr; using com.github.javaparser.ast.stmt; using JavaToCSharp.Expressions; @@ -19,6 +20,24 @@ public class ExpressionStatementVisitor : StatementVisitor return VisitVariableDeclarationStatement(context, expr); } + // `target = switch (...) { ... yield ... }` lowers to a switch statement assigning to the target. + if (expression is AssignExpr { } assignExpr + && assignExpr.getValue() is SwitchExpr assignedSwitch + && assignExpr.getOperator() == AssignExpr.Operator.ASSIGN + && SwitchExpressionLowering.RequiresLowering(assignedSwitch)) + { + var targetSyntax = ExpressionVisitor.VisitExpression(context, assignExpr.getTarget()); + + if (targetSyntax is IdentifierNameSyntax identifier) + { + context.Options.Warning( + "Multi-statement switch expression converted to a switch statement. Review the generated code carefully.", + assignedSwitch.getBegin().FromRequiredOptional().line); + + return SwitchExpressionLowering.Lower(context, assignedSwitch, identifier.Identifier.Text); + } + } + var expressionSyntax = ExpressionVisitor.VisitExpression(context, expression); return expressionSyntax is null ? null : SyntaxFactory.ExpressionStatement(expressionSyntax); @@ -30,6 +49,7 @@ private static StatementSyntax VisitVariableDeclarationStatement(ConversionConte int? arrayRank = null; var variables = new List(); + var loweredSwitches = new List(); var variableDeclarators = varExpr.getVariables()?.ToList() ?? []; @@ -57,6 +77,23 @@ private static StatementSyntax VisitVariableDeclarationStatement(ConversionConte var initExpr = item.getInitializer().FromOptional(); + if (initExpr is SwitchExpr switchExpr && SwitchExpressionLowering.RequiresLowering(switchExpr)) + { + // C# switch expressions cannot contain multiple statements per arm, so lower this + // into a switch statement that assigns to the declared variable. The declaration is + // emitted without an initializer and the switch statement precedes the current statement. + context.Options.Warning( + "Multi-statement switch expression converted to a switch statement. Review the generated code carefully.", + switchExpr.getBegin().FromRequiredOptional().line); + + variables.Add(SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(name))); + + loweredSwitches.Add( + SwitchExpressionLowering.Lower(context, switchExpr, TypeHelper.EscapeIdentifier(name))); + + continue; + } + if (initExpr is not null) { var initSyntax = ExpressionVisitor.VisitExpression(context, initExpr); @@ -74,7 +111,19 @@ private static StatementSyntax VisitVariableDeclarationStatement(ConversionConte var typeSyntax = TypeHelper.ConvertTypeSyntax(commonType, arrayRank ?? 0); - return SyntaxFactory.LocalDeclarationStatement( + var declaration = SyntaxFactory.LocalDeclarationStatement( SyntaxFactory.VariableDeclaration(typeSyntax, SyntaxFactory.SeparatedList(variables, Enumerable.Repeat(SyntaxFactory.Token(SyntaxKind.CommaToken), variables.Count - 1)))); + + if (loweredSwitches.Count == 0) + { + return declaration; + } + + // The declaration must precede the switch statements that assign to it, so emit it first + // and return the final switch as this statement's syntax. + context.PendingStatements.Add(declaration); + context.PendingStatements.AddRange(loweredSwitches[..^1]); + + return loweredSwitches[^1]; } } diff --git a/JavaToCSharp/Statements/ReturnStatementVisitor.cs b/JavaToCSharp/Statements/ReturnStatementVisitor.cs index 3b92de16..40c1350e 100644 --- a/JavaToCSharp/Statements/ReturnStatementVisitor.cs +++ b/JavaToCSharp/Statements/ReturnStatementVisitor.cs @@ -1,4 +1,6 @@ -using com.github.javaparser.ast.expr; +using com.github.javaparser; +using com.github.javaparser.ast.body; +using com.github.javaparser.ast.expr; using com.github.javaparser.ast.stmt; using JavaToCSharp.Expressions; using Microsoft.CodeAnalysis.CSharp; @@ -17,8 +19,55 @@ public override StatementSyntax Visit(ConversionContext context, ReturnStmt retu return SyntaxFactory.ReturnStatement(); // i.e. "return" in a void method } + if (expr is SwitchExpr switchExpr && SwitchExpressionLowering.RequiresLowering(switchExpr)) + { + // C# switch expressions cannot contain multiple statements per arm. Lower into a switch + // statement over a temporary, then return that temporary. + context.Options.Warning( + "Multi-statement switch expression converted to a switch statement. Review the generated code carefully.", + switchExpr.getBegin().FromRequiredOptional().line); + + var temp = context.CreateUniqueLocalName("switchResult"); + + // `var` needs an initializer, so use the enclosing method's declared return type. + var methodDecl = FindEnclosingMethod(returnStmt) + ?? throw new InvalidOperationException( + "Multi-statement switch expression in a return statement outside of a method is not supported"); + + var returnTypeNode = methodDecl.getType(); + var tempType = TypeHelper.ConvertTypeSyntax(returnTypeNode, returnTypeNode.getArrayLevel()); + + context.PendingStatements.Add( + SyntaxFactory.LocalDeclarationStatement( + SyntaxFactory.VariableDeclaration( + tempType, + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.VariableDeclarator(temp))))); + + context.PendingStatements.Add(SwitchExpressionLowering.Lower(context, switchExpr, temp)); + + return SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName(temp)); + } + var exprSyntax = ExpressionVisitor.VisitExpression(context, expr); return SyntaxFactory.ReturnStatement(exprSyntax); } + + private static MethodDeclaration? FindEnclosingMethod(com.github.javaparser.ast.Node node) + { + var current = node.getParentNode().FromOptional(); + + while (current is not null) + { + if (current is MethodDeclaration methodDecl) + { + return methodDecl; + } + + current = current.getParentNode().FromOptional(); + } + + return null; + } } diff --git a/JavaToCSharp/Statements/StatementVisitor.cs b/JavaToCSharp/Statements/StatementVisitor.cs index 196cb9c9..36e3737a 100644 --- a/JavaToCSharp/Statements/StatementVisitor.cs +++ b/JavaToCSharp/Statements/StatementVisitor.cs @@ -38,6 +38,7 @@ static StatementVisitor() { typeof(ThrowStmt), new ThrowStatementVisitor() }, { typeof(TryStmt), new TryStatementVisitor() }, { typeof(WhileStmt), new WhileStatementVisitor() }, + { typeof(YieldStmt), new YieldStatementVisitor() }, { typeof(EmptyStmt), new EmptyStatementVisitor() }, { typeof(LocalClassDeclarationStmt), new TypeDeclarationStatementVisitor() } }; @@ -46,11 +47,38 @@ static StatementVisitor() protected abstract StatementSyntax? Visit(ConversionContext context, Statement statement); public static List VisitStatements(ConversionContext context, IEnumerable? statements) - => statements is null - ? [] - : statements.Select(statement => VisitStatement(context, statement)) - .OfType() // filter out nulls - .ToList(); + { + if (statements is null) + { + return []; + } + + var results = new List(); + + // Statements pending from an outer statement list must not be drained here; this list is + // only responsible for the statements its own children produce. + var outerPending = context.PendingStatements.Count; + + foreach (var statement in statements) + { + var syntax = VisitStatement(context, statement); + + // A visitor may have lowered part of this statement into statements that must precede it, + // for example a multi-statement switch expression becoming a switch statement. + if (context.PendingStatements.Count > outerPending) + { + results.AddRange(context.PendingStatements.Skip(outerPending)); + context.PendingStatements.RemoveRange(outerPending, context.PendingStatements.Count - outerPending); + } + + if (syntax is not null) + { + results.Add(syntax); + } + } + + return results; + } public static StatementSyntax? VisitStatement(ConversionContext context, Statement statement) { diff --git a/JavaToCSharp/Statements/SwitchExpressionLowering.cs b/JavaToCSharp/Statements/SwitchExpressionLowering.cs new file mode 100644 index 00000000..5ac1e371 --- /dev/null +++ b/JavaToCSharp/Statements/SwitchExpressionLowering.cs @@ -0,0 +1,146 @@ +using com.github.javaparser.ast.expr; +using com.github.javaparser.ast.stmt; +using JavaToCSharp.Expressions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace JavaToCSharp.Statements; + +/// +/// Lowers Java 14 multi-statement switch expressions (those using yield) into C# switch +/// statements that assign into a target variable. C# switch expressions only permit a single +/// expression per arm, so multi-statement arms cannot be represented directly. +/// +internal static class SwitchExpressionLowering +{ + /// + /// Determines whether the given switch expression requires lowering to a switch statement, + /// i.e. whether any arm contains more than a single expression or throw. + /// + public static bool RequiresLowering(SwitchExpr expr) + { + foreach (var entry in expr.getEntries().ToList() ?? []) + { + var statements = entry.getStatements().ToList() ?? []; + + // Zero statements is a fallthrough label in the colon form, which is representable. + if (statements.Count > 1) + { + return true; + } + + if (statements.Count == 1 && statements[0] is not (ThrowStmt or ExpressionStmt)) + { + return true; + } + } + + return false; + } + + /// + /// Builds a switch statement equivalent to , assigning each arm's + /// yielded value to . + /// + public static StatementSyntax Lower(ConversionContext context, SwitchExpr expr, string target) + { + var selector = ExpressionVisitor.VisitExpression(context, expr.getSelector()) + ?? throw new InvalidOperationException("Switch expression selector cannot be null"); + + var entries = expr.getEntries().ToList() ?? []; + var sections = new List(); + var pendingLabels = new List(); + + var previousTarget = context.YieldTarget; + context.YieldTarget = target; + + try + { + foreach (var entry in entries) + { + var labels = entry.getLabels().ToList() ?? []; + + if (labels.Count == 0) + { + pendingLabels.Add(SyntaxFactory.DefaultSwitchLabel()); + } + else + { + foreach (var label in labels) + { + var labelExpr = ExpressionVisitor.VisitExpression(context, label) + ?? throw new InvalidOperationException("Switch expression label must contain an expression"); + + pendingLabels.Add(SyntaxFactory.CaseSwitchLabel(labelExpr)); + } + } + + var statements = entry.getStatements().ToList() ?? []; + + // A label with no statements falls through to the next entry's labels. + if (statements.Count == 0) + { + continue; + } + + var body = BuildSectionBody(context, statements, target); + + sections.Add(SyntaxFactory.SwitchSection( + SyntaxFactory.List(pendingLabels), + SyntaxFactory.List(body))); + + pendingLabels = []; + } + } + finally + { + context.YieldTarget = previousTarget; + } + + return SyntaxFactory.SwitchStatement(selector, SyntaxFactory.List(sections)); + } + + private static List BuildSectionBody( + ConversionContext context, + List statements, + string target) + { + List body; + + // The arrow form wraps the arm body in a block; flatten it so the assignment and break + // sit directly in the switch section rather than inside a nested scope. + if (statements is [BlockStmt block]) + { + body = StatementVisitor.VisitStatements(context, block.getStatements().ToList()); + } + else if (statements is [ExpressionStmt exprStmt]) + { + // Arrow form with a bare expression: `case X -> value` yields that value. + var value = ExpressionVisitor.VisitExpression(context, exprStmt.getExpression()) + ?? throw new InvalidOperationException("Switch expression arm must contain an expression"); + + body = + [ + SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(target), + value)) + ]; + } + else + { + body = StatementVisitor.VisitStatements(context, statements); + } + + if (!EndsControlFlow(body)) + { + body.Add(SyntaxFactory.BreakStatement()); + } + + return body; + } + + private static bool EndsControlFlow(List body) + => body.Count > 0 && body[^1] is BreakStatementSyntax or ReturnStatementSyntax or ThrowStatementSyntax; +} diff --git a/JavaToCSharp/Statements/YieldStatementVisitor.cs b/JavaToCSharp/Statements/YieldStatementVisitor.cs new file mode 100644 index 00000000..222aedfe --- /dev/null +++ b/JavaToCSharp/Statements/YieldStatementVisitor.cs @@ -0,0 +1,37 @@ +using com.github.javaparser.ast.expr; +using com.github.javaparser.ast.stmt; +using JavaToCSharp.Expressions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace JavaToCSharp.Statements; + +/// +/// Converts a Java yield statement. Java's yield produces a value from a switch +/// expression arm; the C# equivalent depends on how the enclosing switch expression was lowered, +/// which is communicated via . +/// +public class YieldStatementVisitor : StatementVisitor +{ + public override StatementSyntax Visit(ConversionContext context, YieldStmt yieldStmt) + { + var expr = yieldStmt.getExpression(); + var exprSyntax = ExpressionVisitor.VisitExpression(context, expr) + ?? throw new InvalidOperationException("Yield statement must contain an expression"); + + var target = context.YieldTarget; + + if (target is null) + { + // Not inside a lowered switch expression; nothing sensible to translate to. + throw new InvalidOperationException("Yield statement encountered outside of a switch expression"); + } + + // Assign to the temporary that receives the switch expression's value, then leave the section. + return SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(target), + exprSyntax)); + } +} From dcc5be4a6fd42a86905b119cb3367d8d6315d14a Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sat, 15 Aug 2026 21:38:40 -0600 Subject: [PATCH 2/2] Add IIFE fallback for nested multi-statement switch expressions When a multi-statement switch expression is nested inside a larger expression, there is nowhere to hoist the statements to, so the statement-level lowering does not apply. These previously failed conversion outright. Emit an immediately-invoked lambda for those arms instead, as described in the issue: Foo(x switch { 1 => ((Func)(() => { int a = 1; return a; }))(), _ => 20 }); The return type cannot be inferred because no symbol solver is configured, so `SPECIFY_ME` is emitted for the user to replace and a warning says so. This does not compile as-is, but converting with one spot to fix is better than failing the whole file. Java's `yield` becomes `return` inside the lambda, selected by a null `YieldTarget`. Arms that are already a single expression are untouched, including a block whose only statement is a yield, so the common cases keep their existing output and no lambda is introduced needlessly. Co-Authored-By: Claude Opus 5 (1M context) --- .../SwitchExpressionLoweringTests.cs | 45 +++++++++-- .../Expressions/SwitchExpressionVisitor.cs | 74 ++++++++++++++++--- .../Statements/YieldStatementVisitor.cs | 8 +- 3 files changed, 108 insertions(+), 19 deletions(-) diff --git a/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs b/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs index c14f3f99..f409a074 100644 --- a/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs +++ b/JavaToCSharp.Tests/SwitchExpressionLoweringTests.cs @@ -27,11 +27,44 @@ public void SingleExpressionArms_ConvertToSwitchExpression(string javaExpr) /// Arms with more than one statement cannot be represented as a C# switch expression arm. /// These are lowered by the statement visitors, so converting the expression alone throws. /// - [Theory] - [InlineData("switch (x) { case 1 -> { int a = 1; yield a; } default -> 20; }")] - public void MultiStatementArms_AreNotConvertibleAsExpression(string javaExpr) + /// + /// In positions where the statements cannot be hoisted out — nested inside a larger expression — + /// the arm is emitted as an immediately-invoked lambda instead. The return type cannot be + /// inferred without a symbol solver, so a placeholder is emitted for the user to replace. + /// + /// + /// Parsed as a whole file rather than via parseExpression, which does not recognise `yield` + /// as a yield statement outside of statement context. + /// + [Fact] + public void MultiStatementArms_NestedInExpression_ConvertToInvokedLambda() { - Assert.ThrowsAny(() => Convert(javaExpr)); + var warnings = new List(); + var options = new JavaConversionOptions { IncludeComments = false }; + options.WarningEncountered += (_, e) => warnings.Add(e.Message); + + var csharp = JavaToCSharpConverter.ConvertText( + """ + package example; + public class Program { + static int foo(int v) { return v; } + public static void main(String[] args) { + int x = 1; + foo(switch (x) { case 1 -> { int a = 1; yield a; } default -> 20; }); + } + } + """, options); + + Assert.NotNull(csharp); + Assert.Contains("Func", csharp); + // yield becomes return inside the lambda + Assert.Contains("return a;", csharp); + // the lambda must actually be invoked, not merely constructed + Assert.Contains("))()", csharp); + // arms that are already a single expression are left alone + Assert.Contains("_ => 20", csharp); + + Assert.Contains(warnings, w => w.Contains("SPECIFY_ME")); } /// @@ -46,11 +79,11 @@ public void FallthroughLabels_DoNotCrash() Assert.Contains("=>", csharp); } - private static string? Convert(string javaExpr) + private static string? Convert(string javaExpr, JavaConversionOptions? options = null) { var parseResult = new JavaParser().parseExpression(javaExpr); var parsedExpr = parseResult.getResult().FromRequiredOptional(); - var context = new ConversionContext(new JavaConversionOptions()); + var context = new ConversionContext(options ?? new JavaConversionOptions()); return ExpressionVisitor.VisitExpression(context, parsedExpr)?.ToString(); } diff --git a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs index 2a98fcb1..1a117642 100644 --- a/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs +++ b/JavaToCSharp/Expressions/SwitchExpressionVisitor.cs @@ -1,5 +1,7 @@ +using com.github.javaparser; using com.github.javaparser.ast.expr; using com.github.javaparser.ast.stmt; +using JavaToCSharp.Statements; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; @@ -72,27 +74,79 @@ private static ExpressionSyntax GetArmExpressionSyntax(ConversionContext context { var statements = entry.getStatements().ToList() ?? []; - if (statements.Count != 1) + if (statements.Count == 0) { - // Multi-statement arms are lowered into switch statements by the statement visitors, - // which is only possible when the switch expression is the whole initializer, assigned - // value, or returned value. Anywhere else there is nowhere to put the extra statements. - throw new InvalidOperationException( - "Switch expressions with multiple statements are only supported when directly assigned to a variable or returned"); + throw new InvalidOperationException("Switch expression entry must contain at least one statement"); + } + + // Reaching here with multiple statements, a block, or a yield means the enclosing statement + // visitor could not hoist the statements out, i.e. the switch expression is nested inside a + // larger expression. Emit an immediately-invoked lambda so the arm can still hold statements. + // A block containing nothing but a yield is equivalent to that single expression. + if (statements is [BlockStmt onlyBlock] + && onlyBlock.getStatements().ToList() is [YieldStmt blockYield]) + { + statements = [blockYield]; + } + + if (statements.Count > 1 || statements[0] is BlockStmt) + { + return GetArmInvokedLambdaSyntax(context, entry, statements); } var armExpr = statements[0] switch { ThrowStmt throwStmt => throwStmt.getExpression(), ExpressionStmt exprStmt => exprStmt.getExpression(), - // A yield or block here means the arm needs lowering, which is only possible when the - // switch expression is directly assigned or returned rather than nested in another expression. - YieldStmt or BlockStmt => throw new InvalidOperationException( - "Switch expressions with multiple statements are only supported when directly assigned to a variable or returned"), + // A lone yield is just the value it yields. + YieldStmt yieldStmt => yieldStmt.getExpression(), _ => throw new InvalidOperationException("Only throw and expression statements are supported in switch expressions") }; return VisitExpression(context, armExpr) ?? throw new InvalidOperationException("Switch expression entry must contain a single expression statement"); } + + /// + /// Builds ((Func<SPECIFY_ME>)(() => { ... }))() for an arm whose statements cannot be + /// hoisted into the enclosing block. The return type cannot be inferred without a symbol solver, + /// so a placeholder is emitted for the user to replace. + /// + private static ExpressionSyntax GetArmInvokedLambdaSyntax( + ConversionContext context, + SwitchEntry entry, + List statements) + { + context.Options.Warning( + "Switch expression arm with multiple statements is nested within another expression and was " + + "converted to an invoked lambda. Replace SPECIFY_ME with the appropriate return type.", + entry.getBegin().FromRequiredOptional().line); + + // Inside a lambda, Java's `yield` becomes `return`. A null YieldTarget selects that behaviour. + var previousTarget = context.YieldTarget; + context.YieldTarget = null; + + List body; + + try + { + // The arrow form wraps the arm in a block; use its statements directly to avoid nesting. + body = statements is [BlockStmt block] + ? StatementVisitor.VisitStatements(context, block.getStatements().ToList()) + : StatementVisitor.VisitStatements(context, statements); + } + finally + { + context.YieldTarget = previousTarget; + } + + var lambda = ParenthesizedLambdaExpression().WithBlock(Block(body)); + + var funcType = GenericName(Identifier("Func")) + .WithTypeArgumentList(TypeArgumentList(SingletonSeparatedList(IdentifierName("SPECIFY_ME")))); + + return InvocationExpression( + ParenthesizedExpression( + CastExpression(funcType, ParenthesizedExpression(lambda)))); + } } diff --git a/JavaToCSharp/Statements/YieldStatementVisitor.cs b/JavaToCSharp/Statements/YieldStatementVisitor.cs index 222aedfe..5c229c29 100644 --- a/JavaToCSharp/Statements/YieldStatementVisitor.cs +++ b/JavaToCSharp/Statements/YieldStatementVisitor.cs @@ -9,7 +9,9 @@ namespace JavaToCSharp.Statements; /// /// Converts a Java yield statement. Java's yield produces a value from a switch /// expression arm; the C# equivalent depends on how the enclosing switch expression was lowered, -/// which is communicated via . +/// which is communicated via . When a target is set the +/// value is assigned to it within a switch statement; otherwise the arm became an invoked lambda +/// and the value is returned. /// public class YieldStatementVisitor : StatementVisitor { @@ -23,8 +25,8 @@ public override StatementSyntax Visit(ConversionContext context, YieldStmt yield if (target is null) { - // Not inside a lowered switch expression; nothing sensible to translate to. - throw new InvalidOperationException("Yield statement encountered outside of a switch expression"); + // Inside an invoked lambda, yielding a value is a return. + return SyntaxFactory.ReturnStatement(exprSyntax); } // Assign to the temporary that receives the switch expression's value, then leave the section.