diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Uploads.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Uploads.swift new file mode 100644 index 000000000..518463160 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Uploads.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public enum UploadError: Error { + case rejected +} + +/// Swift errors as values: Kotlin `Throwable`s. +public enum Uploads { + /// A completion handler taking an optional error. + public static func upload(_ id: String, onDone: @escaping (Error?) -> Void) { + onDone(id.isEmpty ? UploadError.rejected : nil) + } + + public static func failure(for id: String) -> Error? { + id.isEmpty ? UploadError.rejected : nil + } + + public static func isRejected(_ error: Error) -> Bool { + (error as? UploadError) == .rejected + } + + public static func describe(_ error: Error) -> String { + "\(error)" + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ErrorsTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ErrorsTest.kt new file mode 100644 index 000000000..cd442adc3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ErrorsTest.kt @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// Swift `Error` values are Kotlin `Throwable`s, in both directions. +class ErrorsTest { + @Test + fun completionHandler() { + val seen = mutableListOf() + Uploads.upload("") { error -> seen += error.orElse(null) } + Uploads.upload("a") { error -> seen += error.orElse(null) } + assertIs(seen[0]) + assertNull(seen[1]) + } + + @Test + fun resultsAndParameters() { + val error: Throwable? = Uploads.failure("") + assertFailsWith { throw error!! } + assertTrue(Uploads.isRejected(error!!)) // back in Swift, the same case + assertNull(Uploads.failure("a")) + + val kotlinError = IllegalStateException("disk full") + assertFalse(Uploads.isRejected(kotlinError)) + assertTrue(Uploads.describe(kotlinError).contains("disk full")) + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ErrorValues.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ErrorValues.swift new file mode 100644 index 000000000..a051b9dc4 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ErrorValues.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public enum SampleError: Error { + case offline + case timeout +} + +/// Not jextracted: crosses as a `SwiftErrorException` holding the Swift value. +struct CodedError: Error { + let code: Int64 +} + +/// Swift errors as values: Java `Throwable`s. +public enum ErrorValues { + public static func make(offline: Bool) -> Error? { + offline ? SampleError.offline : nil + } + + public static func isOffline(_ error: Error) -> Bool { + (error as? SampleError) == .offline + } + + public static func coded(_ code: Int64) -> any Error { + CodedError(code: code) + } + + public static func code(_ error: any Error) -> Int64 { + (error as? CodedError)?.code ?? -1 + } + + public static func describe(_ error: Error) -> String { + "\(error)" + } + + public static func roundTrip(_ error: Error?) -> Error? { + error + } + + public static func report(_ callback: @escaping (Error?) -> Void) { + callback(SampleError.timeout) + callback(nil) + } + + public static func makeLogger() -> (Error) -> String { + { "logged: \($0)" } + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ErrorValuesTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ErrorValuesTest.java new file mode 100644 index 000000000..dadc15ba4 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ErrorValuesTest.java @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// 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.SwiftErrorException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class ErrorValuesTest { + @Test + void jextractedErrorRoundTrip() { + Throwable error = ErrorValues.make(true).orElseThrow(); + assertInstanceOf(SampleError.class, error); + // Back in Swift, it's the same case. + assertTrue(ErrorValues.isOffline(error)); + assertTrue(ErrorValues.make(false).isEmpty()); + } + + @Test + void otherSwiftErrorRoundTrip() { + Throwable error = ErrorValues.coded(7); + assertInstanceOf(SwiftErrorException.class, error); + assertEquals(7, ErrorValues.code(error)); + } + + @Test + void javaExceptionRoundTrip() { + IllegalStateException exception = new IllegalStateException("offline"); + assertTrue(ErrorValues.describe(exception).contains("offline")); + assertFalse(ErrorValues.isOffline(exception)); + // A Java exception comes back as the same object. + assertSame(exception, ErrorValues.roundTrip(Optional.of(exception)).orElseThrow()); + assertTrue(ErrorValues.roundTrip(Optional.empty()).isEmpty()); + } + + @Test + void closureArguments() { + List> seen = new ArrayList<>(); + ErrorValues.report(seen::add); + assertEquals(2, seen.size()); + assertInstanceOf(SampleError.class, seen.get(0).orElseThrow()); + assertTrue(seen.get(1).isEmpty()); + } + + @Test + void returnedClosure() { + ErrorValues.makeLogger.result log = ErrorValues.makeLogger(); + assertTrue(log.apply(new RuntimeException("disk full")).contains("disk full")); + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift index 7761b4dff..785229b86 100644 --- a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift @@ -53,7 +53,7 @@ enum JNIJavaTypeTranslator { .array, .dictionary, .set, - .asyncStream, .asyncThrowingStream, + .asyncStream, .asyncThrowingStream, .error, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, .foundationURL, .essentialsURL: @@ -82,7 +82,7 @@ enum JNIJavaTypeTranslator { .array, .dictionary, .set, - .asyncStream, .asyncThrowingStream, + .asyncStream, .asyncThrowingStream, .error, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, .foundationURL, .essentialsURL: @@ -111,7 +111,7 @@ enum JNIJavaTypeTranslator { .array, .dictionary, .set, - .asyncStream, .asyncThrowingStream, + .asyncStream, .asyncThrowingStream, .error, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, .foundationURL, .essentialsURL: diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift index 467cbcb06..18f3830c9 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -65,6 +65,12 @@ extension JNISwift2JavaGenerator { /// 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 type.isSwiftError { + return isResult ? "Throwable!" : "Throwable?" + } + if !isResult, optionalWrapped(type)?.isSwiftError == true { + return "JavaOptional?" + } if let nominal = jextractedNominal(type) { return nominal.generatedJavaClassMacroName + (isResult ? "!" : "?") } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index ae3d46f11..9ca696d79 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -241,6 +241,9 @@ extension JNISwift2JavaGenerator { if type.isDirectlyTranslatedToWrapJava { return .placeholder } + if type.isSwiftError { + return .toJavaThrowable(.placeholder) + } switch type { case .nominal(let nominalType): @@ -425,6 +428,9 @@ enum UpcallConversionStep { indirect case toJavaOptional(UpcallConversionStep) + /// A Swift error as SwiftJava's `Throwable`. + indirect case toJavaThrowable(UpcallConversionStep) + indirect case fromJavaOptional(UpcallConversionStep) indirect case map(UpcallConversionStep, body: UpcallConversionStep) @@ -480,6 +486,10 @@ enum UpcallConversionStep { let inner = inner.render(&printer, placeholder) return "\(inner).toJavaOptional()" + case .toJavaThrowable(let inner): + let inner = inner.render(&printer, placeholder) + return "_makeThrowable(for: \(inner))" + case .fromJavaOptional(let inner): let inner = inner.render(&printer, placeholder) return "Optional(javaOptional: \(inner))" diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 97b426688..1a832b1d5 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -589,6 +589,14 @@ extension JNISwift2JavaGenerator { // If the result type should cause any annotations on the method, include them here. let parameterAnnotations: [JavaAnnotation] = getJavaTypeAnnotations(swiftType: swiftType, config: config) + // A Swift error is a Java `Throwable`. + if swiftType.isSwiftError { + return TranslatedParameter( + parameter: JavaParameter(name: parameterName, type: .javaLangThrowable, annotations: parameterAnnotations), + conversion: .placeholder + ) + } + switch swiftType { case .nominal(let nominalType): let nominalTypeName = nominalType.nominalTypeDecl.qualifiedName @@ -596,6 +604,16 @@ extension JNISwift2JavaGenerator { if let knownType = nominalType.asKnownType { switch knownType { case .optional(let wrapped): + if wrapped.isSwiftError { + return TranslatedParameter( + parameter: JavaParameter( + name: parameterName, + type: .optional(.javaLangThrowable), + annotations: parameterAnnotations + ), + conversion: .method(.placeholder, function: "orElse", arguments: [.constant("null")]) + ) + } if let (functionType, _) = Self.closureType(swiftType) { let closure = translateFunctionParameter( swiftType: .function(functionType), @@ -1235,11 +1253,31 @@ extension JNISwift2JavaGenerator { // If the result type should cause any annotations on the method, include them here. let resultAnnotations: [JavaAnnotation] = getJavaTypeAnnotations(swiftType: swiftType, config: config) + // A Swift error is a Java `Throwable`, `null` for `nil`. + if swiftType.isSwiftError { + return TranslatedResult( + javaType: .javaLangThrowable, + nativeJavaType: .javaLangThrowable, + annotations: resultAnnotations, + outParameters: [], + conversion: .placeholder + ) + } + switch swiftType { case .nominal(let nominalType): let nominalTypeName = nominalType.nominalTypeDecl.qualifiedName if let knownType = nominalType.asKnownType { switch knownType { + case .optional(let wrapped) where wrapped.isSwiftError: + return TranslatedResult( + javaType: .optional(.javaLangThrowable), + nativeJavaType: .javaLangThrowable, + annotations: resultAnnotations, + outParameters: [], + conversion: .method(.constant("java.util.Optional"), function: "ofNullable", arguments: [.placeholder]) + ) + case .optional(let wrapped): return try translateOptionalResult( wrappedType: wrapped, diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 5ade0533d..0beda8f2d 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -126,12 +126,30 @@ extension JNISwift2JavaGenerator { genericParameters: [SwiftGenericParameterDeclaration], genericRequirements: [SwiftGenericRequirement] ) throws -> NativeParameter { + // A Swift error arrives as a Java `Throwable`. + if type.isSwiftError { + return NativeParameter( + parameters: [JavaParameter(name: parameterName, type: .javaLangThrowable)], + conversion: .swiftErrorFromJava(isOptional: false), + indirectConversion: nil, + conversionCheck: nil + ) + } + switch type { case .nominal(let nominalType): let nominalTypeName = nominalType.nominalTypeDecl.name if let knownType = nominalType.asKnownType { switch knownType { + case .optional(let wrapped) where wrapped.isSwiftError: + return NativeParameter( + parameters: [JavaParameter(name: parameterName, type: .javaLangThrowable)], + conversion: .swiftErrorFromJava(isOptional: true), + indirectConversion: nil, + conversionCheck: nil + ) + case .optional(let wrapped): if let (functionType, _) = JavaTranslation.closureType(type) { let closure = try translateFunctionParameter( @@ -859,11 +877,19 @@ extension JNISwift2JavaGenerator { methodName: String, resultName: String = "result" ) throws -> NativeResult { + // A Swift error is returned as a Java `Throwable`. + if swiftType.isSwiftError { + return NativeResult(javaType: .javaLangThrowable, conversion: .javaThrowable(isOptional: false), outParameters: []) + } + switch swiftType { case .nominal(let nominalType): let nominalTypeName = nominalType.nominalTypeDecl.qualifiedName if let knownType = nominalType.asKnownType { switch knownType { + case .optional(let wrapped) where wrapped.isSwiftError: + return NativeResult(javaType: .javaLangThrowable, conversion: .javaThrowable(isOptional: true), outParameters: []) + case .optional(let wrapped): return try translateOptionalResult(wrappedType: wrapped, methodName: methodName, resultName: resultName) @@ -1327,6 +1353,9 @@ extension JNISwift2JavaGenerator { } private func bridgeTypeName(for swiftType: SwiftType) throws -> String { + if swiftType.isSwiftError { + return "ErrorBridge" + } switch swiftType { case .nominal(let nominalType): if let knownType = nominalType.asKnownType { @@ -1486,6 +1515,12 @@ extension JNISwift2JavaGenerator { indirect case initializeSwiftJavaWrapper(NativeSwiftConversionStep, wrapperName: String) + /// The Swift error for a Java `Throwable` argument; `nil` for `null` when optional. + case swiftErrorFromJava(isOptional: Bool) + + /// The Java `Throwable` for a Swift error result; `null` for `nil` when optional. + case javaThrowable(isOptional: Bool) + /// `nil` for a null Java object, otherwise the inner conversion: an optional escaping closure. indirect case nilOrClosure(NativeSwiftConversionStep) @@ -1931,6 +1966,16 @@ extension JNISwift2JavaGenerator { let inner = inner.render(&printer, placeholder) return "\(wrapperName)(javaThis: \(inner), environment: environment)" + case .swiftErrorFromJava(let isOptional): + return isOptional + ? "\(placeholder).map { environment.makeSwiftError(for: $0) }" + : "environment.makeSwiftError(for: \(placeholder)!)" + + case .javaThrowable(let isOptional): + return isOptional + ? "(\(placeholder)).flatMap { environment.makeJavaThrowableLocalRef(for: $0) }" + : "environment.makeJavaThrowableLocalRef(for: \(placeholder))" + case .nilOrClosure(let inner): return "\(placeholder) == nil ? nil : \(inner.render(&printer, placeholder))" diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift index 5ecc2cbd5..8ec7e8534 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift @@ -57,9 +57,11 @@ public enum SwiftKnownType: Equatable { case essentialsUUID case foundationURL case essentialsURL + case error public init?(kind: SwiftKnownTypeDeclKind, genericArguments: [SwiftType]?) { switch kind { + case .error: self = .error case .bool: self = .bool case .int: self = .int case .uint: self = .uint @@ -165,6 +167,7 @@ public enum SwiftKnownType: Equatable { case .essentialsUUID: .essentialsUUID case .foundationURL: .foundationURL case .essentialsURL: .essentialsURL + case .error: .error } } } @@ -201,6 +204,7 @@ public enum SwiftKnownTypeDeclKind: String, Hashable { case set = "Swift.Set" case asyncStream = "Swift.AsyncStream" case asyncThrowingStream = "Swift.AsyncThrowingStream" + case error = "Swift.Error" // Foundation case foundationDataProtocol = "Foundation.DataProtocol" diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift index 40f37f861..11660fa64 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift @@ -102,6 +102,18 @@ public enum SwiftType: Equatable { } } + /// Whether this is `Error` or `any Error`: a Swift error of any type. + public var isSwiftError: Bool { + switch self { + case .nominal(let nominal): + return nominal.nominalTypeDecl.knownTypeKind == .error + case .existential(let constraint): + return constraint.isSwiftError + default: + return false + } + } + public var isBoolean: Bool { if case let .nominal(nominal) = self { return nominal.nominalTypeDecl.knownTypeKind == .bool diff --git a/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift b/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift index c675ac7c2..ae89cf5cd 100644 --- a/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift +++ b/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift @@ -241,6 +241,28 @@ extension _JNIMethodIDCache { } } + public enum SwiftError { + private static let memoryAddressMethod = Method(name: "$memoryAddress", signature: "()J") + private static let typeMetadataAddressMethod = Method(name: "$typeMetadataAddress", signature: "()J") + + private static let cache = _JNIMethodIDCache( + className: "org/swift/swiftkit/core/SwiftError", + methods: [memoryAddressMethod, typeMetadataAddressMethod] + ) + + public static var `class`: jclass { + cache.javaClass + } + + public static var memoryAddress: jmethodID { + cache.methods[memoryAddressMethod]! + } + + public static var typeMetadataAddress: jmethodID { + cache.methods[typeMetadataAddressMethod]! + } + } + public enum SwiftErrorException { private static let wrapMemoryAddressUnsafeMethod = Method( name: "wrapMemoryAddressUnsafe", diff --git a/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift b/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift index 2fb72c0d4..5ccf493e0 100644 --- a/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift +++ b/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift @@ -42,6 +42,34 @@ extension JNIEnvironment { return makeSwiftErrorException(for: error) } + /// The Swift error for a Java `Throwable`, the reverse of ``makeJavaThrowable(for:)``: + /// + /// - A `SwiftError` (a `SwiftErrorException`, or a jextracted error type's exception) gives back a + /// copy of the Swift error it holds. + /// - Any other `Throwable` is the error itself, as SwiftJava's `Throwable`. + public func makeSwiftError(for throwable: jthrowable) -> any Error { + if interface.IsInstanceOf(self, throwable, _JNIMethodIDCache.SwiftError.class) == JNI_TRUE { + let address = interface.CallLongMethodA(self, throwable, _JNIMethodIDCache.SwiftError.memoryAddress, nil) + let metadata = interface.CallLongMethodA(self, throwable, _JNIMethodIDCache.SwiftError.typeMetadataAddress, nil) + if let pointer = UnsafeRawPointer(bitPattern: Int(address)), + let metadataPointer = UnsafeRawPointer(bitPattern: Int(metadata)) + { + func load(_: T.Type) -> Any { pointer.load(as: T.self) } + let type = unsafeBitCast(metadataPointer, to: Any.Type.self) + if let error = _openExistential(type, do: load) as? any Error { + return error + } + } + } + return Throwable(javaThis: throwable, environment: self) + } + + /// A new local reference to the Java `Throwable` for `error`, to return from a native method: the one + /// ``makeJavaThrowable(for:)`` gives for a Java `Throwable` is owned by its Swift wrapper. + public func makeJavaThrowableLocalRef(for error: any Error) -> jthrowable? { + interface.NewLocalRef(self, makeJavaThrowable(for: error)) + } + /// Throw the given Swift error as a Java exception. public func throwSwiftErrorAsJava(_ error: any Error) { guard let throwable = makeJavaThrowable(for: error) else { @@ -68,3 +96,32 @@ extension JNIEnvironment { ) } } + +/// A Swift error as SwiftJava's `Throwable`, to pass to a Java method, such as a closure implemented in Java. +public func _makeThrowable(for error: any Error) -> Throwable { + let environment = try! JavaVirtualMachine.shared().environment() + return Throwable(javaThis: environment.makeJavaThrowable(for: error)!, environment: environment) +} + +/// Bridges a Swift error to and from a Java `Throwable`, such as for a closure's arguments and result. +public enum ErrorBridge: JobjectBridge { + public typealias SwiftType = any Error + + public static func toJavaObject(_ value: any Error, in environment: JNIEnvironment) -> jobject? { + environment.makeJavaThrowableLocalRef(for: value) + } + + public static func fromJavaObject(_ obj: jobject?, in environment: JNIEnvironment) -> any Error { + guard let obj else { + fatalError("ErrorBridge.fromJavaObject received a null Java object") + } + return environment.makeSwiftError(for: obj) + } + + public static func withJNIClass( + in environment: JNIEnvironment, + _ body: (jclass) throws -> Result + ) throws -> Result { + try body(try JavaClass(environment: environment).javaThis) + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIErrorValuesTests.swift b/Tests/JExtractSwiftTests/JNI/JNIErrorValuesTests.swift new file mode 100644 index 000000000..1087e1556 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIErrorValuesTests.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// Swift `Error` values cross as Java `Throwable`s. +@Suite +struct JNIErrorValuesTests { + let source = + """ + public func lastError() -> Error? { nil } + public func describe(_ error: any Error) -> String { "" } + public func wrap(_ error: Error?) -> Error? { error } + public func onDone(_ cb: @escaping (Error?) -> Void) {} + public func onFail(_ cb: @escaping (any Error) -> Void) {} + public func makeHandler() -> (Error) -> String { { _ in "" } } + """ + + @Test + func javaSignatures() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static java.util.Optional lastError() {", + "public static java.lang.String describe(java.lang.Throwable error) {", + "public static java.util.Optional wrap(java.util.Optional error) {", + "void apply(java.util.Optional _0);", + "void apply(java.lang.Throwable _0);", + "java.lang.String apply(java.lang.Throwable _0);", + ] + ) + } + + @Test + func swiftConversions() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "return (SwiftModule.lastError()).flatMap { environment.makeJavaThrowableLocalRef(for: $0) }", + "return SwiftModule.describe(environment.makeSwiftError(for: error!)).getJNILocalRefValue(in: environment)", + "return (SwiftModule.wrap(error.map { environment.makeSwiftError(for: $0) })).flatMap { environment.makeJavaThrowableLocalRef(for: $0) }", + // Closures implemented in Java get SwiftJava's `Throwable`. + "public func apply(_ _0: JavaOptional?)", + "public func apply(_ _0: Throwable?)", + "javaInterface_cb$.apply(_makeThrowable(for: _0))", + // A closure returned to Java converts with `ErrorBridge`. + "closure$(ErrorBridge.fromJavaObject(_swiftClosureArgument(arguments$, 0, in: environment), in: environment))", + ] + ) + } +}