diff --git a/Package.swift b/Package.swift index c1dd72d04..84c644a71 100644 --- a/Package.swift +++ b/Package.swift @@ -228,11 +228,25 @@ let package = Package( swiftSettings: availabilityMacros + featureSettings + testOnlySwiftSettings ), + // NewCodableMacros + .macro( + name: "NewCodableMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + swiftSettings: featureSettings + ), + // NewCodable .target( name: "NewCodable", dependencies: [ .target(name: "FoundationEssentials"), + "NewCodableMacros", .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "BasicContainers", package: "swift-collections"), ], @@ -277,6 +291,14 @@ let package = Package( .enableExperimentalFeature("Lifetimes"), .enableUpcomingFeature("MemberImportVisibility"), ] + ), + .testTarget( + name: "NewCodableMacrosTests", + dependencies: [ + "NewCodableMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ], + swiftSettings: featureSettings ) ] ) diff --git a/Sources/NewCodable/JSON/JSONParserDecoder.swift b/Sources/NewCodable/JSON/JSONParserDecoder.swift index 451284833..c21cc6704 100644 --- a/Sources/NewCodable/JSON/JSONParserDecoder.swift +++ b/Sources/NewCodable/JSON/JSONParserDecoder.swift @@ -1749,6 +1749,7 @@ extension JSONParserDecoder.ArrayDecoder: CommonArrayDecoder { } extension JSONParserDecoder.StructDecoder: CommonStructDecoder { + @_lifetime(self: copy self) public mutating func decodeExpectedOrderField(required: Bool, matchingClosure: (UTF8Span) -> Bool, andValue valueDecoderClosure: (inout JSONParserDecoder) throws(CodingError.Decoding) -> Void) throws(CodingError.Decoding) -> Bool { try self.decodeExpectedOrderField(required: required, matchingClosure: matchingClosure, optimizedSafeStringKey: nil, andValue: valueDecoderClosure) } diff --git a/Sources/NewCodable/JSON/NewJSONEncoder.swift b/Sources/NewCodable/JSON/NewJSONEncoder.swift index 41425a41f..650b14617 100644 --- a/Sources/NewCodable/JSON/NewJSONEncoder.swift +++ b/Sources/NewCodable/JSON/NewJSONEncoder.swift @@ -623,6 +623,7 @@ extension JSONDirectEncoder { try self.encode(arbitraryPrecisionNumber: decimal.description.utf8Span) } + @_lifetime(self: copy self) internal mutating func encodeGenericNonCopyable(_ value: borrowing T) throws(CodingError.Encoding) { try value.encode(to: &self) } diff --git a/Sources/NewCodable/Macros.swift b/Sources/NewCodable/Macros.swift new file mode 100644 index 000000000..cf4043067 --- /dev/null +++ b/Sources/NewCodable/Macros.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Experimental NewCodable macro API. +/// +/// The macro spellings in this file are provisional and may evolve with the +/// macro-based Codable design. The per-property marker macros below are +/// especially likely to change as the feature set is refined. +@attached(member, names: named(CodingFields)) +@attached(extension, conformances: JSONEncodable, names: named(encode)) +public macro JSONEncodable() = #externalMacro(module: "NewCodableMacros", type: "JSONEncodableMacro") + +/// Experimental macro that synthesizes `JSONDecodable` conformance. +@attached(member, names: named(CodingFields)) +@attached(extension, conformances: JSONDecodable, names: named(decode)) +public macro JSONDecodable() = #externalMacro(module: "NewCodableMacros", type: "JSONDecodableMacro") + +/// Experimental macro that synthesizes both `JSONEncodable` and `JSONDecodable`. +@attached(member, names: named(CodingFields)) +@attached(extension, conformances: JSONEncodable, JSONDecodable, names: named(encode), named(decode)) +public macro JSONCodable() = #externalMacro(module: "NewCodableMacros", type: "JSONCodableMacro") + +/// Experimental per-property marker macro for overriding the serialized key. +@attached(peer) +public macro CodingKey(_ name: String) = #externalMacro(module: "NewCodableMacros", type: "CodingKeyMacro") + +/// Experimental per-property marker macro for supplying a default decoding value. +@attached(peer) +public macro CodableDefault(_ value: T) = #externalMacro(module: "NewCodableMacros", type: "CodableDefaultMacro") + +/// Experimental per-property marker macro for accepting alternate decoding keys. +@attached(peer) +public macro DecodableAlias(_ names: String...) = #externalMacro(module: "NewCodableMacros", type: "DecodableAliasMacro") diff --git a/Sources/NewCodableMacros/CodableDefaultMacro.swift b/Sources/NewCodableMacros/CodableDefaultMacro.swift new file mode 100644 index 000000000..3c29ecb9a --- /dev/null +++ b/Sources/NewCodableMacros/CodableDefaultMacro.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct CodableDefaultMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/NewCodableMacros/CodingKeyMacro.swift b/Sources/NewCodableMacros/CodingKeyMacro.swift new file mode 100644 index 000000000..fe51d3f56 --- /dev/null +++ b/Sources/NewCodableMacros/CodingKeyMacro.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct CodingKeyMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/NewCodableMacros/DecodableAliasMacro.swift b/Sources/NewCodableMacros/DecodableAliasMacro.swift new file mode 100644 index 000000000..f670d63de --- /dev/null +++ b/Sources/NewCodableMacros/DecodableAliasMacro.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct DecodableAliasMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/NewCodableMacros/JSONCodableMacro.swift b/Sources/NewCodableMacros/JSONCodableMacro.swift new file mode 100644 index 000000000..c0c2f57f9 --- /dev/null +++ b/Sources/NewCodableMacros/JSONCodableMacro.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// 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 JSONCodableMacro { } + +extension JSONCodableMacro: 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) + guard !properties.isEmpty else { + return [] + } + + return [makeCodingFieldsDecl(from: properties, kind: .coding)] + } +} + +extension JSONCodableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + let encodableExtensions = try JSONEncodableMacro.expansion( + of: node, + attachedTo: declaration, + providingExtensionsOf: type, + conformingTo: protocols, + in: context + ) + let decodableExtensions = try JSONDecodableMacro.expansion( + of: node, + attachedTo: declaration, + providingExtensionsOf: type, + conformingTo: protocols, + in: context + ) + return encodableExtensions + decodableExtensions + } +} diff --git a/Sources/NewCodableMacros/JSONDecodableMacro.swift b/Sources/NewCodableMacros/JSONDecodableMacro.swift new file mode 100644 index 000000000..5e0e39f10 --- /dev/null +++ b/Sources/NewCodableMacros/JSONDecodableMacro.swift @@ -0,0 +1,323 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxBuilder +import SwiftDiagnostics + +public struct JSONDecodableMacro { } + +private struct DecodableStoredProperty { + let name: String + let jsonKey: String + let 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, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + context.diagnose(.init( + node: node, + message: JSONDecodableDiagnostic.notAStruct + )) + return [] + } + + guard let properties = extractDecodableStoredProperties(from: declaration.memberBlock, in: context) else { + return [] + } + + let typeName = type.trimmed + + let extensionDecl: DeclSyntax + if properties.isEmpty { + extensionDecl = """ + extension \(typeName): JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> \(typeName) { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + \(typeName)() + } + } + } + """ + } else { + let varDeclarations = properties.map { + "var \($0.name): \($0.typeName)?" + }.joined(separator: "\n ") + + let switchCases = properties.map { prop in + "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] + } +} + +enum JSONDecodableDiagnostic: String, DiagnosticMessage { + case notAStruct + case codingKeyOnMultipleBindings + case missingTypeAnnotation + + var message: String { + switch self { + case .notAStruct: + return "@JSONDecodable can only be applied to structs" + case .codingKeyOnMultipleBindings: + return "@CodingKey cannot be applied to a declaration with multiple bindings" + case .missingTypeAnnotation: + return "@JSONDecodable requires all stored properties to have explicit type annotations" + } + } + + var diagnosticID: MessageID { + MessageID(domain: "NewCodableMacros", id: rawValue) + } + + var severity: DiagnosticSeverity { .error } +} diff --git a/Sources/NewCodableMacros/JSONEncodableMacro.swift b/Sources/NewCodableMacros/JSONEncodableMacro.swift new file mode 100644 index 000000000..18c9f8d41 --- /dev/null +++ b/Sources/NewCodableMacros/JSONEncodableMacro.swift @@ -0,0 +1,303 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxBuilder +import SwiftDiagnostics + +public struct JSONEncodableMacro { } + +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, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(StructDeclSyntax.self) else { + return [] + } + + let properties = extractStoredProperties(from: declaration.memberBlock, in: context) + + let extensionDecl: DeclSyntax + if properties.isEmpty { + extensionDecl = """ + extension \(type.trimmed): JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + """ + } else { + let encodeStatements = properties.map { + "try structEncoder.encode(field: CodingFields.\($0.name), value: self.\($0.name))" + }.joined(separator: "\n ") + + let fieldCount = properties.count + + extensionDecl = """ + extension \(type.trimmed): JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: \(raw: fieldCount)) { structEncoder throws(CodingError.Encoding) in + \(raw: encodeStatements) + } + } + } + """ + } + + guard let ext = extensionDecl.as(ExtensionDeclSyntax.self) else { + return [] + } + + return [ext] + } +} + +enum JSONEncodableDiagnostic: String, DiagnosticMessage { + case notAStruct + 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 } +} diff --git a/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift new file mode 100644 index 000000000..acb207698 --- /dev/null +++ b/Sources/NewCodableMacros/NewCodableMacrosPlugin.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftCompilerPlugin + +@main +struct NewCodableMacrosPlugin: CompilerPlugin { + var providingMacros: [Macro.Type] = [ + JSONEncodableMacro.self, + JSONDecodableMacro.self, + JSONCodableMacro.self, + CodingKeyMacro.self, + CodableDefaultMacro.self, + DecodableAliasMacro.self, + ] +} diff --git a/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift new file mode 100644 index 000000000..005aa3b29 --- /dev/null +++ b/Tests/NewCodableMacrosTests/JSONCodableMacroTests.swift @@ -0,0 +1,514 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport +import Testing +import NewCodableMacros + +@Suite("@JSONCodable Macro") +struct JSONCodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @JSONCodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + enum CodingFields: JSONOptimizedCodingField { + 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: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + + extension Person: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Person { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var age: Int? + 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( + """ + @JSONCodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + + enum CodingFields: JSONOptimizedCodingField { + 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: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.rating, value: self.rating) + } + } + } + + extension Item: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Item { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var rating: Double? + 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( + """ + @JSONCodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + + enum CodingFields: JSONOptimizedCodingField { + case publishDate + case title + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: "date_published" + case .title: "title" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": .publishDate + case "title": .title + default: .unknown + } + } + } + } + + extension Post: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.publishDate, value: self.publishDate) + try structEncoder.encode(field: CodingFields.title, value: self.title) + } + } + } + + extension Post: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Post { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var publishDate: String? + var title: String? + 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( + """ + @JSONCodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + + extension Empty: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Empty { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + Empty() + } + } + } + """, + macros: codableTestMacros + ) + } + + @Test func defaultValue() { + assertMacroExpansion( + """ + @JSONCodable + struct Config { + let name: String + @CodableDefault("en") let locale: String + } + """, + expandedSource: """ + struct Config { + let name: String + let locale: String + + enum CodingFields: JSONOptimizedCodingField { + 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: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.locale, value: self.locale) + } + } + } + + extension Config: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var locale: String? + 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( + """ + @JSONCodable + struct User { + @DecodableAlias("user_name") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + + enum CodingFields: JSONOptimizedCodingField { + 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: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + } + } + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + 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( + """ + @JSONCodable + struct User { + @CodingKey("user_name") @DecodableAlias("username") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + + enum CodingFields: JSONOptimizedCodingField { + case userName + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "user_name" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "user_name": .userName + case "username": .userName + default: .unknown + } + } + } + } + + extension User: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + } + } + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + 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( + """ + @JSONCodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONEncodable can only be applied to structs", line: 1, column: 1), + DiagnosticSpec(message: "@JSONDecodable can only be applied to structs", line: 1, column: 1), + ], + macros: codableTestMacros + ) + } +} + +private let codableTestMacros: [String: Macro.Type] = [ + "JSONCodable": JSONCodableMacro.self, + "CodingKey": CodingKeyMacro.self, + "CodableDefault": CodableDefaultMacro.self, + "DecodableAlias": DecodableAliasMacro.self, +] diff --git a/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift new file mode 100644 index 000000000..b14afb524 --- /dev/null +++ b/Tests/NewCodableMacrosTests/JSONDecodableMacroTests.swift @@ -0,0 +1,1026 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport +import Testing +import NewCodableMacros + +@Suite("@JSONDecodable Macro") +struct JSONDecodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @JSONDecodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Person { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var age: Int? + 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( + """ + @JSONDecodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Item { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var rating: Double? + 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( + """ + @JSONDecodable + struct Preferences { + let theme: String? + let fontSize: Int? + } + """, + expandedSource: """ + struct Preferences { + let theme: String? + let fontSize: Int? + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Preferences { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var theme: String? + var fontSize: Int? + 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( + """ + @JSONDecodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + + enum CodingFields: JSONOptimizedDecodingField { + case publishDate + case title + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: "date_published" + case .title: "title" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "date_published": .publishDate + case "title": .title + default: .unknown + } + } + } + } + + extension Post: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Post { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var publishDate: String? + var title: String? + 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( + """ + @JSONDecodable + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + } + """, + expandedSource: """ + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + + enum CodingFields: JSONOptimizedDecodingField { + case name + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: .unknown + } + } + } + } + + extension Thing: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Thing { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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( + """ + @JSONDecodable + struct Config { + static let defaultName = "test" + let name: String + } + """, + expandedSource: """ + struct Config { + static let defaultName = "test" + let name: String + + enum CodingFields: JSONOptimizedDecodingField { + case name + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: .unknown + } + } + } + } + + extension Config: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var _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( + """ + @JSONDecodable + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + } + """, + expandedSource: """ + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + + enum CodingFields: JSONOptimizedDecodingField { + case name + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + default: .unknown + } + } + } + } + + extension Cached: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Cached { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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( + """ + @JSONDecodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Empty { + try decoder.decodeStruct { _ throws(CodingError.Decoding) in + Empty() + } + } + } + """, + macros: decodableTestMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @JSONDecodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONDecodable can only be applied to structs", line: 1, column: 1) + ], + macros: decodableTestMacros + ) + } + + @Test func propertyWithoutTypeAnnotation() { + assertMacroExpansion( + """ + @JSONDecodable + struct Bad { + let name = "default" + } + """, + expandedSource: """ + struct Bad { + let name = "default" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONDecodable requires all stored properties to have explicit type annotations", line: 3, column: 5) + ], + macros: decodableTestMacros + ) + } + + @Test func defaultValue() { + assertMacroExpansion( + """ + @JSONDecodable + struct Config { + let name: String + @CodableDefault("en") let locale: String + @CodableDefault(0) let retryCount: Int + } + """, + expandedSource: """ + struct Config { + let name: String + let locale: String + let retryCount: Int + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Config { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + var locale: String? + var retryCount: Int? + 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( + """ + @JSONDecodable + struct Defaults { + @CodableDefault("hello") let greeting: String + @CodableDefault(false) let verbose: Bool + } + """, + expandedSource: """ + struct Defaults { + let greeting: String + let verbose: Bool + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Defaults { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var greeting: String? + var verbose: Bool? + 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( + """ + @JSONDecodable + struct Setting { + @CodingKey("max_retries") @CodableDefault(3) let maxRetries: Int + } + """, + expandedSource: """ + struct Setting { + let maxRetries: Int + + enum CodingFields: JSONOptimizedDecodingField { + case maxRetries + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .maxRetries: "max_retries" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "max_retries": .maxRetries + default: .unknown + } + } + } + } + + extension Setting: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Setting { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var maxRetries: Int? + 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( + """ + @JSONDecodable + struct Prefs { + @CodableDefault("en") let locale: String? + } + """, + expandedSource: """ + struct Prefs { + let locale: String? + + enum CodingFields: JSONOptimizedDecodingField { + case locale + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .locale: "locale" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "locale": .locale + default: .unknown + } + } + } + } + + extension Prefs: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> Prefs { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var locale: String? + 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( + """ + @JSONDecodable + struct WithExpr { + @CodableDefault([]) let tags: [String] + } + """, + expandedSource: """ + struct WithExpr { + let tags: [String] + + enum CodingFields: JSONOptimizedDecodingField { + case tags + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .tags: "tags" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "tags": .tags + default: .unknown + } + } + } + } + + extension WithExpr: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> WithExpr { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var tags: [String]? + 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( + """ + @JSONDecodable + struct User { + @DecodableAlias("user_name") let userName: String + let age: Int + } + """, + expandedSource: """ + struct User { + let userName: String + let age: Int + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + var age: Int? + 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( + """ + @JSONDecodable + struct User { + @CodingKey("user_name") @DecodableAlias("username") let userName: String + } + """, + expandedSource: """ + struct User { + let userName: String + + enum CodingFields: JSONOptimizedDecodingField { + 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: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var userName: String? + var _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( + """ + @JSONDecodable + struct User { + @DecodableAlias("a", "b", "c") let name: String + } + """, + expandedSource: """ + struct User { + let name: String + + enum CodingFields: JSONOptimizedDecodingField { + case name + case unknown + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .unknown: fatalError() + } + } + + static func field(for key: UTF8Span) throws(CodingError.Decoding) -> CodingFields { + switch UTF8SpanComparator(key) { + case "name": .name + case "a": .name + case "b": .name + case "c": .name + default: .unknown + } + } + } + } + + extension User: JSONDecodable { + static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(CodingError.Decoding) -> User { + try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in + var name: String? + 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] = [ + "JSONDecodable": JSONDecodableMacro.self, + "CodingKey": CodingKeyMacro.self, + "CodableDefault": CodableDefaultMacro.self, + "DecodableAlias": DecodableAliasMacro.self, +] diff --git a/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift new file mode 100644 index 000000000..d14a30c60 --- /dev/null +++ b/Tests/NewCodableMacrosTests/JSONEncodableMacroTests.swift @@ -0,0 +1,462 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport +import Testing +import NewCodableMacros + +let testMacros: [String: Macro.Type] = [ + "JSONEncodable": JSONEncodableMacro.self, + "CodingKey": CodingKeyMacro.self, + "DecodableAlias": DecodableAliasMacro.self, +] + +@Suite("@JSONEncodable Macro") +struct JSONEncodableMacroTests { + + @Test func basicStruct() { + assertMacroExpansion( + """ + @JSONEncodable + struct Person { + let name: String + let age: Int + } + """, + expandedSource: """ + struct Person { + let name: String + let age: Int + + enum CodingFields: JSONOptimizedEncodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .age: "age" + } + } + } + } + + extension Person: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + """, + macros: testMacros + ) + } + + @Test func customCodingKey() { + assertMacroExpansion( + """ + @JSONEncodable + struct Post { + @CodingKey("date_published") let publishDate: String + let title: String + } + """, + expandedSource: """ + struct Post { + let publishDate: String + let title: String + + enum CodingFields: JSONOptimizedEncodingField { + case publishDate + case title + + @_transparent + var staticString: StaticString { + switch self { + case .publishDate: "date_published" + case .title: "title" + } + } + } + } + + extension Post: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.publishDate, value: self.publishDate) + try structEncoder.encode(field: CodingFields.title, value: self.title) + } + } + } + """, + macros: testMacros + ) + } + + @Test func optionalProperty() { + assertMacroExpansion( + """ + @JSONEncodable + struct Item { + let name: String + let rating: Double? + } + """, + expandedSource: """ + struct Item { + let name: String + let rating: Double? + + enum CodingFields: JSONOptimizedEncodingField { + case name + case rating + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .rating: "rating" + } + } + } + } + + extension Item: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.rating, value: self.rating) + } + } + } + """, + macros: testMacros + ) + } + + @Test func computedPropertySkipped() { + assertMacroExpansion( + """ + @JSONEncodable + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + } + """, + expandedSource: """ + struct Thing { + let name: String + var displayName: String { + get { name.uppercased() } + } + + enum CodingFields: JSONOptimizedEncodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + } + } + + extension Thing: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } + + @Test func staticPropertySkipped() { + assertMacroExpansion( + """ + @JSONEncodable + struct Config { + static let defaultName = "test" + let name: String + } + """, + expandedSource: """ + struct Config { + static let defaultName = "test" + let name: String + + enum CodingFields: JSONOptimizedEncodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + } + } + + extension Config: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } + + @Test func errorOnNonStruct() { + assertMacroExpansion( + """ + @JSONEncodable + class NotAStruct { + let name: String = "" + } + """, + expandedSource: """ + class NotAStruct { + let name: String = "" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@JSONEncodable can only be applied to structs", line: 1, column: 1) + ], + macros: testMacros + ) + } + + @Test func emptyStruct() { + assertMacroExpansion( + """ + @JSONEncodable + struct Empty { + } + """, + expandedSource: """ + struct Empty { + } + + extension Empty: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 0) { _ throws(CodingError.Encoding) in } + } + } + """, + macros: testMacros + ) + } + + @Test func lazyVarSkipped() { + assertMacroExpansion( + """ + @JSONEncodable + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + } + """, + expandedSource: """ + struct Cached { + let name: String + lazy var uppercasedName: String = name.uppercased() + + enum CodingFields: JSONOptimizedEncodingField { + case name + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + } + } + } + } + + extension Cached: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 1) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } + + @Test func 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( + """ + @JSONEncodable + struct WithDefault { + let name: String = "default" + let age: Int + } + """, + expandedSource: """ + struct WithDefault { + let name: String = "default" + let age: Int + + enum CodingFields: JSONOptimizedEncodingField { + case name + case age + + @_transparent + var staticString: StaticString { + switch self { + case .name: "name" + case .age: "age" + } + } + } + } + + extension WithDefault: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.name, value: self.name) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + """, + macros: testMacros + ) + } + + @Test func decodableAliasIgnoredForEncodingOnly() { + assertMacroExpansion( + """ + @JSONEncodable + struct User { + @DecodableAlias("user_name", "username") let userName: String + let age: Int + } + """, + expandedSource: """ + struct User { + let userName: String + let age: Int + + enum CodingFields: JSONOptimizedEncodingField { + case userName + case age + + @_transparent + var staticString: StaticString { + switch self { + case .userName: "userName" + case .age: "age" + } + } + } + } + + extension User: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.userName, value: self.userName) + try structEncoder.encode(field: CodingFields.age, value: self.age) + } + } + } + """, + macros: testMacros + ) + } + + @Test func propertyWithObservers() { + assertMacroExpansion( + """ + @JSONEncodable + struct Observed { + var count: Int { + didSet { print(count) } + } + let name: String + } + """, + expandedSource: """ + struct Observed { + var count: Int { + didSet { print(count) } + } + let name: String + + enum CodingFields: JSONOptimizedEncodingField { + case count + case name + + @_transparent + var staticString: StaticString { + switch self { + case .count: "count" + case .name: "name" + } + } + } + } + + extension Observed: JSONEncodable { + func encode(to encoder: inout JSONDirectEncoder) throws(CodingError.Encoding) { + try encoder.encodeStructFields(count: 2) { structEncoder throws(CodingError.Encoding) in + try structEncoder.encode(field: CodingFields.count, value: self.count) + try structEncoder.encode(field: CodingFields.name, value: self.name) + } + } + } + """, + macros: testMacros + ) + } +} diff --git a/Tests/NewCodableTests/CodableRevolutionTests.swift b/Tests/NewCodableTests/CodableRevolutionTests.swift index 97dc85db0..7ea5ce258 100644 --- a/Tests/NewCodableTests/CodableRevolutionTests.swift +++ b/Tests/NewCodableTests/CodableRevolutionTests.swift @@ -591,6 +591,7 @@ struct NewCodableTests { guard let decodedCase else { throw CodingError.dataCorrupted(debugDescription: "Missing enum case") } + let unknownError = CodingError.unknownKey(decodedCase.utf8Span) // TODO: It'd be nice to figure out how to encapsulate this. if contents.isEmpty { return try structDecoder.withWrappingDecoder { wrappingDecoder throws(CodingError.Decoding) in @@ -600,7 +601,7 @@ struct NewCodableTests { case "bar": try decodeBar(from: &wrappingDecoder) default: - throw CodingError.unknownKey(decodedCase.utf8Span) + throw unknownError } } } else { @@ -611,7 +612,7 @@ struct NewCodableTests { case "bar": return try decodeBar(from: &valueDecoder) default: - throw CodingError.unknownKey(decodedCase.utf8Span) + throw unknownError } } } @@ -634,88 +635,17 @@ struct NewCodableTests { } @Test func testDefaultValue() throws { - /* - @JSONCodable - struct Foo { - @CodableDefault("hello") - let bar: String - } - */ - - struct Foo: JSONDecodable, Equatable { - let bar: String - - enum CodingFields: JSONOptimizedDecodingField { - case bar - - static func field(for key: UTF8Span) throws(NewCodable.CodingError.Decoding) -> Self { - switch UTF8SpanComparator(key) { - case "bar": .bar - default: throw CodingError.unknownKey(key) - } - } - - @inline(__always) - var staticString: StaticString { - switch self { - case .bar: "bar" - } - } - } - - static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(NewCodable.CodingError.Decoding) -> Foo { - return try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in - var bar: String? - var inOrder = false - _ = try structDecoder.decodeExpectedOrderField(CodingFields.bar, inOrder: &inOrder, required: false) { valueDecoder throws(CodingError.Decoding) in - bar = try valueDecoder.decode(String.self) - } - return Foo(bar: bar ?? "hello") - } - } - } - let emptyJSON = Data("{}".utf8) let decoder = NewJSONDecoder() - let result = try decoder.decode(Foo.self, from: emptyJSON) - #expect(result == Foo(bar: "hello")) + let result = try decoder.decode(CodableStructWithDefaultedProperty.self, from: emptyJSON) + #expect(result.bar == "hello") } @Test func testAliases() throws { - /* - @JSONCodable - struct Foo { - @CodableAlias("baz", "qux") - let bar: String - } - */ - - struct Foo: JSONDecodable, Equatable { - let bar: String - - static func decode(from decoder: inout some JSONDecoderProtocol & ~Escapable) throws(NewCodable.CodingError.Decoding) -> Foo { - return try decoder.decodeStruct { structDecoder throws(CodingError.Decoding) in - var bar: String? - try structDecoder.decodeEachKeyAndValue { key, valueDecoder throws(CodingError.Decoding) in - switch key { - // TODO: Deal with duplicates. - case "baz": bar = try valueDecoder.decode(String.self) - case "bar": bar = try valueDecoder.decode(String.self) - case "qux": bar = try valueDecoder.decode(String.self) - default: break // Skip. - - } - return false - } - return Foo(bar: bar ?? "hello") - } - } - } - let qux = Data("{ \"qux\" : \"hello\" }".utf8) let decoder = NewJSONDecoder() - let result = try decoder.decode(Foo.self, from: qux) - #expect(result == Foo(bar: "hello")) + let result = try decoder.decode(CodableStructWithAliasedProperty.self, from: qux) + #expect(result.bar == "hello") } @Test func testEmbeddedEncodable() throws { @@ -2071,3 +2001,196 @@ 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) + } +}