diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift index fdb0ddd28..6a3af1c91 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Closures.swift @@ -19,8 +19,20 @@ import Dispatch public final class Notifier { private var handler: ((Item) -> Void)? + /// An optional closure property: a Kotlin property of the setter's functional interface. + public var onItem: ((Item) -> Void)? + public init() {} + public func emit(id: String) { + onItem?(Item(id: id, count: 1)) + } + + /// A closure returned to Kotlin. + public func makeFormatter(prefix: String) -> (Item) -> String { + { "\(prefix)\($0.id)" } + } + public func setHandler(_ handler: @escaping (Item) -> Void) { self.handler = handler } diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt index 4e0964bd5..19d4da448 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ClosuresTest.kt @@ -19,6 +19,7 @@ import org.junit.jupiter.api.condition.OS import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertNull import kotlin.test.assertTrue // Kotlin lambdas passed as Swift escaping closures. Functional-interface names follow @@ -49,6 +50,25 @@ class ClosuresTest { assertEquals(2L, out.count) } + @Test + fun optionalClosureProperty() { + val notifier = Notifier.init() + val seen = mutableListOf() + assertNull(notifier.onItem) + notifier.onItem = { item -> seen += item.id } + notifier.emit("a") + notifier.onItem?.apply(Item.init("b", 1)) // the Swift closure, calling the lambda + notifier.onItem = null + notifier.emit("c") + assertEquals(listOf("a", "b"), seen) + } + + @Test + fun returnedClosure() { + val format = Notifier.init().makeFormatter("#") + assertEquals(listOf("#1", "#2"), listOf("1", "2").map { format.apply(Item.init(it, 1)) }) + } + @Test fun sendableClosureCalledFromAnotherThread() { val testThread = Thread.currentThread() diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClosureResults.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClosureResults.swift new file mode 100644 index 000000000..f5669a0e4 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ClosureResults.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Closures returned to Java, and an optional closure property. +public final class ClosureFactory { + public var onStruct: ((MySwiftStruct) -> Void)? + + public init() {} + + public func makeAdder(_ base: Int64) -> (Int64) -> Int64 { + { base + $0 } + } + + public func makeDescriber() -> (MySwiftStruct, String) -> String { + { "\($1)\($0.getCapacity())" } + } + + public func send(_ value: MySwiftStruct) { + onStruct?(value) + } + + /// How many closures from `makeTracked()` were released. + nonisolated(unsafe) public static var releasedClosures: Int64 = 0 + + /// A closure holding an object that counts its release. + public static func makeTracked() -> () -> Int64 { + let tracker = ReleaseTracker() + return { tracker.value } + } +} + +final class ReleaseTracker { + let value: Int64 = 1 + + deinit { + ClosureFactory.releasedClosures += 1 + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClosureResultsTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClosureResultsTest.java new file mode 100644 index 000000000..eb3b3ff8f --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ClosureResultsTest.java @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// +// 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.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class ClosureResultsTest { + @Test + void returnedPrimitiveClosure() { + try (var arena = SwiftArena.ofConfined()) { + ClosureFactory.makeAdder.result add = ClosureFactory.init(arena).makeAdder(10); + assertEquals(15, add.apply(5)); + long sum = 0; + for (int i = 0; i < 1000; i++) { + sum += add.apply(i); + } + assertEquals(10 * 1000 + 999 * 1000 / 2, sum); + } + } + + @Test + void returnedStructClosure() { + try (var arena = SwiftArena.ofConfined()) { + var describe = ClosureFactory.init(arena).makeDescriber(); + assertEquals("cap: 3", describe.apply(MySwiftStruct.init(3, 1, arena), "cap: ")); + } + } + + @Test + void droppedClosureIsReleased() throws InterruptedException { + long before = ClosureFactory.getReleasedClosures(); + for (int i = 0; i < 100; i++) { + assertEquals(1, ClosureFactory.makeTracked().apply()); + } + long deadline = System.currentTimeMillis() + 10_000; + while (ClosureFactory.getReleasedClosures() == before && System.currentTimeMillis() < deadline) { + System.gc(); + Thread.sleep(10); + } + assertTrue(ClosureFactory.getReleasedClosures() > before); + } + + @Test + void optionalClosureProperty() { + try (var arena = SwiftArena.ofConfined()) { + ClosureFactory factory = ClosureFactory.init(arena); + assertTrue(factory.getOnStruct().isEmpty()); + + List seen = new ArrayList<>(); + factory.setOnStruct(Optional.of(value -> seen.add(value.getCapacity()))); + factory.send(MySwiftStruct.init(1, 0, arena)); + // The getter returns the Swift closure, which calls the Java lambda set above. + factory.getOnStruct().get().apply(MySwiftStruct.init(2, 0, arena)); + factory.setOnStruct(Optional.empty()); + factory.send(MySwiftStruct.init(3, 0, arena)); + assertEquals(List.of(1L, 2L), seen); + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 1345bb37a..25627d2d2 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -974,19 +974,21 @@ extension JNISwift2JavaGenerator { { return } - // Overloads of a method share its helper class; the first, with every parameter, prints it. - guard printedJavaClosureHelperClasses.insert("\(translated.parentName).\(translated.name)").inserted else { + // Overloads of a method share its helper class, and so do a closure property's getter and setter; the + // first one prints it. + let className = translated.closureHelperClassName + guard printedJavaClosureHelperClasses.insert("\(translated.parentName).\(className)").inserted else { return } printer.printBraceBlock( """ - public static class \(translated.name) + public static class \(className) """ ) { printer in // Only holds interfaces. Without a public constructor, Kotlin's `Type.method()` can't also mean // `Type.method` constructed, when the method takes no arguments. - printer.print("private \(translated.name)() {}") + printer.print("private \(className)() {}") for functionType in translated.functionTypes { if KnownJavaFunctionalInterface.find(functionType.swiftType) != nil { continue diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index fbae25922..30446963c 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -236,6 +236,18 @@ extension JNISwift2JavaGenerator { } resultSignature.result = SwiftResult(convention: .direct, type: streamElementType) } + // A returned closure crosses as the handle of a Swift closure; Java wraps it in a lambda, below. + let closureResult = Self.closureType(decl.functionSignature.result.type).map { closureType, isOptional in + var closureType = closureType + closureType.isEscaping = true + return (closureType, isOptional) + } + if closureResult != nil { + if decl.functionSignature.isAsync || decl.functionSignature.isIsolated || decl.functionSignature.isImplicitlyAsync { + throw JavaTranslationError.unsupportedSwiftType(decl.functionSignature.result.type) + } + resultSignature.result = SwiftResult(convention: .direct, type: knownTypes.int64) + } // Swift -> Java var translatedFunctionSignature = try self.translate( @@ -265,8 +277,8 @@ extension JNISwift2JavaGenerator { for (idx, param) in decl.functionSignature.parameters.enumerated() { let parameterName = param.parameterName ?? "_\(idx)" - switch param.type { - case .function(let funcTy): + switch Self.closureType(param.type) { + case .some((let funcTy, _)): var translatedClosure = try translateFunctionType( name: parameterName, swiftType: funcTy, @@ -286,11 +298,48 @@ extension JNISwift2JavaGenerator { nativeFunctionSignature.parameters[idx].syntheticClosure } funcTypes.append(translatedClosure) - default: + case .none: break } } + var closureHelperClassName = javaName + if let (closureType, isOptional) = closureResult { + // A getter returns the closure type its setter takes, so Kotlin sees one property. + var interfaceName = "result" + if decl.apiKind == .getter, Self.hasSetter(decl, config: config) { + closureHelperClassName = "set\(decl.name.firstCharacterUppercased)" + interfaceName = "newValue" + } + let translatedClosure = try translateFunctionType( + name: interfaceName, + swiftType: closureType, + parentName: parentName, + ) + funcTypes.append(translatedClosure) + let interfaceType = JavaType.class( + package: javaPackage, + name: String.javaQualifiedName(parentName.fullName, closureHelperClassName, interfaceName) + ) + let nullable = isOptional && config.effectiveOptionalsAsNullable + translatedFunctionSignature.result = TranslatedResult( + javaType: isOptional && !nullable ? .optional(interfaceType) : interfaceType, + nativeJavaType: .long, + outParameters: [], + conversion: .swiftClosure( + .placeholder, + interfaceType: interfaceType, + parameters: translatedClosure.parameters.map(\.parameter.type.javaType), + result: translatedClosure.result.javaType, + optional: isOptional ? (nullable ? .nullable : .javaOptional) : nil + ) + ) + nativeFunctionSignature.result = try nativeTranslation.translateSwiftClosureResult( + closureType, + isOptional: isOptional + ) + } + if streamElementType != nil { try self.convertToAsyncStream( translatedFunctionSignature: &translatedFunctionSignature, @@ -332,6 +381,7 @@ extension JNISwift2JavaGenerator { isImplicitlyAsync: decl.functionSignature.isImplicitlyAsync, nativeFunctionName: "$\(javaName)", parentName: parentName, + closureHelperClassName: closureHelperClassName, functionTypes: funcTypes, translatedFunctionSignature: translatedFunctionSignature, nativeFunctionSignature: nativeFunctionSignature, @@ -518,6 +568,24 @@ extension JNISwift2JavaGenerator { if let knownType = nominalType.asKnownType { switch knownType { case .optional(let wrapped): + if let (functionType, _) = Self.closureType(swiftType) { + let closure = translateFunctionParameter( + swiftType: .function(functionType), + functionType: functionType, + parameterName: parameterName, + methodName: methodName, + parentName: parentName, + parameterAnnotations: parameterAnnotations + ) + return TranslatedParameter( + parameter: JavaParameter( + name: parameterName, + type: .optional(closure.parameter.type.javaType), + annotations: parameterAnnotations + ), + conversion: .method(.placeholder, function: "orElse", arguments: [.constant("null")]) + ) + } return try translateOptionalParameter( wrappedType: wrapped, parameterName: parameterName, @@ -943,6 +1011,34 @@ extension JNISwift2JavaGenerator { ) } + /// The closure type of a closure or optional closure. An optional closure is escaping: it can be stored, and + /// only escaping closure types get their own functional interface. + static func closureType(_ type: SwiftType) -> (SwiftFunctionType, isOptional: Bool)? { + switch type { + case .function(let functionType): + return (functionType, false) + case .nominal(let nominal): + guard case .optional(.function(var functionType)) = nominal.asKnownType else { + return nil + } + functionType.isEscaping = true + return (functionType, true) + default: + return nil + } + } + + /// Whether the property of this getter also has a setter. + static func hasSetter(_ getter: ExtractedFunc, config: Configuration) -> Bool { + guard let variable = getter.swiftDecl.as(VariableDeclSyntax.self), let binding = variable.bindings.first else { + return false + } + return variable.supportedAccessorKinds( + binding: binding, + minimumAccessLevel: config.effectiveMinimumInputAccessLevelMode + ).contains(.set) + } + static func isOptional(_ type: SwiftType) -> Bool { if case .nominal(let nominal) = type, nominal.nominalTypeDecl.knownTypeKind == .optional { return true @@ -1980,6 +2076,9 @@ extension JNISwift2JavaGenerator { /// The name of the Java parent scope this function is declared in var parentName: SwiftQualifiedTypeName + /// The class holding `functionTypes`: the Java method name, or a getter's setter name. + var closureHelperClassName: String + /// Functional interfaces required for the Java method. var functionTypes: [TranslatedFunctionType] @@ -2204,6 +2303,21 @@ extension JNISwift2JavaGenerator { indirect case placeToVar(JavaNativeConversionStep, name: String, type: JavaType? = nil) + /// A lambda of `interfaceType` over the Swift closure whose handle is the inner value; its arguments + /// and result are boxed through `SwiftClosure.call`. Optional when the handle may be 0, for `nil`. + indirect case swiftClosure( + JavaNativeConversionStep, + interfaceType: JavaType, + parameters: [JavaType], + result: JavaType, + optional: OptionalForm? + ) + + enum OptionalForm { + case nullable + case javaOptional + } + /// `Arrays.stream(args)` static func arraysStream(_ argument: JavaNativeConversionStep) -> JavaNativeConversionStep { .method(.constant("Arrays"), function: "stream", arguments: [argument]) @@ -2380,6 +2494,29 @@ extension JNISwift2JavaGenerator { let type = type?.description ?? "var" printer.print("\(type) \(name) = \(inner);") return name + + case .swiftClosure(let inner, let interfaceType, let parameters, let result, let optional): + let handle = inner.render(&printer, placeholder) + let closureClass = "org.swift.swiftkit.core.SwiftClosure" + if optional == nil { + printer.print("\(closureClass) closure$ = new \(closureClass)(\(handle));") + } else { + printer.print("long closure$handle = \(handle);") + printer.print("\(closureClass) closure$ = closure$handle == 0 ? null : new \(closureClass)(closure$handle);") + } + let names = parameters.indices.map { "closure$\($0)" } + let lambdaParameters = zip(parameters, names).map { "\($0) \($1)" }.joined(separator: .comma) + let call = "closure$.call(\(names.joined(separator: .comma)))" + let body = result == .void ? call : "(\(result)) \(call)" + let lambda = "(\(interfaceType)) (\(lambdaParameters)) -> \(body)" + switch optional { + case nil: + return lambda + case .nullable: + return "closure$ == null ? null : \(lambda)" + case .javaOptional: + return "java.util.Optional.ofNullable(closure$ == null ? null : \(lambda))" + } } } @@ -2448,6 +2585,9 @@ extension JNISwift2JavaGenerator { case .placeToVar(let inner, _, _): return inner.requiresSwiftArena + + case .swiftClosure(let inner, _, _, _, _): + return inner.requiresSwiftArena } } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 9069d6dbe..b0cfc3c97 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -133,6 +133,22 @@ extension JNISwift2JavaGenerator { if let knownType = nominalType.asKnownType { switch knownType { case .optional(let wrapped): + if let (functionType, _) = JavaTranslation.closureType(type) { + let closure = try translateFunctionParameter( + swiftType: .function(functionType), + functionType: functionType, + parameterName: parameterName, + methodName: methodName, + parentName: parentName + ) + return NativeParameter( + parameters: closure.parameters, + conversion: .nilOrClosure(closure.conversion), + indirectConversion: nil, + conversionCheck: nil, + syntheticClosure: closure.syntheticClosure + ) + } return try translateOptionalParameter( wrappedType: wrapped, parameterName: parameterName @@ -1295,6 +1311,21 @@ extension JNISwift2JavaGenerator { ) } + /// A returned closure, as the handle of a Swift closure Java calls with boxed arguments; 0 for `nil`. + func translateSwiftClosureResult(_ functionType: SwiftFunctionType, isOptional: Bool) throws -> NativeResult { + // Java calls it synchronously, on any thread. + if functionType.isAsync || functionType.isThrowing || functionType.isMainActorIsolated { + throw JavaTranslationError.unsupportedSwiftType(.function(functionType)) + } + let argumentBridges = try functionType.parameters.map { try bridgeTypeName(for: $0.type) } + let resultBridge = functionType.resultType.isVoid ? nil : try bridgeTypeName(for: functionType.resultType) + return NativeResult( + javaType: .long, + conversion: .swiftClosure(argumentBridges: argumentBridges, resultBridge: resultBridge, isOptional: isOptional), + outParameters: [] + ) + } + private func bridgeTypeName(for swiftType: SwiftType) throws -> String { switch swiftType { case .nominal(let nominalType): @@ -1455,6 +1486,13 @@ extension JNISwift2JavaGenerator { indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) + /// `nil` for a null Java object, otherwise the inner conversion: an optional escaping closure. + indirect case nilOrClosure(NativeSwiftConversionStep) + + /// The handle of a new Swift closure calling the closure value, which converts the boxed Java arguments + /// and result with these `JobjectBridge`s (no result bridge for `Void`). + case swiftClosure(argumentBridges: [String], resultBridge: String?, isOptional: Bool) + indirect case optionalLowering(NativeSwiftConversionStep, discriminatorName: String, valueName: String) indirect case optionalChain(NativeSwiftConversionStep) @@ -1870,6 +1908,26 @@ extension JNISwift2JavaGenerator { let inner = inner.render(&printer, placeholder) return "\(wrapperName)(javaThis: \(inner), environment: environment)" + case .nilOrClosure(let inner): + return "\(placeholder) == nil ? nil : \(inner.render(&printer, placeholder))" + + case .swiftClosure(let argumentBridges, let resultBridge, let isOptional): + printer.print("let closure$ = \(placeholder)") + let arguments = argumentBridges.enumerated().map { index, bridge in + "\(bridge).fromJavaObject(_swiftClosureArgument(arguments$, \(index), in: environment), in: environment)" + } + let call = "closure$(\(arguments.joined(separator: .comma)))" + let body = + if let resultBridge { + ["return \(resultBridge).toJavaObject(\(call), in: environment)"] + } else { + [call, "return nil"] + } + let make = + (["_makeSwiftClosure { arguments$, environment in"] + body.map { " \($0)" } + ["}"]) + .joined(separator: "\n") + return isOptional ? "closure$.map { closure$ in \(make) } ?? 0" : make + case .optionalLowering(let valueConversion, let discriminatorName, let valueName): let value = valueConversion.render(&printer, valueName) return "\(discriminatorName) == 1 ? \(value) : nil" diff --git a/Sources/JavaStdlib/JavaUtilFunction/generated/JavaOptional+Extensions.swift b/Sources/JavaStdlib/JavaUtilFunction/generated/JavaOptional+Extensions.swift index adde97ee3..42b6638a1 100644 --- a/Sources/JavaStdlib/JavaUtilFunction/generated/JavaOptional+Extensions.swift +++ b/Sources/JavaStdlib/JavaUtilFunction/generated/JavaOptional+Extensions.swift @@ -13,7 +13,7 @@ extension JavaOptional { public func filter(_ arg0: JavaPredicate?) -> JavaOptional! public func filterOptional(_ arg0: JavaPredicate?) -> T? { - Optional(javaOptional: filter(arg0)) + Optional(javaOptional: self.filter(arg0)) } /// Java method `flatMap`. @@ -26,7 +26,7 @@ extension JavaOptional { public func flatMap(_ arg0: JavaFunction>?) -> JavaOptional! public func flatMapOptional(_ arg0: JavaFunction>?) -> U? { - Optional(javaOptional: flatMap(arg0)) + Optional(javaOptional: self.flatMap(arg0)) } /// Java method `ifPresent`. @@ -48,7 +48,7 @@ extension JavaOptional { public func map(_ arg0: JavaFunction?) -> JavaOptional! public func mapOptional(_ arg0: JavaFunction?) -> U? { - Optional(javaOptional: map(arg0)) + Optional(javaOptional: self.map(arg0)) } /// Java method `or`. @@ -61,7 +61,7 @@ extension JavaOptional { public func or(_ arg0: JavaSupplier>?) -> JavaOptional! public func orOptional(_ arg0: JavaSupplier>?) -> T? { - Optional(javaOptional: or(arg0)) + Optional(javaOptional: self.or(arg0)) } /// Java method `orElseGet`. diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift index b2805cd76..40f37f861 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift @@ -513,6 +513,13 @@ extension SwiftType { self = knownTypes.optionalSugar(try SwiftType(optionalType.wrappedType, lookupContext: lookupContext)) case .tupleType(let tupleType): + // `(T)` is `T` in parentheses, not a tuple: `((Item) -> Void)?` is an optional closure. + if tupleType.elements.count == 1, let element = tupleType.elements.first, element.firstName == nil, + element.ellipsis == nil + { + self = try SwiftType(element.type, lookupContext: lookupContext) + return + } self = try .tuple( tupleType.elements.map { element in SwiftTupleElement( diff --git a/Sources/SwiftJava/generated/JavaDouble.swift b/Sources/SwiftJava/generated/JavaDouble.swift index b395a6510..f5077966f 100644 --- a/Sources/SwiftJava/generated/JavaDouble.swift +++ b/Sources/SwiftJava/generated/JavaDouble.swift @@ -229,7 +229,7 @@ open class JavaDouble: JavaNumber { open func describeConstable() -> JavaOptional! open func describeConstableOptional() -> JavaDouble? { - Optional(javaOptional: describeConstable()) + Optional(javaOptional: self.describeConstable()) } /// Java method `doubleValue`. diff --git a/Sources/SwiftJava/generated/JavaFloat.swift b/Sources/SwiftJava/generated/JavaFloat.swift index 98b372b99..148e2a166 100644 --- a/Sources/SwiftJava/generated/JavaFloat.swift +++ b/Sources/SwiftJava/generated/JavaFloat.swift @@ -251,7 +251,7 @@ open class JavaFloat: JavaNumber { open func describeConstable() -> JavaOptional! open func describeConstableOptional() -> JavaFloat? { - Optional(javaOptional: describeConstable()) + Optional(javaOptional: self.describeConstable()) } /// Java method `doubleValue`. diff --git a/Sources/SwiftJava/generated/JavaInteger.swift b/Sources/SwiftJava/generated/JavaInteger.swift index c74f1379b..a14b2f335 100644 --- a/Sources/SwiftJava/generated/JavaInteger.swift +++ b/Sources/SwiftJava/generated/JavaInteger.swift @@ -433,7 +433,7 @@ open class JavaInteger: JavaNumber { open func describeConstable() -> JavaOptional! open func describeConstableOptional() -> JavaInteger? { - Optional(javaOptional: describeConstable()) + Optional(javaOptional: self.describeConstable()) } /// Java method `doubleValue`. diff --git a/Sources/SwiftJava/generated/JavaLong.swift b/Sources/SwiftJava/generated/JavaLong.swift index 40a10404d..94441b986 100644 --- a/Sources/SwiftJava/generated/JavaLong.swift +++ b/Sources/SwiftJava/generated/JavaLong.swift @@ -424,7 +424,7 @@ open class JavaLong: JavaNumber { open func describeConstable() -> JavaOptional! open func describeConstableOptional() -> JavaLong? { - Optional(javaOptional: describeConstable()) + Optional(javaOptional: self.describeConstable()) } /// Java method `doubleValue`. diff --git a/Sources/SwiftJava/generated/JavaOptional.swift b/Sources/SwiftJava/generated/JavaOptional.swift index 6d4f558b8..99c23c2b9 100644 --- a/Sources/SwiftJava/generated/JavaOptional.swift +++ b/Sources/SwiftJava/generated/JavaOptional.swift @@ -12,7 +12,7 @@ extension JavaClass { public func empty() -> JavaOptional! where ObjectType == JavaOptional public func emptyOptional() -> T? where ObjectType == JavaOptional { - Optional(javaOptional: empty()) + Optional(javaOptional: self.empty()) } /// Java method `of`. @@ -25,7 +25,7 @@ extension JavaClass { public func of(_ arg0: T?) -> JavaOptional! where ObjectType == JavaOptional public func ofOptional(_ arg0: T?) -> T? where ObjectType == JavaOptional { - Optional(javaOptional: of(arg0)) + Optional(javaOptional: self.of(arg0)) } /// Java method `ofNullable`. @@ -38,7 +38,7 @@ extension JavaClass { public func ofNullable(_ arg0: T?) -> JavaOptional! where ObjectType == JavaOptional public func ofNullableOptional(_ arg0: T?) -> T? where ObjectType == JavaOptional { - Optional(javaOptional: ofNullable(arg0)) + Optional(javaOptional: self.ofNullable(arg0)) } } @JavaClass("java.util.Optional") diff --git a/Sources/SwiftJava/generated/JavaOptionalDouble.swift b/Sources/SwiftJava/generated/JavaOptionalDouble.swift index 89320916c..59ecf9082 100644 --- a/Sources/SwiftJava/generated/JavaOptionalDouble.swift +++ b/Sources/SwiftJava/generated/JavaOptionalDouble.swift @@ -12,7 +12,7 @@ extension JavaClass { public func empty() -> JavaOptionalDouble! public func emptyOptional() -> Double? { - Optional(javaOptional: empty()) + Optional(javaOptional: self.empty()) } /// Java method `of`. @@ -25,7 +25,7 @@ extension JavaClass { public func of(_ arg0: Double) -> JavaOptionalDouble! public func ofOptional(_ arg0: Double) -> Double? { - Optional(javaOptional: of(arg0)) + Optional(javaOptional: self.of(arg0)) } } @JavaClass("java.util.OptionalDouble") diff --git a/Sources/SwiftJava/generated/JavaOptionalInt.swift b/Sources/SwiftJava/generated/JavaOptionalInt.swift index 4e275afd9..74e344288 100644 --- a/Sources/SwiftJava/generated/JavaOptionalInt.swift +++ b/Sources/SwiftJava/generated/JavaOptionalInt.swift @@ -12,7 +12,7 @@ extension JavaClass { public func empty() -> JavaOptionalInt! public func emptyOptional() -> Int32? { - Optional(javaOptional: empty()) + Optional(javaOptional: self.empty()) } /// Java method `of`. @@ -25,7 +25,7 @@ extension JavaClass { public func of(_ arg0: Int32) -> JavaOptionalInt! public func ofOptional(_ arg0: Int32) -> Int32? { - Optional(javaOptional: of(arg0)) + Optional(javaOptional: self.of(arg0)) } } @JavaClass("java.util.OptionalInt") diff --git a/Sources/SwiftJava/generated/JavaOptionalLong.swift b/Sources/SwiftJava/generated/JavaOptionalLong.swift index de64b0fe6..98761155f 100644 --- a/Sources/SwiftJava/generated/JavaOptionalLong.swift +++ b/Sources/SwiftJava/generated/JavaOptionalLong.swift @@ -12,7 +12,7 @@ extension JavaClass { public func empty() -> JavaOptionalLong! public func emptyOptional() -> Int64? { - Optional(javaOptional: empty()) + Optional(javaOptional: self.empty()) } /// Java method `of`. @@ -25,7 +25,7 @@ extension JavaClass { public func of(_ arg0: Int64) -> JavaOptionalLong! public func ofOptional(_ arg0: Int64) -> Int64? { - Optional(javaOptional: of(arg0)) + Optional(javaOptional: self.of(arg0)) } } @JavaClass("java.util.OptionalLong") diff --git a/Sources/SwiftJava/generated/JavaString.swift b/Sources/SwiftJava/generated/JavaString.swift index e9d2f7d8e..dd00c4759 100644 --- a/Sources/SwiftJava/generated/JavaString.swift +++ b/Sources/SwiftJava/generated/JavaString.swift @@ -317,7 +317,7 @@ open class JavaString: JavaObject { open func describeConstable() -> JavaOptional! open func describeConstableOptional() -> JavaString? { - Optional(javaOptional: describeConstable()) + Optional(javaOptional: self.describeConstable()) } /// Java method `isEmpty`. diff --git a/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift b/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift new file mode 100644 index 000000000..60ddd70dd --- /dev/null +++ b/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftJava +import SwiftJavaJNICore + +/// A Swift closure held by a Java `org.swift.swiftkit.core.SwiftClosure`. +final class SwiftClosureBox { + let call: (_ arguments: jobjectArray?, _ environment: JNIEnvironment) -> jobject? + + init(_ call: @escaping (_ arguments: jobjectArray?, _ environment: JNIEnvironment) -> jobject?) { + self.call = call + } +} + +/// The handle of a new Swift closure for a Java `SwiftClosure`, which takes ownership of it. +/// +/// Used by generated code for functions returning closures: `call` converts the boxed Java +/// arguments, calls the closure and converts its result. +public func _makeSwiftClosure( + _ call: @escaping (_ arguments: jobjectArray?, _ environment: JNIEnvironment) -> jobject? +) -> jlong { + Int64(Int(bitPattern: Unmanaged.passRetained(SwiftClosureBox(call)).toOpaque())) +} + +/// The argument at `index` of a `SwiftClosure` call. +public func _swiftClosureArgument(_ arguments: jobjectArray?, _ index: Int32, in environment: JNIEnvironment) -> jobject? { + environment.interface.GetObjectArrayElement(environment, arguments, index) +} + +@_cdecl("Java_org_swift_swiftkit_core_SwiftClosure__00024call") +public func Java_org_swift_swiftkit_core_SwiftClosure__00024call( + environment: UnsafeMutablePointer!, + thisClass: jclass, + handle: jlong, + arguments: jobjectArray? +) -> jobject? { + let box = Unmanaged.fromOpaque(UnsafeRawPointer(bitPattern: Int(handle))!).takeUnretainedValue() + return box.call(arguments, environment) +} + +@_cdecl("Java_org_swift_swiftkit_core_SwiftClosure__00024release") +public func Java_org_swift_swiftkit_core_SwiftClosure__00024release( + environment: UnsafeMutablePointer!, + thisClass: jclass, + handle: jlong +) { + Unmanaged.fromOpaque(UnsafeRawPointer(bitPattern: Int(handle))!).release() +} diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index 125def18b..78e55fb2a 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -1174,8 +1174,10 @@ extension JavaClassTranslator { } let resultOptional: String = resultType.optionalWrappedType() ?? resultType + // `self.`: a nested type of the same name, such as jextract's helper class for the method's closure + // types, would make the bare call ambiguous. let baseBody: ExprSyntax = - "\(raw: javaMethod.throwsCheckedException ? "try " : "")\(raw: swiftMethodName)(\(raw: parameters.map(\.passedArg).joined(separator: ", ")))" + "\(raw: javaMethod.throwsCheckedException ? "try " : "")self.\(raw: swiftMethodName)(\(raw: parameters.map(\.passedArg).joined(separator: ", ")))" let body: ExprSyntax = if resultType.optionalWrappedType() != nil { "Optional(javaOptional: \(baseBody))" diff --git a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftClosure.java b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftClosure.java new file mode 100644 index 000000000..7f9bf94b1 --- /dev/null +++ b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftClosure.java @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// 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 org.swift.swiftkit.core; + +import org.swift.swiftkit.core.ref.SwiftCleaner; + +/** + * A Swift closure handed to Java, such as one a Swift function returns. + * + *

Generated code wraps it in a lambda of the closure's functional interface, which converts its + * arguments and result. The Swift closure is released when this object is garbage collected. + */ +public final class SwiftClosure { + private static final SwiftCleaner CLEANER = SwiftCleaner.create(runnable -> { + Thread thread = new Thread(runnable, "SwiftClosureCleanerThread"); + thread.setDaemon(true); + return thread; + }); + + private final long handle; + + /** Takes ownership of the Swift closure at {@code handle}. */ + public SwiftClosure(long handle) { + this.handle = handle; + CLEANER.register(this, () -> $release(handle)); + } + + /** Calls the Swift closure. Arguments and the result are boxed Java values. */ + public Object call(Object... arguments) { + return $call(handle, arguments); + } + + private static native Object $call(long handle, Object[] arguments); + + private static native void $release(long handle); +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift new file mode 100644 index 000000000..94da1874c --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift @@ -0,0 +1,167 @@ +//===----------------------------------------------------------------------===// +// +// 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 CodePrinting +import JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// Closures returned to Java: a lambda of the closure's functional interface over a `SwiftClosure`. +@Suite +struct JNIClosureResultsTests { + let source = + """ + public struct Item { + public var id: String + } + public final class Notifier { + public var onItem: ((Item) -> Void)? + public let format: (Item, Int64) -> String + public func makeFormatter(prefix: String) -> (Item) -> String {} + } + """ + + @Test + func functionResult_javaLambda() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public com.example.swift.Notifier.makeFormatter.result makeFormatter(java.lang.String prefix) { + org.swift.swiftkit.core.SwiftClosure closure$ = new org.swift.swiftkit.core.SwiftClosure(Notifier.$makeFormatter(prefix, this.$memoryAddress())); + return (com.example.swift.Notifier.makeFormatter.result) (Item closure$0) -> (java.lang.String) closure$.call(closure$0); + } + """, + "private static native long $makeFormatter(java.lang.String prefix, long selfPointer);", + """ + @FunctionalInterface + public interface result { + java.lang.String apply(Item _0); + } + """, + ] + ) + } + + @Test + func functionResult_swiftClosureHandle() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + let closure$ = selfPointer$.pointee.makeFormatter(prefix: String(fromJNI: prefix, in: environment)) + return _makeSwiftClosure { arguments$, environment in + return JavaBoxableBridge.toJavaObject(closure$(_JNIBridge_Item.fromJavaObject(_swiftClosureArgument(arguments$, 0, in: environment), in: environment)), in: environment) + } + """ + ] + ) + } + + @Test + func settableProperty_getterReturnsTheSetterInterface() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public java.util.Optional getOnItem() {", + "long closure$handle = Notifier.$getOnItem(this.$memoryAddress());", + "return java.util.Optional.ofNullable(closure$ == null ? null : (com.example.swift.Notifier.setOnItem.newValue) (Item closure$0) -> closure$.call(closure$0));", + "public void setOnItem(java.util.Optional newValue) {", + "Notifier.$setOnItem(newValue.orElse(null), this.$memoryAddress());", + ] + ) + } + + @Test + func settableProperty_nullableHelperClassPrintedOnce() throws { + var config = Configuration() + config.swiftModule = "SwiftModule" + config.optionalsAsNullable = true + try assertOutput( + input: source, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public com.example.swift.Notifier.setOnItem.newValue getOnItem() {", + "return closure$ == null ? null : (com.example.swift.Notifier.setOnItem.newValue) (Item closure$0) -> closure$.call(closure$0);", + "public void setOnItem(com.example.swift.Notifier.setOnItem.newValue newValue) {", + ] + ) + let translator = makeSwiftJavaAnalyzer(config: config) + try translator.analyze(path: "/fake/Fake.swiftinterface", text: source) + let generator = JNISwift2JavaGenerator( + config: config, + translator: translator, + javaPackage: "com.example.swift", + swiftOutputDirectory: "/fake", + javaOutputDirectory: "/fake", + javaClassLookupTable: [:], + moduleJavaPackages: [:] + ) + var javaPrinter = JavaPrinter(mode: .accumulateAll) + try generator.writeExportedJavaSources(&javaPrinter) + // The getter and the setter share it. + #expect(javaPrinter.contents.components(separatedBy: "public static class setOnItem").count == 2) + } + + @Test + func optionalClosureParameter_nilOrEscapingClosure() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "selfPointer$.pointee.onItem = newValue == nil ? nil : {", + "return closure$.map { closure$ in _makeSwiftClosure { arguments$, environment in", + ] + ) + } + + @Test + func readOnlyProperty_ownInterface() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public com.example.swift.Notifier.getFormat.result getFormat() {", + "return (com.example.swift.Notifier.getFormat.result) (Item closure$0, long closure$1) -> (java.lang.String) closure$.call(closure$0, closure$1);", + ] + ) + } + + @Test + func mainActorClosureResult_isSkipped() throws { + try assertOutput( + input: "public func makeHandler() -> @MainActor () -> Void {}", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [], + notExpectedChunks: ["makeHandler()"] + ) + } +}