From f55ad98205efb13da1ef6fe4ba3283b89f173482 Mon Sep 17 00:00:00 2001 From: Konrad `ktoso` Malawski Date: Mon, 13 Jul 2026 12:34:47 +0900 Subject: [PATCH 1/2] wrap-java: handle sealed/permits in interfaces and classes When we know the exact set of permitted subclasses we should include them in the generated macro such that we can then from the macro generate safe `as...()` methods. These are useful since it is common to use subclassing with sealed hierarchies like that. The normal `as` still exists. --- Sources/SwiftJava/JavaClassModifier.swift | 37 ++++++ Sources/SwiftJava/Macros.swift | 95 ++++++++++++++- .../Documentation.docc/SupportedFeatures.md | 2 +- Sources/SwiftJavaMacros/JavaClassMacro.swift | 15 ++- .../JavaClassTranslator.swift | 39 ++++++- .../JavaClassMacroTests.swift | 60 ++++++++++ .../CompileJavaWrapTools.swift | 10 +- .../Java2SwiftTests.swift | 2 +- .../RecordSealedWrapJavaTests.swift | 108 ++++++++++++++++++ 9 files changed, 346 insertions(+), 22 deletions(-) create mode 100644 Sources/SwiftJava/JavaClassModifier.swift create mode 100644 Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift diff --git a/Sources/SwiftJava/JavaClassModifier.swift b/Sources/SwiftJava/JavaClassModifier.swift new file mode 100644 index 000000000..a4a4690fb --- /dev/null +++ b/Sources/SwiftJava/JavaClassModifier.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Java class-level modifiers surfaced as an option set for the ``JavaClass``, +/// ``JavaRecord``, and ``JavaInterface`` attached macros. +/// +/// ```swift +/// @JavaClass(.sealed, "com.example.Shape", permits: Circle.self, Square.self) +/// @JavaClass(.final, "com.example.Leaf") +/// ``` +public struct JavaClassModifier: OptionSet, Sendable, Hashable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Java `sealed` type. Has a specific set of subclasses in Java. + public static let sealed = JavaClassModifier(rawValue: 1 << 0) + + /// Java `final` type. Cannot be extended in Java. + public static let `final` = JavaClassModifier(rawValue: 1 << 1) + + /// Java `abstract` type. Cannot be instantiated directly. + public static let abstract = JavaClassModifier(rawValue: 1 << 2) +} diff --git a/Sources/SwiftJava/Macros.swift b/Sources/SwiftJava/Macros.swift index 20a911233..2d4a0ca4a 100644 --- a/Sources/SwiftJava/Macros.swift +++ b/Sources/SwiftJava/Macros.swift @@ -12,6 +12,9 @@ // //===----------------------------------------------------------------------===// +// ==== ----------------------------------------------------------------------- +// MARK: @JavaClass + /// Attached macro that declares that a particular `struct` type is a wrapper around a Java class. /// /// Use this macro to describe a type that was implemented as a class in Java. The @@ -42,9 +45,39 @@ public macro JavaClass( _ fullClassName: String, extends: (any AnyJavaObject.Type)? = nil, - implements: (any AnyJavaObject.Type)?... + implements: (any AnyJavaObject.Type)?..., + permits: (any AnyJavaObject.Type)?... +) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") + +/// Variant of ``JavaClass(_:extends:implements:permits:)`` that carries Java +/// class modifiers (e.g. `.sealed`, `.final`) as an option-set first positional +/// argument. +/// +/// ```swift +/// @JavaClass(.sealed, "com.example.Shape", permits: Circle.self, Square.self) +/// @JavaClass(.final, "com.example.Leaf") +/// @JavaClass([.sealed, .abstract], "com.example.Base", permits: Sub.self) +/// ``` +@attached( + member, + names: named(fullJavaClassName), + named(javaHolder), + named(init(javaHolder:)), + named(JavaSuperclass), + named(`as`) +) +@attached(extension, conformances: AnyJavaObject) +public macro JavaClass( + _ modifiers: JavaClassModifier, + _ fullClassName: String, + extends: (any AnyJavaObject.Type)? = nil, + implements: (any AnyJavaObject.Type)?..., + permits: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") +// ==== ----------------------------------------------------------------------- +// MARK: @JavaRecord + /// Attached macro that declares that a particular Swift type is a wrapper around a Java `record`. /// /// This behaves identically to ``JavaClass`` today (records are wrapped as @@ -82,9 +115,31 @@ public macro JavaClass( public macro JavaRecord( _ fullClassName: String, extends: (any AnyJavaObject.Type)? = nil, - implements: (any AnyJavaObject.Type)?... + implements: (any AnyJavaObject.Type)?..., + permits: (any AnyJavaObject.Type)?... +) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") + +/// Modifier-carrying variant of ``JavaRecord(_:extends:implements:permits:)``. +@attached( + member, + names: named(fullJavaClassName), + named(javaHolder), + named(init(javaHolder:)), + named(JavaSuperclass), + named(`as`) +) +@attached(extension, conformances: AnyJavaObject) +public macro JavaRecord( + _ modifiers: JavaClassModifier, + _ fullClassName: String, + extends: (any AnyJavaObject.Type)? = nil, + implements: (any AnyJavaObject.Type)?..., + permits: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") +// ==== ----------------------------------------------------------------------- +// MARK: @JavaInterface + /// Attached macro that declares that a particular `struct` type is a wrapper around a Java interface. /// /// Use this macro to describe a type that was implemented as an @@ -113,8 +168,31 @@ public macro JavaRecord( named(`as`) ) @attached(extension, conformances: AnyJavaObject) -public macro JavaInterface(_ fullClassName: String, extends: (any AnyJavaObject.Type)?...) = - #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") +public macro JavaInterface( + _ fullClassName: String, + extends: (any AnyJavaObject.Type)?..., + permits: (any AnyJavaObject.Type)?... +) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") + +/// Modifier-carrying variant of ``JavaInterface(_:extends:permits:)``. +@attached( + member, + names: named(fullJavaClassName), + named(javaHolder), + named(init(javaHolder:)), + named(JavaSuperclass), + named(`as`) +) +@attached(extension, conformances: AnyJavaObject) +public macro JavaInterface( + _ modifiers: JavaClassModifier, + _ fullClassName: String, + extends: (any AnyJavaObject.Type)?..., + permits: (any AnyJavaObject.Type)?... +) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") + +// ==== ----------------------------------------------------------------------- +// MARK: @JavaField / @JavaStaticField /// Attached macro that turns a Swift property into one that accesses a Java field on the underlying Java object. /// @@ -145,6 +223,9 @@ public macro JavaField(_ javaFieldName: String? = nil, isFinal: Bool = false) = public macro JavaStaticField(_ javaFieldName: String? = nil, isFinal: Bool = false) = #externalMacro(module: "SwiftJavaMacros", type: "JavaFieldMacro") +// ==== ----------------------------------------------------------------------- +// MARK: @JavaMethod / @JavaStaticMethod + /// Attached macro that turns a Swift method into one that wraps a Java method on the underlying Java object. /// /// The macro must be used in an AnyJavaObject-conforming type. @@ -207,6 +288,9 @@ public macro JavaMethod( public macro JavaStaticMethod(_ javaMethodName: String? = nil) = #externalMacro(module: "SwiftJavaMacros", type: "JavaMethodMacro") +// ==== ----------------------------------------------------------------------- +// MARK: @JavaImplementation + /// Macro that marks extensions to specify that all of the @JavaMethod /// methods are implementations of Java methods spelled as `native`. /// @@ -233,6 +317,9 @@ public macro JavaStaticMethod(_ javaMethodName: String? = nil) = public macro JavaImplementation(_ fullClassName: String) = #externalMacro(module: "SwiftJavaMacros", type: "JavaImplementationMacro") +// ==== ----------------------------------------------------------------------- +// MARK: @JavaExport + /// Marker macro that forces a Swift declaration to be exported to Java via jextract. /// /// When applied to a `typealias`, it registers a specialization entry for generic types: diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index f30149069..9d3702d99 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -34,7 +34,7 @@ Java `native` functions. SwiftJava simplifies the type conversions | Java `abstract class` | TODO | | Java `enum` | ❌ | | Java `record` (Java 16+) | ✅ `@JavaRecord` | -| Java `sealed class` / `sealed interface` (Java 17+) | 🟡 recognized, but missing special handling of `permits` list | +| Java `sealed class` / `sealed interface` (Java 17+) | ✅ `.sealed` modifier + `permits:` list | | **This list is very work in progress** | | ### JExtract – calling Swift from Java diff --git a/Sources/SwiftJavaMacros/JavaClassMacro.swift b/Sources/SwiftJavaMacros/JavaClassMacro.swift index 6797f61de..576656b87 100644 --- a/Sources/SwiftJavaMacros/JavaClassMacro.swift +++ b/Sources/SwiftJavaMacros/JavaClassMacro.swift @@ -31,9 +31,14 @@ extension JavaClassMacro: MemberMacro { } let swiftName = namedDecl.name.text - // Dig out the Java class name. - guard case .argumentList(let arguments) = node.arguments, - let wrapperTypeNameExpr = arguments.first?.expression, + // Dig out the Java class name. The class name is the first unlabeled + // string-literal positional argument; a leading modifier option-set + // (e.g. `@JavaClass(.sealed, "com.example.Foo", ...)`) can precede it. + guard case .argumentList(let arguments) = node.arguments else { + throw MacroErrors.classNameNotStringLiteral + } + let unlabeledPositional = arguments.filter { $0.label == nil } + guard let wrapperTypeNameExpr = unlabeledPositional.first(where: { $0.expression.is(StringLiteralExprSyntax.self) })?.expression, let stringLiteral = wrapperTypeNameExpr.as(StringLiteralExprSyntax.self), stringLiteral.segments.count == 1, case let .stringSegment(classNameSegment)? = stringLiteral.segments.first @@ -57,9 +62,7 @@ extension JavaClassMacro: MemberMacro { isJavaLangObject = false // Dig out the "extends" argument from the attribute. - if let superclassArg = arguments.dropFirst().first, - let superclassArgLabel = superclassArg.label, - superclassArgLabel.text == "extends", + if let superclassArg = arguments.first(where: { $0.label?.text == "extends" }), let superclassMemberAccess = superclassArg.expression.as(MemberAccessExprSyntax.self), superclassMemberAccess.declName.trimmedDescription == "self", let superclassMemberBase = superclassMemberAccess.base diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index bbb796da7..29705f5ba 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -368,7 +368,6 @@ extension JavaClassTranslator { } } -/// MARK: Rendering of Java class members as Swift declarations. extension JavaClassTranslator { /// Render the Swift declarations that will express this Java class in Swift. package func render() -> [DeclSyntax] { @@ -393,13 +392,24 @@ extension JavaClassTranslator { } if javaClass.isSealed() { let kind = javaClass.isInterface() ? "sealed interface" : "sealed class" - let permits = javaClass.getPermittedSubclasses().compactMap { $0?.getName() } + let permits = javaClass.getPermittedSubclasses().compactMap { $0 } if permits.isEmpty { lines.append("/// Java `\(kind)`") } else { - lines.append("/// Java `\(kind)`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", "))") + let rendered: [String] = permits.map { permitted in + let javaName = permitted.getName() + if let swiftName = translator.translatedClasses[javaName]?.swiftType { + return "``\(swiftName)`` (`\(javaName)`)" + } else { + return "`\(javaName)`" + } + } + lines.append("/// Java `\(kind)`, permits: \(rendered.joined(separator: ", "))") } - log.info("Wrap Java \(kind): `\(javaClass.getName())`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", ")))") + let logPermitNames = permits.map { $0.getName() } + log.info( + "Wrap Java \(kind): `\(javaClass.getName())`, permits: \(logPermitNames.map({ "`\($0)`" }).joined(separator: ", ")))" + ) } if lines.isEmpty { return "" @@ -492,6 +502,25 @@ extension JavaClassTranslator { interfacesStr = ", \(prefix): \(swiftInterfaces.map { "\($0).self" }.joined(separator: ", "))" } + // Compute `sealed` and `permits` metadata for Java `sealed` types. + let sealedModifierPrefix: String + let permitsArgs: String + if javaClass.isSealed() { + sealedModifierPrefix = ".sealed, " + let permittedTypes = javaClass.getPermittedSubclasses().compactMap { $0 } + let knownSwiftNames: [String] = permittedTypes.compactMap { permitted in + translator.translatedClasses[permitted.getName()]?.swiftType + } + if knownSwiftNames.isEmpty { + permitsArgs = "" + } else { + permitsArgs = ", permits: \(knownSwiftNames.map { "\($0).self" }.joined(separator: ", "))" + } + } else { + sealedModifierPrefix = "" + permitsArgs = "" + } + let genericParameterClause = if swiftGenericParameterNames.isEmpty { "" @@ -519,7 +548,7 @@ extension JavaClassTranslator { let javaKindDocs = renderJavaKindDocc() var classDecl: DeclSyntax = """ - \(raw: javaKindDocs)\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)) + \(raw: javaKindDocs)\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(raw: sealedModifierPrefix)\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)\(raw: permitsArgs)) \(raw: introducer) \(raw: swiftInnermostTypeName)\(raw: genericParameterClause)\(raw: inheritanceClause) { \(raw: members.map { $0.description }.joined(separator: "\n\n")) } diff --git a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift index dad2b2db6..352e1db51 100644 --- a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift +++ b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift @@ -13,8 +13,10 @@ //===----------------------------------------------------------------------===// import SwiftJavaMacros +import SwiftParser import SwiftSyntax import SwiftSyntaxBuilder +import SwiftSyntaxMacroExpansion import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest @@ -527,6 +529,20 @@ class JavaKitMacroTests: XCTestCase { ) } + func testJavaClassSealedWithoutPermits() throws { + assertMacroExpansion( + """ + @JavaClass(.sealed, "com.example.Foo") + open class Foo: JavaObject { + } + """, + expectedChunks: [], + notExpectedChunks: [ + "public func as" + ] + ) + } + func testJavaGenericClassGenericMethodParameter() throws { assertMacroExpansion( """ @@ -577,3 +593,47 @@ class JavaKitMacroTests: XCTestCase { ) } } + +private func assertMacroExpansion( + _ source: String, + expectedChunks: [String], + notExpectedChunks: [String] = [], + file: StaticString = #fileID, + line: UInt = #line +) { + let expanded = expandMacros(in: source) + let expandedCollapsed = expanded.replacing(" ", with: "") + for chunk in expectedChunks { + let chunkCollapsed = chunk.replacing(" ", with: "") + XCTAssertTrue( + expandedCollapsed.contains(chunkCollapsed), + "Expected chunk:\n\(chunk)\n\nnot found in expanded source:\n\(expanded)", + file: file, + line: line + ) + } + for chunk in notExpectedChunks { + let chunkCollapsed = chunk.replacing(" ", with: "") + XCTAssertFalse( + expandedCollapsed.contains(chunkCollapsed), + "Unexpected chunk:\n\(chunk)\n\nfound in expanded source:\n\(expanded)", + file: file, + line: line + ) + } +} + +private func expandMacros(in source: String) -> String { + let origSourceFile = Parser.parse(source: source) + let context = BasicMacroExpansionContext( + sourceFiles: [origSourceFile: .init(moduleName: "TestModule", fullFilePath: "test.swift")] + ) + let expanded = origSourceFile.expand( + macros: JavaKitMacroTests.javaKitMacros, + contextGenerator: { syntax in + BasicMacroExpansionContext(sharingWith: context, lexicalContext: syntax.allMacroLexicalContexts()) + }, + indentationWidth: .spaces(2) + ) + return expanded.description +} diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift index 1fb48fc42..a0762b583 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift @@ -124,17 +124,17 @@ func assertWrapJavaOutput( } javaClasses.append(javaClass) - // FIXME: deduplicate this with SwiftJava.WrapJavaCommand.runCommand !!! - // TODO: especially because nested classes - // WrapJavaCommand(). - let swiftUnqualifiedName = classNameMappings[javaClassName] ?? javaClassName.javaClassNameToCanonicalName.defaultSwiftNameForJavaClass translator.translatedClasses[javaClassName] = .init(module: nil, name: swiftUnqualifiedName) + } + + try translator.validateClassConfiguration() - try translator.validateClassConfiguration() + for javaClass in javaClasses { + // FIXME: deduplicate this with SwiftJava.WrapJavaCommand.runCommand let swiftClassDecls = try translator.translateClass(javaClass) let importDecls = translator.getImportDecls() diff --git a/Tests/SwiftJavaToolLibTests/Java2SwiftTests.swift b/Tests/SwiftJavaToolLibTests/Java2SwiftTests.swift index 374d2b332..91c682fe5 100644 --- a/Tests/SwiftJavaToolLibTests/Java2SwiftTests.swift +++ b/Tests/SwiftJavaToolLibTests/Java2SwiftTests.swift @@ -654,7 +654,7 @@ class Java2SwiftTests: XCTestCase { expectedChunks: [ "import JavaNio", """ - @JavaClass("java.nio.ByteBuffer") + @JavaClass(.sealed, "java.nio.ByteBuffer") open class NIOByteBuffer: NIOBuffer { """, """ diff --git a/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift b/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift new file mode 100644 index 000000000..7251d49fb --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import JavaNet +import JavaUtilJar +import Subprocess +@_spi(Testing) import SwiftJava +import SwiftJavaConfigurationShared +import SwiftJavaShared +import SwiftJavaToolLib +import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 + +final class RecordSealedWrapJavaTests: XCTestCase { + + func test_wrapJava_javaRecord_isMarkedInDocs() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public record Point(int x, int y) {} + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.Point" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + /// Java record: `com.example.Point` + @JavaRecord("com.example.Point") + open class Point: JavaObject { + @JavaMethod + @_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil) + """ + ] + ) + } + + func test_wrapJava_javaSealed_isMarkedInDocs_withPermits() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public sealed class Shape permits Circle, Square {} + final class Circle extends Shape {} + final class Square extends Shape {} + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.Shape", + "com.example.Circle", + "com.example.Square", + ], + classpath: [classpathURL], + expectedChunks: [ + """ + /// Java `sealed class`, permits: ``Circle`` (`com.example.Circle`), ``Square`` (`com.example.Square`) + @JavaClass(.sealed, "com.example.Shape", permits: Circle.self, Square.self) + open class Shape: + """ + ] + ) + } + + func test_wrapJava_javaSealedInterface_isMarkedInDocs_withPermits() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public sealed interface Op permits Add, Mul {} + final class Add implements Op {} + final class Mul implements Op {} + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.Op", + "com.example.Add", + "com.example.Mul", + ], + classpath: [classpathURL], + expectedChunks: [ + """ + /// Java `sealed interface`, permits: ``Add`` (`com.example.Add`), ``Mul`` (`com.example.Mul`) + @JavaInterface(.sealed, "com.example.Op", permits: Add.self, Mul.self) + public struct Op { + """ + ] + ) + } +} From d13e42d96684c2c8e61cf0203f0296ea1dbedf1a Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Mon, 13 Jul 2026 16:22:15 +0900 Subject: [PATCH 2/2] regenerate wrapped jdk types using .sealed support --- .../JavaKitExample/com/example/swift/Add.java | 23 +++ .../JavaKitExample/com/example/swift/Mul.java | 23 +++ .../JavaKitExample/com/example/swift/Op.java | 36 ++++ .../Sources/JavaKitExample/swift-java.config | 5 +- .../JavaSealedInterfaceRuntimeTests.swift | 144 ++++++++++++++ .../generated/Executable.swift | 5 +- Sources/SwiftJava/JavaClassModifier.swift | 8 +- Sources/SwiftJava/Macros.swift | 34 ++-- Sources/SwiftJava/generated/ByteBuffer.swift | 5 +- Sources/SwiftJava/generated/JavaThread.swift | 45 ++++- .../Documentation.docc/SupportedFeatures.md | 2 +- Sources/SwiftJavaMacros/JavaClassMacro.swift | 26 ++- .../JavaClassTranslator.swift | 175 +++++++++++++++--- .../JavaClassMacroTests.swift | 29 +++ .../RecordSealedWrapJavaTests.swift | 108 ++++++++++- 15 files changed, 601 insertions(+), 67 deletions(-) create mode 100644 Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Add.java create mode 100644 Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Mul.java create mode 100644 Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Op.java create mode 100644 Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaSealedInterfaceRuntimeTests.swift diff --git a/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Add.java b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Add.java new file mode 100644 index 000000000..703f06803 --- /dev/null +++ b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Add.java @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +/** Permitted subclass of {@link Op}; carries an add-flavored value. */ +public final class Add implements Op { + private final int value; + public Add(int value) { this.value = value; } + @Override public int eval() { return value; } + @Override public Op combine(Op other) { return new Add(value + other.eval()); } +} diff --git a/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Mul.java b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Mul.java new file mode 100644 index 000000000..30036f103 --- /dev/null +++ b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Mul.java @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +/** Permitted subclass of {@link Op}; carries a mul-flavored value. */ +public final class Mul implements Op { + private final int value; + public Mul(int value) { this.value = value; } + @Override public int eval() { return value; } + @Override public Op combine(Op other) { return new Mul(value * other.eval()); } +} diff --git a/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Op.java b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Op.java new file mode 100644 index 000000000..98ce6e8dc --- /dev/null +++ b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Op.java @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.swift; + +/** + * Java sealed interface used to smoke-test wrap-java's enum-based + * translation of {@code sealed interface}. Verifies that: + *
    + *
  1. the parent interface is wrapped as a Swift {@code enum} with + * a case per permitted subclass,
  2. + *
  3. methods declared on the parent interface remain callable on + * the enum itself (JNI virtual-dispatches to the underlying + * concrete Java class),
  4. + *
  5. returning the parent interface type from Java produces a + * Swift enum value whose case matches the actual runtime class.
  6. + *
+ */ +public sealed interface Op permits Add, Mul { + int eval(); + Op combine(Op other); + + static Op makeAdd(int x) { return new Add(x); } + static Op makeMul(int x) { return new Mul(x); } +} diff --git a/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config b/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config index d9e8e090d..d1a1ce85d 100644 --- a/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config +++ b/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config @@ -5,6 +5,9 @@ "com.example.swift.HelloJavaKitArrays" : "HelloJavaKitArrays", "com.example.swift.JavaKitSampleMain" : "JavaKitSampleMain", "com.example.swift.Point" : "Point", - "com.example.swift.ThreadSafeHelperClass" : "ThreadSafeHelperClass" + "com.example.swift.ThreadSafeHelperClass" : "ThreadSafeHelperClass", + "com.example.swift.Op" : "Op", + "com.example.swift.Add" : "Add", + "com.example.swift.Mul" : "Mul" } } diff --git a/Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaSealedInterfaceRuntimeTests.swift b/Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaSealedInterfaceRuntimeTests.swift new file mode 100644 index 000000000..77aad42c2 --- /dev/null +++ b/Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaSealedInterfaceRuntimeTests.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import JavaKitExample +import SwiftJava +import Testing + +/// Runtime tests for wrap-java's enum-based translation of Java +/// `sealed interface`. These exercise the code paths that only run +/// when a real JVM is present: JNI method lookup on the underlying +/// concrete class, and the enum's `init(javaHolder:)` dispatch that +/// wraps a returned parent-typed object back into the correct case. +@Suite +struct JavaSealedInterfaceRuntimeTests { + + let jvm = try JavaKitSampleJVM.shared + + /// Constructing an `Add` and wrapping it into the sealed enum via + /// `init(javaHolder:)` picks the `.add` case (not `.unknown`). + @Test + func initJavaHolderDispatchesToAddCase() throws { + let env = try jvm.environment() + + let add = Add(3, environment: env) + let op = Op(javaHolder: add.javaHolder) + + switch op { + case .add: break // expected + case .mul(let v): Issue.record("expected .add, got .mul(\(v.eval()))") + case .unknown(let v): + Issue.record("expected .add, got .unknown(\(v.javaClass.getName()))") + } + + // The enum's own `eval()` method (declared on the sealed interface) + // must virtual-dispatch to Add.eval via JNI. + #expect(op.eval() == 3) + } + + /// Same as above but for `Mul`; guards against a broken always-first-case + /// dispatch in the enum's `init(javaHolder:)`. + @Test + func initJavaHolderDispatchesToMulCase() throws { + let env = try jvm.environment() + + let mul = Mul(7, environment: env) + let op = Op(javaHolder: mul.javaHolder) + + switch op { + case .mul: break // expected + case .add(let v): Issue.record("expected .mul, got .add(\(v.eval()))") + case .unknown(let v): + Issue.record("expected .mul, got .unknown(\(v.javaClass.getName()))") + } + + #expect(op.eval() == 7) + } + + /// The static Java factories return the sealed interface type `Op`; + /// wrap-java exposes them on `JavaClass`. The returned Swift value + /// is an `Op!` enum whose case reflects the actual runtime class. + @Test + func staticFactoryReturnsCorrectEnumCase() throws { + let env = try jvm.environment() + + let opClass = try JavaClass(environment: env) + + guard let add = opClass.makeAdd(10) else { + Issue.record("makeAdd returned nil") + return + } + switch add { + case .add: break + default: Issue.record("expected .add from makeAdd, got \(add)") + } + #expect(add.eval() == 10) + + guard let mul = opClass.makeMul(5) else { + Issue.record("makeMul returned nil") + return + } + switch mul { + case .mul: break + default: Issue.record("expected .mul from makeMul, got \(mul)") + } + #expect(mul.eval() == 5) + } + + /// `combine(Op) -> Op` is declared on the sealed interface, so wrap-java + /// emits it directly on the enum. Verifies that: + /// 1. calling the method on the enum reaches the concrete Java subclass + /// (Add.combine adds, Mul.combine multiplies), + /// 2. the returned enum value's case matches the concrete Java subclass + /// that Java produced (Add returns an Add, Mul returns a Mul). + @Test + func combineOnEnumDispatchesToConcreteSubclass() throws { + let env = try jvm.environment() + + let opClass = try JavaClass(environment: env) + let three = opClass.makeAdd(3)! + let two = opClass.makeMul(2)! + + // 3 + 2 = 5, and the result should be an Add (Add.combine returns Add) + let sum = three.combine(two)! + #expect(sum.eval() == 5) + switch sum { + case .add: break + default: Issue.record("expected .add from Add.combine, got \(sum)") + } + + // 2 * 3 = 6, and the result should be a Mul (Mul.combine returns Mul) + let product = two.combine(three)! + #expect(product.eval() == 6) + switch product { + case .mul: break + default: Issue.record("expected .mul from Mul.combine, got \(product)") + } + } + + /// The macro-generated `@JavaMethod` body on the enum uses + /// `self.javaThis` from `self.javaHolder`, then `GetObjectClass` to + /// resolve the method — so `eval()` on `.add(v)` must return the + /// same value as `v.eval()` called on the concrete wrapper directly. + @Test + func methodOnEnumMatchesMethodOnConcreteSubclass() throws { + let env = try jvm.environment() + + let add = Add(42, environment: env) + let asOp: Op = .add(add) + + #expect(asOp.eval() == add.eval()) + #expect(asOp.eval() == 42) + } +} diff --git a/Sources/JavaStdlib/JavaLangReflect/generated/Executable.swift b/Sources/JavaStdlib/JavaLangReflect/generated/Executable.swift index 00e2170d4..0cf17940d 100644 --- a/Sources/JavaStdlib/JavaLangReflect/generated/Executable.swift +++ b/Sources/JavaStdlib/JavaLangReflect/generated/Executable.swift @@ -2,7 +2,10 @@ import SwiftJava import SwiftJavaJNICore -@JavaClass("java.lang.reflect.Executable", implements: GenericDeclaration.self) +/// Java `sealed class`, permits: +/// - ``Constructor`` (`java.lang.reflect.Constructor`) +/// - ``Method`` (`java.lang.reflect.Method`) +@JavaClass(.sealed, "java.lang.reflect.Executable", implements: GenericDeclaration.self) open class Executable: AccessibleObject { /// Java method `getAnnotatedExceptionTypes`. /// diff --git a/Sources/SwiftJava/JavaClassModifier.swift b/Sources/SwiftJava/JavaClassModifier.swift index a4a4690fb..44e7195fd 100644 --- a/Sources/SwiftJava/JavaClassModifier.swift +++ b/Sources/SwiftJava/JavaClassModifier.swift @@ -16,9 +16,15 @@ /// ``JavaRecord``, and ``JavaInterface`` attached macros. /// /// ```swift -/// @JavaClass(.sealed, "com.example.Shape", permits: Circle.self, Square.self) +/// @JavaClass(.sealed, "com.example.Shape") /// @JavaClass(.final, "com.example.Leaf") /// ``` +/// +/// Java sealed types don't carry an explicit permitted-subclass list in Swift: +/// the generator models the hierarchy directly in the type system. Sealed +/// interfaces become Swift enums with one case per permitted subclass; sealed +/// classes remain Swift classes and the permitted subclasses are just the +/// declared subclasses reachable via the enclosing type. public struct JavaClassModifier: OptionSet, Sendable, Hashable { public let rawValue: Int diff --git a/Sources/SwiftJava/Macros.swift b/Sources/SwiftJava/Macros.swift index 2d4a0ca4a..f5db573a1 100644 --- a/Sources/SwiftJava/Macros.swift +++ b/Sources/SwiftJava/Macros.swift @@ -45,19 +45,24 @@ public macro JavaClass( _ fullClassName: String, extends: (any AnyJavaObject.Type)? = nil, - implements: (any AnyJavaObject.Type)?..., - permits: (any AnyJavaObject.Type)?... + implements: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") -/// Variant of ``JavaClass(_:extends:implements:permits:)`` that carries Java +/// Variant of ``JavaClass(_:extends:implements:)`` that carries Java /// class modifiers (e.g. `.sealed`, `.final`) as an option-set first positional /// argument. /// /// ```swift -/// @JavaClass(.sealed, "com.example.Shape", permits: Circle.self, Square.self) +/// @JavaClass(.sealed, "com.example.Shape") /// @JavaClass(.final, "com.example.Leaf") -/// @JavaClass([.sealed, .abstract], "com.example.Base", permits: Sub.self) +/// @JavaClass([.sealed, .abstract], "com.example.Base") /// ``` +/// +/// Note: Java `sealed` types don't carry an explicit permitted-subclass list +/// in Swift because the generator models the hierarchy directly in the type +/// system (permitted subclasses become nested types, or enum cases when the +/// sealed type is an interface). The `.sealed` marker is preserved for +/// documentation and reflection-style tooling. @attached( member, names: named(fullJavaClassName), @@ -71,8 +76,7 @@ public macro JavaClass( _ modifiers: JavaClassModifier, _ fullClassName: String, extends: (any AnyJavaObject.Type)? = nil, - implements: (any AnyJavaObject.Type)?..., - permits: (any AnyJavaObject.Type)?... + implements: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") // ==== ----------------------------------------------------------------------- @@ -115,11 +119,10 @@ public macro JavaClass( public macro JavaRecord( _ fullClassName: String, extends: (any AnyJavaObject.Type)? = nil, - implements: (any AnyJavaObject.Type)?..., - permits: (any AnyJavaObject.Type)?... + implements: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") -/// Modifier-carrying variant of ``JavaRecord(_:extends:implements:permits:)``. +/// Modifier-carrying variant of ``JavaRecord(_:extends:implements:)``. @attached( member, names: named(fullJavaClassName), @@ -133,8 +136,7 @@ public macro JavaRecord( _ modifiers: JavaClassModifier, _ fullClassName: String, extends: (any AnyJavaObject.Type)? = nil, - implements: (any AnyJavaObject.Type)?..., - permits: (any AnyJavaObject.Type)?... + implements: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") // ==== ----------------------------------------------------------------------- @@ -170,11 +172,10 @@ public macro JavaRecord( @attached(extension, conformances: AnyJavaObject) public macro JavaInterface( _ fullClassName: String, - extends: (any AnyJavaObject.Type)?..., - permits: (any AnyJavaObject.Type)?... + extends: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") -/// Modifier-carrying variant of ``JavaInterface(_:extends:permits:)``. +/// Modifier-carrying variant of ``JavaInterface(_:extends:)``. @attached( member, names: named(fullJavaClassName), @@ -187,8 +188,7 @@ public macro JavaInterface( public macro JavaInterface( _ modifiers: JavaClassModifier, _ fullClassName: String, - extends: (any AnyJavaObject.Type)?..., - permits: (any AnyJavaObject.Type)?... + extends: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") // ==== ----------------------------------------------------------------------- diff --git a/Sources/SwiftJava/generated/ByteBuffer.swift b/Sources/SwiftJava/generated/ByteBuffer.swift index 4b872aa47..030223237 100644 --- a/Sources/SwiftJava/generated/ByteBuffer.swift +++ b/Sources/SwiftJava/generated/ByteBuffer.swift @@ -38,7 +38,10 @@ extension JavaClass { @JavaStaticMethod public func wrap(_ arg0: [Int8], _ arg1: Int32, _ arg2: Int32) -> ByteBuffer! } -@JavaClass("java.nio.ByteBuffer") +/// Java `sealed class`, permits: +/// - `java.nio.HeapByteBuffer` +/// - `java.nio.MappedByteBuffer` +@JavaClass(.sealed, "java.nio.ByteBuffer") open class ByteBuffer: JavaObject { /// Java method `alignedSlice`. /// diff --git a/Sources/SwiftJava/generated/JavaThread.swift b/Sources/SwiftJava/generated/JavaThread.swift index 6d4319c79..1fcf0d9ca 100644 --- a/Sources/SwiftJava/generated/JavaThread.swift +++ b/Sources/SwiftJava/generated/JavaThread.swift @@ -383,8 +383,41 @@ open class JavaThread: JavaObject { open override func toString() -> String } extension JavaThread { - @JavaInterface("java.lang.Thread$Builder") - public struct Builder { + /// Java `sealed interface`, permits: + /// - ``JavaThread.Builder.OfPlatform`` (`java.lang.Thread$Builder$OfPlatform`) + /// - ``JavaThread.Builder.OfVirtual`` (`java.lang.Thread$Builder$OfVirtual`) + @JavaInterface(.sealed, "java.lang.Thread$Builder") + public enum Builder { + case ofPlatform(JavaThread.Builder.OfPlatform) + + case ofVirtual(JavaThread.Builder.OfVirtual) + + case unknown(JavaObject) + + public var javaHolder: JavaObjectHolder { + switch self { + case .ofPlatform(let v): + return v.javaHolder + case .ofVirtual(let v): + return v.javaHolder + case .unknown(let v): + return v.javaHolder + } + } + + public init(javaHolder: JavaObjectHolder) { + let raw = JavaObject(javaHolder: javaHolder) + if let v = raw.as(JavaThread.Builder.OfPlatform.self) { + self = .ofPlatform(v) + return + } + if let v = raw.as(JavaThread.Builder.OfVirtual.self) { + self = .ofVirtual(v) + return + } + self = .unknown(raw) + } + /// Java method `inheritInheritableThreadLocals`. /// /// ### Java method signature @@ -423,7 +456,9 @@ extension JavaThread { } } extension JavaThread.Builder { - @JavaInterface("java.lang.Thread$Builder$OfPlatform", extends: JavaThread.Builder.self) + /// Java `sealed interface`, permits: + /// - `java.lang.ThreadBuilders$PlatformThreadBuilder` + @JavaInterface(.sealed, "java.lang.Thread$Builder$OfPlatform", extends: JavaThread.Builder.self) public struct OfPlatform { /// Java method `daemon`. /// @@ -499,7 +534,9 @@ extension JavaThread.Builder { } } extension JavaThread.Builder { - @JavaInterface("java.lang.Thread$Builder$OfVirtual", extends: JavaThread.Builder.self) + /// Java `sealed interface`, permits: + /// - `java.lang.ThreadBuilders$VirtualThreadBuilder` + @JavaInterface(.sealed, "java.lang.Thread$Builder$OfVirtual", extends: JavaThread.Builder.self) public struct OfVirtual { /// Java method `inheritInheritableThreadLocals`. /// diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index 9d3702d99..b951d96bd 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -34,7 +34,7 @@ Java `native` functions. SwiftJava simplifies the type conversions | Java `abstract class` | TODO | | Java `enum` | ❌ | | Java `record` (Java 16+) | ✅ `@JavaRecord` | -| Java `sealed class` / `sealed interface` (Java 17+) | ✅ `.sealed` modifier + `permits:` list | +| Java `sealed class` / `sealed interface` (Java 17+) | ✅ `.sealed` modifier; sealed interfaces are wrapped as Swift enums with a case per permitted subclass plus an `unknown` catch-all | | **This list is very work in progress** | | ### JExtract – calling Swift from Java diff --git a/Sources/SwiftJavaMacros/JavaClassMacro.swift b/Sources/SwiftJavaMacros/JavaClassMacro.swift index 576656b87..71a336d78 100644 --- a/Sources/SwiftJavaMacros/JavaClassMacro.swift +++ b/Sources/SwiftJavaMacros/JavaClassMacro.swift @@ -47,18 +47,33 @@ extension JavaClassMacro: MemberMacro { } // Determine whether we're exposing the Java class as a Swift class, which - // changes how we generate some of the members. + // changes how we generate some of the members. Swift enums are also + // supported for Java `sealed interface` types (see JavaClassTranslator): + // the enum's case list carries the permitted-subclass information, so + // the parent's macro attribute no longer needs `permits:`, breaking the + // mutual `@JavaInterface` macro-expansion cycle. The macro emits only + // `fullJavaClassName` for enums; the generator supplies `javaHolder`, + // `init(javaHolder:)` and per-subclass dispatch, since it (unlike the + // macro) knows the permitted-subclass list. let isSwiftClass: Bool + let isSwiftEnum: Bool let isJavaLangObject: Bool let specifiedSuperclass: String? if let classDecl = declaration.as(ClassDeclSyntax.self) { isSwiftClass = true + isSwiftEnum = false isJavaLangObject = classDecl.isJavaLangObject // Retrieve the superclass, if there is one. specifiedSuperclass = classDecl.inheritanceClause?.inheritedTypes.first?.trimmedDescription + } else if declaration.is(EnumDeclSyntax.self) { + isSwiftClass = false + isSwiftEnum = true + isJavaLangObject = false + specifiedSuperclass = nil } else { isSwiftClass = false + isSwiftEnum = false isJavaLangObject = false // Dig out the "extends" argument from the attribute. @@ -108,6 +123,15 @@ extension JavaClassMacro: MemberMacro { """ ) + // Enums (used for Java `sealed interface` wrappers) can't have stored + // properties, and their `javaHolder` / `init(javaHolder:)` / `_dispatch` + // implementations depend on the permitted-subclass list, which only the + // generator knows. Emit only `fullJavaClassName` here and let the + // generator supply the rest inside the enum body. + if isSwiftEnum { + return members + } + // struct wrappers need a JavaSuperclass type. if !isSwiftClass { members.append( diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index 29705f5ba..2173e2f64 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -392,23 +392,25 @@ extension JavaClassTranslator { } if javaClass.isSealed() { let kind = javaClass.isInterface() ? "sealed interface" : "sealed class" - let permits = javaClass.getPermittedSubclasses().compactMap { $0 } - if permits.isEmpty { + let permittedSubclasses = javaClass.getPermittedSubclasses().compactMap { $0 } + if permittedSubclasses.isEmpty { lines.append("/// Java `\(kind)`") } else { - let rendered: [String] = permits.map { permitted in + // One permitted subclass per line so the DocC-rendered output stays + // readable even for hierarchies with many alternatives. + lines.append("/// Java `\(kind)`, permits:") + for permitted in permittedSubclasses { let javaName = permitted.getName() if let swiftName = translator.translatedClasses[javaName]?.swiftType { - return "``\(swiftName)`` (`\(javaName)`)" + lines.append("/// - ``\(swiftName)`` (`\(javaName)`)") } else { - return "`\(javaName)`" + lines.append("/// - `\(javaName)`") } } - lines.append("/// Java `\(kind)`, permits: \(rendered.joined(separator: ", "))") } - let logPermitNames = permits.map { $0.getName() } + let logPermittedNames = permittedSubclasses.map { $0.getName() } log.info( - "Wrap Java \(kind): `\(javaClass.getName())`, permits: \(logPermitNames.map({ "`\($0)`" }).joined(separator: ", ")))" + "Wrap Java \(kind): `\(javaClass.getName())`, permits: \(logPermittedNames.map({ "`\($0)`" }).joined(separator: ", ")))" ) } if lines.isEmpty { @@ -445,8 +447,30 @@ extension JavaClassTranslator { } } + // A Java `sealed interface` is emitted as a Swift enum whose cases carry + // the permitted subclasses' Swift wrapper types. See + // `renderSealedInterfaceEnumMembers()` for the case list and the + // computed `javaHolder` / `init(javaHolder:)` synthesis. + // + // The interface's own instance methods are still emitted (as + // `@JavaMethod` funcs on the enum): the enum conforms to + // `AnyJavaObject` via the extension-macro branch, so the macro-supplied + // body's `dynamicJavaMethodCall` sees a valid `self.javaThis`. When such + // a method returns the enum itself, our `init(javaHolder:)` runs on the + // JNI result and picks the correct case via `.as(_:)` dispatch. + // + // Only take the enum route when at least one permitted subclass has a + // Swift wrapper: otherwise the enum has only a `unknown(JavaObject)` + // case, which is strictly worse than the plain struct form that still + // carries the Java interface's methods. + let renderAsSealedInterfaceEnum: Bool = { + guard javaClass.isSealed() && javaClass.isInterface() else { return false } + let permittedSubclasses = javaClass.getPermittedSubclasses().compactMap { $0 } + return permittedSubclasses.contains { translator.translatedClasses[$0.getName()] != nil } + }() + // Render all of the instance methods in Swift. - let instanceMethods = methods.methods.sortedForEmission().compactMap { method in + let instanceMethods: [DeclSyntax] = methods.methods.sortedForEmission().compactMap { method in do { return try renderMethod(method, implementedInSwift: false) } catch { @@ -465,8 +489,16 @@ extension JavaClassTranslator { DeclSyntax("public typealias \(raw: javaDecl.getName()) = \(raw: swiftName)") } + // For a sealed interface, the enum body carries a case per permitted + // Swift subclass, a computed `javaHolder`, and an `init(javaHolder:)` + // that dispatches via `.as(_:)` down to the concrete wrapper. + let sealedInterfaceEnumMembers: [DeclSyntax] = + renderAsSealedInterfaceEnum ? renderSealedInterfaceEnumMembers() : [] + // Collect all of the members of this type. - let members = genericParameterTypeAliases + initializers + properties + enumDecls + instanceMethods + let members = + genericParameterTypeAliases + initializers + properties + enumDecls + + sealedInterfaceEnumMembers + instanceMethods // Compute the "extends" clause for the superclass (of the struct // formulation) or the inheritance clause (for the class @@ -502,24 +534,14 @@ extension JavaClassTranslator { interfacesStr = ", \(prefix): \(swiftInterfaces.map { "\($0).self" }.joined(separator: ", "))" } - // Compute `sealed` and `permits` metadata for Java `sealed` types. - let sealedModifierPrefix: String - let permitsArgs: String - if javaClass.isSealed() { - sealedModifierPrefix = ".sealed, " - let permittedTypes = javaClass.getPermittedSubclasses().compactMap { $0 } - let knownSwiftNames: [String] = permittedTypes.compactMap { permitted in - translator.translatedClasses[permitted.getName()]?.swiftType - } - if knownSwiftNames.isEmpty { - permitsArgs = "" - } else { - permitsArgs = ", permits: \(knownSwiftNames.map { "\($0).self" }.joined(separator: ", "))" - } - } else { - sealedModifierPrefix = "" - permitsArgs = "" - } + // Compute the `.sealed` marker for Java `sealed` types. The permitted- + // subclass list is no longer emitted as a `permits:` macro argument + // because we model the hierarchy directly in the Swift type system + // (sealed interfaces are wrapped as Swift enums with cases per + // permitted subclass; sealed classes rely on the emitted subclass + // decls being findable via the enclosing type's members). + let sealedModifierPrefix: String = + javaClass.isSealed() ? ".sealed, " : "" let genericParameterClause = if swiftGenericParameterNames.isEmpty { @@ -539,7 +561,14 @@ extension JavaClassTranslator { } else { "JavaClass" } - let introducer = translateAsClass ? "open class" : "public struct" + let introducer = + if translateAsClass { + "open class" + } else if renderAsSealedInterfaceEnum { + "public enum" + } else { + "public struct" + } let classAvailableAttributes = swiftAvailableAttributes( from: annotations, runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations, @@ -548,7 +577,7 @@ extension JavaClassTranslator { let javaKindDocs = renderJavaKindDocc() var classDecl: DeclSyntax = """ - \(raw: javaKindDocs)\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(raw: sealedModifierPrefix)\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)\(raw: permitsArgs)) + \(raw: javaKindDocs)\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(raw: sealedModifierPrefix)\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)) \(raw: introducer) \(raw: swiftInnermostTypeName)\(raw: genericParameterClause)\(raw: inheritanceClause) { \(raw: members.map { $0.description }.joined(separator: "\n\n")) } @@ -569,6 +598,92 @@ extension JavaClassTranslator { return classDecl.formatted(using: translator.format).cast(DeclSyntax.self) } + /// Render the enum body for a Java `sealed interface` wrapper. + /// + /// Emits one case per Swift-translated permitted subclass plus a trailing + /// ``unknown(JavaObject)`` catch-all for Java subclasses whose Swift wrapper + /// has not been generated. Also emits the computed ``javaHolder`` and + /// ``init(javaHolder:)`` required by ``AnyJavaObject``. + /// + /// The permitted-subclass list lives in the enum's case declarations rather + /// than in a `permits:` macro argument. That's what breaks the mutual + /// `@JavaInterface` macro-expansion cycle: the parent's macro attribute no + /// longer names the children, and case declarations are resolved at member + /// lookup, well after both parent and child macros have expanded. + private func renderSealedInterfaceEnumMembers() -> [DeclSyntax] { + let permittedSubclasses = javaClass.getPermittedSubclasses().compactMap { $0 } + + // Only permitted subclasses whose Swift wrapper is known can become + // typed enum cases. Unknown permitted subclasses are still handled at + // runtime by the `unknown` case. + struct SealedCase { + let caseName: String + let swiftType: String + } + var cases: [SealedCase] = [] + var seenCaseNames = Set() + for permittedSubclass in permittedSubclasses { + guard let swiftType = translator.translatedClasses[permittedSubclass.getName()]?.swiftType else { + continue + } + // Case name is the last Swift name segment, lowercased first letter. + let lastSegment = swiftType.split(separator: ".").last.map(String.init) ?? swiftType + var caseName = lastSegment + if let firstChar = caseName.first { + caseName = firstChar.lowercased() + caseName.dropFirst() + } + // Guard against name clashes if two permitted subclasses share the + // same simple name in different parents. + guard seenCaseNames.insert(caseName).inserted else { + continue + } + cases.append(SealedCase(caseName: caseName, swiftType: swiftType)) + } + + var members: [DeclSyntax] = [] + for c in cases { + members.append("case \(raw: c.caseName)(\(raw: c.swiftType))") + } + // Catch-all for permitted subclasses whose Swift wrapper has not been + // generated. The wrapped `JavaObject` still reflects the real runtime + // class -- callers can query it via `.javaClass.getName()`. + members.append("case unknown(JavaObject)") + + let holderArms: String = + (cases.map { c in + "case .\(c.caseName)(let v): return v.javaHolder" + } + ["case .unknown(let v): return v.javaHolder"]).joined(separator: "\n ") + members.append( + """ + public var javaHolder: JavaObjectHolder { + switch self { + \(raw: holderArms) + } + } + """ + ) + + let initArms: String = cases.map { c in + """ + if let v = raw.as(\(c.swiftType).self) { + self = .\(c.caseName)(v) + return + } + """ + }.joined(separator: "\n ") + members.append( + """ + public init(javaHolder: JavaObjectHolder) { + let raw = JavaObject(javaHolder: javaHolder) + \(raw: initArms) + self = .unknown(raw) + } + """ + ) + + return members + } + /// Render any nested classes that will not be rendered separately. func renderNestedClasses() -> [DeclSyntax] { nestedClasses diff --git a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift index 352e1db51..5953e0183 100644 --- a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift +++ b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift @@ -25,6 +25,7 @@ class JavaKitMacroTests: XCTestCase { static let javaKitMacros: [String: any Macro.Type] = [ "JavaClass": JavaClassMacro.self, "JavaRecord": JavaClassMacro.self, + "JavaInterface": JavaClassMacro.self, "JavaMethod": JavaMethodMacro.self, "JavaField": JavaFieldMacro.self, "JavaStaticMethod": JavaMethodMacro.self, @@ -543,6 +544,34 @@ class JavaKitMacroTests: XCTestCase { ) } + func testJavaInterfaceOnEnum() throws { + // A Java `sealed interface` is wrapped as a Swift enum. The macro emits + // only `fullJavaClassName` for enum declarations -- the case list, + // `javaHolder`, and `init(javaHolder:)` are supplied by the generator + // (which has the permitted-subclass list). The extension macro still + // adds `AnyJavaObject` conformance on top. + assertMacroExpansion( + """ + @JavaInterface(.sealed, "com.example.Op") + public enum Op { + case add(Add) + case mul(Mul) + } + """, + expectedChunks: [ + "public static var fullJavaClassName: String", + #""com.example.Op""#, + ], + notExpectedChunks: [ + // Enum branch must not synthesize a stored `javaHolder` (illegal + // on enums) or the struct-shaped `init(javaHolder:)`. + "public var javaHolder: JavaObjectHolder", + "public required init(javaHolder: JavaObjectHolder)", + "public typealias JavaSuperclass", + ] + ) + } + func testJavaGenericClassGenericMethodParameter() throws { assertMacroExpansion( """ diff --git a/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift b/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift index 7251d49fb..140c0042d 100644 --- a/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift @@ -50,7 +50,7 @@ final class RecordSealedWrapJavaTests: XCTestCase { ) } - func test_wrapJava_javaSealed_isMarkedInDocs_withPermits() async throws { + func test_wrapJava_javaSealed_isMarkedInDocs() async throws { let classpathURL = try await compileJava( """ package com.example; @@ -70,22 +70,30 @@ final class RecordSealedWrapJavaTests: XCTestCase { classpath: [classpathURL], expectedChunks: [ """ - /// Java `sealed class`, permits: ``Circle`` (`com.example.Circle`), ``Square`` (`com.example.Square`) - @JavaClass(.sealed, "com.example.Shape", permits: Circle.self, Square.self) + /// Java `sealed class`, permits: + /// - ``Circle`` (`com.example.Circle`) + /// - ``Square`` (`com.example.Square`) + @JavaClass(.sealed, "com.example.Shape") open class Shape: """ ] ) } - func test_wrapJava_javaSealedInterface_isMarkedInDocs_withPermits() async throws { + func test_wrapJava_javaSealedInterface_isTranslatedAsSwiftEnum() async throws { let classpathURL = try await compileJava( """ package com.example; - public sealed interface Op permits Add, Mul {} - final class Add implements Op {} - final class Mul implements Op {} + public sealed interface Op permits Add, Mul { + Op combine(Op other); + } + final class Add implements Op { + @Override public Op combine(Op other) { return this; } + } + final class Mul implements Op { + @Override public Op combine(Op other) { return this; } + } """ ) @@ -97,11 +105,91 @@ final class RecordSealedWrapJavaTests: XCTestCase { ], classpath: [classpathURL], expectedChunks: [ + // Sealed interface becomes a Swift enum. The permitted subclasses + // are modelled as typed cases plus an `unknown` catch-all for any + // Java subclass whose Swift wrapper hasn't been generated. The + // interface's own methods are emitted on the enum itself, + // so callers can dispatch without pattern-matching first. The + // macro-generated body invokes `dynamicJavaMethodCall` on the + // enum (which conforms to `AnyJavaObject` via the extension + // macro) and wraps the result back into the enum via + // `init(javaHolder:)`. """ - /// Java `sealed interface`, permits: ``Add`` (`com.example.Add`), ``Mul`` (`com.example.Mul`) - @JavaInterface(.sealed, "com.example.Op", permits: Add.self, Mul.self) - public struct Op { + /// Java `sealed interface`, permits: + /// - ``Add`` (`com.example.Add`) + /// - ``Mul`` (`com.example.Mul`) + @JavaInterface(.sealed, "com.example.Op") + public enum Op { + case add(Add) + + case mul(Mul) + + case unknown(JavaObject) + + public var javaHolder: JavaObjectHolder { + switch self { + case .add(let v): + return v.javaHolder + case .mul(let v): + return v.javaHolder + case .unknown(let v): + return v.javaHolder + } + } + + public init(javaHolder: JavaObjectHolder) { + let raw = JavaObject(javaHolder: javaHolder) + if let v = raw.as(Add.self) { + self = .add(v) + return + } + if let v = raw.as(Mul.self) { + self = .mul(v) + return + } + self = .unknown(raw) + } + + /// Java method `combine`. + /// + /// ### Java method signature + /// ```java + /// public abstract com.example.Op com.example.Op.combine(com.example.Op) + /// ``` + @JavaMethod + public func combine(_ arg0: Op?) -> Op! + } + """, + // The Java `Op combine(Op)` method is inherited by each permitted + // subclass; wrap-java re-emits it on the concrete `Add` / `Mul` + // wrappers too, so callers holding a concrete case can call + // `.combine(...)` without pattern matching. + """ + @JavaClass("com.example.Add", implements: Op.self) + open class Add: JavaObject { + /// Java method `combine`. + /// + /// ### Java method signature + /// ```java + /// public com.example.Op com.example.Add.combine(com.example.Op) + /// ``` + @JavaMethod + open func combine(_ arg0: Op?) -> Op! + } + """, """ + @JavaClass("com.example.Mul", implements: Op.self) + open class Mul: JavaObject { + /// Java method `combine`. + /// + /// ### Java method signature + /// ```java + /// public com.example.Op com.example.Mul.combine(com.example.Op) + /// ``` + @JavaMethod + open func combine(_ arg0: Op?) -> Op! + } + """, ] ) }