diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ReturnProtocolCastTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ReturnProtocolCastTest.java new file mode 100644 index 000000000..48263b655 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ReturnProtocolCastTest.java @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// +// 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; + +import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.JNISwiftInstance; +import org.swift.swiftkit.core.SwiftArena; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises the dynamic {@code as} downcast (Swift's {@code as?}) that recovers a + * concrete jextracted Swift type from a value returned as {@code any P} / {@code some P}. + */ +public class ReturnProtocolCastTest { + + /** Downcast a returned {@code any Greeter} to its concrete Swift struct type. */ + @Test + void downcastToConcreteType_succeeds() { + try (var arena = SwiftArena.ofConfined()) { + Greeter greeter = MySwiftLibrary.makeEnglishGreeter("World", arena); + + Optional english = greeter.as(EnglishGreeter.class, arena); + assertTrue(english.isPresent()); + // `name` is a member of the concrete struct, not visible through the protocol. + assertEquals("World", english.get().getName()); + assertEquals("Hello, World!", english.get().greeting()); + } + } + + /** Downcasting to the wrong dynamic type returns empty (a faithful Swift {@code as?}). */ + @Test + void downcastToWrongType_isEmpty() { + try (var arena = SwiftArena.ofConfined()) { + Greeter greeter = MySwiftLibrary.makeEnglishGreeter("World", arena); + + Optional danish = greeter.as(DanishGreeter.class, arena); + assertTrue(danish.isEmpty()); + } + } + + /** {@code some Greeter} (opaque return) downcasts the same as {@code any Greeter}. */ + @Test + void downcastOpaqueReturn_succeeds() { + try (var arena = SwiftArena.ofConfined()) { + Greeter greeter = MySwiftLibrary.makeOpaqueGreeter("World", arena); + + Optional english = greeter.as(EnglishGreeter.class, arena); + assertTrue(english.isPresent()); + assertEquals("World", english.get().getName()); + } + } + + /** + * {@code as} works on a concrete-typed reference too (the {@code public} variant on + * the class, not the {@code default} on the protocol interface). + */ + @Test + void downcastFromConcreteReference() { + try (var arena = SwiftArena.ofConfined()) { + EnglishGreeter greeter = EnglishGreeter.init("World", arena); + + Optional english = greeter.as(EnglishGreeter.class, arena); + assertTrue(english.isPresent()); + assertEquals("World", english.get().getName()); + + Optional danish = greeter.as(DanishGreeter.class, arena); + assertTrue(danish.isEmpty()); + } + } + + /** + * A Java-implemented conformer has no backing Swift value, so {@code as} returns + * empty rather than crashing (the {@code instanceof JNISwiftInstance} guard). This is + * the case that only exists with Java callbacks enabled. + */ + @Test + void downcastJavaImplementedConformer_isEmpty() { + try (var arena = SwiftArena.ofConfined()) { + Greeter javaGreeter = new Greeter() { + @Override + public String greeting() { + return "Hi from Java"; + } + + @Override + public String repeated(long count) { + return "Hi from Java"; + } + }; + assertFalse(javaGreeter instanceof JNISwiftInstance); + + Optional english = javaGreeter.as(EnglishGreeter.class, arena); + assertTrue(english.isEmpty()); + } + } + + @Test + void downcastResultIsAWorkingInstance() { + try (var arena = SwiftArena.ofConfined()) { + Greeter greeter = MySwiftLibrary.makeEnglishGreeter("World", arena); + + EnglishGreeter english = greeter.as(EnglishGreeter.class, arena).orElseThrow(); + assertEquals("Hello, World! Hello, World!", english.repeated(2)); + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index b213c71ca..376bdc867 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -198,6 +198,8 @@ extension JNISwift2JavaGenerator { if !self.interfaceProtocolWrappers.keys.contains(decl) && !extends.contains("JNISwiftInstance") { extends.append("JNISwiftInstance") } + + extends.append("SwiftDowncastable") let extendsString = extends.isEmpty ? "" : " extends \(extends.joined(separator: .comma))" printer.printBraceBlock("public interface \(decl.effectiveJavaSimpleName)\(extendsString)") { printer in diff --git a/Sources/SwiftJava/SwiftObjects.swift b/Sources/SwiftJava/SwiftObjects.swift index 1a412094d..d42e7120b 100644 --- a/Sources/SwiftJava/SwiftObjects.swift +++ b/Sources/SwiftJava/SwiftObjects.swift @@ -158,6 +158,36 @@ extension SwiftObjects { } return perform(as: typeMetadata) } + + @JavaMethod + public static func dynamicCast( + environment: UnsafeMutablePointer!, + selfPointer: Int64, + selfTypePointer: Int64, + targetTypePointter: Int64 + ) -> Int64 { + guard let selfType$ = UnsafeRawPointer(bitPattern: Int(selfTypePointer)) else { + fatalError("selfType metadata address was null") + } + guard let targetType$ = UnsafeRawPointer(bitPattern: Int(targetTypePointter)) else { + fatalError("targetType metadata address was null") + } + + let selfTypeMetadata = unsafeBitCast(selfType$, to: Any.Type.self) + let targetTypeMetadata = unsafeBitCast(targetType$, to: Any.Type.self) + + func perform(srcType: S.Type, targetType: T.Type) -> Int64 { + guard let self$ = UnsafeMutablePointer(bitPattern: Int(selfPointer)) else { + fatalError("self memory address was null") + } + + guard let castedValue = self$.pointee as? T else { return 0 } + let castedPointer = UnsafeMutablePointer.allocate(capacity: 1) + castedPointer.initialize(to: castedValue) + return Int64(Int(bitPattern: castedPointer)) + } + return perform(srcType: selfTypeMetadata, targetType: targetTypeMetadata) + } } public class HashableClass: Hashable { diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index 38b7bd0b7..a76787756 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -76,6 +76,7 @@ SwiftJava's `swift-java jextract` tool automates generating Java bindings from S | Existential parameters `f(x: any (A & B)) ` | ❌ | ✅ | | Existential return types of a single protocol: `f() -> any SomeProtocol` | ❌ | ✅ | | Existential return types of a composite: `f() -> any (A & B)` | ❌ | ❌ | +| Downcasting a returned protocol value to its concrete type: `greeter.as(EnglishGreeter.class, arena)` | ❌ | ✅ | | Foundation Data and DataProtocol: `f(x: any DataProtocol) -> Data` | ✅ | ✅ | | Foundation Date: `f(date: Date) -> Date` | ❌ | ✅ | | Foundation UUID: `f(uuid: UUID) -> UUID` | ❌ | ✅ | @@ -395,6 +396,43 @@ Static requirements (`static func`, `init`) and returning a *composite* existent (`any (A & B)`) are not currently supported; such requirements are simply omitted from the generated box, and composite returns are not extracted. +#### Downcasting to the concrete type (`as`) + +> Note: Downcasting returned protocol values is currently only supported in JNI mode. + +A value returned as `any P` / `some P` preserves its concrete dynamic type, so it can be +recovered; This is equivalent to Swift's `as?`, of a checked `instanceof` cast in Java. Every imported protocol `interface` therefore +exposes an `as` method: + +```java + Optional as(Class type, SwiftArena arena); + Optional as(Class type); // uses the default arena +``` + +`type` must be a concrete jextracted type. +The cast succeeds only when the +value's dynamic Swift type is exactly that type, in which case you receive a fresh binding +registered in the given arena; otherwise the result is `Optional.empty()`. + +Continuing the `Greeter` example, where `makeEnglishGreeter` returns `any Greeter` backed +by a concrete `EnglishGreeter`: + +```java +try (var arena = SwiftArena.ofConfined()) { + Greeter greeter = MySwiftLibrary.makeEnglishGreeter("World", arena); + + Optional english = greeter.as(EnglishGreeter.class, arena); + assertEquals("World", english.orElseThrow().getName()); + + // A cast to the wrong dynamic type yields an empty Optional: + assertTrue(greeter.as(DanishGreeter.class, arena).isEmpty()); +} +``` + +The cast returns an empty optional if the cast fails, which might happen when +the dynamic type differs, when `type` is not a concrete jextracted type (e.g. a generic type +or another protocol). + ### Swift closures Non-escaping closures are called synchronously by the Swift function they are passed to, diff --git a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftDowncastable.java b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftDowncastable.java new file mode 100644 index 000000000..ecb0b9adc --- /dev/null +++ b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftDowncastable.java @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// 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 org.swift.swiftkit.core; + +import java.lang.reflect.Method; +import java.util.Optional; + +/** + * Exposes Swift's dynamic {@code as?} cast on jextracted values: recover a concrete + * jextracted Swift type from a value whose static Java type is a protocol interface + * (e.g. a value returned as {@code any P} / {@code some P}) or any other super-type. +*/ +public interface SwiftDowncastable { + + /** + * Swift {@code as?}: attempt type-cast this instance to {@code T}, + * which must be a non-generic concrete jextracted Swift wrapper type. + * + * @return the type-casted instance, or {@link Optional#empty()} if this value's + * dynamic type is not {@code T}, if this value is not backed by a Swift value + * (e.g. a Java-implemented conformer), or if {@code T} is not a concrete + * jextracted type. + */ + default Optional as(Class type, SwiftArena arena) { + if (!(this instanceof JNISwiftInstance)) return Optional.empty(); + JNISwiftInstance src = (JNISwiftInstance) this; + try { + // Java does not support static protocol requirements + // and we need to access the type memory address of T + // so we use Java reflection to call the static native downcall instead. + // and also the "constructor" to get back the correct Java wrapper. + Method metadata = type.getDeclaredMethod("$typeMetadataAddressDowncall"); + metadata.setAccessible(true); + long targetType = (long) metadata.invoke(null); + long p = SwiftObjects.dynamicCast(src.$memoryAddress(), src.$typeMetadataAddress(), targetType); + if (p == 0) return Optional.empty(); + Method wrap = type.getMethod("wrapMemoryAddressUnsafe", long.class, SwiftArena.class); + return Optional.of(type.cast(wrap.invoke(null, p, arena))); + } catch (ReflectiveOperationException e) { + // Not a concrete jextracted type + return Optional.empty(); + } + } + + /** + * Convenience overload using the default automatic arena. + * + * @see #as(Class, SwiftArena) + */ + default Optional as(Class type) { + return as(type, SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA); + } +} diff --git a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftObjects.java b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftObjects.java index a8cce9166..785ee758b 100644 --- a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftObjects.java +++ b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftObjects.java @@ -31,4 +31,5 @@ public static void requireNonZero(long number, String name) { public static native String typeDescription(long selfTypePointer); public static native boolean equals(long lhsPointer, long lhsTypePointer, long rhsPointer, long rhsTypePointer); public static native int hashCode(long selfPointer, long selfTypePointer); + public static native long dynamicCast(long selfPointer, long selfTypePointer, long targetTypePointer); } diff --git a/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift b/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift index a3cadbd09..98f4df649 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift @@ -101,7 +101,7 @@ struct JNIProtocolTests { import org.swift.swiftkit.core.util.*; """, """ - public interface SomeProtocol { + public interface SomeProtocol extends SwiftDowncastable { ... public void method(); ... @@ -123,14 +123,14 @@ struct JNIProtocolTests { detectChunkByInitialLines: 1, expectedChunks: [ """ - public interface ParentProtocol { + public interface ParentProtocol extends SwiftDowncastable { ... public void parentMethod(); ... } """, """ - public interface ChildProtocol extends ParentProtocol { + public interface ChildProtocol extends ParentProtocol, SwiftDowncastable { ... public void childMethod(); ... @@ -594,7 +594,7 @@ struct JNIProtocolTests { detectChunkByInitialLines: 1, expectedChunks: [ """ - public interface Loader extends JNISwiftInstance { + public interface Loader extends JNISwiftInstance, SwiftDowncastable { ... public long load(); ...