From 31b7befe9ace43295c03ffc6c913535d5fccb343 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 15:32:40 +0100 Subject: [PATCH] jextract: closureMethodName names the method of closure interfaces Closure interfaces declare one method, `apply`, so Kotlin code calls a closure value as `handler.apply(x)`. Kotlin treats a Java method named `invoke` as the invoke operator, so with `"closureMethodName": "invoke"` the same closure is called as `handler(x)`, and Kotlin lambdas still convert to the interface. The default stays `apply`. The name is used for the Java interface, the `@JavaInterface` printed for escaping closures and the call into Java, and the method looked up for non-escaping closures. 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 | 9 +- ...t2JavaGenerator+JavaBindingsPrinting.swift | 6 +- ...ISwift2JavaGenerator+JavaTranslation.swift | 2 +- ...wift2JavaGenerator+NativeTranslation.swift | 17 ++-- .../KnownFunctionalInterfaces.swift | 14 +++ .../Configuration.swift | 9 ++ .../Documentation.docc/SwiftJavaConfigFile.md | 12 +++ .../JNI/JNIClosureMethodNameTests.swift | 90 +++++++++++++++++++ 9 files changed, 147 insertions(+), 14 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 6b6a4e729..408d8d062 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -50,7 +50,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 236977e5e..cab09920b 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -99,6 +99,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 } @@ -140,12 +143,13 @@ extension JNISwift2JavaGenerator { } /// Generates a synthetic closure function translation. - /// This treats the closure as if it were a protocol with a single `apply` method, + /// This treats the closure as if it were a protocol with a single method named `methodName`, /// allowing it to use the same conversion infrastructure for optionals, arrays, etc. func generateSyntheticClosureFunction( functionType: SwiftFunctionType, javaInterfaceName: String, javaBinaryName: String, + methodName: String, ) throws -> SyntheticEscapingClosureFunctionType { let parameterConversions = try functionType.parameters.enumerated().map { idx, param in try self.translateParameter( @@ -156,7 +160,7 @@ extension JNISwift2JavaGenerator { let resultConversion = try self.translateResult( type: functionType.resultType, - methodName: "apply" + methodName: methodName ) return SyntheticEscapingClosureFunctionType( @@ -164,6 +168,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 504514f57..e3e78d0ce 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -810,7 +810,7 @@ extension JNISwift2JavaGenerator { // If contains one function type and it's known java functional interface type, we don't need to print the whole class if translated.functionTypes.count == 1, let ty = translated.functionTypes.first, - KnownJavaFunctionalInterface.find(ty) != nil + KnownJavaFunctionalInterface.find(ty, closureMethodName: config.effectiveClosureMethodName) != nil { return } @@ -821,7 +821,7 @@ extension JNISwift2JavaGenerator { """ ) { printer in for functionType in translated.functionTypes { - if KnownJavaFunctionalInterface.find(functionType) != nil { + if KnownJavaFunctionalInterface.find(functionType, closureMethodName: config.effectiveClosureMethodName) != nil { continue } @@ -846,7 +846,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 9dc93d507..37a86d845 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -761,7 +761,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 6490586d7..d443b6c54 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -398,6 +398,7 @@ extension JNISwift2JavaGenerator { functionType: functionType, javaInterfaceName: javaClosureInterfaceName, javaBinaryName: javaBinaryName, + methodName: config.effectiveClosureMethodName, ) return NativeParameter( @@ -432,7 +433,7 @@ extension JNISwift2JavaGenerator { let result = try translateClosureResult(functionType.resultType) let interfaceJavaType = - if let known = KnownJavaFunctionalInterface.find(functionType) { + if let known = KnownJavaFunctionalInterface.find(functionType, closureMethodName: config.effectiveClosureMethodName) { known.javaType } else { JavaType.class( @@ -450,7 +451,8 @@ extension JNISwift2JavaGenerator { ], conversion: .closureLowering( parameters: parameters, - result: result + result: result, + closureMethodName: config.effectiveClosureMethodName ), indirectConversion: nil, conversionCheck: nil @@ -1364,7 +1366,8 @@ extension JNISwift2JavaGenerator { /// of the `Unsafe(Mutable)Pointer` types in Swift. indirect case pointee(NativeSwiftConversionStep) - indirect case closureLowering(parameters: [NativeParameter], result: NativeResult) + /// `closureMethodName` is the method of the closure's own interface; a `java.util.function` one keeps its own. + indirect case closureLowering(parameters: [NativeParameter], result: NativeResult, closureMethodName: String) /// Escaping closure lowering using the protocol infrastructure. /// This uses UpcallConversionStep for full support of optionals, arrays, custom types, etc. @@ -1664,7 +1667,7 @@ extension JNISwift2JavaGenerator { let inner = inner.render(&printer, placeholder) return "\(inner).pointee" - case .closureLowering(let parameters, let nativeResult): + case .closureLowering(let parameters, let nativeResult, let closureMethodName): var printer = SwiftPrinter() let methodSignature = MethodSignature( @@ -1691,10 +1694,10 @@ extension JNISwift2JavaGenerator { let methodName = - if let known = KnownJavaFunctionalInterface.find(methodSignature) { + if closureMethodName == "apply", let known = KnownJavaFunctionalInterface.find(methodSignature) { known.method } else { - "apply" + closureMethodName } printer.print( @@ -1746,7 +1749,7 @@ extension JNISwift2JavaGenerator { // Note: The Java interface is synchronous even for async closures. // The async nature is on the Swift side, inferred from the expected type. var resultPrinter = SwiftPrinter() - let upcallExpr = "\(javaInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))" + let upcallExpr = "\(javaInterfaceVar).\(syntheticFunction.methodName)(\(upcallArguments.joined(separator: .comma)))" let resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr) let resultPrefix = resultPrinter.finalize() diff --git a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift index 9b1f75ab2..1f0465b2e 100644 --- a/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift +++ b/Sources/JExtractSwiftLib/KnownFunctionalInterfaces.swift @@ -225,6 +225,20 @@ 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 + } + + /// See ``find(_:closureMethodName:)``. + static func find( + _ functionType: JNISwift2JavaGenerator.TranslatedFunctionType, + closureMethodName: String + ) -> KnownJavaFunctionalInterface? { + closureMethodName == "apply" ? find(functionType) : nil + } + static func find(_ functionType: SwiftFunctionType) -> KnownJavaFunctionalInterface? { if functionType.isEscaping { return nil diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index c45b00b2e..a4a7a36df 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -181,6 +181,15 @@ public struct Configuration: Codable { enableJavaCallbacks ?? false } + /// The name of the single method of the functional interfaces jextract generates for Swift closures, `apply` by + /// default. With `invoke`, Kotlin calls such a closure 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 specified, JExtract will output to this file a list of paths to all generated Java source files. public var generatedJavaSourcesListFileOutput: String? diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index bacce7ccf..da21337b7 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -246,6 +246,18 @@ only supported in `jni` mode. --- +#### 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 such a closure 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. + +--- + #### generatedJavaSourcesListFileOutput - **Type:** `String?` diff --git a/Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift new file mode 100644 index 000000000..3d5ddd03d --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIClosureMethodNameTests.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// 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 JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// `closureMethodName` names the method of closure interfaces, so Kotlin can call a closure as `handler()`. +@Suite +struct JNIClosureMethodNameTests { + let source = + """ + public final class Notifier { + public init() {} + public func onMessage(_ handler: @escaping (String) -> Void) {} + 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(java.lang.String _0);", + // A closure `java.util.function` covers gets its own interface, so it is 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: String)", + "javaInterface_handler$.invoke(_0)", + #"environment.interface.GetMethodID(environment, class$, "invoke", "(J)J")!"#, + ], + notExpectedChunks: [ + "func apply(", + ".apply(", + ] + ) + } + + @Test + func applyIsTheDefault() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "void apply(java.lang.String _0);", + "java.util.function.LongUnaryOperator", + ] + ) + } +}