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..1f3e499 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,18 +26,22 @@ 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 [] } let inheritanceClause: InheritanceClauseSyntax? = - if case .classType(let hasSuperclass) = structureType, hasSuperclass { + if case .classType(let hasSuperclass) = structureType, + hasSuperclass, + !codableOptions.contains(.skipSuperCoding) + { nil } else { InheritanceClauseSyntax { @@ -62,7 +67,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 +99,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 +121,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 +136,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 +199,7 @@ extension CodableMacro { fileprivate static func genInitDecoderDecl( from properties: [Property], modifiers: [DeclModifierSyntax], + codableOptions: CodableOptions, hasSuper: Bool ) -> InitializerDeclSyntax { InitializerDeclSyntax( @@ -231,7 +261,11 @@ extension CodableMacro { } if hasSuper { - "try super.init(from: decoder)" + if codableOptions.contains(.skipSuperCoding) { + "super.init()" + } else { + "try super.init(from: decoder)" + } } } } @@ -240,6 +274,7 @@ extension CodableMacro { fileprivate static func genEncodeFuncDecl( from properties: [Property], modifiers: [DeclModifierSyntax], + codableOptions: CodableOptions, hasSuper: Bool ) -> FunctionDeclSyntax { FunctionDeclSyntax( @@ -292,7 +327,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) + } +} 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) + ) + + } }