Skip to content

Commit a994527

Browse files
authored
[SE-0489] Better debugDescription for EncodingError and DecodingError (#80941)
Now accepted as [SE-0489](https://github.com/ZevEisenberg/swift-evolution/blob/main/proposals/0489-codable-error-printing.md). # To Do - [x] confirm which version of Swift to use for the availability annotations. Probably 6.3 at time of writing. # Context Re: [Swift forum post](https://forums.swift.org/t/the-future-of-serialization-deserialization-apis/78585/77), where a discussion about future serialization tools in Swift prompted Kevin Perry to suggest that some proposed changes could actually be made in today's stdlib. # Summary of Changes Conforms `EncodingError` and `DecodingError` to `CustomDebugStringConvertible` and adds a more-readable `debugDescription`. # Future Directions This is a pared-down version of some experiments I did in [UsefulDecode](https://github.com/ZevEisenberg/UsefulDecode). The changes in this PR are the best I could do without changing the public interface of `DecodingError` and `EncodingError`, and without modifying the way the `JSON`/`PropertyList` `Encoder`/`Decoder` in Foundation generate their errors' debug descriptions. In the above-linked [UsefulDecode](https://github.com/ZevEisenberg/UsefulDecode) repo, when JSON decoding fails, I go back and re-decode the JSON using `JSONSerialization` in order to provide more context about what failed, and why. I didn't attempt to make such a change here, but I'd like to discuss what may be possible. # Examples To illustrate the effect of the changes in this PR, I removed my changes to stdlib/public/core/Codable.swift and ran my new test cases again. Here are the resulting diffs. ## `test_encodingError_invalidValue_nonEmptyCodingPath_nilUnderlyingError` ### Before `invalidValue(234, Swift.EncodingError.Context(codingPath: [GenericCodingKey(stringValue: "first", intValue: nil), GenericCodingKey(stringValue: "second", intValue: nil), GenericCodingKey(stringValue: "2", intValue: 2)], debugDescription: "You cannot do that!", underlyingError: nil))` ### After `EncodingError.invalidValue: 234 (Int). Path: first.second[2]. Debug description: You cannot do that!` ## `test_decodingError_valueNotFound_nilUnderlyingError` ### Before `valueNotFound(Swift.String, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "firstName", intValue: nil)], debugDescription: "Description for debugging purposes", underlyingError: nil))` ### After `DecodingError.valueNotFound: Expected value of type String but found null instead. Path: [0].firstName. Debug description: Description for debugging purposes` ## `test_decodingError_keyNotFound_nonNilUnderlyingError` ### Before `keyNotFound(GenericCodingKey(stringValue: "name", intValue: nil), Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "city", intValue: nil)], debugDescription: "Just some info to help you out", underlyingError: Optional(main.GenericError(name: "hey, who turned out the lights?"))))` ### After `DecodingError.keyNotFound: Key \'name\' not found in keyed decoding container. Path: [0].address.city. Debug description: Just some info to help you out. Underlying error: GenericError(name: "hey, who turned out the lights?")` ## `test_decodingError_typeMismatch_nilUnderlyingError` ### Before `typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "city", intValue: nil), GenericCodingKey(stringValue: "birds", intValue: nil), GenericCodingKey(stringValue: "1", intValue: 1), GenericCodingKey(stringValue: "name", intValue: nil)], debugDescription: "This is where the debug description goes", underlyingError: nil))` ### After `DecodingError.typeMismatch: expected value of type String. Path: [0].address.city.birds[1].name. Debug description: This is where the debug description goes` ## `test_decodingError_dataCorrupted_nonEmptyCodingPath` ### Before `dataCorrupted(Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "first", intValue: nil), GenericCodingKey(stringValue: "second", intValue: nil), GenericCodingKey(stringValue: "2", intValue: 2)], debugDescription: "There was apparently some data corruption!", underlyingError: Optional(main.GenericError(name: "This data corruption is getting out of hand"))))` ### After `DecodingError.dataCorrupted: Data was corrupted. Path: first.second[2]. Debug description: There was apparently some data corruption!. Underlying error: GenericError(name: "This data corruption is getting out of hand")` ## `test_decodingError_valueNotFound_nonNilUnderlyingError` ### Before `valueNotFound(Swift.Int, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "population", intValue: nil)], debugDescription: "Here is the debug description for value-not-found", underlyingError: Optional(main.GenericError(name: "these aren\\\'t the droids you\\\'re looking for"))))` ### After `DecodingError.valueNotFound: Expected value of type Int but found null instead. Path: [0].population. Debug description: Here is the debug description for value-not-found. Underlying error: GenericError(name: "these aren\\\'t the droids you\\\'re looking for")` ## `test_encodingError_invalidValue_emptyCodingPath_nonNilUnderlyingError` ### Before `invalidValue(345, Swift.EncodingError.Context(codingPath: [], debugDescription: "You cannot do that!", underlyingError: Optional(main.GenericError(name: "You really cannot do that"))))` ### After `EncodingError.invalidValue: 345 (Int). Debug description: You cannot do that!. Underlying error: GenericError(name: "You really cannot do that")` ## `test_decodingError_typeMismatch_nonNilUnderlyingError` ### Before `typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "1", intValue: 1), GenericCodingKey(stringValue: "street", intValue: nil)], debugDescription: "Some debug description", underlyingError: Optional(main.GenericError(name: "some generic error goes here"))))` ### After `DecodingError.typeMismatch: expected value of type String. Path: [0].address[1].street. Debug description: Some debug description. Underlying error: GenericError(name: "some generic error goes here")` ## `test_encodingError_invalidValue_emptyCodingPath_nilUnderlyingError` ### Before `invalidValue(123, Swift.EncodingError.Context(codingPath: [], debugDescription: "You cannot do that!", underlyingError: nil))` ### After `EncodingError.invalidValue: 123 (Int). Debug description: You cannot do that!` ## `test_decodingError_keyNotFound_nilUnderlyingError` ### Before `keyNotFound(GenericCodingKey(stringValue: "name", intValue: nil), Swift.DecodingError.Context(codingPath: [GenericCodingKey(stringValue: "0", intValue: 0), GenericCodingKey(stringValue: "address", intValue: nil), GenericCodingKey(stringValue: "city", intValue: nil)], debugDescription: "How would you describe your relationship with your debugger?", underlyingError: nil))` ### After `DecodingError.keyNotFound: Key \'name\' not found in keyed decoding container. Path: [0]address.city. Debug description: How would you describe your relationship with your debugger?` ## `test_decodingError_dataCorrupted_emptyCodingPath` ### Before `dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON", underlyingError: Optional(main.GenericError(name: "just some data corruption"))))` ### After `DecodingError.dataCorrupted: Data was corrupted. Debug description: The given data was not valid JSON. Underlying error: GenericError(name: "just some data corruption")` ## `test_encodingError_invalidValue_nonEmptyCodingPath_nonNilUnderlyingError` ### Before `invalidValue(456, Swift.EncodingError.Context(codingPath: [GenericCodingKey(stringValue: "first", intValue: nil), GenericCodingKey(stringValue: "second", intValue: nil), GenericCodingKey(stringValue: "2", intValue: 2)], debugDescription: "You cannot do that!", underlyingError: Optional(main.GenericError(name: "You really cannot do that"))))` ### After `EncodingError.invalidValue: 456 (Int). Path: first.second[2]. Debug description: You cannot do that!. Underlying error: GenericError(name: "You really cannot do that")`
1 parent 0c882d4 commit a994527

File tree

6 files changed

+422
-1
lines changed

6 files changed

+422
-1
lines changed

stdlib/public/core/Codable.swift

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,58 @@ extension CodingKey {
8989
public var debugDescription: String {
9090
return description
9191
}
92+
93+
/// A simplified description: the int value, if present, in square brackets.
94+
/// Otherwise, the string value by itself. Used when concatenating coding keys
95+
/// to form a path when printing debug information.
96+
/// - parameter isFirst: Whether this is the first key in a coding path, in
97+
/// which case we will omit the prepended '.' delimiter from string keys.
98+
fileprivate func _errorPresentationDescription(isFirstInCodingPath isFirst: Bool = true) -> String {
99+
if let intValue {
100+
return "[\(intValue)]"
101+
} else {
102+
let delimiter = isFirst ? "" : "."
103+
return "\(delimiter)\(stringValue._escapedForCodingKeyErrorPresentationDescription)"
104+
}
105+
}
106+
}
107+
108+
extension [any CodingKey] {
109+
/// Concatenates the elements of an array of coding keys and joins them with
110+
/// "/" separators to make them read like a path.
111+
fileprivate func _errorPresentationDescription() -> String {
112+
return (
113+
self.prefix(1).map { $0._errorPresentationDescription(isFirstInCodingPath: true) }
114+
+ self.dropFirst(1).map { $0._errorPresentationDescription(isFirstInCodingPath: false) }
115+
).joined(separator: "")
116+
}
117+
}
118+
119+
extension String {
120+
/// When printing coding paths, delimit string keys with a '.' (period). If
121+
/// the key contains a period, escape it with backticks so that it can be
122+
/// distinguished from the delimiter. Also escape backslashes and backticks
123+
/// (but *not* periods) to avoid confusion with delimiters.
124+
internal var _escapedForCodingKeyErrorPresentationDescription: String {
125+
let charactersThatNeedBackticks: Set<Character> = [".", "`", "\\"]
126+
let charactersThatNeedEscaping: Set<Character> = ["`", "\\"]
127+
assert(
128+
charactersThatNeedEscaping.isSubset(of: charactersThatNeedBackticks),
129+
"Only some characters in backticks will require further escaping to disambiguate them from the backticks"
130+
)
131+
132+
var escaped = self
133+
var needsBackticks = false
134+
for (character, index) in zip(self, indices).reversed() {
135+
if charactersThatNeedBackticks.contains(character) {
136+
needsBackticks = true
137+
if charactersThatNeedEscaping.contains(character) {
138+
escaped.insert("\\", at: index)
139+
}
140+
}
141+
}
142+
return needsBackticks ? "`\(escaped)`" : self
143+
}
92144
}
93145

94146
//===----------------------------------------------------------------------===//
@@ -3724,6 +3776,80 @@ public enum DecodingError: Error {
37243776
}
37253777
}
37263778

3779+
@available(SwiftStdlib 6.3, *)
3780+
extension EncodingError: CustomDebugStringConvertible {
3781+
/// A textual representation of this encoding error, intended for debugging.
3782+
///
3783+
/// - Important: The contents of the returned string are not guaranteed to
3784+
/// remain stable: they may arbitrarily change in any Swift release.
3785+
@available(SwiftStdlib 6.3, *)
3786+
public var debugDescription: String {
3787+
let (message, context) = switch self {
3788+
case .invalidValue(let value, let context):
3789+
(
3790+
"EncodingError.invalidValue: \(String(reflecting: value)) (\(type(of: value)))",
3791+
context
3792+
)
3793+
}
3794+
3795+
var output = message
3796+
3797+
let contextDebugDescription = context.debugDescription
3798+
3799+
if !context.codingPath.isEmpty {
3800+
output.append(". Path: \(context.codingPath._errorPresentationDescription())")
3801+
}
3802+
3803+
if !contextDebugDescription.isEmpty {
3804+
output.append(". Debug description: \(context.debugDescription)")
3805+
}
3806+
3807+
if let underlyingError = context.underlyingError {
3808+
output.append(". Underlying error: \(underlyingError)")
3809+
}
3810+
3811+
return output
3812+
}
3813+
}
3814+
3815+
@available(SwiftStdlib 6.3, *)
3816+
extension DecodingError: CustomDebugStringConvertible {
3817+
/// A textual representation of this decoding error, intended for debugging.
3818+
///
3819+
/// - Important: The contents of the returned string are not guaranteed to
3820+
/// remain stable: they may arbitrarily change in any Swift release.
3821+
@available(SwiftStdlib 6.3, *)
3822+
public var debugDescription: String {
3823+
let (message, context) = switch self {
3824+
case .typeMismatch(let expectedType, let context):
3825+
("DecodingError.typeMismatch: expected value of type \(expectedType)", context)
3826+
case .valueNotFound(let expectedType, let context):
3827+
("DecodingError.valueNotFound: Expected value of type \(expectedType) but found null instead", context)
3828+
case .keyNotFound(let expectedKey, let context):
3829+
("DecodingError.keyNotFound: Key '\(expectedKey._errorPresentationDescription())' not found in keyed decoding container", context)
3830+
case .dataCorrupted(let context):
3831+
("DecodingError.dataCorrupted: Data was corrupted", context)
3832+
}
3833+
3834+
var output = message
3835+
3836+
if !context.codingPath.isEmpty {
3837+
output.append(". Path: \(context.codingPath._errorPresentationDescription())")
3838+
}
3839+
3840+
let contextDebugDescription = context.debugDescription
3841+
if !contextDebugDescription.isEmpty {
3842+
output.append(". Debug description: \(contextDebugDescription)")
3843+
}
3844+
3845+
if let underlyingError = context.underlyingError {
3846+
output.append(". Underlying error: \(underlyingError)")
3847+
}
3848+
3849+
return output
3850+
}
3851+
}
3852+
37273853
// The following extensions allow for easier error construction.
37283854

37293855
internal struct _GenericIndexKey: CodingKey, Sendable {

test/Macros/macro_plugin_error.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func test() {
3737
// FIXME: -module-abi-name ABI name is leaking.
3838

3939
let _: String = #fooMacro(1)
40-
// expected-error @-1 {{typeMismatch(_CompilerSwiftCompilerPluginMessageHandling.PluginToHostMessage}}
40+
// expected-error @-1 {{typeMismatch}}
4141
let _: String = #fooMacro(2)
4242
// expected-error @-1 {{failed to receive result from plugin (from macro 'fooMacro')}}
4343
let _: String = #fooMacro(3)

test/abi/macOS/arm64/stdlib.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,3 +1151,13 @@ Added: _swift_retain_preservemost
11511151

11521152
// New debug environment variable for the concurrency runtime.
11531153
Added: _concurrencyEnableTaskSlabAllocator
1154+
1155+
// SE-0489 Better debugDescription for EncodingError and DecodingError
1156+
Added: _$ss13DecodingErrorO16debugDescriptionSSvg
1157+
Added: _$ss13DecodingErrorO16debugDescriptionSSvpMV
1158+
Added: _$ss13DecodingErrorOs28CustomDebugStringConvertiblesMc
1159+
Added: _$ss13DecodingErrorOs28CustomDebugStringConvertiblesWP
1160+
Added: _$ss13EncodingErrorO16debugDescriptionSSvg
1161+
Added: _$ss13EncodingErrorO16debugDescriptionSSvpMV
1162+
Added: _$ss13EncodingErrorOs28CustomDebugStringConvertiblesMc
1163+
Added: _$ss13EncodingErrorOs28CustomDebugStringConvertiblesWP

test/abi/macOS/x86_64/stdlib.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,3 +1146,13 @@ Added: __swift_debug_metadataAllocatorPageSize
11461146

11471147
// New debug environment variable for the concurrency runtime.
11481148
Added: _concurrencyEnableTaskSlabAllocator
1149+
1150+
// SE-0489 Better debugDescription for EncodingError and DecodingError
1151+
Added: _$ss13DecodingErrorO16debugDescriptionSSvg
1152+
Added: _$ss13DecodingErrorO16debugDescriptionSSvpMV
1153+
Added: _$ss13DecodingErrorOs28CustomDebugStringConvertiblesMc
1154+
Added: _$ss13DecodingErrorOs28CustomDebugStringConvertiblesWP
1155+
Added: _$ss13EncodingErrorO16debugDescriptionSSvg
1156+
Added: _$ss13EncodingErrorO16debugDescriptionSSvpMV
1157+
Added: _$ss13EncodingErrorOs28CustomDebugStringConvertiblesMc
1158+
Added: _$ss13EncodingErrorOs28CustomDebugStringConvertiblesWP

test/api-digester/stability-stdlib-abi-without-asserts.test

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,4 +879,8 @@ Func _float64ToStringImpl(_:_:_:_:) is a new API without '@available'
879879
Func _int64ToStringImpl(_:_:_:_:_:) is a new API without '@available'
880880
Func _uint64ToStringImpl(_:_:_:_:_:) is a new API without '@available'
881881

882+
// New conformances from SE-0489: Better debugDescription for EncodingError and DecodingError
883+
Enum DecodingError has added a conformance to an existing protocol CustomDebugStringConvertible
884+
Enum EncodingError has added a conformance to an existing protocol CustomDebugStringConvertible
885+
882886
// *** DO NOT DISABLE OR XFAIL THIS TEST. *** (See comment above.)

0 commit comments

Comments
 (0)