Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Sources/ErrorKit/Catching.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`<ReturnType>(
_ 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`<ReturnType>(
isolation: isolated (any Actor)?,
_ operation: () async throws -> ReturnType
) async throws(Self) -> ReturnType {
do {
return try await operation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion Sources/ErrorKit/ErrorKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/ErrorKit/Helpers/String+ErrorKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
109 changes: 109 additions & 0 deletions Tests/ErrorKitTests/CatchingIsolationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
93 changes: 91 additions & 2 deletions Tests/ErrorKitTests/ErrorKitTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import ErrorKit
import Foundation
import Testing

@testable import ErrorKit

enum ErrorKitTests {
struct SomeLocalizedError: LocalizedError {
let errorDescription: String? = "Something failed."
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions Tests/ErrorKitTests/SourceCompatibilityTests.swift
Original file line number Diff line number Diff line change
@@ -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."
"""
)
}
}
Loading