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+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 d8b0a1d1b..9c31e00a2 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -151,12 +151,36 @@ 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 parameterName = parameter.parameterName ?? "_\(i)" + let closureParamName = parameter.parameterName ?? "_\(i)" let closureParameter = try translateClosureParameter( parameter.type, - parameterName: parameterName + parameterName: closureParamName ) parameters.append(closureParameter) } @@ -407,6 +431,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 +725,13 @@ extension JNISwift2JavaGenerator { indirect case pointee(NativeSwiftConversionStep) indirect case closureLowering(parameters: [NativeParameter], result: NativeResult) + + /// Escaping closure lowering using the protocol infrastructure. + /// This uses UpcallConversionStep for full support of optionals, arrays, custom types, etc. + indirect case escapingClosureLowering( + syntheticFunction: SyntheticClosureFunction, + closureName: String + ) indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) @@ -917,6 +957,62 @@ extension JNISwift2JavaGenerator { printer.print("}") return printer.finalize() + + case .escapingClosureLowering(let syntheticFunction, let closureName): + var printer = CodePrinter() + + 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" + + printer.print( + """ + { + guard let \(placeholder) else { + fatalError(\"\(placeholder) is null\") + } + + let closureContext_\(closureName)$ = JavaObjectHolder(object: \(placeholder), environment: environment) + + return \(closureHeader) + guard let env$ = try? JavaVirtualMachine.shared().environment() else { + fatalError(\"Failed to get JNI environment for escaping closure call\") + } + + let javaInterface$ = \(syntheticFunction.wrapJavaInterfaceName)(javaThis: closureContext_\(closureName)$.object!, environment: env$) + \(resultPrefix)\(isVoid ? resultConverted : "return \(resultConverted)") + } + }() + """ + ) + + 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(); + } + """ + ]) + } +} +