Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<EnglishGreeter> 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<DanishGreeter> 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<EnglishGreeter> 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<EnglishGreeter> english = greeter.as(EnglishGreeter.class, arena);
assertTrue(english.isPresent());
assertEquals("World", english.get().getName());

Optional<DanishGreeter> 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<EnglishGreeter> 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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions Sources/SwiftJava/SwiftObjects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,36 @@ extension SwiftObjects {
}
return perform(as: typeMetadata)
}

@JavaMethod
public static func dynamicCast(
environment: UnsafeMutablePointer<JNIEnv?>!,
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<S, T>(srcType: S.Type, targetType: T.Type) -> Int64 {
guard let self$ = UnsafeMutablePointer<S>(bitPattern: Int(selfPointer)) else {
fatalError("self memory address was null")
}

guard let castedValue = self$.pointee as? T else { return 0 }
let castedPointer = UnsafeMutablePointer<T>.allocate(capacity: 1)
castedPointer.initialize(to: castedValue)
return Int64(Int(bitPattern: castedPointer))
}
return perform(srcType: selfTypeMetadata, targetType: targetTypeMetadata)
}
}

public class HashableClass: Hashable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` | ❌ | ✅ |
Expand Down Expand Up @@ -395,6 +396,44 @@ 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 — the equivalent of Swift's `as?`. Every imported protocol `interface` therefore
Comment thread
ktoso marked this conversation as resolved.
Outdated
exposes an `as` method:

```java
<T extends JNISwiftInstance> Optional<T> as(Class<T> type, SwiftArena arena);
<T extends JNISwiftInstance> Optional<T> as(Class<T> type); // uses the default arena
```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to not have signatures copy pasted into docs


`type` must be a concrete jextracted type (a `class`, `struct`, or `enum` binding — the
Comment thread
ktoso marked this conversation as resolved.
Outdated
`JNISwiftInstance` bound enforces this at compile time). The cast succeeds only when the
Comment thread
ktoso marked this conversation as resolved.
Outdated
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);

// Recover the concrete type — its `struct`-only members become available:
Comment thread
ktoso marked this conversation as resolved.
Outdated
Optional<EnglishGreeter> 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 `Optional.empty()` — rather than throwing — whenever it cannot apply: when
Comment thread
ktoso marked this conversation as resolved.
Outdated
the dynamic type differs, when `type` is not a concrete jextracted type (e.g. a generic type
or another protocol), or when the receiver is not backed by a wrapped Swift value.
Comment thread
ktoso marked this conversation as resolved.
Outdated

### Swift closures

Non-escaping closures are called synchronously by the Swift function they are passed to,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 to recover {@code type} — a non-generic concrete
Comment thread
ktoso marked this conversation as resolved.
Outdated
* jextracted Swift type — from this value.
Comment thread
ktoso marked this conversation as resolved.
Outdated
*
* @return the recovered instance, or {@link Optional#empty()} if this value's
Comment thread
ktoso marked this conversation as resolved.
Outdated
* dynamic type is not {@code type}, if this value is not backed by a Swift value
Comment thread
ktoso marked this conversation as resolved.
Outdated
* (e.g. a Java-implemented conformer), or if {@code type} is not a concrete
Comment thread
ktoso marked this conversation as resolved.
Outdated
* jextracted type.
*/
default <T extends JNISwiftInstance> Optional<T> as(Class<T> 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll get warnings about this AFAIR on newer JDKs unless I'm misremembering, but that's fine for the time being... Better would be to just make the method public and avoid the setAccessible

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 <T extends JNISwiftInstance> Optional<T> as(Class<T> type) {
return as(type, SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
8 changes: 4 additions & 4 deletions Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ struct JNIProtocolTests {
import org.swift.swiftkit.core.util.*;
""",
"""
public interface SomeProtocol {
public interface SomeProtocol extends SwiftDowncastable {
...
public void method();
...
Expand All @@ -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();
...
Expand Down Expand Up @@ -594,7 +594,7 @@ struct JNIProtocolTests {
detectChunkByInitialLines: 1,
expectedChunks: [
"""
public interface Loader extends JNISwiftInstance {
public interface Loader extends JNISwiftInstance, SwiftDowncastable {
...
public long load();
...
Expand Down
Loading