From 3f4b2d47398f4d53b76d4461dd7110ff5545e0e8 Mon Sep 17 00:00:00 2001 From: Justin Bishop Date: Sat, 3 May 2025 16:13:55 -0700 Subject: [PATCH 1/7] Support error chain description with opening indent level --- Sources/ErrorKit/ErrorKit.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 4914a8d..e985bae 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -149,10 +149,12 @@ public enum ErrorKit { /// 2. Shows the underlying cause (DatabaseError.connectionFailed) /// 3. Provides the user-friendly message for context (users will report this) /// - /// - Parameter error: The error to describe, potentially containing nested errors + /// - Parameters: + /// - error: The error to describe, potentially containing nested errors + /// - indent: The initial indent string (normally "") /// - 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)) + public static func errorChainDescription(for error: Error, indent: String = "") -> String { + Self.chainDescription(for: error, indent: indent, enclosingType: type(of: error)) } private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { From 2064780fb8c014fef974dea4bc14fcb0c0e00b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sun, 25 Oct 2026 17:03:39 +0100 Subject: [PATCH 2/7] Test the indent parameter and point doc links at the new signature --- .../ErrorKit.docc/Guides/Error-Chain-Debugging.md | 2 +- Sources/ErrorKit/ErrorKit.swift | 4 ++-- Sources/ErrorKit/Helpers/String+ErrorKit.swift | 4 ++-- Tests/ErrorKitTests/ErrorKitTests.swift | 12 ++++++++++++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md b/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md index 7bd4f89..c23519e 100644 --- a/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md +++ b/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md @@ -181,7 +181,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.swift b/Sources/ErrorKit/ErrorKit.swift index e985bae..6ca9141 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -151,7 +151,7 @@ public enum ErrorKit { /// /// - Parameters: /// - error: The error to describe, potentially containing nested errors - /// - indent: The initial indent string (normally "") + /// - indent: A prefix for every line after the first. Pass 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 { Self.chainDescription(for: error, indent: indent, enclosingType: type(of: error)) @@ -199,7 +199,7 @@ public enum ErrorKit { /// Generates a stable identifier that groups similar errors based on their type structure. /// - /// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages, + /// While ``errorChainDescription(for:indent:)`` provides a detailed view of an error chain including all parameters and messages, /// this function creates a consistent hash that only considers the error type hierarchy. This allows grouping similar errors /// that differ only in their specific parameters or localized messages. /// diff --git a/Sources/ErrorKit/Helpers/String+ErrorKit.swift b/Sources/ErrorKit/Helpers/String+ErrorKit.swift index 3ba134e..f545f0e 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:indent:)`` 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:indent:)`` to show the full error hierarchy, /// type information, and nested structure. Ideal for logging and debugging. /// /// ```swift diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index b21d6e6..56fdc21 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -142,6 +142,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( From 831a43e16a908789deac348fe41801560232cb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sun, 25 Oct 2026 17:30:30 +0100 Subject: [PATCH 3/7] Run the async catch closure on the caller's actor Calling `catch` from a @MainActor type or an actor failed under Swift 6 with "sending value of non-Sendable type risks causing data races". #36 solved this with `@Sendable`, but that rejects closures that capture non-Sendable values, which compile fine today. Inheriting the caller's isolation via `#isolation` fixes the actor case and keeps every existing call site working. Co-authored-by: Justin Bishop --- Sources/ErrorKit/Catching.swift | 7 +- .../CatchingIsolationTests.swift | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 Tests/ErrorKitTests/CatchingIsolationTests.swift diff --git a/Sources/ErrorKit/Catching.swift b/Sources/ErrorKit/Catching.swift index 9290d11..de5bee3 100644 --- a/Sources/ErrorKit/Catching.swift +++ b/Sources/ErrorKit/Catching.swift @@ -174,10 +174,15 @@ extension Catching { /// } /// ``` /// - /// - Parameter operation: The async throwing operation to execute. + /// The operation runs on the caller's actor, so it can use actor-isolated state and non-Sendable values directly. + /// + /// - Parameters: + /// - isolation: The actor to run on. Leave the default, which is 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)? = #isolation, _ operation: () async throws -> ReturnType ) async throws(Self) -> ReturnType { do { diff --git a/Tests/ErrorKitTests/CatchingIsolationTests.swift b/Tests/ErrorKitTests/CatchingIsolationTests.swift new file mode 100644 index 0000000..c60cb45 --- /dev/null +++ b/Tests/ErrorKitTests/CatchingIsolationTests.swift @@ -0,0 +1,77 @@ +import Testing + +import ErrorKit + +/// 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 { + try await self.fetch() + self.total + } + } + } + + actor Store { + var count = 1 + + func increment() async throws -> Int { + try await LoadError.catch { + 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 + func nonSendableCapturesStillWork() async throws { + let counter = Counter() + let result = try await LoadError.catch { + try await Task.sleep(for: .zero) + counter.value += 1 + return counter.value + } + #expect(result == 1) + } + + @Test + func errorsAreStillWrapped() async { + struct Underlying: Error {} + await #expect(throws: LoadError.self) { + try await LoadError.catch { () async throws -> Int in throw Underlying() } + } + } +} From 04b70d20ff941b0cd5e7659002e87c13d15cd4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sun, 25 Oct 2026 19:34:34 +0100 Subject: [PATCH 4/7] Restore source-compatible ErrorKit overloads Keep the original async catch and error-chain formatter signatures so existing typed function references compile. Require explicit arguments for isolation and indentation on the additional overloads. Retain actor-local closure coverage, add compilation regressions for both function references, and distinguish all catch overloads in DocC. --- Sources/ErrorKit/Catching.swift | 21 ++++++++++-- .../Guides/Typed-Throws-and-Error-Nesting.md | 3 +- Sources/ErrorKit/ErrorKit.swift | 12 +++++-- .../CatchingIsolationTests.swift | 11 +++---- .../SourceCompatibilityTests.swift | 32 +++++++++++++++++++ 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 Tests/ErrorKitTests/SourceCompatibilityTests.swift diff --git a/Sources/ErrorKit/Catching.swift b/Sources/ErrorKit/Catching.swift index de5bee3..fa0f699 100644 --- a/Sources/ErrorKit/Catching.swift +++ b/Sources/ErrorKit/Catching.swift @@ -174,15 +174,30 @@ extension Catching { /// } /// ``` /// - /// The operation runs on the caller's actor, so it can use actor-isolated state and non-Sendable values directly. + /// 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 to run on. Leave the default, which is the caller's actor. + /// - 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)? = #isolation, + isolation: isolated (any Actor)?, _ operation: () async throws -> ReturnType ) async throws(Self) -> ReturnType { do { 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 6ca9141..abd64cd 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -149,11 +149,19 @@ public enum ErrorKit { /// 2. Shows the underlying cause (DatabaseError.connectionFailed) /// 3. Provides the user-friendly message for context (users will report this) /// + /// - 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.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: A prefix for every line after the first. Pass the indentation of the surrounding text when you embed the chain in an already indented message. + /// - 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 { + public static func errorChainDescription(for error: Error, indent: String) -> String { Self.chainDescription(for: error, indent: indent, enclosingType: type(of: error)) } diff --git a/Tests/ErrorKitTests/CatchingIsolationTests.swift b/Tests/ErrorKitTests/CatchingIsolationTests.swift index c60cb45..7fc3620 100644 --- a/Tests/ErrorKitTests/CatchingIsolationTests.swift +++ b/Tests/ErrorKitTests/CatchingIsolationTests.swift @@ -1,6 +1,5 @@ -import Testing - 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 @@ -24,7 +23,7 @@ struct CatchingIsolationTests { func fetch() async throws -> Int { 2 } func refresh() async throws { - self.total = try await LoadError.catch { + self.total = try await LoadError.catch(isolation: #isolation) { try await self.fetch() + self.total } } @@ -34,7 +33,7 @@ struct CatchingIsolationTests { var count = 1 func increment() async throws -> Int { - try await LoadError.catch { + try await LoadError.catch(isolation: #isolation) { try await Task.sleep(for: .zero) self.count += 1 return self.count @@ -59,7 +58,7 @@ struct CatchingIsolationTests { @Test func nonSendableCapturesStillWork() async throws { let counter = Counter() - let result = try await LoadError.catch { + let result = try await LoadError.catch(isolation: #isolation) { try await Task.sleep(for: .zero) counter.value += 1 return counter.value @@ -71,7 +70,7 @@ struct CatchingIsolationTests { func errorsAreStillWrapped() async { struct Underlying: Error {} await #expect(throws: LoadError.self) { - try await LoadError.catch { () async throws -> Int in throw Underlying() } + try await LoadError.catch(isolation: #isolation) { () async throws -> Int in throw Underlying() } } } } 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." + """ + ) + } +} From 0c9bc2a029e3d5ed203defebf3d7479b6906d259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sun, 25 Oct 2026 21:35:41 +0100 Subject: [PATCH 5/7] Strengthen isolation and wrapping tests and restore formatter docs --- .../Guides/Error-Chain-Debugging.md | 1 + Sources/ErrorKit/ErrorKit.swift | 2 +- .../ErrorKit/Helpers/String+ErrorKit.swift | 4 +- .../CatchingIsolationTests.swift | 39 +++++++++++++++++-- Tests/ErrorKitTests/ErrorKitTests.swift | 24 ++++++++++++ 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md b/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md index c23519e..345d7df 100644 --- a/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md +++ b/Sources/ErrorKit/ErrorKit.docc/Guides/Error-Chain-Debugging.md @@ -181,6 +181,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:)`` diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index abd64cd..963a977 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -207,7 +207,7 @@ public enum ErrorKit { /// Generates a stable identifier that groups similar errors based on their type structure. /// - /// While ``errorChainDescription(for:indent:)`` provides a detailed view of an error chain including all parameters and messages, + /// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages, /// this function creates a consistent hash that only considers the error type hierarchy. This allows grouping similar errors /// that differ only in their specific parameters or localized messages. /// diff --git a/Sources/ErrorKit/Helpers/String+ErrorKit.swift b/Sources/ErrorKit/Helpers/String+ErrorKit.swift index f545f0e..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:indent:)`` 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:indent:)`` 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 index 7fc3620..96f43da 100644 --- a/Tests/ErrorKitTests/CatchingIsolationTests.swift +++ b/Tests/ErrorKitTests/CatchingIsolationTests.swift @@ -56,6 +56,7 @@ struct CatchingIsolationTests { } @Test + @MainActor func nonSendableCapturesStillWork() async throws { let counter = Counter() let result = try await LoadError.catch(isolation: #isolation) { @@ -64,13 +65,45 @@ struct CatchingIsolationTests { return counter.value } #expect(result == 1) + #expect(counter.value == 1) } @Test func errorsAreStillWrapped() async { - struct Underlying: Error {} - await #expect(throws: LoadError.self) { - try await LoadError.catch(isolation: #isolation) { () async throws -> Int in throw Underlying() } + 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 56fdc21..57b269c 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -177,6 +177,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( From 229d8abc8fc7a7169338d74cee9e230c6f4c6e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Sun, 25 Oct 2026 22:46:40 +0100 Subject: [PATCH 6/7] Fix indentation of multiline error chain messages --- Sources/ErrorKit/ErrorKit.swift | 3 ++- Tests/ErrorKitTests/ErrorKitTests.swift | 34 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 963a977..187c02d 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -162,7 +162,8 @@ public enum ErrorKit { /// - 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 { - Self.chainDescription(for: error, indent: indent, enclosingType: type(of: error)) + let description = Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) + return description.replacing("\n", with: "\n\(indent)") } private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index 57b269c..40ea2a1 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -86,6 +86,40 @@ enum ErrorKitTests { } enum ErrorChainDescription { + struct MultilineThrowable: Throwable { + let userFriendlyMessage = "First line\n\nLast line\n" + } + + @Test(arguments: ["", " ", "│ ", "\t>> "], [false, true]) + static func multilineMessageContinuationWithIndent(indent: String, deeplyNested: Bool) { + let leaf = MultilineThrowable() + 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 static func localizedError() { #expect( From 12ceaf82791448022c60bef20cf3cc6d53fb41ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Mon, 26 Oct 2026 00:17:56 +0100 Subject: [PATCH 7/7] Indent every newline character in error chain descriptions --- Sources/ErrorKit/ErrorKit.swift | 8 +++++++- Tests/ErrorKitTests/ErrorKitTests.swift | 27 +++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 187c02d..59fa9d4 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -163,7 +163,13 @@ public enum ErrorKit { /// - 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)) - return description.replacing("\n", with: "\n\(indent)") + 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/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index 40ea2a1..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." @@ -87,12 +86,12 @@ enum ErrorKitTests { enum ErrorChainDescription { struct MultilineThrowable: Throwable { - let userFriendlyMessage = "First line\n\nLast line\n" + let userFriendlyMessage: String } @Test(arguments: ["", " ", "│ ", "\t>> "], [false, true]) static func multilineMessageContinuationWithIndent(indent: String, deeplyNested: Bool) { - let leaf = MultilineThrowable() + let leaf = MultilineThrowable(userFriendlyMessage: "First line\n\nLast line\n") let error: any Error = deeplyNested ? StateError.caught(OperationError.caught(DatabaseError.caught(leaf))) @@ -120,6 +119,26 @@ enum ErrorKitTests { #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(