diff --git a/JavaToCSharp.Tests/ConvertSealedTests.cs b/JavaToCSharp.Tests/ConvertSealedTests.cs new file mode 100644 index 00000000..071e16e3 --- /dev/null +++ b/JavaToCSharp.Tests/ConvertSealedTests.cs @@ -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(); + 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(); + 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(); + 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(); + 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(); + var parsed = Convert(javaCode, NewOptions(warnings)); + + Assert.DoesNotContain("// Java:", parsed); + Assert.DoesNotContain("closed", parsed); + Assert.Empty(warnings); + } + + private static JavaConversionOptions NewOptions(List? 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()) ?? ""; +} diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index 780df29d..f80d70d7 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -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 diff --git a/JavaToCSharp.Tests/Resources/Java17SealedClasses.java b/JavaToCSharp.Tests/Resources/Java17SealedClasses.java new file mode 100644 index 00000000..1c8a5a74 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java17SealedClasses.java @@ -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() { + } + } +} diff --git a/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs b/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs index fe18368d..0bf55952 100644 --- a/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs @@ -1,7 +1,9 @@ -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; @@ -9,6 +11,44 @@ namespace JavaToCSharp.Declarations; public class ClassOrInterfaceDeclarationVisitor : BodyDeclarationVisitor { + /// + /// Builds the // Java: sealed, permits ... comment describing a sealed hierarchy that C# cannot + /// fully express, or when the declaration is neither sealed nor non-sealed. + /// + private static string? GetSealedComment(ClassOrInterfaceDeclaration decl, ISet 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() ?? []; + + 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, @@ -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().line); + } + var extends = interfaceDecl.getExtendedTypes().ToList(); if (extends is not null) @@ -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, @@ -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().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().line); + } + var extends = classDecl.getExtendedTypes().ToList() ?? []; foreach (var extend in extends) @@ -223,6 +295,6 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con } } - return classSyntax.WithJavaComments(context, classDecl); + return WithSealedComment(classSyntax.WithJavaComments(context, classDecl), sealedComment); } } diff --git a/JavaToCSharp/JavaConversionOptions.cs b/JavaToCSharp/JavaConversionOptions.cs index 4d78abca..805cd1c6 100644 --- a/JavaToCSharp/JavaConversionOptions.cs +++ b/JavaToCSharp/JavaConversionOptions.cs @@ -33,6 +33,12 @@ public class JavaConversionOptions public bool UseFileScopedNamespaces { get; set; } + /// + /// Translates Java sealed classes using the C# 15 closed modifier, which requires .NET 11 or later. + /// When disabled, sealed classes are emitted without a modifier and a warning is raised instead. + /// + public bool UseClosedForSealedClasses { get; set; } = true; + public SyntaxMapping SyntaxMappings { get; set; } = new SyntaxMapping(); public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None) diff --git a/JavaToCSharpCli/Program.cs b/JavaToCSharpCli/Program.cs index bf02bec5..9d18b176 100644 --- a/JavaToCSharpCli/Program.cs +++ b/JavaToCSharpCli/Program.cs @@ -66,6 +66,12 @@ public class Program DefaultValueFactory = _ => false, }; + private static readonly Option _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 _clearDefaultUsingsOption = new("--clear-usings") { Description = "Remove all default usings provided by this app", @@ -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); @@ -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)) diff --git a/JavaToCSharpGui/App.config b/JavaToCSharpGui/App.config index 070571fe..e44869ed 100644 --- a/JavaToCSharpGui/App.config +++ b/JavaToCSharpGui/App.config @@ -31,6 +31,9 @@ False + + True + \ No newline at end of file diff --git a/JavaToCSharpGui/CurrentOptions.cs b/JavaToCSharpGui/CurrentOptions.cs index 4c421995..0a22eec5 100644 --- a/JavaToCSharpGui/CurrentOptions.cs +++ b/JavaToCSharpGui/CurrentOptions.cs @@ -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(';')); } @@ -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(); diff --git a/JavaToCSharpGui/Properties/Settings.Designer.cs b/JavaToCSharpGui/Properties/Settings.Designer.cs index 85bf611e..a7530ae9 100644 --- a/JavaToCSharpGui/Properties/Settings.Designer.cs +++ b/JavaToCSharpGui/Properties/Settings.Designer.cs @@ -118,5 +118,17 @@ public bool UseFileScopedNamespaces { this["UseFileScopedNamespaces"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool UseClosedForSealedClasses { + get { + return ((bool)(this["UseClosedForSealedClasses"])); + } + set { + this["UseClosedForSealedClasses"] = value; + } + } } } diff --git a/JavaToCSharpGui/Properties/Settings.settings b/JavaToCSharpGui/Properties/Settings.settings index 1f26626c..1b69f214 100644 --- a/JavaToCSharpGui/Properties/Settings.settings +++ b/JavaToCSharpGui/Properties/Settings.settings @@ -26,5 +26,8 @@ False + + True + diff --git a/JavaToCSharpGui/ViewModels/SettingsWindowViewModel.cs b/JavaToCSharpGui/ViewModels/SettingsWindowViewModel.cs index 5dd364fe..15d9af87 100644 --- a/JavaToCSharpGui/ViewModels/SettingsWindowViewModel.cs +++ b/JavaToCSharpGui/ViewModels/SettingsWindowViewModel.cs @@ -26,6 +26,8 @@ public partial class SettingsWindowViewModel : ViewModelBase [ObservableProperty] private bool _useFileScopedNamespaces = CurrentOptions.Options.UseFileScopedNamespaces; + [ObservableProperty] private bool _useClosedForSealedClasses = CurrentOptions.Options.UseClosedForSealedClasses; + public event EventHandler? CloseRequested; [RelayCommand] @@ -54,6 +56,7 @@ private void Save() CurrentOptions.Options.UseUnrecognizedCodeToComment = UnrecognizedCodeToComment; CurrentOptions.Options.ConvertSystemOutToConsole = ConvertSystemOutToConsole; CurrentOptions.Options.UseFileScopedNamespaces = UseFileScopedNamespaces; + CurrentOptions.Options.UseClosedForSealedClasses = UseClosedForSealedClasses; CurrentOptions.Options.SetUsings(Usings); diff --git a/JavaToCSharpGui/Views/SettingsWindow.axaml b/JavaToCSharpGui/Views/SettingsWindow.axaml index 1b53d4a6..0284fab9 100644 --- a/JavaToCSharpGui/Views/SettingsWindow.axaml +++ b/JavaToCSharpGui/Views/SettingsWindow.axaml @@ -42,6 +42,10 @@ IsChecked="{CompiledBinding UseFileScopedNamespaces}"> Use file-scoped namespaces + + Use C# 15 `closed` for Java sealed classes + Include comments in output