From 9bfd5f82dc97cfdd248d036f211c9dd51bb51146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 01/15] fix build issues --- Sources/NewCodable/JSON/JSONParserDecoder.swift | 1 + Sources/NewCodable/JSON/NewJSONEncoder.swift | 1 + Tests/NewCodableTests/CodableRevolutionTests.swift | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/NewCodable/JSON/JSONParserDecoder.swift b/Sources/NewCodable/JSON/JSONParserDecoder.swift index 451284833..c21cc6704 100644 --- a/Sources/NewCodable/JSON/JSONParserDecoder.swift +++ b/Sources/NewCodable/JSON/JSONParserDecoder.swift @@ -1749,6 +1749,7 @@ extension JSONParserDecoder.ArrayDecoder: CommonArrayDecoder { } extension JSONParserDecoder.StructDecoder: CommonStructDecoder { + @_lifetime(self: copy self) public mutating func decodeExpectedOrderField(required: Bool, matchingClosure: (UTF8Span) -> Bool, andValue valueDecoderClosure: (inout JSONParserDecoder) throws(CodingError.Decoding) -> Void) throws(CodingError.Decoding) -> Bool { try self.decodeExpectedOrderField(required: required, matchingClosure: matchingClosure, optimizedSafeStringKey: nil, andValue: valueDecoderClosure) } diff --git a/Sources/NewCodable/JSON/NewJSONEncoder.swift b/Sources/NewCodable/JSON/NewJSONEncoder.swift index 41425a41f..650b14617 100644 --- a/Sources/NewCodable/JSON/NewJSONEncoder.swift +++ b/Sources/NewCodable/JSON/NewJSONEncoder.swift @@ -623,6 +623,7 @@ extension JSONDirectEncoder { try self.encode(arbitraryPrecisionNumber: decimal.description.utf8Span) } + @_lifetime(self: copy self) internal mutating func encodeGenericNonCopyable(_ value: borrowing T) throws(CodingError.Encoding) { try value.encode(to: &self) } diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index 97dc85db0..95bd40aff 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -591,6 +591,7 @@ struct NewCodableTests { guard let decodedCase else { throw CodingError.dataCorrupted(debugDescription: "Missing enum case") } + let unknownError = CodingError.unknownKey(decodedCase.utf8Span) // TODO: It'd be nice to figure out how to encapsulate this. if contents.isEmpty { return try structDecoder.withWrappingDecoder { wrappingDecoder throws(CodingError.Decoding) in @@ -600,7 +601,7 @@ struct NewCodableTests { case "bar": try decodeBar(from: &wrappingDecoder) default: - throw CodingError.unknownKey(decodedCase.utf8Span) + throw unknownError } } } else { @@ -611,7 +612,7 @@ struct NewCodableTests { case "bar": return try decodeBar(from: &valueDecoder) default: - throw CodingError.unknownKey(decodedCase.utf8Span) + throw unknownError } } } From 4f6ed482cf5baeee4ff62804adda19342ff9bbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 02/15] @JSONEncodable macro --- Package.swift | 22 ++ Sources/NewCodable/Macros.swift | 15 + .../NewCodableMacros/JSONEncodableMacro.swift | 180 +++++++++ .../NewCodableMacrosPlugin.swift | 21 ++ .../JSONEncodableMacroTests.swift | 352 ++++++++++++++++++ .../CodableRevolutionTests.swift | 28 ++ 6 files changed, 618 insertions(+) create mode 100644 Sources/NewCodable/Macros.swift create mode 100644 Sources/NewCodableMacros/JSONEncodableMacro.swift create mode 100644 Sources/NewCodableMacros/NewCodableMacrosPlugin.swift create mode 100644 Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift diff --git a/Package.swift b/Package.swift index c1dd72d04..84c644a71 100644 --- a/Package.swift +++ b/Package.swift @@ -228,11 +228,25 @@ let package = Package( swiftSettings: availabilityMacros + featureSettings + testOnlySwiftSettings ), + // NewCodableMacros + .macro( + name: "NewCodableMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + swiftSettings: featureSettings + ), + // NewCodable .target( name: "NewCodable", dependencies: [ .target(name: "FoundationEssentials"), + "NewCodableMacros", .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "BasicContainers", package: "swift-collections"), ], @@ -277,6 +291,14 @@ let package = Package( .enableExperimentalFeature("Lifetimes"), .enableUpcomingFeature("MemberImportVisibility"), ] + ), + .testTarget( + name: "NewCodableMacrosTests", + dependencies: [ + "NewCodableMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ], + swiftSettings: featureSettings ) ] ) diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift new file mode 100644 index 000000000..77a0cfb23 --- /dev/null +++ b/Sources/NewCodable/Macros.swift @@ -0,0 +1,15 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +@attached(member, names: named(CodingFields)) +@attached(extension, conformances: JSONEncodable, names: named(encode)) +public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: "JSONEncodableMacro") diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift new file mode 100644 index 000000000..2fe49d8fa --- /dev/null +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -0,0 +1,180 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxBuilder +import SwiftDiagnostics + +public struct JSONEncodableMacro { } + +private struct StoredProperty { + let name: String + let jsonKey: String +} + +private func extractStoredProperties( + from members: MemberBlockSyntax, + in context: some MacroExpansionContext +) -> [StoredProperty] { + var properties: [StoredProperty] = [] + + for member in members.members { + guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { + continue + } + + if varDecl.modifiers.contains(where: { + $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.lazy) + }) { + continue + } + + for binding in varDecl.bindings { + if let accessorBlock = binding.accessorBlock { + switch accessorBlock.accessors { + case .getter: + continue + case .accessors(let accessors): + let hasGetOrSet = accessors.contains { + $0.accessorSpecifier.tokenKind == .keyword(.get) || + $0.accessorSpecifier.tokenKind == .keyword(.set) + } + if hasGetOrSet { + continue + } + } + } + + guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { + continue + } + + let propertyName = pattern.identifier.trimmedDescription + let jsonKey = propertyName + + properties.append(StoredProperty(name: propertyName, jsonKey: jsonKey)) + } + } + + return properties +} + +extension JSONEncodableMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + context.diagnose(.init( + node: node, + message: JSONEncodableDiagnostic.notAStruct + )) + return [] + } + + let properties = extractStoredProperties(from: declaration.memberBlock, in: context) + + if properties.isEmpty { + return [] + } + + let cases = properties.map { "case \($0.name)" }.joined(separator: "\n ") + + let switchCases = properties.map { + "case .\($0.name): \"\($0.jsonKey)\"" + }.joined(separator: "\n ") + + let enumDecl: DeclSyntax = """ + enum CodingFields: Int, JSONOptimizedCodingField { + \(raw: cases) + + @_transparent + var staticString: StaticString { + switch self { + \(raw: switchCases) + } + } + } + """ + + return [enumDecl] + } +} + +extension JSONEncodableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + return [] + } + + let properties = extractStoredProperties(from: declaration.memberBlock, in: context) + + let extensionDecl: DeclSyntax + if properties.isEmpty { + extensionDecl = """ + extension \(type.trimmed): JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + """ + } else { + let encodeStatements = properties.map { + "try structEncoder.encode(field: CodingFields.\($0.name), value: self.\($0.name))" + }.joined(separator: "\n ") + + let fieldCount = properties.count + + extensionDecl = """ + extension \(type.trimmed): JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: \(raw: fieldCount)) { structEncoder throws(CodingError.Encoding) in + \(raw: encodeStatements) + } + } + } + """ + } + + guard let ext = extensionDecl.as(ExtensionDeclSyntax.self) else { + return [] + } + + return [ext] + } +} + +enum JSONEncodableDiagnostic: String, DiagnosticMessage { + case notAStruct + + var message: String { + switch self { + case .notAStruct: + return "@JSONEncodable can only be applied to structs" + } + } + + var diagnosticID: MessageID { + MessageID(domain: "NewCodableMacros", id: rawValue) + } + + var severity: DiagnosticSeverity { .error } +} diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift new file mode 100644 index 000000000..710731e34 --- /dev/null +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftCompilerPlugin + +@main +struct NewCodableMacrosPlugin: CompilerPlugin { + var providingMacros: [Macro.Type] = [ + JSONEncodableMacro.self, + ] +} diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift new file mode 100644 index 000000000..de3f66520 --- /dev/null +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -0,0 +1,352 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport +import Testing +import NewCodableMacros + +let testMacros: [String: Macro.Type] = [ + "JSONEncodable": JSONEncodableMacro.self, +] + +@Suite("@JSONEncodable Macro") +struct JSONEncodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @JSONEncodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .age: "age" + } + } + } + } + + extension Person: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + """, + macros: testMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @JSONEncodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + case rating + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .rating: "rating" + } + } + } + } + + extension Item: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.rating, value: self.rating) + } + } + } + """, + macros: testMacros + ) + } + + @Test func computedPropertySkipped() { + assertMacroExpansion( + """ + @JSONEncodable + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + } + """, + expandedSource: """ + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + } + } + + extension Thing: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } + + @Test func staticPropertySkipped() { + assertMacroExpansion( + """ + @JSONEncodable + struct Config { + static let defaultName = "test" + let name: String + } + """, + expandedSource: """ + struct Config { + static let defaultName = "test" + let name: String + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + } + } + + extension Config: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @JSONEncodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONEncodable can only be applied to structs", line: 1, column: 1) + ], + macros: testMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @JSONEncodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + """, + macros: testMacros + ) + } + + @Test func lazyVarSkipped() { + assertMacroExpansion( + """ + @JSONEncodable + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + } + """, + expandedSource: """ + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + } + } + + extension Cached: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } + + @Test func propertyWithDefaultValue() { + assertMacroExpansion( + """ + @JSONEncodable + struct WithDefault { + let name: String = "default" + let age: Int + } + """, + expandedSource: """ + struct WithDefault { + let name: String = "default" + let age: Int + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .age: "age" + } + } + } + } + + extension WithDefault: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + """, + macros: testMacros + ) + } + + @Test func propertyWithObservers() { + assertMacroExpansion( + """ + @JSONEncodable + struct Observed { + var count: Int { + didSet { print(count) } + } + let name: String + } + """, + expandedSource: """ + struct Observed { + var count: Int { + didSet { print(count) } + } + let name: String + + enum CodingFields: Int, JSONOptimizedCodingField { + case count + case name + + @_transparent + var staticString: StaticString { + switch self { + case .count: "count" + case .name: "name" + } + } + } + } + + extension Observed: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.count, value: self.count) + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } +} diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index 95bd40aff..fb5855a37 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2072,3 +2072,31 @@ extension Array where Element: BinaryFloatingPoint { return true } } + +@JSONEncodable +struct SimplePost { + let title: String + let body: String +} + +@JSONEncodable +struct EmptyEncodable {} + +@Suite("@JSONEncodable Macro Integration") +struct JSONEncodableMacroIntegrationTests { + + @Test func simpleStruct() throws { + let post = SimplePost(title: "Hello", body: "World") + let data = try NewJSONEncoder().encode(post) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"title\":\"Hello\"")) + #expect(json.contains("\"body\":\"World\"")) + } + + @Test func emptyStruct() throws { + let empty = EmptyEncodable() + let data = try NewJSONEncoder().encode(empty) + let json = String(data: data, encoding: .utf8)! + #expect(json == "{}") + } +} From 0ae71dfc3a87bb4b2e639e8dd71cb671a3a9334e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 03/15] @CodingKey macro --- Sources/NewCodable/Macros.swift | 3 + Sources/NewCodableMacros/CodingKeyMacro.swift | 24 +++++++ .../NewCodableMacros/JSONEncodableMacro.swift | 30 +++++++- .../NewCodableMacrosPlugin.swift | 1 + .../JSONEncodableMacroTests.swift | 68 +++++++++++++++++++ .../CodableRevolutionTests.swift | 25 +++++++ 6 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 Sources/NewCodableMacros/CodingKeyMacro.swift diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index 77a0cfb23..4cf21632f 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -13,3 +13,6 @@ @attached(member, names: named(CodingFields)) @attached(extension, conformances: JSONEncodable, names: named(encode)) public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: "JSONEncodableMacro") + +@attached(peer) +public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") diff --git a/Sources/NewCodableMacros/CodingKeyMacro.swift b/Sources/NewCodableMacros/CodingKeyMacro.swift new file mode 100644 index 000000000..fe51d3f56 --- /dev/null +++ b/Sources/NewCodableMacros/CodingKeyMacro.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct CodingKeyMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index 2fe49d8fa..fdd442803 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -39,6 +39,15 @@ private func extractStoredProperties( continue } + let customKey = customCodingKey(from: varDecl.attributes) + if customKey != nil && varDecl.bindings.count > 1 { + context.diagnose(.init( + node: Syntax(varDecl), + message: JSONEncodableDiagnostic.codingKeyOnMultipleBindings + )) + continue + } + for binding in varDecl.bindings { if let accessorBlock = binding.accessorBlock { switch accessorBlock.accessors { @@ -60,7 +69,7 @@ private func extractStoredProperties( } let propertyName = pattern.identifier.trimmedDescription - let jsonKey = propertyName + let jsonKey = customKey ?? propertyName properties.append(StoredProperty(name: propertyName, jsonKey: jsonKey)) } @@ -69,6 +78,22 @@ private func extractStoredProperties( return properties } +private func customCodingKey(from attributes: AttributeListSyntax) -> String? { + for attribute in attributes { + guard let attr = attribute.as(AttributeSyntax.self), + let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), + identifierType.name.trimmedDescription == "CodingKey", + let arguments = attr.arguments?.as(LabeledExprListSyntax.self), + let firstArg = arguments.first, + let stringLiteral = firstArg.expression.as(StringLiteralExprSyntax.self), + let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) else { + continue + } + return segment.content.text + } + return nil +} + extension JSONEncodableMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, @@ -164,11 +189,14 @@ extension JSONEncodableMacro: ExtensionMacro { enum JSONEncodableDiagnostic: String, DiagnosticMessage { case notAStruct + case codingKeyOnMultipleBindings var message: String { switch self { case .notAStruct: return "@JSONEncodable can only be applied to structs" + case .codingKeyOnMultipleBindings: + return "@CodingKey cannot be applied to a declaration with multiple bindings" } } diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index 710731e34..eebb8ada0 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -17,5 +17,6 @@ import SwiftCompilerPlugin struct NewCodableMacrosPlugin: CompilerPlugin { var providingMacros: [Macro.Type] = [ JSONEncodableMacro.self, + CodingKeyMacro.self, ] } diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index de3f66520..e5810b7cd 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -18,6 +18,7 @@ import NewCodableMacros let testMacros: [String: Macro.Type] = [ "JSONEncodable": JSONEncodableMacro.self, + "CodingKey": CodingKeyMacro.self, ] @Suite("@JSONEncodable Macro") @@ -64,6 +65,47 @@ struct JSONEncodableMacroTests { ) } + @Test func customCodingKey() { + assertMacroExpansion( + """ + @JSONEncodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + + enum CodingFields: Int, JSONOptimizedCodingField { + case publishDate + case title + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: "date_published" + case .title: "title" + } + } + } + } + + extension Post: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.publishDate, value: self.publishDate) + try structEncoder.encode(field: CodingFields.title, value: self.title) + } + } + } + """, + macros: testMacros + ) + } + @Test func optionalProperty() { assertMacroExpansion( """ @@ -264,6 +306,32 @@ struct JSONEncodableMacroTests { ) } + @Test func codingKeyOnMultipleBindingsError() { + assertMacroExpansion( + """ + @JSONEncodable + struct Multi { + @CodingKey("custom") var x, y: Int + } + """, + expandedSource: """ + struct Multi { + var x, y: Int + } + + extension Multi: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodingKey cannot be applied to a declaration with multiple bindings", line: 3, column: 5) + ], + macros: testMacros + ) + } + @Test func propertyWithDefaultValue() { assertMacroExpansion( """ diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index fb5855a37..df3c6aeaf 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2079,6 +2079,14 @@ struct SimplePost { let body: String } +@JSONEncodable +struct BlogPost { + let title: String + @CodingKey("date_published") let publishDate: String + let tags: [String] + let rating: Double? +} + @JSONEncodable struct EmptyEncodable {} @@ -2093,6 +2101,23 @@ struct JSONEncodableMacroIntegrationTests { #expect(json.contains("\"body\":\"World\"")) } + @Test func customCodingKey() throws { + let post = BlogPost(title: "Test", publishDate: "2026-01-01", tags: ["swift"], rating: 4.5) + let data = try NewJSONEncoder().encode(post) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"date_published\":\"2026-01-01\"")) + #expect(json.contains("\"title\":\"Test\"")) + #expect(json.contains("\"tags\":[\"swift\"]")) + #expect(json.contains("\"rating\":4.5")) + } + + @Test func optionalNilValue() throws { + let post = BlogPost(title: "Test", publishDate: "2026-01-01", tags: [], rating: nil) + let data = try NewJSONEncoder().encode(post) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"rating\":null")) + } + @Test func emptyStruct() throws { let empty = EmptyEncodable() let data = try NewJSONEncoder().encode(empty) From 4d51b8362b96e41bb0f2c59e469941ec6a5cd1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 04/15] @JSONDecodable macro --- Sources/NewCodable/Macros.swift | 3 + .../NewCodableMacros/JSONDecodableMacro.swift | 236 ++++++++++++ .../NewCodableMacrosPlugin.swift | 1 + .../JSONDecodableMacroTests.swift | 364 ++++++++++++++++++ .../CodableRevolutionTests.swift | 66 ++++ 5 files changed, 670 insertions(+) create mode 100644 Sources/NewCodableMacros/JSONDecodableMacro.swift create mode 100644 Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index 4cf21632f..f61287519 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -14,5 +14,8 @@ @attached(extension, conformances: JSONEncodable, names: named(encode)) public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: "JSONEncodableMacro") +@attached(extension, conformances: JSONDecodable, names: named(decode)) +public macro JSONDecodable() = #externalMacro(module: "NewCodableMacros", type: "JSONDecodableMacro") + @attached(peer) public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift new file mode 100644 index 000000000..6eb3fd846 --- /dev/null +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxBuilder +import SwiftDiagnostics + +public struct JSONDecodableMacro { } + +private struct DecodableStoredProperty { + let name: String + let jsonKey: String + let typeName: String + let isOptional: Bool +} + +private func extractDecodableStoredProperties( + from members: MemberBlockSyntax, + in context: some MacroExpansionContext +) -> [DecodableStoredProperty]? { + var properties: [DecodableStoredProperty] = [] + + for member in members.members { + guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { + continue + } + + if varDecl.modifiers.contains(where: { + $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.lazy) + }) { + continue + } + + let customKey = decodableCustomCodingKey(from: varDecl.attributes) + if customKey != nil && varDecl.bindings.count > 1 { + context.diagnose(.init( + node: Syntax(varDecl), + message: JSONDecodableDiagnostic.codingKeyOnMultipleBindings + )) + continue + } + + for binding in varDecl.bindings { + if let accessorBlock = binding.accessorBlock { + switch accessorBlock.accessors { + case .getter: + continue + case .accessors(let accessors): + let hasGetOrSet = accessors.contains { + $0.accessorSpecifier.tokenKind == .keyword(.get) || + $0.accessorSpecifier.tokenKind == .keyword(.set) + } + if hasGetOrSet { + continue + } + } + } + + guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { + continue + } + + guard let typeAnnotation = binding.typeAnnotation else { + context.diagnose(.init( + node: Syntax(binding), + message: JSONDecodableDiagnostic.missingTypeAnnotation + )) + return nil + } + + let propertyName = pattern.identifier.trimmedDescription + let jsonKey = customKey ?? propertyName + + let type = typeAnnotation.type + let isOptional: Bool + let typeName: String + + if let optionalType = type.as(OptionalTypeSyntax.self) { + isOptional = true + typeName = optionalType.wrappedType.trimmedDescription + } else if let iuoType = type.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { + isOptional = true + typeName = iuoType.wrappedType.trimmedDescription + } else { + isOptional = false + typeName = type.trimmedDescription + } + + properties.append(DecodableStoredProperty( + name: propertyName, + jsonKey: jsonKey, + typeName: typeName, + isOptional: isOptional + )) + } + } + + return properties +} + +private func decodableCustomCodingKey(from attributes: AttributeListSyntax) -> String? { + for attribute in attributes { + guard let attr = attribute.as(AttributeSyntax.self), + let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), + identifierType.name.trimmedDescription == "CodingKey", + let arguments = attr.arguments?.as(LabeledExprListSyntax.self), + let firstArg = arguments.first, + let stringLiteral = firstArg.expression.as(StringLiteralExprSyntax.self), + let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) else { + continue + } + return segment.content.text + } + return nil +} + +extension JSONDecodableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + context.diagnose(.init( + node: node, + message: JSONDecodableDiagnostic.notAStruct + )) + return [] + } + + guard let properties = extractDecodableStoredProperties(from: declaration.memberBlock, in: context) else { + return [] + } + + let typeName = type.trimmed + + let extensionDecl: DeclSyntax + if properties.isEmpty { + extensionDecl = """ + extension \(typeName): JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> \(typeName) { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + \(typeName)() + } + } + } + """ + } else { + let varDeclarations = properties.map { + "var \($0.name): \($0.typeName)?" + }.joined(separator: "\n ") + + let switchCases = properties.map { + "case \"\($0.jsonKey)\": \($0.name) = try valueDecoder.decode(\($0.typeName).self)" + }.joined(separator: "\n ") + + let requiredProperties = properties.filter { !$0.isOptional } + + let guardAndReturn: String + if requiredProperties.isEmpty { + let args = properties.map { "\($0.name): \($0.name)" }.joined(separator: ", ") + guardAndReturn = "return \(typeName)(\(args))" + } else { + let guardNames = requiredProperties.map { "let \($0.name)" }.joined(separator: ", ") + let args = properties.map { + "\($0.name): \($0.name)" + }.joined(separator: ", ") + guardAndReturn = """ + guard \(guardNames) else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return \(typeName)(\(args)) + """ + } + + extensionDecl = """ + extension \(typeName): JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> \(typeName) { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + \(raw: varDeclarations) + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + \(raw: switchCases) + default: break + } + return false + } + \(raw: guardAndReturn) + } + } + } + """ + } + + guard let ext = extensionDecl.as(ExtensionDeclSyntax.self) else { + return [] + } + + return [ext] + } +} + +enum JSONDecodableDiagnostic: String, DiagnosticMessage { + case notAStruct + case codingKeyOnMultipleBindings + case missingTypeAnnotation + + var message: String { + switch self { + case .notAStruct: + return "@JSONDecodable can only be applied to structs" + case .codingKeyOnMultipleBindings: + return "@CodingKey cannot be applied to a declaration with multiple bindings" + case .missingTypeAnnotation: + return "@JSONDecodable requires all stored properties to have explicit type annotations" + } + } + + var diagnosticID: MessageID { + MessageID(domain: "NewCodableMacros", id: rawValue) + } + + var severity: DiagnosticSeverity { .error } +} diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index eebb8ada0..4274e6979 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -17,6 +17,7 @@ import SwiftCompilerPlugin struct NewCodableMacrosPlugin: CompilerPlugin { var providingMacros: [Macro.Type] = [ JSONEncodableMacro.self, + JSONDecodableMacro.self, CodingKeyMacro.self, ] } diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift new file mode 100644 index 000000000..1a1d47d7c --- /dev/null +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -0,0 +1,364 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport +import Testing +import NewCodableMacros + +@Suite("@JSONDecodable Macro") +struct JSONDecodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @JSONDecodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + } + + extension Person: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Person { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var age: Int? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "age": age = try valueDecoder.decode(Int.self) + default: break + } + return false + } + guard let name, let age else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Person(name: name, age: age) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @JSONDecodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + } + + extension Item: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Item { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var rating: Double? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "rating": rating = try valueDecoder.decode(Double.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Item(name: name, rating: rating) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func allOptionalProperties() { + assertMacroExpansion( + """ + @JSONDecodable + struct Preferences { + let theme: String? + let fontSize: Int? + } + """, + expandedSource: """ + struct Preferences { + let theme: String? + let fontSize: Int? + } + + extension Preferences: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Preferences { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var theme: String? + var fontSize: Int? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "theme": theme = try valueDecoder.decode(String.self) + case "fontSize": fontSize = try valueDecoder.decode(Int.self) + default: break + } + return false + } + return Preferences(theme: theme, fontSize: fontSize) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func customCodingKey() { + assertMacroExpansion( + """ + @JSONDecodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + } + + extension Post: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Post { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var publishDate: String? + var title: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "date_published": publishDate = try valueDecoder.decode(String.self) + case "title": title = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let publishDate, let title else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Post(publishDate: publishDate, title: title) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func computedPropertySkipped() { + assertMacroExpansion( + """ + @JSONDecodable + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + } + """, + expandedSource: """ + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + } + + extension Thing: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Thing { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Thing(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func staticPropertySkipped() { + assertMacroExpansion( + """ + @JSONDecodable + struct Config { + static let defaultName = "test" + let name: String + } + """, + expandedSource: """ + struct Config { + static let defaultName = "test" + let name: String + } + + extension Config: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Config(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func lazyVarSkipped() { + assertMacroExpansion( + """ + @JSONDecodable + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + } + """, + expandedSource: """ + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + } + + extension Cached: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Cached { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Cached(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @JSONDecodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Empty { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + Empty() + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @JSONDecodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONDecodable can only be applied to structs", line: 1, column: 1) + ], + macros: decodableTestMacros + ) + } + + @Test func propertyWithoutTypeAnnotation() { + assertMacroExpansion( + """ + @JSONDecodable + struct Bad { + let name = "default" + } + """, + expandedSource: """ + struct Bad { + let name = "default" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONDecodable requires all stored properties to have explicit type annotations", line: 3, column: 5) + ], + macros: decodableTestMacros + ) + } +} + +private let decodableTestMacros: [String: Macro.Type] = [ + "JSONDecodable": JSONDecodableMacro.self, + "CodingKey": CodingKeyMacro.self, +] diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index df3c6aeaf..7e9ed40e6 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2090,6 +2090,28 @@ struct BlogPost { @JSONEncodable struct EmptyEncodable {} +@JSONEncodable @JSONDecodable +struct RoundTripPerson { + let name: String + let age: Int +} + +@JSONEncodable @JSONDecodable +struct RoundTripPost { + let title: String + @CodingKey("date_published") let publishDate: String + let rating: Double? +} + +@JSONDecodable +struct DecodableOnly { + let name: String + let value: Int +} + +@JSONDecodable +struct EmptyDecodable {} + @Suite("@JSONEncodable Macro Integration") struct JSONEncodableMacroIntegrationTests { @@ -2125,3 +2147,47 @@ struct JSONEncodableMacroIntegrationTests { #expect(json == "{}") } } + +@Suite("@JSONDecodable Macro Integration") +struct JSONDecodableMacroIntegrationTests { + + @Test func roundTripBasic() throws { + let original = RoundTripPerson(name: "Alice", age: 30) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(RoundTripPerson.self, from: data) + #expect(decoded.name == "Alice") + #expect(decoded.age == 30) + } + + @Test func roundTripCustomCodingKey() throws { + let original = RoundTripPost(title: "Hello", publishDate: "2026-01-01", rating: 4.5) + let data = try NewJSONEncoder().encode(original) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"date_published\":\"2026-01-01\"")) + let decoded = try NewJSONDecoder().decode(RoundTripPost.self, from: data) + #expect(decoded.title == "Hello") + #expect(decoded.publishDate == "2026-01-01") + #expect(decoded.rating == 4.5) + } + + @Test func roundTripOptionalNil() throws { + let original = RoundTripPost(title: "Test", publishDate: "2026-03-05", rating: nil) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(RoundTripPost.self, from: data) + #expect(decoded.title == "Test") + #expect(decoded.rating == nil) + } + + @Test func decodeOnly() throws { + let json = Data(#"{"name":"Bob","value":42}"#.utf8) + let decoded = try NewJSONDecoder().decode(DecodableOnly.self, from: json) + #expect(decoded.name == "Bob") + #expect(decoded.value == 42) + } + + @Test func emptyDecodable() throws { + let json = Data("{}".utf8) + let decoded = try NewJSONDecoder().decode(EmptyDecodable.self, from: json) + _ = decoded + } +} From 1f71da8ea1159a960307174d1b55a9a171e9104c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 05/15] @JSONCodable macro --- Sources/NewCodable/Macros.swift | 4 + .../NewCodableMacros/JSONCodableMacro.swift | 58 ++++ .../NewCodableMacrosPlugin.swift | 1 + .../JSONCodableMacroTests.swift | 262 ++++++++++++++++++ .../CodableRevolutionTests.swift | 44 +++ 5 files changed, 369 insertions(+) create mode 100644 Sources/NewCodableMacros/JSONCodableMacro.swift create mode 100644 Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index f61287519..131ce726f 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -17,5 +17,9 @@ public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: @attached(extension, conformances: JSONDecodable, names: named(decode)) public macro JSONDecodable() = #externalMacro(module: "NewCodableMacros", type: "JSONDecodableMacro") +@attached(member, names: named(CodingFields)) +@attached(extension, conformances: JSONEncodable, JSONDecodable, names: named(encode), named(decode)) +public macro JSONCodable() = #externalMacro(module: "NewCodableMacros", type: "JSONCodableMacro") + @attached(peer) public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") diff --git a/Sources/NewCodableMacros/JSONCodableMacro.swift b/Sources/NewCodableMacros/JSONCodableMacro.swift new file mode 100644 index 000000000..25171da27 --- /dev/null +++ b/Sources/NewCodableMacros/JSONCodableMacro.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct JSONCodableMacro { } + +extension JSONCodableMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + try JSONEncodableMacro.expansion( + of: node, + providingMembersOf: declaration, + conformingTo: protocols, + in: context + ) + } +} + +extension JSONCodableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + let encodableExtensions = try JSONEncodableMacro.expansion( + of: node, + attachedTo: declaration, + providingExtensionsOf: type, + conformingTo: protocols, + in: context + ) + let decodableExtensions = try JSONDecodableMacro.expansion( + of: node, + attachedTo: declaration, + providingExtensionsOf: type, + conformingTo: protocols, + in: context + ) + return encodableExtensions + decodableExtensions + } +} diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index 4274e6979..0dc20e8ab 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -18,6 +18,7 @@ struct NewCodableMacrosPlugin: CompilerPlugin { var providingMacros: [Macro.Type] = [ JSONEncodableMacro.self, JSONDecodableMacro.self, + JSONCodableMacro.self, CodingKeyMacro.self, ] } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift new file mode 100644 index 000000000..de16a7402 --- /dev/null +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport +import Testing +import NewCodableMacros + +@Suite("@JSONCodable Macro") +struct JSONCodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @JSONCodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .age: "age" + } + } + } + } + + extension Person: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + + extension Person: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Person { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var age: Int? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "age": age = try valueDecoder.decode(Int.self) + default: break + } + return false + } + guard let name, let age else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Person(name: name, age: age) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @JSONCodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + case rating + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .rating: "rating" + } + } + } + } + + extension Item: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.rating, value: self.rating) + } + } + } + + extension Item: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Item { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var rating: Double? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "rating": rating = try valueDecoder.decode(Double.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Item(name: name, rating: rating) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func customCodingKey() { + assertMacroExpansion( + """ + @JSONCodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + + enum CodingFields: Int, JSONOptimizedCodingField { + case publishDate + case title + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: "date_published" + case .title: "title" + } + } + } + } + + extension Post: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.publishDate, value: self.publishDate) + try structEncoder.encode(field: CodingFields.title, value: self.title) + } + } + } + + extension Post: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Post { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var publishDate: String? + var title: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "date_published": publishDate = try valueDecoder.decode(String.self) + case "title": title = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let publishDate, let title else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Post(publishDate: publishDate, title: title) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @JSONCodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + + extension Empty: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Empty { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + Empty() + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @JSONCodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONEncodable can only be applied to structs", line: 1, column: 1), + DiagnosticSpec(message: "@JSONDecodable can only be applied to structs", line: 1, column: 1), + ], + macros: codableTestMacros + ) + } +} + +private let codableTestMacros: [String: Macro.Type] = [ + "JSONCodable": JSONCodableMacro.self, + "CodingKey": CodingKeyMacro.self, +] diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index 7e9ed40e6..ebd8e0264 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2112,6 +2112,19 @@ struct DecodableOnly { @JSONDecodable struct EmptyDecodable {} +@JSONCodable +struct CodablePerson { + let name: String + let age: Int +} + +@JSONCodable +struct CodablePost { + let title: String + @CodingKey("date_published") let publishDate: String + let rating: Double? +} + @Suite("@JSONEncodable Macro Integration") struct JSONEncodableMacroIntegrationTests { @@ -2191,3 +2204,34 @@ struct JSONDecodableMacroIntegrationTests { _ = decoded } } + +@Suite("@JSONCodable Macro Integration") +struct JSONCodableMacroIntegrationTests { + + @Test func roundTrip() throws { + let original = CodablePerson(name: "Alice", age: 30) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(CodablePerson.self, from: data) + #expect(decoded.name == "Alice") + #expect(decoded.age == 30) + } + + @Test func roundTripWithCustomKey() throws { + let original = CodablePost(title: "Hello", publishDate: "2026-01-01", rating: 4.5) + let data = try NewJSONEncoder().encode(original) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"date_published\":\"2026-01-01\"")) + let decoded = try NewJSONDecoder().decode(CodablePost.self, from: data) + #expect(decoded.title == "Hello") + #expect(decoded.publishDate == "2026-01-01") + #expect(decoded.rating == 4.5) + } + + @Test func roundTripOptionalNil() throws { + let original = CodablePost(title: "Test", publishDate: "2026-03-10", rating: nil) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(CodablePost.self, from: data) + #expect(decoded.title == "Test") + #expect(decoded.rating == nil) + } +} From 44dd4f7cb11290d20051c44f9164e78b8cadf694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 06/15] @CodableDefault macro --- Sources/NewCodable/Macros.swift | 3 + .../CodableDefaultMacro.swift | 24 +++ .../NewCodableMacros/JSONDecodableMacro.swift | 41 +++- .../NewCodableMacros/JSONEncodableMacro.swift | 11 ++ .../NewCodableMacrosPlugin.swift | 1 + .../JSONCodableMacroTests.swift | 95 +++++++++ .../JSONDecodableMacroTests.swift | 181 ++++++++++++++++++ .../JSONEncodableMacroTests.swift | 61 ++++++ .../CodableRevolutionTests.swift | 41 ---- 9 files changed, 411 insertions(+), 47 deletions(-) create mode 100644 Sources/NewCodableMacros/CodableDefaultMacro.swift diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index 131ce726f..c5b3d7bd4 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -23,3 +23,6 @@ public macro JSONCodable() = #externalMacro(module: "NewCodableMacros", type: "J @attached(peer) public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") + +@attached(peer) +public macro CodableDefault(_ value: T) = #externalMacro(module: "NewCodableMacros", type: "CodableDefaultMacro") diff --git a/Sources/NewCodableMacros/CodableDefaultMacro.swift b/Sources/NewCodableMacros/CodableDefaultMacro.swift new file mode 100644 index 000000000..3c29ecb9a --- /dev/null +++ b/Sources/NewCodableMacros/CodableDefaultMacro.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct CodableDefaultMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 6eb3fd846..907993361 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -22,6 +22,11 @@ private struct DecodableStoredProperty { let jsonKey: String let typeName: String let isOptional: Bool + let defaultExpr: String? + + var isRequired: Bool { + !isOptional && defaultExpr == nil + } } private func extractDecodableStoredProperties( @@ -42,7 +47,8 @@ private func extractDecodableStoredProperties( } let customKey = decodableCustomCodingKey(from: varDecl.attributes) - if customKey != nil && varDecl.bindings.count > 1 { + let defaultExpr = decodableDefaultExpression(from: varDecl.attributes) + if (customKey != nil || defaultExpr != nil) && varDecl.bindings.count > 1 { context.diagnose(.init( node: Syntax(varDecl), message: JSONDecodableDiagnostic.codingKeyOnMultipleBindings @@ -100,7 +106,8 @@ private func extractDecodableStoredProperties( name: propertyName, jsonKey: jsonKey, typeName: typeName, - isOptional: isOptional + isOptional: isOptional, + defaultExpr: defaultExpr )) } } @@ -108,6 +115,20 @@ private func extractDecodableStoredProperties( return properties } +private func decodableDefaultExpression(from attributes: AttributeListSyntax) -> String? { + for attribute in attributes { + guard let attr = attribute.as(AttributeSyntax.self), + let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), + identifierType.name.trimmedDescription == "CodableDefault", + let arguments = attr.arguments?.as(LabeledExprListSyntax.self), + let firstArg = arguments.first else { + continue + } + return firstArg.expression.trimmedDescription + } + return nil +} + private func decodableCustomCodingKey(from attributes: AttributeListSyntax) -> String? { for attribute in attributes { guard let attr = attribute.as(AttributeSyntax.self), @@ -166,16 +187,24 @@ extension JSONDecodableMacro: ExtensionMacro { "case \"\($0.jsonKey)\": \($0.name) = try valueDecoder.decode(\($0.typeName).self)" }.joined(separator: "\n ") - let requiredProperties = properties.filter { !$0.isOptional } + let requiredProperties = properties.filter { $0.isRequired } let guardAndReturn: String if requiredProperties.isEmpty { - let args = properties.map { "\($0.name): \($0.name)" }.joined(separator: ", ") + let args = properties.map { prop -> String in + if let defaultExpr = prop.defaultExpr { + return "\(prop.name): \(prop.name) ?? \(defaultExpr)" + } + return "\(prop.name): \(prop.name)" + }.joined(separator: ", ") guardAndReturn = "return \(typeName)(\(args))" } else { let guardNames = requiredProperties.map { "let \($0.name)" }.joined(separator: ", ") - let args = properties.map { - "\($0.name): \($0.name)" + let args = properties.map { prop -> String in + if let defaultExpr = prop.defaultExpr { + return "\(prop.name): \(prop.name) ?? \(defaultExpr)" + } + return "\(prop.name): \(prop.name)" }.joined(separator: ", ") guardAndReturn = """ guard \(guardNames) else { diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index fdd442803..443383caf 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -121,6 +121,10 @@ extension JSONEncodableMacro: MemberMacro { "case .\($0.name): \"\($0.jsonKey)\"" }.joined(separator: "\n ") + let fieldForKeyCases = properties.map { + "case \"\($0.jsonKey)\": .\($0.name)" + }.joined(separator: "\n ") + let enumDecl: DeclSyntax = """ enum CodingFields: Int, JSONOptimizedCodingField { \(raw: cases) @@ -131,6 +135,13 @@ extension JSONEncodableMacro: MemberMacro { \(raw: switchCases) } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + \(raw: fieldForKeyCases) + default: throw CodingError.unknownKey(key) + } + } } """ diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index 0dc20e8ab..75c8dc468 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -20,5 +20,6 @@ struct NewCodableMacrosPlugin: CompilerPlugin { JSONDecodableMacro.self, JSONCodableMacro.self, CodingKeyMacro.self, + CodableDefaultMacro.self, ] } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index de16a7402..b1bf2f607 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -44,6 +44,14 @@ struct JSONCodableMacroTests { case .age: "age" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "age": .age + default: throw CodingError.unknownKey(key) + } + } } } @@ -106,6 +114,14 @@ struct JSONCodableMacroTests { case .rating: "rating" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "rating": .rating + default: throw CodingError.unknownKey(key) + } + } } } @@ -168,6 +184,14 @@ struct JSONCodableMacroTests { case .title: "title" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": .publishDate + case "title": .title + default: throw CodingError.unknownKey(key) + } + } } } @@ -234,6 +258,76 @@ struct JSONCodableMacroTests { ) } + @Test func defaultValue() { + assertMacroExpansion( + """ + @JSONCodable + struct Config { + let name: String + @CodableDefault("en") let locale: String + } + """, + expandedSource: """ + struct Config { + let name: String + let locale: String + + enum CodingFields: Int, JSONOptimizedCodingField { + case name + case locale + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .locale: "locale" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "locale": .locale + default: throw CodingError.unknownKey(key) + } + } + } + } + + extension Config: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.locale, value: self.locale) + } + } + } + + extension Config: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var locale: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "locale": locale = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Config(name: name, locale: locale ?? "en") + } + } + } + """, + macros: codableTestMacros + ) + } + @Test func errorOnNonStruct() { assertMacroExpansion( """ @@ -259,4 +353,5 @@ struct JSONCodableMacroTests { private let codableTestMacros: [String: Macro.Type] = [ "JSONCodable": JSONCodableMacro.self, "CodingKey": CodingKeyMacro.self, + "CodableDefault": CodableDefaultMacro.self, ] diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 1a1d47d7c..4d8188766 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -356,9 +356,190 @@ struct JSONDecodableMacroTests { macros: decodableTestMacros ) } + + @Test func defaultValue() { + assertMacroExpansion( + """ + @JSONDecodable + struct Config { + let name: String + @CodableDefault("en") let locale: String + @CodableDefault(0) let retryCount: Int + } + """, + expandedSource: """ + struct Config { + let name: String + let locale: String + let retryCount: Int + } + + extension Config: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var locale: String? + var retryCount: Int? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "locale": locale = try valueDecoder.decode(String.self) + case "retryCount": retryCount = try valueDecoder.decode(Int.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return Config(name: name, locale: locale ?? "en", retryCount: retryCount ?? 0) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func allDefaultValues() { + assertMacroExpansion( + """ + @JSONDecodable + struct Defaults { + @CodableDefault("hello") let greeting: String + @CodableDefault(false) let verbose: Bool + } + """, + expandedSource: """ + struct Defaults { + let greeting: String + let verbose: Bool + } + + extension Defaults: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Defaults { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var greeting: String? + var verbose: Bool? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "greeting": greeting = try valueDecoder.decode(String.self) + case "verbose": verbose = try valueDecoder.decode(Bool.self) + default: break + } + return false + } + return Defaults(greeting: greeting ?? "hello", verbose: verbose ?? false) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func defaultWithCodingKey() { + assertMacroExpansion( + """ + @JSONDecodable + struct Setting { + @CodingKey("max_retries") @CodableDefault(3) let maxRetries: Int + } + """, + expandedSource: """ + struct Setting { + let maxRetries: Int + } + + extension Setting: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Setting { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var maxRetries: Int? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "max_retries": maxRetries = try valueDecoder.decode(Int.self) + default: break + } + return false + } + return Setting(maxRetries: maxRetries ?? 3) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func defaultOnOptionalProperty() { + assertMacroExpansion( + """ + @JSONDecodable + struct Prefs { + @CodableDefault("en") let locale: String? + } + """, + expandedSource: """ + struct Prefs { + let locale: String? + } + + extension Prefs: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Prefs { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var locale: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "locale": locale = try valueDecoder.decode(String.self) + default: break + } + return false + } + return Prefs(locale: locale ?? "en") + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func defaultWithArbitraryExpression() { + assertMacroExpansion( + """ + @JSONDecodable + struct WithExpr { + @CodableDefault([]) let tags: [String] + } + """, + expandedSource: """ + struct WithExpr { + let tags: [String] + } + + extension WithExpr: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> WithExpr { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var tags: [String]? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "tags": tags = try valueDecoder.decode([String].self) + default: break + } + return false + } + return WithExpr(tags: tags ?? []) + } + } + } + """, + macros: decodableTestMacros + ) + } } private let decodableTestMacros: [String: Macro.Type] = [ "JSONDecodable": JSONDecodableMacro.self, "CodingKey": CodingKeyMacro.self, + "CodableDefault": CodableDefaultMacro.self, ] diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index e5810b7cd..91fb57c02 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -49,6 +49,14 @@ struct JSONEncodableMacroTests { case .age: "age" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "age": .age + default: throw CodingError.unknownKey(key) + } + } } } @@ -90,6 +98,14 @@ struct JSONEncodableMacroTests { case .title: "title" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": .publishDate + case "title": .title + default: throw CodingError.unknownKey(key) + } + } } } @@ -131,6 +147,14 @@ struct JSONEncodableMacroTests { case .rating: "rating" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "rating": .rating + default: throw CodingError.unknownKey(key) + } + } } } @@ -174,6 +198,13 @@ struct JSONEncodableMacroTests { case .name: "name" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: throw CodingError.unknownKey(key) + } + } } } @@ -212,6 +243,13 @@ struct JSONEncodableMacroTests { case .name: "name" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: throw CodingError.unknownKey(key) + } + } } } @@ -291,6 +329,13 @@ struct JSONEncodableMacroTests { case .name: "name" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: throw CodingError.unknownKey(key) + } + } } } @@ -357,6 +402,14 @@ struct JSONEncodableMacroTests { case .age: "age" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "age": .age + default: throw CodingError.unknownKey(key) + } + } } } @@ -402,6 +455,14 @@ struct JSONEncodableMacroTests { case .name: "name" } } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "count": .count + case "name": .name + default: throw CodingError.unknownKey(key) + } + } } } diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index ebd8e0264..bdbafce16 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -635,47 +635,6 @@ struct NewCodableTests { } @Test func testDefaultValue() throws { - /* - @JSONCodable - struct Foo { - @CodableDefault("hello") - let bar: String - } - */ - - struct Foo: JSONDecodable, Equatable { - let bar: String - - enum CodingFields: JSONOptimizedDecodingField { - case bar - - static func field(for key: UTF8Span) throws(NewCodable.CodingError.Decoding) -> Self { - switch UTF8SpanComparator(key) { - case "bar": .bar - default: throw CodingError.unknownKey(key) - } - } - - @inline(__always) - var staticString: StaticString { - switch self { - case .bar: "bar" - } - } - } - - static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(NewCodable.CodingError.Decoding) -> Foo { - return try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in - var bar: String? - var inOrder = false - _ = try structDecoder.decodeExpectedOrderField(CodingFields.bar, inOrder: &inOrder, required: false) { valueDecoder throws(CodingError.Decoding) in - bar = try valueDecoder.decode(String.self) - } - return Foo(bar: bar ?? "hello") - } - } - } - let emptyJSON = Data("{}".utf8) let decoder = NewJSONDecoder() let result = try decoder.decode(Foo.self, from: emptyJSON) From ae17f72f1345e67b32074ca15e2020618c96cc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Teveli?= Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 07/15] @CodableAlias macro --- Sources/NewCodable/Macros.swift | 3 + .../NewCodableMacros/CodableAliasMacro.swift | 24 ++++ .../NewCodableMacros/JSONDecodableMacro.swift | 33 ++++- .../NewCodableMacros/JSONEncodableMacro.swift | 33 ++++- .../NewCodableMacrosPlugin.swift | 1 + .../JSONCodableMacroTests.swift | 65 ++++++++++ .../JSONDecodableMacroTests.swift | 118 ++++++++++++++++++ .../JSONEncodableMacroTests.swift | 52 ++++++++ .../CodableRevolutionTests.swift | 50 +++----- 9 files changed, 338 insertions(+), 41 deletions(-) create mode 100644 Sources/NewCodableMacros/CodableAliasMacro.swift diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index c5b3d7bd4..c9499f12b 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -26,3 +26,6 @@ public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacro @attached(peer) public macro CodableDefault(_ value: T) = #externalMacro(module: "NewCodableMacros", type: "CodableDefaultMacro") + +@attached(peer) +public macro CodableAlias(_ names: String...) = #externalMacro(module: "NewCodableMacros", type: "CodableAliasMacro") diff --git a/Sources/NewCodableMacros/CodableAliasMacro.swift b/Sources/NewCodableMacros/CodableAliasMacro.swift new file mode 100644 index 000000000..75105d039 --- /dev/null +++ b/Sources/NewCodableMacros/CodableAliasMacro.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct CodableAliasMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 907993361..60abac076 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -20,6 +20,7 @@ public struct JSONDecodableMacro { } private struct DecodableStoredProperty { let name: String let jsonKey: String + let aliases: [String] let typeName: String let isOptional: Bool let defaultExpr: String? @@ -48,7 +49,8 @@ private func extractDecodableStoredProperties( let customKey = decodableCustomCodingKey(from: varDecl.attributes) let defaultExpr = decodableDefaultExpression(from: varDecl.attributes) - if (customKey != nil || defaultExpr != nil) && varDecl.bindings.count > 1 { + let aliases = decodableAliases(from: varDecl.attributes) + if (customKey != nil || defaultExpr != nil || !aliases.isEmpty) && varDecl.bindings.count > 1 { context.diagnose(.init( node: Syntax(varDecl), message: JSONDecodableDiagnostic.codingKeyOnMultipleBindings @@ -105,6 +107,7 @@ private func extractDecodableStoredProperties( properties.append(DecodableStoredProperty( name: propertyName, jsonKey: jsonKey, + aliases: aliases, typeName: typeName, isOptional: isOptional, defaultExpr: defaultExpr @@ -145,6 +148,25 @@ private func decodableCustomCodingKey(from attributes: AttributeListSyntax) -> S return nil } +private func decodableAliases(from attributes: AttributeListSyntax) -> [String] { + var aliases: [String] = [] + for attribute in attributes { + guard let attr = attribute.as(AttributeSyntax.self), + let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), + identifierType.name.trimmedDescription == "CodableAlias", + let arguments = attr.arguments?.as(LabeledExprListSyntax.self) else { + continue + } + for arg in arguments { + if let stringLiteral = arg.expression.as(StringLiteralExprSyntax.self), + let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) { + aliases.append(segment.content.text) + } + } + } + return aliases +} + extension JSONDecodableMacro: ExtensionMacro { public static func expansion( of node: AttributeSyntax, @@ -183,8 +205,13 @@ extension JSONDecodableMacro: ExtensionMacro { "var \($0.name): \($0.typeName)?" }.joined(separator: "\n ") - let switchCases = properties.map { - "case \"\($0.jsonKey)\": \($0.name) = try valueDecoder.decode(\($0.typeName).self)" + let switchCases = properties.flatMap { prop -> [String] in + let decodeExpr = "\(prop.name) = try valueDecoder.decode(\(prop.typeName).self)" + var cases = ["case \"\(prop.jsonKey)\": \(decodeExpr)"] + for alias in prop.aliases { + cases.append("case \"\(alias)\": \(decodeExpr)") + } + return cases }.joined(separator: "\n ") let requiredProperties = properties.filter { $0.isRequired } diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index 443383caf..42c2dd13c 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -20,6 +20,7 @@ public struct JSONEncodableMacro { } private struct StoredProperty { let name: String let jsonKey: String + let aliases: [String] } private func extractStoredProperties( @@ -40,7 +41,8 @@ private func extractStoredProperties( } let customKey = customCodingKey(from: varDecl.attributes) - if customKey != nil && varDecl.bindings.count > 1 { + let aliases = codableAliases(from: varDecl.attributes) + if (customKey != nil || !aliases.isEmpty) && varDecl.bindings.count > 1 { context.diagnose(.init( node: Syntax(varDecl), message: JSONEncodableDiagnostic.codingKeyOnMultipleBindings @@ -71,7 +73,7 @@ private func extractStoredProperties( let propertyName = pattern.identifier.trimmedDescription let jsonKey = customKey ?? propertyName - properties.append(StoredProperty(name: propertyName, jsonKey: jsonKey)) + properties.append(StoredProperty(name: propertyName, jsonKey: jsonKey, aliases: aliases)) } } @@ -94,6 +96,25 @@ private func customCodingKey(from attributes: AttributeListSyntax) -> String? { return nil } +private func codableAliases(from attributes: AttributeListSyntax) -> [String] { + var aliases: [String] = [] + for attribute in attributes { + guard let attr = attribute.as(AttributeSyntax.self), + let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), + identifierType.name.trimmedDescription == "CodableAlias", + let arguments = attr.arguments?.as(LabeledExprListSyntax.self) else { + continue + } + for arg in arguments { + if let stringLiteral = arg.expression.as(StringLiteralExprSyntax.self), + let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) { + aliases.append(segment.content.text) + } + } + } + return aliases +} + extension JSONEncodableMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, @@ -121,8 +142,12 @@ extension JSONEncodableMacro: MemberMacro { "case .\($0.name): \"\($0.jsonKey)\"" }.joined(separator: "\n ") - let fieldForKeyCases = properties.map { - "case \"\($0.jsonKey)\": .\($0.name)" + let fieldForKeyCases = properties.flatMap { prop -> [String] in + var cases = ["case \"\(prop.jsonKey)\": .\(prop.name)"] + for alias in prop.aliases { + cases.append("case \"\(alias)\": .\(prop.name)") + } + return cases }.joined(separator: "\n ") let enumDecl: DeclSyntax = """ diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index 75c8dc468..d36b531d1 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -21,5 +21,6 @@ struct NewCodableMacrosPlugin: CompilerPlugin { JSONCodableMacro.self, CodingKeyMacro.self, CodableDefaultMacro.self, + CodableAliasMacro.self, ] } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index b1bf2f607..1abf18236 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -328,6 +328,70 @@ struct JSONCodableMacroTests { ) } + @Test func aliasFullRoundtrip() { + assertMacroExpansion( + """ + @JSONCodable + struct User { + @CodableAlias("user_name") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + + enum CodingFields: Int, JSONOptimizedCodingField { + case userName + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "userName" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "userName": .userName + case "user_name": .userName + default: throw CodingError.unknownKey(key) + } + } + } + } + + extension User: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + } + } + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "userName": userName = try valueDecoder.decode(String.self) + case "user_name": userName = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return User(userName: userName) + } + } + } + """, + macros: codableTestMacros + ) + } + @Test func errorOnNonStruct() { assertMacroExpansion( """ @@ -354,4 +418,5 @@ private let codableTestMacros: [String: Macro.Type] = [ "JSONCodable": JSONCodableMacro.self, "CodingKey": CodingKeyMacro.self, "CodableDefault": CodableDefaultMacro.self, + "CodableAlias": CodableAliasMacro.self, ] diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 4d8188766..afa5c5ddf 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -536,10 +536,128 @@ struct JSONDecodableMacroTests { macros: decodableTestMacros ) } + + @Test func aliasBasic() { + assertMacroExpansion( + """ + @JSONDecodable + struct User { + @CodableAlias("user_name") let userName: String + let age: Int + } + """, + expandedSource: """ + struct User { + let userName: String + let age: Int + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + var age: Int? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "userName": userName = try valueDecoder.decode(String.self) + case "user_name": userName = try valueDecoder.decode(String.self) + case "age": age = try valueDecoder.decode(Int.self) + default: break + } + return false + } + guard let userName, let age else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return User(userName: userName, age: age) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func aliasCombinedWithCodingKey() { + assertMacroExpansion( + """ + @JSONDecodable + struct User { + @CodingKey("user_name") @CodableAlias("username") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "user_name": userName = try valueDecoder.decode(String.self) + case "username": userName = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return User(userName: userName) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func aliasMultiple() { + assertMacroExpansion( + """ + @JSONDecodable + struct User { + @CodableAlias("a", "b", "c") let name: String + } + """, + expandedSource: """ + struct User { + let name: String + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch key { + case "name": name = try valueDecoder.decode(String.self) + case "a": name = try valueDecoder.decode(String.self) + case "b": name = try valueDecoder.decode(String.self) + case "c": name = try valueDecoder.decode(String.self) + default: break + } + return false + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + } + return User(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } } private let decodableTestMacros: [String: Macro.Type] = [ "JSONDecodable": JSONDecodableMacro.self, "CodingKey": CodingKeyMacro.self, "CodableDefault": CodableDefaultMacro.self, + "CodableAlias": CodableAliasMacro.self, ] diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index 91fb57c02..2e2a02781 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -19,6 +19,7 @@ import NewCodableMacros let testMacros: [String: Macro.Type] = [ "JSONEncodable": JSONEncodableMacro.self, "CodingKey": CodingKeyMacro.self, + "CodableAlias": CodableAliasMacro.self, ] @Suite("@JSONEncodable Macro") @@ -426,6 +427,57 @@ struct JSONEncodableMacroTests { ) } + @Test func aliasInFieldForKey() { + assertMacroExpansion( + """ + @JSONEncodable + struct User { + @CodableAlias("user_name", "username") let userName: String + let age: Int + } + """, + expandedSource: """ + struct User { + let userName: String + let age: Int + + enum CodingFields: Int, JSONOptimizedCodingField { + case userName + case age + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "userName" + case .age: "age" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "userName": .userName + case "user_name": .userName + case "username": .userName + case "age": .age + default: throw CodingError.unknownKey(key) + } + } + } + } + + extension User: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + """, + macros: testMacros + ) + } + @Test func propertyWithObservers() { assertMacroExpansion( """ diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index bdbafce16..7973fc3aa 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -637,45 +637,15 @@ struct NewCodableTests { @Test func testDefaultValue() throws { let emptyJSON = Data("{}".utf8) let decoder = NewJSONDecoder() - let result = try decoder.decode(Foo.self, from: emptyJSON) - #expect(result == Foo(bar: "hello")) + let result = try decoder.decode(CodableStructWithDefaultedProperty.self, from: emptyJSON) + #expect(result.bar == "hello") } @Test func testAliases() throws { - /* - @JSONCodable - struct Foo { - @CodableAlias("baz", "qux") - let bar: String - } - */ - - struct Foo: JSONDecodable, Equatable { - let bar: String - - static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(NewCodable.CodingError.Decoding) -> Foo { - return try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in - var bar: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - // TODO: Deal with duplicates. - case "baz": bar = try valueDecoder.decode(String.self) - case "bar": bar = try valueDecoder.decode(String.self) - case "qux": bar = try valueDecoder.decode(String.self) - default: break // Skip. - - } - return false - } - return Foo(bar: bar ?? "hello") - } - } - } - let qux = Data("{ \"qux\" : \"hello\" }".utf8) let decoder = NewJSONDecoder() - let result = try decoder.decode(Foo.self, from: qux) - #expect(result == Foo(bar: "hello")) + let result = try decoder.decode(CodableStructWithAliasedProperty.self, from: qux) + #expect(result.bar == "hello") } @Test func testEmbeddedEncodable() throws { @@ -2084,6 +2054,18 @@ struct CodablePost { let rating: Double? } +@JSONCodable +struct CodableStructWithDefaultedProperty { + @CodableDefault("hello") + let bar: String +} + +@JSONCodable +struct CodableStructWithAliasedProperty { + @CodableAlias("baz", "qux") + let bar: String +} + @Suite("@JSONEncodable Macro Integration") struct JSONEncodableMacroIntegrationTests { From 11cac52974ca2e6cfd62b06189f82c6dbabb7882 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 08/15] Code review: docs --- Sources/NewCodable/Macros.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index c9499f12b..8012fb954 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -10,22 +10,32 @@ // //===----------------------------------------------------------------------===// +/// Experimental NewCodable macro API. +/// +/// The macro spellings in this file are provisional and may evolve with the +/// macro-based Codable design. The per-property marker macros below are +/// especially likely to change as the feature set is refined. @attached(member, names: named(CodingFields)) @attached(extension, conformances: JSONEncodable, names: named(encode)) public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: "JSONEncodableMacro") +/// Experimental macro that synthesizes `JSONDecodable` conformance. @attached(extension, conformances: JSONDecodable, names: named(decode)) public macro JSONDecodable() = #externalMacro(module: "NewCodableMacros", type: "JSONDecodableMacro") +/// Experimental macro that synthesizes both `JSONEncodable` and `JSONDecodable`. @attached(member, names: named(CodingFields)) @attached(extension, conformances: JSONEncodable, JSONDecodable, names: named(encode), named(decode)) public macro JSONCodable() = #externalMacro(module: "NewCodableMacros", type: "JSONCodableMacro") +/// Experimental per-property marker macro for overriding the serialized key. @attached(peer) public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") +/// Experimental per-property marker macro for supplying a default decoding value. @attached(peer) public macro CodableDefault(_ value: T) = #externalMacro(module: "NewCodableMacros", type: "CodableDefaultMacro") +/// Experimental per-property marker macro for accepting alternate decoding keys. @attached(peer) public macro CodableAlias(_ names: String...) = #externalMacro(module: "NewCodableMacros", type: "CodableAliasMacro") From 88646834b9f3b6a31bc0f75e62e762b0ef51af9b Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 09/15] Code review: include missing field names --- .../NewCodableMacros/JSONDecodableMacro.swift | 12 ++++--- .../JSONCodableMacroTests.swift | 20 +++++++---- .../JSONDecodableMacroTests.swift | 35 ++++++++++++------- .../CodableRevolutionTests.swift | 20 +++++++++++ 4 files changed, 63 insertions(+), 24 deletions(-) diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 60abac076..d496f441b 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -226,7 +226,13 @@ extension JSONDecodableMacro: ExtensionMacro { }.joined(separator: ", ") guardAndReturn = "return \(typeName)(\(args))" } else { - let guardNames = requiredProperties.map { "let \($0.name)" }.joined(separator: ", ") + let requiredFieldGuards = requiredProperties.map { + """ + guard let \($0.name) else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field '\($0.jsonKey)'") + } + """ + }.joined(separator: "\n ") let args = properties.map { prop -> String in if let defaultExpr = prop.defaultExpr { return "\(prop.name): \(prop.name) ?? \(defaultExpr)" @@ -234,9 +240,7 @@ extension JSONDecodableMacro: ExtensionMacro { return "\(prop.name): \(prop.name)" }.joined(separator: ", ") guardAndReturn = """ - guard \(guardNames) else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") - } + \(requiredFieldGuards) return \(typeName)(\(args)) """ } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index 1abf18236..c357e298b 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -77,8 +77,11 @@ struct JSONCodableMacroTests { } return false } - guard let name, let age else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + guard let age else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'age'") } return Person(name: name, age: age) } @@ -148,7 +151,7 @@ struct JSONCodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Item(name: name, rating: rating) } @@ -217,8 +220,11 @@ struct JSONCodableMacroTests { } return false } - guard let publishDate, let title else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + guard let publishDate else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'date_published'") + } + guard let title else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'title'") } return Post(publishDate: publishDate, title: title) } @@ -317,7 +323,7 @@ struct JSONCodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Config(name: name, locale: locale ?? "en") } @@ -381,7 +387,7 @@ struct JSONCodableMacroTests { return false } guard let userName else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'userName'") } return User(userName: userName) } diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index afa5c5ddf..7b60b265b 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -47,8 +47,11 @@ struct JSONDecodableMacroTests { } return false } - guard let name, let age else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + guard let age else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'age'") } return Person(name: name, age: age) } @@ -88,7 +91,7 @@ struct JSONDecodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Item(name: name, rating: rating) } @@ -164,8 +167,11 @@ struct JSONDecodableMacroTests { } return false } - guard let publishDate, let title else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + guard let publishDate else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'date_published'") + } + guard let title else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'title'") } return Post(publishDate: publishDate, title: title) } @@ -207,7 +213,7 @@ struct JSONDecodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Thing(name: name) } @@ -245,7 +251,7 @@ struct JSONDecodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Config(name: name) } @@ -283,7 +289,7 @@ struct JSONDecodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Cached(name: name) } @@ -390,7 +396,7 @@ struct JSONDecodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return Config(name: name, locale: locale ?? "en", retryCount: retryCount ?? 0) } @@ -566,8 +572,11 @@ struct JSONDecodableMacroTests { } return false } - guard let userName, let age else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'userName'") + } + guard let age else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'age'") } return User(userName: userName, age: age) } @@ -604,7 +613,7 @@ struct JSONDecodableMacroTests { return false } guard let userName else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'user_name'") } return User(userName: userName) } @@ -643,7 +652,7 @@ struct JSONDecodableMacroTests { return false } guard let name else { - throw CodingError.dataCorrupted(debugDescription: "Missing required fields") + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") } return User(name: name) } diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index 7973fc3aa..b8881b48c 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2038,6 +2038,11 @@ struct DecodableOnly { let value: Int } +@JSONDecodable +struct DecodableOnlyWithRequiredCustomKey { + @CodingKey("date_published") let publishDate: String +} + @JSONDecodable struct EmptyDecodable {} @@ -2139,6 +2144,21 @@ struct JSONDecodableMacroIntegrationTests { #expect(decoded.value == 42) } + @Test func missingRequiredFieldErrorIncludesCustomKeyName() { + let json = Data("{}".utf8) + + do { + _ = try NewJSONDecoder().decode(DecodableOnlyWithRequiredCustomKey.self, from: json) + Issue.record("Expected decoding to fail for a missing required field") + } catch let error { + guard case .dataCorrupted = error.kind else { + Issue.record("Unexpected CodingError.Decoding type: \(error)") + return + } + #expect(error.debugDescription.contains("Missing required field 'date_published'")) + } + } + @Test func emptyDecodable() throws { let json = Data("{}".utf8) let decoded = try NewJSONDecoder().decode(EmptyDecodable.self, from: json) From be0124bb405c78507a6788642ec423d5eebd6726 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 10/15] Code review: CodingFields enum --- .../NewCodableMacros/JSONEncodableMacro.swift | 2 +- .../JSONCodableMacroTests.swift | 10 +++++----- .../JSONEncodableMacroTests.swift | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index 42c2dd13c..a3d448745 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -151,7 +151,7 @@ extension JSONEncodableMacro: MemberMacro { }.joined(separator: "\n ") let enumDecl: DeclSyntax = """ - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { \(raw: cases) @_transparent diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index c357e298b..584a87b5a 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -33,7 +33,7 @@ struct JSONCodableMacroTests { let name: String let age: Int - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name case age @@ -106,7 +106,7 @@ struct JSONCodableMacroTests { let name: String let rating: Double? - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name case rating @@ -176,7 +176,7 @@ struct JSONCodableMacroTests { let publishDate: String let title: String - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case publishDate case title @@ -278,7 +278,7 @@ struct JSONCodableMacroTests { let name: String let locale: String - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name case locale @@ -346,7 +346,7 @@ struct JSONCodableMacroTests { struct User { let userName: String - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case userName @_transparent diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index 2e2a02781..2ef35be65 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -39,7 +39,7 @@ struct JSONEncodableMacroTests { let name: String let age: Int - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name case age @@ -88,7 +88,7 @@ struct JSONEncodableMacroTests { let publishDate: String let title: String - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case publishDate case title @@ -137,7 +137,7 @@ struct JSONEncodableMacroTests { let name: String let rating: Double? - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name case rating @@ -190,7 +190,7 @@ struct JSONEncodableMacroTests { get { name.uppercased() } } - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name @_transparent @@ -235,7 +235,7 @@ struct JSONEncodableMacroTests { static let defaultName = "test" let name: String - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name @_transparent @@ -321,7 +321,7 @@ struct JSONEncodableMacroTests { let name: String lazy var uppercasedName: String = name.uppercased() - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name @_transparent @@ -392,7 +392,7 @@ struct JSONEncodableMacroTests { let name: String = "default" let age: Int - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case name case age @@ -441,7 +441,7 @@ struct JSONEncodableMacroTests { let userName: String let age: Int - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case userName case age @@ -496,7 +496,7 @@ struct JSONEncodableMacroTests { } let name: String - enum CodingFields: Int, JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedCodingField { case count case name From 5d52714c194cca2168b5a285be5b0d938f028c54 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 11/15] Code review: separate CodingKeys conformances --- Sources/NewCodable/Macros.swift | 1 + .../NewCodableMacros/JSONCodableMacro.swift | 21 +- .../NewCodableMacros/JSONDecodableMacro.swift | 30 ++ .../NewCodableMacros/JSONEncodableMacro.swift | 120 ++++--- .../JSONDecodableMacroTests.swift | 299 ++++++++++++++++++ .../JSONEncodableMacroTests.swift | 89 +----- 6 files changed, 437 insertions(+), 123 deletions(-) diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index 8012fb954..3d92dcd5e 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -20,6 +20,7 @@ public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: "JSONEncodableMacro") /// Experimental macro that synthesizes `JSONDecodable` conformance. +@attached(member, names: named(CodingFields)) @attached(extension, conformances: JSONDecodable, names: named(decode)) public macro JSONDecodable() = #externalMacro(module: "NewCodableMacros", type: "JSONDecodableMacro") diff --git a/Sources/NewCodableMacros/JSONCodableMacro.swift b/Sources/NewCodableMacros/JSONCodableMacro.swift index 25171da27..c0c2f57f9 100644 --- a/Sources/NewCodableMacros/JSONCodableMacro.swift +++ b/Sources/NewCodableMacros/JSONCodableMacro.swift @@ -12,6 +12,7 @@ import SwiftSyntax import SwiftSyntaxMacros +import SwiftDiagnostics public struct JSONCodableMacro { } @@ -22,12 +23,20 @@ extension JSONCodableMacro: MemberMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - try JSONEncodableMacro.expansion( - of: node, - providingMembersOf: declaration, - conformingTo: protocols, - in: context - ) + guard declaration.is(StructDeclSyntax.self) else { + context.diagnose(.init( + node: node, + message: JSONEncodableDiagnostic.notAStruct + )) + return [] + } + + let properties = extractStoredProperties(from: declaration.memberBlock, in: context) + guard !properties.isEmpty else { + return [] + } + + return [makeCodingFieldsDecl(from: properties, kind: .coding)] } } diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index d496f441b..2f2b3bdd0 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -167,6 +167,36 @@ private func decodableAliases(from attributes: AttributeListSyntax) -> [String] return aliases } +extension JSONDecodableMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + context.diagnose(.init( + node: node, + message: JSONDecodableDiagnostic.notAStruct + )) + return [] + } + + guard let properties = extractDecodableStoredProperties(from: declaration.memberBlock, in: context) else { + return [] + } + + if properties.isEmpty { + return [] + } + + let codingFields = properties.map { + StoredProperty(name: $0.name, jsonKey: $0.jsonKey, aliases: $0.aliases) + } + return [makeCodingFieldsDecl(from: codingFields, kind: .decodingOnly)] + } +} + extension JSONDecodableMacro: ExtensionMacro { public static func expansion( of node: AttributeSyntax, diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index a3d448745..3d69ebef3 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -17,13 +17,93 @@ import SwiftDiagnostics public struct JSONEncodableMacro { } -private struct StoredProperty { +enum CodingFieldExpansionKind { + case encodingOnly + case decodingOnly + case coding + + var protocolName: String { + switch self { + case .encodingOnly: + return "JSONOptimizedEncodingField" + case .decodingOnly: + return "JSONOptimizedDecodingField" + case .coding: + return "JSONOptimizedCodingField" + } + } + + var includesKeyLookup: Bool { + switch self { + case .encodingOnly: + return false + case .decodingOnly, .coding: + return true + } + } +} + +struct StoredProperty { let name: String let jsonKey: String let aliases: [String] } -private func extractStoredProperties( +func makeCodingFieldsDecl( + from properties: [StoredProperty], + kind: CodingFieldExpansionKind +) -> DeclSyntax { + let cases = properties.map { "case \($0.name)" }.joined(separator: "\n ") + + let switchCases = properties.map { + "case .\($0.name): \"\($0.jsonKey)\"" + }.joined(separator: "\n ") + + if kind.includesKeyLookup { + let fieldForKeyCases = properties.flatMap { prop -> [String] in + var cases = ["case \"\(prop.jsonKey)\": .\(prop.name)"] + for alias in prop.aliases { + cases.append("case \"\(alias)\": .\(prop.name)") + } + return cases + }.joined(separator: "\n ") + + return """ + enum CodingFields: \(raw: kind.protocolName) { + \(raw: cases) + + @_transparent + var staticString: StaticString { + switch self { + \(raw: switchCases) + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + \(raw: fieldForKeyCases) + default: throw CodingError.unknownKey(key) + } + } + } + """ + } else { + return """ + enum CodingFields: \(raw: kind.protocolName) { + \(raw: cases) + + @_transparent + var staticString: StaticString { + switch self { + \(raw: switchCases) + } + } + } + """ + } +} + +func extractStoredProperties( from members: MemberBlockSyntax, in context: some MacroExpansionContext ) -> [StoredProperty] { @@ -136,41 +216,7 @@ extension JSONEncodableMacro: MemberMacro { return [] } - let cases = properties.map { "case \($0.name)" }.joined(separator: "\n ") - - let switchCases = properties.map { - "case .\($0.name): \"\($0.jsonKey)\"" - }.joined(separator: "\n ") - - let fieldForKeyCases = properties.flatMap { prop -> [String] in - var cases = ["case \"\(prop.jsonKey)\": .\(prop.name)"] - for alias in prop.aliases { - cases.append("case \"\(alias)\": .\(prop.name)") - } - return cases - }.joined(separator: "\n ") - - let enumDecl: DeclSyntax = """ - enum CodingFields: JSONOptimizedCodingField { - \(raw: cases) - - @_transparent - var staticString: StaticString { - switch self { - \(raw: switchCases) - } - } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - \(raw: fieldForKeyCases) - default: throw CodingError.unknownKey(key) - } - } - } - """ - - return [enumDecl] + return [makeCodingFieldsDecl(from: properties, kind: .encodingOnly)] } } diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 7b60b265b..6589386ee 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -32,6 +32,27 @@ struct JSONDecodableMacroTests { struct Person { let name: String let age: Int + + enum CodingFields: JSONOptimizedDecodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .age: "age" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "age": .age + default: throw CodingError.unknownKey(key) + } + } + } } extension Person: JSONDecodable { @@ -75,6 +96,27 @@ struct JSONDecodableMacroTests { struct Item { let name: String let rating: Double? + + enum CodingFields: JSONOptimizedDecodingField { + case name + case rating + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .rating: "rating" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "rating": .rating + default: throw CodingError.unknownKey(key) + } + } + } } extension Item: JSONDecodable { @@ -115,6 +157,27 @@ struct JSONDecodableMacroTests { struct Preferences { let theme: String? let fontSize: Int? + + enum CodingFields: JSONOptimizedDecodingField { + case theme + case fontSize + + @_transparent + var staticString: StaticString { + switch self { + case .theme: "theme" + case .fontSize: "fontSize" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "theme": .theme + case "fontSize": .fontSize + default: throw CodingError.unknownKey(key) + } + } + } } extension Preferences: JSONDecodable { @@ -152,6 +215,27 @@ struct JSONDecodableMacroTests { struct Post { let publishDate: String let title: String + + enum CodingFields: JSONOptimizedDecodingField { + case publishDate + case title + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: "date_published" + case .title: "title" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": .publishDate + case "title": .title + default: throw CodingError.unknownKey(key) + } + } + } } extension Post: JSONDecodable { @@ -199,6 +283,24 @@ struct JSONDecodableMacroTests { var displayName: String { get { name.uppercased() } } + + enum CodingFields: JSONOptimizedDecodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: throw CodingError.unknownKey(key) + } + } + } } extension Thing: JSONDecodable { @@ -237,6 +339,24 @@ struct JSONDecodableMacroTests { struct Config { static let defaultName = "test" let name: String + + enum CodingFields: JSONOptimizedDecodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: throw CodingError.unknownKey(key) + } + } + } } extension Config: JSONDecodable { @@ -275,6 +395,24 @@ struct JSONDecodableMacroTests { struct Cached { let name: String lazy var uppercasedName: String = name.uppercased() + + enum CodingFields: JSONOptimizedDecodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: throw CodingError.unknownKey(key) + } + } + } } extension Cached: JSONDecodable { @@ -378,6 +516,30 @@ struct JSONDecodableMacroTests { let name: String let locale: String let retryCount: Int + + enum CodingFields: JSONOptimizedDecodingField { + case name + case locale + case retryCount + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .locale: "locale" + case .retryCount: "retryCount" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "locale": .locale + case "retryCount": .retryCount + default: throw CodingError.unknownKey(key) + } + } + } } extension Config: JSONDecodable { @@ -420,6 +582,27 @@ struct JSONDecodableMacroTests { struct Defaults { let greeting: String let verbose: Bool + + enum CodingFields: JSONOptimizedDecodingField { + case greeting + case verbose + + @_transparent + var staticString: StaticString { + switch self { + case .greeting: "greeting" + case .verbose: "verbose" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "greeting": .greeting + case "verbose": .verbose + default: throw CodingError.unknownKey(key) + } + } + } } extension Defaults: JSONDecodable { @@ -455,6 +638,24 @@ struct JSONDecodableMacroTests { expandedSource: """ struct Setting { let maxRetries: Int + + enum CodingFields: JSONOptimizedDecodingField { + case maxRetries + + @_transparent + var staticString: StaticString { + switch self { + case .maxRetries: "max_retries" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "max_retries": .maxRetries + default: throw CodingError.unknownKey(key) + } + } + } } extension Setting: JSONDecodable { @@ -488,6 +689,24 @@ struct JSONDecodableMacroTests { expandedSource: """ struct Prefs { let locale: String? + + enum CodingFields: JSONOptimizedDecodingField { + case locale + + @_transparent + var staticString: StaticString { + switch self { + case .locale: "locale" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "locale": .locale + default: throw CodingError.unknownKey(key) + } + } + } } extension Prefs: JSONDecodable { @@ -521,6 +740,24 @@ struct JSONDecodableMacroTests { expandedSource: """ struct WithExpr { let tags: [String] + + enum CodingFields: JSONOptimizedDecodingField { + case tags + + @_transparent + var staticString: StaticString { + switch self { + case .tags: "tags" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "tags": .tags + default: throw CodingError.unknownKey(key) + } + } + } } extension WithExpr: JSONDecodable { @@ -556,6 +793,28 @@ struct JSONDecodableMacroTests { struct User { let userName: String let age: Int + + enum CodingFields: JSONOptimizedDecodingField { + case userName + case age + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "userName" + case .age: "age" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "userName": .userName + case "user_name": .userName + case "age": .age + default: throw CodingError.unknownKey(key) + } + } + } } extension User: JSONDecodable { @@ -598,6 +857,25 @@ struct JSONDecodableMacroTests { expandedSource: """ struct User { let userName: String + + enum CodingFields: JSONOptimizedDecodingField { + case userName + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "user_name" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "user_name": .userName + case "username": .userName + default: throw CodingError.unknownKey(key) + } + } + } } extension User: JSONDecodable { @@ -635,6 +913,27 @@ struct JSONDecodableMacroTests { expandedSource: """ struct User { let name: String + + enum CodingFields: JSONOptimizedDecodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "a": .name + case "b": .name + case "c": .name + default: throw CodingError.unknownKey(key) + } + } + } } extension User: JSONDecodable { diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index 2ef35be65..1c3ecd061 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -39,7 +39,7 @@ struct JSONEncodableMacroTests { let name: String let age: Int - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case name case age @@ -50,14 +50,6 @@ struct JSONEncodableMacroTests { case .age: "age" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "name": .name - case "age": .age - default: throw CodingError.unknownKey(key) - } - } } } @@ -88,7 +80,7 @@ struct JSONEncodableMacroTests { let publishDate: String let title: String - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case publishDate case title @@ -99,14 +91,6 @@ struct JSONEncodableMacroTests { case .title: "title" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "date_published": .publishDate - case "title": .title - default: throw CodingError.unknownKey(key) - } - } } } @@ -137,7 +121,7 @@ struct JSONEncodableMacroTests { let name: String let rating: Double? - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case name case rating @@ -148,14 +132,6 @@ struct JSONEncodableMacroTests { case .rating: "rating" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "name": .name - case "rating": .rating - default: throw CodingError.unknownKey(key) - } - } } } @@ -190,7 +166,7 @@ struct JSONEncodableMacroTests { get { name.uppercased() } } - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case name @_transparent @@ -199,13 +175,6 @@ struct JSONEncodableMacroTests { case .name: "name" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "name": .name - default: throw CodingError.unknownKey(key) - } - } } } @@ -235,7 +204,7 @@ struct JSONEncodableMacroTests { static let defaultName = "test" let name: String - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case name @_transparent @@ -244,13 +213,6 @@ struct JSONEncodableMacroTests { case .name: "name" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "name": .name - default: throw CodingError.unknownKey(key) - } - } } } @@ -321,7 +283,7 @@ struct JSONEncodableMacroTests { let name: String lazy var uppercasedName: String = name.uppercased() - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case name @_transparent @@ -330,13 +292,6 @@ struct JSONEncodableMacroTests { case .name: "name" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "name": .name - default: throw CodingError.unknownKey(key) - } - } } } @@ -392,7 +347,7 @@ struct JSONEncodableMacroTests { let name: String = "default" let age: Int - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case name case age @@ -403,14 +358,6 @@ struct JSONEncodableMacroTests { case .age: "age" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "name": .name - case "age": .age - default: throw CodingError.unknownKey(key) - } - } } } @@ -441,7 +388,7 @@ struct JSONEncodableMacroTests { let userName: String let age: Int - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case userName case age @@ -452,16 +399,6 @@ struct JSONEncodableMacroTests { case .age: "age" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "userName": .userName - case "user_name": .userName - case "username": .userName - case "age": .age - default: throw CodingError.unknownKey(key) - } - } } } @@ -496,7 +433,7 @@ struct JSONEncodableMacroTests { } let name: String - enum CodingFields: JSONOptimizedCodingField { + enum CodingFields: JSONOptimizedEncodingField { case count case name @@ -507,14 +444,6 @@ struct JSONEncodableMacroTests { case .name: "name" } } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - case "count": .count - case "name": .name - default: throw CodingError.unknownKey(key) - } - } } } From b0fa1177f8272c84af1cfd8a5930c00d06f303cf Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 06:00:00 +0100 Subject: [PATCH 12/15] Code review: permissive handling of unknown keys --- .../NewCodableMacros/JSONDecodableMacro.swift | 15 +- .../NewCodableMacros/JSONEncodableMacro.swift | 23 ++- .../JSONCodableMacroTests.swift | 59 +++--- .../JSONDecodableMacroTests.swift | 171 ++++++++++-------- 4 files changed, 155 insertions(+), 113 deletions(-) diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 2f2b3bdd0..73314e4e6 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -235,14 +235,9 @@ extension JSONDecodableMacro: ExtensionMacro { "var \($0.name): \($0.typeName)?" }.joined(separator: "\n ") - let switchCases = properties.flatMap { prop -> [String] in - let decodeExpr = "\(prop.name) = try valueDecoder.decode(\(prop.typeName).self)" - var cases = ["case \"\(prop.jsonKey)\": \(decodeExpr)"] - for alias in prop.aliases { - cases.append("case \"\(alias)\": \(decodeExpr)") - } - return cases - }.joined(separator: "\n ") + let switchCases = properties.map { prop in + "case .\(prop.name): \(prop.name) = try valueDecoder.decode(\(prop.typeName).self)" + }.joined(separator: "\n ") let requiredProperties = properties.filter { $0.isRequired } @@ -281,9 +276,9 @@ extension JSONDecodableMacro: ExtensionMacro { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in \(raw: varDeclarations) try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { + switch try CodingFields.field(for: key) { \(raw: switchCases) - default: break + case .unknown: break } return false } diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index 3d69ebef3..1aed1dae2 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -41,6 +41,15 @@ enum CodingFieldExpansionKind { return true } } + + var includesUnknownCase: Bool { + switch self { + case .encodingOnly: + return false + case .decodingOnly, .coding: + return true + } + } } struct StoredProperty { @@ -53,11 +62,13 @@ func makeCodingFieldsDecl( from properties: [StoredProperty], kind: CodingFieldExpansionKind ) -> DeclSyntax { - let cases = properties.map { "case \($0.name)" }.joined(separator: "\n ") + let cases = (properties.map { "case \($0.name)" } + (kind.includesUnknownCase ? ["case unknown"] : [])) + .joined(separator: "\n ") let switchCases = properties.map { "case .\($0.name): \"\($0.jsonKey)\"" - }.joined(separator: "\n ") + } + (kind.includesUnknownCase ? ["case .unknown: fatalError()"] : []) + let joinedSwitchCases = switchCases.joined(separator: "\n ") if kind.includesKeyLookup { let fieldForKeyCases = properties.flatMap { prop -> [String] in @@ -68,6 +79,8 @@ func makeCodingFieldsDecl( return cases }.joined(separator: "\n ") + let defaultCase = kind.includesUnknownCase ? ".unknown" : "throw CodingError.unknownKey(key)" + return """ enum CodingFields: \(raw: kind.protocolName) { \(raw: cases) @@ -75,14 +88,14 @@ func makeCodingFieldsDecl( @_transparent var staticString: StaticString { switch self { - \(raw: switchCases) + \(raw: joinedSwitchCases) } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { \(raw: fieldForKeyCases) - default: throw CodingError.unknownKey(key) + default: \(raw: defaultCase) } } } @@ -95,7 +108,7 @@ func makeCodingFieldsDecl( @_transparent var staticString: StaticString { switch self { - \(raw: switchCases) + \(raw: joinedSwitchCases) } } } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index 584a87b5a..9c4b3d98a 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -36,12 +36,14 @@ struct JSONCodableMacroTests { enum CodingFields: JSONOptimizedCodingField { case name case age + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" case .age: "age" + case .unknown: fatalError() } } @@ -49,7 +51,7 @@ struct JSONCodableMacroTests { switch UTF8SpanComparator(key) { case "name": .name case "age": .age - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -70,10 +72,10 @@ struct JSONCodableMacroTests { var name: String? var age: Int? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "age": age = try valueDecoder.decode(Int.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .age: age = try valueDecoder.decode(Int.self) + case .unknown: break } return false } @@ -109,12 +111,14 @@ struct JSONCodableMacroTests { enum CodingFields: JSONOptimizedCodingField { case name case rating + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" case .rating: "rating" + case .unknown: fatalError() } } @@ -122,7 +126,7 @@ struct JSONCodableMacroTests { switch UTF8SpanComparator(key) { case "name": .name case "rating": .rating - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -143,10 +147,10 @@ struct JSONCodableMacroTests { var name: String? var rating: Double? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "rating": rating = try valueDecoder.decode(Double.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .rating: rating = try valueDecoder.decode(Double.self) + case .unknown: break } return false } @@ -179,12 +183,14 @@ struct JSONCodableMacroTests { enum CodingFields: JSONOptimizedCodingField { case publishDate case title + case unknown @_transparent var staticString: StaticString { switch self { case .publishDate: "date_published" case .title: "title" + case .unknown: fatalError() } } @@ -192,7 +198,7 @@ struct JSONCodableMacroTests { switch UTF8SpanComparator(key) { case "date_published": .publishDate case "title": .title - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -213,10 +219,10 @@ struct JSONCodableMacroTests { var publishDate: String? var title: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "date_published": publishDate = try valueDecoder.decode(String.self) - case "title": title = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .publishDate: publishDate = try valueDecoder.decode(String.self) + case .title: title = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -281,12 +287,14 @@ struct JSONCodableMacroTests { enum CodingFields: JSONOptimizedCodingField { case name case locale + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" case .locale: "locale" + case .unknown: fatalError() } } @@ -294,7 +302,7 @@ struct JSONCodableMacroTests { switch UTF8SpanComparator(key) { case "name": .name case "locale": .locale - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -315,10 +323,10 @@ struct JSONCodableMacroTests { var name: String? var locale: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "locale": locale = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .locale: locale = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -348,11 +356,13 @@ struct JSONCodableMacroTests { enum CodingFields: JSONOptimizedCodingField { case userName + case unknown @_transparent var staticString: StaticString { switch self { case .userName: "userName" + case .unknown: fatalError() } } @@ -360,7 +370,7 @@ struct JSONCodableMacroTests { switch UTF8SpanComparator(key) { case "userName": .userName case "user_name": .userName - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -379,10 +389,9 @@ struct JSONCodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var userName: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "userName": userName = try valueDecoder.decode(String.self) - case "user_name": userName = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .userName: userName = try valueDecoder.decode(String.self) + case .unknown: break } return false } diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 6589386ee..1d9fbca28 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -36,12 +36,14 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case name case age + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" case .age: "age" + case .unknown: fatalError() } } @@ -49,7 +51,7 @@ struct JSONDecodableMacroTests { switch UTF8SpanComparator(key) { case "name": .name case "age": .age - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -61,10 +63,10 @@ struct JSONDecodableMacroTests { var name: String? var age: Int? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "age": age = try valueDecoder.decode(Int.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .age: age = try valueDecoder.decode(Int.self) + case .unknown: break } return false } @@ -100,12 +102,14 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case name case rating + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" case .rating: "rating" + case .unknown: fatalError() } } @@ -113,7 +117,7 @@ struct JSONDecodableMacroTests { switch UTF8SpanComparator(key) { case "name": .name case "rating": .rating - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -125,10 +129,10 @@ struct JSONDecodableMacroTests { var name: String? var rating: Double? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "rating": rating = try valueDecoder.decode(Double.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .rating: rating = try valueDecoder.decode(Double.self) + case .unknown: break } return false } @@ -161,12 +165,14 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case theme case fontSize + case unknown @_transparent var staticString: StaticString { switch self { case .theme: "theme" case .fontSize: "fontSize" + case .unknown: fatalError() } } @@ -174,7 +180,7 @@ struct JSONDecodableMacroTests { switch UTF8SpanComparator(key) { case "theme": .theme case "fontSize": .fontSize - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -186,10 +192,10 @@ struct JSONDecodableMacroTests { var theme: String? var fontSize: Int? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "theme": theme = try valueDecoder.decode(String.self) - case "fontSize": fontSize = try valueDecoder.decode(Int.self) - default: break + switch try CodingFields.field(for: key) { + case .theme: theme = try valueDecoder.decode(String.self) + case .fontSize: fontSize = try valueDecoder.decode(Int.self) + case .unknown: break } return false } @@ -219,12 +225,14 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case publishDate case title + case unknown @_transparent var staticString: StaticString { switch self { case .publishDate: "date_published" case .title: "title" + case .unknown: fatalError() } } @@ -232,7 +240,7 @@ struct JSONDecodableMacroTests { switch UTF8SpanComparator(key) { case "date_published": .publishDate case "title": .title - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -244,10 +252,10 @@ struct JSONDecodableMacroTests { var publishDate: String? var title: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "date_published": publishDate = try valueDecoder.decode(String.self) - case "title": title = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .publishDate: publishDate = try valueDecoder.decode(String.self) + case .title: title = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -286,18 +294,20 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case name + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" + case .unknown: fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { case "name": .name - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -308,9 +318,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -342,18 +352,20 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case name + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" + case .unknown: fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { case "name": .name - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -364,9 +376,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -398,18 +410,20 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case name + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" + case .unknown: fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { case "name": .name - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -420,9 +434,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -521,6 +535,7 @@ struct JSONDecodableMacroTests { case name case locale case retryCount + case unknown @_transparent var staticString: StaticString { @@ -528,6 +543,7 @@ struct JSONDecodableMacroTests { case .name: "name" case .locale: "locale" case .retryCount: "retryCount" + case .unknown: fatalError() } } @@ -536,7 +552,7 @@ struct JSONDecodableMacroTests { case "name": .name case "locale": .locale case "retryCount": .retryCount - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -549,11 +565,11 @@ struct JSONDecodableMacroTests { var locale: String? var retryCount: Int? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "locale": locale = try valueDecoder.decode(String.self) - case "retryCount": retryCount = try valueDecoder.decode(Int.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .locale: locale = try valueDecoder.decode(String.self) + case .retryCount: retryCount = try valueDecoder.decode(Int.self) + case .unknown: break } return false } @@ -586,12 +602,14 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case greeting case verbose + case unknown @_transparent var staticString: StaticString { switch self { case .greeting: "greeting" case .verbose: "verbose" + case .unknown: fatalError() } } @@ -599,7 +617,7 @@ struct JSONDecodableMacroTests { switch UTF8SpanComparator(key) { case "greeting": .greeting case "verbose": .verbose - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -611,10 +629,10 @@ struct JSONDecodableMacroTests { var greeting: String? var verbose: Bool? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "greeting": greeting = try valueDecoder.decode(String.self) - case "verbose": verbose = try valueDecoder.decode(Bool.self) - default: break + switch try CodingFields.field(for: key) { + case .greeting: greeting = try valueDecoder.decode(String.self) + case .verbose: verbose = try valueDecoder.decode(Bool.self) + case .unknown: break } return false } @@ -641,18 +659,20 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case maxRetries + case unknown @_transparent var staticString: StaticString { switch self { case .maxRetries: "max_retries" + case .unknown: fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { case "max_retries": .maxRetries - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -663,9 +683,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var maxRetries: Int? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "max_retries": maxRetries = try valueDecoder.decode(Int.self) - default: break + switch try CodingFields.field(for: key) { + case .maxRetries: maxRetries = try valueDecoder.decode(Int.self) + case .unknown: break } return false } @@ -692,18 +712,20 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case locale + case unknown @_transparent var staticString: StaticString { switch self { case .locale: "locale" + case .unknown: fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { case "locale": .locale - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -714,9 +736,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var locale: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "locale": locale = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .locale: locale = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -743,18 +765,20 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case tags + case unknown @_transparent var staticString: StaticString { switch self { case .tags: "tags" + case .unknown: fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { case "tags": .tags - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -765,9 +789,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var tags: [String]? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "tags": tags = try valueDecoder.decode([String].self) - default: break + switch try CodingFields.field(for: key) { + case .tags: tags = try valueDecoder.decode([String].self) + case .unknown: break } return false } @@ -797,12 +821,14 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case userName case age + case unknown @_transparent var staticString: StaticString { switch self { case .userName: "userName" case .age: "age" + case .unknown: fatalError() } } @@ -811,7 +837,7 @@ struct JSONDecodableMacroTests { case "userName": .userName case "user_name": .userName case "age": .age - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -823,11 +849,10 @@ struct JSONDecodableMacroTests { var userName: String? var age: Int? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "userName": userName = try valueDecoder.decode(String.self) - case "user_name": userName = try valueDecoder.decode(String.self) - case "age": age = try valueDecoder.decode(Int.self) - default: break + switch try CodingFields.field(for: key) { + case .userName: userName = try valueDecoder.decode(String.self) + case .age: age = try valueDecoder.decode(Int.self) + case .unknown: break } return false } @@ -860,11 +885,13 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case userName + case unknown @_transparent var staticString: StaticString { switch self { case .userName: "user_name" + case .unknown: fatalError() } } @@ -872,7 +899,7 @@ struct JSONDecodableMacroTests { switch UTF8SpanComparator(key) { case "user_name": .userName case "username": .userName - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -883,10 +910,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var userName: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "user_name": userName = try valueDecoder.decode(String.self) - case "username": userName = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .userName: userName = try valueDecoder.decode(String.self) + case .unknown: break } return false } @@ -916,11 +942,13 @@ struct JSONDecodableMacroTests { enum CodingFields: JSONOptimizedDecodingField { case name + case unknown @_transparent var staticString: StaticString { switch self { case .name: "name" + case .unknown: fatalError() } } @@ -930,7 +958,7 @@ struct JSONDecodableMacroTests { case "a": .name case "b": .name case "c": .name - default: throw CodingError.unknownKey(key) + default: .unknown } } } @@ -941,12 +969,9 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - case "name": name = try valueDecoder.decode(String.self) - case "a": name = try valueDecoder.decode(String.self) - case "b": name = try valueDecoder.decode(String.self) - case "c": name = try valueDecoder.decode(String.self) - default: break + switch try CodingFields.field(for: key) { + case .name: name = try valueDecoder.decode(String.self) + case .unknown: break } return false } From 6c9d38debfe49ef78ac423c102599dee0f7da909 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 19:14:03 +0100 Subject: [PATCH 13/15] Code review: rename alias macro --- Sources/NewCodable/Macros.swift | 2 +- ...sMacro.swift => DecodableAliasMacro.swift} | 2 +- .../NewCodableMacros/JSONDecodableMacro.swift | 2 +- .../NewCodableMacros/JSONEncodableMacro.swift | 6 +- .../NewCodableMacrosPlugin.swift | 2 +- .../JSONCodableMacroTests.swift | 69 ++++++++++++++++++- .../JSONDecodableMacroTests.swift | 8 +-- .../JSONEncodableMacroTests.swift | 6 +- .../CodableRevolutionTests.swift | 2 +- 9 files changed, 82 insertions(+), 17 deletions(-) rename Sources/NewCodableMacros/{CodableAliasMacro.swift => DecodableAliasMacro.swift} (94%) diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index 3d92dcd5e..cf4043067 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -39,4 +39,4 @@ public macro CodableDefault(_ value: T) = #externalMacro(module: "NewCodableM /// Experimental per-property marker macro for accepting alternate decoding keys. @attached(peer) -public macro CodableAlias(_ names: String...) = #externalMacro(module: "NewCodableMacros", type: "CodableAliasMacro") +public macro DecodableAlias(_ names: String...) = #externalMacro(module: "NewCodableMacros", type: "DecodableAliasMacro") diff --git a/Sources/NewCodableMacros/CodableAliasMacro.swift b/Sources/NewCodableMacros/DecodableAliasMacro.swift similarity index 94% rename from Sources/NewCodableMacros/CodableAliasMacro.swift rename to Sources/NewCodableMacros/DecodableAliasMacro.swift index 75105d039..f670d63de 100644 --- a/Sources/NewCodableMacros/CodableAliasMacro.swift +++ b/Sources/NewCodableMacros/DecodableAliasMacro.swift @@ -13,7 +13,7 @@ import SwiftSyntax import SwiftSyntaxMacros -public struct CodableAliasMacro: PeerMacro { +public struct DecodableAliasMacro: PeerMacro { public static func expansion( of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 73314e4e6..68c70eb29 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -153,7 +153,7 @@ private func decodableAliases(from attributes: AttributeListSyntax) -> [String] for attribute in attributes { guard let attr = attribute.as(AttributeSyntax.self), let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), - identifierType.name.trimmedDescription == "CodableAlias", + identifierType.name.trimmedDescription == "DecodableAlias", let arguments = attr.arguments?.as(LabeledExprListSyntax.self) else { continue } diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index 1aed1dae2..18c9f8d41 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -134,7 +134,7 @@ func extractStoredProperties( } let customKey = customCodingKey(from: varDecl.attributes) - let aliases = codableAliases(from: varDecl.attributes) + let aliases = decodableAliases(from: varDecl.attributes) if (customKey != nil || !aliases.isEmpty) && varDecl.bindings.count > 1 { context.diagnose(.init( node: Syntax(varDecl), @@ -189,12 +189,12 @@ private func customCodingKey(from attributes: AttributeListSyntax) -> String? { return nil } -private func codableAliases(from attributes: AttributeListSyntax) -> [String] { +private func decodableAliases(from attributes: AttributeListSyntax) -> [String] { var aliases: [String] = [] for attribute in attributes { guard let attr = attribute.as(AttributeSyntax.self), let identifierType = attr.attributeName.as(IdentifierTypeSyntax.self), - identifierType.name.trimmedDescription == "CodableAlias", + identifierType.name.trimmedDescription == "DecodableAlias", let arguments = attr.arguments?.as(LabeledExprListSyntax.self) else { continue } diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index d36b531d1..acb207698 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -21,6 +21,6 @@ struct NewCodableMacrosPlugin: CompilerPlugin { JSONCodableMacro.self, CodingKeyMacro.self, CodableDefaultMacro.self, - CodableAliasMacro.self, + DecodableAliasMacro.self, ] } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index 9c4b3d98a..c492ea818 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -347,7 +347,7 @@ struct JSONCodableMacroTests { """ @JSONCodable struct User { - @CodableAlias("user_name") let userName: String + @DecodableAlias("user_name") let userName: String } """, expandedSource: """ @@ -407,6 +407,71 @@ struct JSONCodableMacroTests { ) } + @Test func aliasCombinedWithCodingKey() { + assertMacroExpansion( + """ + @JSONCodable + struct User { + @CodingKey("user_name") @DecodableAlias("username") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + + enum CodingFields: JSONOptimizedCodingField { + case userName + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "user_name" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "user_name": .userName + case "username": .userName + default: .unknown + } + } + } + } + + extension User: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + } + } + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in + switch try CodingFields.field(for: key) { + case .userName: userName = try valueDecoder.decode(String.self) + case .unknown: break + } + return false + } + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'user_name'") + } + return User(userName: userName) + } + } + } + """, + macros: codableTestMacros + ) + } + @Test func errorOnNonStruct() { assertMacroExpansion( """ @@ -433,5 +498,5 @@ private let codableTestMacros: [String: Macro.Type] = [ "JSONCodable": JSONCodableMacro.self, "CodingKey": CodingKeyMacro.self, "CodableDefault": CodableDefaultMacro.self, - "CodableAlias": CodableAliasMacro.self, + "DecodableAlias": DecodableAliasMacro.self, ] diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 1d9fbca28..068bf6b20 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -809,7 +809,7 @@ struct JSONDecodableMacroTests { """ @JSONDecodable struct User { - @CodableAlias("user_name") let userName: String + @DecodableAlias("user_name") let userName: String let age: Int } """, @@ -876,7 +876,7 @@ struct JSONDecodableMacroTests { """ @JSONDecodable struct User { - @CodingKey("user_name") @CodableAlias("username") let userName: String + @CodingKey("user_name") @DecodableAlias("username") let userName: String } """, expandedSource: """ @@ -933,7 +933,7 @@ struct JSONDecodableMacroTests { """ @JSONDecodable struct User { - @CodableAlias("a", "b", "c") let name: String + @DecodableAlias("a", "b", "c") let name: String } """, expandedSource: """ @@ -992,5 +992,5 @@ private let decodableTestMacros: [String: Macro.Type] = [ "JSONDecodable": JSONDecodableMacro.self, "CodingKey": CodingKeyMacro.self, "CodableDefault": CodableDefaultMacro.self, - "CodableAlias": CodableAliasMacro.self, + "DecodableAlias": DecodableAliasMacro.self, ] diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index 1c3ecd061..d14a30c60 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -19,7 +19,7 @@ import NewCodableMacros let testMacros: [String: Macro.Type] = [ "JSONEncodable": JSONEncodableMacro.self, "CodingKey": CodingKeyMacro.self, - "CodableAlias": CodableAliasMacro.self, + "DecodableAlias": DecodableAliasMacro.self, ] @Suite("@JSONEncodable Macro") @@ -374,12 +374,12 @@ struct JSONEncodableMacroTests { ) } - @Test func aliasInFieldForKey() { + @Test func decodableAliasIgnoredForEncodingOnly() { assertMacroExpansion( """ @JSONEncodable struct User { - @CodableAlias("user_name", "username") let userName: String + @DecodableAlias("user_name", "username") let userName: String let age: Int } """, diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index b8881b48c..b2731f87c 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2067,7 +2067,7 @@ struct CodableStructWithDefaultedProperty { @JSONCodable struct CodableStructWithAliasedProperty { - @CodableAlias("baz", "qux") + @DecodableAlias("baz", "qux") let bar: String } From 97ea3155fa14bea454f0373a51a0e0b0eb7b00f6 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 19:26:27 +0100 Subject: [PATCH 14/15] Code review: #expect(throws:) --- .../CodableRevolutionTests.swift | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index b2731f87c..7ea5ce258 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2019,13 +2019,13 @@ struct BlogPost { @JSONEncodable struct EmptyEncodable {} -@JSONEncodable @JSONDecodable +@JSONCodable struct RoundTripPerson { let name: String let age: Int } -@JSONEncodable @JSONDecodable +@JSONCodable struct RoundTripPost { let title: String @CodingKey("date_published") let publishDate: String @@ -2147,16 +2147,14 @@ struct JSONDecodableMacroIntegrationTests { @Test func missingRequiredFieldErrorIncludesCustomKeyName() { let json = Data("{}".utf8) - do { - _ = try NewJSONDecoder().decode(DecodableOnlyWithRequiredCustomKey.self, from: json) - Issue.record("Expected decoding to fail for a missing required field") - } catch let error { - guard case .dataCorrupted = error.kind else { - Issue.record("Unexpected CodingError.Decoding type: \(error)") - return - } - #expect(error.debugDescription.contains("Missing required field 'date_published'")) + let error = #expect(throws: CodingError.Decoding.self) { + try NewJSONDecoder().decode(DecodableOnlyWithRequiredCustomKey.self, from: json) + } + guard case .dataCorrupted = error?.kind else { + Issue.record("Unexpected CodingError.Decoding type: \(error)") + return } + #expect(error.debugDescription.contains("Missing required field 'date_published'")) } @Test func emptyDecodable() throws { From 76e0a9e3d4f42de91b729af91eefe35697a16969 Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Wed, 11 Mar 2026 19:51:23 +0100 Subject: [PATCH 15/15] Code review: use decodeEachField --- .../NewCodableMacros/JSONDecodableMacro.swift | 8 +- .../JSONCodableMacroTests.swift | 48 ++++--- .../JSONDecodableMacroTests.swift | 120 +++++++++++------- 3 files changed, 110 insertions(+), 66 deletions(-) diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 68c70eb29..5e0e39f10 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -275,12 +275,14 @@ extension JSONDecodableMacro: ExtensionMacro { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> \(typeName) { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in \(raw: varDeclarations) - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { \(raw: switchCases) case .unknown: break } - return false } \(raw: guardAndReturn) } diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index c492ea818..005aa3b29 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -71,13 +71,15 @@ struct JSONCodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? var age: Int? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .age: age = try valueDecoder.decode(Int.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -146,13 +148,15 @@ struct JSONCodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? var rating: Double? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .rating: rating = try valueDecoder.decode(Double.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -218,13 +222,15 @@ struct JSONCodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var publishDate: String? var title: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .publishDate: publishDate = try valueDecoder.decode(String.self) case .title: title = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let publishDate else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'date_published'") @@ -322,13 +328,15 @@ struct JSONCodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? var locale: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .locale: locale = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -388,12 +396,14 @@ struct JSONCodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var userName: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .userName: userName = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let userName else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'userName'") @@ -453,12 +463,14 @@ struct JSONCodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var userName: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .userName: userName = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let userName else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'user_name'") diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 068bf6b20..b14afb524 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -62,13 +62,15 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? var age: Int? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .age: age = try valueDecoder.decode(Int.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -128,13 +130,15 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? var rating: Double? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .rating: rating = try valueDecoder.decode(Double.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -191,13 +195,15 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var theme: String? var fontSize: Int? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .theme: theme = try valueDecoder.decode(String.self) case .fontSize: fontSize = try valueDecoder.decode(Int.self) case .unknown: break } - return false } return Preferences(theme: theme, fontSize: fontSize) } @@ -251,13 +257,15 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var publishDate: String? var title: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .publishDate: publishDate = try valueDecoder.decode(String.self) case .title: title = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let publishDate else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'date_published'") @@ -317,12 +325,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Thing { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -375,12 +385,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -433,12 +445,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Cached { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -564,14 +578,16 @@ struct JSONDecodableMacroTests { var name: String? var locale: String? var retryCount: Int? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .locale: locale = try valueDecoder.decode(String.self) case .retryCount: retryCount = try valueDecoder.decode(Int.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") @@ -628,13 +644,15 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var greeting: String? var verbose: Bool? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .greeting: greeting = try valueDecoder.decode(String.self) case .verbose: verbose = try valueDecoder.decode(Bool.self) case .unknown: break } - return false } return Defaults(greeting: greeting ?? "hello", verbose: verbose ?? false) } @@ -682,12 +700,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Setting { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var maxRetries: Int? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .maxRetries: maxRetries = try valueDecoder.decode(Int.self) case .unknown: break } - return false } return Setting(maxRetries: maxRetries ?? 3) } @@ -735,12 +755,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Prefs { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var locale: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .locale: locale = try valueDecoder.decode(String.self) case .unknown: break } - return false } return Prefs(locale: locale ?? "en") } @@ -788,12 +810,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> WithExpr { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var tags: [String]? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .tags: tags = try valueDecoder.decode([String].self) case .unknown: break } - return false } return WithExpr(tags: tags ?? []) } @@ -848,13 +872,15 @@ struct JSONDecodableMacroTests { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var userName: String? var age: Int? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .userName: userName = try valueDecoder.decode(String.self) case .age: age = try valueDecoder.decode(Int.self) case .unknown: break } - return false } guard let userName else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'userName'") @@ -909,12 +935,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var userName: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .userName: userName = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let userName else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'user_name'") @@ -968,12 +996,14 @@ struct JSONDecodableMacroTests { static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in var name: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch try CodingFields.field(for: key) { + var _codingField: CodingFields? + try structDecoder.decodeEachField { fieldDecoder throws(CodingError.Decoding) in + _codingField = try fieldDecoder.decode(CodingFields.self) + } andValue: { valueDecoder throws(CodingError.Decoding) in + switch _codingField! { case .name: name = try valueDecoder.decode(String.self) case .unknown: break } - return false } guard let name else { throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'")