From 7f81ac0a95c7c9b4eeb4eecf0aebe6a666eae903 Mon Sep 17 00:00:00 2001 From: Gregorio Gevartosky Torrezan Date: Wed, 10 Dec 2025 09:21:15 -0400 Subject: [PATCH] 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 | 133 ++++++++++++++ 7 files changed, 502 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..0db44d886 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift @@ -0,0 +1,133 @@ +//===----------------------------------------------------------------------===// +// +// 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: [ + """ + // Create GlobalRef for escaping closure + let globalRef_callback$ = environment.interface.NewGlobalRef(environment, callback)! + """ + ] + ) + } + + @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(); + } + """ + ]) + } +} +