From d05e1ebbe51a720e84d7a19f68d6a5b9d9fe89dc Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Mon, 17 Aug 2026 10:59:41 -0600 Subject: [PATCH 1/2] Support C# 15 labeled break and continue with goto fallback Java's labeled `break`/`continue` were previously converted to a plain `break`/`continue` with a warning, which silently changed the semantics: the jump targeted the innermost loop rather than the labeled one. Adds a `UseLabeledBreakAndContinue` option, enabled by default, which emits the C# 15 labeled jump syntax (`break outer;`). When disabled, the jumps are lowered to an equivalent `goto` with generated target labels, which is valid in every C# version. Roslyn cannot represent a labeled jump: there is no label operand on BreakStatementSyntax, and the parser rejects `break outer;` even at LanguageVersion.Preview. The C# 15 form is therefore emitted as a `goto` placeholder and substituted into the final text after normalization. The goto fallback places the continue target at the end of the loop body and the break target after the loop, emitting only the targets actually used. Closes #169 Co-Authored-By: Claude Opus 5 (1M context) --- .../ConvertLabeledBreakContinueTests.cs | 189 ++++++++++++++++++ JavaToCSharp.Tests/IntegrationTests.cs | 3 + .../Resources/LabeledBreakContinue.java | 42 ++++ JavaToCSharp/ConversionContext.cs | 12 ++ JavaToCSharp/JavaConversionOptions.cs | 7 + JavaToCSharp/JavaToCSharpConverter.cs | 4 +- .../Statements/BreakStatementVisitor.cs | 8 +- .../Statements/ContinueStatementVisitor.cs | 8 +- JavaToCSharp/Statements/LabeledJumpHelper.cs | 78 ++++++++ .../Statements/LabeledStatementVisitor.cs | 93 ++++++++- JavaToCSharpCli/Program.cs | 8 + JavaToCSharpGui/App.config | 3 + JavaToCSharpGui/CurrentOptions.cs | 2 + .../Properties/Settings.Designer.cs | 12 ++ JavaToCSharpGui/Properties/Settings.settings | 3 + .../ViewModels/SettingsWindowViewModel.cs | 3 + JavaToCSharpGui/Views/SettingsWindow.axaml | 4 + 17 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 JavaToCSharp.Tests/ConvertLabeledBreakContinueTests.cs create mode 100644 JavaToCSharp.Tests/Resources/LabeledBreakContinue.java create mode 100644 JavaToCSharp/Statements/LabeledJumpHelper.cs diff --git a/JavaToCSharp.Tests/ConvertLabeledBreakContinueTests.cs b/JavaToCSharp.Tests/ConvertLabeledBreakContinueTests.cs new file mode 100644 index 00000000..24dc7dc0 --- /dev/null +++ b/JavaToCSharp.Tests/ConvertLabeledBreakContinueTests.cs @@ -0,0 +1,189 @@ +namespace JavaToCSharp.Tests; + +public class ConvertLabeledBreakContinueTests +{ + private const string NestedLoops = """ + package com.example; + public class Grid { + public void scan() { + outer: for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + if (j == 1) { + continue outer; + } + if (i == 3) { + break outer; + } + } + } + } + } + """; + + [Fact] + public void Labeled_Jumps_Use_CSharp15_Syntax_By_Default() + { + var warnings = new List(); + var parsed = Convert(NestedLoops, NewOptions(warnings)); + + Assert.Contains("outer:", parsed); + Assert.Contains("break outer;", parsed); + Assert.Contains("continue outer;", parsed); + Assert.DoesNotContain("goto", parsed); + Assert.Empty(warnings); + } + + [Fact] + public void Labeled_Jumps_Fall_Back_To_Goto_When_Option_Disabled() + { + var warnings = new List(); + var parsed = Convert(NestedLoops, NewOptions(warnings, useLabeledJumps: false)); + + Assert.Contains("goto outer_break;", parsed); + Assert.Contains("goto outer_continue;", parsed); + Assert.Contains("outer_break:", parsed); + Assert.Contains("outer_continue:", parsed); + Assert.DoesNotContain("break outer;", parsed); + Assert.DoesNotContain("continue outer;", parsed); + Assert.Empty(warnings); + } + + [Fact] + public void Goto_Fallback_Emits_Continue_Target_Inside_Loop_And_Break_Target_After() + { + var parsed = Convert(NestedLoops, NewOptions(useLabeledJumps: false)); + + // The continue target must precede the break target: it belongs to the end of the loop body, + // while the break target must follow the loop entirely. + int continueTarget = parsed.IndexOf("outer_continue:", StringComparison.Ordinal); + int breakTarget = parsed.IndexOf("outer_break:", StringComparison.Ordinal); + + Assert.True(continueTarget > 0 && breakTarget > 0); + Assert.True(continueTarget < breakTarget); + } + + [Fact] + public void Goto_Fallback_Only_Emits_Targets_That_Are_Used() + { + const string breakOnly = """ + package com.example; + public class Grid { + public void scan() { + outer: for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + if (j == 1) { + break outer; + } + } + } + } + } + """; + + var parsed = Convert(breakOnly, NewOptions(useLabeledJumps: false)); + + Assert.Contains("outer_break:", parsed); + Assert.DoesNotContain("outer_continue", parsed); + } + + [Fact] + public void Unlabeled_Jumps_Are_Unaffected() + { + const string unlabeled = """ + package com.example; + public class Grid { + public void scan() { + for (int i = 0; i < 4; i++) { + if (i == 1) { + continue; + } + if (i == 3) { + break; + } + } + } + } + """; + + foreach (bool useLabeledJumps in new[] { true, false }) + { + var parsed = Convert(unlabeled, NewOptions(useLabeledJumps: useLabeledJumps)); + + Assert.Contains("break;", parsed); + Assert.Contains("continue;", parsed); + Assert.DoesNotContain("goto", parsed); + } + } + + [Fact] + public void Labeled_Continue_On_While_Loop_Is_Lowered_Into_Loop_Body() + { + const string whileLoop = """ + package com.example; + public class Counter { + public void count() { + int k = 0; + loop: while (k < 5) { + k++; + if (k < 5) { + continue loop; + } + break loop; + } + } + } + """; + + var parsed = Convert(whileLoop, NewOptions(useLabeledJumps: false)); + + int loopEnd = parsed.IndexOf("loop_break:", StringComparison.Ordinal); + int continueTarget = parsed.IndexOf("loop_continue:", StringComparison.Ordinal); + + // The continue target belongs inside the while body, so it appears before the break target. + Assert.True(continueTarget > 0); + Assert.True(continueTarget < loopEnd); + } + + [Fact] + public void Nested_Labels_Each_Get_Their_Own_Targets() + { + const string nestedLabels = """ + package com.example; + public class Grid { + public void scan() { + outer: for (int i = 0; i < 4; i++) { + inner: for (int j = 0; j < 4; j++) { + if (j == 1) { + break inner; + } + if (i == 3) { + break outer; + } + } + } + } + } + """; + + var parsed = Convert(nestedLabels, NewOptions(useLabeledJumps: false)); + + Assert.Contains("goto inner_break;", parsed); + Assert.Contains("goto outer_break;", parsed); + Assert.Contains("inner_break:", parsed); + Assert.Contains("outer_break:", parsed); + } + + private static JavaConversionOptions NewOptions(List? warnings = null, bool useLabeledJumps = true) + { + var options = new JavaConversionOptions + { + IncludeComments = false, + UseLabeledBreakAndContinue = useLabeledJumps, + }; + options.WarningEncountered += (_, eventArgs) => warnings?.Add(eventArgs.Message); + return options; + } + + private static string Convert(string javaCode, JavaConversionOptions? options = null) + => JavaToCSharpConverter.ConvertText(javaCode, options ?? NewOptions()) ?? ""; +} diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index 58dfd831..9030f07d 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -87,12 +87,15 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/Java16LocalRecords.java")] [InlineData("Resources/InstanceInitializers.java")] [InlineData("Resources/StaticImports.java")] + // Uses the `goto` fallback: the C# 15 labeled jump syntax cannot be compiled by the Roslyn version here. + [InlineData("Resources/LabeledBreakContinue.java")] public void FullIntegrationTests(string filePath, bool allowWarnings = false) { var options = new JavaConversionOptions { ConvertSystemOutToConsole = true, IncludeComments = false, + UseLabeledBreakAndContinue = false, }; options.AddUsing("System"); diff --git a/JavaToCSharp.Tests/Resources/LabeledBreakContinue.java b/JavaToCSharp.Tests/Resources/LabeledBreakContinue.java new file mode 100644 index 00000000..3ded6299 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/LabeledBreakContinue.java @@ -0,0 +1,42 @@ +/// - output: "0,0\n1,0\n2,0\nsearch=1,2\ndone\n" +package example; + +public class Program { + public static void main(String[] args) { + outer: for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + if (j == 1) { + continue outer; + } + if (i == 3) { + break outer; + } + System.out.println(i + "," + j); + } + } + + int foundRow = -1; + int foundCol = -1; + search: for (int row = 0; row < 3; row++) { + for (int col = 0; col < 3; col++) { + if (row + col == 3) { + foundRow = row; + foundCol = col; + break search; + } + } + } + System.out.println("search=" + foundRow + "," + foundCol); + + int k = 0; + loop: while (k < 5) { + k++; + if (k < 5) { + continue loop; + } + break loop; + } + + System.out.println("done"); + } +} diff --git a/JavaToCSharp/ConversionContext.cs b/JavaToCSharp/ConversionContext.cs index 6b2d5ac5..250e15f8 100644 --- a/JavaToCSharp/ConversionContext.cs +++ b/JavaToCSharp/ConversionContext.cs @@ -42,6 +42,18 @@ public class ConversionContext(JavaConversionOptions options) /// internal string? YieldTarget { get; set; } + /// + /// Java labels targeted by a labeled break that was lowered to a goto. The labeled statement + /// visitor emits a matching target label after the loop for each one. + /// + internal ISet LabelsNeedingBreakTarget { get; } = new HashSet(); + + /// + /// Java labels targeted by a labeled continue that was lowered to a goto. The labeled + /// statement visitor emits a matching target label at the end of the loop body for each one. + /// + internal ISet LabelsNeedingContinueTarget { get; } = new HashSet(); + private int _uniqueLocalCounter; /// diff --git a/JavaToCSharp/JavaConversionOptions.cs b/JavaToCSharp/JavaConversionOptions.cs index 805cd1c6..a5ddb5b7 100644 --- a/JavaToCSharp/JavaConversionOptions.cs +++ b/JavaToCSharp/JavaConversionOptions.cs @@ -39,6 +39,13 @@ public class JavaConversionOptions /// public bool UseClosedForSealedClasses { get; set; } = true; + /// + /// Translates Java labeled break and continue using the C# 15 labeled jump syntax + /// (break outer;), which requires .NET 11 or later. + /// When disabled, the jumps are lowered to an equivalent goto and generated target labels instead. + /// + public bool UseLabeledBreakAndContinue { get; set; } = true; + public SyntaxMapping SyntaxMappings { get; set; } = new SyntaxMapping(); public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None) diff --git a/JavaToCSharp/JavaToCSharpConverter.cs b/JavaToCSharp/JavaToCSharpConverter.cs index 20e94093..cbdf204c 100644 --- a/JavaToCSharp/JavaToCSharpConverter.cs +++ b/JavaToCSharp/JavaToCSharpConverter.cs @@ -156,6 +156,8 @@ public static class JavaToCSharpConverter context.ConversionStateChanged(ConversionState.Done); - return tree.GetText().ToString(); + // C# 15 labeled jumps cannot be represented in a Roslyn tree, so they are emitted as placeholders + // and substituted into the final text here. See LabeledJumpHelper. + return Statements.LabeledJumpHelper.RewritePlaceholders(tree.GetText().ToString()); } } diff --git a/JavaToCSharp/Statements/BreakStatementVisitor.cs b/JavaToCSharp/Statements/BreakStatementVisitor.cs index e84deb9a..b70b3f1a 100644 --- a/JavaToCSharp/Statements/BreakStatementVisitor.cs +++ b/JavaToCSharp/Statements/BreakStatementVisitor.cs @@ -1,4 +1,4 @@ -using com.github.javaparser; +using com.github.javaparser.ast.expr; using com.github.javaparser.ast.stmt; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -9,8 +9,10 @@ public class BreakStatementVisitor : StatementVisitor { public override StatementSyntax Visit(ConversionContext context, BreakStmt brk) { - if (brk.getLabel().isPresent()) - context.Options.Warning("Break with label detected, using plain break instead. Check for correctness.", brk.getBegin().FromRequiredOptional().line); + var label = brk.getLabel().FromOptional(); + + if (label is not null) + return LabeledJumpHelper.CreateJump(context, label.asString(), isBreak: true); return SyntaxFactory.BreakStatement(); } diff --git a/JavaToCSharp/Statements/ContinueStatementVisitor.cs b/JavaToCSharp/Statements/ContinueStatementVisitor.cs index aee2a3a7..5f2a4828 100644 --- a/JavaToCSharp/Statements/ContinueStatementVisitor.cs +++ b/JavaToCSharp/Statements/ContinueStatementVisitor.cs @@ -1,4 +1,4 @@ -using com.github.javaparser; +using com.github.javaparser.ast.expr; using com.github.javaparser.ast.stmt; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -9,8 +9,10 @@ public class ContinueStatementVisitor : StatementVisitor { public override StatementSyntax Visit(ConversionContext context, ContinueStmt cnt) { - if (cnt.getLabel().isPresent()) - context.Options.Warning("Continue with label detected, using plain continue instead. Check for correctness.", cnt.getBegin().FromRequiredOptional().line); + var label = cnt.getLabel().FromOptional(); + + if (label is not null) + return LabeledJumpHelper.CreateJump(context, label.asString(), isBreak: false); return SyntaxFactory.ContinueStatement(); } diff --git a/JavaToCSharp/Statements/LabeledJumpHelper.cs b/JavaToCSharp/Statements/LabeledJumpHelper.cs new file mode 100644 index 00000000..1a8bd2b5 --- /dev/null +++ b/JavaToCSharp/Statements/LabeledJumpHelper.cs @@ -0,0 +1,78 @@ +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace JavaToCSharp.Statements; + +/// +/// Lowers Java's labeled break/continue into C#. +/// +/// +/// Two shapes are supported, selected by : +/// +/// C# 15 labeled jumps (break outer;). Roslyn cannot model these — there is no label operand on +/// and the parser rejects the syntax even at +/// LanguageVersion.Preview — so a goto placeholder is emitted instead and rewritten to the real +/// text after whitespace normalization by . +/// A goto to a generated target label, which is valid in every C# version. +/// +/// +internal static partial class LabeledJumpHelper +{ + /// + /// Prefix for the placeholder goto target that stands in for a C# 15 labeled jump. + /// Chosen to be a valid C# identifier so the placeholder tree parses and normalizes cleanly. + /// + private const string PlaceholderPrefix = "__javaToCSharp_labeled_"; + + [GeneratedRegex($@"goto {PlaceholderPrefix}(break|continue)_(\w+);")] + private static partial Regex PlaceholderRegex { get; } + + /// + /// The label a goto jumps to in order to leave the loop labeled . + /// Emitted immediately after the loop. + /// + internal static string BreakTargetLabel(string label) => $"{label}_break"; + + /// + /// The label a goto jumps to in order to start the next iteration of the loop labeled + /// . Emitted as the last statement of the loop body. + /// + internal static string ContinueTargetLabel(string label) => $"{label}_continue"; + + /// + /// Creates the statement for a labeled break or continue, recording on the context which + /// target labels the goto fallback must emit. + /// + internal static StatementSyntax CreateJump(ConversionContext context, string label, bool isBreak) + { + if (context.Options.UseLabeledBreakAndContinue) + { + // Placeholder for `break