From 10282a7cb37cee14b957fe2ae596797656eb69b7 Mon Sep 17 00:00:00 2001 From: Gregorio Gevartosky Torrezan Date: Wed, 10 Dec 2025 09:21:15 -0400 Subject: [PATCH 1/4] Initial commit to support primitive escaping closures --- .../MySwiftLibrary/EscapingClosures.swift | 83 +++++++++ .../example/swift/EscapingClosuresTest.java | 167 ++++++++++++++++++ ...wift2JavaGenerator+NativeTranslation.swift | 96 +++++++++- .../SwiftTypes/SwiftFunctionType.swift | 6 +- .../SwiftTypes/SwiftType.swift | 32 +++- .../Documentation.docc/SupportedFeatures.md | 4 +- .../JNI/JNIEscapingClosureTests.swift | 132 ++++++++++++++ 7 files changed, 501 insertions(+), 19 deletions(-) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java create mode 100644 Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift new file mode 100644 index 000000000..2f44b4af5 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 +// +//===----------------------------------------------------------------------===// + +public class CallbackManager { + private var callback: (() -> Void)? + private var intCallback: ((Int64) -> Int64)? + + public init() {} + + public func setCallback(callback: @escaping () -> Void) { + self.callback = callback + } + + public func triggerCallback() { + callback?() + } + + public func clearCallback() { + callback = nil + } + + public func setIntCallback(callback: @escaping (Int64) -> Int64) { + self.intCallback = callback + } + + public func triggerIntCallback(value: Int64) -> Int64? { + return intCallback?(value) + } +} + +// public func delayedExecution(closure: @escaping (Int64) -> Int64, input: Int64) -> Int64 { +// // In a real implementation, this might be async +// // For testing purposes, we just call it synchronously +// return closure(input) +// } + +public class ClosureStore { + private var closures: [() -> Void] = [] + + public init() {} + + public func addClosure(closure: @escaping () -> Void) { + closures.append(closure) + } + + public func executeAll() { + for closure in closures { + closure() + } + } + + public func clear() { + closures.removeAll() + } + + public func count() -> Int64 { + return Int64(closures.count) + } +} + +public func multipleEscapingClosures( + onSuccess: @escaping (Int64) -> Void, + onFailure: @escaping (Int64) -> Void, + condition: Bool +) { + if condition { + onSuccess(42) + } else { + onFailure(-1) + } +} + diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java new file mode 100644 index 000000000..2da95f297 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java @@ -0,0 +1,167 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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.SwiftArena; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +public class EscapingClosuresTest { + + @Test + void testCallbackManager_singleCallback() { + try (var arena = SwiftArena.ofConfined()) { + CallbackManager manager = CallbackManager.init(arena); + + AtomicBoolean wasCalled = new AtomicBoolean(false); + + // Create an escaping closure (no try-with-resources needed - cleanup is automatic via Swift ARC) + CallbackManager.setCallback.callback callback = () -> { + wasCalled.set(true); + }; + + // Set the callback + manager.setCallback(callback); + + // Trigger it + manager.triggerCallback(); + assertTrue(wasCalled.get(), "Callback should have been called"); + + // Trigger again to ensure it's still stored + wasCalled.set(false); + manager.triggerCallback(); + assertTrue(wasCalled.get(), "Callback should be called multiple times"); + + // Clear the callback - this releases the closure on Swift side, triggering GlobalRef cleanup + manager.clearCallback(); + } + } + + @Test + void testCallbackManager_intCallback() { + try (var arena = SwiftArena.ofConfined()) { + CallbackManager manager = CallbackManager.init(arena); + + CallbackManager.setIntCallback.callback callback = (value) -> { + return value * 2; + }; + + manager.setIntCallback(callback); + + // Trigger the callback - returns OptionalLong since Swift returns Int64? + OptionalLong result = manager.triggerIntCallback(21); + assertTrue(result.isPresent(), "Result should be present"); + assertEquals(42, result.getAsLong(), "Callback should double the input"); + } + } + + @Test + void testClosureStore() { + try (var arena = SwiftArena.ofConfined()) { + ClosureStore store = ClosureStore.init(arena); + + AtomicLong counter = new AtomicLong(0); + + // Add multiple closures + ClosureStore.addClosure.closure closure1 = () -> { + counter.incrementAndGet(); + }; + ClosureStore.addClosure.closure closure2 = () -> { + counter.addAndGet(10); + }; + ClosureStore.addClosure.closure closure3 = () -> { + counter.addAndGet(100); + }; + + store.addClosure(closure1); + store.addClosure(closure2); + store.addClosure(closure3); + + assertEquals(3, store.count(), "Should have 3 closures stored"); + + // Execute all closures + store.executeAll(); + assertEquals(111, counter.get(), "All closures should be executed"); + + // Execute again + counter.set(0); + store.executeAll(); + assertEquals(111, counter.get(), "Closures should be reusable"); + + // Clear - this releases closures on Swift side, triggering GlobalRef cleanup + store.clear(); + assertEquals(0, store.count(), "Store should be empty after clear"); + } + } + + @Test + void testMultipleEscapingClosures() { + AtomicLong successValue = new AtomicLong(0); + AtomicLong failureValue = new AtomicLong(0); + + MySwiftLibrary.multipleEscapingClosures.onSuccess onSuccess = (value) -> { + successValue.set(value); + }; + MySwiftLibrary.multipleEscapingClosures.onFailure onFailure = (value) -> { + failureValue.set(value); + }; + + // Test success case + MySwiftLibrary.multipleEscapingClosures(onSuccess, onFailure, true); + assertEquals(42, successValue.get(), "Success callback should be called"); + assertEquals(0, failureValue.get(), "Failure callback should not be called"); + + // Reset and test failure case + successValue.set(0); + failureValue.set(0); + MySwiftLibrary.multipleEscapingClosures(onSuccess, onFailure, false); + assertEquals(0, successValue.get(), "Success callback should not be called"); + assertEquals(-1, failureValue.get(), "Failure callback should be called"); + } + + @Test + void testMultipleManagersWithDifferentClosures() { + try (var arena = SwiftArena.ofConfined()) { + CallbackManager manager1 = CallbackManager.init(arena); + CallbackManager manager2 = CallbackManager.init(arena); + + AtomicBoolean called1 = new AtomicBoolean(false); + AtomicBoolean called2 = new AtomicBoolean(false); + + CallbackManager.setCallback.callback callback1 = () -> { + called1.set(true); + }; + CallbackManager.setCallback.callback callback2 = () -> { + called2.set(true); + }; + + manager1.setCallback(callback1); + manager2.setCallback(callback2); + + // Trigger first manager + manager1.triggerCallback(); + assertTrue(called1.get(), "First callback should be called"); + assertFalse(called2.get(), "Second callback should not be called"); + + // Trigger second manager + manager2.triggerCallback(); + assertTrue(called2.get(), "Second callback should be called"); + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index d8b0a1d1b..dade1b90a 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -151,6 +151,8 @@ extension JNISwift2JavaGenerator { ) case .function(let fn): + + // @Sendable is not supported yet as "environment" is later captured inside the closure. var parameters = [NativeParameter]() for (i, parameter) in fn.parameters.enumerated() { let parameterName = parameter.parameterName ?? "_\(i)" @@ -163,15 +165,28 @@ extension JNISwift2JavaGenerator { let result = try translateClosureResult(fn.resultType) - return NativeParameter( - parameters: [ - JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) - ], - conversion: .closureLowering( - parameters: parameters, - result: result + if fn.isEscaping { + return NativeParameter( + parameters: [ + JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) + ], + conversion: .escapingClosureLowering( + parameters: parameters, + result: result, + closureName: parameterName + ) ) - ) + } else { + return NativeParameter( + parameters: [ + JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) + ], + conversion: .closureLowering( + parameters: parameters, + result: result + ) + ) + } case .optional(let wrapped): return try translateOptionalParameter( @@ -407,6 +422,15 @@ extension JNISwift2JavaGenerator { switch type { case .nominal(let nominal): if let knownType = nominal.nominalTypeDecl.knownTypeKind { + + if knownType == .void { + return NativeResult( + javaType: .void, + conversion: .placeholder, + outParameters: [] + ) + } + guard let javaType = JNIJavaTypeTranslator.translate(knownType: knownType, config: self.config), javaType.implementsJavaValue else { throw JavaTranslationError.unsupportedSwiftType(type) @@ -692,6 +716,8 @@ extension JNISwift2JavaGenerator { indirect case pointee(NativeSwiftConversionStep) indirect case closureLowering(parameters: [NativeParameter], result: NativeResult) + + indirect case escapingClosureLowering(parameters: [NativeParameter], result: NativeResult, closureName: String) indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) @@ -917,6 +943,60 @@ extension JNISwift2JavaGenerator { printer.print("}") return printer.finalize() + + case .escapingClosureLowering(let parameters, let nativeResult, let closureName): + var printer = CodePrinter() + + let methodSignature = MethodSignature( + resultType: nativeResult.javaType, + parameterTypes: parameters.flatMap { + $0.parameters.map { parameter in + guard case .concrete(let type) = parameter.type else { + fatalError("Closures do not support Java generics") + } + return type + } + } + ) + + let arguments = parameters.map { + $0.conversion.render(&printer, $0.parameters.first!.name) + } + + let closureParameters = parameters.flatMap { $0.parameters.map(\.name) }.joined(separator: ", ") + + let upcall = "env$.interface.\(nativeResult.javaType.jniCallMethodAName)(env$, closureContext_\(closureName)$.object!, methodID$, arguments$)" + let result = nativeResult.conversion.render(&printer, upcall) + let returnResult = if nativeResult.javaType.isVoid { result } else { "return \(result)" } + + printer.print( + """ + { + guard let \(placeholder) else { + fatalError(\"\(placeholder) is null") + } + + let closureContext_\(closureName)$ = JavaObjectHolder(object: \(placeholder), environment: environment) + return { \(parameters.isEmpty ? "" : "\(closureParameters) in") + guard let env$ = try? JavaVirtualMachine.shared().environment() else { + fatalError(\"Failed to get JNI environment for escaping closure call\") + } + + // Call the Java closure + let class$ = env$.interface.GetObjectClass(env$, closureContext_\(closureName)$.object!) + guard let methodID$ = env$.interface.GetMethodID(env$, class$, \"apply\", \"\(methodSignature.mangledName)\") else { + fatalError(\"Failed to find apply method on closure\") + } + + let arguments$: [jvalue] = [\(arguments.joined(separator: ", "))] + + \(returnResult) + } + }() + """ + ) + + return printer.finalize() case .initializeSwiftJavaWrapper(let inner, let wrapperName): let inner = inner.render(&printer, placeholder) diff --git a/Sources/JExtractSwiftLib/SwiftTypes/SwiftFunctionType.swift b/Sources/JExtractSwiftLib/SwiftTypes/SwiftFunctionType.swift index da6fd2a2a..d1ed84080 100644 --- a/Sources/JExtractSwiftLib/SwiftTypes/SwiftFunctionType.swift +++ b/Sources/JExtractSwiftLib/SwiftTypes/SwiftFunctionType.swift @@ -23,6 +23,7 @@ struct SwiftFunctionType: Equatable { var convention: Convention var parameters: [SwiftParameter] var resultType: SwiftType + var isEscaping: Bool = false } extension SwiftFunctionType: CustomStringConvertible { @@ -32,7 +33,8 @@ extension SwiftFunctionType: CustomStringConvertible { case .c: "@convention(c) " case .swift: "" } - return "\(conventionPrefix)(\(parameterString)) -> \(resultType.description)" + let escapingPrefix = isEscaping ? "@escaping " : "" + return "\(escapingPrefix)\(conventionPrefix)(\(parameterString)) -> \(resultType.description)" } } @@ -40,9 +42,11 @@ extension SwiftFunctionType { init( _ node: FunctionTypeSyntax, convention: Convention, + isEscaping: Bool = false, lookupContext: SwiftTypeLookupContext ) throws { self.convention = convention + self.isEscaping = isEscaping self.parameters = try node.parameters.map { param in let isInout = param.inoutKeyword != nil return SwiftParameter( diff --git a/Sources/JExtractSwiftLib/SwiftTypes/SwiftType.swift b/Sources/JExtractSwiftLib/SwiftTypes/SwiftType.swift index aeb88bfba..c734f9883 100644 --- a/Sources/JExtractSwiftLib/SwiftTypes/SwiftType.swift +++ b/Sources/JExtractSwiftLib/SwiftTypes/SwiftType.swift @@ -228,23 +228,37 @@ extension SwiftType { throw TypeTranslationError.unimplementedType(type) case .attributedType(let attributedType): - // Only recognize the "@convention(c)" and "@convention(swift)" attributes, and - // then only on function types. + // Recognize "@convention(c)", "@convention(swift)", and "@escaping" attributes on function types. // FIXME: This string matching is a horrible hack. - switch attributedType.attributes.trimmedDescription { - case "@convention(c)", "@convention(swift)": + let attrs = attributedType.attributes.trimmedDescription + + // Handle @escaping attribute + if attrs.contains("@escaping") { let innerType = try SwiftType(attributedType.baseType, lookupContext: lookupContext) switch innerType { case .function(var functionType): - let isConventionC = attributedType.attributes.trimmedDescription == "@convention(c)" - let convention: SwiftFunctionType.Convention = isConventionC ? .c : .swift - functionType.convention = convention + functionType.isEscaping = true self = .function(functionType) default: throw TypeTranslationError.unimplementedType(type) } - default: - throw TypeTranslationError.unimplementedType(type) + } else { + // Handle @convention attributes + switch attrs { + case "@convention(c)", "@convention(swift)": + let innerType = try SwiftType(attributedType.baseType, lookupContext: lookupContext) + switch innerType { + case .function(var functionType): + let isConventionC = attrs == "@convention(c)" + let convention: SwiftFunctionType.Convention = isConventionC ? .c : .swift + functionType.convention = convention + self = .function(functionType) + default: + throw TypeTranslationError.unimplementedType(type) + } + default: + throw TypeTranslationError.unimplementedType(type) + } } case .functionType(let functionType): diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index 370f33925..533fd4e23 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -91,7 +91,9 @@ SwiftJava's `swift-java jextract` tool automates generating Java bindings from S | Non-escaping `Void` closures: `func callMe(maybe: () -> ())` | ✅ | ✅ | | Non-escaping closures with primitive arguments/results: `func callMe(maybe: (Int) -> (Double))` | ✅ | ✅ | | Non-escaping closures with object arguments/results: `func callMe(maybe: (JavaObj) -> (JavaObj))` | ❌ | ❌ | -| `@escaping` closures: `func callMe(_: @escaping () -> ())` | ❌ | ❌ | +| `@escaping` `Void` closures: `func callMe(_: @escaping () -> ())` | ❌ | ✅ | +| `@escaping` closures with primitive arguments/results: `func callMe(_: @escaping (String) -> (String))` | ❌ | ✅ | +| `@escaping` closures with custom arguments/results: `func callMe(_: @escaping (Obj) -> (Obj))` | ❌ | ❌ | | Swift type extensions: `extension String { func uppercased() }` | ✅ | ✅ | | Swift macros (maybe) | ❌ | ❌ | | Result builders | ❌ | ❌ | diff --git a/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift new file mode 100644 index 000000000..8501014e7 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 Testing + +@Suite +struct JNIEscapingClosureTests { + let source = + """ + public class CallbackManager { + private var callback: (() -> Void)? + + public init() {} + + public func setCallback(callback: @escaping () -> Void) { + self.callback = callback + } + + public func triggerCallback() { + callback?() + } + + public func clearCallback() { + callback = nil + } + } + + public func delayedExecution(closure: @escaping (Int64) -> Int64, input: Int64) -> Int64 { + // Simplified for testing - would normally be async + return closure(input) + } + """ + + @Test + func escapingEmptyClosure_javaBindings() throws { + let simpleSource = + """ + public func setCallback(callback: @escaping () -> Void) {} + """ + + try assertOutput(input: simpleSource, .jni, .java, expectedChunks: [ + """ + public static class setCallback { + @FunctionalInterface + public interface callback { + void apply(); + } + } + """, + """ + /** + * Downcall to Swift: + * {@snippet lang=swift : + * public func setCallback(callback: @escaping () -> Void) + * } + */ + public static void setCallback(com.example.swift.SwiftModule.setCallback.callback callback) { + SwiftModule.$setCallback(callback); + } + """ + ]) + } + + @Test + func escapingClosureWithParameters_javaBindings() throws { + let source = + """ + public func delayedExecution(closure: @escaping (Int64) -> Int64) {} + """ + + try assertOutput(input: source, .jni, .java, expectedChunks: [ + """ + public static class delayedExecution { + @FunctionalInterface + public interface closure { + long apply(long _0); + } + } + """ + ]) + } + + @Test + func escapingClosure_swiftThunks() throws { + let source = + """ + public func setCallback(callback: @escaping () -> Void) {} + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + let closureContext_callback$ = JavaObjectHolder(object: callback, environment: environment) + """ + ] + ) + } + + @Test + func nonEscapingClosure_stillWorks() throws { + let source = + """ + public func call(closure: () -> Void) {} + """ + + try assertOutput(input: source, .jni, .java, expectedChunks: [ + """ + @FunctionalInterface + public interface closure { + void apply(); + } + """ + ]) + } +} + From dd7b066ac272c76c8e7a3302c305cfae49bb62e8 Mon Sep 17 00:00:00 2001 From: Gregorio Gevartosky Torrezan Date: Thu, 8 Jan 2026 14:09:51 -0400 Subject: [PATCH 2/4] Refactoring escapingClosureLowering to benefit from the work done on supporting protocols --- ...wift2JavaGenerator+NativeTranslation.swift | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index dade1b90a..6e8375aa5 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -155,10 +155,10 @@ extension JNISwift2JavaGenerator { // @Sendable is not supported yet as "environment" is later captured inside the closure. var parameters = [NativeParameter]() for (i, parameter) in fn.parameters.enumerated() { - let parameterName = parameter.parameterName ?? "_\(i)" + let closureParamName = parameter.parameterName ?? "_\(i)" let closureParameter = try translateClosureParameter( parameter.type, - parameterName: parameterName + parameterName: closureParamName ) parameters.append(closureParameter) } @@ -166,6 +166,10 @@ extension JNISwift2JavaGenerator { let result = try translateClosureResult(fn.resultType) if fn.isEscaping { + // Use wrap-java interface for escaping closures + // Format: Java{ParentName}.{methodName}.{parameterName} + let wrapJavaInterfaceName = "Java\(parentName).\(methodName).\(parameterName)" + return NativeParameter( parameters: [ JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) @@ -173,7 +177,8 @@ extension JNISwift2JavaGenerator { conversion: .escapingClosureLowering( parameters: parameters, result: result, - closureName: parameterName + closureName: parameterName, + wrapJavaInterfaceName: wrapJavaInterfaceName ) ) } else { @@ -717,7 +722,14 @@ extension JNISwift2JavaGenerator { indirect case closureLowering(parameters: [NativeParameter], result: NativeResult) - indirect case escapingClosureLowering(parameters: [NativeParameter], result: NativeResult, closureName: String) + /// Converts an escaping closure parameter using wrap-java interface for upcalls. + /// This reuses the protocol callback infrastructure for unified handling. + indirect case escapingClosureLowering( + parameters: [NativeParameter], + result: NativeResult, + closureName: String, + wrapJavaInterfaceName: String + ) indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) @@ -944,53 +956,33 @@ extension JNISwift2JavaGenerator { return printer.finalize() - case .escapingClosureLowering(let parameters, let nativeResult, let closureName): + case .escapingClosureLowering(let parameters, let nativeResult, let closureName, let wrapJavaInterfaceName): var printer = CodePrinter() - let methodSignature = MethodSignature( - resultType: nativeResult.javaType, - parameterTypes: parameters.flatMap { - $0.parameters.map { parameter in - guard case .concrete(let type) = parameter.type else { - fatalError("Closures do not support Java generics") - } - return type - } - } - ) - - let arguments = parameters.map { - $0.conversion.render(&printer, $0.parameters.first!.name) - } - let closureParameters = parameters.flatMap { $0.parameters.map(\.name) }.joined(separator: ", ") + let parameterNames = parameters.flatMap { $0.parameters.map(\.name) } + let isVoid = nativeResult.javaType.isVoid - let upcall = "env$.interface.\(nativeResult.javaType.jniCallMethodAName)(env$, closureContext_\(closureName)$.object!, methodID$, arguments$)" - let result = nativeResult.conversion.render(&printer, upcall) - let returnResult = if nativeResult.javaType.isVoid { result } else { "return \(result)" } - + // Use wrap-java interface for upcalls - this reuses the protocol callback infrastructure printer.print( """ { guard let \(placeholder) else { - fatalError(\"\(placeholder) is null") + fatalError(\"\(placeholder) is null\") } + // Hold a reference to the Java object for the lifetime of the closure let closureContext_\(closureName)$ = JavaObjectHolder(object: \(placeholder), environment: environment) + return { \(parameters.isEmpty ? "" : "\(closureParameters) in") guard let env$ = try? JavaVirtualMachine.shared().environment() else { fatalError(\"Failed to get JNI environment for escaping closure call\") } - // Call the Java closure - let class$ = env$.interface.GetObjectClass(env$, closureContext_\(closureName)$.object!) - guard let methodID$ = env$.interface.GetMethodID(env$, class$, \"apply\", \"\(methodSignature.mangledName)\") else { - fatalError(\"Failed to find apply method on closure\") - } - - let arguments$: [jvalue] = [\(arguments.joined(separator: ", "))] - - \(returnResult) + // Create wrap-java interface instance and call apply method + // This leverages the same infrastructure as protocol callbacks + let javaInterface$ = \(wrapJavaInterfaceName)(javaThis: closureContext_\(closureName)$.object!, environment: env$) + \(isVoid ? "" : "return ")javaInterface$.apply(\(parameterNames.joined(separator: ", "))) } }() """ From 2a12b05a1c42eebc591c34aab1c6c2b4f153cf06 Mon Sep 17 00:00:00 2001 From: Gregorio Gevartosky Torrezan Date: Fri, 9 Jan 2026 11:01:19 -0400 Subject: [PATCH 3/4] Passing a synthetic closure protocol to the JNI tool --- ...Generator+InterfaceWrapperGeneration.swift | 44 +++++++ ...wift2JavaGenerator+NativeTranslation.swift | 110 +++++++++++------- 2 files changed, 111 insertions(+), 43 deletions(-) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index e5ba671d8..df712507d 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -75,6 +75,22 @@ extension JNISwift2JavaGenerator { } } + /// Represents a synthetic protocol-like translation for escaping closures. + /// This allows closures to use the same conversion infrastructure as protocols, + /// providing support for optionals, arrays, custom types, async, etc. + struct SyntheticClosureFunction { + /// The wrap-java interface name (e.g., "JavaMyClass.setCallback.callback") + let wrapJavaInterfaceName: String + + /// Conversion steps for each parameter + let parameterConversions: [UpcallConversionStep] + + /// Conversion step for the result + let resultConversion: UpcallConversionStep + + /// The original Swift function type + let functionType: SwiftFunctionType + } struct JavaInterfaceProtocolWrapperGenerator { func generate(for type: ImportedNominalType) throws -> JavaInterfaceSwiftWrapper { @@ -105,6 +121,34 @@ extension JNISwift2JavaGenerator { ) } + /// Generates a synthetic closure function translation. + /// This treats the closure as if it were a protocol with a single `apply` method, + /// allowing it to use the same conversion infrastructure for optionals, arrays, etc. + func generateSyntheticClosureFunction( + functionType: SwiftFunctionType, + wrapJavaInterfaceName: String + ) throws -> SyntheticClosureFunction { + let parameterConversions = try functionType.parameters.enumerated().map { idx, param in + try self.translateParameter( + parameterName: param.parameterName ?? "_\(idx)", + type: param.type + ) + } + + let resultConversion = try self.translateResult( + type: functionType.resultType, + methodName: "apply" + ) + + return SyntheticClosureFunction( + wrapJavaInterfaceName: wrapJavaInterfaceName, + parameterConversions: parameterConversions, + resultConversion: resultConversion, + functionType: functionType + ) + } + + private func translate(function: ImportedFunc) throws -> JavaInterfaceSwiftWrapper.Function { let parameters = try function.functionSignature.parameters.map { try self.translateParameter($0) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 6e8375aa5..9c31e00a2 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -153,6 +153,28 @@ extension JNISwift2JavaGenerator { case .function(let fn): // @Sendable is not supported yet as "environment" is later captured inside the closure. + if fn.isEscaping { + // Use the protocol infrastructure for escaping closures. + // This provides full support for optionals, arrays, custom types, async, etc. + let wrapJavaInterfaceName = "Java\(parentName).\(methodName).\(parameterName)" + let generator = JavaInterfaceProtocolWrapperGenerator() + let syntheticFunction = try generator.generateSyntheticClosureFunction( + functionType: fn, + wrapJavaInterfaceName: wrapJavaInterfaceName + ) + + return NativeParameter( + parameters: [ + JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) + ], + conversion: .escapingClosureLowering( + syntheticFunction: syntheticFunction, + closureName: parameterName + ) + ) + } + + // Non-escaping closures use the legacy translation var parameters = [NativeParameter]() for (i, parameter) in fn.parameters.enumerated() { let closureParamName = parameter.parameterName ?? "_\(i)" @@ -165,33 +187,15 @@ extension JNISwift2JavaGenerator { let result = try translateClosureResult(fn.resultType) - if fn.isEscaping { - // Use wrap-java interface for escaping closures - // Format: Java{ParentName}.{methodName}.{parameterName} - let wrapJavaInterfaceName = "Java\(parentName).\(methodName).\(parameterName)" - - return NativeParameter( - parameters: [ - JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) - ], - conversion: .escapingClosureLowering( - parameters: parameters, - result: result, - closureName: parameterName, - wrapJavaInterfaceName: wrapJavaInterfaceName - ) - ) - } else { - return NativeParameter( - parameters: [ - JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) - ], - conversion: .closureLowering( - parameters: parameters, - result: result - ) + return NativeParameter( + parameters: [ + JavaParameter(name: parameterName, type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)")) + ], + conversion: .closureLowering( + parameters: parameters, + result: result ) - } + ) case .optional(let wrapped): return try translateOptionalParameter( @@ -722,13 +726,11 @@ extension JNISwift2JavaGenerator { indirect case closureLowering(parameters: [NativeParameter], result: NativeResult) - /// Converts an escaping closure parameter using wrap-java interface for upcalls. - /// This reuses the protocol callback infrastructure for unified handling. + /// Escaping closure lowering using the protocol infrastructure. + /// This uses UpcallConversionStep for full support of optionals, arrays, custom types, etc. indirect case escapingClosureLowering( - parameters: [NativeParameter], - result: NativeResult, - closureName: String, - wrapJavaInterfaceName: String + syntheticFunction: SyntheticClosureFunction, + closureName: String ) indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) @@ -956,14 +958,39 @@ extension JNISwift2JavaGenerator { return printer.finalize() - case .escapingClosureLowering(let parameters, let nativeResult, let closureName, let wrapJavaInterfaceName): + case .escapingClosureLowering(let syntheticFunction, let closureName): var printer = CodePrinter() - let closureParameters = parameters.flatMap { $0.parameters.map(\.name) }.joined(separator: ", ") - let parameterNames = parameters.flatMap { $0.parameters.map(\.name) } - let isVoid = nativeResult.javaType.isVoid + let fn = syntheticFunction.functionType + let parameterNames = fn.parameters.enumerated().map { idx, param in + param.parameterName ?? "_\(idx)" + } + let closureParameters = parameterNames.joined(separator: ", ") + let isVoid = fn.resultType == .tuple([]) + + // Build upcall arguments using UpcallConversionStep conversions + var upcallArguments: [String] = [] + for (idx, conversion) in syntheticFunction.parameterConversions.enumerated() { + var argPrinter = CodePrinter() + let paramName = parameterNames[idx] + let converted = conversion.render(&argPrinter, paramName) + upcallArguments.append(converted) + } + + // Build result conversion + // 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 = CodePrinter() + let upcallExpr = "javaInterface$.apply(\(upcallArguments.joined(separator: ", ")))" + let resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr) + let resultPrefix = resultPrinter.finalize() + + // Note: async is part of the closure TYPE, not the closure literal syntax. + // For closures without parameters, we can omit "in" entirely. + let closureHeader = fn.parameters.isEmpty + ? "{" + : "{ \(closureParameters) in" - // Use wrap-java interface for upcalls - this reuses the protocol callback infrastructure printer.print( """ { @@ -971,18 +998,15 @@ extension JNISwift2JavaGenerator { fatalError(\"\(placeholder) is null\") } - // Hold a reference to the Java object for the lifetime of the closure let closureContext_\(closureName)$ = JavaObjectHolder(object: \(placeholder), environment: environment) - return { \(parameters.isEmpty ? "" : "\(closureParameters) in") + return \(closureHeader) guard let env$ = try? JavaVirtualMachine.shared().environment() else { fatalError(\"Failed to get JNI environment for escaping closure call\") } - // Create wrap-java interface instance and call apply method - // This leverages the same infrastructure as protocol callbacks - let javaInterface$ = \(wrapJavaInterfaceName)(javaThis: closureContext_\(closureName)$.object!, environment: env$) - \(isVoid ? "" : "return ")javaInterface$.apply(\(parameterNames.joined(separator: ", "))) + let javaInterface$ = \(syntheticFunction.wrapJavaInterfaceName)(javaThis: closureContext_\(closureName)$.object!, environment: env$) + \(resultPrefix)\(isVoid ? resultConverted : "return \(resultConverted)") } }() """ From 649cf5922e25a95f0072324d4aaa028e192ea4d0 Mon Sep 17 00:00:00 2001 From: Gregorio Gevartosky Torrezan Date: Thu, 22 Jan 2026 08:32:34 -0400 Subject: [PATCH 4/4] Fixing compile error due to branch syncup --- .../JNI/JNISwift2JavaGenerator+NativeTranslation.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 67343aa75..615836a36 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -181,7 +181,9 @@ extension JNISwift2JavaGenerator { conversion: .escapingClosureLowering( syntheticFunction: syntheticFunction, closureName: parameterName - ) + ), + indirectConversion: nil, + conversionCheck: nil ) }