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
189 changes: 189 additions & 0 deletions JavaToCSharp.Tests/ConvertLabeledBreakContinueTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string>();
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<string>? 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()) ?? "";
}
24 changes: 22 additions & 2 deletions JavaToCSharp.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,26 @@ public void GeneralUnsuccessfulConversionTest(string filePath)
[InlineData("Resources/Java16LocalRecords.java")]
[InlineData("Resources/InstanceInitializers.java")]
[InlineData("Resources/StaticImports.java")]
[InlineData("Resources/LabeledBreakContinue.java")]
public void FullIntegrationTests(string filePath, bool allowWarnings = false)
=> RunFullIntegrationTest(filePath, allowWarnings);

/// <summary>
/// Runs the labeled break/continue sample through the <c>goto</c> fallback, to confirm it behaves
/// identically to the C# 15 labeled jumps covered by <see cref="FullIntegrationTests"/>.
/// </summary>
[Theory]
[InlineData("Resources/LabeledBreakContinue.java")]
public void FullIntegrationTestsWithGotoFallback(string filePath)
=> RunFullIntegrationTest(filePath, allowWarnings: false, useLabeledBreakAndContinue: false);

private void RunFullIntegrationTest(string filePath, bool allowWarnings, bool useLabeledBreakAndContinue = true)
{
var options = new JavaConversionOptions
{
ConvertSystemOutToConsole = true,
IncludeComments = false,
UseLabeledBreakAndContinue = useLabeledBreakAndContinue,
};

options.AddUsing("System");
Expand All @@ -113,7 +127,10 @@ public void FullIntegrationTests(string filePath, bool allowWarnings = false)

testOutputHelper.WriteLine(parsed);

var fileName = Path.GetFileNameWithoutExtension(filePath);
// The suffix keeps the two option variants in separate assemblies, because Assembly.LoadFile
// caches by path and would otherwise reuse the first variant's compiled output.
var fileName = Path.GetFileNameWithoutExtension(filePath)
+ (useLabeledBreakAndContinue ? "" : "_Goto");
var assembly = CompileAssembly(fileName, parsed);

var expectation = ParseExpectation(javaText);
Expand Down Expand Up @@ -168,7 +185,10 @@ public void FullIntegrationTests(string filePath, bool allowWarnings = false)

private static Assembly CompileAssembly(string assemblyName, string cSharpLanguageText)
{
var syntaxTree = CSharpSyntaxTree.ParseText(cSharpLanguageText);
// Preview is required for the C# 15 features the converter emits, such as labeled break/continue.
var syntaxTree = CSharpSyntaxTree.ParseText(
cSharpLanguageText,
new CSharpParseOptions(LanguageVersion.Preview));

var options = new CSharpCompilationOptions(OutputKind.ConsoleApplication)
.WithOverflowChecks(true)
Expand Down
42 changes: 42 additions & 0 deletions JavaToCSharp.Tests/Resources/LabeledBreakContinue.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
12 changes: 12 additions & 0 deletions JavaToCSharp/ConversionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ public class ConversionContext(JavaConversionOptions options)
/// </summary>
internal string? YieldTarget { get; set; }

/// <summary>
/// Java labels targeted by a labeled <c>break</c> that was lowered to a <c>goto</c>. The labeled statement
/// visitor emits a matching target label after the loop for each one.
/// </summary>
internal ISet<string> LabelsNeedingBreakTarget { get; } = new HashSet<string>();

/// <summary>
/// Java labels targeted by a labeled <c>continue</c> that was lowered to a <c>goto</c>. The labeled
/// statement visitor emits a matching target label at the end of the loop body for each one.
/// </summary>
internal ISet<string> LabelsNeedingContinueTarget { get; } = new HashSet<string>();

private int _uniqueLocalCounter;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,10 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con
{
if (context.Options.UseClosedForSealedClasses)
{
// Roslyn has no ClosedKeyword token, as `closed` is a C# 15 feature.
classSyntax = classSyntax.AddModifiers(SyntaxFactory.ParseToken("closed "));
// The `closed` keyword is still marked experimental in Roslyn.
#pragma warning disable RSEXPERIMENTAL006
classSyntax = classSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.ClosedKeyword));
#pragma warning restore RSEXPERIMENTAL006
}
else
{
Expand Down
7 changes: 7 additions & 0 deletions JavaToCSharp/JavaConversionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ public class JavaConversionOptions
/// </summary>
public bool UseClosedForSealedClasses { get; set; } = true;

/// <summary>
/// Translates Java labeled <c>break</c> and <c>continue</c> using the C# 15 labeled jump syntax
/// (<c>break outer;</c>), which requires .NET 11 or later.
/// When disabled, the jumps are lowered to an equivalent <c>goto</c> and generated target labels instead.
/// </summary>
public bool UseLabeledBreakAndContinue { get; set; } = true;

public SyntaxMapping SyntaxMappings { get; set; } = new SyntaxMapping();

public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None)
Expand Down
7 changes: 6 additions & 1 deletion JavaToCSharp/JavaToCSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
<ItemGroup>
<PackageReference Include="IKVM" Version="8.15.0"/>
<PackageReference Include="IKVM.Maven.Sdk" Version="1.12.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<!--
Microsoft.CodeAnalysis.CSharp 5.9.0 depends on an unpublished Microsoft.CodeAnalysis.Analyzers
prerelease. Referencing the released 5.9.0 explicitly satisfies that range and avoids NU1603.
-->
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageReference Include="Nullable.Extended.Analyzer" Version="1.15.6581"/>
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.11" />
<PackageReference Include="YamlDotNet" Version="18.1.0" />
Expand Down
8 changes: 5 additions & 3 deletions JavaToCSharp/Statements/BreakStatementVisitor.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,8 +9,10 @@ public class BreakStatementVisitor : StatementVisitor<BreakStmt>
{
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<Position>().line);
var label = brk.getLabel().FromOptional<SimpleName>();

if (label is not null)
return LabeledJumpHelper.CreateJump(context, label.asString(), isBreak: true);

return SyntaxFactory.BreakStatement();
}
Expand Down
8 changes: 5 additions & 3 deletions JavaToCSharp/Statements/ContinueStatementVisitor.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,8 +9,10 @@ public class ContinueStatementVisitor : StatementVisitor<ContinueStmt>
{
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<Position>().line);
var label = cnt.getLabel().FromOptional<SimpleName>();

if (label is not null)
return LabeledJumpHelper.CreateJump(context, label.asString(), isBreak: false);

return SyntaxFactory.ContinueStatement();
}
Expand Down
Loading
Loading