From 1548c4822e61bfdda977dceb1805c051bed17edf Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 15:13:03 +0100 Subject: [PATCH 1/2] JNI: closureMethodName names the method of closure interfaces Closure interfaces declare one method, `apply`, so Kotlin calls a closure read from Swift as `onTap.apply()`, where Skip and Kotlin's own function types give `onTap()`. Kotlin treats a Java method named `invoke` as the invoke operator, so with `"closureMethodName": "invoke"` the same closure is called as `onTap()`, and Kotlin lambdas still convert to the interface. The name is used for the Java interface, the `@JavaInterface` jextract prints for escaping closures, and the calls into Java, synchronous and async. With a name other than `apply`, closures that a `java.util.function` interface would cover, such as `(Int64) -> Int64` with `LongUnaryOperator.applyAsLong`, get their own interface too, so every closure is called the same way. Co-Authored-By: Claude Opus 5.5 --- ...avaGenerator+EscapingClosureWrapJava.swift | 2 +- ...Generator+InterfaceWrapperGeneration.swift | 7 +- ...t2JavaGenerator+JavaBindingsPrinting.swift | 6 +- ...ISwift2JavaGenerator+JavaTranslation.swift | 2 +- ...wift2JavaGenerator+NativeTranslation.swift | 16 ++-- .../KnownFunctionalInterfaces.swift | 6 ++ .../Configuration.swift | 9 ++ .../Documentation.docc/SwiftJavaConfigFile.md | 82 +++++++++++++++++++ .../JNI/JNIClosureMethodNameTests.swift | 76 +++++++++++++++++ 9 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift index 18f3830c9..dff16b960 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -136,7 +136,7 @@ extension JNISwift2JavaGenerator { p.print( """ @JavaMethod - public func apply\(signature) + public func \(config.effectiveClosureMethodName)\(signature) """ ) } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 81c018ea0..06f7c1900 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -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 } @@ -185,6 +188,7 @@ extension JNISwift2JavaGenerator { functionType: SwiftFunctionType, javaInterfaceName: String, javaBinaryName: String, + methodName: String, ) throws -> SyntheticEscapingClosureFunctionType { if functionType.isAsync { try validateAsyncResult(functionType.resultType) @@ -198,7 +202,7 @@ extension JNISwift2JavaGenerator { let resultConversion = try self.translateResult( type: functionType.resultType, - methodName: "apply" + methodName: methodName ) return SyntheticEscapingClosureFunctionType( @@ -206,6 +210,7 @@ extension JNISwift2JavaGenerator { javaBinaryName: javaBinaryName, parameterConversions: parameterConversions, resultConversion: resultConversion, + methodName: methodName, functionType: functionType ) } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 94597ac57..500960f83 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -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 } @@ -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 } @@ -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))); } """ ) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 2f2bc9ec5..f75f1a628 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -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( diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 7a5dbfb2e..308437592 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -451,6 +451,7 @@ extension JNISwift2JavaGenerator { functionType: functionType, javaInterfaceName: javaClosureInterfaceName, javaBinaryName: javaBinaryName, + methodName: config.effectiveClosureMethodName, ) return NativeParameter( @@ -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 @@ -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 @@ -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 { @@ -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() diff --git a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift index 86d21d759..6e307e955 100644 --- a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift +++ b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift @@ -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 { diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index c61d45ecc..0f33f2cc0 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -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 diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index 624bc239e..b20e276bb 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -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?` diff --git a/Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift new file mode 100644 index 000000000..c80ea3c85 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift @@ -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(", + ] + ) + } +} From 108e58e7fc0c9bf9a77aeaa0c6dca8c62f247cbb Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 15:13:03 +0100 Subject: [PATCH 2/2] Samples: Kotlin calls closures read from Swift as functions Co-Authored-By: Claude Opus 5.5 --- .../Sources/KotlinSampleLibrary/swift-java.config | 3 ++- .../test/kotlin/com/example/kotlin/ClosuresTest.kt | 4 ++-- .../com/example/kotlin/ScreenCallbacksTest.kt | 14 +++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config index f8d3303ec..0374115c6 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -7,5 +7,6 @@ "optionalsAsNullable": true, "nullableAnnotation": "org.jetbrains.annotations.Nullable", "nonNullAnnotation": "org.jetbrains.annotations.NotNull", - "foundationTypesAsJavaTypes": true + "foundationTypesAsJavaTypes": true, + "closureMethodName": "invoke" } diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt index 6f64a40b9..b54b27956 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt @@ -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) @@ -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 diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt index 7f12f6102..4e42259bd 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt @@ -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 { failed.get() } assertIs(error.cause)