Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -7,5 +7,6 @@
"optionalsAsNullable": true,
"nullableAnnotation": "org.jetbrains.annotations.Nullable",
"nonNullAnnotation": "org.jetbrains.annotations.NotNull",
"foundationTypesAsJavaTypes": true
"foundationTypesAsJavaTypes": true,
"closureMethodName": "invoke"
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class ClosuresTest {
assertNull(notifier.onItem)
notifier.onItem = { item -> seen += item.id }
notifier.emit("a")
notifier.onItem?.apply(Item.init("b", 1)) // the Swift closure, calling the lambda
notifier.onItem?.invoke(Item.init("b", 1)) // the Swift closure, calling the lambda
notifier.onItem = null
notifier.emit("c")
assertEquals(listOf("a", "b"), seen)
Expand All @@ -74,7 +74,7 @@ class ClosuresTest {
@Test
fun returnedClosure() {
val format = Notifier.init().makeFormatter("#")
assertEquals(listOf("#1", "#2"), listOf("1", "2").map { format.apply(Item.init(it, 1)) })
assertEquals(listOf("#1", "#2"), listOf("1", "2").map { format(Item.init(it, 1)) })
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,24 @@ class ScreenCallbacksTest {
@Test
fun mainActorClosures() {
val screen = ScreenModel.init()
screen.onTap.apply()
screen.onSelect.apply("BTC")
assertEquals("Markets", screen.title.apply())
screen.onDismiss?.apply()
screen.onTap()
screen.onSelect("BTC")
assertEquals("Markets", screen.title())
screen.onDismiss?.invoke()
assertEquals(listOf("tap", "select BTC", "dismiss"), screen.events.toList())
}

@Test
fun asyncClosures() {
val screen = ScreenModel.init()
assertEquals(3L, screen.count.apply("abc").get())
assertEquals(3L, screen.count("abc").get())

// `save` runs on the main actor: pump the main loop rather than block the main thread on it.
val saved = screen.save.apply("draft")
val saved = screen.save("draft")
await(saved)
assertEquals(listOf("save draft"), screen.events.toList())

val failed = screen.save.apply("")
val failed = screen.save("")
await(failed)
val error = assertFailsWith<ExecutionException> { failed.get() }
assertIs<ScreenError>(error.cause)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ extension JNISwift2JavaGenerator {
p.print(
"""
@JavaMethod
public func apply\(signature)
public func \(config.effectiveClosureMethodName)\(signature)
"""
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ extension JNISwift2JavaGenerator {
/// Conversion step for the result
let resultConversion: UpcallConversionStep

/// The name of the interface's single method, `apply` unless `closureMethodName` says otherwise
let methodName: String

/// The original Swift function type
let functionType: SwiftFunctionType
}
Expand Down Expand Up @@ -185,6 +188,7 @@ extension JNISwift2JavaGenerator {
functionType: SwiftFunctionType,
javaInterfaceName: String,
javaBinaryName: String,
methodName: String,
) throws -> SyntheticEscapingClosureFunctionType {
if functionType.isAsync {
try validateAsyncResult(functionType.resultType)
Expand All @@ -198,14 +202,15 @@ extension JNISwift2JavaGenerator {

let resultConversion = try self.translateResult(
type: functionType.resultType,
methodName: "apply"
methodName: methodName
)

return SyntheticEscapingClosureFunctionType(
javaInterfaceName: javaInterfaceName,
javaBinaryName: javaBinaryName,
parameterConversions: parameterConversions,
resultConversion: resultConversion,
methodName: methodName,
functionType: functionType
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -980,7 +980,7 @@ extension JNISwift2JavaGenerator {
// `LongUnaryOperator` but is translated to its own interface, which must then be printed.
if translated.functionTypes.count == 1,
let ty = translated.functionTypes.first,
KnownJavaFunctionalInterface.find(ty.swiftType) != nil
KnownJavaFunctionalInterface.find(ty.swiftType, closureMethodName: config.effectiveClosureMethodName) != nil
{
return
}
Expand All @@ -1000,7 +1000,7 @@ extension JNISwift2JavaGenerator {
// `Type.method` constructed, when the method takes no arguments.
printer.print("private \(className)() {}")
for functionType in translated.functionTypes {
if KnownJavaFunctionalInterface.find(functionType.swiftType) != nil {
if KnownJavaFunctionalInterface.find(functionType.swiftType, closureMethodName: config.effectiveClosureMethodName) != nil {
continue
}

Expand All @@ -1025,7 +1025,7 @@ extension JNISwift2JavaGenerator {
"""
@FunctionalInterface
public interface \(functionType.name) {
\(functionType.result.javaType) apply(\(apiParams.joined(separator: .comma)));
\(functionType.result.javaType) \(config.effectiveClosureMethodName)(\(apiParams.joined(separator: .comma)));
}
"""
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,7 @@ extension JNISwift2JavaGenerator {
parameterAnnotations: [JavaAnnotation]
) -> TranslatedParameter {
let interfacejavaType =
if let known = KnownJavaFunctionalInterface.find(functionType) {
if let known = KnownJavaFunctionalInterface.find(functionType, closureMethodName: config.effectiveClosureMethodName) {
known.javaType
} else {
JavaType.class(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ extension JNISwift2JavaGenerator {
functionType: functionType,
javaInterfaceName: javaClosureInterfaceName,
javaBinaryName: javaBinaryName,
methodName: config.effectiveClosureMethodName,
)

return NativeParameter(
Expand Down Expand Up @@ -489,7 +490,10 @@ extension JNISwift2JavaGenerator {
}

let result = try translateClosureResult(functionType.resultType)
let knownInterface = KnownJavaFunctionalInterface.find(functionType)
let knownInterface = KnownJavaFunctionalInterface.find(
functionType,
closureMethodName: config.effectiveClosureMethodName
)
let interfaceJavaType =
if let known = knownInterface {
known.javaType
Expand All @@ -512,7 +516,7 @@ extension JNISwift2JavaGenerator {
result: result,
// The method to call is decided with the interface, from the Swift type: the JNI
// signature alone can't tell `(Int) -> Int`'s own interface from `LongUnaryOperator`.
methodName: knownInterface?.method ?? "apply"
methodName: knownInterface?.method ?? config.effectiveClosureMethodName
),
indirectConversion: nil,
conversionCheck: nil
Expand Down Expand Up @@ -1938,9 +1942,11 @@ extension JNISwift2JavaGenerator {
let tryKeyword = fn.isThrowing ? "try" : "try!"
let arguments = upcallArguments.isEmpty ? "" : "arguments: \(upcallArguments.joined(separator: .comma)), "
resultPrinter.print(
"let future$ = \(tryKeyword) \(callInterfaceVar).dynamicJavaMethodCall(methodName: \"apply\", \(arguments)resultType: JavaCompletableFuture?.self)"
"let future$ = \(tryKeyword) \(callInterfaceVar).dynamicJavaMethodCall(methodName: \"\(syntheticFunction.methodName)\", \(arguments)resultType: JavaCompletableFuture?.self)"
)
resultPrinter.print(
"guard let future$ else { fatalError(\"Upcall to \(syntheticFunction.methodName) unexpectedly returned a null future\") }"
)
resultPrinter.print("guard let future$ else { fatalError(\"Upcall to apply unexpectedly returned a null future\") }")
if isVoid {
resultConverted = "_ = \(tryKeyword) await _awaitCompletableFuture(future$)"
} else {
Expand All @@ -1954,7 +1960,7 @@ extension JNISwift2JavaGenerator {
}
}
} else {
let upcallExpr = "\(callInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))"
let upcallExpr = "\(callInterfaceVar).\(syntheticFunction.methodName)(\(upcallArguments.joined(separator: .comma)))"
resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr)
}
let resultPrefix = resultPrinter.finalize()
Expand Down
6 changes: 6 additions & 0 deletions Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ struct KnownJavaFunctionalInterface: Sendable {
find(parameters: methodSignature.parameterTypes, result: methodSignature.resultType)
}

/// The JNI generator's lookup: a custom `closureMethodName` keeps every closure on its own interface, since a
/// `java.util.function` interface names its method `apply…`.
static func find(_ functionType: SwiftFunctionType, closureMethodName: String) -> KnownJavaFunctionalInterface? {
closureMethodName == "apply" ? find(functionType) : nil
}

static func find(_ functionType: SwiftFunctionType) -> KnownJavaFunctionalInterface? {
// An async closure's interface returns a `CompletableFuture`.
if functionType.isEscaping || functionType.isAsync {
Expand Down
9 changes: 9 additions & 0 deletions Sources/SwiftJavaConfigurationShared/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,15 @@ public struct Configuration: Codable {
/// of platform types. Applied to the other reference types. Same requirements as `nullableAnnotation`.
public var nonNullAnnotation: String?

/// The name of the single method of the functional interfaces jextract generates for Swift closures, `apply` by
/// default. With `invoke`, Kotlin calls a closure read from Swift as a function, `onTap()` instead of
/// `onTap.apply()`. With any name other than `apply`, closures such as `(Int64) -> Int64` also get their own
/// interface instead of a `java.util.function` one, so they can be called the same way. Only supported in `jni` mode.
public var closureMethodName: String?
public var effectiveClosureMethodName: String {
closureMethodName ?? "apply"
}

/// If true, Foundation values cross as standard Java types instead of Swift-backed wrapper classes generated
/// into each module: `URL` as `java.net.URI`, `Date` as `java.util.Date`, `Data` as `byte[]`, `Locale` as
/// `java.util.Locale` and `TimeZone` as `java.util.TimeZone`. Values are copied when they cross, and a `Date` keeps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,88 @@ only supported in `jni` mode.

---

#### exportMode

- **Type:** `JExtractExportMode?`
- **Default:** `all`

Which declarations to export: `all` (default) exports every declaration that passes the access level
and filters, except those opted out with `@JavaExport(false)`. `annotated` exports only declarations marked
`@JavaExport`, including the members of a marked type or extension.

**Values:**

- `all` - Export every declaration that passes the access level and filters, except those marked `@JavaExport(false)` or inside a type or extension marked `@JavaExport(false)`.
- `annotated` - Export only declarations marked `@JavaExport`, or inside a type or extension marked `@JavaExport`. A type that isn't marked is still exported when one of its members is.

---

#### failOnSkippedDeclarations

- **Type:** `Bool?`
- **Default:** `false`

If true, JExtract fails after generating when it skipped any declaration it could not
extract or translate, and lists each one. Skipped declarations are always reported as warnings.

---

#### payloadlessEnumsAsJavaEnums

- **Type:** `Bool?`
- **Default:** `false`

If true, Swift enums whose cases have no associated values are generated as Java `enum`s,
with one constant per case named like the case, instead of classes wrapping a Swift value. Only supported in
`jni` mode.

---

#### optionalsAsNullable

- **Type:** `Bool?`
- **Default:** `false`

If true, optional parameters and results of functions and properties use the wrapped Java type,
with `null` for `nil`, instead of `Optional`, `OptionalLong`, `OptionalInt` or `OptionalDouble`.
Only supported in `jni` mode.

---

#### nullableAnnotation

- **Type:** `String?`

Fully qualified name of a Java annotation marking parameters and results that can be `null`, such as
`androidx.annotation.Nullable` or `org.jetbrains.annotations.Nullable`, so Kotlin sees nullable types
instead of platform types. Applied to optionals with `optionalsAsNullable`. It must be applicable to
methods and parameters: type-use only annotations, such as JSpecify's, aren't supported. Only supported
in `jni` mode.

---

#### nonNullAnnotation

- **Type:** `String?`

Fully qualified name of a Java annotation marking parameters and results that are never `null`, such as
`androidx.annotation.NonNull` or `org.jetbrains.annotations.NotNull`, so Kotlin sees non-null types instead
of platform types. Applied to the other reference types. Same requirements as `nullableAnnotation`.

---

#### closureMethodName

- **Type:** `String?`
- **Default:** `"apply"`

The name of the single method of the functional interfaces jextract generates for Swift closures, `apply` by
default. With `invoke`, Kotlin calls a closure read from Swift as a function, `onTap()` instead of
`onTap.apply()`. With any name other than `apply`, closures such as `(Int64) -> Int64` also get their own
interface instead of a `java.util.function` one, so they can be called the same way. Only supported in `jni` mode.

---

#### foundationTypesAsJavaTypes

- **Type:** `Bool?`
Expand Down
76 changes: 76 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//===----------------------------------------------------------------------===//
//
// 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 CodePrinting
import JExtractSwiftLib
import SwiftJavaConfigurationShared
import Testing

/// `closureMethodName` names the method of closure interfaces, so Kotlin can call `onTap()` with `invoke`.
@Suite
struct JNIClosureMethodNameTests {
let source =
"""
public struct Item {}

public final class Notifier {
public var onItem: (Item) -> Void
public init(onItem: @escaping (Item) -> Void) { self.onItem = onItem }
public func double(_ f: (Int64) -> Int64) -> Int64 { f(2) }
}
"""

var config: Configuration {
var config = Configuration()
config.closureMethodName = "invoke"
return config
}

@Test
func javaInterfacesUseTheName() throws {
try assertOutput(
input: source,
config: config,
.jni,
.java,
detectChunkByInitialLines: 1,
expectedChunks: [
"void invoke(Item _0);",
// A closure `java.util.function` covers gets its own interface, so it can be called the same way.
"long invoke(long _0);",
],
notExpectedChunks: [
" apply(",
"LongUnaryOperator",
]
)
}

@Test
func swiftCallsTheName() throws {
try assertOutput(
input: source,
config: config,
.jni,
.swift,
detectChunkByInitialLines: 1,
expectedChunks: [
"public func invoke(_ _0: JavaItem?)"
],
notExpectedChunks: [
"func apply(",
".apply(",
]
)
}
}
Loading