diff --git a/Sources/ErrorKit/Catching.swift b/Sources/ErrorKit/Catching.swift index 9290d11..fa0f699 100644 --- a/Sources/ErrorKit/Catching.swift +++ b/Sources/ErrorKit/Catching.swift @@ -174,11 +174,31 @@ extension Catching { /// } /// ``` /// + /// Use ``catch(isolation:_:)`` with `isolation: #isolation` when the closure accesses actor-isolated state. + /// /// - Parameter operation: The async throwing operation to execute. /// - Returns: The value returned by the operation if successful. /// - Throws: An instance of `Self` with the original error wrapped in the `caught` case. public static func `catch`( _ operation: () async throws -> ReturnType + ) async throws(Self) -> ReturnType { + try await Self.catch(isolation: nil, operation) + } + + /// Executes an async throwing operation while the wrapper inherits the specified actor's isolation. + /// + /// Pass `isolation: #isolation` to inherit the caller's actor. This lets an actor-local closure access + /// isolated state and capture non-Sendable values. An async operation value keeps its declared isolation; + /// this wrapper does not move an arbitrary operation onto the specified actor. + /// + /// - Parameters: + /// - isolation: The actor whose isolation the wrapper inherits. Pass `#isolation` to use the caller's actor. + /// - operation: The async throwing operation to execute. + /// - Returns: The value returned by the operation if successful. + /// - Throws: An instance of `Self` with the original error wrapped in the `caught` case. + public static func `catch`( + isolation: isolated (any Actor)?, + _ operation: () async throws -> ReturnType ) async throws(Self) -> ReturnType { do { return try await operation() diff --git a/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md b/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md index 7bd4f89..345d7df 100644 --- a/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md +++ b/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md @@ -182,6 +182,7 @@ ErrorKit's debugging tools transform error handling from a black box into a tran ### Essentials - ``ErrorKit/errorChainDescription(for:)`` +- ``ErrorKit/errorChainDescription(for:indent:)`` - ``ErrorKit/groupingID(for:)`` ### Related Concepts diff --git a/Sources/ErrorKit/ErrorKit.docc/Guides/Typed-Throws-and-Error-Nesting.md b/Sources/ErrorKit/ErrorKit.docc/Guides/Typed-Throws-and-Error-Nesting.md index f4c4d2a..e84e3e7 100644 --- a/Sources/ErrorKit/ErrorKit.docc/Guides/Typed-Throws-and-Error-Nesting.md +++ b/Sources/ErrorKit/ErrorKit.docc/Guides/Typed-Throws-and-Error-Nesting.md @@ -263,7 +263,8 @@ Be sure to explore the error chain debugging features to get the full benefit of - ``Catching`` - ``Catching/catch(_:)-8kmn1`` -- ``Catching/catch(_:)`` +- ``Catching/catch(_:)-63mkk`` +- ``Catching/catch(isolation:_:)`` ### System Overloads diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 4914a8d..59fa9d4 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -152,7 +152,24 @@ public enum ErrorKit { /// - Parameter error: The error to describe, potentially containing nested errors /// - Returns: A formatted string showing the complete error hierarchy with indentation public static func errorChainDescription(for error: Error) -> String { - Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) + Self.errorChainDescription(for: error, indent: "") + } + + /// Describes the error chain with a prefix for every line after the first. + /// + /// - Parameters: + /// - error: The error to describe, potentially containing nested errors + /// - indent: The indentation of the surrounding text when you embed the chain in an already indented message. + /// - Returns: A formatted string showing the complete error hierarchy with indentation + public static func errorChainDescription(for error: Error, indent: String) -> String { + let description = Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) + guard indent.isEmpty == false else { return description } + return description.reduce(into: "") { result, character in + result.append(character) + if character.isNewline { + result.append(indent) + } + } } private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { diff --git a/Sources/ErrorKit/Helpers/String+ErrorKit.swift b/Sources/ErrorKit/Helpers/String+ErrorKit.swift index 3ba134e..1985f3b 100644 --- a/Sources/ErrorKit/Helpers/String+ErrorKit.swift +++ b/Sources/ErrorKit/Helpers/String+ErrorKit.swift @@ -35,7 +35,7 @@ extension String.StringInterpolation { /// Interpolates an error using its complete chain description for debugging. /// - /// Uses ``ErrorKit.errorChainDescription(for:)`` to show the full error hierarchy, + /// Uses ``ErrorKit/errorChainDescription(for:)`` to show the full error hierarchy, /// type information, and nested structure. Ideal for logging and debugging. /// /// ```swift @@ -54,7 +54,7 @@ extension String.StringInterpolation { /// Interpolates an error using its complete chain description for debugging. /// - /// Uses ``ErrorKit.errorChainDescription(for:)`` to show the full error hierarchy, + /// Uses ``ErrorKit/errorChainDescription(for:)`` to show the full error hierarchy, /// type information, and nested structure. Ideal for logging and debugging. /// /// ```swift diff --git a/Tests/ErrorKitTests/CatchingIsolationTests.swift b/Tests/ErrorKitTests/CatchingIsolationTests.swift new file mode 100644 index 0000000..96f43da --- /dev/null +++ b/Tests/ErrorKitTests/CatchingIsolationTests.swift @@ -0,0 +1,109 @@ +import ErrorKit +import Testing + +/// These tests mostly prove something at compile time: `catch` must accept a closure that touches actor-isolated state +/// or captures non-Sendable values. Without the inherited isolation, Swift 6 rejects both with "sending value of +/// non-Sendable type risks causing data races". +@Suite("Catching isolation") +struct CatchingIsolationTests { + enum LoadError: Throwable, Catching { + case caught(Error) + + var userFriendlyMessage: String { "Loading failed." } + } + + final class Counter { + var value = 0 + } + + @MainActor + final class ViewModel { + var total = 0 + + func fetch() async throws -> Int { 2 } + + func refresh() async throws { + self.total = try await LoadError.catch(isolation: #isolation) { + try await self.fetch() + self.total + } + } + } + + actor Store { + var count = 1 + + func increment() async throws -> Int { + try await LoadError.catch(isolation: #isolation) { + try await Task.sleep(for: .zero) + self.count += 1 + return self.count + } + } + } + + @Test + @MainActor + func mainActorStateIsAccessible() async throws { + let viewModel = ViewModel() + try await viewModel.refresh() + #expect(viewModel.total == 2) + } + + @Test + func actorStateIsAccessible() async throws { + let store = Store() + #expect(try await store.increment() == 2) + } + + @Test + @MainActor + func nonSendableCapturesStillWork() async throws { + let counter = Counter() + let result = try await LoadError.catch(isolation: #isolation) { + try await Task.sleep(for: .zero) + counter.value += 1 + return counter.value + } + #expect(result == 1) + #expect(counter.value == 1) + } + + @Test + func errorsAreStillWrapped() async { + struct Underlying: Error, Equatable { + let code: Int + } + let expectedError = Underlying(code: 42) + do { + _ = try await LoadError.catch(isolation: #isolation) { () async throws -> Int in + throw expectedError + } + Issue.record("Expected the underlying error to be wrapped.") + } catch { + switch error { + case .caught(let underlying): + #expect(underlying as? Underlying == expectedError) + } + } + } + + @Test + func errorsAreNotWrappedTwice() async { + struct Underlying: Error, Equatable { + let code: Int + } + let expectedError = Underlying(code: 43) + let wrappedError = LoadError.caught(expectedError) + do { + _ = try await LoadError.catch(isolation: #isolation) { () async throws -> Int in + throw wrappedError + } + Issue.record("Expected the already wrapped error to be thrown.") + } catch { + switch error { + case .caught(let underlying): + #expect(underlying as? Underlying == expectedError) + } + } + } +} diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index b21d6e6..9c13fd6 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -1,8 +1,7 @@ +import ErrorKit import Foundation import Testing -@testable import ErrorKit - enum ErrorKitTests { struct SomeLocalizedError: LocalizedError { let errorDescription: String? = "Something failed." @@ -86,6 +85,60 @@ enum ErrorKitTests { } enum ErrorChainDescription { + struct MultilineThrowable: Throwable { + let userFriendlyMessage: String + } + + @Test(arguments: ["", " ", "│ ", "\t>> "], [false, true]) + static func multilineMessageContinuationWithIndent(indent: String, deeplyNested: Bool) { + let leaf = MultilineThrowable(userFriendlyMessage: "First line\n\nLast line\n") + let error: any Error = + deeplyNested + ? StateError.caught(OperationError.caught(DatabaseError.caught(leaf))) + : leaf + let expected = + deeplyNested + ? """ + StateError + \(indent)└─ OperationError + \(indent) └─ DatabaseError + \(indent) └─ MultilineThrowable [Struct] + \(indent) └─ userFriendlyMessage: "First line + \(indent) + \(indent)Last line + \(indent)" + """ + : """ + MultilineThrowable [Struct] + \(indent)└─ userFriendlyMessage: "First line + \(indent) + \(indent)Last line + \(indent)" + """ + #expect(ErrorKit.errorChainDescription(for: error, indent: indent) == expected) + #expect(ErrorKit.errorChainDescription(for: error) == ErrorKit.errorChainDescription(for: error, indent: "")) + } + + @Test(arguments: ["\n", "\r\n", "\r", "\u{000B}", "\u{000C}", "\u{0085}", "\u{2028}", "\u{2029}"], [false, true]) + static func newlineCharactersPreserveBytesAndIndent(separator: String, deeplyNested: Bool) { + let leaf = MultilineThrowable(userFriendlyMessage: "First line\(separator)\(separator)Last line\(separator)") + let error: any Error = + deeplyNested + ? StateError.caught(OperationError.caught(DatabaseError.caught(leaf))) + : leaf + for indent in ["", "│ "] { + let expected = + deeplyNested + ? "StateError\n\(indent)└─ OperationError\n\(indent) └─ DatabaseError\n\(indent) └─ MultilineThrowable [Struct]\n\(indent) └─ userFriendlyMessage: \"First line\(separator)\(indent)\(separator)\(indent)Last line\(separator)\(indent)\"" + : "MultilineThrowable [Struct]\n\(indent)└─ userFriendlyMessage: \"First line\(separator)\(indent)\(separator)\(indent)Last line\(separator)\(indent)\"" + let actual = ErrorKit.errorChainDescription(for: error, indent: indent) + #expect(Array(actual.utf8) == Array(expected.utf8)) + if indent.isEmpty { + #expect(Array(ErrorKit.errorChainDescription(for: error).utf8) == Array(expected.utf8)) + } + } + } + @Test static func localizedError() { #expect( @@ -142,6 +195,18 @@ enum ErrorKitTests { #expect(generatedErrorChainDescription == expectedErrorChainDescription) } + @Test + static func shallowNestedWithIndent() { + let nestedError = DatabaseError.caught(FileError.fileNotFound(fileName: "App.sqlite")) + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError, indent: " ") + let expectedErrorChainDescription = """ + DatabaseError + └─ FileError.fileNotFound(fileName: "App.sqlite") + └─ userFriendlyMessage: "The file App.sqlite could not be located. Please verify the file path and try again." + """ + #expect(generatedErrorChainDescription == expectedErrorChainDescription) + } + @Test static func deeplyNestedThrowablesWithEnumLeaf() { let nestedError = StateError.caught( @@ -165,6 +230,30 @@ enum ErrorKitTests { #expect(generatedErrorChainDescription == expectedErrorChainDescription) } + @Test + static func deeplyNestedThrowablesWithIndent() { + let nestedError = StateError.caught( + OperationError.caught( + DatabaseError.caught( + FileError.caught( + PermissionError.denied(permission: "~/Downloads/Profile.png") + ) + ) + ) + ) + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError, indent: ">>") + let expectedErrorChainDescription = """ + StateError + >>└─ OperationError + >> └─ DatabaseError + >> └─ FileError + >> └─ PermissionError.denied(permission: "~/Downloads/Profile.png") + >> └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png was declined. To use this feature, please enable the permission in your device Settings." + """ + #expect(generatedErrorChainDescription == expectedErrorChainDescription) + #expect(ErrorKit.errorChainDescription(for: nestedError) == ErrorKit.errorChainDescription(for: nestedError, indent: "")) + } + @Test static func deeplyNestedThrowablesWithStructLeaf() { let nestedError = StateError.caught( diff --git a/Tests/ErrorKitTests/SourceCompatibilityTests.swift b/Tests/ErrorKitTests/SourceCompatibilityTests.swift new file mode 100644 index 0000000..fa1294a --- /dev/null +++ b/Tests/ErrorKitTests/SourceCompatibilityTests.swift @@ -0,0 +1,32 @@ +import ErrorKit +import Testing + +@Suite("Source compatibility") +struct SourceCompatibilityTests { + enum ProbeError: Error, Catching { + case caught(Error) + } + + struct ProbeFailure: Throwable { + var userFriendlyMessage: String { "Operation failed." } + } + + @Test + func asyncCatchFunctionReference() async throws { + let wrapper: (() async throws -> Int) async throws(ProbeError) -> Int = ProbeError.catch + let value = try await wrapper { 42 } + #expect(value == 42) + } + + @Test + func errorChainFormatterFunctionReference() { + let formatter: (any Error) -> String = ErrorKit.errorChainDescription(for:) + #expect( + formatter(ProbeFailure()) + == """ + ProbeFailure [Struct] + └─ userFriendlyMessage: "Operation failed." + """ + ) + } +}