diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift index cf4043067..d77f60c47 100644 --- a/Sources/NewCodable/Macros.swift +++ b/Sources/NewCodable/Macros.swift @@ -15,20 +15,29 @@ /// 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)) +@attached(extension, conformances: JSONEncodable, names: named(CodingFields), named(encode)) 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)) +@attached(extension, conformances: JSONDecodable, names: named(CodingFields), 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)) +@attached(extension, conformances: JSONEncodable, JSONDecodable, names: named(CodingFields), named(encode), named(decode)) public macro JSONCodable() = #externalMacro(module: "NewCodableMacros", type: "JSONCodableMacro") +/// Experimental macro that synthesizes `CommonEncodable` conformance. +@attached(extension, conformances: CommonEncodable, names: named(CodingFields), named(encode)) +public macro CommonEncodable() = #externalMacro(module: "NewCodableMacros", type: "CommonEncodableMacro") + +/// Experimental macro that synthesizes `CommonDecodable` conformance. +@attached(extension, conformances: CommonDecodable, names: named(CodingFields), named(decode)) +public macro CommonDecodable() = #externalMacro(module: "NewCodableMacros", type: "CommonDecodableMacro") + +/// Experimental macro that synthesizes both `CommonEncodable` and `CommonDecodable`. +@attached(extension, conformances: CommonEncodable, CommonDecodable, names: named(CodingFields), named(encode), named(decode)) +public macro CommonCodable() = #externalMacro(module: "NewCodableMacros", type: "CommonCodableMacro") + /// Experimental per-property marker macro for overriding the serialized key. @attached(peer) public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") diff --git a/Sources/NewCodable/SharedTypes.swift b/Sources/NewCodable/SharedTypes.swift index f765ee36f..c09a95746 100644 --- a/Sources/NewCodable/SharedTypes.swift +++ b/Sources/NewCodable/SharedTypes.swift @@ -476,7 +476,7 @@ public protocol StaticStringEncodingField: EncodingField, ~Escapable { public protocol StaticStringCodingField: StaticStringDecodingField, StaticStringEncodingField, ~Escapable { } -public extension CodingField where Self: StaticStringCodingField & ~Escapable { +public extension StaticStringCodingField where Self: ~Escapable { @_alwaysEmitIntoClient @inline(__always) // var utf8Span: UTF8Span { @_lifetime(borrow self) get } diff --git a/Sources/NewCodableMacros/CommonCodableMacro.swift b/Sources/NewCodableMacros/CommonCodableMacro.swift new file mode 100644 index 000000000..3fa49f035 --- /dev/null +++ b/Sources/NewCodableMacros/CommonCodableMacro.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftDiagnostics + +public struct CommonCodableMacro { } + +extension CommonCodableMacro: 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 validate(declaration: declaration, for: node, in: context) else { + return [] + } + + guard let (typeName, properties) = extractTypeNameAndStoredProperties( + attachedTo: declaration, + for: node, + providingExtensionsOf: type, + in: context) else { + return [] + } + + let codingFields = makeCodingFieldsExtension(for: typeName, from: properties, kind: CommonCodingFieldExpansionKind.both) + let encodingImpl = makeEncodableExtension(for: typeName, with: properties, kind: CommonEncodableExpansionKind()) + let decodingImpl = makeDecodableExtension(for: typeName, with: properties, kind: CommonDecodableExpansionKind()) + return [codingFields, encodingImpl, decodingImpl].compactMap { $0 } + } +} + +enum CommonCodingFieldExpansionKind: CodingFieldExpansionKind { + case encodingOnly + case decodingOnly + case both + + var protocolName: String { + switch self { + case .encodingOnly: + return "StaticStringEncodingField" + case .decodingOnly: + return "StaticStringDecodingField" + case .both: + return "StaticStringCodingField" + } + } +} diff --git a/Sources/NewCodableMacros/CommonDecodableMacro.swift b/Sources/NewCodableMacros/CommonDecodableMacro.swift new file mode 100644 index 000000000..8a3c7b814 --- /dev/null +++ b/Sources/NewCodableMacros/CommonDecodableMacro.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// 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 CommonDecodableMacro { } + +extension CommonDecodableMacro: 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 validate(declaration: declaration, for: node, in: context) else { + return [] + } + + guard let (typeName, properties) = extractTypeNameAndStoredProperties( + attachedTo: declaration, + for: node, + providingExtensionsOf: type, + in: context) else { + return [] + } + + let codingFields = makeCodingFieldsExtension(for: typeName, from: properties, kind: CommonCodingFieldExpansionKind.decodingOnly) + let impl = makeDecodableExtension(for: typeName, with: properties, kind: CommonDecodableExpansionKind()) + return [codingFields, impl].compactMap { $0 } + } +} + +struct CommonDecodableExpansionKind: DecodableExpansionKind { + var protocolName: String { "CommonDecodable" } + var decoderType: String { "inout some CommonDecoder & ~Escapable" } +} diff --git a/Sources/NewCodableMacros/CommonEncodableMacro.swift b/Sources/NewCodableMacros/CommonEncodableMacro.swift new file mode 100644 index 000000000..441b3ff33 --- /dev/null +++ b/Sources/NewCodableMacros/CommonEncodableMacro.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// 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 CommonEncodableMacro { } + +extension CommonEncodableMacro: 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 validate(declaration: declaration, for: node, in: context) else { + return [] + } + + guard let (typeName, properties) = extractTypeNameAndStoredProperties( + attachedTo: declaration, + for: node, + providingExtensionsOf: type, + in: context) else { + return [] + } + + let codingFields = makeCodingFieldsExtension(for: typeName, from: properties, kind: CommonCodingFieldExpansionKind.encodingOnly) + let impl = makeEncodableExtension(for: typeName, with: properties, kind: CommonEncodableExpansionKind()) + return [codingFields, impl].compactMap { $0 } + } +} + +struct CommonEncodableExpansionKind: EncodableExpansionKind { + var protocolName: String { "CommonEncodable" } + var encoderType: String { "inout some CommonEncoder & ~Copyable & ~Escapable" } +} diff --git a/Sources/NewCodableMacros/JSONCodableMacro.swift b/Sources/NewCodableMacros/JSONCodableMacro.swift index c0c2f57f9..6f1f811da 100644 --- a/Sources/NewCodableMacros/JSONCodableMacro.swift +++ b/Sources/NewCodableMacros/JSONCodableMacro.swift @@ -16,52 +16,46 @@ import SwiftDiagnostics public struct JSONCodableMacro { } -extension JSONCodableMacro: MemberMacro { +extension JSONCodableMacro: ExtensionMacro { public static func expansion( of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard declaration.is(StructDeclSyntax.self) else { - context.diagnose(.init( - node: node, - message: JSONEncodableDiagnostic.notAStruct - )) + ) throws -> [ExtensionDeclSyntax] { + guard validate(declaration: declaration, for: node, in: context) else { return [] } - - let properties = extractStoredProperties(from: declaration.memberBlock, in: context) - guard !properties.isEmpty else { + + guard let (typeName, properties) = extractTypeNameAndStoredProperties( + attachedTo: declaration, + for: node, + providingExtensionsOf: type, + in: context) else { return [] } - - return [makeCodingFieldsDecl(from: properties, kind: .coding)] + + let codingFields = makeCodingFieldsExtension(for: typeName, from: properties, kind: JSONCodingFieldKind.both) + let encodingImpl = makeEncodableExtension(for: typeName, with: properties, kind: JSONEncodableExpansionKind()) + let decodingImpl = makeDecodableExtension(for: typeName, with: properties, kind: JSONDecodableExpansionKind()) + return [codingFields, encodingImpl, decodingImpl].compactMap { $0 } } } -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 +enum JSONCodingFieldKind: CodingFieldExpansionKind { + case encodingOnly + case decodingOnly + case both + + var protocolName: String { + switch self { + case .encodingOnly: + return "JSONOptimizedEncodingField" + case .decodingOnly: + return "JSONOptimizedDecodingField" + case .both: + return "JSONOptimizedCodingField" + } } } diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift index 77737dcbb..fde5cd0f5 100644 --- a/Sources/NewCodableMacros/JSONDecodableMacro.swift +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -17,186 +17,6 @@ import SwiftDiagnostics public struct JSONDecodableMacro { } -private struct DecodableStoredProperty { - let name: String - let jsonKey: String - let aliases: [String] - let typeName: String - let isOptional: Bool - let defaultExpr: String? - - var isRequired: Bool { - !isOptional && defaultExpr == nil - } -} - -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) - let defaultExpr = decodableDefaultExpression(from: varDecl.attributes) - 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 - )) - 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, - aliases: aliases, - typeName: typeName, - isOptional: isOptional, - defaultExpr: defaultExpr - )) - } - } - - 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), - 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 -} - -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 == "DecodableAlias", - 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: 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, @@ -205,123 +25,26 @@ extension JSONDecodableMacro: ExtensionMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard declaration.is(StructDeclSyntax.self) else { - context.diagnose(.init( - node: node, - message: JSONDecodableDiagnostic.notAStruct - )) + guard validate(declaration: declaration, for: node, in: context) else { return [] } - - guard let properties = extractDecodableStoredProperties(from: declaration.memberBlock, in: context) else { + + guard let (typeName, properties) = extractTypeNameAndStoredProperties( + attachedTo: declaration, + for: node, + providingExtensionsOf: type, + 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 { prop in - if prop.isOptional { - return "case .\(prop.name): \(prop.name) = try valueDecoder.decode(\(prop.typeName)?.self)" - } else { - return "case .\(prop.name): \(prop.name) = try valueDecoder.decode(\(prop.typeName).self)" - } - }.joined(separator: "\n ") - - let requiredProperties = properties.filter { $0.isRequired } - - let guardAndReturn: String - if requiredProperties.isEmpty { - 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 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)" - } - return "\(prop.name): \(prop.name)" - }.joined(separator: ", ") - guardAndReturn = """ - \(requiredFieldGuards) - 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) - 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 - } - } - \(raw: guardAndReturn) - } - } - } - """ - } - - guard let ext = extensionDecl.as(ExtensionDeclSyntax.self) else { - return [] - } - - return [ext] + + let codingFields = makeCodingFieldsExtension(for: typeName, from: properties, kind: JSONCodingFieldKind.decodingOnly) + let impl = makeDecodableExtension(for: typeName, with: properties, kind: JSONDecodableExpansionKind()) + return [codingFields, impl].compactMap { $0 } } } -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 } +struct JSONDecodableExpansionKind: DecodableExpansionKind { + var protocolName: String { "JSONDecodable" } + var decoderType: String { "inout some JSONDecoderProtocol & ~Escapable" } } + diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift index 18c9f8d41..2baea786a 100644 --- a/Sources/NewCodableMacros/JSONEncodableMacro.swift +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -17,222 +17,6 @@ import SwiftDiagnostics public struct JSONEncodableMacro { } -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 - } - } - - var includesUnknownCase: Bool { - switch self { - case .encodingOnly: - return false - case .decodingOnly, .coding: - return true - } - } -} - -struct StoredProperty { - let name: String - let jsonKey: String - let aliases: [String] -} - -func makeCodingFieldsDecl( - from properties: [StoredProperty], - kind: CodingFieldExpansionKind -) -> DeclSyntax { - let cases = (properties.map { "case \($0.name)" } + (kind.includesUnknownCase ? ["case unknown"] : [])) - .joined(separator: "\n ") - - let switchCases = properties.map { - "case .\($0.name): \"\($0.jsonKey)\"" - } + (kind.includesUnknownCase ? ["case .unknown: fatalError()"] : []) - let joinedSwitchCases = switchCases.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 ") - - let defaultCase = kind.includesUnknownCase ? ".unknown" : "throw CodingError.unknownKey(key)" - - return """ - enum CodingFields: \(raw: kind.protocolName) { - \(raw: cases) - - @_transparent - var staticString: StaticString { - switch self { - \(raw: joinedSwitchCases) - } - } - - static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { - switch UTF8SpanComparator(key) { - \(raw: fieldForKeyCases) - default: \(raw: defaultCase) - } - } - } - """ - } else { - return """ - enum CodingFields: \(raw: kind.protocolName) { - \(raw: cases) - - @_transparent - var staticString: StaticString { - switch self { - \(raw: joinedSwitchCases) - } - } - } - """ - } -} - -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 - } - - let customKey = customCodingKey(from: varDecl.attributes) - let aliases = decodableAliases(from: varDecl.attributes) - if (customKey != nil || !aliases.isEmpty) && 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 { - 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 = customKey ?? propertyName - - properties.append(StoredProperty(name: propertyName, jsonKey: jsonKey, aliases: aliases)) - } - } - - 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 -} - -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 == "DecodableAlias", - 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, - 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 [] - } - - return [makeCodingFieldsDecl(from: properties, kind: .encodingOnly)] - } -} - extension JSONEncodableMacro: ExtensionMacro { public static func expansion( of node: AttributeSyntax, @@ -241,63 +25,25 @@ extension JSONEncodableMacro: ExtensionMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard declaration.is(StructDeclSyntax.self) else { + guard validate(declaration: declaration, for: node, in: context) 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 { + guard let (typeName, properties) = extractTypeNameAndStoredProperties( + attachedTo: declaration, + for: node, + providingExtensionsOf: type, + in: context) else { return [] } - - return [ext] + + let codingFields = makeCodingFieldsExtension(for: typeName, from: properties, kind: JSONCodingFieldKind.encodingOnly) + let impl = makeEncodableExtension(for: typeName, with: properties, kind: JSONEncodableExpansionKind()) + return [codingFields, impl].compactMap { $0 } } } -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" - } - } - - var diagnosticID: MessageID { - MessageID(domain: "NewCodableMacros", id: rawValue) - } - - var severity: DiagnosticSeverity { .error } +struct JSONEncodableExpansionKind: EncodableExpansionKind { + var protocolName: String { "JSONEncodable" } + var encoderType: String { "inout JSONDirectEncoder" } } diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift index acb207698..ca3cd5b0f 100644 --- a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -19,6 +19,9 @@ struct NewCodableMacrosPlugin: CompilerPlugin { JSONEncodableMacro.self, JSONDecodableMacro.self, JSONCodableMacro.self, + CommonEncodableMacro.self, + CommonDecodableMacro.self, + CommonCodableMacro.self, CodingKeyMacro.self, CodableDefaultMacro.self, DecodableAliasMacro.self, diff --git a/Sources/NewCodableMacros/SharedMacroInfrastructure.swift b/Sources/NewCodableMacros/SharedMacroInfrastructure.swift new file mode 100644 index 000000000..1929bac4f --- /dev/null +++ b/Sources/NewCodableMacros/SharedMacroInfrastructure.swift @@ -0,0 +1,506 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +// MARK: - Shared Data Types + +/// Represents a detailed stored property with type information and default values +struct DetailedStoredProperty { + let name: String + let key: String + let aliases: [String] + let typeName: String + let isOptional: Bool + let defaultExpr: String? + + var isRequired: Bool { + !isOptional && defaultExpr == nil + } +} + +/// Represents the kind of coding fields to generate +protocol CodingFieldExpansionKind: Equatable { + static var encodingOnly: Self { get } + static var decodingOnly: Self { get } + static var both: Self { get } + + /// The protocol name that the generated enum should conform to + var protocolName: String { get } + + /// Whether the generated enum should include key lookup functionality + var includesKeyLookup: Bool { get } + + /// Whether the generated enum should include an "unknown" case + var includesUnknownCase: Bool { get } + + /// Whether this kind supports encoding operations + var supportsEncoding: Bool { get } + + /// Whether this kind supports decoding operations + var supportsDecoding: Bool { get } +} + +extension CodingFieldExpansionKind { + var includesKeyLookup: Bool { + supportsDecoding + } + + var includesUnknownCase: Bool { + supportsDecoding + } + + var supportsEncoding: Bool { + switch self { + case .encodingOnly, .both: + return true + default: + return false + } + } + + var supportsDecoding: Bool { + switch self { + case .decodingOnly, .both: + return true + default: + return false + } + } +} + +// MARK: - Shared Property Extraction + +func extractDetailedStoredProperties( + from members: MemberBlockSyntax, + for node: AttributeSyntax, + in context: some MacroExpansionContext +) -> [DetailedStoredProperty]? { + var properties: [DetailedStoredProperty] = [] + + 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 = customCodingKey(from: varDecl.attributes) + let defaultExpr = defaultValueExpression(from: varDecl.attributes) + let aliases = decodableAliases(from: varDecl.attributes) + if (customKey != nil || defaultExpr != nil || !aliases.isEmpty) && varDecl.bindings.count > 1 { + context.diagnose(.init( + node: Syntax(varDecl), + message: SharedMacroDiagnostic.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: SharedMacroDiagnostic.missingTypeAnnotation(macroName: node.attributeName.trimmedDescription) + )) + return nil + } + + let propertyName = pattern.identifier.trimmedDescription + let key = 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(DetailedStoredProperty( + name: propertyName, + key: key, + aliases: aliases, + typeName: typeName, + isOptional: isOptional, + defaultExpr: defaultExpr + )) + } + } + + return properties +} + +// MARK: - Attribute Parsing Utilities + +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 +} + +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 == "DecodableAlias", + 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 +} + +func defaultValueExpression(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 +} + +// MARK: - Coding Fields Generation + +/// Unified function for generating coding fields with any expansion kind +func makeCodingFieldsExtension( + for typeName: TokenSyntax, + from properties: [DetailedStoredProperty], + kind: T +) -> ExtensionDeclSyntax? { + if properties.isEmpty { + return nil + } + + let casesList = properties.map { "case \($0.name)" } + (kind.includesUnknownCase ? ["case unknown"] : []) + let cases = casesList.joined(separator: "\n") + + let switchCasesList = properties.map { + "case .\($0.name): \"\($0.key)\"" + } + (kind.includesUnknownCase ? ["case .unknown: fatalError()"] : []) + let joinedSwitchCases = switchCasesList.joined(separator: "\n") + + let decl: DeclSyntax + if kind.includesKeyLookup { + let fieldForKeyCasesList = properties.flatMap { prop -> [String] in + var cases = ["case \"\(prop.key)\": .\(prop.name)"] + for alias in prop.aliases { + cases.append("case \"\(alias)\": .\(prop.name)") + } + return cases + } + let fieldForKeyCases = fieldForKeyCasesList.joined(separator: "\n") + + let defaultCase = kind.includesUnknownCase ? ".unknown" : "throw CodingError.unknownKey(key)" + + decl = """ + extension \(typeName) { + enum CodingFields: \(raw: kind.protocolName) { + \(raw: cases) + + @_transparent + var staticString: StaticString { + switch self { + \(raw: joinedSwitchCases) + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + \(raw: fieldForKeyCases) + default: \(raw: defaultCase) + } + } + } + } + """ + } else { + decl = """ + extension \(typeName) { + enum CodingFields: \(raw: kind.protocolName) { + \(raw: cases) + + @_transparent + var staticString: StaticString { + switch self { + \(raw: joinedSwitchCases) + } + } + } + } + """ + } + return decl.as(ExtensionDeclSyntax.self) +} + +// MARK: - Shared Diagnostics + +func validate(declaration: some DeclGroupSyntax, for node: AttributeSyntax, in context: some MacroExpansionContext) -> Bool { + guard declaration.is(StructDeclSyntax.self) else { + context.diagnose(.init( + node: node, + message: SharedMacroDiagnostic.notAStruct(macroName: node.attributeName.trimmedDescription) + )) + return false + } + return true +} + +enum SharedMacroDiagnostic: DiagnosticMessage { + case notAStruct(macroName: String) + case codingKeyOnMultipleBindings + case missingTypeAnnotation(macroName: String) + + var message: String { + switch self { + case .notAStruct(let macroName): + return "@\(macroName) can only be applied to structs" + case .codingKeyOnMultipleBindings: + return "@CodingKey cannot be applied to a declaration with multiple bindings" + case .missingTypeAnnotation(let macroName): + return "@\(macroName) requires all stored properties to have explicit type annotations" + } + } + + var id: String { + switch self { + case .notAStruct: "notAStruct" + case .codingKeyOnMultipleBindings: "codingKeyOnMultipleBindings" + case .missingTypeAnnotation: "missingTypeAnnotation" + } + } + + var diagnosticID: MessageID { + MessageID(domain: "NewCodableMacros", id: self.id) + } + + var severity: DiagnosticSeverity { .error } +} + +// MARK: - Encodable Extension Generation + +/// Abstracts over the differences between Common and JSON encodable macro expansions +protocol EncodableExpansionKind { + /// The protocol the generated extension conforms to (e.g. "CommonEncodable", "JSONEncodable") + var protocolName: String { get } + + /// The encoder parameter type in the encode function signature + var encoderType: String { get } +} + +func makeEncodableExtension( + for typeName: TokenSyntax, + with properties: [DetailedStoredProperty], + kind: some EncodableExpansionKind +) -> ExtensionDeclSyntax? { + let extensionDecl: DeclSyntax + if properties.isEmpty { + extensionDecl = """ + extension \(typeName): \(raw: kind.protocolName) { + func encode(to encoder: \(raw: kind.encoderType)) 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 \(typeName): \(raw: kind.protocolName) { + func encode(to encoder: \(raw: kind.encoderType)) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: \(raw: fieldCount)) { structEncoder throws(CodingError.Encoding) in + \(raw: encodeStatements) + } + } + } + """ + } + + return extensionDecl.as(ExtensionDeclSyntax.self) +} + +// MARK: - Decodable Extension Generation + +/// Abstracts over the differences between Common and JSON decodable macro expansions +protocol DecodableExpansionKind { + /// The protocol the generated extension conforms to (e.g. "CommonDecodable", "JSONDecodable") + var protocolName: String { get } + + /// The decoder parameter type in the decode function signature + var decoderType: String { get } +} + +func makeDecodableExtension( + for typeName: TokenSyntax, + with properties: [DetailedStoredProperty], + kind: some DecodableExpansionKind +) -> ExtensionDeclSyntax? { + let decl: DeclSyntax + if properties.isEmpty { + decl = """ + extension \(typeName): \(raw: kind.protocolName) { + static func decode(from decoder: \(raw: kind.decoderType)) 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 { prop in + if prop.isOptional { + return "case .\(prop.name): \(prop.name) = try valueDecoder.decode(\(prop.typeName)?.self)" + } else { + return "case .\(prop.name): \(prop.name) = try valueDecoder.decode(\(prop.typeName).self)" + } + }.joined(separator: "\n") + + let requiredProperties = properties.filter { $0.isRequired } + + let guardAndReturn: String + if requiredProperties.isEmpty { + 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 requiredFieldGuards = requiredProperties.map { + """ + guard let \($0.name) else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field '\($0.key)'") + } + """ + }.joined(separator: "\n") + 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 = """ + \(requiredFieldGuards) + return \(typeName)(\(args)) + """ + } + + decl = """ + extension \(typeName): \(raw: kind.protocolName) { + static func decode(from decoder: \(raw: kind.decoderType)) throws(CodingError.Decoding) -> \(typeName) { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + \(raw: varDeclarations) + 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 + } + } + \(raw: guardAndReturn) + } + } + } + """ + } + + return decl.as(ExtensionDeclSyntax.self) +} + +// MARK: - Shared Macro Implementation Patterns + +func extractTypeNameAndStoredProperties( + attachedTo declaration: some DeclGroupSyntax, + for node: AttributeSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + in context: some MacroExpansionContext, +) -> (TokenSyntax, [DetailedStoredProperty])? { + guard let properties = extractDetailedStoredProperties(from: declaration.memberBlock, for: node, in: context) else { + return nil + } + + // Extract the type name as a TokenSyntax + let typeName: TokenSyntax + if let identifierType = type.as(IdentifierTypeSyntax.self) { + typeName = identifierType.name + } else { + // For complex types, we'll use the trimmed description as the identifier + typeName = TokenSyntax(.identifier(type.trimmedDescription), presence: .present) + } + return (typeName, properties) +} diff --git a/Tests/NewCodableMacrosTests/CommonCodableMacroTests.swift b/Tests/NewCodableMacrosTests/CommonCodableMacroTests.swift new file mode 100644 index 000000000..e516de75e --- /dev/null +++ b/Tests/NewCodableMacrosTests/CommonCodableMacroTests.swift @@ -0,0 +1,576 @@ +//===----------------------------------------------------------------------===// +// +// 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("@CommonCodable Macro") +struct CommonCodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @CommonCodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + } + + extension Person { + enum CodingFields: StaticStringCodingField { + case name + case age + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .age: + "age" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": + .name + case "age": + .age + default: + .unknown + } + } + } + } + + extension Person: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Person { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var age: Int? + 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 + } + } + 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) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @CommonCodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + } + + extension Item { + enum CodingFields: StaticStringCodingField { + case name + case rating + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .rating: + "rating" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": + .name + case "rating": + .rating + default: + .unknown + } + } + } + } + + extension Item: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Item { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var rating: Double? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Item(name: name, rating: rating) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func customCodingKey() { + assertMacroExpansion( + """ + @CommonCodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + } + + extension Post { + enum CodingFields: StaticStringCodingField { + case publishDate + case title + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: + "date_published" + case .title: + "title" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": + .publishDate + case "title": + .title + default: + .unknown + } + } + } + } + + extension Post: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Post { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var publishDate: String? + var title: String? + 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 + } + } + 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) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @CommonCodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in + } + } + } + + extension Empty: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Empty { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + Empty() + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func defaultValue() { + assertMacroExpansion( + """ + @CommonCodable + struct Config { + let name: String + @CodableDefault("en") let locale: String + } + """, + expandedSource: """ + struct Config { + let name: String + let locale: String + } + + extension Config { + enum CodingFields: StaticStringCodingField { + case name + case locale + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .locale: + "locale" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": + .name + case "locale": + .locale + default: + .unknown + } + } + } + } + + extension Config: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var locale: String? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Config(name: name, locale: locale ?? "en") + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func aliasFullRoundtrip() { + assertMacroExpansion( + """ + @CommonCodable + struct User { + @DecodableAlias("user_name") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + } + + extension User { + enum CodingFields: StaticStringCodingField { + case userName + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .userName: + "userName" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "userName": + .userName + case "user_name": + .userName + default: + .unknown + } + } + } + } + + extension User: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + } + } + } + + extension User: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + 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 + } + } + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'userName'") + } + return User(userName: userName) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func aliasCombinedWithCodingKey() { + assertMacroExpansion( + """ + @CommonCodable + struct User { + @CodingKey("user_name") @DecodableAlias("username") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + } + + extension User { + enum CodingFields: StaticStringCodingField { + 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: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + } + } + } + + extension User: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + 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 + } + } + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'user_name'") + } + return User(userName: userName) + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @CommonCodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CommonCodable can only be applied to structs", line: 1, column: 1), + ], + macros: codableTestMacros + ) + } +} + +private let codableTestMacros: [String: Macro.Type] = [ + "CommonCodable": CommonCodableMacro.self, + "CodingKey": CodingKeyMacro.self, + "CodableDefault": CodableDefaultMacro.self, + "DecodableAlias": DecodableAliasMacro.self, +] diff --git a/Tests/NewCodableMacrosTests/CommonDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/CommonDecodableMacroTests.swift new file mode 100644 index 000000000..f1107cb6a --- /dev/null +++ b/Tests/NewCodableMacrosTests/CommonDecodableMacroTests.swift @@ -0,0 +1,1175 @@ +//===----------------------------------------------------------------------===// +// +// 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("@CommonDecodable Macro") +struct CommonDecodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @CommonDecodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + } + + extension Person { + enum CodingFields: StaticStringDecodingField { + case name + case age + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .age: + "age" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": + .name + case "age": + .age + default: + .unknown + } + } + } + } + + extension Person: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Person { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var age: Int? + 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 + } + } + 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) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @CommonDecodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + } + + extension Item { + enum CodingFields: StaticStringDecodingField { + case name + case rating + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .rating: + "rating" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": + .name + case "rating": + .rating + default: + .unknown + } + } + } + } + + extension Item: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Item { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var rating: Double? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Item(name: name, rating: rating) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func allOptionalProperties() { + assertMacroExpansion( + """ + @CommonDecodable + struct Preferences { + let theme: String? + let fontSize: Int? + } + """, + expandedSource: """ + struct Preferences { + let theme: String? + let fontSize: Int? + } + + extension Preferences { + enum CodingFields: StaticStringDecodingField { + case theme + case fontSize + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .theme: + "theme" + case .fontSize: + "fontSize" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "theme": + .theme + case "fontSize": + .fontSize + default: + .unknown + } + } + } + } + + extension Preferences: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Preferences { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var theme: String? + var fontSize: Int? + 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 Preferences(theme: theme, fontSize: fontSize) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func customCodingKey() { + assertMacroExpansion( + """ + @CommonDecodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + } + + extension Post { + enum CodingFields: StaticStringDecodingField { + case publishDate + case title + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: + "date_published" + case .title: + "title" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": + .publishDate + case "title": + .title + default: + .unknown + } + } + } + } + + extension Post: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Post { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var publishDate: String? + var title: String? + 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 + } + } + 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) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func computedPropertySkipped() { + assertMacroExpansion( + """ + @CommonDecodable + 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 { + enum CodingFields: StaticStringDecodingField { + 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: + .unknown + } + } + } + } + + extension Thing: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Thing { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Thing(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func staticPropertySkipped() { + assertMacroExpansion( + """ + @CommonDecodable + struct Config { + static let defaultName = "test" + let name: String + } + """, + expandedSource: """ + struct Config { + static let defaultName = "test" + let name: String + } + + extension Config { + enum CodingFields: StaticStringDecodingField { + 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: + .unknown + } + } + } + } + + extension Config: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Config(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func lazyVarSkipped() { + assertMacroExpansion( + """ + @CommonDecodable + 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 { + enum CodingFields: StaticStringDecodingField { + 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: + .unknown + } + } + } + } + + extension Cached: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Cached { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Cached(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @CommonDecodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Empty { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + Empty() + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @CommonDecodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CommonDecodable can only be applied to structs", line: 1, column: 1) + ], + macros: decodableTestMacros + ) + } + + @Test func propertyWithoutTypeAnnotation() { + assertMacroExpansion( + """ + @CommonDecodable + struct Bad { + let name = "default" + } + """, + expandedSource: """ + struct Bad { + let name = "default" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CommonDecodable requires all stored properties to have explicit type annotations", line: 3, column: 9) + ], + macros: decodableTestMacros + ) + } + + @Test func defaultValue() { + assertMacroExpansion( + """ + @CommonDecodable + 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 { + enum CodingFields: StaticStringDecodingField { + case name + case locale + case retryCount + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .locale: + "locale" + case .retryCount: + "retryCount" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": + .name + case "locale": + .locale + case "retryCount": + .retryCount + default: + .unknown + } + } + } + } + + extension Config: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var locale: String? + var retryCount: Int? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return Config(name: name, locale: locale ?? "en", retryCount: retryCount ?? 0) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func allDefaultValues() { + assertMacroExpansion( + """ + @CommonDecodable + struct Defaults { + @CodableDefault("hello") let greeting: String + @CodableDefault(false) let verbose: Bool + } + """, + expandedSource: """ + struct Defaults { + let greeting: String + let verbose: Bool + } + + extension Defaults { + enum CodingFields: StaticStringDecodingField { + case greeting + case verbose + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .greeting: + "greeting" + case .verbose: + "verbose" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "greeting": + .greeting + case "verbose": + .verbose + default: + .unknown + } + } + } + } + + extension Defaults: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Defaults { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var greeting: String? + var verbose: Bool? + 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 Defaults(greeting: greeting ?? "hello", verbose: verbose ?? false) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func defaultWithCodingKey() { + assertMacroExpansion( + """ + @CommonDecodable + struct Setting { + @CodingKey("max_retries") @CodableDefault(3) let maxRetries: Int + } + """, + expandedSource: """ + struct Setting { + let maxRetries: Int + } + + extension Setting { + enum CodingFields: StaticStringDecodingField { + 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: + .unknown + } + } + } + } + + extension Setting: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Setting { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var maxRetries: Int? + 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 Setting(maxRetries: maxRetries ?? 3) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func defaultOnOptionalProperty() { + assertMacroExpansion( + """ + @CommonDecodable + struct Prefs { + @CodableDefault("en") let locale: String? + } + """, + expandedSource: """ + struct Prefs { + let locale: String? + } + + extension Prefs { + enum CodingFields: StaticStringDecodingField { + 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: + .unknown + } + } + } + } + + extension Prefs: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> Prefs { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var locale: String? + 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 Prefs(locale: locale ?? "en") + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func defaultWithArbitraryExpression() { + assertMacroExpansion( + """ + @CommonDecodable + struct WithExpr { + @CodableDefault([]) let tags: [String] + } + """, + expandedSource: """ + struct WithExpr { + let tags: [String] + } + + extension WithExpr { + enum CodingFields: StaticStringDecodingField { + 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: + .unknown + } + } + } + } + + extension WithExpr: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> WithExpr { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var tags: [String]? + 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 WithExpr(tags: tags ?? []) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func aliasBasic() { + assertMacroExpansion( + """ + @CommonDecodable + struct User { + @DecodableAlias("user_name") let userName: String + let age: Int + } + """, + expandedSource: """ + struct User { + let userName: String + let age: Int + } + + extension User { + enum CodingFields: StaticStringDecodingField { + case userName + case age + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .userName: + "userName" + case .age: + "age" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "userName": + .userName + case "user_name": + .userName + case "age": + .age + default: + .unknown + } + } + } + } + + extension User: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + var age: Int? + 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 + } + } + 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) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func aliasCombinedWithCodingKey() { + assertMacroExpansion( + """ + @CommonDecodable + struct User { + @CodingKey("user_name") @DecodableAlias("username") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + } + + extension User { + enum CodingFields: StaticStringDecodingField { + 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: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + 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 + } + } + guard let userName else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'user_name'") + } + return User(userName: userName) + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func aliasMultiple() { + assertMacroExpansion( + """ + @CommonDecodable + struct User { + @DecodableAlias("a", "b", "c") let name: String + } + """, + expandedSource: """ + struct User { + let name: String + } + + extension User { + enum CodingFields: StaticStringDecodingField { + 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 + case "a": + .name + case "b": + .name + case "c": + .name + default: + .unknown + } + } + } + } + + extension User: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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 + } + } + guard let name else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'name'") + } + return User(name: name) + } + } + } + """, + macros: decodableTestMacros + ) + } +} + +private let decodableTestMacros: [String: Macro.Type] = [ + "CommonDecodable": CommonDecodableMacro.self, + "CodingKey": CodingKeyMacro.self, + "CodableDefault": CodableDefaultMacro.self, + "DecodableAlias": DecodableAliasMacro.self, +] diff --git a/Tests/NewCodableMacrosTests/CommonEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/CommonEncodableMacroTests.swift new file mode 100644 index 000000000..72a370516 --- /dev/null +++ b/Tests/NewCodableMacrosTests/CommonEncodableMacroTests.swift @@ -0,0 +1,470 @@ +//===----------------------------------------------------------------------===// +// +// 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 commonTestMacros: [String: Macro.Type] = [ + "CommonEncodable": CommonEncodableMacro.self, + "CodingKey": CodingKeyMacro.self, + "DecodableAlias": DecodableAliasMacro.self, +] + +@Suite("@CommonEncodable Macro") +struct CommonEncodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @CommonEncodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + } + + extension Person { + enum CodingFields: StaticStringEncodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .age: + "age" + } + } + } + } + + extension Person: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: commonTestMacros + ) + } + + @Test func customCodingKey() { + assertMacroExpansion( + """ + @CommonEncodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + } + + extension Post { + enum CodingFields: StaticStringEncodingField { + case publishDate + case title + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: + "date_published" + case .title: + "title" + } + } + } + } + + extension Post: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: commonTestMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @CommonEncodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + } + + extension Item { + enum CodingFields: StaticStringEncodingField { + case name + case rating + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .rating: + "rating" + } + } + } + } + + extension Item: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: commonTestMacros + ) + } + + @Test func computedPropertySkipped() { + assertMacroExpansion( + """ + @CommonEncodable + 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 { + enum CodingFields: StaticStringEncodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + } + } + } + } + + extension Thing: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: commonTestMacros + ) + } + + @Test func staticPropertySkipped() { + assertMacroExpansion( + """ + @CommonEncodable + struct Config { + static let defaultName = "test" + let name: String + } + """, + expandedSource: """ + struct Config { + static let defaultName = "test" + let name: String + } + + extension Config { + enum CodingFields: StaticStringEncodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + } + } + } + } + + extension Config: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: commonTestMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @CommonEncodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CommonEncodable can only be applied to structs", line: 1, column: 1) + ], + macros: commonTestMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @CommonEncodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in + } + } + } + """, + macros: commonTestMacros + ) + } + + @Test func lazyVarSkipped() { + assertMacroExpansion( + """ + @CommonEncodable + 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 { + enum CodingFields: StaticStringEncodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + } + } + } + } + + extension Cached: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: commonTestMacros + ) + } + + @Test func propertyWithDefaultValue() { + assertMacroExpansion( + """ + @CommonEncodable + struct WithDefault { + let name: String = "default" + let age: Int + } + """, + expandedSource: """ + struct WithDefault { + let name: String = "default" + let age: Int + } + + extension WithDefault { + enum CodingFields: StaticStringEncodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: + "name" + case .age: + "age" + } + } + } + } + + extension WithDefault: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: commonTestMacros + ) + } + + @Test func decodableAliasIgnoredForEncodingOnly() { + assertMacroExpansion( + """ + @CommonEncodable + struct User { + @DecodableAlias("user_name", "username") let userName: String + let age: Int + } + """, + expandedSource: """ + struct User { + let userName: String + let age: Int + } + + extension User { + enum CodingFields: StaticStringEncodingField { + case userName + case age + + @_transparent + var staticString: StaticString { + switch self { + case .userName: + "userName" + case .age: + "age" + } + } + } + } + + extension User: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: commonTestMacros + ) + } + + @Test func propertyWithObservers() { + assertMacroExpansion( + """ + @CommonEncodable + struct Observed { + var count: Int { + didSet { print(count) } + } + let name: String + } + """, + expandedSource: """ + struct Observed { + var count: Int { + didSet { print(count) } + } + let name: String + } + + extension Observed { + enum CodingFields: StaticStringEncodingField { + case count + case name + + @_transparent + var staticString: StaticString { + switch self { + case .count: + "count" + case .name: + "name" + } + } + } + } + + extension Observed: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) 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: commonTestMacros + ) + } +} diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift index f7d533b1b..3789cfb25 100644 --- a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -32,7 +32,9 @@ struct JSONCodableMacroTests { struct Person { let name: String let age: Int - + } + + extension Person { enum CodingFields: JSONOptimizedCodingField { case name case age @@ -41,17 +43,23 @@ struct JSONCodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .age: "age" - case .unknown: fatalError() + case .name: + "name" + case .age: + "age" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - case "age": .age - default: .unknown + case "name": + .name + case "age": + .age + default: + .unknown } } } @@ -76,9 +84,12 @@ struct JSONCodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .age: + age = try valueDecoder.decode(Int.self) + case .unknown: + break } } guard let name else { @@ -109,7 +120,9 @@ struct JSONCodableMacroTests { struct Item { let name: String let rating: Double? - + } + + extension Item { enum CodingFields: JSONOptimizedCodingField { case name case rating @@ -118,17 +131,23 @@ struct JSONCodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .rating: "rating" - case .unknown: fatalError() + case .name: + "name" + case .rating: + "rating" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - case "rating": .rating - default: .unknown + case "name": + .name + case "rating": + .rating + default: + .unknown } } } @@ -153,9 +172,12 @@ struct JSONCodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .rating: + rating = try valueDecoder.decode(Double?.self) + case .unknown: + break } } guard let name else { @@ -183,7 +205,9 @@ struct JSONCodableMacroTests { struct Post { let publishDate: String let title: String - + } + + extension Post { enum CodingFields: JSONOptimizedCodingField { case publishDate case title @@ -192,17 +216,23 @@ struct JSONCodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .publishDate: "date_published" - case .title: "title" - case .unknown: fatalError() + case .publishDate: + "date_published" + case .title: + "title" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "date_published": .publishDate - case "title": .title - default: .unknown + case "date_published": + .publishDate + case "title": + .title + default: + .unknown } } } @@ -227,9 +257,12 @@ struct JSONCodableMacroTests { _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 + case .publishDate: + publishDate = try valueDecoder.decode(String.self) + case .title: + title = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let publishDate else { @@ -260,7 +293,8 @@ struct JSONCodableMacroTests { extension Empty: JSONEncodable { func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { - try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in + } } } @@ -289,7 +323,9 @@ struct JSONCodableMacroTests { struct Config { let name: String let locale: String - + } + + extension Config { enum CodingFields: JSONOptimizedCodingField { case name case locale @@ -298,17 +334,23 @@ struct JSONCodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .locale: "locale" - case .unknown: fatalError() + case .name: + "name" + case .locale: + "locale" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - case "locale": .locale - default: .unknown + case "name": + .name + case "locale": + .locale + default: + .unknown } } } @@ -333,9 +375,12 @@ struct JSONCodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .locale: + locale = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let name else { @@ -361,7 +406,9 @@ struct JSONCodableMacroTests { expandedSource: """ struct User { let userName: String - + } + + extension User { enum CodingFields: JSONOptimizedCodingField { case userName case unknown @@ -369,16 +416,21 @@ struct JSONCodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .userName: "userName" - case .unknown: fatalError() + case .userName: + "userName" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "userName": .userName - case "user_name": .userName - default: .unknown + case "userName": + .userName + case "user_name": + .userName + default: + .unknown } } } @@ -401,8 +453,10 @@ struct JSONCodableMacroTests { _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 + case .userName: + userName = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let userName else { @@ -428,7 +482,9 @@ struct JSONCodableMacroTests { expandedSource: """ struct User { let userName: String - + } + + extension User { enum CodingFields: JSONOptimizedCodingField { case userName case unknown @@ -436,16 +492,21 @@ struct JSONCodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .userName: "user_name" - case .unknown: fatalError() + 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 + case "user_name": + .userName + case "username": + .userName + default: + .unknown } } } @@ -468,8 +529,10 @@ struct JSONCodableMacroTests { _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 + case .userName: + userName = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let userName else { @@ -498,8 +561,7 @@ struct JSONCodableMacroTests { } """, 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), + DiagnosticSpec(message: "@JSONCodable can only be applied to structs", line: 1, column: 1), ], macros: codableTestMacros ) diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift index 7459a040f..1aa57494b 100644 --- a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -32,7 +32,9 @@ struct JSONDecodableMacroTests { struct Person { let name: String let age: Int + } + extension Person { enum CodingFields: JSONOptimizedDecodingField { case name case age @@ -41,17 +43,23 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .age: "age" - case .unknown: fatalError() + case .name: + "name" + case .age: + "age" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - case "age": .age - default: .unknown + case "name": + .name + case "age": + .age + default: + .unknown } } } @@ -67,9 +75,12 @@ struct JSONDecodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .age: + age = try valueDecoder.decode(Int.self) + case .unknown: + break } } guard let name else { @@ -100,7 +111,9 @@ struct JSONDecodableMacroTests { struct Item { let name: String let rating: Double? + } + extension Item { enum CodingFields: JSONOptimizedDecodingField { case name case rating @@ -109,17 +122,23 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .rating: "rating" - case .unknown: fatalError() + case .name: + "name" + case .rating: + "rating" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - case "rating": .rating - default: .unknown + case "name": + .name + case "rating": + .rating + default: + .unknown } } } @@ -135,9 +154,12 @@ struct JSONDecodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .rating: + rating = try valueDecoder.decode(Double?.self) + case .unknown: + break } } guard let name else { @@ -165,7 +187,9 @@ struct JSONDecodableMacroTests { struct Preferences { let theme: String? let fontSize: Int? + } + extension Preferences { enum CodingFields: JSONOptimizedDecodingField { case theme case fontSize @@ -174,17 +198,23 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .theme: "theme" - case .fontSize: "fontSize" - case .unknown: fatalError() + case .theme: + "theme" + case .fontSize: + "fontSize" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "theme": .theme - case "fontSize": .fontSize - default: .unknown + case "theme": + .theme + case "fontSize": + .fontSize + default: + .unknown } } } @@ -200,9 +230,12 @@ struct JSONDecodableMacroTests { _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 + case .theme: + theme = try valueDecoder.decode(String?.self) + case .fontSize: + fontSize = try valueDecoder.decode(Int?.self) + case .unknown: + break } } return Preferences(theme: theme, fontSize: fontSize) @@ -227,7 +260,9 @@ struct JSONDecodableMacroTests { struct Post { let publishDate: String let title: String + } + extension Post { enum CodingFields: JSONOptimizedDecodingField { case publishDate case title @@ -236,17 +271,23 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .publishDate: "date_published" - case .title: "title" - case .unknown: fatalError() + case .publishDate: + "date_published" + case .title: + "title" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "date_published": .publishDate - case "title": .title - default: .unknown + case "date_published": + .publishDate + case "title": + .title + default: + .unknown } } } @@ -262,9 +303,12 @@ struct JSONDecodableMacroTests { _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 + case .publishDate: + publishDate = try valueDecoder.decode(String.self) + case .title: + title = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let publishDate else { @@ -299,7 +343,9 @@ struct JSONDecodableMacroTests { var displayName: String { get { name.uppercased() } } + } + extension Thing { enum CodingFields: JSONOptimizedDecodingField { case name case unknown @@ -307,15 +353,19 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .unknown: fatalError() + case .name: + "name" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - default: .unknown + case "name": + .name + default: + .unknown } } } @@ -330,8 +380,10 @@ struct JSONDecodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let name else { @@ -359,7 +411,9 @@ struct JSONDecodableMacroTests { struct Config { static let defaultName = "test" let name: String + } + extension Config { enum CodingFields: JSONOptimizedDecodingField { case name case unknown @@ -367,15 +421,19 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .unknown: fatalError() + case .name: + "name" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - default: .unknown + case "name": + .name + default: + .unknown } } } @@ -390,8 +448,10 @@ struct JSONDecodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let name else { @@ -419,7 +479,9 @@ struct JSONDecodableMacroTests { struct Cached { let name: String lazy var uppercasedName: String = name.uppercased() - + } + + extension Cached { enum CodingFields: JSONOptimizedDecodingField { case name case unknown @@ -427,15 +489,19 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .unknown: fatalError() + case .name: + "name" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - default: .unknown + case "name": + .name + default: + .unknown } } } @@ -450,8 +516,10 @@ struct JSONDecodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let name else { @@ -523,7 +591,7 @@ struct JSONDecodableMacroTests { } """, diagnostics: [ - DiagnosticSpec(message: "@JSONDecodable requires all stored properties to have explicit type annotations", line: 3, column: 5) + DiagnosticSpec(message: "@JSONDecodable requires all stored properties to have explicit type annotations", line: 3, column: 9) ], macros: decodableTestMacros ) @@ -544,7 +612,9 @@ struct JSONDecodableMacroTests { let name: String let locale: String let retryCount: Int + } + extension Config { enum CodingFields: JSONOptimizedDecodingField { case name case locale @@ -554,19 +624,27 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .locale: "locale" - case .retryCount: "retryCount" - case .unknown: fatalError() + case .name: + "name" + case .locale: + "locale" + case .retryCount: + "retryCount" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "name": .name - case "locale": .locale - case "retryCount": .retryCount - default: .unknown + case "name": + .name + case "locale": + .locale + case "retryCount": + .retryCount + default: + .unknown } } } @@ -583,10 +661,14 @@ struct JSONDecodableMacroTests { _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 + 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 } } guard let name else { @@ -614,7 +696,9 @@ struct JSONDecodableMacroTests { struct Defaults { let greeting: String let verbose: Bool + } + extension Defaults { enum CodingFields: JSONOptimizedDecodingField { case greeting case verbose @@ -623,17 +707,23 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .greeting: "greeting" - case .verbose: "verbose" - case .unknown: fatalError() + case .greeting: + "greeting" + case .verbose: + "verbose" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "greeting": .greeting - case "verbose": .verbose - default: .unknown + case "greeting": + .greeting + case "verbose": + .verbose + default: + .unknown } } } @@ -649,9 +739,12 @@ struct JSONDecodableMacroTests { _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 + case .greeting: + greeting = try valueDecoder.decode(String.self) + case .verbose: + verbose = try valueDecoder.decode(Bool.self) + case .unknown: + break } } return Defaults(greeting: greeting ?? "hello", verbose: verbose ?? false) @@ -674,7 +767,9 @@ struct JSONDecodableMacroTests { expandedSource: """ struct Setting { let maxRetries: Int + } + extension Setting { enum CodingFields: JSONOptimizedDecodingField { case maxRetries case unknown @@ -682,15 +777,19 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .maxRetries: "max_retries" - case .unknown: fatalError() + 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: .unknown + case "max_retries": + .maxRetries + default: + .unknown } } } @@ -705,8 +804,10 @@ struct JSONDecodableMacroTests { _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 + case .maxRetries: + maxRetries = try valueDecoder.decode(Int.self) + case .unknown: + break } } return Setting(maxRetries: maxRetries ?? 3) @@ -729,7 +830,9 @@ struct JSONDecodableMacroTests { expandedSource: """ struct Prefs { let locale: String? + } + extension Prefs { enum CodingFields: JSONOptimizedDecodingField { case locale case unknown @@ -737,15 +840,19 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .locale: "locale" - case .unknown: fatalError() + case .locale: + "locale" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "locale": .locale - default: .unknown + case "locale": + .locale + default: + .unknown } } } @@ -760,8 +867,10 @@ struct JSONDecodableMacroTests { _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 + case .locale: + locale = try valueDecoder.decode(String?.self) + case .unknown: + break } } return Prefs(locale: locale ?? "en") @@ -784,7 +893,9 @@ struct JSONDecodableMacroTests { expandedSource: """ struct WithExpr { let tags: [String] + } + extension WithExpr { enum CodingFields: JSONOptimizedDecodingField { case tags case unknown @@ -792,15 +903,19 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .tags: "tags" - case .unknown: fatalError() + case .tags: + "tags" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "tags": .tags - default: .unknown + case "tags": + .tags + default: + .unknown } } } @@ -815,8 +930,10 @@ struct JSONDecodableMacroTests { _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 + case .tags: + tags = try valueDecoder.decode([String].self) + case .unknown: + break } } return WithExpr(tags: tags ?? []) @@ -841,7 +958,9 @@ struct JSONDecodableMacroTests { struct User { let userName: String let age: Int + } + extension User { enum CodingFields: JSONOptimizedDecodingField { case userName case age @@ -850,18 +969,25 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .userName: "userName" - case .age: "age" - case .unknown: fatalError() + case .userName: + "userName" + case .age: + "age" + case .unknown: + fatalError() } } static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { switch UTF8SpanComparator(key) { - case "userName": .userName - case "user_name": .userName - case "age": .age - default: .unknown + case "userName": + .userName + case "user_name": + .userName + case "age": + .age + default: + .unknown } } } @@ -877,9 +1003,12 @@ struct JSONDecodableMacroTests { _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 + case .userName: + userName = try valueDecoder.decode(String.self) + case .age: + age = try valueDecoder.decode(Int.self) + case .unknown: + break } } guard let userName else { @@ -908,7 +1037,9 @@ struct JSONDecodableMacroTests { expandedSource: """ struct User { let userName: String + } + extension User { enum CodingFields: JSONOptimizedDecodingField { case userName case unknown @@ -916,16 +1047,21 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .userName: "user_name" - case .unknown: fatalError() + 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 + case "user_name": + .userName + case "username": + .userName + default: + .unknown } } } @@ -940,8 +1076,10 @@ struct JSONDecodableMacroTests { _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 + case .userName: + userName = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let userName else { @@ -967,7 +1105,9 @@ struct JSONDecodableMacroTests { expandedSource: """ struct User { let name: String + } + extension User { enum CodingFields: JSONOptimizedDecodingField { case name case unknown @@ -975,18 +1115,25 @@ struct JSONDecodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .unknown: fatalError() + case .name: + "name" + case .unknown: + fatalError() } } 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: .unknown + case "name": + .name + case "a": + .name + case "b": + .name + case "c": + .name + default: + .unknown } } } @@ -1001,8 +1148,10 @@ struct JSONDecodableMacroTests { _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 + case .name: + name = try valueDecoder.decode(String.self) + case .unknown: + break } } guard let name else { diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift index d14a30c60..549610236 100644 --- a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -38,7 +38,9 @@ struct JSONEncodableMacroTests { struct Person { let name: String let age: Int + } + extension Person { enum CodingFields: JSONOptimizedEncodingField { case name case age @@ -46,8 +48,10 @@ struct JSONEncodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .age: "age" + case .name: + "name" + case .age: + "age" } } } @@ -79,7 +83,9 @@ struct JSONEncodableMacroTests { struct Post { let publishDate: String let title: String + } + extension Post { enum CodingFields: JSONOptimizedEncodingField { case publishDate case title @@ -87,8 +93,10 @@ struct JSONEncodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .publishDate: "date_published" - case .title: "title" + case .publishDate: + "date_published" + case .title: + "title" } } } @@ -120,7 +128,9 @@ struct JSONEncodableMacroTests { struct Item { let name: String let rating: Double? + } + extension Item { enum CodingFields: JSONOptimizedEncodingField { case name case rating @@ -128,8 +138,10 @@ struct JSONEncodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .rating: "rating" + case .name: + "name" + case .rating: + "rating" } } } @@ -165,14 +177,17 @@ struct JSONEncodableMacroTests { var displayName: String { get { name.uppercased() } } + } + extension Thing { enum CodingFields: JSONOptimizedEncodingField { case name @_transparent var staticString: StaticString { switch self { - case .name: "name" + case .name: + "name" } } } @@ -203,14 +218,17 @@ struct JSONEncodableMacroTests { struct Config { static let defaultName = "test" let name: String + } + extension Config { enum CodingFields: JSONOptimizedEncodingField { case name @_transparent var staticString: StaticString { switch self { - case .name: "name" + case .name: + "name" } } } @@ -261,7 +279,8 @@ struct JSONEncodableMacroTests { extension Empty: JSONEncodable { func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { - try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in + } } } """, @@ -282,14 +301,17 @@ struct JSONEncodableMacroTests { struct Cached { let name: String lazy var uppercasedName: String = name.uppercased() + } + extension Cached { enum CodingFields: JSONOptimizedEncodingField { case name @_transparent var staticString: StaticString { switch self { - case .name: "name" + case .name: + "name" } } } @@ -307,32 +329,6 @@ 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( """ @@ -346,7 +342,9 @@ struct JSONEncodableMacroTests { struct WithDefault { let name: String = "default" let age: Int - + } + + extension WithDefault { enum CodingFields: JSONOptimizedEncodingField { case name case age @@ -354,8 +352,10 @@ struct JSONEncodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .name: "name" - case .age: "age" + case .name: + "name" + case .age: + "age" } } } @@ -387,7 +387,9 @@ struct JSONEncodableMacroTests { struct User { let userName: String let age: Int + } + extension User { enum CodingFields: JSONOptimizedEncodingField { case userName case age @@ -395,8 +397,10 @@ struct JSONEncodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .userName: "userName" - case .age: "age" + case .userName: + "userName" + case .age: + "age" } } } @@ -432,7 +436,9 @@ struct JSONEncodableMacroTests { didSet { print(count) } } let name: String + } + extension Observed { enum CodingFields: JSONOptimizedEncodingField { case count case name @@ -440,8 +446,10 @@ struct JSONEncodableMacroTests { @_transparent var staticString: StaticString { switch self { - case .count: "count" - case .name: "name" + case .count: + "count" + case .name: + "name" } } } diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index 6c7a82b1a..21d541a17 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -2243,196 +2243,3 @@ extension Array where Element: BinaryFloatingPoint { return true } } - -@JSONEncodable -struct SimplePost { - let title: String - let body: String -} - -@JSONEncodable -struct BlogPost { - let title: String - @CodingKey("date_published") let publishDate: String - let tags: [String] - let rating: Double? -} - -@JSONEncodable -struct EmptyEncodable {} - -@JSONCodable -struct RoundTripPerson { - let name: String - let age: Int -} - -@JSONCodable -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 DecodableOnlyWithRequiredCustomKey { - @CodingKey("date_published") let publishDate: String -} - -@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? -} - -@JSONCodable -struct CodableStructWithDefaultedProperty { - @CodableDefault("hello") - let bar: String -} - -@JSONCodable -struct CodableStructWithAliasedProperty { - @DecodableAlias("baz", "qux") - let bar: String -} - -@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 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) - let json = String(data: data, encoding: .utf8)! - #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 missingRequiredFieldErrorIncludesCustomKeyName() { - let json = Data("{}".utf8) - - 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 { - let json = Data("{}".utf8) - let decoded = try NewJSONDecoder().decode(EmptyDecodable.self, from: json) - _ = 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) - } -} diff --git a/Tests/NewCodableTests/CommonMacroIntegrationTests.swift b/Tests/NewCodableTests/CommonMacroIntegrationTests.swift new file mode 100644 index 000000000..4c7360fcc --- /dev/null +++ b/Tests/NewCodableTests/CommonMacroIntegrationTests.swift @@ -0,0 +1,274 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif FOUNDATION_FRAMEWORK +import Foundation +#endif +import NewCodable + +@CommonEncodable +struct CommonSimplePost { + let title: String + let body: String +} + +@CommonEncodable +struct CommonBlogPost { + let title: String + @CodingKey("date_published") let publishDate: String + let tags: [String] + let rating: Double? +} + +@CommonEncodable +struct CommonEmptyEncodable {} + +@CommonCodable +struct CommonRoundTripPerson { + let name: String + let age: Int +} + +@CommonCodable +struct CommonRoundTripPost { + let title: String + @CodingKey("date_published") let publishDate: String + let rating: Double? +} + +@CommonDecodable +struct CommonDecodableOnly { + let name: String + let value: Int +} + +@CommonDecodable +struct CommonDecodableOnlyWithRequiredCustomKey { + @CodingKey("date_published") let publishDate: String +} + +@CommonDecodable +struct CommonEmptyDecodable {} + +@CommonCodable +struct CommonCodablePerson { + let name: String + let age: Int +} + +@CommonCodable +struct CommonCodablePost { + let title: String + @CodingKey("date_published") let publishDate: String + let rating: Double? +} + +@CommonCodable +struct CommonCodableStructWithDefaultedProperty { + @CodableDefault("hello") + let bar: String +} + + +struct CommonCodableStructWithAliasedProperty { + @DecodableAlias("baz", "qux") + let bar: String +} + +extension CommonCodableStructWithAliasedProperty { + enum CodingFields: StaticStringCodingField { + case bar + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .bar: + "bar" + case .unknown: + fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "bar": + .bar + case "baz": + .bar + case "qux": + .bar + default: + .unknown + } + } + } +} + +extension CommonCodableStructWithAliasedProperty: CommonEncodable { + func encode(to encoder: inout some CommonEncoder & ~Copyable & ~Escapable) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.bar, value: self.bar) + } + } +} + +extension CommonCodableStructWithAliasedProperty: CommonDecodable { + static func decode(from decoder: inout some CommonDecoder & ~Escapable) throws(CodingError.Decoding) -> CommonCodableStructWithAliasedProperty { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var bar: String? + 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 .bar: + bar = try valueDecoder.decode(String.self) + case .unknown: + break + } + } + guard let bar else { + throw CodingError.dataCorrupted(debugDescription: "Missing required field 'bar'") + } + return CommonCodableStructWithAliasedProperty(bar: bar) + } + } +} + + +@Suite("@CommonEncodable Macro Integration") +struct CommonEncodableMacroIntegrationTests { + + @Test func simpleStruct() throws { + let post = CommonSimplePost(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 customCodingKey() throws { + let post = CommonBlogPost(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 = CommonBlogPost(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 = CommonEmptyEncodable() + let data = try NewJSONEncoder().encode(empty) + let json = String(data: data, encoding: .utf8)! + #expect(json == "{}") + } +} + +@Suite("@CommonDecodable Macro Integration") +struct CommonDecodableMacroIntegrationTests { + + @Test func roundTripBasic() throws { + let original = CommonRoundTripPerson(name: "Alice", age: 30) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(CommonRoundTripPerson.self, from: data) + #expect(decoded.name == "Alice") + #expect(decoded.age == 30) + } + + @Test func roundTripCustomCodingKey() throws { + let original = CommonRoundTripPost(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(CommonRoundTripPost.self, from: data) + #expect(decoded.title == "Hello") + #expect(decoded.publishDate == "2026-01-01") + #expect(decoded.rating == 4.5) + } + + @Test func roundTripOptionalNil() throws { + let original = CommonRoundTripPost(title: "Test", publishDate: "2026-03-05", rating: nil) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(CommonRoundTripPost.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(CommonDecodableOnly.self, from: json) + #expect(decoded.name == "Bob") + #expect(decoded.value == 42) + } + + @Test func missingRequiredFieldErrorIncludesCustomKeyName() { + let json = Data("{}".utf8) + + let error = #expect(throws: CodingError.Decoding.self) { + try NewJSONDecoder().decode(CommonDecodableOnlyWithRequiredCustomKey.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 { + let json = Data("{}".utf8) + let decoded = try NewJSONDecoder().decode(CommonEmptyDecodable.self, from: json) + _ = decoded + } +} + +@Suite("@CommonCodable Macro Integration") +struct CommonCodableMacroIntegrationTests { + + @Test func roundTrip() throws { + let original = CommonCodablePerson(name: "Alice", age: 30) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(CommonCodablePerson.self, from: data) + #expect(decoded.name == "Alice") + #expect(decoded.age == 30) + } + + @Test func roundTripWithCustomKey() throws { + let original = CommonCodablePost(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(CommonCodablePost.self, from: data) + #expect(decoded.title == "Hello") + #expect(decoded.publishDate == "2026-01-01") + #expect(decoded.rating == 4.5) + } + + @Test func roundTripOptionalNil() throws { + let original = CommonCodablePost(title: "Test", publishDate: "2026-03-10", rating: nil) + let data = try NewJSONEncoder().encode(original) + let decoded = try NewJSONDecoder().decode(CommonCodablePost.self, from: data) + #expect(decoded.title == "Test") + #expect(decoded.rating == nil) + } +} diff --git a/Tests/NewCodableTests/JSONMacroIntegrationTests.swift b/Tests/NewCodableTests/JSONMacroIntegrationTests.swift new file mode 100644 index 000000000..a94e7ce8a --- /dev/null +++ b/Tests/NewCodableTests/JSONMacroIntegrationTests.swift @@ -0,0 +1,212 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif FOUNDATION_FRAMEWORK +import Foundation +#endif +import NewCodable + +@JSONEncodable +struct SimplePost { + let title: String + let body: String +} + +@JSONEncodable +struct BlogPost { + let title: String + @CodingKey("date_published") let publishDate: String + let tags: [String] + let rating: Double? +} + +@JSONEncodable +struct EmptyEncodable {} + +@JSONCodable +struct RoundTripPerson { + let name: String + let age: Int +} + +@JSONCodable +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 DecodableOnlyWithRequiredCustomKey { + @CodingKey("date_published") let publishDate: String +} + +@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? +} + +@JSONCodable +struct CodableStructWithDefaultedProperty { + @CodableDefault("hello") + let bar: String +} + +@JSONCodable +struct CodableStructWithAliasedProperty { + @DecodableAlias("baz", "qux") + let bar: String +} + +@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 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) + let json = String(data: data, encoding: .utf8)! + #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 missingRequiredFieldErrorIncludesCustomKeyName() { + let json = Data("{}".utf8) + + 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 { + let json = Data("{}".utf8) + let decoded = try NewJSONDecoder().decode(EmptyDecodable.self, from: json) + _ = 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) + } +}