From 23d99b18a324776ae75ddaac75345dc57508378b Mon Sep 17 00:00:00 2001 From: Wendell Date: Mon, 13 Jan 2025 19:26:30 +0800 Subject: [PATCH 1/3] Add CodableOptions for customizable macro behavior Introduce `CodableOptions` to allow customization of the `@Codable` macro. Update the macro to accept options, enabling users to skip super encode/decode calls when the superclass does not conform to `Codable`. Refactor related methods to utilize the new options, enhancing flexibility in code generation. --- Sources/CodableKit/CodableKit.swift | 12 +++- Sources/CodableKitMacros/CodableMacro.swift | 48 ++++++++++--- Sources/CodableKitMacros/CodeGenCore.swift | 28 +++++++- Sources/CodableKitShared/CodableOptions.swift | 67 +++++++++++++++++++ 4 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 Sources/CodableKitShared/CodableOptions.swift diff --git a/Sources/CodableKit/CodableKit.swift b/Sources/CodableKit/CodableKit.swift index 294f609..dd58b1a 100644 --- a/Sources/CodableKit/CodableKit.swift +++ b/Sources/CodableKit/CodableKit.swift @@ -56,7 +56,9 @@ /// ``` @attached(extension, conformances: Codable, names: named(CodingKeys), named(init(from:))) @attached(member, conformances: Codable, names: named(init(from:)), named(encode(to:))) -public macro Codable() = #externalMacro(module: "CodableKitMacros", type: "CodableMacro") +public macro Codable( + options: CodableOptions = .default +) = #externalMacro(module: "CodableKitMacros", type: "CodableMacro") /// A macro that generates `Decodable` conformance and boilerplate code for a struct, such that the Decodable struct can /// have default values for its properties, and custom keys for encoding and decoding with `@CodableKey`. @@ -96,7 +98,9 @@ public macro Codable() = #externalMacro(module: "CodableKitMacros", type: "Codab /// ``` @attached(extension, conformances: Decodable, names: named(CodingKeys), named(init(from:))) @attached(member, conformances: Decodable, names: named(init(from:))) -public macro Decodable() = #externalMacro(module: "CodableKitMacros", type: "CodableMacro") +public macro Decodable( + options: CodableOptions = .default +) = #externalMacro(module: "CodableKitMacros", type: "CodableMacro") /// A macro that generates `Encodable` conformance and boilerplate code for a struct, such that the Encodable struct can /// have default values for its properties, and custom keys for encoding and decoding with `@CodableKey`. @@ -136,7 +140,9 @@ public macro Decodable() = #externalMacro(module: "CodableKitMacros", type: "Cod /// ``` @attached(extension, conformances: Encodable, names: named(CodingKeys)) @attached(member, conformances: Encodable, names: named(encode(to:))) -public macro Encodable() = #externalMacro(module: "CodableKitMacros", type: "CodableMacro") +public macro Encodable( + options: CodableOptions = .default +) = #externalMacro(module: "CodableKitMacros", type: "CodableMacro") /// Custom the key used for encoding and decoding a property. /// diff --git a/Sources/CodableKitMacros/CodableMacro.swift b/Sources/CodableKitMacros/CodableMacro.swift index 82b9b32..d68c588 100644 --- a/Sources/CodableKitMacros/CodableMacro.swift +++ b/Sources/CodableKitMacros/CodableMacro.swift @@ -5,6 +5,7 @@ // Created by Wendell on 3/30/24. // +import CodableKitShared import Foundation import SwiftDiagnostics import SwiftSyntax @@ -25,12 +26,13 @@ extension CodableMacro: ExtensionMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - try core.prepareCodeGeneration(for: declaration, in: context, conformingTo: protocols) + try core.prepareCodeGeneration(of: node, for: declaration, in: context, conformingTo: protocols) let properties = try core.properties(for: declaration, in: context) let accessModifier = try core.accessModifier(for: declaration, in: context) let structureType = try core.accessStructureType(for: declaration, in: context) let codableType = try core.accessCodableType(for: declaration, in: context) + let codableOptions = try core.accessCodableOptions(for: declaration, in: context) // If there are no properties, return an empty array. guard !properties.isEmpty else { return [] } @@ -62,7 +64,14 @@ extension CodableMacro: ExtensionMacro { ) { genCodingKeyEnumDecl(from: properties) if codableType.contains(.decodable) { - DeclSyntax(genInitDecoderDecl(from: properties, modifiers: [accessModifier], hasSuper: false)) + DeclSyntax( + genInitDecoderDecl( + from: properties, + modifiers: [accessModifier], + codableOptions: codableOptions, + hasSuper: false + ) + ) } } ] @@ -87,12 +96,13 @@ extension CodableMacro: MemberMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - try core.prepareCodeGeneration(for: declaration, in: context, conformingTo: protocols) + try core.prepareCodeGeneration(of: node, for: declaration, in: context, conformingTo: protocols) let properties = try core.properties(for: declaration, in: context) let accessModifier = try core.accessModifier(for: declaration, in: context) let structureType = try core.accessStructureType(for: declaration, in: context) let codableType = try core.accessCodableType(for: declaration, in: context) + let codableOptions = try core.accessCodableOptions(for: declaration, in: context) // If there are no properties, return an empty array. guard !properties.isEmpty else { return [] } @@ -108,7 +118,9 @@ extension CodableMacro: MemberMacro { case let .classType(hasSuperclass): decodeModifiers.append(.init(name: .keyword(.required))) if hasSuperclass { - encodeModifiers.append(.init(name: .keyword(.override))) + if !codableOptions.contains(.skipSuperCoding) { + encodeModifiers.append(.init(name: .keyword(.override))) + } hasSuper = true } case .structType, .enumType: @@ -121,14 +133,28 @@ extension CodableMacro: MemberMacro { case .classType: if codableType.contains(.decodable) { result.append( - DeclSyntax(genInitDecoderDecl(from: properties, modifiers: decodeModifiers, hasSuper: hasSuper)) + DeclSyntax( + genInitDecoderDecl( + from: properties, + modifiers: decodeModifiers, + codableOptions: codableOptions, + hasSuper: hasSuper + ) + ) ) } fallthrough case .structType: if codableType.contains(.encodable) { result.append( - DeclSyntax(genEncodeFuncDecl(from: properties, modifiers: encodeModifiers, hasSuper: hasSuper)) + DeclSyntax( + genEncodeFuncDecl( + from: properties, + modifiers: encodeModifiers, + codableOptions: codableOptions, + hasSuper: hasSuper + ) + ) ) } case .enumType: @@ -170,6 +196,7 @@ extension CodableMacro { fileprivate static func genInitDecoderDecl( from properties: [Property], modifiers: [DeclModifierSyntax], + codableOptions: CodableOptions, hasSuper: Bool ) -> InitializerDeclSyntax { InitializerDeclSyntax( @@ -231,7 +258,11 @@ extension CodableMacro { } if hasSuper { - "try super.init(from: decoder)" + if codableOptions.contains(.skipSuperCoding) { + "super.init()" + } else { + "try super.init(from: decoder)" + } } } } @@ -240,6 +271,7 @@ extension CodableMacro { fileprivate static func genEncodeFuncDecl( from properties: [Property], modifiers: [DeclModifierSyntax], + codableOptions: CodableOptions, hasSuper: Bool ) -> FunctionDeclSyntax { FunctionDeclSyntax( @@ -292,7 +324,7 @@ extension CodableMacro { ) } - if hasSuper { + if hasSuper, !codableOptions.contains(.skipSuperCoding) { "try super.encode(to: encoder)" } } diff --git a/Sources/CodableKitMacros/CodeGenCore.swift b/Sources/CodableKitMacros/CodeGenCore.swift index 39fabf9..7278bbf 100644 --- a/Sources/CodableKitMacros/CodeGenCore.swift +++ b/Sources/CodableKitMacros/CodeGenCore.swift @@ -6,6 +6,7 @@ // Copyright © 2024 WendellXY. All rights reserved. // +import CodableKitShared import Foundation import SwiftDiagnostics import SwiftSyntax @@ -33,6 +34,7 @@ internal final class CodeGenCore: @unchecked Sendable { private var accessModifiers: [MacroContextKey: DeclModifierSyntax] = [:] private var structureTypes: [MacroContextKey: StructureType] = [:] private var codableTypes: [MacroContextKey: CodableType] = [:] + private var codableOptions: [MacroContextKey: CodableOptions] = [:] func key(for declaration: some SyntaxProtocol, in context: some MacroExpansionContext) -> MacroContextKey { let location = context.location(of: declaration) @@ -47,7 +49,10 @@ internal final class CodeGenCore: @unchecked Sendable { } extension CodeGenCore { - func properties(for declaration: some SyntaxProtocol, in context: some MacroExpansionContext) throws -> [Property] { + func properties( + for declaration: some SyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [Property] { properties[key(for: declaration, in: context)] ?? [] } @@ -95,6 +100,21 @@ extension CodeGenCore { severity: .error ) } + + func accessCodableOptions( + for declaration: some SyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> CodableOptions { + if let codableOptions = codableOptions[key(for: declaration, in: context)] { + return codableOptions + } + + throw SimpleDiagnosticMessage( + message: "Codable options for declaration not found", + diagnosticID: messageID, + severity: .error + ) + } } // MARK: - Property Extraction @@ -216,6 +236,7 @@ extension CodeGenCore { /// Prepare the code generation by extracting properties and access modifier. func prepareCodeGeneration( + of node: AttributeSyntax, for declaration: some DeclGroupSyntax, in context: some MacroExpansionContext, conformingTo protocols: [TypeSyntax] = [] @@ -237,6 +258,11 @@ extension CodeGenCore { preparedDeclarations.insert(id) } + codableOptions[id] = node.arguments? + .as(LabeledExprListSyntax.self)? + .first(where: { $0.label?.text == "options" })? + .parseCodableOptions() ?? .default + // Check if properties and access modifier are already prepared if accessModifiers[id] == nil { diff --git a/Sources/CodableKitShared/CodableOptions.swift b/Sources/CodableKitShared/CodableOptions.swift new file mode 100644 index 0000000..8d69a21 --- /dev/null +++ b/Sources/CodableKitShared/CodableOptions.swift @@ -0,0 +1,67 @@ +// +// CodableOptions.swift +// CodableKit +// +// Created by Wendell Wang on 2025/1/13. +// + +import SwiftSyntax + +/// Options that customize the behavior of the `@Codable` macro expansion. +public struct CodableOptions: OptionSet, Sendable { + public let rawValue: Int32 + + public init(rawValue: Int32) { + self.rawValue = rawValue + } + + /// The default options, which perform standard Codable expansion with super encode/decode calls. + public static let `default`: Self = [] + + /// Skips generating super encode/decode calls in the expanded code. + /// + /// Use this option when the superclass doesn't conform to `Codable`. + /// When enabled: + /// - Replaces `super.init(from: decoder)` with `super.init()` + /// - Removes `super.encode(to: encoder)` call entirely + public static let skipSuperCoding = Self(rawValue: 1 << 0) +} + +extension CodableOptions { + package init(from expr: MemberAccessExprSyntax) { + let variableName = expr.declName.baseName.text + switch variableName { + case "skipSuperCoding": + self = .skipSuperCoding + default: + self = .default + } + } +} + +extension CodableOptions { + /// Parse the options from 1a `LabelExprSyntax`. It support parse a single element like `.default`, + /// or multiple elements like `[.ignored, .explicitNil]` + package static func parse(from labeledExpr: LabeledExprSyntax) -> Self { + if let memberAccessExpr = labeledExpr.expression.as(MemberAccessExprSyntax.self) { + Self.init(from: memberAccessExpr) + } else if let arrayExpr = labeledExpr.expression.as(ArrayExprSyntax.self) { + arrayExpr.elements + .compactMap { $0.expression.as(MemberAccessExprSyntax.self) } + .map { Self.init(from: $0) } + .reduce(.default) { $0.union($1) } + } else { + .default + } + } +} + +extension LabeledExprSyntax { + /// Parse the options from a `LabelExprSyntax`. It support parse a single element like .default, + /// or multiple elements like [.ignored, .explicitNil]. + /// + /// This is a convenience method to use for chaining. + package func parseCodableOptions() -> CodableOptions { + CodableOptions.parse(from: self) + } +} From ca0fb733fc86311f3690263525f1a6e18f8c0ba8 Mon Sep 17 00:00:00 2001 From: Wendell Date: Mon, 13 Jan 2025 19:52:55 +0800 Subject: [PATCH 2/3] Prevent skipping superclass coding in CodableMacro Update the inheritance clause logic in CodableMacro to ensure that superclass coding is not skipped when the `skipSuperCoding` option is not present. This change enhances the handling of inheritance for Codable types, ensuring that all relevant properties are encoded correctly. --- Sources/CodableKitMacros/CodableMacro.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CodableKitMacros/CodableMacro.swift b/Sources/CodableKitMacros/CodableMacro.swift index d68c588..1f3e499 100644 --- a/Sources/CodableKitMacros/CodableMacro.swift +++ b/Sources/CodableKitMacros/CodableMacro.swift @@ -38,7 +38,10 @@ extension CodableMacro: ExtensionMacro { guard !properties.isEmpty else { return [] } let inheritanceClause: InheritanceClauseSyntax? = - if case .classType(let hasSuperclass) = structureType, hasSuperclass { + if case .classType(let hasSuperclass) = structureType, + hasSuperclass, + !codableOptions.contains(.skipSuperCoding) + { nil } else { InheritanceClauseSyntax { From d14ca197d1b08ea9041efe760f37673d0d5a8d50 Mon Sep 17 00:00:00 2001 From: Wendell Date: Mon, 13 Jan 2025 19:53:21 +0800 Subject: [PATCH 3/3] Add tests for Codable options with skipping super coding Add tests for the `@Encodable`, `@Decodable`, and `@Codable` macros with the `.skipSuperCoding` option. These tests verify that the macros correctly expand to the expected Swift code, ensuring that the generated classes handle encoding and decoding without inheriting properties from superclasses. This enhances the test coverage for the Codable macros and ensures their correct functionality in various scenarios. --- .../CodableMacroTests+class+inheritance.swift | 47 +++++++++++++++++++ .../CodableMacroTests+class.swift | 46 ++++++++++++++++++ .../CodableMacroTests+class+inheritance.swift | 40 ++++++++++++++++ .../CodableMacroTests+class.swift | 39 +++++++++++++++ .../CodableMacroTests+class+inheritance.swift | 39 +++++++++++++++ .../CodableMacroTests+class.swift | 39 +++++++++++++++ 6 files changed, 250 insertions(+) diff --git a/Tests/CodableKitTests/CodableMacroTests+class+inheritance.swift b/Tests/CodableKitTests/CodableMacroTests+class+inheritance.swift index 6cdd1ff..3c137fa 100644 --- a/Tests/CodableKitTests/CodableMacroTests+class+inheritance.swift +++ b/Tests/CodableKitTests/CodableMacroTests+class+inheritance.swift @@ -736,4 +736,51 @@ final class CodableKitTestsForSubClass: XCTestCase { ) } + + func testMacrosWithCodableOptionSkipSuperCoding() throws { + + assertMacroExpansion( + """ + @Codable(options: .skipSuperCoding) + public class User: NSObject { + let id: UUID + let name: String + let age: Int + } + """, + expandedSource: """ + public class User: NSObject { + let id: UUID + let name: String + let age: Int + + public required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + age = try container.decode(Int.self, forKey: .age) + super.init() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(age, forKey: .age) + } + } + + extension User: Codable { + enum CodingKeys: String, CodingKey { + case id + case name + case age + } + } + """, + macroSpecs: macroSpecs, + indentationWidth: .spaces(2) + ) + + } } diff --git a/Tests/CodableKitTests/CodableMacroTests+class.swift b/Tests/CodableKitTests/CodableMacroTests+class.swift index a4e4de1..04fa313 100644 --- a/Tests/CodableKitTests/CodableMacroTests+class.swift +++ b/Tests/CodableKitTests/CodableMacroTests+class.swift @@ -710,4 +710,50 @@ final class CodableKitTestsForClass: XCTestCase { ) } + + func testMacrosWithCodableOptionSkipSuperCoding() throws { + + assertMacroExpansion( + """ + @Codable(options: .skipSuperCoding) + public class User { + let id: UUID + let name: String + let age: Int + } + """, + expandedSource: """ + public class User { + let id: UUID + let name: String + let age: Int + + public required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + age = try container.decode(Int.self, forKey: .age) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(age, forKey: .age) + } + } + + extension User: Codable { + enum CodingKeys: String, CodingKey { + case id + case name + case age + } + } + """, + macroSpecs: macroSpecs, + indentationWidth: .spaces(2) + ) + + } } diff --git a/Tests/DecodableKitTests/CodableMacroTests+class+inheritance.swift b/Tests/DecodableKitTests/CodableMacroTests+class+inheritance.swift index df42b30..4674252 100644 --- a/Tests/DecodableKitTests/CodableMacroTests+class+inheritance.swift +++ b/Tests/DecodableKitTests/CodableMacroTests+class+inheritance.swift @@ -602,4 +602,44 @@ final class CodableKitTestsForSubClass: XCTestCase { ) } + + func testMacrosWithCodableOptionSkipSuperCoding() throws { + + assertMacroExpansion( + """ + @Decodable(options: .skipSuperCoding) + public class User: NSObject { + let id: UUID + let name: String + let age: Int + } + """, + expandedSource: """ + public class User: NSObject { + let id: UUID + let name: String + let age: Int + + public required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + age = try container.decode(Int.self, forKey: .age) + super.init() + } + } + + extension User: Decodable { + enum CodingKeys: String, CodingKey { + case id + case name + case age + } + } + """, + macroSpecs: macroSpecs, + indentationWidth: .spaces(2) + ) + + } } diff --git a/Tests/DecodableKitTests/CodableMacroTests+class.swift b/Tests/DecodableKitTests/CodableMacroTests+class.swift index f03901e..c50dc41 100644 --- a/Tests/DecodableKitTests/CodableMacroTests+class.swift +++ b/Tests/DecodableKitTests/CodableMacroTests+class.swift @@ -588,4 +588,43 @@ final class CodableKitTestsForClass: XCTestCase { ) } + + func testMacrosWithCodableOptionSkipSuperCoding() throws { + + assertMacroExpansion( + """ + @Decodable(options: .skipSuperCoding) + public class User { + let id: UUID + let name: String + let age: Int + } + """, + expandedSource: """ + public class User { + let id: UUID + let name: String + let age: Int + + public required init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + age = try container.decode(Int.self, forKey: .age) + } + } + + extension User: Decodable { + enum CodingKeys: String, CodingKey { + case id + case name + case age + } + } + """, + macroSpecs: macroSpecs, + indentationWidth: .spaces(2) + ) + + } } diff --git a/Tests/EncodableKitTests/CodableMacroTests+class+inheritance.swift b/Tests/EncodableKitTests/CodableMacroTests+class+inheritance.swift index 21a3513..a8c4cad 100644 --- a/Tests/EncodableKitTests/CodableMacroTests+class+inheritance.swift +++ b/Tests/EncodableKitTests/CodableMacroTests+class+inheritance.swift @@ -602,4 +602,43 @@ final class CodableKitTestsForSubClass: XCTestCase { ) } + + func testMacrosWithCodableOptionSkipSuperCoding() throws { + + assertMacroExpansion( + """ + @Encodable(options: .skipSuperCoding) + public class User: NSObject { + let id: UUID + let name: String + let age: Int + } + """, + expandedSource: """ + public class User: NSObject { + let id: UUID + let name: String + let age: Int + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(age, forKey: .age) + } + } + + extension User: Encodable { + enum CodingKeys: String, CodingKey { + case id + case name + case age + } + } + """, + macroSpecs: macroSpecs, + indentationWidth: .spaces(2) + ) + + } } diff --git a/Tests/EncodableKitTests/CodableMacroTests+class.swift b/Tests/EncodableKitTests/CodableMacroTests+class.swift index 2ad1bf6..0b9061f 100644 --- a/Tests/EncodableKitTests/CodableMacroTests+class.swift +++ b/Tests/EncodableKitTests/CodableMacroTests+class.swift @@ -588,4 +588,43 @@ final class CodableKitTestsForClass: XCTestCase { ) } + + func testMacrosWithCodableOptionSkipSuperCoding() throws { + + assertMacroExpansion( + """ + @Encodable(options: .skipSuperCoding) + public class User { + let id: UUID + let name: String + let age: Int + } + """, + expandedSource: """ + public class User { + let id: UUID + let name: String + let age: Int + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(age, forKey: .age) + } + } + + extension User: Encodable { + enum CodingKeys: String, CodingKey { + case id + case name + case age + } + } + """, + macroSpecs: macroSpecs, + indentationWidth: .spaces(2) + ) + + } }