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 new file mode 100644 index 000000000..44e7195fd --- /dev/null +++ b/Sources/SwiftJava/JavaClassModifier.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// 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") +/// @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 + + 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..f5db573a1 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 @@ -45,6 +48,40 @@ public macro JavaClass( implements: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") +/// 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") +/// @JavaClass(.final, "com.example.Leaf") +/// @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), + 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)?... +) = #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 @@ -85,6 +122,26 @@ public macro JavaRecord( implements: (any AnyJavaObject.Type)?... ) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") +/// Modifier-carrying variant of ``JavaRecord(_:extends:implements:)``. +@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)?... +) = #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 +170,29 @@ 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)?... +) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro") + +/// Modifier-carrying variant of ``JavaInterface(_:extends:)``. +@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)?... +) = #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/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 f30149069..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+) | 🟡 recognized, but missing special handling of `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 6797f61de..71a336d78 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 @@ -42,24 +47,37 @@ 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. - 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 @@ -105,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 bbb796da7..2173e2f64 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,26 @@ extension JavaClassTranslator { } if javaClass.isSealed() { let kind = javaClass.isInterface() ? "sealed interface" : "sealed class" - let permits = javaClass.getPermittedSubclasses().compactMap { $0?.getName() } - if permits.isEmpty { + let permittedSubclasses = javaClass.getPermittedSubclasses().compactMap { $0 } + if permittedSubclasses.isEmpty { lines.append("/// Java `\(kind)`") } else { - lines.append("/// Java `\(kind)`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", "))") + // 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 { + lines.append("/// - ``\(swiftName)`` (`\(javaName)`)") + } else { + lines.append("/// - `\(javaName)`") + } + } } - log.info("Wrap Java \(kind): `\(javaClass.getName())`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", ")))") + let logPermittedNames = permittedSubclasses.map { $0.getName() } + log.info( + "Wrap Java \(kind): `\(javaClass.getName())`, permits: \(logPermittedNames.map({ "`\($0)`" }).joined(separator: ", ")))" + ) } if lines.isEmpty { return "" @@ -435,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 { @@ -455,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 @@ -492,6 +534,15 @@ extension JavaClassTranslator { interfacesStr = ", \(prefix): \(swiftInterfaces.map { "\($0).self" }.joined(separator: ", "))" } + // 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 { "" @@ -510,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, @@ -519,7 +577,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: introducer) \(raw: swiftInnermostTypeName)\(raw: genericParameterClause)\(raw: inheritanceClause) { \(raw: members.map { $0.description }.joined(separator: "\n\n")) } @@ -540,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 dad2b2db6..5953e0183 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 @@ -23,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, @@ -527,6 +530,48 @@ class JavaKitMacroTests: XCTestCase { ) } + func testJavaClassSealedWithoutPermits() throws { + assertMacroExpansion( + """ + @JavaClass(.sealed, "com.example.Foo") + open class Foo: JavaObject { + } + """, + expectedChunks: [], + notExpectedChunks: [ + "public func as" + ] + ) + } + + 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( """ @@ -577,3 +622,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..140c0042d --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/RecordSealedWrapJavaTests.swift @@ -0,0 +1,196 @@ +//===----------------------------------------------------------------------===// +// +// 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() 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") + open class Shape: + """ + ] + ) + } + + func test_wrapJava_javaSealedInterface_isTranslatedAsSwiftEnum() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + 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; } + } + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.Op", + "com.example.Add", + "com.example.Mul", + ], + 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") + 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! + } + """, + ] + ) + } +}