diff --git a/Sources/CodePrinting/CodePrinter+Java.swift b/Sources/CodePrinting/CodePrinter+Java.swift index fcb5af09c..24859abfc 100644 --- a/Sources/CodePrinting/CodePrinter+Java.swift +++ b/Sources/CodePrinting/CodePrinter+Java.swift @@ -32,7 +32,9 @@ extension CodePrinter where Language == JavaLanguage { /// /// Shape depends on `options.sourceVersion`: /// - Java 23+: Markdown-style line comments (`///`) based on https://openjdk.org/jeps/467 - /// - Earlier versions: classic `/** ... */` block, with each body line + /// `{@code X}` inline tags are rewritten to Markdown backticks (`` `X` ``). + /// - Earlier versions: classic `/** ... */` block. Single-line input is + /// collapsed to `/** text */`; multi-line input keeps each body line /// prefixed by ` * ` (just ` *` for blank paragraph separators). /// /// Pass paragraph breaks as blank lines (`\n\n`) inside `text`. @@ -40,8 +42,11 @@ extension CodePrinter where Language == JavaLanguage { let lines = text.split(separator: "\n", omittingEmptySubsequences: false) if options.sourceVersion >= 23 { for line in lines { - print(line.isEmpty ? "///" : "/// \(line)") + let rewritten = Self.rewriteInlineCodeTagsToMarkdown(String(line)) + print(rewritten.isEmpty ? "///" : "/// \(rewritten)") } + } else if lines.count == 1 { + print("/** \(lines[0]) */") } else { print("/**") for line in lines { @@ -50,4 +55,26 @@ extension CodePrinter where Language == JavaLanguage { print(" */") } } + + /// Rewrite `{@code X}` inline Javadoc tags to Markdown backtick spans + /// (`` `X` ``) for JEP 467 `///` comments. + static func rewriteInlineCodeTagsToMarkdown(_ line: String) -> String { + var result = "" + var remaining = line[...] + while let start = remaining.range(of: "{@code ") { + result.append(contentsOf: remaining[.. String { + "\(prefix ?? "")\(parent.fullFlatName)_\(method)_\(parameter)" + } + + /// Joins components with `.`, example: `Outer.Inner.run.cb` + package static func javaQualifiedName(_ components: String...) -> String { + components.joined(separator: ".") + } + + /// Java binary name for a nested synthetic type, example: `com.example.Outer$Inner$run$cb` + /// + /// - SeeAlso: [JLS 13.1 - The Form of a Binary](https://docs.oracle.com/javase/specs/jls/se21/html/jls-13.html#jls-13.1) + package static func javaBinaryName( + package javaPackage: String, + parent: SwiftQualifiedTypeName, + method: String, + qualifier: String? = nil, + ) -> String { + let packagePrefix = javaPackage.isEmpty ? "" : "\(javaPackage)." + let qualifierSuffix = qualifier.map { "$\($0)" } ?? "" + return "\(packagePrefix)\(parent.jniEscapedName)$\(method)\(qualifierSuffix)" + } + + /// JNI C symbol name for a `@_cdecl` native method entry point, example: + /// `Java_com_example_Outer_00024Inner_run__I` (`Java_____`). + /// - SeeAlso: https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/design.html#resolving_native_method_names + package static func jniSymbolName( + package javaPackage: String, + parent: SwiftQualifiedTypeName, + method: String, + signature: String, + ) -> String { + "Java_" + + javaPackage.replacingOccurrences(of: ".", with: "_") + + "_\(parent.jniEscapedName.escapedJNIIdentifier)_" + + method.escapedJNIIdentifier + + "__" + + signature.escapedJNIIdentifier + } } diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift index 5a5ec6ca7..5093dad61 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift @@ -267,8 +267,6 @@ extension FFMSwift2JavaGenerator { } /// Print the helper type container for a user-facing Java API. - /// - /// * User-facing functional interfaces. func printJavaBindingWrapperHelperClass( _ printer: inout JavaPrinter, _ decl: ExtractedFunc, diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift new file mode 100644 index 000000000..6b6a4e729 --- /dev/null +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftExtract +import SwiftJavaJNICore + +extension JNISwift2JavaGenerator { + + /// Emit `@JavaInterface` Swift wrappers for each `@escaping` closure + /// parameter of the passed in function. + /// + /// The wrapper's purpose is to retain the Java side object and delegate the calls on it to + /// the Java object "callback". + /// Each wrapper has it's corresponding Java side printed by `printJavaBindingWrapperHelperClass`. + func printEscapingClosureSwiftFunctionHelperClasses( + _ printer: inout SwiftPrinter, + _ translatedDecl: TranslatedFunctionDecl, + ) { + for ty in translatedDecl.functionTypes { + guard let closureTy = ty.syntheticClosureType else { + continue + } + printEscapingClosureWrapJavaInterface(&printer, closureTy) + } + } + + private func printEscapingClosureWrapJavaInterface( + _ printer: inout SwiftPrinter, + _ closureTy: SyntheticEscapingClosureFunctionType, + ) { + printer.printBraceBlock( + """ + @JavaInterface("\(closureTy.javaBinaryName)") + public struct \(closureTy.javaInterfaceName) + """ + ) { p in + let signature = self.renderEscapingClosureApplySignature(closureTy.functionType) + p.print( + """ + @JavaMethod + public func apply\(signature) + """ + ) + } + printer.println() + } + + 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)" + } + let paramList = "(\(params.joined(separator: .comma)))" + + if type.resultType.isVoid { + return paramList + } else { + return "\(paramList) -> \(type.resultType.description)" + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 1d9e39bc8..236977e5e 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -85,11 +85,13 @@ 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 + struct SyntheticEscapingClosureFunctionType { + /// The name of the Java functional interface to be generated. + let javaInterfaceName: String + + /// Java binary name of the corresponding `@FunctionalInterface`. + /// E.g. `com.example.swift.CallbackManager$setCallback$callback`. + let javaBinaryName: String /// Conversion steps for each parameter let parameterConversions: [UpcallConversionStep] @@ -142,8 +144,9 @@ extension JNISwift2JavaGenerator { /// allowing it to use the same conversion infrastructure for optionals, arrays, etc. func generateSyntheticClosureFunction( functionType: SwiftFunctionType, - wrapJavaInterfaceName: String - ) throws -> SyntheticClosureFunction { + javaInterfaceName: String, + javaBinaryName: String, + ) throws -> SyntheticEscapingClosureFunctionType { let parameterConversions = try functionType.parameters.enumerated().map { idx, param in try self.translateParameter( parameterName: param.parameterName ?? "_\(idx)", @@ -156,8 +159,9 @@ extension JNISwift2JavaGenerator { methodName: "apply" ) - return SyntheticClosureFunction( - wrapJavaInterfaceName: wrapJavaInterfaceName, + return SyntheticEscapingClosureFunctionType( + javaInterfaceName: javaInterfaceName, + javaBinaryName: javaBinaryName, parameterConversions: parameterConversions, resultConversion: resultConversion, functionType: functionType diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 45ef66472..587b0c5b3 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -646,8 +646,6 @@ extension JNISwift2JavaGenerator { } /// Print the helper type container for a user-facing Java API. - /// - /// * User-facing functional interfaces. private func printJavaBindingWrapperHelperClass( _ printer: inout JavaPrinter, _ decl: ExtractedFunc, @@ -675,6 +673,11 @@ extension JNISwift2JavaGenerator { ) { let apiParams = functionType.parameters.map({ $0.parameter.renderParameter() }) + printer.printJavadocComment( + """ + Corresponds to the Swift closure parameter of type {@code \(functionType.swiftType)}. + """ + ) printer.print( """ @FunctionalInterface diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 2afb71da5..ae4eb1396 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -194,11 +194,24 @@ extension JNISwift2JavaGenerator { switch param.type { case .function(let funcTy): - let translatedClosure = try translateFunctionType( + var translatedClosure = try translateFunctionType( name: parameterName, swiftType: funcTy, parentName: parentName, ) + // Lift the escaping-closure spec off the matching `NativeParameter` + // onto the `TranslatedFunctionType`. This keeps the wrap-java + // emission colocated with the decl it belongs to, so the thunk + // printer can emit the `@JavaInterface` Swift struct alongside + // the cdecl thunk that references it - no generator-level + // sink/dict/pre-pass required. `NativeParameter` runs 1:1 with + // the Swift parameters here (a Swift param can lower to multiple + // *JavaParameter*s but stays one `NativeParameter`), so indexing + // by `idx` is correct. + if idx < nativeFunctionSignature.parameters.count { + translatedClosure.syntheticClosureType = + nativeFunctionSignature.parameters[idx].syntheticClosure + } funcTypes.append(translatedClosure) default: break @@ -508,7 +521,10 @@ extension JNISwift2JavaGenerator { return TranslatedParameter( parameter: JavaParameter( name: parameterName, - type: .class(package: javaPackage, name: "\(parentName.fullName).\(methodName).\(parameterName)"), + type: .class( + package: javaPackage, + name: String.javaQualifiedName(parentName.fullName, methodName, parameterName) + ), annotations: parameterAnnotations, ), conversion: .placeholder, @@ -1705,6 +1721,17 @@ extension JNISwift2JavaGenerator { var parameters: [TranslatedParameter] var result: TranslatedResult var swiftType: SwiftFunctionType + + var isEscaping: Bool { swiftType.isEscaping } + + /// Represents this `TranslatedFunctionType` if we need to create a synthetic protocol + /// to handle the cross-language call to the function (closure). + /// + /// E.g. if the function type we're in is `@escaping` we need to create an + /// interface on the Java side to represent it, and a Swift side wrapper to + /// retain/wrap it, that will be then called into from the escaping Swift-side + /// closure we forward to the downcall target. + var syntheticClosureType: SyntheticEscapingClosureFunctionType? } /// Describes how to convert values between Java types and the native Java function diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index d279e4b45..578d072cc 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -250,28 +250,51 @@ extension JNISwift2JavaGenerator { // @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)" + // For escaping closures we e need to create a Swift wrapper around + // the passed down Java functional interface because we must keep it + // alive with a global ref, that will remain around for as long as the + // escaping closure is. + // + // Prepare the name and shapes of the Java side functional interface + // and Swift side @JavaInterface wrapper we'll use to implement that + // interface (and keep the Java object reachable). + + // Name it: interface MyClass_myMethod_theClosure { ... } + let javaClosureInterfaceName = String.flatName( + prefix: "Java", + parent: parentName, + method: methodName, + parameter: parameterName, + ) + let javaBinaryName = String.javaBinaryName( + package: self.javaPackage, + parent: parentName, + method: methodName, + qualifier: parameterName, + ) let generator = JavaInterfaceProtocolWrapperGenerator() let syntheticFunction = try generator.generateSyntheticClosureFunction( functionType: fn, - wrapJavaInterfaceName: wrapJavaInterfaceName + javaInterfaceName: javaClosureInterfaceName, + javaBinaryName: javaBinaryName, ) return NativeParameter( parameters: [ JavaParameter( name: parameterName, - type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)") + type: .class( + package: javaPackage, + name: String.javaQualifiedName(parentName.fullName, methodName, parameterName) + ) ) ], conversion: .escapingClosureLowering( - syntheticFunction: syntheticFunction, - closureName: parameterName + syntheticFunction: syntheticFunction ), indirectConversion: nil, - conversionCheck: nil + conversionCheck: nil, + syntheticClosure: syntheticFunction ) } @@ -292,7 +315,10 @@ extension JNISwift2JavaGenerator { parameters: [ JavaParameter( name: parameterName, - type: .class(package: javaPackage, name: "\(parentName).\(methodName).\(parameterName)") + type: .class( + package: javaPackage, + name: String.javaQualifiedName(parentName.fullName, methodName, parameterName) + ) ) ], conversion: .closureLowering( @@ -1163,6 +1189,13 @@ extension JNISwift2JavaGenerator { /// Represents check operations executed in if/guard conditional block for check during conversion let conversionCheck: NativeSwiftConversionCheck? + + /// Non-nil when this parameter is an `@escaping` closure. Carries the + /// spec needed to emit the matching `@JavaInterface` Swift wrapper + /// struct alongside the cdecl thunk. Threaded upward into + /// ``TranslatedFunctionType/syntheticClosure`` by the Java-translation + /// pass in ``JavaTranslation/translate(_:)``. + var syntheticClosure: SyntheticEscapingClosureFunctionType? = nil } struct NativeResult { @@ -1236,9 +1269,12 @@ extension JNISwift2JavaGenerator { /// Escaping closure lowering using the protocol infrastructure. /// This uses UpcallConversionStep for full support of optionals, arrays, custom types, etc. + /// The closure parameter's user-facing name is supplied by `render(_:_:)` + /// via the `placeholder` argument; the renderer derives its local + /// variable names (`javaInterface_$`) from that, so no separate + /// name payload is needed on the case. indirect case escapingClosureLowering( - syntheticFunction: SyntheticClosureFunction, - closureName: String + syntheticFunction: SyntheticEscapingClosureFunctionType ) indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) @@ -1540,7 +1576,7 @@ extension JNISwift2JavaGenerator { return printer.finalize() - case .escapingClosureLowering(let syntheticFunction, let closureName): + case .escapingClosureLowering(let syntheticFunction): var printer = SwiftPrinter() let fn = syntheticFunction.functionType @@ -1548,7 +1584,9 @@ extension JNISwift2JavaGenerator { param.parameterName ?? "_\(idx)" } let closureParameters = parameterNames.joined(separator: .comma) - let isVoid = fn.resultType == .tuple([]) + let isVoid = fn.resultType.isVoid + + let javaInterfaceVar = "javaInterface_\(placeholder)$" // Build upcall arguments using UpcallConversionStep conversions var upcallArguments: [String] = [] @@ -1563,7 +1601,7 @@ extension JNISwift2JavaGenerator { // 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 = "javaInterface$.apply(\(upcallArguments.joined(separator: .comma)))" + let upcallExpr = "\(javaInterfaceVar).apply(\(upcallArguments.joined(separator: .comma)))" let resultConverted = syntheticFunction.resultConversion.render(&resultPrinter, upcallExpr) let resultPrefix = resultPrinter.finalize() @@ -1574,21 +1612,18 @@ extension JNISwift2JavaGenerator { ? "{" : "{ \(closureParameters) in" + // 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 + // Java side that we'll make an up-call into when the closure runs. printer.print( """ { guard let \(placeholder) else { fatalError(\"\(placeholder) is null\") } - - let closureContext_\(closureName)$ = JavaObjectHolder(object: \(placeholder), environment: environment) - + let \(javaInterfaceVar) = \(syntheticFunction.javaInterfaceName)(javaThis: \(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)") } }() diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index 09f73b4f9..d464c80ee 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -450,7 +450,7 @@ extension JNISwift2JavaGenerator { return } - printSwiftFunctionHelperClasses(&printer, decl) + printAllSwiftFunctionHelperClasses(&printer, decl) printCDecl( &printer, @@ -464,7 +464,22 @@ extension JNISwift2JavaGenerator { } } - private func printSwiftFunctionHelperClasses( + private func printAllSwiftFunctionHelperClasses( + _ printer: inout SwiftPrinter, + _ decl: ExtractedFunc, + ) { + // Emit the `@JavaInterface` Swift wrappers for any `@escaping` closure parameters. + if let translatedDecl = translatedDecl(for: decl) { + printEscapingClosureSwiftFunctionHelperClasses(&printer, translatedDecl) + } + + // Emit the `@JavaInterface` Swift wrappers any existential / protocol parameters. + printProtocolParameterSwiftFunctionHelperClasses(&printer, decl) + } + + /// Emits Swift wrapper types for any parameter type that is a protocol + /// (or a `some`/`any`/generic constraint bound to protocols). + private func printProtocolParameterSwiftFunctionHelperClasses( _ printer: inout SwiftPrinter, _ decl: ExtractedFunc, ) { @@ -761,13 +776,12 @@ extension JNISwift2JavaGenerator { signature += parameter.type.jniTypeSignature } - let cName = - "Java_" - + self.javaPackage.replacingOccurrences(of: ".", with: "_") - + "_\(parentName.jniEscapedName.escapedJNIIdentifier)_" - + javaMethodName.escapedJNIIdentifier - + "__" - + jniSignature.escapedJNIIdentifier + let cName = String.jniSymbolName( + package: self.javaPackage, + parent: parentName, + method: javaMethodName, + signature: jniSignature, + ) self.generatedCDeclSymbolNames.append(cName) diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index bbb6e1b0b..30bfa3766 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -102,8 +102,10 @@ SwiftJava's `swift-java jextract` tool automates generating Java bindings from S | 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` `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))` | ❌ | ❌ | +| `@escaping` closures with primitive arguments/results: `func callMe(_: @escaping (Int64) -> (Double))` | ❌ | ✅ | +| `@escaping` closures with `String` arguments/results: `func callMe(_: @escaping (String) -> (String))` | ❌ | ✅ | +| `@escaping` closures with user defined types: `func callMe(_: @escaping (Obj) -> (Obj))` | ❌ | ❌ | +| `@escaping` closures returning other closures: `func callMe(_: (Obj) -> (() -> ()))` | ❌ | ❌ | | Swift type extensions: `extension String { func uppercased() }` | ✅ | ✅ | | Swift macros (maybe) | ❌ | ❌ | | Result builders | ❌ | ❌ | @@ -352,8 +354,54 @@ but specifically, for allowing passing concrete binding types generated by jextr which conform a to a given Swift protocol. #### Returning protocol types + Protocols are not yet supported as return types. +### Swift closures + +Non-escaping closures are called synchronously by the Swift function they are passed to, +so their Java-side lifetime is trivially bounded by the enclosing native call. + +In Java, Swift closure parameters are represented as functional interfaces. + +SwiftJava generates ad-hoc functional interfaces per parameter, and the parameters +given and returned by the closure are also mapped between the languages just as a normal +top-level function's would be. + +From a Java developers perspective, calling Swift functions accepting callbacks is seamless: + +```java +myModule.setCallback(() -> System.out.println("called from Swift")); // functional interface is used +``` + + +> Note: Closures whose parameters or result are jextract-imported user types +(`@escaping (MyClass) -> MyClass`) are not supported yet. + + +#### Escaping closures (`@escaping`) + +> Note: `@escaping` closure parameters are currently only supported in JNI mode. + +SwiftJava also supports `@escaping` closures, however the runtime in support of them is a bit more involved, +because Swift function may store the closure and invoke it _after_ the +native call has already returned. The Java callback must therefore stay reachable +for as long as Swift retains a reference to the escaping closure. + +Lifetime of the Java object is ensured by SwiftJava automatically, which creates global references, +and removes them when the Swift-side closure gets destroyed. +This means that passing escaping closures to Swift functions does increase the global reference count, +something you may need to be cautious of when working on e.g. Android which limits the total numbers of global references. + +The Java side user experience is unchanged from the non-escaping use-case: + +```java +myModule.setCallback(() -> System.out.println("called from Swift")); // functional interface is used +``` + +> Note: Closures whose parameters or result are jextract-imported user types +(`@escaping (MyClass) -> MyClass`) are not supported yet. + ### `async` functions > Note: Importing `async` functions is currently only available in the JNI mode of jextract. diff --git a/Tests/CodePrintingTests/InlineCommentStyleTests.swift b/Tests/CodePrintingTests/InlineCommentStyleTests.swift index e3f1be28a..ac779aeeb 100644 --- a/Tests/CodePrintingTests/InlineCommentStyleTests.swift +++ b/Tests/CodePrintingTests/InlineCommentStyleTests.swift @@ -133,10 +133,59 @@ struct PrintJavadocCommentSuite { @Test func defaultJavaVersionUsesClassicBlock() { var p = JavaPrinter() // default sourceVersion is 8 p.emitSourceLocations = false - p.printJavadocComment("hello") + p.printJavadocComment("First line.\nSecond line.") #expect(p.contents.contains("/**")) - #expect(p.contents.contains(" * hello")) + #expect(p.contents.contains(" * First line.")) + #expect(p.contents.contains(" * Second line.")) #expect(p.contents.contains(" */")) } + + // ==== ---------------------------------------------------------------------- + // MARK: Single-line input collapses to `/** text */` + + @Test func singleLineCollapsesToInlineBlockForOlderJava() { + var p = JavaPrinter() + p.emitSourceLocations = false + p.options.sourceVersion = 17 + p.printJavadocComment("hello") + + #expect(p.contents.trimmingCharacters(in: .whitespacesAndNewlines) == "/** hello */") + } + + // ==== ---------------------------------------------------------------------- + // MARK: `{@code X}` rewrites to Markdown backticks in `///` mode + + @Test func markdownRewritesInlineCodeTagsToBackticks() { + // JEP 467 recommends Markdown code spans instead of `{@code}` in `///` + // documentation comments; the printer normalizes for us. + var p = JavaPrinter() + p.emitSourceLocations = false + p.options.sourceVersion = 23 + p.printJavadocComment("Corresponds to Swift closure of type {@code () -> Void}.") + + #expect(p.contents.contains("/// Corresponds to Swift closure of type `() -> Void`.")) + #expect(!p.contents.contains("{@code")) + } + + @Test func markdownRewriteHandlesMultipleInlineCodeTagsPerLine() { + var p = JavaPrinter() + p.emitSourceLocations = false + p.options.sourceVersion = 23 + p.printJavadocComment("Apply {@code f} to {@code x}.") + + #expect(p.contents.contains("/// Apply `f` to `x`.")) + } + + @Test func classicBlockLeavesInlineCodeTagsIntact() { + // In `/** */` (older Java) mode `{@code}` remains a first-class Javadoc + // tag; leave it alone. + var p = JavaPrinter() + p.emitSourceLocations = false + p.options.sourceVersion = 17 + p.printJavadocComment("See {@code Foo}.") + + #expect(p.contents.contains("{@code Foo}")) + #expect(!p.contents.contains("`Foo`")) + } } diff --git a/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift index 841420d74..65dd28997 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift @@ -32,6 +32,7 @@ struct JNIClosureTests { expectedChunks: [ """ public static class emptyClosure { + /** Corresponds to the Swift closure parameter of type {@code () -> ()}. */ @FunctionalInterface public interface closure { void apply(); @@ -90,6 +91,7 @@ struct JNIClosureTests { expectedChunks: [ """ public static class closureWithArgumentsAndReturn { + /** Corresponds to the Swift closure parameter of type {@code (Int64, Bool) -> Int64}. */ @FunctionalInterface public interface closure { long apply(long _0, boolean _1); diff --git a/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift index 37ff48f30..c5e4dceca 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift @@ -57,6 +57,7 @@ struct JNIEscapingClosureTests { expectedChunks: [ """ public static class setCallback { + /** Corresponds to the Swift closure parameter of type {@code @escaping () -> Void}. */ @FunctionalInterface public interface callback { void apply(); @@ -92,6 +93,7 @@ struct JNIEscapingClosureTests { expectedChunks: [ """ public static class delayedExecution { + /** Corresponds to the Swift closure parameter of type {@code @escaping (Int64) -> Int64}. */ @FunctionalInterface public interface closure { long apply(long _0); @@ -116,7 +118,56 @@ struct JNIEscapingClosureTests { detectChunkByInitialLines: 1, expectedChunks: [ """ - let closureContext_callback$ = JavaObjectHolder(object: callback, environment: environment) + let javaInterface_callback$ = JavaSwiftModule_setCallback_callback(javaThis: callback, environment: environment) + """, + """ + @JavaInterface("com.example.swift.SwiftModule$setCallback$callback") + public struct JavaSwiftModule_setCallback_callback { + @JavaMethod + public func apply() + } + """, + ] + ) + } + + @Test + func escapingClosure_swiftAndJavaSidesAgreeOnBinaryName() throws { + let source = + """ + public func setCallback(callback: @escaping () -> Void) {} + """ + + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static class setCallback { + /** Corresponds to the Swift closure parameter of type {@code @escaping () -> Void}. */ + @FunctionalInterface + public interface callback { + void apply(); + } + } + """ + ] + ) + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$setCallback$callback") + public struct JavaSwiftModule_setCallback_callback { + @JavaMethod + public func apply() + } """ ] ) @@ -135,6 +186,7 @@ struct JNIEscapingClosureTests { .java, expectedChunks: [ """ + /** Corresponds to the Swift closure parameter of type {@code () -> Void}. */ @FunctionalInterface public interface closure { void apply(); @@ -143,4 +195,216 @@ struct JNIEscapingClosureTests { ] ) } + + @Test + func escapingClosureWithParametersAndResult_swiftThunks() throws { + let source = + """ + public func delayedExecution(closure: @escaping (Int64) -> Int64) {} + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$delayedExecution$closure") + public struct JavaSwiftModule_delayedExecution_closure { + @JavaMethod + public func apply(_ _0: Int64) -> Int64 + } + """ + ] + ) + } + + @Test + func escapingClosure_issue328_StringToString() throws { + // Repro of https://github.com/swiftlang/swift-java/issues/328 + let source = + """ + public func approve(_ fee: @escaping (String) -> String) {} + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$approve$fee") + public struct JavaSwiftModule_approve_fee { + @JavaMethod + public func apply(_ _0: String) -> String + } + """, + """ + let javaInterface_fee$ = JavaSwiftModule_approve_fee(javaThis: fee, environment: environment) + """, + ] + ) + } + + @Test + func escapingClosure_onClassMethod() throws { + let source = + """ + public class CallbackManager { + public init() {} + public func setCallback(_ callback: @escaping () -> Void) {} + } + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.CallbackManager$setCallback$callback") + public struct JavaCallbackManager_setCallback_callback { + @JavaMethod + public func apply() + } + """ + ] + ) + } + + @Test + func escapingClosure_onNestedTypeMethod() throws { + let source = + """ + public class Outer { + public class Inner { + public init() {} + public func run(_ cb: @escaping () -> Void) {} + } + } + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.Outer$Inner$run$cb") + public struct JavaOuter_Inner_run_cb { + @JavaMethod + public func apply() + } + """ + ] + ) + } + + @Test + func escapingClosure_onSpecializedGenericTypeMethod() throws { + let source = + """ + public struct Fish {} + public class Tank { + public init() {} + public func subscribe(_ cb: @escaping () -> Void) {} + } + public typealias FishTank = Tank + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.FishTank$subscribe$cb") + public struct JavaFishTank_subscribe_cb { + @JavaMethod + public func apply() + } + """, + """ + @JavaInterface("com.example.swift.Tank$subscribe$cb") + public struct JavaTank_subscribe_cb { + @JavaMethod + public func apply() + } + """, + ] + ) + } + + @Test + func escapingClosure_multipleClosuresOnOneFunction() throws { + let source = + """ + public func run(onSuccess: @escaping () -> Void, onFailure: @escaping (Int64) -> Void) {} + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.SwiftModule$run$onSuccess") + public struct JavaSwiftModule_run_onSuccess { + @JavaMethod + public func apply() + } + """, + """ + @JavaInterface("com.example.swift.SwiftModule$run$onFailure") + public struct JavaSwiftModule_run_onFailure { + @JavaMethod + public func apply(_ _0: Int64) + } + """, + "let javaInterface_onSuccess$ = JavaSwiftModule_run_onSuccess(javaThis: onSuccess, environment: environment)", + "let javaInterface_onFailure$ = JavaSwiftModule_run_onFailure(javaThis: onFailure, environment: environment)", + ] + ) + } + + @Test + func escapingClosure_sameParamNameOnDifferentMethods() throws { + let source = + """ + public class Bus { + public init() {} + public func register(callback: @escaping () -> Void) {} + public func unregister(callback: @escaping () -> Void) {} + } + """ + + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @JavaInterface("com.example.swift.Bus$register$callback") + public struct JavaBus_register_callback { + @JavaMethod + public func apply() + } + """, + """ + @JavaInterface("com.example.swift.Bus$unregister$callback") + public struct JavaBus_unregister_callback { + @JavaMethod + public func apply() + } + """, + ] + ) + } }