diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift new file mode 100644 index 000000000..5b213c748 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// 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 Dispatch + +/// Escaping closures whose parameters are jextracted types, optionals of them, and closure types +/// carrying @Sendable or @MainActor. +public final class Notifier { + private var handler: ((Item) -> Void)? + + public init() {} + + public func setHandler(_ handler: @escaping (Item) -> Void) { + self.handler = handler + } + + public func fire(id: String) { + handler?(Item(id: id, count: 1)) + } + + /// Mixed arity with an optional struct parameter. + public func check(_ cb: @escaping (String, Int64, Item?) -> Bool) -> Bool { + cb("a", 1, nil) && cb("b", 2, Item(id: "b", count: 2)) + } + + /// A @Sendable closure called on another thread: the upcall uses that thread's JNIEnv. + public func deliverInBackground(id: String, _ cb: @escaping @Sendable (Item) -> Void) { + let group = DispatchGroup() + group.enter() + DispatchQueue.global().async { + cb(Item(id: id, count: 1)) + group.leave() + } + group.wait() + } +} + +/// The shape of a view model's callbacks: @MainActor @Sendable closures taking structs. +public struct Callbacks: Sendable { + let onOpen: @MainActor @Sendable (Item) -> Void + let onClose: @MainActor @Sendable () -> Void + + public init( + onOpen: @escaping @MainActor @Sendable (Item) -> Void, + onClose: @escaping @MainActor @Sendable () -> Void + ) { + self.onOpen = onOpen + self.onClose = onClose + } +} + +@MainActor +public enum Screen { + public static func open(_ callbacks: Callbacks, id: String) { + callbacks.onOpen(Item(id: id, count: 1)) + callbacks.onClose() + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt new file mode 100644 index 000000000..b787b3382 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +package com.example.kotlin + +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +// Kotlin lambdas passed as Swift escaping closures. Functional-interface names follow +// `..`; Kotlin converts lambdas to them. +class ClosuresTest { + @Test + fun structParameter() { + val notifier = Notifier.init() + val seen = mutableListOf() + notifier.setHandler { item -> seen += item.id } + notifier.fire("a") + notifier.fire("b") + assertEquals(listOf("a", "b"), seen) + } + + @Test + fun mixedArityWithOptionalStruct() { + val calls = mutableListOf() + val ok = Notifier.init().check { s, i, item -> calls += "$s$i${item.map { it.id }.orElse("-")}"; true } + assertTrue(ok) + assertEquals(listOf("a1-", "b2b"), calls) + } + + @Test + fun sendableClosureCalledFromAnotherThread() { + val testThread = Thread.currentThread() + var calledOn: Thread? = null + var seenId = "" + Notifier.init().deliverInBackground("bg") { item -> + calledOn = Thread.currentThread() + seenId = item.id + } + assertEquals("bg", seenId) + assertNotEquals(testThread, calledOn) + } + + @Test + @EnabledOnOs(OS.MAC) // Screen is main-actor isolated, see MainActorTest + fun mainActorCallbacks() { + val events = mutableListOf() + val callbacks = Callbacks.init({ item -> events += "open ${item.id}" }, { events += "close" }) + Screen.open(callbacks, "7") + assertEquals(listOf("open 7", "close"), events) + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift index ad69fb03c..657014001 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/EscapingClosures.swift @@ -76,3 +76,17 @@ public func multipleEscapingClosures( onFailure(-1) } } + +public func deliverStruct( + len: Int64, + callback: @escaping @Sendable (MySwiftStruct) -> Void +) { + callback(MySwiftStruct(cap: len, len: len)) +} + +public func deliverOptionalStruct( + len: Int64?, + callback: @escaping (MySwiftStruct?) -> Int64 +) -> Int64 { + callback(len.map { MySwiftStruct(cap: $0, len: $0) }) +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java index e602e0ac2..ae120fd16 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/EscapingClosuresTest.java @@ -166,4 +166,17 @@ void testMultipleManagersWithDifferentClosures() { assertTrue(called2.get(), "Second callback should be called"); } } + + @Test + void testEscapingClosureWithStructParameter() { + AtomicLong received = new AtomicLong(-1); + MySwiftLibrary.deliverStruct(7, value -> received.set(value.getLen())); + assertEquals(7, received.get()); + } + + @Test + void testEscapingClosureWithOptionalStructParameter() { + assertEquals(5, MySwiftLibrary.deliverOptionalStruct(java.util.OptionalLong.of(5), value -> value.map(MySwiftStruct::getLen).orElse(-1L))); + assertEquals(-1, MySwiftLibrary.deliverOptionalStruct(java.util.OptionalLong.empty(), value -> value.map(MySwiftStruct::getLen).orElse(-1L))); + } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift index 6b6a4e729..586f3ebef 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -32,10 +32,87 @@ extension JNISwift2JavaGenerator { guard let closureTy = ty.syntheticClosureType else { continue } + // With Java callbacks enabled, wrap-java generates these classes for us. + if !config.effectiveEnableJavaCallbacks { + let fn = closureTy.functionType + for type in fn.parameters.map(\.type) + [fn.resultType] { + if let nominal = jextractedNominal(type) ?? optionalWrapped(type).flatMap(jextractedNominal) { + printMinimalJavaClassWrapper(&printer, nominal) + } + } + } printEscapingClosureWrapJavaInterface(&printer, closureTy) } } + /// A nominal type this module jextracts, which crosses into Java as its generated Java class. + private func jextractedNominal(_ type: SwiftType) -> SwiftNominalTypeDeclaration? { + guard case .nominal(let nominal) = type, nominal.asKnownType == nil, + nominal.nominalTypeDecl.moduleName == swiftModuleName + else { + return nil + } + return nominal.nominalTypeDecl + } + + private func optionalWrapped(_ type: SwiftType) -> SwiftType? { + guard case .nominal(let nominal) = type, case .optional(let wrapped) = nominal.asKnownType else { + return nil + } + return wrapped + } + + /// The Swift spelling of a closure parameter or result in the `@JavaInterface` wrapper. + /// Jextracted types are passed as their Java class, through its JavaKit wrapper. + private func wrapJavaTypeName(_ type: SwiftType, isResult: Bool) -> String { + if let nominal = jextractedNominal(type) { + return nominal.generatedJavaClassMacroName + (isResult ? "!" : "?") + } + if !isResult, let nominal = optionalWrapped(type).flatMap(jextractedNominal) { + return "JavaOptional<\(nominal.generatedJavaClassMacroName)>?" + } + return type.description + } + + /// Prints just enough of a JavaKit wrapper (`@JavaClass` + `wrapMemoryAddressUnsafe`) for the + /// generated Java class of a jextracted type, so closures can hand Swift values to Java without + /// running wrap-java. Parents of nested types are printed first; each type is printed once. + private func printMinimalJavaClassWrapper(_ printer: inout SwiftPrinter, _ decl: SwiftNominalTypeDeclaration) { + guard !printedMinimalJavaClassWrappers.contains(decl.qualifiedName) else { + return + } + printedMinimalJavaClassWrappers.insert(decl.qualifiedName) + + var names: [String] = [] + var current: SwiftNominalTypeDeclaration? = decl + while let c = current { + names.insert(c.name, at: 0) + current = c.parent + } + let binaryName = "\(javaPackage).\(names.joined(separator: "$"))" + let className = "Java\(decl.name)" + + var classDecl = + """ + @JavaClass("\(binaryName)") + open class \(className): JavaObject {} + """ + if let parent = decl.parent { + printMinimalJavaClassWrapper(&printer, parent) + classDecl = "extension \(parent.generatedJavaClassMacroName) {\n\(classDecl)\n}" + } + printer.print( + """ + \(classDecl) + extension JavaClass<\(decl.generatedJavaClassMacroName)> { + @JavaStaticMethod + public func wrapMemoryAddressUnsafe(_ selfPointer: Int64) -> \(decl.generatedJavaClassMacroName)! + } + """ + ) + printer.println() + } + private func printEscapingClosureWrapJavaInterface( _ printer: inout SwiftPrinter, _ closureTy: SyntheticEscapingClosureFunctionType, @@ -60,15 +137,14 @@ extension JNISwift2JavaGenerator { private func renderEscapingClosureApplySignature(_ type: SwiftFunctionType) -> String { let params = type.parameters.enumerated().map { idx, param -> String in let name = param.parameterName ?? "_\(idx)" - let typeName = param.type.description - return "_ \(name): \(typeName)" + return "_ \(name): \(wrapJavaTypeName(param.type, isResult: false))" } let paramList = "(\(params.joined(separator: .comma)))" if type.resultType.isVoid { return paramList } else { - return "\(paramList) -> \(type.resultType.description)" + return "\(paramList) -> \(wrapJavaTypeName(type.resultType, isResult: true))" } } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 6490586d7..f572570fb 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -369,7 +369,6 @@ extension JNISwift2JavaGenerator { methodName: String, parentName: SwiftQualifiedTypeName ) throws -> NativeParameter { - // @Sendable is not supported yet as "environment" is later captured inside the closure. if functionType.isEscaping { // For escaping closures we e need to create a Swift wrapper around // the passed down Java functional interface because we must keep it @@ -1733,20 +1732,26 @@ extension JNISwift2JavaGenerator { let javaInterfaceVar = "javaInterface_\(placeholder)$" - // Build upcall arguments using UpcallConversionStep conversions + // A @Sendable closure may run on any thread, but the wrapper holds the JNIEnv of the + // thread that created it. Re-wrap the Java object with the current thread's JNIEnv per call. + let callInterfaceVar = fn.isSendable ? "javaInterface_\(placeholder)$call" : javaInterfaceVar + + // Build upcall arguments using UpcallConversionStep conversions. Conversions may print + // statements (e.g. wrapping a Swift value in its Java class), which go in the closure body. + var argPrinter = SwiftPrinter() var upcallArguments: [String] = [] for (idx, conversion) in syntheticFunction.parameterConversions.enumerated() { - var argPrinter = SwiftPrinter() let paramName = parameterNames[idx] let converted = conversion.render(&argPrinter, paramName) upcallArguments.append(converted) } + let argumentPrefix = argPrinter.finalize() // 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 = SwiftPrinter() - let upcallExpr = "\(javaInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))" + let upcallExpr = "\(callInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))" let resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr) let resultPrefix = resultPrinter.finalize() @@ -1757,6 +1762,11 @@ extension JNISwift2JavaGenerator { ? "{" : "{ \(closureParameters) in" + let sendableRebind = + fn.isSendable + ? "let \(callInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(javaInterfaceVar).javaThis, environment: try! JavaVirtualMachine.shared().environment())\n" + : "" + // Construct the generated `@JavaInterface` wrap-java struct. // It will cause a new global ref on the javaThis, so no need for explicit global refs. // This object will be closed over by the closure we pass to Swift, and therefore keep alive the @@ -1767,9 +1777,9 @@ extension JNISwift2JavaGenerator { guard let \(placeholder) else { fatalError(\"\(placeholder) is null\") } - let \(javaInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(placeholder), environment: environment) + \(fn.isSendable ? "nonisolated(unsafe) " : "")let \(javaInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(placeholder), environment: environment) return \(closureHeader) - \(resultPrefix)\(isVoid ? resultConverted : "return \(resultConverted)") + \(sendableRebind)\(argumentPrefix)\(resultPrefix)\(isVoid ? resultConverted : "return \(resultConverted)") } }() """ diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index dbb6e23ab..6a1a23a41 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -44,6 +44,9 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { var thunkNameRegistry = ThunkNameRegistry() + /// Jextracted types that already have a minimal JavaKit class wrapper printed for closures. + var printedMinimalJavaClassWrappers: Set = [] + /// Accumulates every ``@_cdecl`` symbol name emitted during thunk printing. /// Written to a linker version script after generation when /// ``Configuration/linkerExportListOutput`` is set. diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift index bbfa23a63..6f73b99eb 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift @@ -392,8 +392,14 @@ extension SwiftFunctionSignature { self.thrownTypedError = accessorEffects?.thrownTypedError if isSet { + // A closure stored in a variable is always escaping, so its setter's `newValue` is too. + var newValueType = valueType + if case .function(var functionType) = valueType { + functionType.isEscaping = true + newValueType = .function(functionType) + } self.parameters = [ - SwiftParameter(convention: .byValue, parameterName: "newValue", type: valueType) + SwiftParameter(convention: .byValue, parameterName: "newValue", type: newValueType) ] self.result = .void } else { diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift index a7e8f43c5..cd4ab6a40 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift @@ -24,6 +24,10 @@ public struct SwiftFunctionType: Equatable { public var parameters: [SwiftParameter] public var resultType: SwiftType public var isEscaping: Bool = false + /// Whether the function type is marked `@Sendable`. + public var isSendable: Bool = false + /// Whether the function type is isolated to the main actor (`@MainActor`). + public var isMainActorIsolated: Bool = false public var effectSpecifiers: [SwiftEffectSpecifier] = [] @@ -58,7 +62,10 @@ extension SwiftFunctionType: CustomStringConvertible { case .c: "@convention(c) " case .swift: "" } - let escapingPrefix = isEscaping ? "@escaping " : "" + let escapingPrefix = + (isEscaping ? "@escaping " : "") + + (isSendable ? "@Sendable " : "") + + (isMainActorIsolated ? "@MainActor " : "") let throwsString = switch (isThrowing, thrownTypedError) { case (true, .some(let errorType)): " throws(\(errorType.description))" diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift index 6a9a89141..b2805cd76 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift @@ -379,38 +379,39 @@ extension SwiftType { self = .inlineArray(count: count, element: elementType) case .attributedType(let attributedType): - // Recognize "@convention(c)", "@convention(swift)", and "@escaping" attributes on function types. - // FIXME: This string matching is a horrible hack. - 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): - functionType.isEscaping = true - self = .function(functionType) - default: + // Recognize the attributes jextract understands on function types: + // `@escaping`, `@Sendable`, `@MainActor`, and `@convention(c|swift)`. + guard case .function(var functionType) = try SwiftType(attributedType.baseType, lookupContext: lookupContext) + else { + throw TypeTranslationError.unimplementedType(type) + } + var hasUnknownAttribute = false + for element in attributedType.attributes { + guard let attribute = element.as(AttributeSyntax.self) else { 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) + switch attribute.attributeName.trimmedDescription { + case "escaping": + functionType.isEscaping = true + case "Sendable": + functionType.isSendable = true + case "MainActor", "_Concurrency.MainActor": + functionType.isMainActorIsolated = true + case "convention": + switch attribute.arguments?.trimmedDescription { + case "c": functionType.convention = .c + case "swift": functionType.convention = .swift + default: throw TypeTranslationError.unimplementedType(type) } default: - throw TypeTranslationError.unimplementedType(type) + hasUnknownAttribute = true } } + // Other attributes were previously tolerated next to `@escaping`; keep that behavior. + if hasUnknownAttribute && !functionType.isEscaping { + throw TypeTranslationError.unimplementedType(type) + } + self = .function(functionType) case .functionType(let functionType): self = .function( diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md index cd3a43027..dbeb57e0a 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -895,6 +895,11 @@ in both modes. `@escaping` closures with `Void` return or primitive arguments/results are supported. The closure is stored on the Swift side and can be triggered multiple times. +In JNI mode, escaping closure parameters may also be jextracted types (structs, classes, +enums) or optionals of them. Java receives the generated Java class, or a +`java.util.Optional` of it. A closure stored in a property is escaping, so its setter +takes a Java functional interface as well. + @TabNavigator { @Tab("Swift") { @Snippet(path: "Snippets/EscapingClosuresSwift.swift", slice: "escapingClosureDefinition") @@ -931,5 +936,6 @@ Swift types conforming to `Sendable` are surfaced on the Java side with the callers that the wrapped Swift value is safe to share across threads. This translation is applied by both JNI and FFM modes. -> Note: `@Sendable` as a closure-parameter attribute is not yet supported; the -> environment captured inside the closure would need special handling. +In JNI mode, closure types may carry `@Sendable` and `@MainActor`. A `@Sendable` +closure can be invoked on any thread, so each call into Java uses the `JNIEnv` of the +calling thread (attaching it if needed) rather than the one it was created with. diff --git a/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureJExtractedTypesTests.swift b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureJExtractedTypesTests.swift new file mode 100644 index 000000000..4f63e8337 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureJExtractedTypesTests.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +/// Escaping closures whose parameters are jextracted types, and closure types +/// carrying `@Sendable` / `@MainActor`. +@Suite +struct JNIEscapingClosureJExtractedTypesTests { + let itemSource = + """ + public struct Item { + public var id: String + } + """ + + @Test + func structParameter_interfaceUsesJavaClass() throws { + try assertOutput( + input: itemSource + "\npublic func setHandler(_ handler: @escaping (Item) -> Void) {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$setHandler$handler") + public struct JavaSwiftModule_setHandler_handler { + @JavaMethod + public func apply(_ _0: JavaItem?) + } + """ + ] + ) + } + + @Test + func structParameter_minimalJavaClassWrapperWithoutCallbacks() throws { + try assertOutput( + input: itemSource + "\npublic func setHandler(_ handler: @escaping (Item) -> Void) {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaClass("com.example.swift.Item") + open class JavaItem: JavaObject {} + """, + """ + extension JavaClass { + @JavaStaticMethod + public func wrapMemoryAddressUnsafe(_ selfPointer: Int64) -> JavaItem! + } + """, + ] + ) + } + + @Test + func structParameter_conversionIsInsideTheClosure() throws { + try assertOutput( + input: itemSource + "\npublic func setHandler(_ handler: @escaping (Item) -> Void) {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + return { _0 in + let _0Class = try! JavaClass(environment: JavaVirtualMachine.shared().environment()) + let _0Pointer = UnsafeMutablePointer.allocate(capacity: 1) + _0Pointer.initialize(to: _0) + javaInterface_handler$.apply(_0Class.wrapMemoryAddressUnsafe(Int64(Int(bitPattern: _0Pointer)))) + } + """ + ] + ) + } + + @Test + func optionalStructParameter_isJavaOptional() throws { + try assertOutput( + input: itemSource + "\npublic func check(_ cb: @escaping (String, Item?) -> Bool) {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$check$cb") + public struct JavaSwiftModule_check_cb { + @JavaMethod + public func apply(_ _0: String, _ _1: JavaOptional?) -> Bool + } + """ + ] + ) + } + + @Test + func sendableMainActorClosure_isTranslated() throws { + try assertOutput( + input: itemSource + "\npublic func onOpen(_ cb: @escaping @MainActor @Sendable (Item) -> Void) {}", + .jni, + .java, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + /** Corresponds to the Swift closure parameter of type {@code @escaping @Sendable @MainActor (Item) -> Void}. */ + @FunctionalInterface + public interface cb { + void apply(Item _0); + } + """ + ] + ) + } + + @Test + func sendableClosure_usesTheCallingThreadsEnvironment() throws { + try assertOutput( + input: "public func deliver(_ cb: @escaping @Sendable (Int64) -> Void) {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + nonisolated(unsafe) let javaInterface_cb$ = JavaSwiftModule_deliver_cb(javaThis: cb, environment: environment) + """, + """ + let javaInterface_cb$call = JavaSwiftModule_deliver_cb(javaThis: javaInterface_cb$.javaThis, environment: try! JavaVirtualMachine.shared().environment()) + """, + ] + ) + } + + @Test + func closureProperty_setterIsEscaping() throws { + try assertOutput( + input: + """ + public struct Callbacks { + public var onClose: @MainActor @Sendable () -> Void + } + """, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.Callbacks$setOnClose$newValue") + """ + ] + ) + } +}