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
125 changes: 125 additions & 0 deletions JavaToCSharp.Tests/ConvertSealedTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
namespace JavaToCSharp.Tests;

public class ConvertSealedTests
{
[Fact]
public void Sealed_Class_Is_Converted_To_Closed_Class_By_Default()
{
const string javaCode = """
package com.example;
public sealed class Shape
permits Circle, Square, Rectangle {
}
""";

var warnings = new List<string>();
var parsed = Convert(javaCode, NewOptions(warnings));

Assert.Contains("// Java: sealed, permits Circle, Square, Rectangle", parsed);
Assert.Contains("public closed class Shape", parsed);
Assert.Empty(warnings);
}

[Fact]
public void Sealed_Class_Omits_Closed_Modifier_And_Warns_When_Option_Disabled()
{
const string javaCode = """
package com.example;
public sealed class Shape
permits Circle, Square, Rectangle {
}
""";

var warnings = new List<string>();
var options = NewOptions(warnings);
options.UseClosedForSealedClasses = false;

var parsed = Convert(javaCode, options);

Assert.Contains("// Java: sealed, permits Circle, Square, Rectangle", parsed);
Assert.Contains("public class Shape", parsed);
Assert.DoesNotContain("closed", parsed);
Assert.Contains(warnings, w => w.Contains("Sealed class Shape"));
}

[Fact]
public void Sealed_Interface_Is_Converted_To_Plain_Interface_With_Warning()
{
const string javaCode = """
package com.example;
public sealed interface Service permits Alpha, Beta {
void run();
}
""";

var warnings = new List<string>();
var parsed = Convert(javaCode, NewOptions(warnings));

Assert.Contains("// Java: sealed, permits Alpha, Beta", parsed);
Assert.Contains("public interface Service", parsed);
Assert.DoesNotContain("closed", parsed);
Assert.Contains(warnings, w => w.Contains("Sealed interface Service"));
}

[Fact]
public void Non_Sealed_Class_Is_Converted_Without_Modifier_With_Warning()
{
const string javaCode = """
package com.example;
public non-sealed class Square extends Shape {
public double side;
}
""";

var warnings = new List<string>();
var parsed = Convert(javaCode, NewOptions(warnings));

Assert.Contains("// Java: non-sealed", parsed);
Assert.Contains("public class Square : Shape", parsed);
Assert.DoesNotContain("closed", parsed);
Assert.Contains(warnings, w => w.Contains("Non-sealed class Square"));
}

[Fact]
public void Sealed_Class_Without_Permits_Clause_Omits_Permits_From_Comment()
{
const string javaCode = """
package com.example;
public sealed class Shape {
public static final class Circle extends Shape {}
}
""";

var parsed = Convert(javaCode);

Assert.Contains("// Java: sealed", parsed);
Assert.DoesNotContain("permits", parsed);
}

[Fact]
public void Ordinary_Class_Has_No_Sealed_Comment_Or_Warning()
{
const string javaCode = """
package com.example;
public class Shape {
}
""";

var warnings = new List<string>();
var parsed = Convert(javaCode, NewOptions(warnings));

Assert.DoesNotContain("// Java:", parsed);
Assert.DoesNotContain("closed", parsed);
Assert.Empty(warnings);
}

private static JavaConversionOptions NewOptions(List<string>? warnings = null)
{
var options = new JavaConversionOptions { IncludeComments = false };
options.WarningEncountered += (_, eventArgs) => warnings?.Add(eventArgs.Message);
return options;
}

private static string Convert(string javaCode, JavaConversionOptions? options = null)
=> JavaToCSharpConverter.ConvertText(javaCode, options ?? NewOptions()) ?? "";
}
1 change: 1 addition & 0 deletions JavaToCSharp.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class IntegrationTests(ITestOutputHelper testOutputHelper)
[InlineData("Resources/Java9DiamondOperatorInnerClass.java")]
[InlineData("Resources/Java11LambdaInference.java")]
[InlineData("Resources/MultidimensionalArrays.java", true)]
[InlineData("Resources/Java17SealedClasses.java", true)]
public void GeneralSuccessfulConversionTest(string filePath, bool allowWarnings = false)
{
var options = new JavaConversionOptions
Expand Down
41 changes: 41 additions & 0 deletions JavaToCSharp.Tests/Resources/Java17SealedClasses.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package example;

public class Java17SealedClasses {
public sealed class Shape
permits Circle, Square, Rectangle {
public double area() {
return 0.0;
}
}

public final class Circle extends Shape {
public double radius;
}

public non-sealed class Square extends Shape {
public double side;
}

public sealed class Rectangle extends Shape permits FilledRectangle {
public double width;
public double height;
}

public final class FilledRectangle extends Rectangle {
public String color;
}

public sealed interface Service permits Alpha, Beta {
void run();
}

public final class Alpha implements Service {
public void run() {
}
}

public final class Beta implements Service {
public void run() {
}
}
}
78 changes: 75 additions & 3 deletions JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,54 @@
using com.github.javaparser.ast;
using com.github.javaparser;
using com.github.javaparser.ast;
using com.github.javaparser.ast.body;
using com.github.javaparser.ast.expr;
using com.github.javaparser.ast.type;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace JavaToCSharp.Declarations;

public class ClassOrInterfaceDeclarationVisitor : BodyDeclarationVisitor<ClassOrInterfaceDeclaration>
{
/// <summary>
/// Builds the <c>// Java: sealed, permits ...</c> comment describing a sealed hierarchy that C# cannot
/// fully express, or <see langword="null"/> when the declaration is neither sealed nor non-sealed.
/// </summary>
private static string? GetSealedComment(ClassOrInterfaceDeclaration decl, ISet<Modifier.Keyword> mods)
{
bool isSealed = mods.Contains(Modifier.Keyword.SEALED);
bool isNonSealed = mods.Contains(Modifier.Keyword.NON_SEALED);

if (!isSealed && !isNonSealed)
{
return null;
}

string keyword = isSealed ? "sealed" : "non-sealed";

var permitted = decl.getPermittedTypes().ToList<ClassOrInterfaceType>() ?? [];

return permitted.Count > 0
? $"// Java: {keyword}, permits {string.Join(", ", permitted.Select(i => i.getNameAsString()))}"
: $"// Java: {keyword}";
}

private static SyntaxTriviaList BuildSealedTrivia(SyntaxTriviaList existing, string comment)
=> existing
.Add(SyntaxFactory.Comment(comment))
.Add(SyntaxFactory.ElasticCarriageReturnLineFeed);

private static ClassDeclarationSyntax WithSealedComment(ClassDeclarationSyntax syntax, string? comment)
=> comment is null
? syntax
: syntax.WithLeadingTrivia(BuildSealedTrivia(syntax.GetLeadingTrivia(), comment));

private static InterfaceDeclarationSyntax WithSealedComment(InterfaceDeclarationSyntax syntax, string? comment)
=> comment is null
? syntax
: syntax.WithLeadingTrivia(BuildSealedTrivia(syntax.GetLeadingTrivia(), comment));

public override MemberDeclarationSyntax? VisitForClass(
ConversionContext context,
ClassDeclarationSyntax classSyntax,
Expand Down Expand Up @@ -67,6 +107,15 @@ public static InterfaceDeclarationSyntax VisitInterfaceDeclaration(ConversionCon
if (mods.Contains(Modifier.Keyword.FINAL))
classSyntax = classSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.SealedKeyword));

string? sealedComment = GetSealedComment(interfaceDecl, mods);

if (mods.Contains(Modifier.Keyword.SEALED))
{
context.Options.Warning(
$"Sealed interface {newTypeName} has no C# equivalent and was converted to a non-sealed interface. Check for correctness.",
interfaceDecl.getBegin().FromRequiredOptional<Position>().line);
}

var extends = interfaceDecl.getExtendedTypes().ToList<ClassOrInterfaceType>();

if (extends is not null)
Expand Down Expand Up @@ -103,7 +152,7 @@ public static InterfaceDeclarationSyntax VisitInterfaceDeclaration(ConversionCon
}
}

return classSyntax.WithJavaComments(context, interfaceDecl);
return WithSealedComment(classSyntax.WithJavaComments(context, interfaceDecl), sealedComment);
}

public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext context,
Expand Down Expand Up @@ -142,6 +191,29 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con
if (mods.Contains(Modifier.Keyword.FINAL))
classSyntax = classSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.SealedKeyword));

string? sealedComment = GetSealedComment(classDecl, mods);

if (mods.Contains(Modifier.Keyword.SEALED))
{
if (context.Options.UseClosedForSealedClasses)
{
// Roslyn has no ClosedKeyword token, as `closed` is a C# 15 feature.
classSyntax = classSyntax.AddModifiers(SyntaxFactory.ParseToken("closed "));
}
else
{
context.Options.Warning(
$"Sealed class {name} was converted without the C# 15 `closed` modifier. Check for correctness.",
classDecl.getBegin().FromRequiredOptional<Position>().line);
}
}
else if (mods.Contains(Modifier.Keyword.NON_SEALED))
{
context.Options.Warning(
$"Non-sealed class {name} has no C# equivalent and was converted without a modifier. Check for correctness.",
classDecl.getBegin().FromRequiredOptional<Position>().line);
}

var extends = classDecl.getExtendedTypes().ToList<ClassOrInterfaceType>() ?? [];

foreach (var extend in extends)
Expand Down Expand Up @@ -223,6 +295,6 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con
}
}

return classSyntax.WithJavaComments(context, classDecl);
return WithSealedComment(classSyntax.WithJavaComments(context, classDecl), sealedComment);
}
}
6 changes: 6 additions & 0 deletions JavaToCSharp/JavaConversionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public class JavaConversionOptions

public bool UseFileScopedNamespaces { get; set; }

/// <summary>
/// Translates Java sealed classes using the C# 15 <c>closed</c> modifier, which requires .NET 11 or later.
/// When disabled, sealed classes are emitted without a modifier and a warning is raised instead.
/// </summary>
public bool UseClosedForSealedClasses { get; set; } = true;

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

public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None)
Expand Down
8 changes: 8 additions & 0 deletions JavaToCSharpCli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ public class Program
DefaultValueFactory = _ => false,
};

private static readonly Option<bool> _noClosedForSealedClassesOption = new("--no-closed-for-sealed-classes")
{
Description = "Do not use the C# 15 `closed` modifier when converting Java sealed classes",
DefaultValueFactory = _ => false,
};

private static readonly Option<bool> _clearDefaultUsingsOption = new("--clear-usings")
{
Description = "Remove all default usings provided by this app",
Expand Down Expand Up @@ -109,6 +115,7 @@ public static async Task Main(string[] args)
rootCommand.Options.Add(_commentUnrecognizedCodeOption);
rootCommand.Options.Add(_systemOutToConsoleOption);
rootCommand.Options.Add(_fileScopedNamespacesOption);
rootCommand.Options.Add(_noClosedForSealedClassesOption);
rootCommand.Options.Add(_clearDefaultUsingsOption);
rootCommand.Options.Add(_addUsingsOption);
rootCommand.Options.Add(_mappingsFileNameOption);
Expand Down Expand Up @@ -164,6 +171,7 @@ private static JavaConversionOptions GetJavaConversionOptions(ParseResult contex
UseDebugAssertForAsserts = context.GetValue(_useDebugAssertOption),
UseUnrecognizedCodeToComment = context.GetValue(_commentUnrecognizedCodeOption),
UseFileScopedNamespaces = context.GetValue(_fileScopedNamespacesOption),
UseClosedForSealedClasses = !context.GetValue(_noClosedForSealedClassesOption),
};

if (context.GetValue(_clearDefaultUsingsOption))
Expand Down
3 changes: 3 additions & 0 deletions JavaToCSharpGui/App.config
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
<setting name="UseFileScopedNamespaces" serializeAs="String">
<value>False</value>
</setting>
<setting name="UseClosedForSealedClasses" serializeAs="String">
<value>True</value>
</setting>
</JavaToCSharpGui.Properties.Settings>
</userSettings>
</configuration>
2 changes: 2 additions & 0 deletions JavaToCSharpGui/CurrentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ static CurrentOptions()
Options.UseUnrecognizedCodeToComment = Settings.Default.UseUnrecognizedCodeToComment;
Options.ConvertSystemOutToConsole = Settings.Default.ConvertSystemOutToConsole;
Options.UseFileScopedNamespaces = Settings.Default.UseFileScopedNamespaces;
Options.UseClosedForSealedClasses = Settings.Default.UseClosedForSealedClasses;

Options.SetUsings(Settings.Default.Usings.Split(';'));
}
Expand All @@ -29,6 +30,7 @@ public static void Persist()
Settings.Default.UseUnrecognizedCodeToComment = Options.UseUnrecognizedCodeToComment;
Settings.Default.ConvertSystemOutToConsole = Options.ConvertSystemOutToConsole;
Settings.Default.UseFileScopedNamespaces = Options.UseFileScopedNamespaces;
Settings.Default.UseClosedForSealedClasses = Options.UseClosedForSealedClasses;
Settings.Default.Usings = string.Join(";", Options.Usings);

Settings.Default.Save();
Expand Down
12 changes: 12 additions & 0 deletions JavaToCSharpGui/Properties/Settings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions JavaToCSharpGui/Properties/Settings.settings
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@
<Setting Name="UseFileScopedNamespaces" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
<Setting Name="UseClosedForSealedClasses" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
</Settings>
</SettingsFile>
Loading
Loading