diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MySwiftError.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MySwiftError.swift index d9d77d38b..5e3986b34 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MySwiftError.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/MySwiftError.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// -enum MySwiftError: Error { +public enum MySwiftError: Error { case swiftError + case invalidInput(reason: String) } diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Throw.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Throw.swift index b6c0184e6..7c67d21e5 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Throw.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Throw.swift @@ -22,3 +22,30 @@ public func throwString(input: String) throws -> String { return input } // snippet.end + +public func throwTyped(input: String) throws(MySwiftError) -> String { + if input.isEmpty { + throw .swiftError + } + if input == "invalid" { + throw .invalidInput(reason: "input was 'invalid'") + } + return input +} + +// Not public, so it is not extracted and surfaces in Java as `SwiftErrorException`. +struct InternalOnlyError: Error { + let code: Int +} + +public func throwInternalError() throws { + throw InternalOnlyError(code: 42) +} + +public func asyncThrowTyped(input: String) async throws -> String { + try await Task.sleep(for: .milliseconds(10)) + if input.isEmpty { + throw MySwiftError.invalidInput(reason: "empty input") + } + return input +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java index 1e9810954..4ff86284d 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/AsyncTest.java @@ -72,7 +72,7 @@ void asyncThrows() { Throwable cause = ex.getCause(); assertNotNull(cause); - assertEquals(Exception.class, cause.getClass()); + assertEquals(MySwiftError.class, cause.getClass()); assertEquals("swiftError", cause.getMessage()); } diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/IsolatedTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/IsolatedTest.java index 3945b94f5..c7a02df26 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/IsolatedTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/IsolatedTest.java @@ -52,7 +52,7 @@ void incrementThrows() throws Exception { Throwable cause = ex.getCause(); assertNotNull(cause); - assertEquals(Exception.class, cause.getClass()); + assertEquals(MySwiftError.class, cause.getClass()); assertEquals("swiftError", cause.getMessage()); } } diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ThrowTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ThrowTest.java index 8aec9f8ab..15205dc40 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ThrowTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ThrowTest.java @@ -16,6 +16,10 @@ import com.example.swift.MySwiftLibrary; import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.SwiftErrorException; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import static org.junit.jupiter.api.Assertions.*; @@ -36,4 +40,63 @@ void throwStringActuallyThrows() { assertTrue(exception.getMessage().contains("swiftError")); // snippet.end } + + @Test + void catchSwiftErrorEnum() { + MySwiftError error = assertThrows(MySwiftError.class, () -> { + MySwiftLibrary.throwString(""); + }); + switch (error.getCase()) { + case MySwiftError.Case.SwiftError _ -> {} + case MySwiftError.Case.InvalidInput _ -> fail("unexpected case"); + } + } + + @Test + void typedThrowsIsCheckedException() { + // Only `MySwiftError` is caught here: this compiles because the + // Java signature is `throws MySwiftError`, not `throws Exception`. + try { + MySwiftLibrary.throwTyped(""); + fail("expected MySwiftError"); + } catch (MySwiftError e) { + assertTrue(e.getMessage().contains("swiftError")); + } + } + + @Test + void typedThrowsPayload() { + try { + MySwiftLibrary.throwTyped("invalid"); + fail("expected MySwiftError"); + } catch (MySwiftError e) { + switch (e.getCase()) { + case MySwiftError.Case.InvalidInput(var reason) -> assertEquals("input was 'invalid'", reason); + default -> fail("unexpected case"); + } + } + } + + @Test + void typedThrowsSuccess() throws MySwiftError { + assertEquals("ok", MySwiftLibrary.throwTyped("ok")); + } + + @Test + void nonExtractedErrorFallsBackToSwiftErrorException() { + SwiftErrorException e = assertThrows(SwiftErrorException.class, () -> { + MySwiftLibrary.throwInternalError(); + }); + assertNotNull(e.getMessage()); + assertTrue(e.getMessage().contains("InternalOnlyError")); + assertTrue(e.as(MySwiftError.class).isEmpty()); + } + + @Test + void asyncThrowsTypedCause() { + Future future = MySwiftLibrary.asyncThrowTyped(""); + + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertInstanceOf(MySwiftError.class, ex.getCause()); + } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 98d1004c3..504514f57 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -287,7 +287,7 @@ extension JNISwift2JavaGenerator { printer.println() } - printSwiftInstanceObjectMethods(&printer) + printSwiftInstanceObjectMethods(&printer, decl) printer.println() } } @@ -434,7 +434,7 @@ extension JNISwift2JavaGenerator { printTypeMetadataAddressFunction(&printer, decl) printer.println() - printSwiftInstanceObjectMethods(&printer) + printSwiftInstanceObjectMethods(&printer, decl) printer.println() } } @@ -509,7 +509,7 @@ extension JNISwift2JavaGenerator { } /// Prints common Swift object methods such as `equals`, `hashCode` etc. - private func printSwiftInstanceObjectMethods(_ printer: inout JavaPrinter) { + private func printSwiftInstanceObjectMethods(_ printer: inout JavaPrinter, _ decl: ExtractedNominalType) { printer.print( """ public boolean equals(Object obj) { @@ -523,10 +523,21 @@ extension JNISwift2JavaGenerator { return SwiftObjects.hashCode(this.$memoryAddress(), this.$typeMetadataAddress()); } - public java.lang.String toString() { - return SwiftObjects.toString(this.$memoryAddress(), this.$typeMetadataAddress()); - } + """ + ) + // Error types inherit `Throwable.toString()`, which prints the class name followed by the Swift description. + if !self.isSwiftErrorType(decl) { + printer.print( + """ + public java.lang.String toString() { + return SwiftObjects.toString(this.$memoryAddress(), this.$typeMetadataAddress()); + } + """ + ) + } + printer.print( + """ public java.lang.String toDebugString() { return SwiftObjects.toDebugString(this.$memoryAddress(), this.$typeMetadataAddress()); } @@ -589,14 +600,21 @@ extension JNISwift2JavaGenerator { modifiers.append("static") } modifiers.append("final") - var implements = ["JNISwiftInstance"] + let isErrorType = self.isSwiftErrorType(decl) + // Error types get `JNISwiftInstance` from their `SwiftError` base class. + var implements = isErrorType ? [] : ["JNISwiftInstance"] // Only protocols that were actually extracted have a generated Java interface to implement. implements += self.inheritedProtocols(of: decl).map(\.effectiveJavaSimpleName) - let implementsClause = implements.joined(separator: .comma) + // Fully qualified so a user type named `SwiftError` cannot shadow it. + let extendsClause = isErrorType ? " extends org.swift.swiftkit.core.SwiftError" : "" + let implementsClause = implements.isEmpty ? "" : " implements \(implements.joined(separator: .comma))" + if isErrorType { + printer.print("@SuppressWarnings(\"serial\")") + } // Specialized types are concrete — no generic clause on the Java side let genericClause = decl.javaGenericClause printer.printBraceBlock( - "\(modifiers.joined(separator: " ")) class \(decl.effectiveJavaSimpleName)\(genericClause) implements \(implementsClause)" + "\(modifiers.joined(separator: " ")) class \(decl.effectiveJavaSimpleName)\(genericClause)\(extendsClause)\(implementsClause)" ) { printer in body(&printer) } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index f3e6e5189..9dc93d507 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -29,6 +29,7 @@ extension JNISwift2JavaGenerator { knownTypes: SwiftKnownTypes(symbolTable: lookupContext.symbolTable), protocolWrappers: self.interfaceProtocolWrappers, logger: self.logger, + errorTypes: self.swiftErrorTypes, javaIdentifiers: self.currentJavaIdentifiers ) } @@ -40,7 +41,7 @@ extension JNISwift2JavaGenerator { return cached } - let translated: TranslatedFunctionDecl? + var translated: TranslatedFunctionDecl? do { translated = try self.javaTranslator.translate(decl) } catch { @@ -48,10 +49,45 @@ extension JNISwift2JavaGenerator { translated = nil } + if let candidate = translated, collidesWithThrowableMember(candidate, of: decl) { + translated = nil + } + translatedDecls[decl] = translated return translated } + /// Zero-argument `Throwable` members that a generated `SwiftError` subclass cannot redeclare with another meaning. + static let throwableMemberNames: Set = [ + "getMessage", "getLocalizedMessage", "getCause", "fillInStackTrace", "printStackTrace", "getStackTrace", + "getSuppressed", + ] + + /// Whether `translated`, a member of an error type, would clash with a `java.lang.Throwable` method + /// on the generated exception class, in which case it is skipped with a warning. + private func collidesWithThrowableMember(_ translated: TranslatedFunctionDecl, of decl: ExtractedFunc) -> Bool { + guard let parent = decl.parentType?.asNominalTypeDeclaration, + self.swiftErrorTypes.contains(parent.identity), + translated.translatedFunctionSignature.parameters.isEmpty, + Self.throwableMemberNames.contains(translated.name) + else { + return false + } + + // `getMessage()`/`getLocalizedMessage()` returning a String naturally override the Throwable ones. + let isStringOverride = + ["getMessage", "getLocalizedMessage"].contains(translated.name) + && translated.translatedFunctionSignature.result.javaType == .javaLangString + if isStringOverride { + return false + } + + self.logger.warning( + "Skipping '\(parent.qualifiedName).\(translated.name)': it collides with 'java.lang.Throwable.\(translated.name)()' on the generated exception class." + ) + return true + } + func translatedEnumCase( for decl: ExtractedEnumCase ) -> TranslatedEnumCase? { @@ -70,6 +106,7 @@ extension JNISwift2JavaGenerator { knownTypes: SwiftKnownTypes(symbolTable: lookupContext.symbolTable), protocolWrappers: self.interfaceProtocolWrappers, logger: self.logger, + errorTypes: self.swiftErrorTypes, javaIdentifiers: self.currentJavaIdentifiers, ) translated = try translation.translate(enumCase: decl) @@ -91,6 +128,8 @@ extension JNISwift2JavaGenerator { var knownTypes: SwiftKnownTypes let protocolWrappers: [ExtractedNominalType: JavaInterfaceSwiftWrapper] let logger: Logger + /// Module-qualified identities of extracted types that are surfaced to Java as `SwiftError` exceptions. + let errorTypes: Set var javaIdentifiers: JavaIdentifierFactory func translate(enumCase: ExtractedEnumCase) throws -> TranslatedEnumCase { @@ -227,10 +266,23 @@ extension JNISwift2JavaGenerator { ) } + var thrownJavaType: JavaType? + if case .nominal(let thrown) = decl.functionSignature.thrownTypedError, + self.errorTypes.contains(thrown.nominalTypeDecl.identity) + { + // Only qualify the package when the error type lives in another Java package + let thrownPackage = moduleJavaPackages[thrown.nominalTypeDecl.moduleName] + thrownJavaType = .class( + package: thrownPackage == self.javaPackage ? nil : thrownPackage, + name: thrown.nominalTypeDecl.qualifiedName + ) + } + return TranslatedFunctionDecl( name: javaName, isStatic: decl.isStatic || decl.isClass || !decl.hasParent || decl.isInitializer, isThrowing: decl.isThrowing, + thrownJavaType: thrownJavaType, isAsync: decl.isAsync, isIsolated: decl.isIsolated, isImplicitlyAsync: decl.functionSignature.isImplicitlyAsync, @@ -1697,6 +1749,9 @@ extension JNISwift2JavaGenerator { var isThrowing: Bool + /// The Java exception type for functions with typed throws of an extracted error type, `nil` otherwise. + var thrownJavaType: JavaType? + var isAsync: Bool var isIsolated: Bool @@ -1724,14 +1779,16 @@ extension JNISwift2JavaGenerator { } func throwsClause() -> String { + let isSyncThrowing = !(isAsync || isIsolated || isImplicitlyAsync) + let thrownException = (isSyncThrowing ? thrownJavaType?.fullyQualifiedClassName : nil) ?? "Exception" guard !translatedFunctionSignature.exceptions.isEmpty else { - return isThrowing && !(isAsync || isIsolated || isImplicitlyAsync) ? " throws Exception" : "" + return isThrowing && isSyncThrowing ? " throws \(thrownException)" : "" } let signatureExceptions = translatedFunctionSignature.exceptions.compactMap(\.type.className).joined( separator: ", " ) - return " throws \(signatureExceptions)\(isThrowing ? ", Exception" : "")" + return " throws \(signatureExceptions)\(isThrowing ? ", \(thrownException)" : "")" } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 229e48f55..b5f38f2f9 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -2038,7 +2038,7 @@ extension JNISwift2JavaGenerator { printer.print( """ let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.interface.NewObjectA(catchEnvironment, _JNIMethodIDCache.Exception.class, _JNIMethodIDCache.Exception.constructWithMessage, [String(describing: error).getJValue(in: catchEnvironment)]) + let exception = catchEnvironment.makeJavaThrowable(for: error) _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, \(completeExceptionallyMethodID), [jvalue(l: exception)]) """ ) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index 229512e24..c5ac7bba0 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -364,6 +364,11 @@ extension JNISwift2JavaGenerator { printer.println() printNominalJavaBridge(&printer, type) printer.println() + + if self.isSwiftErrorType(type) && !self.inheritsThrowableErrorConformance(type) { + printThrowableErrorConformance(&printer, type) + printer.println() + } } printSpecificTypeThunks(&printer, type) @@ -781,7 +786,7 @@ extension JNISwift2JavaGenerator { printer.indent() printer.print( """ - environment.throwAsException(error) + environment.throwSwiftErrorAsJava(error) \(dummyReturn(for: nativeSignature)) """ ) @@ -951,6 +956,16 @@ extension JNISwift2JavaGenerator { } } + /// Prints the `_JNIThrowableError` conformance that lets the runtime throw this error as its generated Java class. + private func printThrowableErrorConformance(_ printer: inout SwiftPrinter, _ type: ExtractedNominalType) { + let bridgeName = JNICaching.bridgeName(for: type) + printer.printBraceBlock("extension \(type.effectiveSwiftTypeName): _JNIThrowableError") { printer in + printer.printBraceBlock("public func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable?") { printer in + printer.print("\(bridgeName).toJavaObject(self, in: environment)") + } + } + } + private func printNominalJavaBridge(_ printer: inout SwiftPrinter, _ type: ExtractedNominalType) { let bridgeName = JNICaching.bridgeName(for: type) let cacheName = JNICaching.cacheName(for: type) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index 539d59baa..dbb6e23ab 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -52,6 +52,13 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { /// Cached Java translation result. 'nil' indicates failed translation. var translatedDecls: [ExtractedFunc: TranslatedFunctionDecl] = [:] var translatedEnumCases: [ExtractedEnumCase: TranslatedEnumCase] = [:] + + /// Module-qualified identities of extracted types that are surfaced to Java as `SwiftError` exceptions. + lazy var swiftErrorTypes: Set = Set( + analysis.extractedTypes.values + .filter { isSwiftErrorType($0) } + .map(\.swiftNominal.identity) + ) var interfaceProtocolWrappers: [ExtractedNominalType: JavaInterfaceSwiftWrapper] = [:] /// Protocols that should be boxed to support returning them as `any P / some P` @@ -166,6 +173,31 @@ extension JNISwift2JavaGenerator { } } + /// Whether `type` conforms to `Error` and is surfaced to Java as a `SwiftError` subclass. + /// + /// Types that never get a Java class or bridge (protocols, unspecialized generics, + /// specializations and case-less enums such as `Never`) are excluded. + func isSwiftErrorType(_ type: ExtractedNominalType) -> Bool { + guard type.swiftNominal.kind != .protocol else { return false } + guard !type.swiftNominal.isGeneric, !type.isSpecialization else { return false } + guard !(type.swiftNominal.kind == .enum && type.cases.isEmpty) else { return false } + return type.conformsTo("Error", in: analysis.extractedTypes) + || type.conformsTo("LocalizedError", in: analysis.extractedTypes) + } + + /// Whether `type` is a class whose superclass is itself an error type, and therefore already + /// inherits the generated `_JNIThrowableError` conformance (redeclaring it would not compile). + /// + /// Such errors are thrown to Java as their root error class, since the generated Java classes + /// do not mirror Swift class inheritance. + func inheritsThrowableErrorConformance(_ type: ExtractedNominalType) -> Bool { + guard type.swiftNominal.kind == .class else { return false } + return type.inheritedTypes.contains { inherited in + guard let decl = inherited.asNominalTypeDeclaration, decl.kind == .class else { return false } + return self.swiftErrorTypes.contains(decl.identity) + } + } + /// The direct (non-inherited) requirements of `type` (a protocol) that are /// wrappable on the Java side: instance methods and variable accessors /// (getters/setters), excluding statics and anything whose signature diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift index 31ad2add2..d3cf9ffa8 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift @@ -104,6 +104,8 @@ private let swiftSourceFile: SourceFileSyntax = """ public struct Set {} + public protocol Error {} + // FIXME: Support 'typealias Void = ()' public struct Void {} @@ -116,6 +118,8 @@ private let swiftSourceFile: SourceFileSyntax = """ private let foundationEssentialsSourceFile: SourceFileSyntax = """ public protocol DataProtocol {} + public protocol LocalizedError: Error {} + public struct Data: DataProtocol { public init(bytes: UnsafeRawPointer, count: Int) public init(_ bytes: [UInt8]) diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md index 0ef4e6429..24d50e508 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -139,7 +139,8 @@ generated Java wrapper type. ### Throwing functions Throwing Swift functions are imported as Java methods that throw exceptions. -In JNI mode the exception type is `Exception`; in FFM mode it is `SwiftJavaErrorException`. +In FFM mode the exception type is `SwiftJavaErrorException`. +In JNI mode the thrown exception depends on the Swift error, as described below. @TabNavigator { @Tab("Swift") { @@ -153,6 +154,32 @@ In JNI mode the exception type is `Exception`; in FFM mode it is `SwiftJavaError } } +#### Errors in JNI mode + +In JNI mode, extracted Swift types that conform to `Error` or `LocalizedError` are generated as Java classes +extending `org.swift.swiftkit.core.SwiftError`, which is a `java.lang.Exception`. +`getMessage()` returns the Swift `String(describing:)` of the error. + +When a Swift function throws such an error, Java receives that exact class. +Errors whose type was not extracted (for example a non-public error type) are thrown as +`org.swift.swiftkit.core.SwiftErrorException`, which is also a `SwiftError`. + +Swift typed throws (`throws(MyError)`) become a checked `throws MyError` in Java, +while untyped `throws` stays `throws Exception`. +Async throwing functions return a `CompletableFuture` that completes exceptionally with the +same exception, available as the cause of the `ExecutionException` thrown by `get()`. + +```java +try { + MySwiftLibrary.throwTyped(""); +} catch (MySwiftError e) { + switch (e.getCase()) { + case MySwiftError.Case.SwiftError _ -> System.out.println("swift error"); + case MySwiftError.Case.InvalidInput(var reason) -> System.out.println(reason); + } +} +``` + ### Stored properties Stored `var` and `let` properties are imported as getter/setter methods. diff --git a/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift b/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift index b938def03..c675ac7c2 100644 --- a/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift +++ b/Sources/SwiftJavaRuntimeSupport/JNIMethodIDCaches.swift @@ -240,4 +240,25 @@ extension _JNIMethodIDCache { cache.fields[selfTypePointerField]! } } + + public enum SwiftErrorException { + private static let wrapMemoryAddressUnsafeMethod = Method( + name: "wrapMemoryAddressUnsafe", + signature: "(JJ)Lorg/swift/swiftkit/core/SwiftErrorException;", + isStatic: true + ) + + private static let cache = _JNIMethodIDCache( + className: "org/swift/swiftkit/core/SwiftErrorException", + methods: [wrapMemoryAddressUnsafeMethod] + ) + + public static var `class`: jclass { + cache.javaClass + } + + public static var wrapMemoryAddressUnsafe: jmethodID { + cache.methods[wrapMemoryAddressUnsafeMethod]! + } + } } diff --git a/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift b/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift new file mode 100644 index 000000000..2fb72c0d4 --- /dev/null +++ b/Sources/SwiftJavaRuntimeSupport/_JNISwiftErrors.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// A Swift error that knows how to turn itself into a dedicated Java exception. +/// +/// Conformances are generated by jextract. +public protocol _JNIThrowableError: Error { + func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable? +} + +extension JNIEnvironment { + /// Convert the given Swift error into a Java `Throwable`. + /// + /// - A Java `Throwable` is passed through as is. + /// - A ``_JNIThrowableError`` is converted to its dedicated Java exception class. + /// - Any other error is boxed in a `SwiftErrorException`. + public func makeJavaThrowable(for error: any Error) -> jthrowable? { + // If we're throwing something that's already a Java Throwable object, + // use it directly. + if let javaObject = error as? any AnyJavaObject, + let throwable = javaObject.as(Throwable.self) + { + return throwable.javaThis + } + + if let throwableError = error as? any _JNIThrowableError { + return throwableError._makeJavaThrowable(in: self) + } + return makeSwiftErrorException(for: error) + } + + /// Throw the given Swift error as a Java exception. + public func throwSwiftErrorAsJava(_ error: any Error) { + guard let throwable = makeJavaThrowable(for: error) else { + return + } + _ = interface.Throw(self, throwable) + } + + /// Box the error in a `SwiftErrorException`. + private func makeSwiftErrorException(for error: any Error) -> jthrowable? { + let selfPointer$ = UnsafeMutablePointer.allocate(capacity: 1) + selfPointer$.initialize(to: error) + let selfPointerBits$ = Int64(Int(bitPattern: selfPointer$)) + let metadata$ = unsafeBitCast((any Error).self, to: UnsafeRawPointer.self) + let metadataBits$ = Int64(Int(bitPattern: metadata$)) + var args = [jvalue(), jvalue()] + args[0].j = selfPointerBits$.getJNIValue(in: self) + args[1].j = metadataBits$.getJNIValue(in: self) + return interface.CallStaticObjectMethodA( + self, + _JNIMethodIDCache.SwiftErrorException.class, + _JNIMethodIDCache.SwiftErrorException.wrapMemoryAddressUnsafe, + &args + ) + } +} diff --git a/SwiftKitCore/build.gradle.kts b/SwiftKitCore/build.gradle.kts index fcf0590bc..f17364c2a 100644 --- a/SwiftKitCore/build.gradle.kts +++ b/SwiftKitCore/build.gradle.kts @@ -101,6 +101,11 @@ val compileSwift = tasks.register("compileSwift") { "SDK_STAT_CACHE_DIR", "SDK_STAT_CACHE_ENABLE", "SDK_STAT_CACHE_PATH", "SWIFTC_PASS_SDKROOT", "SWIFTC_PASS_SYSROOT", "TOOLCHAINS", ).forEach { environment.remove(it) } + // swift-build (6.4+) may point DEVELOPER_DIR at the toolchain, which has no xcrun. + // Keep deliberate overrides (Xcode, CommandLineTools) that are valid developer dirs. + (environment["DEVELOPER_DIR"] as? String)?.let { dir -> + if (!File(dir, "usr/bin/xcrun").exists()) environment.remove("DEVELOPER_DIR") + } } tasks.build { dependsOn(compileSwift) diff --git a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftError.java b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftError.java new file mode 100644 index 000000000..f83980fc3 --- /dev/null +++ b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftError.java @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// 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; + +/** + * Base class of all Swift {@code Error} values surfaced to Java as exceptions. + *

+ * The exception message is the Swift description of the wrapped error, i.e. {@code String(describing: error)}. + */ +@SuppressWarnings("serial") +public abstract class SwiftError extends Exception implements JNISwiftInstance, SwiftDowncastable { + + @Override + public String getMessage() { + // Loggers and stack trace printers call this implicitly, even after a confined arena has freed the + // Swift value, so avoid reading freed memory + if ($cleanup().isDestroyed()) { + return ""; + } + return SwiftObjects.toString($memoryAddress(), $typeMetadataAddress()); + } +} diff --git a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftErrorException.java b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftErrorException.java new file mode 100644 index 000000000..3f602016f --- /dev/null +++ b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftErrorException.java @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// 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; + +/** + * A boxed Swift {@code any Error}, thrown when the error's type is not surfaced as its own {@link SwiftError} + * subclass, for example a non-public error type or one from a module that was not extracted. + *

+ * {@link #getMessage()} returns the Swift {@code String(describing:)} of the error, and {@link #toDebugString()} + * its {@code String(reflecting:)}. + *

+ * If the error's type was extracted but not recognized as an error type (for example because its {@code Error} + * conformance comes through a protocol from another module), {@link #as(Class)} recovers it, for example + * {@code e.as(MyError.class)}. For error types without a Java class, {@code as} always returns an empty result. + */ +@SuppressWarnings("serial") +public final class SwiftErrorException extends SwiftError { + + /** Pointer to the boxed {@code any Error}. */ + private final long selfPointer; + + /** The metadata address of {@code (any Error).self}. */ + private final long typeMetadataAddress; + + /** Tracks whether this instance has been destroyed; doubles as the destroyed-state holder. */ + private final transient SwiftInstanceCleanup $cleanup; + + /** + * The designated constructor. + * + * @param selfPointer a pointer to the memory containing the boxed {@code any Error} + * @param typeMetadataAddress the metadata address of {@code (any Error).self} + * @param swiftArena the arena this object belongs to. When the arena goes out of scope, this value is destroyed. + */ + private SwiftErrorException(long selfPointer, long typeMetadataAddress, SwiftArena swiftArena) { + SwiftObjects.requireNonZero(selfPointer, "selfPointer"); + SwiftObjects.requireNonZero(typeMetadataAddress, "typeMetadataAddress"); + this.selfPointer = selfPointer; + this.typeMetadataAddress = typeMetadataAddress; + this.$cleanup = $createCleanup(); + + // Only register once we have fully initialized the object since this will need the object pointer. + swiftArena.register(this); + } + + /** + * Assume that the passed pointers point at a boxed {@code any Error} and wrap it, using the default automatic arena. + *

+ * Warnings: + *

    + *
  • No checks are performed about the compatibility of the pointed at memory and the actual error type.
  • + *
  • This operation does not copy, or retain, the pointed at pointer, so its lifetime must be ensured manually to be valid when wrapping.
  • + *
+ */ + public static SwiftErrorException wrapMemoryAddressUnsafe(long selfPointer, long typeMetadataAddress) { + return new SwiftErrorException(selfPointer, typeMetadataAddress, SwiftMemoryManagement.DEFAULT_SWIFT_JAVA_AUTO_ARENA); + } + + @Override + public long $memoryAddress() { + return this.selfPointer; + } + + @Override + public long $typeMetadataAddress() { + return this.typeMetadataAddress; + } + + @Override + public SwiftInstanceCleanup $cleanup() { + return $cleanup; + } + + public boolean equals(Object obj) { + if (obj instanceof JNISwiftInstance) { + JNISwiftInstance rhs = (JNISwiftInstance) obj; + return SwiftObjects.equals(this.$memoryAddress(), this.$typeMetadataAddress(), rhs.$memoryAddress(), rhs.$typeMetadataAddress()); + } + return false; + } + + public int hashCode() { + return SwiftObjects.hashCode(this.$memoryAddress(), this.$typeMetadataAddress()); + } + + public java.lang.String toDebugString() { + return SwiftObjects.toDebugString(this.$memoryAddress(), this.$typeMetadataAddress()); + } +} diff --git a/SwiftKitFFM/build.gradle.kts b/SwiftKitFFM/build.gradle.kts index a56f9df1c..7d3bbd619 100644 --- a/SwiftKitFFM/build.gradle.kts +++ b/SwiftKitFFM/build.gradle.kts @@ -101,6 +101,11 @@ val compileSwift = tasks.register("compileSwift") { "SDK_STAT_CACHE_DIR", "SDK_STAT_CACHE_ENABLE", "SDK_STAT_CACHE_PATH", "SWIFTC_PASS_SDKROOT", "SWIFTC_PASS_SYSROOT", "TOOLCHAINS", ).forEach { environment.remove(it) } + // swift-build (6.4+) may point DEVELOPER_DIR at the toolchain, which has no xcrun. + // Keep deliberate overrides (Xcode, CommandLineTools) that are valid developer dirs. + (environment["DEVELOPER_DIR"] as? String)?.let { dir -> + if (!File(dir, "usr/bin/xcrun").exists()) environment.remove("DEVELOPER_DIR") + } } tasks.build { dependsOn(compileSwift) diff --git a/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift b/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift index 825204af7..866c542d4 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIAsyncTests.swift @@ -154,7 +154,7 @@ struct JNIAsyncTests { } catch { let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.interface.NewObjectA(catchEnvironment, _JNIMethodIDCache.Exception.class, _JNIMethodIDCache.Exception.constructWithMessage, [String(describing: error).getJValue(in: catchEnvironment)]) + let exception = catchEnvironment.makeJavaThrowable(for: error) _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, _JNIMethodIDCache.CompletableFuture.completeExceptionally, [jvalue(l: exception)]) } } @@ -174,7 +174,7 @@ struct JNIAsyncTests { } catch { let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.interface.NewObjectA(catchEnvironment, _JNIMethodIDCache.Exception.class, _JNIMethodIDCache.Exception.constructWithMessage, [String(describing: error).getJValue(in: catchEnvironment)]) + let exception = catchEnvironment.makeJavaThrowable(for: error) _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, _JNIMethodIDCache.CompletableFuture.completeExceptionally, [jvalue(l: exception)]) } } diff --git a/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift b/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift index 2ec0805b5..6b16459de 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIDistributedActorTests.swift @@ -73,7 +73,7 @@ struct JNIDistributedActorTests { """ catch { let catchEnvironment = try! JavaVirtualMachine.shared().environment() - let exception = catchEnvironment.interface.NewObjectA(catchEnvironment, _JNIMethodIDCache.Exception.class, _JNIMethodIDCache.Exception.constructWithMessage, [String(describing: error).getJValue(in: catchEnvironment)]) + let exception = catchEnvironment.makeJavaThrowable(for: error) _ = catchEnvironment.interface.CallBooleanMethodA(catchEnvironment, globalFuture, _JNIMethodIDCache.CompletableFuture.completeExceptionally, [jvalue(l: exception)]) } """, diff --git a/Tests/JExtractSwiftTests/JNI/JNIErrorTests.swift b/Tests/JExtractSwiftTests/JNI/JNIErrorTests.swift new file mode 100644 index 000000000..ddbb3eb8b --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIErrorTests.swift @@ -0,0 +1,176 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftExtract +import SwiftJavaConfigurationShared +import Testing + +@Suite +struct JNIErrorTests { + let source = """ + public enum NetworkError: Error { + case offline + case timeout(seconds: Int64) + } + + public struct ParseError: Error { + public var message: String + } + + public final class StorageError: Error { + public init() {} + } + + public struct LateError {} + extension LateError: Error {} + + public func fetch() throws(NetworkError) {} + """ + + @Test + func errorTypes_javaClassHeaders() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @SuppressWarnings("serial") + public final class NetworkError extends org.swift.swiftkit.core.SwiftError { + """, + """ + @SuppressWarnings("serial") + public final class ParseError extends org.swift.swiftkit.core.SwiftError { + """, + """ + @SuppressWarnings("serial") + public final class StorageError extends org.swift.swiftkit.core.SwiftError { + """, + """ + @SuppressWarnings("serial") + public final class LateError extends org.swift.swiftkit.core.SwiftError { + """, + ] + ) + } + + @Test + func errorTypes_keepsStringMessage() throws { + try assertOutput( + input: source, + .jni, + .java, + expectedChunks: [ + "public java.lang.String getMessage() {" + ] + ) + } + + @Test + func errorTypes_swiftConformance() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + extension NetworkError: _JNIThrowableError { + public func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable? { + _JNIBridge_NetworkError.toJavaObject(self, in: environment) + } + } + """, + """ + extension ParseError: _JNIThrowableError { + public func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable? { + _JNIBridge_ParseError.toJavaObject(self, in: environment) + } + } + """, + """ + extension StorageError: _JNIThrowableError { + public func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable? { + _JNIBridge_StorageError.toJavaObject(self, in: environment) + } + } + """, + """ + extension LateError: _JNIThrowableError { + public func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable? { + _JNIBridge_LateError.toJavaObject(self, in: environment) + } + } + """, + ] + ) + } + + @Test + func errorClassHierarchy_conformanceOnRootOnly() throws { + let input = """ + public class BaseError: Error { + public init() {} + } + + public class SubError: BaseError {} + + public func failBase() throws(BaseError) {} + """ + + try assertOutput( + input: input, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + extension BaseError: _JNIThrowableError { + public func _makeJavaThrowable(in environment: JNIEnvironment) -> jthrowable? { + _JNIBridge_BaseError.toJavaObject(self, in: environment) + } + } + """ + ], + // SubError inherits BaseError's conformance; redeclaring it would not compile + notExpectedChunks: [ + "extension SubError: _JNIThrowableError" + ] + ) + + try assertOutput( + input: input, + .jni, + .java, + expectedChunks: [ + "public static void failBase() throws BaseError {" + ] + ) + } + + @Test + func typedThrows_javaSignature() throws { + try assertOutput( + input: source, + .jni, + .java, + expectedChunks: [ + "public static void fetch() throws NetworkError {" + ] + ) + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIModuleTests.swift b/Tests/JExtractSwiftTests/JNI/JNIModuleTests.swift index 07fbda3a0..82d5a49f7 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIModuleTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIModuleTests.swift @@ -247,7 +247,7 @@ struct JNIModuleTests { do { try SwiftModule.methodA() } catch { - environment.throwAsException(error) + environment.throwSwiftErrorAsJava(error) return () } } @@ -258,7 +258,7 @@ struct JNIModuleTests { do { return try SwiftModule.methodB().getJNILocalRefValue(in: environment) } catch { - environment.throwAsException(error) + environment.throwSwiftErrorAsJava(error) return 0 } } @@ -269,7 +269,7 @@ struct JNIModuleTests { do { return try SwiftModule.methodC().getJNILocalRefValue(in: environment) } catch { - environment.throwAsException(error) + environment.throwSwiftErrorAsJava(error) return nil } } diff --git a/Tests/JExtractSwiftTests/JNI/JNIVariablesTests.swift b/Tests/JExtractSwiftTests/JNI/JNIVariablesTests.swift index 45a4ed94f..87a0a4b5b 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIVariablesTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIVariablesTests.swift @@ -244,7 +244,7 @@ struct JNIVariablesTests { do { return try selfPointer$.pointee.computedThrowing.getJNILocalRefValue(in: environment) } catch { - environment.throwAsException(error) + environment.throwSwiftErrorAsJava(error) return 0 } }