├── .gitignore ├── .spi.yml ├── .editorconfig ├── .swiftlint.yml ├── Sources ├── CodableKitMacros │ ├── Plugin.swift │ ├── Diagnostic.swift │ ├── Helpers.swift │ ├── SwiftSyntaxHelper.swift │ ├── NamespaceNode+CodingKeys.swift │ ├── CodableOptions.swift │ ├── CodableType.swift │ ├── CodableKeyMacro.swift │ ├── NamespaceNode.swift │ ├── CodableKeyOptions.swift │ ├── CodingHookMacro.swift │ ├── CodeGenCore+GenEncode.swift │ └── NamespaceNode+Encode.swift └── CodableKit │ ├── CodableOptions.swift │ ├── LossyArray.swift │ ├── Transformers │ ├── CodingTransformer.swift │ ├── CodingTransformerComposition.swift │ └── BuiltInTransformers.swift │ ├── LossyDictionary.swift │ ├── CodableHook.swift │ ├── CodingHooks.swift │ ├── CodingTransformerRuntime.swift │ └── CodableKeyOptions.swift ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── ci.yml ├── LICENSE ├── Tests ├── CodableKitTests │ ├── NestedCodableTests.swift │ ├── Defines.swift │ ├── CodableMacroTests+hooks.swift │ ├── CodableMacroTests+enum.swift │ ├── CodableMacroTests+transcode.swift │ ├── CodableMacroTests+class_hooks.swift │ ├── TypeInferenceTests.swift │ ├── CodableMacroTests+lossy.data.swift │ ├── CodableMacroTests+keys.swift │ └── CodableMacroTests+diagnostics.swift ├── TransformerTests │ ├── Defines.swift │ ├── BuiltInTransformerTests.swift │ ├── CodingTransformerTests.swift │ └── CustomCodingTransformerTests.swift ├── DecodableKitTests │ ├── Defines.swift │ ├── CodableMacroTests+keys.swift │ ├── CodableMacroTests+enum.swift │ ├── CodableMacroTests+hooks.swift │ └── CodableMacroTests+diagnostics.swift └── EncodableKitTests │ ├── Defines.swift │ ├── CodableMacroTests+hooks.swift │ ├── CodableMacroTests+keys.swift │ ├── CodableMacroTests+enum.swift │ ├── CodingTransformerEncodeTests.swift │ ├── CodableMacroTests+lossy.swift │ └── CodableMacroTests+diagnostics.swift ├── .swift-format ├── Package.swift ├── AGENT.md └── ROADMAP.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm/ 3 | .build/ 4 | Package.resolved 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: 5 | - CodableKit 6 | - CodableKitShared 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.swift] 2 | indent_style = space 3 | indent_size = 2 4 | tab_width = 2 5 | insert_final_newline = true 6 | max_line_length = 120 7 | trim_trailing_whitespace = true 8 | end_of_line = LF 9 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_comma 3 | 4 | excluded: 5 | - .build 6 | - .swiftpm 7 | 8 | line_length: 9 | warning: 120 10 | error: 200 11 | 12 | reporter: "xcode" 13 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/Plugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Plugin.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell on 3/30/24. 6 | // 7 | 8 | import SwiftCompilerPlugin 9 | import SwiftSyntaxMacros 10 | 11 | @main 12 | struct CodableKitPlugin: CompilerPlugin { 13 | let providingMacros: [Macro.Type] = [ 14 | CodableMacro.self, 15 | CodableKeyMacro.self, 16 | DecodableKeyMacro.self, 17 | EncodableKeyMacro.self, 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | push: 7 | branches: ["main"] 8 | pull_request: 9 | branches: ["main"] 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [macos-latest, ubuntu-22.04] 17 | swift: ["6.0.0", "6.1.0", "6.2.0"] 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup Swift ${{ matrix.swift }} on ${{ matrix.os }} 23 | uses: SwiftyLab/setup-swift@latest 24 | with: 25 | swift-version: ${{ matrix.swift }} 26 | - name: Build 27 | run: swift build -v 28 | - name: Run tests 29 | run: swift test -v 30 | -------------------------------------------------------------------------------- /Sources/CodableKit/CodableOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableOptions.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/1/13. 6 | // 7 | 8 | /// Options that customize the behavior of the `@Codable` macro expansion. 9 | public struct CodableOptions: OptionSet, Sendable { 10 | public let rawValue: Int32 11 | 12 | public init(rawValue: Int32) { 13 | self.rawValue = rawValue 14 | } 15 | 16 | /// The default options, which perform standard Codable expansion with super encode/decode calls. 17 | public static let `default`: Self = [] 18 | 19 | /// Skips generating super encode/decode calls in the expanded code. 20 | /// 21 | /// Use this option when the superclass doesn't conform to `Codable`. 22 | /// When enabled: 23 | /// - Replaces `super.init(from: decoder)` with `super.init()` 24 | /// - Removes `super.encode(to: encoder)` call entirely 25 | public static let skipSuperCoding = Self(rawValue: 1 << 0) 26 | } 27 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/Diagnostic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Diagnostic.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell on 3/30/24. 6 | // 7 | 8 | import SwiftDiagnostics 9 | import SwiftSyntax 10 | 11 | struct SimpleDiagnosticMessage: DiagnosticMessage, Error { 12 | static let diagnosticID = MessageID(domain: "CodableKit", id: "CodableMacro") 13 | let message: String 14 | let diagnosticID: MessageID = Self.diagnosticID 15 | let severity: DiagnosticSeverity 16 | 17 | init(message: String, severity: DiagnosticSeverity = .warning) { 18 | self.message = message 19 | self.severity = severity 20 | } 21 | } 22 | 23 | extension SimpleDiagnosticMessage: FixItMessage { 24 | var fixItID: MessageID { diagnosticID } 25 | } 26 | 27 | enum CustomError: Error, CustomStringConvertible { 28 | case message(String) 29 | 30 | var description: String { 31 | switch self { 32 | case .message(let text): 33 | return text 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 WendellXY 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Helpers.swift 3 | // CodableKitMacros 4 | // 5 | // Created by Wendell Wang on 2025/9/10. 6 | // 7 | 8 | @resultBuilder 9 | struct ArrayBuilder { 10 | static func buildBlock() -> [T] { [] } 11 | static func buildExpression(_ expression: T) -> [T] { [expression] } 12 | static func buildExpression(_ expression: T?) -> [T] { [expression].compactMap { $0 } } 13 | static func buildExpression(_ expression: [T]) -> [T] { expression } 14 | static func buildBlock(_ components: [T]...) -> [T] { components.flatMap { $0 } } 15 | static func buildArray(_ components: [[T]]) -> [T] { components.flatMap { $0 } } 16 | static func buildOptional(_ component: [T]?) -> [T] { component ?? [] } 17 | static func buildEither(first component: [T]) -> [T] { component } 18 | static func buildEither(second component: [T]) -> [T] { component } 19 | static func buildLimitedAvailability(_ component: [T]) -> [T] { component } 20 | } 21 | 22 | extension Array { 23 | init(@ArrayBuilder builder: () -> [Element]) { self = builder() } 24 | } 25 | 26 | extension Array { 27 | mutating func appendContentsOf(@ArrayBuilder builder: () -> [Element]) { self.append(contentsOf: builder()) } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/SwiftSyntaxHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftSyntaxHelper.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2024/8/19. 6 | // 7 | 8 | import SwiftSyntax 9 | 10 | private let typePropertyKeywordSet: Set = [ 11 | TokenSyntax.keyword(.static).text, 12 | TokenSyntax.keyword(.class).text, 13 | ] 14 | 15 | extension TokenSyntax { 16 | internal var isTypePropertyKeyword: Bool { 17 | typePropertyKeywordSet.contains(text) 18 | } 19 | } 20 | 21 | internal let accessModifiersKeywordSet: Set = [ 22 | TokenSyntax.keyword(.open).text, 23 | TokenSyntax.keyword(.public).text, 24 | TokenSyntax.keyword(.package).text, 25 | TokenSyntax.keyword(.internal).text, 26 | TokenSyntax.keyword(.private).text, 27 | TokenSyntax.keyword(.fileprivate).text, 28 | ] 29 | 30 | extension AttributeSyntax { 31 | internal var macroName: String { 32 | attributeName.as(IdentifierTypeSyntax.self)?.description ?? "" 33 | } 34 | 35 | internal var isCodableKeyMacro: Bool { 36 | switch macroName { 37 | case "CodableKey", "DecodableKey", "EncodableKey": true 38 | default: false 39 | } 40 | } 41 | } 42 | 43 | extension TokenSyntax { 44 | internal var isAccessModifierKeyword: Bool { 45 | accessModifiersKeywordSet.contains(text) 46 | } 47 | } 48 | 49 | extension LabeledExprListSyntax { 50 | func getExpr(label: String?) -> LabeledExprSyntax? { 51 | first(where: { $0.label?.text == label }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/CodableKit/LossyArray.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LossyArray.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/5. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A custom wrapper to handle lossy decoding 11 | public struct LossyArray: Decodable { 12 | public var elements: [Element] = [] 13 | 14 | public init(from decoder: Decoder) throws { 15 | var container = try decoder.unkeyedContainer() 16 | var result: [Element] = [] 17 | while !container.isAtEnd { 18 | do { 19 | result.append(try container.decode(Element.self)) 20 | } catch { 21 | _ = try? container.superDecoder() // robustly advance one element 22 | } 23 | } 24 | self.elements = result 25 | } 26 | } 27 | 28 | extension LossyArray: Sendable where Element: Sendable {} 29 | extension LossyArray: Equatable where Element: Equatable {} 30 | extension LossyArray: Hashable where Element: Hashable {} 31 | 32 | extension LossyArray: ExpressibleByArrayLiteral { 33 | public init(arrayLiteral elements: Element...) { 34 | self.elements = elements 35 | } 36 | } 37 | 38 | extension LossyArray: CustomStringConvertible { 39 | public var description: String { 40 | return "LossyArray(\(elements.map { "\($0)" }.joined(separator: ", ")))" 41 | } 42 | } 43 | 44 | extension LossyArray: CustomDebugStringConvertible { 45 | public var debugDescription: String { 46 | return "LossyArray(\(elements.map { "\($0)" }.joined(separator: ", ")))" 47 | } 48 | } 49 | 50 | extension LossyArray: Sequence { 51 | public func makeIterator() -> IndexingIterator<[Element]> { 52 | return elements.makeIterator() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/NamespaceNode+CodingKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NamespaceNode+CodingKeys.swift 3 | // CodableKit 4 | // 5 | // Extracted coding keys enum generation from NamespaceNode 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | 11 | extension NamespaceNode { 12 | var enumName: String { 13 | if parent == nil { return rootBaseName } 14 | let suffix = (rootBaseName == "CodingKeys") ? "Keys" : rootBaseName 15 | return segment.capitalized + suffix 16 | } 17 | 18 | var codingKeysEnum: EnumDeclSyntax? { 19 | guard !properties.isEmpty || !children.isEmpty else { return nil } 20 | return EnumDeclSyntax( 21 | name: .identifier(enumName), 22 | inheritanceClause: .init( 23 | inheritedTypesBuilder: { 24 | InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("String"))) 25 | InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("CodingKey"))) 26 | } 27 | ) 28 | ) { 29 | for property in properties where !property.ignored { 30 | if let customCodableKey = property.customCodableKey, 31 | customCodableKey.description != property.name.description 32 | { 33 | "case \(property.name) = \"\(customCodableKey)\"" 34 | } else { 35 | "case \(property.name)" 36 | } 37 | } 38 | for child in children.values.sorted(by: { $0.segment < $1.segment }) { 39 | "case \(raw: child.segment)" 40 | } 41 | } 42 | } 43 | 44 | var allCodingKeysEnums: [EnumDeclSyntax] { 45 | var result: [EnumDeclSyntax] = [] 46 | if let codingKeysEnum { 47 | result.append(codingKeysEnum) 48 | } 49 | for child in children.values.sorted(by: { $0.segment < $1.segment }) { 50 | result.append(contentsOf: child.allCodingKeysEnums) 51 | } 52 | return result 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/CodableOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableOptions.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/10/2. 6 | // 7 | 8 | import SwiftSyntax 9 | 10 | struct CodableOptions: OptionSet, Sendable { 11 | let rawValue: Int32 12 | 13 | init(rawValue: Int32) { 14 | self.rawValue = rawValue 15 | } 16 | 17 | static let `default`: Self = [] 18 | static let skipSuperCoding = Self(rawValue: 1 << 0) 19 | } 20 | 21 | extension CodableOptions { 22 | init(from expr: MemberAccessExprSyntax) { 23 | let variableName = expr.declName.baseName.text 24 | switch variableName { 25 | case "skipSuperCoding": 26 | self = .skipSuperCoding 27 | default: 28 | self = .default 29 | } 30 | } 31 | } 32 | 33 | extension CodableOptions { 34 | /// Parse the options from 1a `LabelExprSyntax`. It support parse a single element like `.default`, 35 | /// or multiple elements like `[.ignored, .explicitNil]` 36 | static func parse(from labeledExpr: LabeledExprSyntax) -> Self { 37 | if let memberAccessExpr = labeledExpr.expression.as(MemberAccessExprSyntax.self) { 38 | Self.init(from: memberAccessExpr) 39 | } else if let arrayExpr = labeledExpr.expression.as(ArrayExprSyntax.self) { 40 | arrayExpr.elements 41 | .compactMap { $0.expression.as(MemberAccessExprSyntax.self) } 42 | .map { Self.init(from: $0) } 43 | .reduce(.default) { $0.union($1) } 44 | } else { 45 | .default 46 | } 47 | } 48 | } 49 | 50 | extension LabeledExprSyntax { 51 | /// Parse the options from a `LabelExprSyntax`. It support parse a single element like .default, 52 | /// or multiple elements like [.ignored, .explicitNil]. 53 | /// 54 | /// This is a convenience method to use for chaining. 55 | func parseCodableOptions() -> CodableOptions { 56 | CodableOptions.parse(from: self) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/CodableType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableType.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2024/11/29. 6 | // 7 | 8 | import SwiftSyntax 9 | 10 | internal struct CodableType: OptionSet { 11 | let rawValue: Int8 12 | 13 | static let none: CodableType = [] 14 | static let codable: CodableType = [.decodable, .encodable] 15 | static let decodable = CodableType(rawValue: 1 << 0) 16 | static let encodable = CodableType(rawValue: 1 << 1) 17 | 18 | static func from(_ protocols: [TypeSyntax]) -> CodableType { 19 | var codableType = CodableType.none 20 | 21 | for `protocol` in protocols { 22 | guard let name = `protocol`.as(IdentifierTypeSyntax.self)?.name.trimmed.text else { continue } 23 | codableType.insert(from(name)) 24 | } 25 | 26 | return codableType 27 | } 28 | 29 | static func from(_ macroName: String) -> CodableType { 30 | switch macroName { 31 | case "Codable": .codable 32 | case "Decodable": .decodable 33 | case "Encodable": .encodable 34 | case "CodableKey": .codable 35 | case "DecodableKey": .decodable 36 | case "EncodableKey": .encodable 37 | default: .none 38 | } 39 | } 40 | } 41 | 42 | extension CodableType { 43 | var __ckDecodeTransformed: DeclReferenceExprSyntax { 44 | DeclReferenceExprSyntax( 45 | baseName: .identifier( 46 | contains(.codable) ? "__ckDecodeTransformed" : "__ckDecodeOneWayTransformed" 47 | ) 48 | ) 49 | } 50 | 51 | var __ckDecodeTransformedIfPresent: DeclReferenceExprSyntax { 52 | DeclReferenceExprSyntax( 53 | baseName: .identifier( 54 | contains(.codable) ? "__ckDecodeTransformedIfPresent" : "__ckDecodeOneWayTransformedIfPresent" 55 | ) 56 | ) 57 | } 58 | 59 | var __ckEncodeTransformedIfPresent: DeclReferenceExprSyntax { 60 | DeclReferenceExprSyntax( 61 | baseName: .identifier( 62 | contains(.codable) ? "__ckEncodeTransformedIfPresent" : "__ckEncodeOneWayTransformedIfPresent" 63 | ) 64 | ) 65 | } 66 | 67 | var __ckEncodeTransformed: DeclReferenceExprSyntax { 68 | DeclReferenceExprSyntax( 69 | baseName: .identifier( 70 | contains(.codable) ? "__ckEncodeTransformed" : "__ckEncodeOneWayTransformed" 71 | ) 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/CodableKit/Transformers/CodingTransformer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingTransformer.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/6. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol CodingTransformer { 11 | associatedtype Input 12 | associatedtype Output 13 | 14 | func transform(_ input: Result) -> Result 15 | } 16 | 17 | extension CodingTransformer { 18 | public func chained( 19 | _ next: some CodingTransformer 20 | ) -> some CodingTransformer { 21 | Chained(transformer1: self, transformer2: next) 22 | } 23 | 24 | public func paired( 25 | _ reversed: some CodingTransformer 26 | ) -> some BidirectionalCodingTransformer { 27 | Paired(transformer: self, reversedTransformer: reversed) 28 | } 29 | 30 | public func conditionally( 31 | condition: Bool, 32 | transformer: @escaping () -> some CodingTransformer 33 | ) -> some CodingTransformer { 34 | self.chained( 35 | Conditionally(condition: condition, transformer: transformer) 36 | ) 37 | } 38 | 39 | public func wrapped( 40 | defaultValue: T? = nil 41 | ) -> some CodingTransformer where Self.Output == T? { 42 | self.chained( 43 | Wrapped(defaultValue: defaultValue) 44 | ) 45 | } 46 | 47 | public func optional() -> some CodingTransformer { 48 | self.chained(Optional()) 49 | } 50 | } 51 | 52 | public protocol BidirectionalCodingTransformer: CodingTransformer { 53 | func reverseTransform(_ input: Result) -> Result 54 | } 55 | 56 | extension BidirectionalCodingTransformer { 57 | public func chained( 58 | _ next: some BidirectionalCodingTransformer 59 | ) -> some BidirectionalCodingTransformer { 60 | Chained(transformer1: self, transformer2: next) 61 | } 62 | 63 | public var reversed: some BidirectionalCodingTransformer { 64 | Reversed(transformer: self) 65 | } 66 | } 67 | 68 | extension BidirectionalCodingTransformer where Input == Output { 69 | public func reverseTransform( 70 | _ input: Result 71 | ) -> Result { 72 | transform(input) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/NestedCodableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NestedCodableTests.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/7/8. 6 | // 7 | 8 | import CodableKitMacros 9 | import SwiftSyntaxMacrosTestSupport 10 | import Testing 11 | 12 | @Test("Codable macro expands nested coding key paths as expected") 13 | func testNestedCodable() throws { 14 | assertMacro( 15 | """ 16 | @Codable 17 | struct User { 18 | @CodableKey("data.uid") let id: Int 19 | @CodableKey("profile.info.name") let name: String 20 | } 21 | """, 22 | expandedSource: 23 | """ 24 | struct User { 25 | let id: Int 26 | let name: String 27 | 28 | internal func encode(to encoder: any Encoder) throws { 29 | var container = encoder.container(keyedBy: CodingKeys.self) 30 | var dataContainer = container.nestedContainer(keyedBy: DataKeys.self, forKey: .data) 31 | var profileContainer = container.nestedContainer(keyedBy: ProfileKeys.self, forKey: .profile) 32 | try dataContainer.encode(id, forKey: .id) 33 | var infoContainer = profileContainer.nestedContainer(keyedBy: InfoKeys.self, forKey: .info) 34 | try infoContainer.encode(name, forKey: .name) 35 | } 36 | } 37 | 38 | extension User: Codable { 39 | enum CodingKeys: String, CodingKey { 40 | case data 41 | case profile 42 | } 43 | enum DataKeys: String, CodingKey { 44 | case id = "uid" 45 | } 46 | enum ProfileKeys: String, CodingKey { 47 | case info 48 | } 49 | enum InfoKeys: String, CodingKey { 50 | case name 51 | } 52 | internal init(from decoder: any Decoder) throws { 53 | let container = try decoder.container(keyedBy: CodingKeys.self) 54 | let dataContainer = try container.nestedContainer(keyedBy: DataKeys.self, forKey: .data) 55 | let profileContainer = try container.nestedContainer(keyedBy: ProfileKeys.self, forKey: .profile) 56 | id = try dataContainer.decode(Int.self, forKey: .id) 57 | let infoContainer = try profileContainer.nestedContainer(keyedBy: InfoKeys.self, forKey: .info) 58 | name = try infoContainer.decode(String.self, forKey: .name) 59 | } 60 | } 61 | """ 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentation" : { 6 | "spaces" : 2 7 | }, 8 | "indentConditionalCompilationBlocks" : false, 9 | "indentSwitchCaseLabels" : false, 10 | "lineBreakAroundMultilineExpressionChainComponents" : false, 11 | "lineBreakBeforeControlFlowKeywords" : false, 12 | "lineBreakBeforeEachArgument" : false, 13 | "lineBreakBeforeEachGenericRequirement" : false, 14 | "lineLength" : 120, 15 | "maximumBlankLines" : 1, 16 | "prioritizeKeepingFunctionOutputTogether" : false, 17 | "respectsExistingLineBreaks" : true, 18 | "rules" : { 19 | "AllPublicDeclarationsHaveDocumentation" : false, 20 | "AlwaysUseLowerCamelCase" : true, 21 | "AmbiguousTrailingClosureOverload" : true, 22 | "BeginDocumentationCommentWithOneLineSummary" : false, 23 | "DoNotUseSemicolons" : true, 24 | "DontRepeatTypeInStaticProperties" : true, 25 | "FileScopedDeclarationPrivacy" : true, 26 | "FullyIndirectEnum" : true, 27 | "GroupNumericLiterals" : true, 28 | "IdentifiersMustBeASCII" : true, 29 | "NeverForceUnwrap" : false, 30 | "NeverUseForceTry" : false, 31 | "NeverUseImplicitlyUnwrappedOptionals" : false, 32 | "NoAccessLevelOnExtensionDeclaration" : true, 33 | "NoAssignmentInExpressions" : true, 34 | "NoBlockComments" : true, 35 | "NoCasesWithOnlyFallthrough" : true, 36 | "NoEmptyTrailingClosureParentheses" : true, 37 | "NoLabelsInCasePatterns" : true, 38 | "NoLeadingUnderscores" : false, 39 | "NoParensAroundConditions" : true, 40 | "NoVoidReturnOnFunctionSignature" : true, 41 | "OneCasePerLine" : true, 42 | "OneVariableDeclarationPerLine" : true, 43 | "OnlyOneTrailingClosureArgument" : true, 44 | "OrderedImports" : true, 45 | "ReturnVoidInsteadOfEmptyTuple" : true, 46 | "UseEarlyExits" : false, 47 | "UseLetInEveryBoundCaseVariable" : true, 48 | "UseShorthandTypeNames" : true, 49 | "UseSingleLinePropertyGetter" : true, 50 | "UseSynthesizedInitializer" : true, 51 | "UseTripleSlashForDocumentationComments" : true, 52 | "UseWhereClausesInForLoops" : false, 53 | "ValidateDocumentationComments" : false 54 | }, 55 | "spacesAroundRangeFormationOperators" : false, 56 | "tabWidth" : 4, 57 | "version" : 1 58 | } 59 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import CompilerPluginSupport 5 | import Foundation 6 | import PackageDescription 7 | 8 | let package = Package( 9 | name: "CodableKit", 10 | platforms: [ 11 | .macOS(.v10_15), 12 | .iOS(.v13), 13 | .tvOS(.v13), 14 | .watchOS(.v6), 15 | .macCatalyst(.v13), 16 | .visionOS(.v1), 17 | ], 18 | products: [ 19 | .library( 20 | name: "CodableKit", 21 | targets: ["CodableKit"] 22 | ) 23 | ], 24 | dependencies: [ 25 | .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"603.0.0") 26 | ], 27 | targets: [ 28 | .macro( 29 | name: "CodableKitMacros", 30 | dependencies: [ 31 | .product(name: "SwiftSyntax", package: "swift-syntax"), 32 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), 33 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), 34 | ] 35 | ), 36 | .target( 37 | name: "CodableKit", 38 | dependencies: [ 39 | "CodableKitMacros", 40 | ] 41 | ), 42 | .testTarget( 43 | name: "CodableKitTests", 44 | dependencies: [ 45 | "CodableKit", 46 | "CodableKitMacros", 47 | .product(name: "SwiftSyntax", package: "swift-syntax"), 48 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 49 | ] 50 | ), 51 | .testTarget( 52 | name: "DecodableKitTests", 53 | dependencies: [ 54 | "CodableKit", 55 | "CodableKitMacros", 56 | .product(name: "SwiftSyntax", package: "swift-syntax"), 57 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 58 | ] 59 | ), 60 | .testTarget( 61 | name: "EncodableKitTests", 62 | dependencies: [ 63 | "CodableKit", 64 | "CodableKitMacros", 65 | .product(name: "SwiftSyntax", package: "swift-syntax"), 66 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 67 | ] 68 | ), 69 | .testTarget( 70 | name: "TransformerTests", 71 | dependencies: [ 72 | "CodableKit", 73 | "CodableKitMacros", 74 | .product(name: "SwiftSyntax", package: "swift-syntax"), 75 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), 76 | ] 77 | ), 78 | ] 79 | ) 80 | -------------------------------------------------------------------------------- /Sources/CodableKit/LossyDictionary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LossyDictionary.swift 3 | // CodableKit 4 | // 5 | // Created by Assistant on 2025/9/6. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A wrapper that decodes a dictionary in a "lossy" way: 11 | /// - Skips entries whose value fails to decode 12 | /// - Skips keys that cannot be converted to the expected `Key` type 13 | /// 14 | /// Notes: 15 | /// - JSON object keys are strings. This wrapper requires `Key` to be 16 | /// `LosslessStringConvertible` so keys can be constructed from the raw 17 | /// JSON string key. Typical keys like `String` and `Int` are supported. 18 | public struct LossyDictionary: Decodable where Key: LosslessStringConvertible & Hashable, Value: Decodable { 19 | public var elements: [Key: Value] = [:] 20 | 21 | public init(from decoder: Decoder) throws { 22 | let container = try decoder.container(keyedBy: AnyCodingKey.self) 23 | var result: [Key: Value] = [:] 24 | for codingKey in container.allKeys { 25 | guard let key = Key(codingKey.stringValue) else { continue } 26 | do { 27 | let value = try container.decode(Value.self, forKey: codingKey) 28 | result[key] = value 29 | } catch { 30 | // Skip invalid values 31 | continue 32 | } 33 | } 34 | self.elements = result 35 | } 36 | 37 | private struct AnyCodingKey: CodingKey { 38 | var stringValue: String 39 | var intValue: Int? 40 | init?(stringValue: String) { 41 | self.stringValue = stringValue 42 | self.intValue = Int(stringValue) 43 | } 44 | init?(intValue: Int) { 45 | self.intValue = intValue 46 | self.stringValue = String(intValue) 47 | } 48 | } 49 | } 50 | 51 | extension LossyDictionary: Sendable where Key: Sendable, Value: Sendable {} 52 | extension LossyDictionary: Equatable where Value: Equatable {} 53 | extension LossyDictionary: Hashable where Value: Hashable {} 54 | 55 | extension LossyDictionary: ExpressibleByDictionaryLiteral { 56 | public init(dictionaryLiteral elements: (Key, Value)...) { 57 | self.elements = Dictionary(uniqueKeysWithValues: elements) 58 | } 59 | } 60 | 61 | extension LossyDictionary: CustomStringConvertible { 62 | public var description: String { 63 | let pairs = elements.map { "\($0): \($1)" }.joined(separator: ", ") 64 | return "LossyDictionary(\(pairs))" 65 | } 66 | } 67 | 68 | extension LossyDictionary: CustomDebugStringConvertible { 69 | public var debugDescription: String { description } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/Defines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defines.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/27 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import CodableKitMacros 10 | import SwiftSyntax 11 | import SwiftSyntaxMacroExpansion 12 | import SwiftSyntaxMacros 13 | import SwiftSyntaxMacrosGenericTestSupport 14 | import SwiftSyntaxMacrosTestSupport 15 | import Testing 16 | 17 | let macros: [String: any Macro.Type] = [ 18 | "Codable": CodableMacro.self, 19 | "CodableKey": CodableKeyMacro.self, 20 | "DecodableKey": DecodableKeyMacro.self, 21 | "EncodableKey": EncodableKeyMacro.self, 22 | ] 23 | 24 | let macroSpecs: [String: MacroSpec] = [ 25 | "Codable": MacroSpec(type: CodableMacro.self, conformances: ["Codable"]), 26 | "CodableKey": MacroSpec(type: CodableKeyMacro.self), 27 | "DecodableKey": MacroSpec(type: DecodableKeyMacro.self), 28 | "EncodableKey": MacroSpec(type: EncodableKeyMacro.self), 29 | ] 30 | 31 | func assertMacro( 32 | _ originalSource: String, 33 | expandedSource expectedExpandedSource: String, 34 | diagnostics: [DiagnosticSpec] = [], 35 | applyFixIts: [String]? = nil, 36 | fixedSource expectedFixedSource: String? = nil, 37 | testModuleName: String = "TestModule", 38 | testFileName: String = "test.swift", 39 | indentationWidth: Trivia = .spaces(2), 40 | fileID: StaticString = #fileID, 41 | file filePath: StaticString = #filePath, 42 | function: StaticString = #function, 43 | line: UInt = #line, 44 | column: UInt = #column 45 | ) { 46 | SwiftSyntaxMacrosGenericTestSupport.assertMacroExpansion( 47 | originalSource, 48 | expandedSource: expectedExpandedSource, 49 | diagnostics: diagnostics, 50 | macroSpecs: macroSpecs, 51 | applyFixIts: applyFixIts, 52 | fixedSource: expectedFixedSource, 53 | testModuleName: testModuleName, 54 | testFileName: testFileName, 55 | indentationWidth: indentationWidth, 56 | failureHandler: { 57 | #expect( 58 | Bool(false), 59 | .init(stringLiteral: $0.message), 60 | sourceLocation: .init( 61 | fileID: String(describing: fileID), 62 | filePath: String(describing: filePath), 63 | line: Int(line), 64 | column: Int(column) 65 | ) 66 | ) 67 | }, 68 | fileID: fileID, 69 | // Not used in the failure handler 70 | filePath: filePath, 71 | /// MahdiBM comment: requires StaticString so just set it to "" for now. 72 | line: line, 73 | column: column // Not used in the failure handler 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /Tests/TransformerTests/Defines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defines.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/27 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import CodableKitMacros 10 | import SwiftSyntax 11 | import SwiftSyntaxMacroExpansion 12 | import SwiftSyntaxMacros 13 | import SwiftSyntaxMacrosGenericTestSupport 14 | import SwiftSyntaxMacrosTestSupport 15 | import Testing 16 | 17 | let macros: [String: any Macro.Type] = [ 18 | "Codable": CodableMacro.self, 19 | "CodableKey": CodableKeyMacro.self, 20 | "DecodableKey": DecodableKeyMacro.self, 21 | "EncodableKey": EncodableKeyMacro.self, 22 | ] 23 | 24 | let macroSpecs: [String: MacroSpec] = [ 25 | "Codable": MacroSpec(type: CodableMacro.self, conformances: ["Codable"]), 26 | "CodableKey": MacroSpec(type: CodableKeyMacro.self), 27 | "DecodableKey": MacroSpec(type: DecodableKeyMacro.self), 28 | "EncodableKey": MacroSpec(type: EncodableKeyMacro.self), 29 | ] 30 | 31 | func assertMacro( 32 | _ originalSource: String, 33 | expandedSource expectedExpandedSource: String, 34 | diagnostics: [DiagnosticSpec] = [], 35 | applyFixIts: [String]? = nil, 36 | fixedSource expectedFixedSource: String? = nil, 37 | testModuleName: String = "TestModule", 38 | testFileName: String = "test.swift", 39 | indentationWidth: Trivia = .spaces(2), 40 | fileID: StaticString = #fileID, 41 | file filePath: StaticString = #filePath, 42 | function: StaticString = #function, 43 | line: UInt = #line, 44 | column: UInt = #column 45 | ) { 46 | SwiftSyntaxMacrosGenericTestSupport.assertMacroExpansion( 47 | originalSource, 48 | expandedSource: expectedExpandedSource, 49 | diagnostics: diagnostics, 50 | macroSpecs: macroSpecs, 51 | applyFixIts: applyFixIts, 52 | fixedSource: expectedFixedSource, 53 | testModuleName: testModuleName, 54 | testFileName: testFileName, 55 | indentationWidth: indentationWidth, 56 | failureHandler: { 57 | #expect( 58 | Bool(false), 59 | .init(stringLiteral: $0.message), 60 | sourceLocation: .init( 61 | fileID: String(describing: fileID), 62 | filePath: String(describing: filePath), 63 | line: Int(line), 64 | column: Int(column) 65 | ) 66 | ) 67 | }, 68 | fileID: fileID, 69 | // Not used in the failure handler 70 | filePath: filePath, 71 | /// MahdiBM comment: requires StaticString so just set it to "" for now. 72 | line: line, 73 | column: column // Not used in the failure handler 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /Tests/DecodableKitTests/Defines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defines.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/27 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import CodableKitMacros 10 | import SwiftSyntax 11 | import SwiftSyntaxMacroExpansion 12 | import SwiftSyntaxMacros 13 | import SwiftSyntaxMacrosGenericTestSupport 14 | import SwiftSyntaxMacrosTestSupport 15 | import Testing 16 | 17 | let macros: [String: any Macro.Type] = [ 18 | "Codable": CodableMacro.self, 19 | "CodableKey": CodableKeyMacro.self, 20 | "DecodableKey": DecodableKeyMacro.self, 21 | "EncodableKey": EncodableKeyMacro.self, 22 | ] 23 | 24 | let macroSpecs: [String: MacroSpec] = [ 25 | "Decodable": MacroSpec(type: CodableMacro.self, conformances: ["Decodable"]), 26 | "CodableKey": MacroSpec(type: CodableKeyMacro.self), 27 | "DecodableKey": MacroSpec(type: DecodableKeyMacro.self), 28 | "EncodableKey": MacroSpec(type: EncodableKeyMacro.self), 29 | ] 30 | 31 | func assertMacro( 32 | _ originalSource: String, 33 | expandedSource expectedExpandedSource: String, 34 | diagnostics: [DiagnosticSpec] = [], 35 | applyFixIts: [String]? = nil, 36 | fixedSource expectedFixedSource: String? = nil, 37 | testModuleName: String = "TestModule", 38 | testFileName: String = "test.swift", 39 | indentationWidth: Trivia = .spaces(2), 40 | fileID: StaticString = #fileID, 41 | file filePath: StaticString = #filePath, 42 | function: StaticString = #function, 43 | line: UInt = #line, 44 | column: UInt = #column 45 | ) { 46 | SwiftSyntaxMacrosGenericTestSupport.assertMacroExpansion( 47 | originalSource, 48 | expandedSource: expectedExpandedSource, 49 | diagnostics: diagnostics, 50 | macroSpecs: macroSpecs, 51 | applyFixIts: applyFixIts, 52 | fixedSource: expectedFixedSource, 53 | testModuleName: testModuleName, 54 | testFileName: testFileName, 55 | indentationWidth: indentationWidth, 56 | failureHandler: { 57 | #expect( 58 | Bool(false), 59 | .init(stringLiteral: $0.message), 60 | sourceLocation: .init( 61 | fileID: String(describing: fileID), 62 | filePath: String(describing: filePath), 63 | line: Int(line), 64 | column: Int(column) 65 | ) 66 | ) 67 | }, 68 | fileID: fileID, 69 | // Not used in the failure handler 70 | filePath: filePath, 71 | /// MahdiBM comment: requires StaticString so just set it to "" for now. 72 | line: line, 73 | column: column // Not used in the failure handler 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/Defines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defines.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/27 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import CodableKitMacros 10 | import SwiftSyntax 11 | import SwiftSyntaxMacroExpansion 12 | import SwiftSyntaxMacros 13 | import SwiftSyntaxMacrosGenericTestSupport 14 | import SwiftSyntaxMacrosTestSupport 15 | import Testing 16 | 17 | let macros: [String: any Macro.Type] = [ 18 | "Codable": CodableMacro.self, 19 | "CodableKey": CodableKeyMacro.self, 20 | "DecodableKey": DecodableKeyMacro.self, 21 | "EncodableKey": EncodableKeyMacro.self, 22 | ] 23 | 24 | let macroSpecs: [String: MacroSpec] = [ 25 | "Encodable": MacroSpec(type: CodableMacro.self, conformances: ["Encodable"]), 26 | "CodableKey": MacroSpec(type: CodableKeyMacro.self), 27 | "DecodableKey": MacroSpec(type: DecodableKeyMacro.self), 28 | "EncodableKey": MacroSpec(type: EncodableKeyMacro.self), 29 | ] 30 | 31 | func assertMacro( 32 | _ originalSource: String, 33 | expandedSource expectedExpandedSource: String, 34 | diagnostics: [DiagnosticSpec] = [], 35 | applyFixIts: [String]? = nil, 36 | fixedSource expectedFixedSource: String? = nil, 37 | testModuleName: String = "TestModule", 38 | testFileName: String = "test.swift", 39 | indentationWidth: Trivia = .spaces(2), 40 | fileID: StaticString = #fileID, 41 | file filePath: StaticString = #filePath, 42 | function: StaticString = #function, 43 | line: UInt = #line, 44 | column: UInt = #column 45 | ) { 46 | SwiftSyntaxMacrosGenericTestSupport.assertMacroExpansion( 47 | originalSource, 48 | expandedSource: expectedExpandedSource, 49 | diagnostics: diagnostics, 50 | macroSpecs: macroSpecs, 51 | applyFixIts: applyFixIts, 52 | fixedSource: expectedFixedSource, 53 | testModuleName: testModuleName, 54 | testFileName: testFileName, 55 | indentationWidth: indentationWidth, 56 | failureHandler: { 57 | #expect( 58 | Bool(false), 59 | .init(stringLiteral: $0.message), 60 | sourceLocation: .init( 61 | fileID: String(describing: fileID), 62 | filePath: String(describing: filePath), 63 | line: Int(line), 64 | column: Int(column) 65 | ) 66 | ) 67 | }, 68 | fileID: fileID, 69 | // Not used in the failure handler 70 | filePath: filePath, 71 | /// MahdiBM comment: requires StaticString so just set it to "" for now. 72 | line: line, 73 | column: column // Not used in the failure handler 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/CodableKeyMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableKeyMacro.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell on 3/30/24. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxMacros 10 | 11 | public struct CodableKeyMacro: PeerMacro { 12 | public static func expansion( 13 | of node: AttributeSyntax, 14 | providingPeersOf declaration: some DeclSyntaxProtocol, 15 | in context: some MacroExpansionContext 16 | ) throws -> [DeclSyntax] { 17 | try CodeGenCore() 18 | .prepareCodeGeneration(for: declaration, in: context, with: node) 19 | .filter(\.shouldGenerateCustomCodingKeyVariable) 20 | .compactMap(genCustomKeyVariable) 21 | .map(DeclSyntax.init) 22 | } 23 | } 24 | 25 | public struct EncodableKeyMacro: PeerMacro { 26 | public static func expansion( 27 | of node: AttributeSyntax, 28 | providingPeersOf declaration: some DeclSyntaxProtocol, 29 | in context: some MacroExpansionContext 30 | ) throws -> [DeclSyntax] { 31 | try CodeGenCore() 32 | .prepareCodeGeneration(for: declaration, in: context, with: node) 33 | .filter(\.shouldGenerateCustomCodingKeyVariable) 34 | .compactMap(genCustomKeyVariable) 35 | .map(DeclSyntax.init) 36 | } 37 | } 38 | 39 | public struct DecodableKeyMacro: PeerMacro { 40 | public static func expansion( 41 | of node: AttributeSyntax, 42 | providingPeersOf declaration: some DeclSyntaxProtocol, 43 | in context: some MacroExpansionContext 44 | ) throws -> [DeclSyntax] { 45 | try CodeGenCore() 46 | .prepareCodeGeneration(for: declaration, in: context, with: node) 47 | .filter(\.shouldGenerateCustomCodingKeyVariable) 48 | .compactMap(genCustomKeyVariable) 49 | .map(DeclSyntax.init) 50 | } 51 | } 52 | 53 | /// Generate the custom key variable for the property. 54 | private func genCustomKeyVariable(for property: CodableProperty) -> VariableDeclSyntax? { 55 | guard let customCodableKey = property.customCodableKey else { return nil } 56 | 57 | let pattern = PatternBindingSyntax( 58 | pattern: customCodableKey, 59 | typeAnnotation: TypeAnnotationSyntax(type: property.type), 60 | accessorBlock: AccessorBlockSyntax( 61 | leadingTrivia: .space, 62 | leftBrace: .leftBraceToken(), 63 | accessors: .getter("\(property.name)"), 64 | rightBrace: .rightBraceToken() 65 | ) 66 | ) 67 | 68 | return VariableDeclSyntax( 69 | modifiers: DeclModifierListSyntax([property.accessModifier]), 70 | bindingSpecifier: .keyword(.var), 71 | bindings: [pattern] 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/NamespaceNode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NamespaceNode.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/7/8. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | 12 | final class NamespaceNode { 13 | let type: CodableType 14 | let segment: String 15 | let rootBaseName: String 16 | var children: [String: NamespaceNode] = [:] 17 | var properties: [CodableProperty] = [] // Track properties at this node (usually leaf) 18 | 19 | weak var parent: NamespaceNode? // Optional parent node 20 | 21 | init(_ type: CodableType, segment: String, rootBaseName: String) { 22 | self.type = type 23 | self.segment = segment 24 | self.rootBaseName = rootBaseName 25 | } 26 | 27 | // Add a property to the tree based on its full key path 28 | private func add(property: CodableProperty, path: ArraySlice) { 29 | guard path.count > 1, let first = path.first else { 30 | // This node is the property leaf 31 | properties.append(property) 32 | return 33 | } 34 | let child = 35 | children[first] 36 | ?? { 37 | let node = NamespaceNode(type, segment: first, rootBaseName: rootBaseName) 38 | node.parent = self 39 | children[first] = node 40 | return node 41 | }() 42 | child.add(property: property, path: path.dropFirst()) 43 | } 44 | 45 | static func buildTree( 46 | _ type: CodableType, 47 | from propertyList: [CodableProperty] 48 | ) -> NamespaceNode { 49 | let rootBaseName = 50 | switch type { 51 | case .decodable: "DecodeKeys" 52 | case .encodable: "EncodeKeys" 53 | default: "CodingKeys" 54 | } 55 | 56 | let root = NamespaceNode(type, segment: rootBaseName, rootBaseName: rootBaseName) 57 | 58 | let propertyList = propertyList.map { 59 | $0.generateProperty(for: type) 60 | } 61 | 62 | for property in propertyList { 63 | let path = property.customCodableKeyPath ?? [property.name.description] 64 | root.add(property: property, path: path[...]) 65 | } 66 | return root 67 | } 68 | 69 | func codingKeyChain(for property: CodableProperty) -> [(String, String)] { 70 | var chain: [(String, String)] = [] 71 | chain.append((enumName, property.name.description)) 72 | var node = self 73 | while let parent = node.parent { 74 | let key = parent.enumName 75 | let value = node.segment 76 | chain.append((key, value)) 77 | node = parent 78 | } 79 | // Reverse the chain to have the root first 80 | return chain.reversed() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/CodableKit/CodableHook.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableHook.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/10/2. 6 | // 7 | 8 | /// Lifecycle stages at which `@CodableHook`-annotated methods are invoked. 9 | /// 10 | /// Use these values with `@CodableHook(_:)` on methods to opt into 11 | /// invocation during macro-generated `init(from:)` and `encode(to:)`. 12 | /// 13 | /// Call order: 14 | /// - Decoding: all `.willDecode` hooks → property decoding → all `.didDecode` hooks 15 | /// - Encoding: all `.willEncode` hooks → property encoding → all `.didEncode` hooks 16 | /// 17 | /// Multiple hooks per stage are supported and are called in declaration order. 18 | public enum HookStage: String, Sendable { 19 | /// Runs before any property decoding occurs inside `init(from:)`. 20 | /// - Usage: annotate a static/class method with `@CodableHook(.willDecode)`. 21 | /// The method should accept `from decoder: any Decoder` and can throw. 22 | /// - Important: Must be a `static` or `class` method; instance `willDecode` is not supported. 23 | case willDecode 24 | 25 | /// Runs immediately before property encoding in `encode(to:)`. 26 | /// - Usage: annotate an instance method with `@CodableHook(.willEncode)`. 27 | /// The method should accept `to encoder: any Encoder` and can throw. 28 | /// - Note: Should be nonmutating. 29 | case willEncode 30 | 31 | /// Runs after property encoding completes in `encode(to:)`. 32 | /// - Usage: annotate an instance method with `@CodableHook(.didEncode)`. 33 | /// The method should accept `to encoder: any Encoder` and can throw. 34 | /// - Note: Should be nonmutating. 35 | case didEncode 36 | 37 | /// Runs after all properties have been decoded in `init(from:)`. 38 | /// - Usage: annotate an instance method with `@CodableHook(.didDecode)`. 39 | /// The method should accept `from decoder: any Decoder` and can throw. 40 | /// - Note: May be `mutating` for structs. 41 | case didDecode 42 | } 43 | 44 | /// Marks a method to be invoked by the container macro at a particular coding stage. 45 | /// 46 | /// Usage: 47 | /// - `@CodableHook(.willDecode)` on static methods taking `(from decoder: any Decoder)` 48 | /// - `@CodableHook(.didDecode)` on methods taking `(from decoder: any Decoder)` 49 | /// - `@CodableHook(.willEncode)` or `@CodableHook(.didEncode)` on methods taking `(to encoder: any Encoder)` 50 | /// 51 | /// Note: 52 | /// - Hooks should be `throws` and nonmutating for encode stages. For structs, `.didDecode` may be `mutating`. 53 | @attached(peer) 54 | public macro CodableHook(_ stage: HookStage) = #externalMacro(module: "CodableKitMacros", type: "CodingHookMacro") 55 | -------------------------------------------------------------------------------- /Sources/CodableKit/CodingHooks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingHooks.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/6/5. 6 | // 7 | 8 | /// Provides hooks for custom actions after decoding from a Decoder. 9 | /// 10 | /// **Note:** This protocol is a helper for code completion and will be removed after compilation 11 | /// if you do not provide any hooks. You can implement these methods in different forms: 12 | /// - `func didDecode(from decoder: any Decoder) throws` (for classes) 13 | /// - `mutating func didDecode(from decoder: any Decoder) throws` (for structs) 14 | public protocol DecodingHooks { 15 | /// Called immediately after all properties are decoded. 16 | /// 17 | /// - Parameter decoder: The decoder instance used for decoding. 18 | /// - Throws: Any error thrown from custom logic. 19 | @inline(__always) 20 | func didDecode(from decoder: any Decoder) throws 21 | } 22 | 23 | /// Provides hooks for custom actions before and after encoding to an Encoder. 24 | /// 25 | /// **Note:** This protocol is a helper for code completion and will be removed after compilation 26 | /// if you do not provide any hooks. You can implement these methods in different forms: 27 | /// - `func willEncode(to encoder: any Encoder) throws` (for classes) 28 | /// - `mutating func willEncode(to encoder: any Encoder) throws` (for structs) 29 | /// - `func didEncode(to encoder: any Encoder) throws` (for classes) 30 | /// - `mutating func didEncode(to encoder: any Encoder) throws` (for structs) 31 | public protocol EncodingHooks { 32 | /// Called immediately before any property is encoded. 33 | /// 34 | /// - Parameter encoder: The encoder instance used for encoding. 35 | /// - Throws: Any error thrown from custom logic. 36 | @inline(__always) 37 | func willEncode(to encoder: any Encoder) throws 38 | 39 | /// Called immediately after all properties are encoded. 40 | /// 41 | /// - Parameter encoder: The encoder instance used for encoding. 42 | /// - Throws: Any error thrown from custom logic. 43 | @inline(__always) 44 | func didEncode(to encoder: any Encoder) throws 45 | } 46 | 47 | /// Composite protocol that includes all encoding and decoding hooks. 48 | /// 49 | /// **Note:** This protocol is a helper for code completion and will be removed after compilation 50 | /// if you do not provide any hooks. 51 | public typealias CodableHooks = DecodingHooks & EncodingHooks 52 | 53 | // MARK: - Default Implementations 54 | 55 | extension DecodingHooks { 56 | @inline(__always) 57 | public func didDecode(from decoder: any Decoder) throws { 58 | // Default: do nothing 59 | } 60 | } 61 | 62 | extension EncodingHooks { 63 | @inline(__always) 64 | public func willEncode(to encoder: any Encoder) throws { 65 | // Default: do nothing 66 | } 67 | 68 | @inline(__always) 69 | public func didEncode(to encoder: any Encoder) throws { 70 | // Default: do nothing 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/CodableKeyOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableKeyOptions.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell on 4/3/24. 6 | // 7 | 8 | import SwiftSyntax 9 | 10 | struct CodableKeyOptions: OptionSet, Sendable { 11 | let rawValue: Int32 12 | 13 | init(rawValue: Int32) { 14 | self.rawValue = rawValue 15 | } 16 | 17 | static let `default`: Self = [] 18 | static let safeTranscodeRawString: Self = [.transcodeRawString, .useDefaultOnFailure] 19 | static let ignored = Self(rawValue: 1 << 0) 20 | static let explicitNil = Self(rawValue: 1 << 1) 21 | static let generateCustomKey = Self(rawValue: 1 << 2) 22 | static let transcodeRawString = Self(rawValue: 1 << 3) 23 | static let useDefaultOnFailure = Self(rawValue: 1 << 4) 24 | static let lossy = Self(rawValue: 1 << 5) 25 | } 26 | 27 | extension CodableKeyOptions { 28 | init(from expr: MemberAccessExprSyntax) { 29 | self = 30 | switch expr.declName.baseName.text { 31 | case "ignored": .ignored 32 | case "explicitNil": .explicitNil 33 | case "generateCustomKey": .generateCustomKey 34 | case "transcodeRawString": .transcodeRawString 35 | case "useDefaultOnFailure": .useDefaultOnFailure 36 | case "safeTranscodeRawString": .safeTranscodeRawString 37 | case "lossy": .lossy 38 | default: .default 39 | } 40 | } 41 | } 42 | 43 | extension CodableKeyOptions { 44 | /// Parse the options from 1a `LabelExprSyntax`. It support parse a single element like `.default`, 45 | /// or multiple elements like `[.ignored, .explicitNil]` 46 | static func parse(from labeledExpr: LabeledExprSyntax) -> Self { 47 | if let memberAccessExpr = labeledExpr.expression.as(MemberAccessExprSyntax.self) { 48 | Self.init(from: memberAccessExpr) 49 | } else if let arrayExpr = labeledExpr.expression.as(ArrayExprSyntax.self) { 50 | arrayExpr.elements 51 | .compactMap { $0.expression.as(MemberAccessExprSyntax.self) } 52 | .map { Self.init(from: $0) } 53 | .reduce(.default) { $0.union($1) } 54 | } else { 55 | .default 56 | } 57 | } 58 | } 59 | 60 | extension LabeledExprSyntax { 61 | /// Parse the options from a `LabelExprSyntax`. It support parse a single element like .default, 62 | /// or multiple elements like [.ignored, .explicitNil]. 63 | /// 64 | /// This is a convenience method to use for chaining. 65 | func parseOptions() -> CodableKeyOptions { 66 | CodableKeyOptions.parse(from: self) 67 | } 68 | } 69 | 70 | 71 | extension CodableKeyMacro { 72 | /// Options for customizing the behavior of a `CodableKey`. 73 | typealias Options = CodableKeyOptions 74 | } 75 | 76 | extension DecodableKeyMacro { 77 | /// Options for customizing the behavior of a `DecodeKey`. 78 | typealias Options = CodableKeyOptions 79 | } 80 | 81 | extension EncodableKeyMacro { 82 | /// Options for customizing the behavior of an `EncodeKey`. 83 | typealias Options = CodableKeyOptions 84 | } 85 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/CodableMacroTests+hooks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+hooks.swift 3 | // CodableKit 4 | // 5 | // Created by AI on 2025/10/02. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct EncodableHooksExpansionTests { 15 | @Test func classWillAndDidEncodeHooksIncludedWhenPresent() throws { 16 | assertMacro( 17 | """ 18 | @Encodable 19 | public class User { 20 | let id: UUID 21 | let name: String 22 | let age: Int 23 | 24 | @CodableHook(.willEncode) 25 | func prepare() throws {} 26 | 27 | @CodableHook(.didEncode) 28 | func finish() throws {} 29 | } 30 | """, 31 | expandedSource: """ 32 | public class User { 33 | let id: UUID 34 | let name: String 35 | let age: Int 36 | 37 | @CodableHook(.willEncode) 38 | func prepare() throws {} 39 | 40 | @CodableHook(.didEncode) 41 | func finish() throws {} 42 | 43 | public func encode(to encoder: any Encoder) throws { 44 | try prepare() 45 | var container = encoder.container(keyedBy: CodingKeys.self) 46 | try container.encode(id, forKey: .id) 47 | try container.encode(name, forKey: .name) 48 | try container.encode(age, forKey: .age) 49 | try finish() 50 | } 51 | } 52 | 53 | extension User: Encodable { 54 | enum CodingKeys: String, CodingKey { 55 | case id 56 | case name 57 | case age 58 | } 59 | } 60 | """ 61 | ) 62 | } 63 | 64 | @Test func conventionalWillAndDidEncodeWithoutAnnotationsParameterless() throws { 65 | assertMacro( 66 | """ 67 | @Encodable 68 | public class User { 69 | let id: UUID 70 | let name: String 71 | let age: Int 72 | 73 | func willEncode() throws {} 74 | func didEncode() throws {} 75 | } 76 | """, 77 | expandedSource: """ 78 | public class User { 79 | let id: UUID 80 | let name: String 81 | let age: Int 82 | 83 | func willEncode() throws {} 84 | func didEncode() throws {} 85 | 86 | public func encode(to encoder: any Encoder) throws { 87 | try willEncode() 88 | var container = encoder.container(keyedBy: CodingKeys.self) 89 | try container.encode(id, forKey: .id) 90 | try container.encode(name, forKey: .name) 91 | try container.encode(age, forKey: .age) 92 | try didEncode() 93 | } 94 | } 95 | 96 | extension User: Encodable { 97 | enum CodingKeys: String, CodingKey { 98 | case id 99 | case name 100 | case age 101 | } 102 | } 103 | """ 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+hooks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+hooks.swift 3 | // CodableKit 4 | // 5 | // Created by AI on 2025/10/02. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableHooksExpansionTestsForStruct { 15 | @Test func combinedAnnotatedHooksRunInOrder() throws { 16 | assertMacro( 17 | """ 18 | @Codable 19 | public struct User { 20 | let id: UUID 21 | let name: String 22 | 23 | @CodableHook(.willDecode) 24 | static func pre1() throws {} 25 | @CodableHook(.willDecode) 26 | static func pre2(from decoder: any Decoder) throws {} 27 | @CodableHook(.didDecode) 28 | mutating func post1() throws {} 29 | @CodableHook(.didDecode) 30 | mutating func post2(from decoder: any Decoder) throws {} 31 | @CodableHook(.willEncode) 32 | func start() throws {} 33 | @CodableHook(.willEncode) 34 | func ready(to encoder: any Encoder) throws {} 35 | @CodableHook(.didEncode) 36 | func finish() throws {} 37 | @CodableHook(.didEncode) 38 | func end(to encoder: any Encoder) throws {} 39 | } 40 | """, 41 | expandedSource: """ 42 | public struct User { 43 | let id: UUID 44 | let name: String 45 | 46 | @CodableHook(.willDecode) 47 | static func pre1() throws {} 48 | @CodableHook(.willDecode) 49 | static func pre2(from decoder: any Decoder) throws {} 50 | @CodableHook(.didDecode) 51 | mutating func post1() throws {} 52 | @CodableHook(.didDecode) 53 | mutating func post2(from decoder: any Decoder) throws {} 54 | @CodableHook(.willEncode) 55 | func start() throws {} 56 | @CodableHook(.willEncode) 57 | func ready(to encoder: any Encoder) throws {} 58 | @CodableHook(.didEncode) 59 | func finish() throws {} 60 | @CodableHook(.didEncode) 61 | func end(to encoder: any Encoder) throws {} 62 | 63 | public func encode(to encoder: any Encoder) throws { 64 | try start() 65 | try ready(to: encoder) 66 | var container = encoder.container(keyedBy: CodingKeys.self) 67 | try container.encode(id, forKey: .id) 68 | try container.encode(name, forKey: .name) 69 | try finish() 70 | try end(to: encoder) 71 | } 72 | } 73 | 74 | extension User: Codable { 75 | enum CodingKeys: String, CodingKey { 76 | case id 77 | case name 78 | } 79 | 80 | public init(from decoder: any Decoder) throws { 81 | try Self.pre1() 82 | try Self.pre2(from: decoder) 83 | let container = try decoder.container(keyedBy: CodingKeys.self) 84 | id = try container.decode(UUID.self, forKey: .id) 85 | name = try container.decode(String.self, forKey: .name) 86 | try post1() 87 | try post2(from: decoder) 88 | } 89 | } 90 | """ 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/CodableMacroTests+keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+keys.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/7. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitEncodableTestsForDifferentKeys { 15 | @Test func test_encodable_key_applies_custom_key_for_encodable() throws { 16 | assertMacro( 17 | """ 18 | @Encodable 19 | public struct User { 20 | let id: UUID 21 | @EncodableKey("name_en") 22 | let name: String 23 | let age: Int 24 | } 25 | """, 26 | expandedSource: """ 27 | public struct User { 28 | let id: UUID 29 | let name: String 30 | let age: Int 31 | 32 | public func encode(to encoder: any Encoder) throws { 33 | var container = encoder.container(keyedBy: CodingKeys.self) 34 | try container.encode(id, forKey: .id) 35 | try container.encode(name, forKey: .name) 36 | try container.encode(age, forKey: .age) 37 | } 38 | } 39 | 40 | extension User: Encodable { 41 | enum CodingKeys: String, CodingKey { 42 | case id 43 | case name = "name_en" 44 | case age 45 | } 46 | } 47 | """ 48 | ) 49 | } 50 | 51 | @Test func test_encodable_key_with_decodable_key() throws { 52 | assertMacro( 53 | """ 54 | @Encodable 55 | public struct User { 56 | let id: UUID 57 | @DecodableKey("name_de") 58 | let name: String 59 | let age: Int 60 | } 61 | """, 62 | expandedSource: """ 63 | public struct User { 64 | let id: UUID 65 | let name: String 66 | let age: Int 67 | } 68 | """, 69 | diagnostics: [ 70 | DiagnosticSpec( 71 | message: 72 | "The attached Key macro CodableType(rawValue: 1) does not match the Container macro CodableType(rawValue: 2)", 73 | line: 1, column: 1 74 | ) 75 | ] 76 | ) 77 | } 78 | 79 | @Test func test_encodable_key_with_decodable_key_only() throws { 80 | assertMacro( 81 | """ 82 | @Encodable 83 | public struct User { 84 | let id: UUID 85 | let name: String 86 | let age: Int 87 | } 88 | """, 89 | expandedSource: """ 90 | public struct User { 91 | let id: UUID 92 | let name: String 93 | let age: Int 94 | 95 | public func encode(to encoder: any Encoder) throws { 96 | var container = encoder.container(keyedBy: CodingKeys.self) 97 | try container.encode(id, forKey: .id) 98 | try container.encode(name, forKey: .name) 99 | try container.encode(age, forKey: .age) 100 | } 101 | } 102 | 103 | extension User: Encodable { 104 | enum CodingKeys: String, CodingKey { 105 | case id 106 | case name 107 | case age 108 | } 109 | } 110 | """ 111 | ) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Tests/DecodableKitTests/CodableMacroTests+keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+keys.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/7. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitDecodableTestsForDifferentKeys { 15 | @Test func test_decodable_key_applies_custom_key_for_decodable() throws { 16 | assertMacro( 17 | """ 18 | @Decodable 19 | public struct User { 20 | let id: UUID 21 | @DecodableKey("name_de") 22 | let name: String 23 | let age: Int 24 | } 25 | """, 26 | expandedSource: """ 27 | public struct User { 28 | let id: UUID 29 | let name: String 30 | let age: Int 31 | } 32 | 33 | extension User: Decodable { 34 | enum CodingKeys: String, CodingKey { 35 | case id 36 | case name = "name_de" 37 | case age 38 | } 39 | 40 | public init(from decoder: any Decoder) throws { 41 | let container = try decoder.container(keyedBy: CodingKeys.self) 42 | id = try container.decode(UUID.self, forKey: .id) 43 | name = try container.decode(String.self, forKey: .name) 44 | age = try container.decode(Int.self, forKey: .age) 45 | } 46 | } 47 | """ 48 | ) 49 | } 50 | 51 | @Test func test_decodable_key_with_encodable_key() throws { 52 | assertMacro( 53 | """ 54 | @Decodable 55 | public struct User { 56 | let id: UUID 57 | @EncodableKey("name_de") 58 | let name: String 59 | let age: Int 60 | } 61 | """, 62 | expandedSource: """ 63 | public struct User { 64 | let id: UUID 65 | let name: String 66 | let age: Int 67 | } 68 | """, 69 | diagnostics: [ 70 | DiagnosticSpec( 71 | message: 72 | "The attached Key macro CodableType(rawValue: 2) does not match the Container macro CodableType(rawValue: 1)", 73 | line: 1, column: 1) 74 | ] 75 | ) 76 | } 77 | 78 | @Test func test_decodable_key_with_decodable_key_only() throws { 79 | assertMacro( 80 | """ 81 | @Decodable 82 | public struct User { 83 | let id: UUID 84 | @DecodableKey("name_de") 85 | let name: String 86 | let age: Int 87 | } 88 | """, 89 | expandedSource: """ 90 | public struct User { 91 | let id: UUID 92 | let name: String 93 | let age: Int 94 | } 95 | 96 | extension User: Decodable { 97 | enum CodingKeys: String, CodingKey { 98 | case id 99 | case name = "name_de" 100 | case age 101 | } 102 | 103 | public init(from decoder: any Decoder) throws { 104 | let container = try decoder.container(keyedBy: CodingKeys.self) 105 | id = try container.decode(UUID.self, forKey: .id) 106 | name = try container.decode(String.self, forKey: .name) 107 | age = try container.decode(Int.self, forKey: .age) 108 | } 109 | } 110 | """ 111 | ) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+enum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+enum.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2024/11/22. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitTestsForEnum { 15 | @Test func macros() throws { 16 | 17 | assertMacro( 18 | """ 19 | @Codable 20 | public enum TestEnum { 21 | case string(String) 22 | case int(Int) 23 | case none 24 | } 25 | """, 26 | expandedSource: """ 27 | public enum TestEnum { 28 | case string(String) 29 | case int(Int) 30 | case none 31 | } 32 | 33 | extension TestEnum: Codable { 34 | enum CodingKeys: String, CodingKey { 35 | case string 36 | case int 37 | case none 38 | } 39 | } 40 | """ 41 | ) 42 | 43 | } 44 | 45 | @Test func macrosWithCodableKey() throws { 46 | 47 | assertMacro( 48 | """ 49 | @Codable 50 | public enum TestEnum { 51 | @CodableKey("str") case string(String) 52 | case int(Int) 53 | @CodableKey("empty") case none 54 | } 55 | """, 56 | expandedSource: """ 57 | public enum TestEnum { 58 | case string(String) 59 | case int(Int) 60 | case none 61 | } 62 | 63 | extension TestEnum: Codable { 64 | enum CodingKeys: String, CodingKey { 65 | case string = "str" 66 | case int 67 | case none = "empty" 68 | } 69 | } 70 | """ 71 | ) 72 | 73 | } 74 | 75 | @Test func macrosWithIgnoredCodableKey() throws { 76 | 77 | assertMacro( 78 | """ 79 | @Codable 80 | public enum TestEnum { 81 | @CodableKey("str") case string(String) 82 | case int(Int) 83 | @CodableKey(options: .ignored) case none 84 | } 85 | """, 86 | expandedSource: """ 87 | public enum TestEnum { 88 | case string(String) 89 | case int(Int) 90 | case none 91 | } 92 | 93 | extension TestEnum: Codable { 94 | enum CodingKeys: String, CodingKey { 95 | case string = "str" 96 | case int 97 | } 98 | } 99 | """ 100 | ) 101 | 102 | } 103 | 104 | @Test func macrosWithIndirectCase() throws { 105 | 106 | assertMacro( 107 | """ 108 | @Codable 109 | public enum TestEnum { 110 | @CodableKey("str") case string(String) 111 | case int(Int) 112 | @CodableKey("empty") case none 113 | indirect case nestedA(TestEnum) 114 | @CodableKey("b") indirect case nestedB(TestEnum) 115 | } 116 | """, 117 | expandedSource: """ 118 | public enum TestEnum { 119 | case string(String) 120 | case int(Int) 121 | case none 122 | indirect case nestedA(TestEnum) 123 | indirect case nestedB(TestEnum) 124 | } 125 | 126 | extension TestEnum: Codable { 127 | enum CodingKeys: String, CodingKey { 128 | case string = "str" 129 | case int 130 | case none = "empty" 131 | case nestedA 132 | case nestedB = "b" 133 | } 134 | } 135 | """ 136 | ) 137 | 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Tests/DecodableKitTests/CodableMacroTests+enum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+enum.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2024/11/22. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitTestsForEnum { 15 | @Test func macros() throws { 16 | 17 | assertMacro( 18 | """ 19 | @Decodable 20 | public enum TestEnum { 21 | case string(String) 22 | case int(Int) 23 | case none 24 | } 25 | """, 26 | expandedSource: """ 27 | public enum TestEnum { 28 | case string(String) 29 | case int(Int) 30 | case none 31 | } 32 | 33 | extension TestEnum: Decodable { 34 | enum CodingKeys: String, CodingKey { 35 | case string 36 | case int 37 | case none 38 | } 39 | } 40 | """ 41 | ) 42 | 43 | } 44 | 45 | @Test func macrosWithCodableKey() throws { 46 | 47 | assertMacro( 48 | """ 49 | @Decodable 50 | public enum TestEnum { 51 | @CodableKey("str") case string(String) 52 | case int(Int) 53 | @CodableKey("empty") case none 54 | } 55 | """, 56 | expandedSource: """ 57 | public enum TestEnum { 58 | case string(String) 59 | case int(Int) 60 | case none 61 | } 62 | 63 | extension TestEnum: Decodable { 64 | enum CodingKeys: String, CodingKey { 65 | case string = "str" 66 | case int 67 | case none = "empty" 68 | } 69 | } 70 | """ 71 | ) 72 | 73 | } 74 | 75 | @Test func macrosWithIgnoredCodableKey() throws { 76 | 77 | assertMacro( 78 | """ 79 | @Decodable 80 | public enum TestEnum { 81 | @CodableKey("str") case string(String) 82 | case int(Int) 83 | @CodableKey(options: .ignored) case none 84 | } 85 | """, 86 | expandedSource: """ 87 | public enum TestEnum { 88 | case string(String) 89 | case int(Int) 90 | case none 91 | } 92 | 93 | extension TestEnum: Decodable { 94 | enum CodingKeys: String, CodingKey { 95 | case string = "str" 96 | case int 97 | } 98 | } 99 | """ 100 | ) 101 | 102 | } 103 | 104 | @Test func macrosWithIndirectCase() throws { 105 | 106 | assertMacro( 107 | """ 108 | @Decodable 109 | public enum TestEnum { 110 | @CodableKey("str") case string(String) 111 | case int(Int) 112 | @CodableKey("empty") case none 113 | indirect case nestedA(TestEnum) 114 | @CodableKey("b") indirect case nestedB(TestEnum) 115 | } 116 | """, 117 | expandedSource: """ 118 | public enum TestEnum { 119 | case string(String) 120 | case int(Int) 121 | case none 122 | indirect case nestedA(TestEnum) 123 | indirect case nestedB(TestEnum) 124 | } 125 | 126 | extension TestEnum: Decodable { 127 | enum CodingKeys: String, CodingKey { 128 | case string = "str" 129 | case int 130 | case none = "empty" 131 | case nestedA 132 | case nestedB = "b" 133 | } 134 | } 135 | """ 136 | ) 137 | 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/CodableMacroTests+enum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+enum.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2024/11/22. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitTestsForEnum { 15 | @Test func macros() throws { 16 | 17 | assertMacro( 18 | """ 19 | @Encodable 20 | public enum TestEnum { 21 | case string(String) 22 | case int(Int) 23 | case none 24 | } 25 | """, 26 | expandedSource: """ 27 | public enum TestEnum { 28 | case string(String) 29 | case int(Int) 30 | case none 31 | } 32 | 33 | extension TestEnum: Encodable { 34 | enum CodingKeys: String, CodingKey { 35 | case string 36 | case int 37 | case none 38 | } 39 | } 40 | """ 41 | ) 42 | 43 | } 44 | 45 | @Test func macrosWithCodableKey() throws { 46 | 47 | assertMacro( 48 | """ 49 | @Encodable 50 | public enum TestEnum { 51 | @CodableKey("str") case string(String) 52 | case int(Int) 53 | @CodableKey("empty") case none 54 | } 55 | """, 56 | expandedSource: """ 57 | public enum TestEnum { 58 | case string(String) 59 | case int(Int) 60 | case none 61 | } 62 | 63 | extension TestEnum: Encodable { 64 | enum CodingKeys: String, CodingKey { 65 | case string = "str" 66 | case int 67 | case none = "empty" 68 | } 69 | } 70 | """ 71 | ) 72 | 73 | } 74 | 75 | @Test func macrosWithIgnoredCodableKey() throws { 76 | 77 | assertMacro( 78 | """ 79 | @Encodable 80 | public enum TestEnum { 81 | @CodableKey("str") case string(String) 82 | case int(Int) 83 | @CodableKey(options: .ignored) case none 84 | } 85 | """, 86 | expandedSource: """ 87 | public enum TestEnum { 88 | case string(String) 89 | case int(Int) 90 | case none 91 | } 92 | 93 | extension TestEnum: Encodable { 94 | enum CodingKeys: String, CodingKey { 95 | case string = "str" 96 | case int 97 | } 98 | } 99 | """ 100 | ) 101 | 102 | } 103 | 104 | @Test func macrosWithIndirectCase() throws { 105 | 106 | assertMacro( 107 | """ 108 | @Encodable 109 | public enum TestEnum { 110 | @CodableKey("str") case string(String) 111 | case int(Int) 112 | @CodableKey("empty") case none 113 | indirect case nestedA(TestEnum) 114 | @CodableKey("b") indirect case nestedB(TestEnum) 115 | } 116 | """, 117 | expandedSource: """ 118 | public enum TestEnum { 119 | case string(String) 120 | case int(Int) 121 | case none 122 | indirect case nestedA(TestEnum) 123 | indirect case nestedB(TestEnum) 124 | } 125 | 126 | extension TestEnum: Encodable { 127 | enum CodingKeys: String, CodingKey { 128 | case string = "str" 129 | case int 130 | case none = "empty" 131 | case nestedA 132 | case nestedB = "b" 133 | } 134 | } 135 | """ 136 | ) 137 | 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/CodingHookMacro.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableHookMacro.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/10/2. 6 | // 7 | 8 | import Foundation 9 | import SwiftDiagnostics 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | 14 | public struct CodingHookMacro: PeerMacro { 15 | public static func expansion( 16 | of node: AttributeSyntax, 17 | providingPeersOf declaration: some DeclSyntaxProtocol, 18 | in context: some MacroExpansionContext 19 | ) throws -> [DeclSyntax] { 20 | // Validate: must attach to a function 21 | guard let fn = FunctionDeclSyntax(declaration) else { 22 | let diag = Diagnostic( 23 | node: node, 24 | message: SimpleDiagnosticMessage( 25 | message: "@CodableHook can only be attached to functions", 26 | severity: .error 27 | ) 28 | ) 29 | context.diagnose(diag) 30 | return [] 31 | } 32 | 33 | // Validate the stage argument exists 34 | if node.arguments?.as(LabeledExprListSyntax.self)?.first == nil { 35 | let diag = Diagnostic( 36 | node: node, 37 | message: SimpleDiagnosticMessage( 38 | message: "@CodableHook requires a stage argument (e.g., .didDecode)", 39 | severity: .error 40 | ) 41 | ) 42 | context.diagnose(diag) 43 | return [] 44 | } 45 | 46 | // Soft validation of signature based on stage token presence 47 | if let arg = node.arguments?.as(LabeledExprListSyntax.self)?.first?.expression { 48 | let stageText = arg.description 49 | let params = fn.signature.parameterClause.parameters 50 | let isStatic = fn.modifiers.contains(where: { $0.name.text == "static" || $0.name.text == "class" }) 51 | if stageText.contains("didDecode") { 52 | // If parameter exists, prefer it to mention Decoder. If not, allow zero-parameter hooks. 53 | if let first = params.first, !first.type.description.contains("Decoder") { 54 | let diag = Diagnostic( 55 | node: node, 56 | message: SimpleDiagnosticMessage( 57 | message: "didDecode hooks should take a Decoder parameter", 58 | severity: .warning 59 | ) 60 | ) 61 | context.diagnose(diag) 62 | } 63 | } else if stageText.contains("willEncode") || stageText.contains("didEncode") { 64 | if let first = params.first, !first.type.description.contains("Encoder") { 65 | let diag = Diagnostic( 66 | node: node, 67 | message: SimpleDiagnosticMessage( 68 | message: "encode hooks should take an Encoder parameter", 69 | severity: .warning 70 | ) 71 | ) 72 | context.diagnose(diag) 73 | } 74 | } else if stageText.contains("willDecode") { 75 | if !isStatic { 76 | let diag = Diagnostic( 77 | node: node, 78 | message: SimpleDiagnosticMessage( 79 | message: "willDecode hooks must be static or class methods", 80 | severity: .warning 81 | ) 82 | ) 83 | context.diagnose(diag) 84 | } 85 | if let first = params.first, !first.type.description.contains("Decoder") { 86 | let diag = Diagnostic( 87 | node: node, 88 | message: SimpleDiagnosticMessage( 89 | message: "willDecode hooks should take a Decoder parameter", 90 | severity: .warning 91 | ) 92 | ) 93 | context.diagnose(diag) 94 | } 95 | } 96 | } 97 | 98 | // No peer declarations needed; this attribute is a marker used by container macros. 99 | return [] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/TransformerTests/BuiltInTransformerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuiltInTransformerTests.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/13. 6 | // 7 | 8 | import CodableKit 9 | import Foundation 10 | import Testing 11 | 12 | @Codable 13 | struct TestModelDefaultOnFailureTransformer { 14 | @CodableKey(transformer: DefaultOnFailureTransformer(defaultValue: 0)) 15 | var count: Int 16 | } 17 | 18 | @Codable 19 | struct TestModelUseDefaultOnFailureOption { 20 | @CodableKey(options: .useDefaultOnFailure) 21 | var count: Int = 0 22 | } 23 | 24 | @Suite("Builtin Transformer Tests") 25 | struct BuiltInTransformerTests { 26 | @Test func testDefaultOnFailureTransformer() async throws { 27 | let json = #"{"count":"1"}"# 28 | let data = json.data(using: .utf8)! 29 | let decoded = try JSONDecoder().decode(TestModelDefaultOnFailureTransformer.self, from: data) 30 | #expect(decoded.count == 0) 31 | 32 | let oldDecoded = try JSONDecoder().decode(TestModelUseDefaultOnFailureOption.self, from: data) 33 | #expect(oldDecoded.count == decoded.count) 34 | } 35 | 36 | @Test func testIdentityTransformer_propagates_success_and_failure() async throws { 37 | let t = IdentityTransformer() 38 | let ok = t.transform(.success(7)) 39 | #expect(try ok.get() == 7) 40 | 41 | enum E: Error { case boom } 42 | let err = t.transform(.failure(E.boom)) 43 | var threw = false 44 | do { _ = try err.get() } catch { threw = true } 45 | #expect(threw) 46 | } 47 | 48 | @Test func testDefaultOnFailureTransformer_direct() async throws { 49 | let t = DefaultOnFailureTransformer(defaultValue: 9) 50 | #expect(try t.transform(.success(1)).get() == 1) 51 | 52 | // Failure recovers to default when provided 53 | #expect(try t.transform(.failure(NSError(domain: "x", code: 1))).get() == 9) 54 | 55 | // When default is nil, failure should propagate 56 | let noDefault = DefaultOnFailureTransformer(defaultValue: nil) 57 | var threw = false 58 | do { _ = try noDefault.transform(.failure(NSError(domain: "y", code: 2))).get() } catch { threw = true } 59 | #expect(threw) 60 | } 61 | 62 | struct BRoom: Codable, Equatable { 63 | let id: Int 64 | let name: String 65 | } 66 | 67 | @Test func testRawStringDecodingTransformer_success_and_failure() async throws { 68 | let jsonString = #"{"id":1,"name":"One"}"# 69 | let t = RawStringDecodingTransformer() 70 | let ok = try t.transform(.success(jsonString)).get() 71 | #expect(ok == BRoom(id: 1, name: "One")) 72 | 73 | // Invalid JSON should fail 74 | let bad = t.transform(.success("not json")) 75 | var threw = false 76 | do { _ = try bad.get() } catch { threw = true } 77 | #expect(threw) 78 | } 79 | 80 | @Test func testRawStringEncodingTransformer_success() async throws { 81 | let room = BRoom(id: 2, name: "Two") 82 | let t = RawStringEncodingTransformer() 83 | let s = try t.transform(.success(room)).get() 84 | let dict = try JSONSerialization.jsonObject(with: s.data(using: .utf8)!) as! [String: Any] 85 | #expect((dict["id"] as? NSNumber)?.intValue == 2) 86 | #expect(dict["name"] as? String == "Two") 87 | } 88 | 89 | @Test func testRawStringBidirectionalTransformer_roundtrip() async throws { 90 | let room = BRoom(id: 3, name: "Three") 91 | let t = RawStringTransformer() 92 | let enc = try t.reverseTransform(.success(room)).get() 93 | let dec = try t.transform(.success(enc)).get() 94 | #expect(dec == room) 95 | } 96 | 97 | @Test func testIntegerToBooleanTransformer_decode_and_encode() async throws { 98 | let t = IntegerToBooleanTransformer() 99 | #expect(try t.transform(.success(1)).get() == true) 100 | #expect(try t.transform(.success(0)).get() == false) 101 | #expect(try t.reverseTransform(.success(true)).get() == 1) 102 | #expect(try t.reverseTransform(.success(false)).get() == 0) 103 | } 104 | 105 | @Test func testKeyPathTransformer_projects_value() async throws { 106 | struct Wrap { let inner: Int } 107 | let t = KeyPathTransformer(keyPath: \Wrap.inner) 108 | #expect(try t.transform(.success(.init(inner: 10))).get() == 10) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/CodeGenCore+GenEncode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeGenCore+GenEncode.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/29 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import SwiftSyntax 10 | 11 | extension CodeGenCore { 12 | /// Generate raw data handle expression 13 | /// 14 | /// The generated expression will be like below: 15 | /// 16 | /// ```swift 17 | /// if let [rawStringName] = String(data: [rawDataName], encoding: .utf8) { 18 | /// try [containerName].encode([rawStringName], forKey: .[key]) 19 | /// } else { 20 | /// throw EncodingError.invalidValue( 21 | /// [rawDataName], 22 | /// EncodingError.Context( 23 | /// codingPath: [CodingKeys.[key]], 24 | /// debugDescription: [message] 25 | /// ) 26 | /// ) 27 | /// } 28 | /// ``` 29 | static func genEncodeRawDataHandleExpr( 30 | property: CodableProperty, 31 | containerName: String, 32 | codingPath: [(String, String)], 33 | message: String 34 | ) -> ExprSyntax { 35 | let key = property.name 36 | let rawDataName = property.rawDataName 37 | let rawStringName = property.rawStringName 38 | let explicitNil = property.options.contains(.explicitNil) 39 | let isOptional = property.isOptional 40 | 41 | return ExprSyntax( 42 | IfExprSyntax( 43 | conditions: [ 44 | ConditionElementSyntax( 45 | condition: .expression("let \(rawStringName) = String(data: \(rawDataName), encoding: .utf8)"), 46 | trailingTrivia: .spaces(1) 47 | ) 48 | ], 49 | body: CodeBlockSyntax { 50 | "try \(raw: containerName).\(raw: isOptional && !explicitNil ? "encodeIfPresent" : "encode")(\(rawStringName), forKey: \(genChainingMembers("\(key)")))" 51 | }, 52 | elseKeyword: .keyword(.else), 53 | elseBody: .init( 54 | CodeBlockSyntax { 55 | CodeBlockItemSyntax( 56 | item: .stmt( 57 | genInvalidValueEncodingErrorThrowStmt( 58 | data: rawDataName, 59 | codingPath: codingPath, 60 | message: message 61 | ) 62 | ) 63 | ) 64 | } 65 | ) 66 | ) 67 | ) 68 | } 69 | 70 | /// Generate a `EncodingError` throwing statement for the value is invalid. 71 | /// 72 | /// The generated statement will be like below: 73 | /// ```swift 74 | /// throw EncodingError.invalidValue( 75 | /// [data], 76 | /// EncodingError.Context( 77 | /// codingPath: [CodingKeys.[codingPath]], 78 | /// debugDescription: [message] 79 | /// ) 80 | /// ) 81 | /// ``` 82 | fileprivate static func genInvalidValueEncodingErrorThrowStmt( 83 | data: PatternSyntax, 84 | codingPath: [(String, String)], 85 | message: String 86 | ) -> StmtSyntax { 87 | StmtSyntax( 88 | ThrowStmtSyntax( 89 | expression: FunctionCallExprSyntax( 90 | calledExpression: ExprSyntax("EncodingError.invalidValue"), 91 | leftParen: .leftParenToken(trailingTrivia: .newline), 92 | rightParen: .rightParenToken(leadingTrivia: .newline) 93 | ) { 94 | LabeledExprSyntax( 95 | leadingTrivia: .spaces(2), 96 | expression: DeclReferenceExprSyntax(baseName: .identifier("\(data)")), 97 | trailingComma: .commaToken(trailingTrivia: .newline) 98 | ) 99 | LabeledExprSyntax( 100 | expression: FunctionCallExprSyntax( 101 | calledExpression: ExprSyntax("EncodingError.Context"), 102 | leftParen: .leftParenToken(), 103 | rightParen: .rightParenToken(leadingTrivia: .newline) 104 | ) { 105 | LabeledExprSyntax( 106 | leadingTrivia: .newline, 107 | label: "codingPath", 108 | colon: .colonToken(), 109 | expression: ArrayExprSyntax( 110 | expressions: codingPath.map { key, value in 111 | ExprSyntax(genChainingMembers(key, value)) 112 | } 113 | ), 114 | trailingComma: .commaToken(trailingTrivia: .newline) 115 | ) 116 | LabeledExprSyntax( 117 | label: "debugDescription", 118 | colon: .colonToken(), 119 | expression: StringLiteralExprSyntax(content: message) 120 | ) 121 | } 122 | ) 123 | } 124 | ) 125 | ) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+transcode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+transcode.swift 3 | // CodableKitTests 4 | // 5 | // Verifies optional transcode behavior and shared codec variables. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitTranscodeTests { 15 | @Test func optionalTranscode_omitsKey_whenNil() throws { 16 | assertMacro( 17 | """ 18 | struct Room: Codable { 19 | let id: UUID 20 | let name: String 21 | } 22 | @Codable 23 | public struct User { 24 | @CodableKey(options: .transcodeRawString) 25 | var room: Room? 26 | } 27 | """, 28 | expandedSource: """ 29 | struct Room: Codable { 30 | let id: UUID 31 | let name: String 32 | } 33 | public struct User { 34 | var room: Room? 35 | 36 | public func encode(to encoder: any Encoder) throws { 37 | var container = encoder.container(keyedBy: CodingKeys.self) 38 | let __ckEncoder = JSONEncoder() 39 | if let roomUnwrapped = room { 40 | let roomRawData = try __ckEncoder.encode(roomUnwrapped) 41 | if let roomRawString = String(data: roomRawData, encoding: .utf8) { 42 | try container.encodeIfPresent(roomRawString, forKey: .room) 43 | } else { 44 | throw EncodingError.invalidValue( 45 | roomRawData, 46 | EncodingError.Context( 47 | codingPath: [CodingKeys.room], 48 | debugDescription: "Failed to transcode raw data to string" 49 | ) 50 | ) 51 | } 52 | } 53 | } 54 | } 55 | 56 | extension User: Codable { 57 | enum CodingKeys: String, CodingKey { 58 | case room 59 | } 60 | 61 | public init(from decoder: any Decoder) throws { 62 | let container = try decoder.container(keyedBy: CodingKeys.self) 63 | let __ckDecoder = JSONDecoder() 64 | let roomRawString = try container.decodeIfPresent(String.self, forKey: .room) ?? "" 65 | if !roomRawString.isEmpty, let roomRawData = roomRawString.data(using: .utf8) { 66 | room = (try? __ckDecoder.decode(Room?.self, from: roomRawData)) ?? nil 67 | } else { 68 | room = nil 69 | } 70 | } 71 | } 72 | """ 73 | ) 74 | } 75 | 76 | @Test func optionalTranscode_explicitNil_encodesNullString() throws { 77 | assertMacro( 78 | """ 79 | struct Room: Codable { 80 | let id: UUID 81 | let name: String 82 | } 83 | @Codable 84 | public struct User { 85 | @CodableKey(options: [.transcodeRawString, .explicitNil]) 86 | var room: Room? 87 | } 88 | """, 89 | expandedSource: """ 90 | struct Room: Codable { 91 | let id: UUID 92 | let name: String 93 | } 94 | public struct User { 95 | var room: Room? 96 | 97 | public func encode(to encoder: any Encoder) throws { 98 | var container = encoder.container(keyedBy: CodingKeys.self) 99 | let __ckEncoder = JSONEncoder() 100 | let roomRawData = try __ckEncoder.encode(room) 101 | if let roomRawString = String(data: roomRawData, encoding: .utf8) { 102 | try container.encode(roomRawString, forKey: .room) 103 | } else { 104 | throw EncodingError.invalidValue( 105 | roomRawData, 106 | EncodingError.Context( 107 | codingPath: [CodingKeys.room], 108 | debugDescription: "Failed to transcode raw data to string" 109 | ) 110 | ) 111 | } 112 | } 113 | } 114 | 115 | extension User: Codable { 116 | enum CodingKeys: String, CodingKey { 117 | case room 118 | } 119 | 120 | public init(from decoder: any Decoder) throws { 121 | let container = try decoder.container(keyedBy: CodingKeys.self) 122 | let __ckDecoder = JSONDecoder() 123 | let roomRawString = try container.decodeIfPresent(String.self, forKey: .room) ?? "" 124 | if !roomRawString.isEmpty, let roomRawData = roomRawString.data(using: .utf8) { 125 | room = (try? __ckDecoder.decode(Room?.self, from: roomRawData)) ?? nil 126 | } else { 127 | room = nil 128 | } 129 | } 130 | } 131 | """ 132 | ) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/CodingTransformerEncodeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingTransformerEncodeTests.swift 3 | // Encodable macro expansion tests for transformer encode paths 4 | // 5 | 6 | import SwiftSyntax 7 | import SwiftSyntaxBuilder 8 | import SwiftSyntaxMacros 9 | import SwiftSyntaxMacrosTestSupport 10 | import Testing 11 | 12 | @Suite struct CodingTransformerEncodeMacroTests { 13 | @Test func transformer_nonOptional_encodes_via_helper() throws { 14 | assertMacro( 15 | """ 16 | struct IntFromString: BidirectionalCodingTransformer { 17 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 18 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 19 | } 20 | @Encodable 21 | public struct Model { 22 | @CodableKey(transformer: IntFromString()) 23 | var count: Int 24 | } 25 | """, 26 | expandedSource: """ 27 | struct IntFromString: BidirectionalCodingTransformer { 28 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 29 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 30 | } 31 | public struct Model { 32 | var count: Int 33 | 34 | public func encode(to encoder: any Encoder) throws { 35 | var container = encoder.container(keyedBy: CodingKeys.self) 36 | try __ckEncodeTransformed(transformer: IntFromString(), value: count, into: &container, forKey: .count) 37 | } 38 | } 39 | 40 | extension Model: Encodable { 41 | enum CodingKeys: String, CodingKey { 42 | case count 43 | } 44 | } 45 | """ 46 | ) 47 | } 48 | 49 | @Test func transformer_optional_encodes_if_present_and_explicitNil_false() throws { 50 | assertMacro( 51 | """ 52 | struct IntFromString: BidirectionalCodingTransformer { 53 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 54 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 55 | } 56 | @Encodable 57 | public struct Model { 58 | @CodableKey(transformer: IntFromString()) 59 | var count: Int? 60 | } 61 | """, 62 | expandedSource: """ 63 | struct IntFromString: BidirectionalCodingTransformer { 64 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 65 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 66 | } 67 | public struct Model { 68 | var count: Int? 69 | 70 | public func encode(to encoder: any Encoder) throws { 71 | var container = encoder.container(keyedBy: CodingKeys.self) 72 | try __ckEncodeTransformedIfPresent(transformer: IntFromString(), value: count, into: &container, forKey: .count, explicitNil: false) 73 | } 74 | } 75 | 76 | extension Model: Encodable { 77 | enum CodingKeys: String, CodingKey { 78 | case count 79 | } 80 | } 81 | """ 82 | ) 83 | } 84 | 85 | @Test func transformer_optional_encodes_explicit_nil_when_enabled() throws { 86 | assertMacro( 87 | """ 88 | struct IntFromString: BidirectionalCodingTransformer { 89 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 90 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 91 | } 92 | @Encodable 93 | public struct Model { 94 | @CodableKey(options: .explicitNil, transformer: IntFromString()) 95 | var count: Int? 96 | } 97 | """, 98 | expandedSource: """ 99 | struct IntFromString: BidirectionalCodingTransformer { 100 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 101 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 102 | } 103 | public struct Model { 104 | var count: Int? 105 | 106 | public func encode(to encoder: any Encoder) throws { 107 | var container = encoder.container(keyedBy: CodingKeys.self) 108 | try __ckEncodeTransformedIfPresent(transformer: IntFromString(), value: count, into: &container, forKey: .count, explicitNil: true) 109 | } 110 | } 111 | 112 | extension Model: Encodable { 113 | enum CodingKeys: String, CodingKey { 114 | case count 115 | } 116 | } 117 | """ 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/CodableKit/CodingTransformerRuntime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingTransformerRuntime.swift 3 | // CodableKit 4 | // 5 | // Created by Assistant on 2025/9/6. 6 | // 7 | 8 | @inline(__always) 9 | public func __ckDecodeTransformed( 10 | transformer: T, 11 | from container: KeyedDecodingContainer, 12 | forKey key: K, 13 | useDefaultOnFailure: Bool, 14 | defaultValue: T.Output? = nil 15 | ) throws -> T.Output where T: BidirectionalCodingTransformer, T.Input: Decodable { 16 | try DecodeAtKey(container, for: key) 17 | .chained(transformer) 18 | .conditionally(condition: useDefaultOnFailure) { 19 | DefaultOnFailureTransformer(defaultValue: defaultValue) 20 | } 21 | .transform(.success(())) 22 | .get() 23 | } 24 | 25 | @inline(__always) 26 | public func __ckDecodeTransformedIfPresent( 27 | transformer: T, 28 | from container: KeyedDecodingContainer, 29 | forKey key: K, 30 | useDefaultOnFailure: Bool, 31 | defaultValue: T.Output? = nil 32 | ) throws -> T.Output? where T: BidirectionalCodingTransformer, T.Input: Decodable { 33 | try DecodeAtKeyIfPresent(container, for: key) 34 | .wrapped() 35 | .chained(transformer) 36 | .conditionally(condition: useDefaultOnFailure) { 37 | DefaultOnFailureTransformer(defaultValue: defaultValue) 38 | } 39 | .optional() 40 | .transform(.success(())) 41 | .flatMapError { error -> Result in 42 | if let error = error as? WrappedError, error == .valueNotFound { 43 | .success(defaultValue) 44 | } else { 45 | .failure(error) 46 | } 47 | } 48 | .get() 49 | } 50 | 51 | @inline(__always) 52 | public func __ckEncodeTransformed( 53 | transformer: T, 54 | value: T.Output, 55 | into container: inout KeyedEncodingContainer, 56 | forKey key: K 57 | ) throws where T: BidirectionalCodingTransformer, T.Input: Encodable { 58 | let input = try transformer.reverseTransform(.success(value)).get() 59 | try container.encode(input, forKey: key) 60 | } 61 | 62 | @inline(__always) 63 | public func __ckEncodeTransformedIfPresent( 64 | transformer: T, 65 | value: T.Output?, 66 | into container: inout KeyedEncodingContainer, 67 | forKey key: K, 68 | explicitNil: Bool 69 | ) throws where T: BidirectionalCodingTransformer, T.Input: Encodable { 70 | if let value { 71 | let input = try transformer.reverseTransform(.success(value)).get() 72 | try container.encode(input, forKey: key) 73 | } else if explicitNil { 74 | try container.encodeNil(forKey: key) 75 | } 76 | } 77 | 78 | // MARK: - One-way transformer support 79 | 80 | @inline(__always) 81 | public func __ckDecodeOneWayTransformed( 82 | transformer: T, 83 | from container: KeyedDecodingContainer, 84 | forKey key: K, 85 | useDefaultOnFailure: Bool, 86 | defaultValue: T.Output? = nil 87 | ) throws -> T.Output where T: CodingTransformer, T.Input: Decodable { 88 | try DecodeAtKey(container, for: key) 89 | .chained(transformer) 90 | .conditionally(condition: useDefaultOnFailure) { 91 | DefaultOnFailureTransformer(defaultValue: defaultValue) 92 | } 93 | .transform(.success(())) 94 | .get() 95 | } 96 | 97 | @inline(__always) 98 | public func __ckDecodeOneWayTransformedIfPresent( 99 | transformer: T, 100 | from container: KeyedDecodingContainer, 101 | forKey key: K, 102 | useDefaultOnFailure: Bool, 103 | defaultValue: T.Output? = nil 104 | ) throws -> T.Output? where T: CodingTransformer, T.Input: Decodable { 105 | try DecodeAtKeyIfPresent(container, for: key) 106 | .wrapped() 107 | .chained(transformer) 108 | .conditionally(condition: useDefaultOnFailure) { 109 | DefaultOnFailureTransformer(defaultValue: defaultValue) 110 | } 111 | .optional() 112 | .transform(.success(())) 113 | .flatMapError { error -> Result in 114 | if let error = error as? WrappedError, error == .valueNotFound { 115 | .success(defaultValue) 116 | } else { 117 | .failure(error) 118 | } 119 | } 120 | .get() 121 | } 122 | 123 | @inline(__always) 124 | public func __ckEncodeOneWayTransformed( 125 | transformer: T, 126 | value: T.Input, 127 | into container: inout KeyedEncodingContainer, 128 | forKey key: K 129 | ) throws where T: CodingTransformer, T.Output: Encodable { 130 | let encoded = try transformer.transform(.success(value)).get() 131 | try container.encode(encoded, forKey: key) 132 | } 133 | 134 | @inline(__always) 135 | public func __ckEncodeOneWayTransformedIfPresent( 136 | transformer: T, 137 | value: T.Input?, 138 | into container: inout KeyedEncodingContainer, 139 | forKey key: K, 140 | explicitNil: Bool 141 | ) throws where T: CodingTransformer, T.Output: Encodable { 142 | if let value { 143 | let encoded = try transformer.transform(.success(value)).get() 144 | try container.encode(encoded, forKey: key) 145 | } else if explicitNil { 146 | try container.encodeNil(forKey: key) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /AGENT.md: -------------------------------------------------------------------------------- 1 | # AGENT.md 2 | 3 | This document is for AI agents and contributors working on `CodableKit`. It explains the project structure, how to build and test locally, coding standards, and common workflows. Keep it concise and high-signal. 4 | 5 | ## Overview 6 | 7 | CodableKit is a Swift macro package that generates Codable/Encodable/Decodable conformances with powerful customization: 8 | 9 | - Default values and graceful decoding fallbacks 10 | - Custom/nested coding keys, property-level options 11 | - String ↔ Struct transcoding 12 | - Lifecycle hooks (didDecode/willEncode/didEncode) 13 | - Macro-based, compile-time codegen; no runtime cost 14 | 15 | ## Repository Map 16 | 17 | - `Package.swift`: SwiftPM manifest; targets and dependencies. 18 | - `Sources/` 19 | - `CodableKitShared/`: Shared types exposed to both runtime and macro targets (e.g., `CodableKeyOptions`, `CodableOptions`). 20 | - `CodableKitMacros/`: SwiftSyntax-based macro implementations and compiler plugin entry points. 21 | - Key files: `CodableMacro.swift`, `CodableKeyMacro.swift`, `CodeGenCore(+GenDecode/+GenEncode).swift`, `Diagnostic.swift`, `Plugin.swift`. 22 | - `CodableKit/`: Public runtime facade and helper protocols (e.g., `CodableHooks`, `EncodingHooks`, `DecodingHooks`). 23 | - `Tests/` 24 | - `CodableKitTests/`, `DecodableKitTests/`, `EncodableKitTests/`: Macro expansion tests and behavior coverage (structs, classes, enums, diagnostics, inheritance). 25 | 26 | ## Build Matrix 27 | 28 | - Swift tools: 6.0 29 | - Platforms: macOS 10.15+, iOS 13+, tvOS 13+, watchOS 6+, Mac Catalyst 13+ 30 | - Dependencies: `swift-syntax` ≥ 600.0.0 31 | 32 | ## Getting Started (Local Dev) 33 | 34 | 1) Prerequisites 35 | 36 | - Xcode 16+ or Swift toolchain compatible with Swift 6.0 and SwiftSyntax 600.0.0 37 | - macOS host recommended (macro plugin requires Apple toolchains) 38 | 39 | 2) Clone and open 40 | 41 | ```bash 42 | git clone https://github.com/WendellXY/CodableKit.git 43 | cd CodableKit 44 | ``` 45 | 46 | 3) Build 47 | 48 | ```bash 49 | swift build -v 50 | ``` 51 | 52 | 4) Test 53 | 54 | ```bash 55 | swift test -v 56 | ``` 57 | 58 | 5) Open in Xcode (optional) 59 | 60 | ```bash 61 | xed . 62 | ``` 63 | 64 | ## Using in Your Project 65 | 66 | Add to your package dependencies: 67 | 68 | ```swift 69 | .package(url: "https://github.com/WendellXY/CodableKit.git", from: "1.4.0") 70 | ``` 71 | 72 | Import and apply macros: 73 | 74 | ```swift 75 | import CodableKit 76 | 77 | @Codable 78 | struct User { /* ... */ } 79 | ``` 80 | 81 | For Swift 5 users or projects constrained to `swift-syntax 510.x`, use `from: "0.4.0"` (legacy line). 82 | 83 | ## Common Tasks 84 | 85 | - Update macro behavior: modify `Sources/CodableKitMacros/CodeGenCore*.swift` and related macro files; ensure diagnostics remain helpful. 86 | - Add property option: extend `Sources/CodableKitShared/CodableKeyOptions.swift` and update codegen logic accordingly. 87 | - Add macro-level option: edit `Sources/CodableKitShared/CodableOptions.swift` and relevant generation branches. 88 | - Adjust plugin registration: see `Sources/CodableKitMacros/Plugin.swift`. 89 | 90 | ## Coding Standards 91 | 92 | - Prefer explicit, readable names; avoid abbreviations. 93 | - Keep code generation paths clear with early exits and guard clauses. 94 | - Use minimal but meaningful comments explaining intent and invariants; avoid trivial commentary. 95 | - Match existing formatting; avoid reformatting unrelated code. 96 | - Add tests alongside feature changes (struct/class/enum/diagnostics coverage where applicable). 97 | 98 | ## Testing Guidance 99 | 100 | - Run `swift test` locally; tests cover macro expansion for classes (incl. inheritance), structs, enums, and diagnostics. 101 | - When adding new features, include examples in the appropriate test module (Encodable/Decodable/Codable) to validate expansion and behavior. 102 | - For inheritance where superclass is not Codable, use `.skipSuperCoding` in tests to avoid calling missing super methods. 103 | 104 | ## Troubleshooting 105 | 106 | - Build fails with SwiftSyntax version errors: ensure toolchain matches `swift-syntax` 600.x and Swift tools 6.0. 107 | - Macro not expanding / plugin issues: clean build folder, ensure Xcode uses the correct toolchain, and re-run `swift build -v`. 108 | - Decoding errors in tests: verify default values and `.useDefaultOnFailure` or `.safeTranscodeRawString` semantics; check key paths. 109 | 110 | ## Contribution Workflow 111 | 112 | 1) Create a feature branch 113 | 2) Implement changes with tests 114 | 3) Run `swift build` and `swift test` 115 | 4) Update `README.md` if user-facing behavior changes 116 | 5) Open a PR with a concise description and rationale 117 | 118 | ## Release Notes 119 | 120 | - Keep `README.md` Installation section aligned with the latest published version. 121 | - Update badges and CI as needed for new Swift versions. 122 | 123 | ## Security 124 | 125 | No known sensitive data or credentials. Report vulnerabilities via GitHub issues. 126 | 127 | ## Maintainers 128 | 129 | - Primary: Wendell (repo owner) 130 | - Contributions welcome via PRs 131 | -------------------------------------------------------------------------------- /Sources/CodableKit/Transformers/CodingTransformerComposition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingTransformerComposition.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/6. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Composed Transformers 11 | 12 | /// A composition that feeds the output of the first transformer into the second. 13 | /// 14 | /// Use `a.chained(b)` to build a pipeline `a -> b`. Failures are propagated. 15 | struct Chained: CodingTransformer 16 | where 17 | T: CodingTransformer, 18 | U: CodingTransformer, 19 | T.Output == U.Input 20 | { 21 | let transformer1: T 22 | let transformer2: U 23 | 24 | init(transformer1: T, transformer2: U) { 25 | self.transformer1 = transformer1 26 | self.transformer2 = transformer2 27 | } 28 | 29 | func transform(_ input: Result) -> Result { 30 | transformer2.transform(transformer1.transform(input)) 31 | } 32 | } 33 | 34 | extension Chained: BidirectionalCodingTransformer 35 | where 36 | T: BidirectionalCodingTransformer, 37 | U: BidirectionalCodingTransformer 38 | { 39 | func reverseTransform(_ input: Result) -> Result { 40 | transformer1.reverseTransform(transformer2.reverseTransform(input)) 41 | } 42 | } 43 | 44 | /// Adapts a bidirectional transformer into its inverse direction. 45 | /// 46 | /// For a transformer `T: BidirectionalCodingTransformer`, `Reversed(T)` 47 | /// behaves as `BidirectionalCodingTransformer` by swapping directions. 48 | struct Reversed: BidirectionalCodingTransformer 49 | where 50 | T: BidirectionalCodingTransformer 51 | { 52 | let transformer: T 53 | 54 | init(transformer: T) { 55 | self.transformer = transformer 56 | } 57 | 58 | func transform(_ input: Result) -> Result { 59 | transformer.reverseTransform(input) 60 | } 61 | 62 | func reverseTransform(_ input: Result) -> Result { 63 | transformer.transform(input) 64 | } 65 | } 66 | 67 | /// Couples two one-way transformers into a bidirectional pair. 68 | /// 69 | /// Useful when you have independent forward and reverse transformers and want 70 | /// a `BidirectionalCodingTransformer` facade for composition. 71 | struct Paired: BidirectionalCodingTransformer 72 | where 73 | T: CodingTransformer, 74 | U: CodingTransformer, 75 | T.Input == U.Output, 76 | T.Output == U.Input 77 | { 78 | typealias Input = T.Input 79 | typealias Output = T.Output 80 | 81 | let transformer: T 82 | let reversedTransformer: U 83 | 84 | init(transformer: T, reversedTransformer: U) { 85 | self.transformer = transformer 86 | self.reversedTransformer = reversedTransformer 87 | } 88 | 89 | func transform(_ input: Result) -> Result { 90 | transformer.transform(input) 91 | } 92 | 93 | func reverseTransform(_ input: Result) -> Result { 94 | reversedTransformer.transform(input) 95 | } 96 | } 97 | 98 | /// Conditionally applies an in-place transformer when `condition` is true. 99 | /// 100 | /// When false, the input result is passed through unchanged. 101 | struct Conditionally: CodingTransformer 102 | where 103 | T: CodingTransformer, 104 | T.Input == T.Output 105 | { 106 | let condition: Bool 107 | let transformer: () -> T 108 | 109 | typealias Input = T.Input 110 | typealias Output = T.Output 111 | 112 | init(condition: Bool, transformer: @escaping () -> T) { 113 | self.condition = condition 114 | self.transformer = transformer 115 | } 116 | 117 | func transform(_ input: Result) -> Result { 118 | if condition { 119 | transformer().transform(input) 120 | } else { 121 | input 122 | } 123 | } 124 | } 125 | 126 | /// Errors that can be thrown by `Wrapped` when no value or default exists. 127 | enum WrappedError: Error { 128 | case valueNotFound 129 | } 130 | 131 | /// Lifts `Result` into `Result` with an optional default. 132 | /// 133 | /// If the incoming optional is nil and a `defaultValue` is provided, the default 134 | /// is used; otherwise a `.valueNotFound` error is produced. 135 | struct Wrapped: CodingTransformer { 136 | let defaultValue: T? 137 | 138 | init(defaultValue: T?) { 139 | self.defaultValue = defaultValue 140 | } 141 | 142 | func transform(_ input: Result) -> Result { 143 | input.flatMap { value in 144 | if let value { 145 | .success(value) 146 | } else if let defaultValue { 147 | .success(defaultValue) 148 | } else { 149 | .failure(WrappedError.valueNotFound) 150 | } 151 | } 152 | } 153 | } 154 | 155 | /// Wraps a non-optional value into an optional result for further chaining. 156 | struct Optional: CodingTransformer { 157 | init() {} 158 | 159 | func transform(_ input: Result) -> Result { 160 | input.flatMap { value in 161 | .success(value) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+class_hooks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+class_hooks.swift 3 | // CodableKit 4 | // 5 | // Created by AI on 2025/10/02. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableHooksExpansionTestsForClass { 15 | @Test func combinedAnnotatedHooksRunInOrder() throws { 16 | assertMacro( 17 | """ 18 | @Codable 19 | public class User { 20 | let id: UUID 21 | let name: String 22 | 23 | @CodableHook(.willDecode) 24 | class func preA() throws {} 25 | @CodableHook(.willDecode) 26 | static func preB(from decoder: any Decoder) throws {} 27 | 28 | @CodableHook(.didDecode) 29 | func postA() throws {} 30 | @CodableHook(.didDecode) 31 | func postB(from decoder: any Decoder) throws {} 32 | 33 | @CodableHook(.willEncode) 34 | func start() throws {} 35 | @CodableHook(.willEncode) 36 | func ready(to encoder: any Encoder) throws {} 37 | 38 | @CodableHook(.didEncode) 39 | func finish() throws {} 40 | @CodableHook(.didEncode) 41 | func end(to encoder: any Encoder) throws {} 42 | } 43 | """, 44 | expandedSource: """ 45 | public class User { 46 | let id: UUID 47 | let name: String 48 | 49 | @CodableHook(.willDecode) 50 | class func preA() throws {} 51 | @CodableHook(.willDecode) 52 | static func preB(from decoder: any Decoder) throws {} 53 | 54 | @CodableHook(.didDecode) 55 | func postA() throws {} 56 | @CodableHook(.didDecode) 57 | func postB(from decoder: any Decoder) throws {} 58 | 59 | @CodableHook(.willEncode) 60 | func start() throws {} 61 | @CodableHook(.willEncode) 62 | func ready(to encoder: any Encoder) throws {} 63 | 64 | @CodableHook(.didEncode) 65 | func finish() throws {} 66 | @CodableHook(.didEncode) 67 | func end(to encoder: any Encoder) throws {} 68 | 69 | public required init(from decoder: any Decoder) throws { 70 | try Self.preA() 71 | try Self.preB(from: decoder) 72 | let container = try decoder.container(keyedBy: CodingKeys.self) 73 | id = try container.decode(UUID.self, forKey: .id) 74 | name = try container.decode(String.self, forKey: .name) 75 | try postA() 76 | try postB(from: decoder) 77 | } 78 | 79 | public func encode(to encoder: any Encoder) throws { 80 | try start() 81 | try ready(to: encoder) 82 | var container = encoder.container(keyedBy: CodingKeys.self) 83 | try container.encode(id, forKey: .id) 84 | try container.encode(name, forKey: .name) 85 | try finish() 86 | try end(to: encoder) 87 | } 88 | } 89 | 90 | extension User: Codable { 91 | enum CodingKeys: String, CodingKey { 92 | case id 93 | case name 94 | } 95 | } 96 | """ 97 | ) 98 | } 99 | 100 | @Test func annotatedOverridesConventionalForClass() throws { 101 | assertMacro( 102 | """ 103 | @Codable 104 | public class User { 105 | let id: UUID 106 | let name: String 107 | 108 | func willEncode() throws {} 109 | func didEncode() throws {} 110 | func didDecode() throws {} 111 | 112 | @CodableHook(.willEncode) 113 | func start() throws {} 114 | @CodableHook(.didEncode) 115 | func finish() throws {} 116 | @CodableHook(.didDecode) 117 | func post() throws {} 118 | } 119 | """, 120 | expandedSource: """ 121 | public class User { 122 | let id: UUID 123 | let name: String 124 | 125 | func willEncode() throws {} 126 | func didEncode() throws {} 127 | func didDecode() throws {} 128 | 129 | @CodableHook(.willEncode) 130 | func start() throws {} 131 | @CodableHook(.didEncode) 132 | func finish() throws {} 133 | @CodableHook(.didDecode) 134 | func post() throws {} 135 | 136 | public required init(from decoder: any Decoder) throws { 137 | let container = try decoder.container(keyedBy: CodingKeys.self) 138 | id = try container.decode(UUID.self, forKey: .id) 139 | name = try container.decode(String.self, forKey: .name) 140 | try post() 141 | } 142 | 143 | public func encode(to encoder: any Encoder) throws { 144 | try start() 145 | var container = encoder.container(keyedBy: CodingKeys.self) 146 | try container.encode(id, forKey: .id) 147 | try container.encode(name, forKey: .name) 148 | try finish() 149 | } 150 | } 151 | 152 | extension User: Codable { 153 | enum CodingKeys: String, CodingKey { 154 | case id 155 | case name 156 | } 157 | } 158 | """ 159 | ) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Tests/DecodableKitTests/CodableMacroTests+hooks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+hooks.swift 3 | // CodableKit 4 | // 5 | // Created by AI on 2025/10/02. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct DecodableHooksExpansionTests { 15 | @Test func staticWillDecodeHookIncludedBeforeDecoding() throws { 16 | assertMacro( 17 | """ 18 | @Decodable 19 | public struct User { 20 | let id: UUID 21 | let name: String 22 | let age: Int 23 | 24 | @CodableHook(.willDecode) 25 | static func pre(from decoder: any Decoder) throws {} 26 | } 27 | """, 28 | expandedSource: """ 29 | public struct User { 30 | let id: UUID 31 | let name: String 32 | let age: Int 33 | 34 | @CodableHook(.willDecode) 35 | static func pre(from decoder: any Decoder) throws {} 36 | } 37 | 38 | extension User: Decodable { 39 | enum CodingKeys: String, CodingKey { 40 | case id 41 | case name 42 | case age 43 | } 44 | 45 | public init(from decoder: any Decoder) throws { 46 | try Self.pre(from: decoder) 47 | let container = try decoder.container(keyedBy: CodingKeys.self) 48 | id = try container.decode(UUID.self, forKey: .id) 49 | name = try container.decode(String.self, forKey: .name) 50 | age = try container.decode(Int.self, forKey: .age) 51 | } 52 | } 53 | """ 54 | ) 55 | } 56 | @Test func structDidDecodeHookIncludedWhenPresent() throws { 57 | assertMacro( 58 | """ 59 | @Decodable 60 | public struct User { 61 | let id: UUID 62 | let name: String 63 | var age: Int 64 | 65 | @CodableHook(.didDecode) 66 | mutating func post() throws { age += 1 } 67 | } 68 | """, 69 | expandedSource: """ 70 | public struct User { 71 | let id: UUID 72 | let name: String 73 | var age: Int 74 | 75 | @CodableHook(.didDecode) 76 | mutating func post() throws { age += 1 } 77 | } 78 | 79 | extension User: Decodable { 80 | enum CodingKeys: String, CodingKey { 81 | case id 82 | case name 83 | case age 84 | } 85 | 86 | public init(from decoder: any Decoder) throws { 87 | let container = try decoder.container(keyedBy: CodingKeys.self) 88 | id = try container.decode(UUID.self, forKey: .id) 89 | name = try container.decode(String.self, forKey: .name) 90 | age = try container.decode(Int.self, forKey: .age) 91 | try post() 92 | } 93 | } 94 | """ 95 | ) 96 | } 97 | 98 | @Test func staticWillDecodeHookWithoutParamsIncluded() throws { 99 | assertMacro( 100 | """ 101 | @Decodable 102 | public struct User { 103 | let id: UUID 104 | let name: String 105 | let age: Int 106 | 107 | @CodableHook(.willDecode) 108 | static func pre() throws {} 109 | } 110 | """, 111 | expandedSource: """ 112 | public struct User { 113 | let id: UUID 114 | let name: String 115 | let age: Int 116 | 117 | @CodableHook(.willDecode) 118 | static func pre() throws {} 119 | } 120 | 121 | extension User: Decodable { 122 | enum CodingKeys: String, CodingKey { 123 | case id 124 | case name 125 | case age 126 | } 127 | 128 | public init(from decoder: any Decoder) throws { 129 | try Self.pre() 130 | let container = try decoder.container(keyedBy: CodingKeys.self) 131 | id = try container.decode(UUID.self, forKey: .id) 132 | name = try container.decode(String.self, forKey: .name) 133 | age = try container.decode(Int.self, forKey: .age) 134 | } 135 | } 136 | """ 137 | ) 138 | } 139 | 140 | @Test func conventionalDidDecodeWithoutAnnotationParameterless() throws { 141 | assertMacro( 142 | """ 143 | @Decodable 144 | public struct User { 145 | let id: UUID 146 | let name: String 147 | let age: Int 148 | 149 | mutating func didDecode() throws {} 150 | } 151 | """, 152 | expandedSource: """ 153 | public struct User { 154 | let id: UUID 155 | let name: String 156 | let age: Int 157 | 158 | mutating func didDecode() throws {} 159 | } 160 | 161 | extension User: Decodable { 162 | enum CodingKeys: String, CodingKey { 163 | case id 164 | case name 165 | case age 166 | } 167 | 168 | public init(from decoder: any Decoder) throws { 169 | let container = try decoder.container(keyedBy: CodingKeys.self) 170 | id = try container.decode(UUID.self, forKey: .id) 171 | name = try container.decode(String.self, forKey: .name) 172 | age = try container.decode(Int.self, forKey: .age) 173 | try didDecode() 174 | } 175 | } 176 | """ 177 | ) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/TypeInferenceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypeInferenceTests.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/5. 6 | // 7 | 8 | import CodableKitMacros 9 | import SwiftSyntaxMacrosTestSupport 10 | import Testing 11 | 12 | @Suite("Type Inference Tests") 13 | struct TypeInferenceTests { 14 | @Test("Type Inference for literals") 15 | func testTypeInferenceForLiterals() async throws { 16 | assertMacro( 17 | """ 18 | @Codable 19 | public struct User { 20 | var strLiteral = "Hello" 21 | var intLiteral = 123 22 | var doubleLiteral = 123.456 23 | var boolLiteral = true 24 | var negativeIntLiteral = -123 25 | var negativeDoubleLiteral = -123.456 26 | } 27 | """, 28 | expandedSource: 29 | """ 30 | public struct User { 31 | var strLiteral = "Hello" 32 | var intLiteral = 123 33 | var doubleLiteral = 123.456 34 | var boolLiteral = true 35 | var negativeIntLiteral = -123 36 | var negativeDoubleLiteral = -123.456 37 | 38 | public func encode(to encoder: any Encoder) throws { 39 | var container = encoder.container(keyedBy: CodingKeys.self) 40 | try container.encode(strLiteral, forKey: .strLiteral) 41 | try container.encode(intLiteral, forKey: .intLiteral) 42 | try container.encode(doubleLiteral, forKey: .doubleLiteral) 43 | try container.encode(boolLiteral, forKey: .boolLiteral) 44 | try container.encode(negativeIntLiteral, forKey: .negativeIntLiteral) 45 | try container.encode(negativeDoubleLiteral, forKey: .negativeDoubleLiteral) 46 | } 47 | } 48 | 49 | extension User: Codable { 50 | enum CodingKeys: String, CodingKey { 51 | case strLiteral 52 | case intLiteral 53 | case doubleLiteral 54 | case boolLiteral 55 | case negativeIntLiteral 56 | case negativeDoubleLiteral 57 | } 58 | 59 | public init(from decoder: any Decoder) throws { 60 | let container = try decoder.container(keyedBy: CodingKeys.self) 61 | strLiteral = try container.decodeIfPresent(String.self, forKey: .strLiteral) ?? "Hello" 62 | intLiteral = try container.decodeIfPresent(Int.self, forKey: .intLiteral) ?? 123 63 | doubleLiteral = try container.decodeIfPresent(Double.self, forKey: .doubleLiteral) ?? 123.456 64 | boolLiteral = try container.decodeIfPresent(Bool.self, forKey: .boolLiteral) ?? true 65 | negativeIntLiteral = try container.decodeIfPresent(Int.self, forKey: .negativeIntLiteral) ?? -123 66 | negativeDoubleLiteral = try container.decodeIfPresent(Double.self, forKey: .negativeDoubleLiteral) ?? -123.456 67 | } 68 | } 69 | """ 70 | ) 71 | } 72 | 73 | @Test("Type Inference for arrays") 74 | func testTypeInferenceForArrays() async throws { 75 | assertMacro( 76 | """ 77 | @Codable 78 | public struct User { 79 | var arrayLiteral = [1, 2, 3] 80 | var stringArrayLiteral = ["Hello", "World"] 81 | var boolArrayLiteral = [true, false] 82 | var doubleArrayLiteral = [1.0, 2.0, 3.0] 83 | var numberArrayLiteral = [1, 2.0, 3] 84 | } 85 | """, 86 | expandedSource: 87 | """ 88 | public struct User { 89 | var arrayLiteral = [1, 2, 3] 90 | var stringArrayLiteral = ["Hello", "World"] 91 | var boolArrayLiteral = [true, false] 92 | var doubleArrayLiteral = [1.0, 2.0, 3.0] 93 | var numberArrayLiteral = [1, 2.0, 3] 94 | 95 | public func encode(to encoder: any Encoder) throws { 96 | var container = encoder.container(keyedBy: CodingKeys.self) 97 | try container.encode(arrayLiteral, forKey: .arrayLiteral) 98 | try container.encode(stringArrayLiteral, forKey: .stringArrayLiteral) 99 | try container.encode(boolArrayLiteral, forKey: .boolArrayLiteral) 100 | try container.encode(doubleArrayLiteral, forKey: .doubleArrayLiteral) 101 | try container.encode(numberArrayLiteral, forKey: .numberArrayLiteral) 102 | } 103 | } 104 | 105 | extension User: Codable { 106 | enum CodingKeys: String, CodingKey { 107 | case arrayLiteral 108 | case stringArrayLiteral 109 | case boolArrayLiteral 110 | case doubleArrayLiteral 111 | case numberArrayLiteral 112 | } 113 | 114 | public init(from decoder: any Decoder) throws { 115 | let container = try decoder.container(keyedBy: CodingKeys.self) 116 | arrayLiteral = try container.decodeIfPresent([Int].self, forKey: .arrayLiteral) ?? [1, 2, 3] 117 | stringArrayLiteral = try container.decodeIfPresent([String].self, forKey: .stringArrayLiteral) ?? ["Hello", "World"] 118 | boolArrayLiteral = try container.decodeIfPresent([Bool].self, forKey: .boolArrayLiteral) ?? [true, false] 119 | doubleArrayLiteral = try container.decodeIfPresent([Double].self, forKey: .doubleArrayLiteral) ?? [1.0, 2.0, 3.0] 120 | numberArrayLiteral = try container.decodeIfPresent([Double].self, forKey: .numberArrayLiteral) ?? [1, 2.0, 3] 121 | } 122 | } 123 | """ 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/CodableKitMacros/NamespaceNode+Encode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NamespaceNode+Encode.swift 3 | // CodableKit 4 | // 5 | // Extracted encode generation from NamespaceNode 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | 11 | extension NamespaceNode { 12 | @ArrayBuilder var encodeContainersAssignment: [CodeBlockItemSyntax] { 13 | if parent == nil { 14 | "var container = encoder.container(keyedBy: \(raw: enumName).self)" 15 | if hasTranscodeRawStringInSubtree { 16 | "let __ckEncoder = JSONEncoder()" 17 | } 18 | } 19 | for child in children.values.sorted(by: { $0.segment < $1.segment }) { 20 | "var \(raw: child.containerName) = \(raw: containerName).nestedContainer(keyedBy: \(raw: child.enumName).self, forKey: .\(raw: child.segment))" 21 | } 22 | } 23 | } 24 | 25 | extension NamespaceNode { 26 | fileprivate func containerEncodeExpr(property: CodableProperty) -> CodeBlockItemSyntax { 27 | let encodeFuncName = property.isOptional && !property.options.contains(.explicitNil) ? "encodeIfPresent" : "encode" 28 | let chainingMembers = CodeGenCore.genChainingMembers("\(property.name)") 29 | return "try \(raw: containerName).\(raw: encodeFuncName)(\(property.name), forKey: \(chainingMembers))" 30 | } 31 | 32 | private var propertyEncodeAssignment: [CodeBlockItemSyntax] { 33 | var result: [CodeBlockItemSyntax] = [] 34 | 35 | result.appendContentsOf { 36 | // Transformer-based encoding (before normal path and before transcodeRawString) 37 | for property in properties where property.transformerExpr != nil && !property.options.contains(.ignored) { 38 | if property.isOptional { 39 | "try \(type.__ckEncodeTransformedIfPresent)(transformer: \(property.transformerExpr!), value: \(property.name), into: &\(raw: containerName), forKey: \(CodeGenCore.genChainingMembers("\(property.name)")), explicitNil: \(raw: property.options.contains(.explicitNil) ? "true" : "false"))" 40 | } else { 41 | "try \(type.__ckEncodeTransformed)(transformer: \(property.transformerExpr!), value: \(property.name), into: &\(raw: containerName), forKey: \(CodeGenCore.genChainingMembers("\(property.name)")))" 42 | } 43 | } 44 | 45 | for property in properties where property.isNormal && !property.options.contains(.ignored) { 46 | containerEncodeExpr(property: property) 47 | } 48 | 49 | // Encode lossy properties normally (lossy is decode-only). Skip when also using transcodeRawString. 50 | for property in properties 51 | where property.options.contains(.lossy) 52 | && !property.options.contains(.transcodeRawString) 53 | && !property.options.contains(.ignored) 54 | { 55 | containerEncodeExpr(property: property) 56 | } 57 | } 58 | 59 | // Encode as raw JSON string (transcoding). For optionals without `.explicitNil`, omit the key when nil. 60 | for property in properties 61 | where property.options.contains(.transcodeRawString) && !property.options.contains(.ignored) { 62 | if property.isOptional && !property.options.contains(.explicitNil) { 63 | // if let Unwrapped = { ... encode ... } 64 | let unwrappedName: PatternSyntax = "\(property.name)Unwrapped" 65 | result.append( 66 | CodeBlockItemSyntax( 67 | item: .expr( 68 | ExprSyntax( 69 | IfExprSyntax( 70 | conditions: [ 71 | ConditionElementSyntax( 72 | condition: .expression("let \(unwrappedName) = \(property.name)"), 73 | trailingTrivia: .spaces(1) 74 | ) 75 | ], 76 | body: CodeBlockSyntax { 77 | "let \(raw: property.rawDataName) = try \(raw: hasTranscodeRawStringInSubtree ? "__ckEncoder" : "JSONEncoder()").encode(\(raw: unwrappedName))" 78 | CodeBlockItemSyntax( 79 | item: .expr( 80 | CodeGenCore.genEncodeRawDataHandleExpr( 81 | property: property, 82 | containerName: containerName, 83 | codingPath: codingKeyChain(for: property), 84 | message: "Failed to transcode raw data to string" 85 | ) 86 | ) 87 | ) 88 | } 89 | ) 90 | ) 91 | ) 92 | ) 93 | ) 94 | } else { 95 | // Non-optional or `.explicitNil` option: encode current value, allowing explicit nil as string 96 | result.appendContentsOf { 97 | "let \(raw: property.rawDataName) = try \(raw: hasTranscodeRawStringInSubtree ? "__ckEncoder" : "JSONEncoder()").encode(\(raw: property.name))" 98 | CodeBlockItemSyntax( 99 | item: .expr( 100 | CodeGenCore.genEncodeRawDataHandleExpr( 101 | property: property, 102 | containerName: containerName, 103 | codingPath: codingKeyChain(for: property), 104 | message: "Failed to transcode raw data to string" 105 | ) 106 | ) 107 | ) 108 | } 109 | } 110 | } 111 | 112 | return result 113 | } 114 | 115 | var encodeBlockItem: [CodeBlockItemSyntax] { 116 | var result: [CodeBlockItemSyntax] = [] 117 | 118 | result.append(contentsOf: encodeContainersAssignment) 119 | result.append(contentsOf: propertyEncodeAssignment) 120 | for child in children.values.sorted(by: { $0.segment < $1.segment }) { 121 | result.append(contentsOf: child.encodeBlockItem) 122 | } 123 | 124 | return result 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/TransformerTests/CodingTransformerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingTransformerTests.swift 3 | // CodableKitTests 4 | // 5 | // Created by Assistant on 2025/9/6. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodingTransformerMacroTests { 15 | @Test func transformer_nonOptional_roundTrip() throws { 16 | assertMacro( 17 | """ 18 | struct IntFromString: BidirectionalCodingTransformer { 19 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 20 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 21 | } 22 | @Codable 23 | public struct Model { 24 | @CodableKey(transformer: IntFromString()) 25 | var count: Int 26 | } 27 | """, 28 | expandedSource: """ 29 | struct IntFromString: BidirectionalCodingTransformer { 30 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 31 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 32 | } 33 | public struct Model { 34 | var count: Int 35 | 36 | public func encode(to encoder: any Encoder) throws { 37 | var container = encoder.container(keyedBy: CodingKeys.self) 38 | try __ckEncodeTransformed(transformer: IntFromString(), value: count, into: &container, forKey: .count) 39 | } 40 | } 41 | 42 | extension Model: Codable { 43 | enum CodingKeys: String, CodingKey { 44 | case count 45 | } 46 | 47 | public init(from decoder: any Decoder) throws { 48 | let container = try decoder.container(keyedBy: CodingKeys.self) 49 | count = try __ckDecodeTransformed(transformer: IntFromString(), from: container, forKey: .count, useDefaultOnFailure: false) 50 | } 51 | } 52 | """ 53 | ) 54 | } 55 | 56 | @Test func transformer_optional_encodeIfPresent() throws { 57 | assertMacro( 58 | """ 59 | struct IntFromString: BidirectionalCodingTransformer { 60 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 61 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 62 | } 63 | @Codable 64 | public struct Model { 65 | @CodableKey(transformer: IntFromString()) 66 | var count: Int? 67 | } 68 | """, 69 | expandedSource: """ 70 | struct IntFromString: BidirectionalCodingTransformer { 71 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 72 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 73 | } 74 | public struct Model { 75 | var count: Int? 76 | 77 | public func encode(to encoder: any Encoder) throws { 78 | var container = encoder.container(keyedBy: CodingKeys.self) 79 | try __ckEncodeTransformedIfPresent(transformer: IntFromString(), value: count, into: &container, forKey: .count, explicitNil: false) 80 | } 81 | } 82 | 83 | extension Model: Codable { 84 | enum CodingKeys: String, CodingKey { 85 | case count 86 | } 87 | 88 | public init(from decoder: any Decoder) throws { 89 | let container = try decoder.container(keyedBy: CodingKeys.self) 90 | count = try __ckDecodeTransformedIfPresent(transformer: IntFromString(), from: container, forKey: .count, useDefaultOnFailure: false) 91 | } 92 | } 93 | """ 94 | ) 95 | } 96 | 97 | @Test func transformer_nonOptional_withDefault_and_useDefaultOnFailure() throws { 98 | assertMacro( 99 | """ 100 | struct IntFromString: BidirectionalCodingTransformer { 101 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 102 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 103 | } 104 | @Codable 105 | public struct Model { 106 | @CodableKey(options: .useDefaultOnFailure, transformer: IntFromString()) 107 | var count: Int = 42 108 | } 109 | """, 110 | expandedSource: """ 111 | struct IntFromString: BidirectionalCodingTransformer { 112 | func transform(_ input: Result) -> Result { input.map { Int($0) ?? 0 } } 113 | func reverseTransform(_ input: Result) -> Result { input.map(String.init) } 114 | } 115 | public struct Model { 116 | var count: Int = 42 117 | 118 | public func encode(to encoder: any Encoder) throws { 119 | var container = encoder.container(keyedBy: CodingKeys.self) 120 | try __ckEncodeTransformed(transformer: IntFromString(), value: count, into: &container, forKey: .count) 121 | } 122 | } 123 | 124 | extension Model: Codable { 125 | enum CodingKeys: String, CodingKey { 126 | case count 127 | } 128 | 129 | public init(from decoder: any Decoder) throws { 130 | let container = try decoder.container(keyedBy: CodingKeys.self) 131 | count = (try __ckDecodeTransformedIfPresent(transformer: IntFromString(), from: container, forKey: .count, useDefaultOnFailure: true, defaultValue: 42)) ?? 42 132 | } 133 | } 134 | """ 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+lossy.data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+lossy.data.swift 3 | // CodableKitTests 4 | // 5 | // Runtime behavior tests for lossy decoding 6 | // 7 | 8 | import CodableKit 9 | import Foundation 10 | import Testing 11 | 12 | // Top-level test models (local types cannot have attached macros) 13 | struct LossyItem: Codable, Equatable { let id: Int } 14 | 15 | @Codable 16 | struct LossyArrayModel { 17 | @CodableKey(options: .lossy) 18 | var items: [LossyItem] 19 | } 20 | 21 | @Codable 22 | struct LossyOptionalSetModel { 23 | @CodableKey(options: .lossy) 24 | var tags: Set? 25 | } 26 | 27 | @Codable 28 | struct LossyDefaultModel { 29 | @CodableKey(options: [.lossy, .useDefaultOnFailure]) 30 | var items: [LossyItem] = [LossyItem(id: 7)] 31 | } 32 | 33 | @Codable 34 | struct LossyTranscodeModel { 35 | @CodableKey(options: [.lossy, .transcodeRawString]) 36 | var values: [Int] 37 | } 38 | 39 | @Codable 40 | struct LossySafeTranscodeModel { 41 | @CodableKey(options: [.lossy, .safeTranscodeRawString]) 42 | var values: [Int] = [1, 2] 43 | } 44 | 45 | @Codable 46 | struct LossyDictStringIntModel { 47 | @CodableKey(options: .lossy) 48 | var map: [String: Int] 49 | } 50 | 51 | @Codable 52 | struct LossyDictIntDoubleModel { 53 | @CodableKey(options: .lossy) 54 | var map: [Int: Double] 55 | } 56 | 57 | @Codable 58 | struct LossyOptionalDictModel { 59 | @CodableKey(options: .lossy) 60 | var scores: [String: Int]? 61 | } 62 | 63 | @Codable 64 | struct LossyDictDefaultModel { 65 | @CodableKey(options: [.lossy, .useDefaultOnFailure]) 66 | var map: [String: Int] = ["a": 1] 67 | } 68 | 69 | @Codable 70 | struct LossyDictTranscodeModel { 71 | @CodableKey(options: [.lossy, .transcodeRawString]) 72 | var map: [String: Int] 73 | } 74 | 75 | @Codable 76 | struct LossyDictSafeTranscodeModel { 77 | @CodableKey(options: [.lossy, .safeTranscodeRawString]) 78 | var map: [String: Int] = [:] 79 | } 80 | 81 | @Suite struct LossyRuntimeTests { 82 | @Test func lossyArray_dropsInvalidElements() throws { 83 | let json = #"{"items":[{"id":1},{"id":"oops"},{"id":3},{"bad":true},4,{"id":5}] }"# 84 | let data = json.data(using: .utf8)! 85 | let decoded = try JSONDecoder().decode(LossyArrayModel.self, from: data) 86 | #expect(decoded.items == [LossyItem(id: 1), LossyItem(id: 3), LossyItem(id: 5)]) 87 | } 88 | 89 | @Test func lossySet_optional_missingKey_isNil() throws { 90 | let json = #"{}"# 91 | let data = json.data(using: .utf8)! 92 | let decoded = try JSONDecoder().decode(LossyOptionalSetModel.self, from: data) 93 | #expect(decoded.tags == nil) 94 | } 95 | 96 | @Test func lossyArray_withDefault_and_useDefaultOnFailure() throws { 97 | // Non-array type for items → falls back to default due to .useDefaultOnFailure 98 | let json = #"{"items": 123}"# 99 | let data = json.data(using: .utf8)! 100 | let decoded = try JSONDecoder().decode(LossyDefaultModel.self, from: data) 101 | #expect(decoded.items == [LossyItem(id: 7)]) 102 | } 103 | 104 | @Test func lossy_transcodeRawString_combined() throws { 105 | // values is a JSON string with mixed valid/invalid entries 106 | let embedded = #"[{\"id\":1},2,3,\"oops\",4]"# 107 | let payload = #"{"values":"\#(embedded)"}"# 108 | let data = payload.data(using: .utf8)! 109 | 110 | // Note: Our lossy path expects element to be Int; non-ints will be dropped 111 | let decoded = try JSONDecoder().decode(LossyTranscodeModel.self, from: data) 112 | #expect(decoded.values == [2, 3, 4]) 113 | } 114 | 115 | @Test func lossy_safeTranscodeRawString_withDefault() throws { 116 | // Missing/invalid string should fall back to default 117 | let payload = #"{"values": null}"# 118 | let data = payload.data(using: .utf8)! 119 | let decoded = try JSONDecoder().decode(LossySafeTranscodeModel.self, from: data) 120 | #expect(decoded.values == [1, 2]) 121 | } 122 | 123 | @Test func lossyDict_dropsInvalidEntries() throws { 124 | // Mixed types inside the object; invalid entries dropped 125 | let json = #"{"map":{"a":1,"b":"oops","c":3,"d":true,"e":4}}"# 126 | let data = json.data(using: .utf8)! 127 | let decoded = try JSONDecoder().decode(LossyDictStringIntModel.self, from: data) 128 | #expect(decoded.map == ["a": 1, "c": 3, "e": 4]) 129 | } 130 | 131 | @Test func lossyDict_dropsNonConvertibleKeys_andInvalidValues() throws { 132 | // Keys must be LosslessStringConvertible (Int). "two" cannot convert and is dropped. 133 | // Also drops value that cannot decode as Double 134 | let json = #"{"map":{"1":0.5,"two":2.5,"3":"bad","4":4}}"# 135 | let data = json.data(using: .utf8)! 136 | let decoded = try JSONDecoder().decode(LossyDictIntDoubleModel.self, from: data) 137 | #expect(decoded.map == [1: 0.5, 4: 4.0]) 138 | } 139 | 140 | @Test func lossyDict_optional_missingKey_isNil() throws { 141 | let json = #"{}"# 142 | let data = json.data(using: .utf8)! 143 | let decoded = try JSONDecoder().decode(LossyOptionalDictModel.self, from: data) 144 | #expect(decoded.scores == nil) 145 | } 146 | 147 | @Test func lossyDict_withDefault_and_useDefaultOnFailure() throws { 148 | // Non-dictionary value → fallback to default due to .useDefaultOnFailure 149 | let json = #"{"map": 123}"# 150 | let data = json.data(using: .utf8)! 151 | let decoded = try JSONDecoder().decode(LossyDictDefaultModel.self, from: data) 152 | #expect(decoded.map == ["a": 1]) 153 | } 154 | 155 | @Test func lossyDict_transcodeRawString_combined() throws { 156 | // Dictionary encoded as a JSON string with some invalid entries 157 | let embedded = #"{\"a\":1,\"b\":\"oops\",\"c\":3}"# 158 | let payload = #"{"map":"\#(embedded)"}"# 159 | let data = payload.data(using: .utf8)! 160 | let decoded = try JSONDecoder().decode(LossyDictTranscodeModel.self, from: data) 161 | #expect(decoded.map == ["a": 1, "c": 3]) 162 | } 163 | 164 | @Test func lossyDict_safeTranscodeRawString_withDefault() throws { 165 | let payload = #"{"map": null}"# 166 | let data = payload.data(using: .utf8)! 167 | let decoded = try JSONDecoder().decode(LossyDictSafeTranscodeModel.self, from: data) 168 | #expect(decoded.map == [:]) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/CodableMacroTests+lossy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+lossy.swift 3 | // CodableKitTests 4 | // 5 | // Created by Assistant on 2025/9/5. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitLossyEncodeTests { 15 | @Test func lossyArray_nonOptional_noDefault() throws { 16 | assertMacro( 17 | """ 18 | @Encodable 19 | public struct Model { 20 | @CodableKey(options: .lossy) 21 | let values: [Int] 22 | @CodableKey(options: .lossy) 23 | let values2: Array 24 | } 25 | """, 26 | expandedSource: """ 27 | public struct Model { 28 | let values: [Int] 29 | let values2: Array 30 | 31 | public func encode(to encoder: any Encoder) throws { 32 | var container = encoder.container(keyedBy: CodingKeys.self) 33 | try container.encode(values, forKey: .values) 34 | try container.encode(values2, forKey: .values2) 35 | } 36 | } 37 | 38 | extension Model: Encodable { 39 | enum CodingKeys: String, CodingKey { 40 | case values 41 | case values2 42 | } 43 | } 44 | """ 45 | ) 46 | } 47 | 48 | @Test func lossyArray_optional() throws { 49 | assertMacro( 50 | """ 51 | @Encodable 52 | public struct Model { 53 | @CodableKey(options: .lossy) 54 | let values: [String]? 55 | } 56 | """, 57 | expandedSource: """ 58 | public struct Model { 59 | let values: [String]? 60 | 61 | public func encode(to encoder: any Encoder) throws { 62 | var container = encoder.container(keyedBy: CodingKeys.self) 63 | try container.encodeIfPresent(values, forKey: .values) 64 | } 65 | } 66 | 67 | extension Model: Encodable { 68 | enum CodingKeys: String, CodingKey { 69 | case values 70 | } 71 | } 72 | """ 73 | ) 74 | } 75 | 76 | @Test func lossyArray_nonOptional_withDefault_and_useDefaultOnFailure() throws { 77 | assertMacro( 78 | """ 79 | @Encodable 80 | public struct Model { 81 | @CodableKey(options: [.lossy, .useDefaultOnFailure]) 82 | var values: [Int] = [1, 2] 83 | } 84 | """, 85 | expandedSource: """ 86 | public struct Model { 87 | var values: [Int] = [1, 2] 88 | 89 | public func encode(to encoder: any Encoder) throws { 90 | var container = encoder.container(keyedBy: CodingKeys.self) 91 | try container.encode(values, forKey: .values) 92 | } 93 | } 94 | 95 | extension Model: Encodable { 96 | enum CodingKeys: String, CodingKey { 97 | case values 98 | } 99 | } 100 | """ 101 | ) 102 | } 103 | 104 | @Test func lossySet_optional() throws { 105 | assertMacro( 106 | """ 107 | @Encodable 108 | public struct Model { 109 | @CodableKey(options: .lossy) 110 | let ids: Set? 111 | } 112 | """, 113 | expandedSource: """ 114 | public struct Model { 115 | let ids: Set? 116 | 117 | public func encode(to encoder: any Encoder) throws { 118 | var container = encoder.container(keyedBy: CodingKeys.self) 119 | try container.encodeIfPresent(ids, forKey: .ids) 120 | } 121 | } 122 | 123 | extension Model: Encodable { 124 | enum CodingKeys: String, CodingKey { 125 | case ids 126 | } 127 | } 128 | """ 129 | ) 130 | } 131 | 132 | @Test func lossy_combined_with_transcodeRawString() throws { 133 | assertMacro( 134 | """ 135 | @Encodable 136 | public struct Model { 137 | @CodableKey(options: [.lossy, .transcodeRawString]) 138 | let values: [Int] 139 | } 140 | """, 141 | expandedSource: """ 142 | public struct Model { 143 | let values: [Int] 144 | 145 | public func encode(to encoder: any Encoder) throws { 146 | var container = encoder.container(keyedBy: CodingKeys.self) 147 | let __ckEncoder = JSONEncoder() 148 | let valuesRawData = try __ckEncoder.encode(values) 149 | if let valuesRawString = String(data: valuesRawData, encoding: .utf8) { 150 | try container.encode(valuesRawString, forKey: .values) 151 | } else { 152 | throw EncodingError.invalidValue( 153 | valuesRawData, 154 | EncodingError.Context( 155 | codingPath: [CodingKeys.values], 156 | debugDescription: "Failed to transcode raw data to string" 157 | ) 158 | ) 159 | } 160 | } 161 | } 162 | 163 | extension Model: Encodable { 164 | enum CodingKeys: String, CodingKey { 165 | case values 166 | } 167 | } 168 | """ 169 | ) 170 | } 171 | 172 | @Test func lossy_combined_with_safeTranscodeRawString() throws { 173 | assertMacro( 174 | """ 175 | @Encodable 176 | public struct Model { 177 | @CodableKey(options: [.lossy, .safeTranscodeRawString]) 178 | var values: [Int] = [1, 2] 179 | } 180 | """, 181 | expandedSource: """ 182 | public struct Model { 183 | var values: [Int] = [1, 2] 184 | 185 | public func encode(to encoder: any Encoder) throws { 186 | var container = encoder.container(keyedBy: CodingKeys.self) 187 | let __ckEncoder = JSONEncoder() 188 | let valuesRawData = try __ckEncoder.encode(values) 189 | if let valuesRawString = String(data: valuesRawData, encoding: .utf8) { 190 | try container.encode(valuesRawString, forKey: .values) 191 | } else { 192 | throw EncodingError.invalidValue( 193 | valuesRawData, 194 | EncodingError.Context( 195 | codingPath: [CodingKeys.values], 196 | debugDescription: "Failed to transcode raw data to string" 197 | ) 198 | ) 199 | } 200 | } 201 | } 202 | 203 | extension Model: Encodable { 204 | enum CodingKeys: String, CodingKey { 205 | case values 206 | } 207 | } 208 | """ 209 | ) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /Sources/CodableKit/Transformers/BuiltInTransformers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuiltInTransformers.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/6. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct NothingToTransform: BidirectionalCodingTransformer { 11 | public init() {} 12 | 13 | public func transform(_ input: Result) -> Result { 14 | input 15 | } 16 | } 17 | 18 | /// Decodes a concrete `Value` at a given `CodingKey` from a keyed container. 19 | /// 20 | /// Start a pipeline with `Result.success(())` and use this transformer to fetch 21 | /// the field as the first step when building manual chains. 22 | struct DecodeAtKey: CodingTransformer { 23 | let container: KeyedDecodingContainer 24 | let key: Key 25 | 26 | init(_ container: KeyedDecodingContainer, for key: Key) { 27 | self.container = container 28 | self.key = key 29 | } 30 | 31 | public func transform(_ input: Result) -> Result { 32 | input.flatMap { 33 | Result { 34 | try container.decode(Output.self, forKey: key) 35 | } 36 | } 37 | } 38 | } 39 | 40 | /// Decodes an optional `Value` if present at a given `CodingKey`. 41 | /// 42 | /// Produces `nil` when the key is absent or explicitly `null`. 43 | struct DecodeAtKeyIfPresent: CodingTransformer { 44 | let container: KeyedDecodingContainer 45 | let key: Key 46 | 47 | init(_ container: KeyedDecodingContainer, for key: Key) { 48 | self.container = container 49 | self.key = key 50 | } 51 | 52 | func transform(_ input: Result) -> Result { 53 | input.flatMap { 54 | Result { 55 | try container.decodeIfPresent(Value.self, forKey: key) 56 | } 57 | } 58 | } 59 | } 60 | 61 | /// Pass-through transformer. Useful as a placeholder or for API symmetry. 62 | public struct IdentityTransformer: CodingTransformer { 63 | public typealias Input = Value 64 | public typealias Output = Value 65 | 66 | public init() {} 67 | 68 | public func transform(_ input: Result) -> Result { 69 | input 70 | } 71 | } 72 | 73 | /// Converts any failure into a success with `defaultValue` when provided. 74 | /// 75 | /// The reverse direction is identical to the forward direction for same-type 76 | /// defaults, so only `transform(_)` is implemented. 77 | public struct DefaultOnFailureTransformer: BidirectionalCodingTransformer { 78 | public let defaultValue: Value? 79 | 80 | public init(defaultValue: Value?) { 81 | self.defaultValue = defaultValue 82 | } 83 | 84 | public func transform(_ input: Result) -> Result { 85 | switch input { 86 | case .success(let value): .success(value) 87 | case .failure(let error): 88 | if let defaultValue { 89 | .success(defaultValue) 90 | } else { 91 | .failure(error) 92 | } 93 | } 94 | } 95 | } 96 | 97 | /// Decodes a `Value` from a JSON string that contains serialized JSON. 98 | /// 99 | /// Useful for APIs that nest objects as raw JSON strings. 100 | public struct RawStringDecodingTransformer: CodingTransformer { 101 | public typealias Output = Value 102 | 103 | public let decoder: JSONDecoder 104 | 105 | public init(decoder: JSONDecoder = JSONDecoder()) { 106 | self.decoder = decoder 107 | } 108 | 109 | public func transform(_ input: Result) -> Result { 110 | switch input { 111 | case .success(let string): 112 | Result { 113 | guard let data = string.data(using: .utf8) else { 114 | throw EncodingError.invalidValue(string, .init(codingPath: [], debugDescription: "Invalid UTF-8 string")) 115 | } 116 | return try decoder.decode(Value.self, from: data) 117 | } 118 | case .failure(let error): 119 | .failure(error) 120 | } 121 | } 122 | } 123 | 124 | /// Encodes a `Value` into a JSON string containing its serialized representation. 125 | /// 126 | /// Useful for APIs that expect objects as stringified JSON. 127 | public struct RawStringEncodingTransformer: CodingTransformer { 128 | public let encoder: JSONEncoder 129 | 130 | public init(encoder: JSONEncoder = JSONEncoder()) { 131 | self.encoder = encoder 132 | } 133 | 134 | public func transform(_ input: Result) -> Result { 135 | switch input { 136 | case .success(let value): 137 | Result { 138 | let data = try encoder.encode(value) 139 | if let string = String(data: data, encoding: .utf8) { 140 | return string 141 | } else { 142 | throw EncodingError.invalidValue(data, .init(codingPath: [], debugDescription: "Invalid UTF-8 data")) 143 | } 144 | } 145 | case .failure(let error): 146 | .failure(error) 147 | } 148 | } 149 | } 150 | 151 | /// Bidirectional wrapper combining raw-string encode and decode transforms. 152 | public struct RawStringTransformer: BidirectionalCodingTransformer { 153 | // Forward (transform): String -> Value; Reverse (reverseTransform): Value -> String 154 | private let transformer: Paired, RawStringEncodingTransformer> 155 | 156 | public init(decoder: JSONDecoder = JSONDecoder(), encoder: JSONEncoder = JSONEncoder()) { 157 | self.transformer = Paired( 158 | transformer: RawStringDecodingTransformer(decoder: decoder), 159 | reversedTransformer: RawStringEncodingTransformer(encoder: encoder) 160 | ) 161 | } 162 | 163 | public func transform(_ input: Result) -> Result { 164 | transformer.transform(input) 165 | } 166 | 167 | public func reverseTransform(_ input: Result) -> Result { 168 | transformer.reverseTransform(input) 169 | } 170 | } 171 | 172 | /// Maps `0/1` integers to booleans and back. 173 | public struct IntegerToBooleanTransformer: BidirectionalCodingTransformer { 174 | public typealias Output = Bool 175 | 176 | public init() {} 177 | 178 | public func transform(_ input: Result) -> Result { 179 | input.map { $0 == 1 } 180 | } 181 | 182 | public func reverseTransform(_ input: Result) -> Result { 183 | input.map { $0 ? 1 : 0 } 184 | } 185 | } 186 | 187 | /// Projects a property via key path from an input value. 188 | /// 189 | /// Useful to extract a sub-value before applying subsequent transforms. 190 | public struct KeyPathTransformer: CodingTransformer { 191 | public typealias Input = T 192 | public typealias Output = U 193 | 194 | let keyPath: KeyPath 195 | 196 | public init(keyPath: KeyPath) { 197 | self.keyPath = keyPath 198 | } 199 | 200 | public func transform(_ input: Result) -> Result { 201 | input.map { $0[keyPath: keyPath] } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /Tests/EncodableKitTests/CodableMacroTests+diagnostics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+Diagnostics.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/27 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import SwiftSyntax 10 | import SwiftSyntaxBuilder 11 | import SwiftSyntaxMacros 12 | import SwiftSyntaxMacrosTestSupport 13 | import Testing 14 | 15 | @Suite struct CodableKitDiagnosticsTests { 16 | @Test func macroWithNoTypeAnnotation() throws { 17 | assertMacro( 18 | """ 19 | @Encodable 20 | public struct User { 21 | let id: UUID 22 | let name: String 23 | var age = genSomeThing() 24 | } 25 | """, 26 | expandedSource: """ 27 | public struct User { 28 | let id: UUID 29 | let name: String 30 | var age = genSomeThing() 31 | } 32 | """, 33 | diagnostics: [ 34 | .init(message: "Properties must have a type annotation", line: 1, column: 1) 35 | ] 36 | ) 37 | } 38 | 39 | @Test func macroWithIgnoredPropertyTypeAnnotation() throws { 40 | 41 | assertMacro( 42 | """ 43 | @Encodable 44 | public struct User { 45 | let id: UUID 46 | let name: String 47 | let age: Int 48 | @CodableKey(options: .ignored) 49 | var ignored: String = "Hello World" 50 | } 51 | """, 52 | expandedSource: """ 53 | public struct User { 54 | let id: UUID 55 | let name: String 56 | let age: Int 57 | var ignored: String = "Hello World" 58 | 59 | public func encode(to encoder: any Encoder) throws { 60 | var container = encoder.container(keyedBy: CodingKeys.self) 61 | try container.encode(id, forKey: .id) 62 | try container.encode(name, forKey: .name) 63 | try container.encode(age, forKey: .age) 64 | } 65 | } 66 | 67 | extension User: Encodable { 68 | enum CodingKeys: String, CodingKey { 69 | case id 70 | case name 71 | case age 72 | } 73 | } 74 | """ 75 | ) 76 | 77 | } 78 | 79 | @Test func macroWithStaticTypeAnnotation() throws { 80 | 81 | assertMacro( 82 | """ 83 | @Encodable 84 | public struct User { 85 | let id: UUID 86 | let name: String 87 | let age: Int 88 | 89 | static let staticProperty = "Hello World" 90 | } 91 | """, 92 | expandedSource: """ 93 | public struct User { 94 | let id: UUID 95 | let name: String 96 | let age: Int 97 | 98 | static let staticProperty = "Hello World" 99 | 100 | public func encode(to encoder: any Encoder) throws { 101 | var container = encoder.container(keyedBy: CodingKeys.self) 102 | try container.encode(id, forKey: .id) 103 | try container.encode(name, forKey: .name) 104 | try container.encode(age, forKey: .age) 105 | } 106 | } 107 | 108 | extension User: Encodable { 109 | enum CodingKeys: String, CodingKey { 110 | case id 111 | case name 112 | case age 113 | } 114 | } 115 | """ 116 | ) 117 | 118 | } 119 | 120 | @Test func macroOnComputeProperty() throws { 121 | 122 | assertMacro( 123 | """ 124 | @Encodable 125 | public struct User { 126 | let id: UUID 127 | let name: String 128 | var age: Int = 24 129 | @CodableKey("hello") 130 | var address: String { 131 | "A" 132 | } 133 | } 134 | """, 135 | expandedSource: """ 136 | public struct User { 137 | let id: UUID 138 | let name: String 139 | var age: Int = 24 140 | var address: String { 141 | "A" 142 | } 143 | 144 | public func encode(to encoder: any Encoder) throws { 145 | var container = encoder.container(keyedBy: CodingKeys.self) 146 | try container.encode(id, forKey: .id) 147 | try container.encode(name, forKey: .name) 148 | try container.encode(age, forKey: .age) 149 | } 150 | } 151 | 152 | extension User: Encodable { 153 | enum CodingKeys: String, CodingKey { 154 | case id 155 | case name 156 | case age 157 | } 158 | } 159 | """, 160 | diagnostics: [ 161 | .init(message: "Only variable declarations with no accessor block are supported", line: 6, column: 3) 162 | ] 163 | ) 164 | 165 | } 166 | 167 | @Test func macroOnStaticComputeProperty() throws { 168 | 169 | assertMacro( 170 | """ 171 | @Encodable 172 | public struct User { 173 | let id: UUID 174 | let name: String 175 | var age: Int = 24 176 | @CodableKey("hello") 177 | static var address: String { 178 | "A" 179 | } 180 | } 181 | """, 182 | expandedSource: """ 183 | public struct User { 184 | let id: UUID 185 | let name: String 186 | var age: Int = 24 187 | static var address: String { 188 | "A" 189 | } 190 | 191 | public func encode(to encoder: any Encoder) throws { 192 | var container = encoder.container(keyedBy: CodingKeys.self) 193 | try container.encode(id, forKey: .id) 194 | try container.encode(name, forKey: .name) 195 | try container.encode(age, forKey: .age) 196 | } 197 | } 198 | 199 | extension User: Encodable { 200 | enum CodingKeys: String, CodingKey { 201 | case id 202 | case name 203 | case age 204 | } 205 | } 206 | """, 207 | diagnostics: [ 208 | .init(message: "Only variable declarations with no accessor block are supported", line: 6, column: 3) 209 | ] 210 | ) 211 | 212 | } 213 | 214 | @Test func macroOnStaticProperty() throws { 215 | 216 | assertMacro( 217 | """ 218 | @Encodable 219 | public struct User { 220 | let id: UUID 221 | let name: String 222 | var age: Int = 24 223 | @CodableKey("hello") 224 | static var address: String = "A" 225 | } 226 | """, 227 | expandedSource: """ 228 | public struct User { 229 | let id: UUID 230 | let name: String 231 | var age: Int = 24 232 | static var address: String = "A" 233 | 234 | public func encode(to encoder: any Encoder) throws { 235 | var container = encoder.container(keyedBy: CodingKeys.self) 236 | try container.encode(id, forKey: .id) 237 | try container.encode(name, forKey: .name) 238 | try container.encode(age, forKey: .age) 239 | } 240 | } 241 | 242 | extension User: Encodable { 243 | enum CodingKeys: String, CodingKey { 244 | case id 245 | case name 246 | case age 247 | } 248 | } 249 | """, 250 | diagnostics: [ 251 | .init(message: "Only non-static variable declarations are supported", line: 6, column: 3) 252 | ] 253 | ) 254 | 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /Tests/DecodableKitTests/CodableMacroTests+diagnostics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // CodableMacroTests+Diagnostics.swift 4 | // CodableKit 5 | // 6 | // Created by WendellXY on 2024/5/27 7 | // Copyright © 2024 WendellXY. All rights reserved. 8 | // 9 | 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SwiftSyntaxMacros 13 | import SwiftSyntaxMacrosTestSupport 14 | import Testing 15 | 16 | @Suite struct CodableKitDiagnosticsTests { 17 | @Test func macroWithNoTypeAnnotation() throws { 18 | assertMacro( 19 | """ 20 | @Decodable 21 | public struct User { 22 | let id: UUID 23 | let name: String 24 | var age = genSomeThing() 25 | } 26 | """, 27 | expandedSource: """ 28 | public struct User { 29 | let id: UUID 30 | let name: String 31 | var age = genSomeThing() 32 | } 33 | """, 34 | diagnostics: [ 35 | .init(message: "Properties must have a type annotation", line: 1, column: 1) 36 | ] 37 | ) 38 | } 39 | 40 | @Test func macroWithIgnoredPropertyTypeAnnotation() throws { 41 | 42 | assertMacro( 43 | """ 44 | @Decodable 45 | public struct User { 46 | let id: UUID 47 | let name: String 48 | let age: Int 49 | @CodableKey(options: .ignored) 50 | var ignored: String = "Hello World" 51 | } 52 | """, 53 | expandedSource: """ 54 | public struct User { 55 | let id: UUID 56 | let name: String 57 | let age: Int 58 | var ignored: String = "Hello World" 59 | } 60 | 61 | extension User: Decodable { 62 | enum CodingKeys: String, CodingKey { 63 | case id 64 | case name 65 | case age 66 | } 67 | 68 | public init(from decoder: any Decoder) throws { 69 | let container = try decoder.container(keyedBy: CodingKeys.self) 70 | id = try container.decode(UUID.self, forKey: .id) 71 | name = try container.decode(String.self, forKey: .name) 72 | age = try container.decode(Int.self, forKey: .age) 73 | } 74 | } 75 | """ 76 | ) 77 | 78 | } 79 | 80 | @Test func macroWithStaticTypeAnnotation() throws { 81 | 82 | assertMacro( 83 | """ 84 | @Decodable 85 | public struct User { 86 | let id: UUID 87 | let name: String 88 | let age: Int 89 | 90 | static let staticProperty = "Hello World" 91 | } 92 | """, 93 | expandedSource: """ 94 | public struct User { 95 | let id: UUID 96 | let name: String 97 | let age: Int 98 | 99 | static let staticProperty = "Hello World" 100 | } 101 | 102 | extension User: Decodable { 103 | enum CodingKeys: String, CodingKey { 104 | case id 105 | case name 106 | case age 107 | } 108 | 109 | public init(from decoder: any Decoder) throws { 110 | let container = try decoder.container(keyedBy: CodingKeys.self) 111 | id = try container.decode(UUID.self, forKey: .id) 112 | name = try container.decode(String.self, forKey: .name) 113 | age = try container.decode(Int.self, forKey: .age) 114 | } 115 | } 116 | """ 117 | ) 118 | 119 | } 120 | 121 | @Test func macroOnComputeProperty() throws { 122 | 123 | assertMacro( 124 | """ 125 | @Decodable 126 | public struct User { 127 | let id: UUID 128 | let name: String 129 | var age: Int = 24 130 | @CodableKey("hello") 131 | var address: String { 132 | "A" 133 | } 134 | } 135 | """, 136 | expandedSource: """ 137 | public struct User { 138 | let id: UUID 139 | let name: String 140 | var age: Int = 24 141 | var address: String { 142 | "A" 143 | } 144 | } 145 | 146 | extension User: Decodable { 147 | enum CodingKeys: String, CodingKey { 148 | case id 149 | case name 150 | case age 151 | } 152 | 153 | public init(from decoder: any Decoder) throws { 154 | let container = try decoder.container(keyedBy: CodingKeys.self) 155 | id = try container.decode(UUID.self, forKey: .id) 156 | name = try container.decode(String.self, forKey: .name) 157 | age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 24 158 | } 159 | } 160 | """, 161 | diagnostics: [ 162 | .init(message: "Only variable declarations with no accessor block are supported", line: 6, column: 3) 163 | ] 164 | ) 165 | 166 | } 167 | 168 | @Test func macroOnStaticComputeProperty() throws { 169 | 170 | assertMacro( 171 | """ 172 | @Decodable 173 | public struct User { 174 | let id: UUID 175 | let name: String 176 | var age: Int = 24 177 | @CodableKey("hello") 178 | static var address: String { 179 | "A" 180 | } 181 | } 182 | """, 183 | expandedSource: """ 184 | public struct User { 185 | let id: UUID 186 | let name: String 187 | var age: Int = 24 188 | static var address: String { 189 | "A" 190 | } 191 | } 192 | 193 | extension User: Decodable { 194 | enum CodingKeys: String, CodingKey { 195 | case id 196 | case name 197 | case age 198 | } 199 | 200 | public init(from decoder: any Decoder) throws { 201 | let container = try decoder.container(keyedBy: CodingKeys.self) 202 | id = try container.decode(UUID.self, forKey: .id) 203 | name = try container.decode(String.self, forKey: .name) 204 | age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 24 205 | } 206 | } 207 | """, 208 | diagnostics: [ 209 | .init(message: "Only variable declarations with no accessor block are supported", line: 6, column: 3) 210 | ] 211 | ) 212 | 213 | } 214 | 215 | @Test func macroOnStaticProperty() throws { 216 | 217 | assertMacro( 218 | """ 219 | @Decodable 220 | public struct User { 221 | let id: UUID 222 | let name: String 223 | var age: Int = 24 224 | @CodableKey("hello") 225 | static var address: String = "A" 226 | } 227 | """, 228 | expandedSource: """ 229 | public struct User { 230 | let id: UUID 231 | let name: String 232 | var age: Int = 24 233 | static var address: String = "A" 234 | } 235 | 236 | extension User: Decodable { 237 | enum CodingKeys: String, CodingKey { 238 | case id 239 | case name 240 | case age 241 | } 242 | 243 | public init(from decoder: any Decoder) throws { 244 | let container = try decoder.container(keyedBy: CodingKeys.self) 245 | id = try container.decode(UUID.self, forKey: .id) 246 | name = try container.decode(String.self, forKey: .name) 247 | age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 24 248 | } 249 | } 250 | """, 251 | diagnostics: [ 252 | .init(message: "Only non-static variable declarations are supported", line: 6, column: 3) 253 | ] 254 | ) 255 | 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+keys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+keys.swift 3 | // CodableKit 4 | // 5 | // Created by Wendell Wang on 2025/9/7. 6 | // 7 | 8 | import SwiftSyntax 9 | import SwiftSyntaxBuilder 10 | import SwiftSyntaxMacros 11 | import SwiftSyntaxMacrosTestSupport 12 | import Testing 13 | 14 | @Suite struct CodableKitTestsForDifferentKeys { 15 | @Test func test_different_coding_keys_with_different_coding_keys() throws { 16 | assertMacro( 17 | """ 18 | @Codable 19 | public struct User { 20 | let id: UUID 21 | @DecodableKey("name_de") 22 | @EncodableKey("name_en") 23 | let name: String 24 | let age: Int 25 | } 26 | """, 27 | expandedSource: """ 28 | public struct User { 29 | let id: UUID 30 | let name: String 31 | let age: Int 32 | 33 | public func encode(to encoder: any Encoder) throws { 34 | var container = encoder.container(keyedBy: EncodeKeys.self) 35 | try container.encode(id, forKey: .id) 36 | try container.encode(name, forKey: .name) 37 | try container.encode(age, forKey: .age) 38 | } 39 | } 40 | 41 | extension User: Codable { 42 | enum DecodeKeys: String, CodingKey { 43 | case id 44 | case name = "name_de" 45 | case age 46 | } 47 | enum EncodeKeys: String, CodingKey { 48 | case id 49 | case name = "name_en" 50 | case age 51 | } 52 | 53 | public init(from decoder: any Decoder) throws { 54 | let container = try decoder.container(keyedBy: DecodeKeys.self) 55 | id = try container.decode(UUID.self, forKey: .id) 56 | name = try container.decode(String.self, forKey: .name) 57 | age = try container.decode(Int.self, forKey: .age) 58 | } 59 | } 60 | """ 61 | ) 62 | } 63 | 64 | @Test func test_different_coding_keys_with_same_coding_keys() throws { 65 | assertMacro( 66 | """ 67 | @Codable 68 | public struct User { 69 | let id: UUID 70 | @DecodableKey("name_de") 71 | @EncodableKey("name_de") 72 | let name: String 73 | let age: Int 74 | } 75 | """, 76 | expandedSource: """ 77 | public struct User { 78 | let id: UUID 79 | let name: String 80 | let age: Int 81 | 82 | public func encode(to encoder: any Encoder) throws { 83 | var container = encoder.container(keyedBy: CodingKeys.self) 84 | try container.encode(id, forKey: .id) 85 | try container.encode(name, forKey: .name) 86 | try container.encode(age, forKey: .age) 87 | } 88 | } 89 | 90 | extension User: Codable { 91 | enum CodingKeys: String, CodingKey { 92 | case id 93 | case name = "name_de" 94 | case age 95 | } 96 | 97 | public init(from decoder: any Decoder) throws { 98 | let container = try decoder.container(keyedBy: CodingKeys.self) 99 | id = try container.decode(UUID.self, forKey: .id) 100 | name = try container.decode(String.self, forKey: .name) 101 | age = try container.decode(Int.self, forKey: .age) 102 | } 103 | } 104 | """ 105 | ) 106 | } 107 | 108 | @Test func test_codable_with_decodable_key_only() throws { 109 | assertMacro( 110 | """ 111 | @Codable 112 | public struct User { 113 | let id: UUID 114 | @DecodableKey("name_de") 115 | let name: String 116 | let age: Int 117 | } 118 | """, 119 | expandedSource: """ 120 | public struct User { 121 | let id: UUID 122 | let name: String 123 | let age: Int 124 | 125 | public func encode(to encoder: any Encoder) throws { 126 | var container = encoder.container(keyedBy: EncodeKeys.self) 127 | try container.encode(id, forKey: .id) 128 | try container.encode(name, forKey: .name) 129 | try container.encode(age, forKey: .age) 130 | } 131 | } 132 | 133 | extension User: Codable { 134 | enum DecodeKeys: String, CodingKey { 135 | case id 136 | case name = "name_de" 137 | case age 138 | } 139 | enum EncodeKeys: String, CodingKey { 140 | case id 141 | case name 142 | case age 143 | } 144 | 145 | public init(from decoder: any Decoder) throws { 146 | let container = try decoder.container(keyedBy: DecodeKeys.self) 147 | id = try container.decode(UUID.self, forKey: .id) 148 | name = try container.decode(String.self, forKey: .name) 149 | age = try container.decode(Int.self, forKey: .age) 150 | } 151 | } 152 | """ 153 | ) 154 | } 155 | 156 | @Test func test_nested_codable() async throws { 157 | assertMacro( 158 | """ 159 | @dynamicMemberLookup 160 | @Codable public struct ColorDesignToken: DesignToken { 161 | @Codable public struct ColorValue: Sendable { 162 | @CodableKey("light") private let lightHex: String 163 | @CodableKey("dark") private let darkHex: String 164 | 165 | public subscript(_ scheme: ColorScheme) -> String { 166 | switch scheme { 167 | case .light: lightHex 168 | case .dark: darkHex 169 | @unknown default: lightHex 170 | } 171 | } 172 | 173 | public init(lightHex: String, darkHex: String) { 174 | self.lightHex = lightHex 175 | self.darkHex = darkHex 176 | } 177 | } 178 | @CodableKey("light") public let name: String 179 | } 180 | """, 181 | expandedSource: 182 | """ 183 | @dynamicMemberLookup 184 | public struct ColorDesignToken: DesignToken { 185 | public struct ColorValue: Sendable { 186 | private let lightHex: String 187 | private let darkHex: String 188 | 189 | public subscript(_ scheme: ColorScheme) -> String { 190 | switch scheme { 191 | case .light: lightHex 192 | case .dark: darkHex 193 | @unknown default: lightHex 194 | } 195 | } 196 | 197 | public init(lightHex: String, darkHex: String) { 198 | self.lightHex = lightHex 199 | self.darkHex = darkHex 200 | } 201 | 202 | public func encode(to encoder: any Encoder) throws { 203 | var container = encoder.container(keyedBy: CodingKeys.self) 204 | try container.encode(lightHex, forKey: .lightHex) 205 | try container.encode(darkHex, forKey: .darkHex) 206 | } 207 | } 208 | public let name: String 209 | 210 | public func encode(to encoder: any Encoder) throws { 211 | var container = encoder.container(keyedBy: CodingKeys.self) 212 | try container.encode(name, forKey: .name) 213 | } 214 | } 215 | 216 | extension ColorDesignToken.ColorValue: Codable { 217 | enum CodingKeys: String, CodingKey { 218 | case lightHex = "light" 219 | case darkHex = "dark" 220 | } 221 | public init(from decoder: any Decoder) throws { 222 | let container = try decoder.container(keyedBy: CodingKeys.self) 223 | lightHex = try container.decode(String.self, forKey: .lightHex) 224 | darkHex = try container.decode(String.self, forKey: .darkHex) 225 | } 226 | } 227 | 228 | extension ColorDesignToken: Codable { 229 | enum CodingKeys: String, CodingKey { 230 | case name = "light" 231 | } 232 | public init(from decoder: any Decoder) throws { 233 | let container = try decoder.container(keyedBy: CodingKeys.self) 234 | name = try container.decode(String.self, forKey: .name) 235 | } 236 | } 237 | """) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /Sources/CodableKit/CodableKeyOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableKeyOptions.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/14 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | public struct CodableKeyOptions: OptionSet, Sendable { 10 | public let rawValue: Int32 11 | 12 | public init(rawValue: Int32) { 13 | self.rawValue = rawValue 14 | } 15 | 16 | /// The default options for a `CodableKey`, which is equivalent to an empty set. 17 | public static let `default`: Self = [] 18 | 19 | /// A convenience option combining ``transcodeRawString`` and ``useDefaultOnFailure`` for safe JSON string transcoding. 20 | /// 21 | /// This option provides a safer way to handle string-encoded JSON by automatically falling back to 22 | /// default values or `nil` when the JSON string is invalid or malformed. It's equivalent to 23 | /// `[.transcodeRawString, .useDefaultOnFailure]`. 24 | /// 25 | /// Example usage with invalid JSON handling: 26 | /// 27 | /// ```json 28 | /// { 29 | /// "name": "Tom", 30 | /// "validCar": "{\"brand\":\"XYZ\",\"year\":9999}", 31 | /// "invalidCar": "corrupted json string", 32 | /// "optionalCar": null 33 | /// } 34 | /// ``` 35 | /// 36 | /// ```swift 37 | /// @Codable 38 | /// struct Person { 39 | /// let name: String 40 | /// 41 | /// // Successfully decodes valid JSON string 42 | /// @CodableKey(options: .safeTranscodeRawString) 43 | /// var validCar: Car = Car(brand: "Default", year: 2024) 44 | /// 45 | /// // Uses default value for invalid JSON string 46 | /// @CodableKey(options: .safeTranscodeRawString) 47 | /// var invalidCar: Car = Car(brand: "Default", year: 2024) 48 | /// 49 | /// // Becomes nil for invalid JSON string or null 50 | /// @CodableKey(options: .safeTranscodeRawString) 51 | /// var optionalCar: Car? 52 | /// } 53 | /// ``` 54 | /// 55 | /// - Note: This is a convenience option. It's identical to using 56 | /// `@CodableKey(options: [.transcodeRawString, .useDefaultOnFailure])` 57 | /// - Important: When using this option, ensure your properties either: 58 | /// - Have an explicit default value, or 59 | /// - Are optional (implicitly having `nil` as default) 60 | public static let safeTranscodeRawString: Self = [.transcodeRawString, .useDefaultOnFailure] 61 | 62 | /// The key will be ignored during encoding and decoding. 63 | /// 64 | /// This option is useful when you want to add a local or runtime-only property to the structure without creating 65 | /// another structure. 66 | /// 67 | /// - Important: Using the `.ignored` option for an enum case may lead to runtime issues if you attempt to decode 68 | /// or encode the enum with that case using the `.ignored` option. 69 | public static let ignored = Self(rawValue: 1 << 0) 70 | 71 | /// The key will be explicitly set to `nil` (`null`) when encoding and decoding. 72 | /// By default, the key will be omitted if the value is `nil`. 73 | public static let explicitNil = Self(rawValue: 1 << 1) 74 | 75 | /// If the key has a custom CodableKey, a computed property will be generated to access the key; otherwise, this 76 | /// option is ignored. 77 | /// 78 | /// For example, if you have a custom key `myKey` and the original key `key`, a computed property `myKey` will be 79 | /// generated to access the original key `key`. 80 | /// 81 | /// ```swift 82 | /// @Codable 83 | /// struct MyStruct { 84 | /// @CodableKey("key", options: .generateCustomKey) 85 | /// var myKey: String 86 | /// } 87 | /// ``` 88 | /// 89 | /// The generated code will be: 90 | /// ```swift 91 | /// struct MyStruct { 92 | /// var myKey: String 93 | /// var key: String { 94 | /// myKey 95 | /// } 96 | /// } 97 | /// ``` 98 | public static let generateCustomKey = Self(rawValue: 1 << 2) 99 | 100 | /// Transcode the value between raw string and the target type. 101 | /// 102 | /// This option enables automatic conversion between a JSON string representation and a 103 | /// strongly-typed model during encoding and decoding. The property type must conform to 104 | /// the appropriate coding protocol based on usage: 105 | /// - For decoding: must conform to `Decodable` 106 | /// - For encoding: must conform to `Encodable` 107 | /// - For both operations: must conform to `Codable` (which combines both protocols) 108 | /// 109 | /// This is particularly useful when dealing with APIs that encode nested objects as 110 | /// string-encoded JSON, eliminating the need for custom encoding/decoding logic. 111 | /// 112 | /// For example, given this JSON response where `car` is a string-encoded JSON object: 113 | /// 114 | /// ```json 115 | /// { 116 | /// "name": "Tom", 117 | /// "car": "{\"brand\":\"XYZ\",\"year\":9999}" 118 | /// } 119 | /// ``` 120 | /// 121 | /// You can decode it directly into typed models: 122 | /// 123 | /// ```swift 124 | /// @Codable 125 | /// struct Car { 126 | /// let brand: String 127 | /// let year: Int 128 | /// } 129 | /// 130 | /// @Codable 131 | /// struct Person { 132 | /// let name: String 133 | /// @CodableKey(options: .transcodeRawString) 134 | /// var car: Car 135 | /// } 136 | /// ``` 137 | /// 138 | /// When dealing with potentially invalid JSON strings, you can combine with other options. 139 | /// For example: 140 | /// 141 | /// ```json 142 | /// { 143 | /// "name": "Tom", 144 | /// "car": "invalid json string" 145 | /// } 146 | /// ``` 147 | /// 148 | /// ```swift 149 | /// @Codable 150 | /// struct SafePerson { 151 | /// let name: String 152 | /// 153 | /// // Will use the default car when JSON string is invalid 154 | /// @CodableKey(options: [.transcodeRawString, .useDefaultOnFailure]) 155 | /// var car: Car = Car(brand: "Default", year: 2024) 156 | /// 157 | /// // Will be nil when JSON string is invalid 158 | /// @CodableKey(options: [.transcodeRawString, .useDefaultOnFailure]) 159 | /// var optionalCar: Car? 160 | /// } 161 | /// ``` 162 | /// 163 | /// Without this option, you would need to: 164 | /// 1. First decode the car field as a String 165 | /// 2. Parse that string into JSON data 166 | /// 3. Decode the JSON data into the Car type 167 | /// 4. Implement the reverse process for encoding 168 | /// 169 | /// The `transcodeRawString` option handles all these steps automatically. 170 | /// 171 | /// - Note: The property type must conform to the appropriate coding protocol based on usage: 172 | /// `Decodable` for decoding, `Encodable` for encoding, or `Codable` for both. 173 | /// A compile-time error will occur if the type does not satisfy these requirements. 174 | /// - Important: The string value must contain valid JSON that matches the structure of 175 | /// the target type. If the JSON is invalid or doesn't match the expected structure, 176 | /// a decoding error will be thrown at runtime. See ``useDefaultOnFailure`` option 177 | /// for handling invalid JSON strings gracefully. 178 | public static let transcodeRawString = Self(rawValue: 1 << 3) 179 | 180 | /// Use the default value or `nil` when decoding or encoding fails. 181 | /// 182 | /// This option provides fallback behavior when coding operations fail, with two scenarios: 183 | /// 184 | /// 1. For properties with explicit default values: 185 | /// ```swift 186 | /// @CodableKey(options: .useDefaultOnFailure) 187 | /// var status: Status = .unknown 188 | /// ``` 189 | /// The default value (`.unknown`) will be used when decoding fails. 190 | /// 191 | /// 2. For optional properties: 192 | /// ```swift 193 | /// @CodableKey(options: .useDefaultOnFailure) 194 | /// var status: Status? 195 | /// ``` 196 | /// The property will be set to `nil` when decoding fails. 197 | /// 198 | /// This is particularly useful for: 199 | /// - Enum properties where the raw value might not match any defined cases 200 | /// - Handling backward compatibility when adding new properties 201 | /// - Gracefully handling malformed or unexpected data 202 | /// 203 | /// Example handling an enum with invalid raw value: 204 | /// ```swift 205 | /// enum Status: String, Codable { 206 | /// case active 207 | /// case inactive 208 | /// case unknown 209 | /// } 210 | /// 211 | /// struct User { 212 | /// let id: Int 213 | /// @CodableKey(options: .useDefaultOnFailure) 214 | /// var status: Status = .unknown // Will use .unknown if JSON contains invalid status 215 | /// } 216 | /// 217 | /// // JSON: {"id": 1, "status": "invalid_value"} 218 | /// // Decodes without error, status will be .unknown 219 | /// ``` 220 | /// 221 | /// - Note: This option must be used with either: 222 | /// - A property that has an explicit default value 223 | /// - An optional property (which implicitly has `nil` as default) 224 | /// - Important: Without this option, decoding failures would throw an error and halt the 225 | /// entire decoding process. With this option, failures are handled gracefully 226 | /// by falling back to the default value or `nil`. 227 | public static let useDefaultOnFailure = Self(rawValue: 1 << 4) 228 | 229 | /// Decode the value in a lossy way for collections. 230 | /// 231 | /// - For arrays and sets: invalid elements are dropped. 232 | /// - For dictionaries: entries with invalid values (or keys that cannot be 233 | /// converted from JSON string keys) are dropped. 234 | /// 235 | /// This option is useful when you want to tolerate partially-invalid data 236 | /// from APIs without failing the entire decode. 237 | public static let lossy = Self(rawValue: 1 << 5) 238 | } 239 | -------------------------------------------------------------------------------- /Tests/TransformerTests/CustomCodingTransformerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodingTransformerTests+data.swift 3 | // CodableKitTests 4 | // 5 | // Runtime behavior tests for CodingTransformer 6 | // 7 | 8 | import CodableKit 9 | import Foundation 10 | import Testing 11 | 12 | // MARK: - Transformers 13 | 14 | struct IntFromString: BidirectionalCodingTransformer { 15 | func transform(_ input: Result) -> Result { 16 | input.map { Int($0) ?? 0 } 17 | } 18 | 19 | func reverseTransform(_ input: Result) -> Result { 20 | input.map(String.init) 21 | } 22 | } 23 | 24 | struct ISO8601DateTransformer: BidirectionalCodingTransformer { 25 | func transform(_ input: Result) -> Result { 26 | input.flatMap { str in 27 | let f = ISO8601DateFormatter() 28 | f.formatOptions = [.withInternetDateTime] 29 | if let d = f.date(from: str) { 30 | return .success(d) 31 | } 32 | return .failure(DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid ISO8601"))) 33 | } 34 | } 35 | 36 | func reverseTransform(_ input: Result) -> Result { 37 | input.map { 38 | let f = ISO8601DateFormatter() 39 | f.formatOptions = [.withInternetDateTime] 40 | return f.string(from: $0) 41 | } 42 | } 43 | } 44 | 45 | // MARK: - Models 46 | 47 | @Codable 48 | struct ModelIntNonOptional { 49 | @CodableKey(transformer: IntFromString()) 50 | var count: Int 51 | } 52 | 53 | @Codable 54 | struct ModelIntOptional { 55 | @CodableKey(transformer: IntFromString()) 56 | var count: Int? 57 | } 58 | 59 | @Codable 60 | struct ModelIntOptionalExplicitNil { 61 | @CodableKey(options: .explicitNil, transformer: IntFromString()) 62 | var count: Int? 63 | } 64 | 65 | @Codable 66 | struct ModelIntDefaultUseDefault { 67 | @CodableKey(options: .useDefaultOnFailure, transformer: IntFromString()) 68 | var count: Int = 42 69 | } 70 | 71 | @Codable 72 | struct ModelDateNonOptional { 73 | @CodableKey( 74 | transformer: ISO8601DateTransformer() 75 | .chained(DefaultOnFailureTransformer(defaultValue: .distantPast)) 76 | ) 77 | var date: Date 78 | } 79 | 80 | // Transformer that fails on reverseTransform to test encode error propagation 81 | struct IntFromStringFailingReverse: BidirectionalCodingTransformer { 82 | func transform(_ input: Result) -> Result { 83 | input.map { Int($0) ?? 0 } 84 | } 85 | 86 | func reverseTransform(_ input: Result) -> Result { 87 | input.flatMap { value in 88 | if value == 13 { 89 | return .failure(EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Unlucky number"))) 90 | } 91 | return .success(String(value)) 92 | } 93 | } 94 | } 95 | 96 | // In-place increment transformer for composition tests 97 | struct IncrementTransformer: BidirectionalCodingTransformer { 98 | func transform(_ input: Result) -> Result { 99 | input.map { $0 + 1 } 100 | } 101 | 102 | func reverseTransform(_ input: Result) -> Result { 103 | input.map { $0 - 1 } 104 | } 105 | } 106 | 107 | struct TRoom: Codable, Equatable { 108 | let id: Int 109 | let name: String 110 | } 111 | 112 | @Codable 113 | struct ModelRoomRawString { 114 | @CodableKey(transformer: RawStringTransformer()) 115 | var room: TRoom 116 | } 117 | 118 | @Codable 119 | struct ModelBoolAsInt { 120 | @CodableKey(transformer: IntegerToBooleanTransformer()) 121 | var isOn: Bool 122 | } 123 | 124 | @Codable 125 | struct ModelIntFailingReverse { 126 | @CodableKey(transformer: IntFromStringFailingReverse()) 127 | var count: Int 128 | } 129 | 130 | @Codable 131 | struct ModelIntWithIncrement { 132 | @CodableKey( 133 | transformer: IntFromString() 134 | .chained(IncrementTransformer()) 135 | ) 136 | var value: Int 137 | } 138 | 139 | // MARK: - Tests 140 | 141 | @Suite struct CodingTransformerRuntimeTests { 142 | @Test func decode_nonOptional_transformer() throws { 143 | let json = #"{"count":"123"}"# 144 | let data = json.data(using: .utf8)! 145 | let decoded = try JSONDecoder().decode(ModelIntNonOptional.self, from: data) 146 | #expect(decoded.count == 123) 147 | } 148 | 149 | @Test func encode_nonOptional_transformer() throws { 150 | let model = ModelIntNonOptional(count: 45) 151 | let data = try JSONEncoder().encode(model) 152 | // Expect count encoded as string 153 | let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] 154 | #expect(dict["count"] as? String == "45") 155 | } 156 | 157 | @Test func optional_missingKey_decodes_nil_and_omits_key_on_encode() throws { 158 | let json = #"{}"# 159 | let data = json.data(using: .utf8)! 160 | let decoded = try JSONDecoder().decode(ModelIntOptional.self, from: data) 161 | #expect(decoded.count == nil) 162 | 163 | let encoded = try JSONEncoder().encode(decoded) 164 | let dict = try JSONSerialization.jsonObject(with: encoded) as! [String: Any] 165 | #expect(dict["count"] == nil) 166 | } 167 | 168 | @Test func optional_explicitNil_encodes_null() throws { 169 | let model = ModelIntOptionalExplicitNil(count: nil) 170 | let data = try JSONEncoder().encode(model) 171 | let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] 172 | #expect(dict["count"] is NSNull) 173 | } 174 | 175 | @Test func useDefaultOnFailure_falls_back_on_type_mismatch_and_missing() throws { 176 | // Type mismatch (number instead of string) → default 42 177 | let jsonMismatch = #"{"count": 7}"# 178 | let dataMismatch = jsonMismatch.data(using: .utf8)! 179 | let decodedMismatch = try JSONDecoder().decode(ModelIntDefaultUseDefault.self, from: dataMismatch) 180 | #expect(decodedMismatch.count == 42) 181 | 182 | // Missing key → default 42 183 | let jsonMissing = #"{}"# 184 | let dataMissing = jsonMissing.data(using: .utf8)! 185 | let decodedMissing = try JSONDecoder().decode(ModelIntDefaultUseDefault.self, from: dataMissing) 186 | #expect(decodedMissing.count == 42) 187 | 188 | // Valid string → transformed value 189 | let jsonOK = #"{"count":"5"}"# 190 | let dataOK = jsonOK.data(using: .utf8)! 191 | let decodedOK = try JSONDecoder().decode(ModelIntDefaultUseDefault.self, from: dataOK) 192 | #expect(decodedOK.count == 5) 193 | } 194 | 195 | @Test func date_transformer_decode_and_encode_iso8601() throws { 196 | let s = "2020-01-02T03:04:05Z" 197 | let json = #"{"date":"\#(s)"}"# 198 | let data = json.data(using: .utf8)! 199 | let decoded = try JSONDecoder().decode(ModelDateNonOptional.self, from: data) 200 | let f = ISO8601DateFormatter() 201 | f.formatOptions = [.withInternetDateTime] 202 | let expected = f.date(from: s)! 203 | #expect(abs(decoded.date.timeIntervalSince1970 - expected.timeIntervalSince1970) < 0.5) 204 | 205 | let encoded = try JSONEncoder().encode(decoded) 206 | let dict = try JSONSerialization.jsonObject(with: encoded) as! [String: Any] 207 | #expect(dict["date"] as? String == s) 208 | } 209 | 210 | @Test func date_transformer_decode_and_encode_iso8601_with_default_on_failure() throws { 211 | let s = "null" 212 | let json = #"{"date":"\#(s)"}"# 213 | let data = json.data(using: .utf8)! 214 | let decoded = try JSONDecoder().decode(ModelDateNonOptional.self, from: data) 215 | #expect(decoded.date == .distantPast) 216 | } 217 | 218 | @Test func encode_rawString_transformer_emits_stringified_json() throws { 219 | let room = TRoom(id: 7, name: "Seven") 220 | let model = ModelRoomRawString(room: room) 221 | 222 | let data = try JSONEncoder().encode(model) 223 | let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] 224 | let encodedRoomString = dict["room"] as? String 225 | 226 | #expect(encodedRoomString != nil) 227 | let decodedRoom = try JSONDecoder().decode(TRoom.self, from: encodedRoomString!.data(using: .utf8)!) 228 | #expect(decodedRoom == room) 229 | } 230 | 231 | @Test func encode_bool_via_integer_transformer_writes_1_or_0() throws { 232 | do { 233 | let model = ModelBoolAsInt(isOn: true) 234 | let data = try JSONEncoder().encode(model) 235 | let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] 236 | let num = dict["isOn"] as? NSNumber 237 | #expect(num?.intValue == 1) 238 | } 239 | 240 | do { 241 | let model = ModelBoolAsInt(isOn: false) 242 | let data = try JSONEncoder().encode(model) 243 | let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] 244 | let num = dict["isOn"] as? NSNumber 245 | #expect(num?.intValue == 0) 246 | } 247 | } 248 | 249 | @Test func encode_reverseTransform_failure_propagates_error() throws { 250 | let model = ModelIntFailingReverse(count: 13) 251 | var threw = false 252 | do { 253 | _ = try JSONEncoder().encode(model) 254 | } catch { 255 | threw = true 256 | } 257 | #expect(threw) 258 | } 259 | 260 | @Test func decode_rawString_transformer_reads_stringified_json() throws { 261 | let s = #"{\"id\":7,\"name\":\"Seven\"}"# 262 | let json = #"{"room":"\#(s)"}"# 263 | let data = json.data(using: .utf8)! 264 | let decoded = try JSONDecoder().decode(ModelRoomRawString.self, from: data) 265 | #expect(decoded.room == TRoom(id: 7, name: "Seven")) 266 | } 267 | 268 | @Test func decode_bool_via_integer_transformer_reads_1_or_0() throws { 269 | do { 270 | let json = #"{"isOn":1}"# 271 | let data = json.data(using: .utf8)! 272 | let decoded = try JSONDecoder().decode(ModelBoolAsInt.self, from: data) 273 | #expect(decoded.isOn == true) 274 | } 275 | 276 | do { 277 | let json = #"{"isOn":0}"# 278 | let data = json.data(using: .utf8)! 279 | let decoded = try JSONDecoder().decode(ModelBoolAsInt.self, from: data) 280 | #expect(decoded.isOn == false) 281 | } 282 | } 283 | 284 | @Test func decode_chained_transformer_with_increment() throws { 285 | let json = #"{"value":"5"}"# 286 | let data = json.data(using: .utf8)! 287 | let decoded = try JSONDecoder().decode(ModelIntWithIncrement.self, from: data) 288 | #expect(decoded.value == 6) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # CodableKit Roadmap 2 | 3 | This roadmap outlines the major priorities and phased delivery plan for CodableKit. It focuses on correctness, stability, ergonomics, and extensibility while keeping macro output predictable and fast. 4 | 5 | ## Guiding Principles 6 | - Prefer compile-time guarantees and zero/low runtime overhead 7 | - Predictable code generation; minimal surprises for users 8 | - Opt-in features via options; maintain backwards compatibility by default 9 | - Clear diagnostics with actionable fix-its 10 | - Strong test coverage (snapshots + behavior) and CI signal 11 | 12 | ## Milestones 13 | - 1.5.x: Phase 1 — correctness & stability (PRs #10, #11) 14 | - 1.6.x: Phase 2 — ergonomics & resilience 15 | - 1.7.x: Coding Transformer — functional, composable coding pipeline (MVP) 16 | - 1.8.x: Transformer Ecosystem — official set, docs, performance 17 | - 2.0.0: Breaking changes window (only if truly necessary) 18 | 19 | ## Phase 1 — Correctness and Stability (Target: 1.5.x) 20 | - Status: ([PR #10](https://github.com/WendellXY/CodableKit/pull/10), [PR #11](https://github.com/WendellXY/CodableKit/pull/11)) 21 | - [x] Optional raw-string transcode: omit key when value is nil unless `.explicitNil` 22 | - Acceptance: 23 | - Optional property with `.transcodeRawString` encodes no key when `nil` 24 | - With `.explicitNil`, encodes null semantics appropriately 25 | - Decode path honors defaults/optionals as configured 26 | - [x] Reuse a single `JSONEncoder`/`JSONDecoder` per encode/decode function 27 | - Acceptance: 28 | - Generated code creates one encoder/decoder per function 29 | - Tests assert behavior unchanged; micro-bench shows reduced churn 30 | - [x] Deterministic codegen order for nested key-paths and containers 31 | - Acceptance: 32 | - Snapshot tests reveal stable order across runs/platforms 33 | - [ ] Diagnostics improvements (clearer messages, targeted fix-its) — ([PR #11](https://github.com/WendellXY/CodableKit/pull/11)) 34 | - [x] Warn when `.useDefaultOnFailure` on non-optional without default 35 | - [x] Warn when `.explicitNil` on non-optional property 36 | - [x] Suggest `.skipSuperCoding` for classes with inheritance when superclass may be non-Codable 37 | - [ ] Add fix-it for missing type annotation 38 | - [ ] Improve multi-binding with custom key error messaging 39 | - [ ] Enum options validation warnings 40 | - [ ] CodableKey custom key path validation warnings 41 | - Acceptance: 42 | - Missing type annotation reports actionable error and fix-it 43 | - Multi-binding with custom key reports clear error 44 | - Enum options validation warns appropriately 45 | - Tests 46 | - [x] Optional transcode: encode/decode (with/without `.explicitNil`) 47 | - [x] Deterministic nested ordering snapshots 48 | 49 | ## Phase 2 — Ergonomics and Resilience (Target: 1.6.x) 50 | - [x] Lossy decoding for arrays and sets (`.lossy`) 51 | - Acceptance: 52 | - `LossyArray` semantics: invalid items dropped, valid items decoded 53 | - Default/optional behaviors still respected (including `.useDefaultOnFailure`) 54 | - Works with `Set` (deduplication preserved) 55 | - Composes with `.transcodeRawString` and `.safeTranscodeRawString` (decode lossy from transcoded payload) 56 | - [x] Lossy decoding for dictionaries (`.lossy`) 57 | - Acceptance: 58 | - Gracefully drop invalid entries and decode valid key/value pairs 59 | - Size-limit guard for raw-string transcode decoding 60 | - Acceptance: 61 | - Reasonable default limit; configurable via option or macro-level configuration in a later patch 62 | - Exceeding limit produces decode error or default/`nil` when `.useDefaultOnFailure` present 63 | - Tests 64 | - Mixed-validity collections round-trips (Array and Set) 65 | - Combined `.lossy` + `.transcodeRawString` and `.safeTranscodeRawString` 66 | - Guard behavior, with and without defaults and `.useDefaultOnFailure` 67 | 68 | ## Coding Transformer — Functional, Composable Coding Pipeline (Target: 1.7.x) 69 | - Problem statement 70 | - Per-field customization (e.g., dates, numbers-from-strings, lossy mapping) often requires options or manual code. 71 | - Some needs (like per-type date strategies) push users towards hacks and can impact performance. 72 | - Goal: Provide a first-class, composable pipeline for coding operations inspired by functional programming. 73 | - Core concept 74 | - `CodingTransformer` transforms `Result` → `Result`. 75 | - Chainable and reusable (e.g., `.map`, `.flatMap`, `.compose(_)`). 76 | - Sendable-friendly; encourages stateless transformers or shared, cached resources (e.g., formatters). 77 | - Symmetric support: decoding transformers, encoding transformers, or bi-directional transformers when possible. 78 | - Integration 79 | - Property-level: `@CodableKey(transformers: [...])` to apply a pipeline during decode/encode. 80 | - Type-level defaults: optional default pipeline applied across the type, overridable per-field. 81 | - Backwards compatibility: existing options like `.transcodeRawString`, `.useDefaultOnFailure`, `.lossy` map to built-in transformers. 82 | - Diagnostics: compile-time checks for transformer type compatibility and fix-its for common mistakes. 83 | - Initial official transformers (MVP) 84 | - `date(.iso8601 | .secondsSince1970 | .millisecondsSince1970 | .formatted(DateFormatter))` 85 | - `numberFromString()` 86 | - `boolFromInt(0/1)` and `boolFromString("true"/"false")` 87 | - `rawStringTranscode()` with `safeRawStringTranscode` variant 88 | - `defaultOnFailure(_:)` and `nilOnFailure` 89 | - `lossyArray`, `lossySet`, `lossyDictionary` 90 | - Utility transforms: `trim`, `nonEmpty`, `clamp`, `coalesce(_:)` 91 | - API sketch 92 | - Protocol: 93 | ```swift 94 | public protocol CodingTransformer { 95 | associatedtype Input 96 | associatedtype Output 97 | func transform(_ input: Result) -> Result 98 | } 99 | ``` 100 | - Composition: 101 | ```swift 102 | extension CodingTransformer { 103 | func compose(_ next: T) -> some CodingTransformer where T.Input == Output { /* ... */ } 104 | } 105 | ``` 106 | - Usage with macro: 107 | ```swift 108 | @Codable 109 | struct Event { 110 | @CodableKey("date", transformers: [.date(.iso8601)]) 111 | var date: Date 112 | } 113 | ``` 114 | - Acceptance 115 | - Pipelines apply in declared order; deterministic codegen, with snapshots. 116 | - Encode/decode symmetry where applicable; clear diagnostics otherwise. 117 | - Micro-benchmarks show minimal overhead compared to hand-written equivalents. 118 | - Documentation with examples and migration notes from options to transformers. 119 | 120 | ## Known Limitations & Workarounds 121 | - JSON date decoding strategy is not configurable per type 122 | - Swift’s `Decoder` protocol does not expose `JSONDecoder.dateDecodingStrategy`. Once inside `init(from:)`, you cannot change the strategy. 123 | - Recommended: configure `JSONDecoder` at the call site (preferred approach). 124 | - Alternatives when call site cannot be controlled: 125 | - Pass a `DateFormatter` via `decoder.userInfo` and decode dates from `String` manually in the type. 126 | - Use wrapper types (e.g., `ISO8601Date`, `MillisecondsSince1970Date`) that handle per-field decoding. 127 | - Avoid attempting to cast `Decoder` to `JSONDecoder` — it is not reliable and breaks abstraction. 128 | 129 | - Performance considerations of workarounds 130 | - `userInfo` lookups are cheap, but manual `String`→`Date` parsing adds per-field cost. 131 | - Always reuse static/shared `DateFormatter`/`ISO8601DateFormatter`; constructing formatters per decode is expensive. 132 | - Raw-string transcoding incurs extra allocations (string→`Data`→model). Keep payloads small; prefer native `Date` decoding when possible. 133 | - The upcoming Coding Transformer pipeline (1.7.x) will provide official, reusable date transformers with shared formatters and minimal overhead. 134 | 135 | - Documentation and benchmarking 136 | - Add README guidance and examples for the above patterns and trade-offs. 137 | - Add micro-benchmarks comparing: call-site strategy vs `userInfo` vs wrapper types vs transformer pipelines. 138 | 139 | ## Transformer Ecosystem, Docs, and Performance (Target: 1.8.x) 140 | - Official transformer set expansion 141 | - Additional date/time variants (custom calendars/timezones), locale-aware number parsing. 142 | - Key strategy transformer (e.g., snake_case) as a pipeline stage where applicable. 143 | - Registry for user-defined transformers and sharing across modules. 144 | - Performance and caching 145 | - Shared/cached formatters; zero-allocation fast paths; reduce intermediary `Data`/`String` churn. 146 | - Benchmarks for transformer chains vs. macro options and manual code. 147 | - Tooling & diagnostics 148 | - Better compile-time validation for transformer chains and inverse-encode coverage. 149 | - Lint-like mode to preview pipelines without codegen. 150 | - Docs & adoption 151 | - Cookbook of transformer recipes; migration guide from options to transformers. 152 | - CI matrix expansion across Apple platforms and Swift toolchains. 153 | 154 | ## Stretch / Long‑Term Ideas (Post 1.8.x) 155 | - Additional key strategies (e.g., kebab-case) if requested 156 | - Plugin points for custom transforms (user-supplied encode/decode hooks per field) 157 | - Lint-like mode: dry-run expansion check with warnings only 158 | - Better Xcode diagnostics surfacing with notes and fix-its 159 | 160 | ## Breaking Changes Policy 161 | - Avoid breaking changes in 1.x; introduce new behavior as opt-in options 162 | - If 2.0 is required, provide deprecation path and migration notes at least one minor version beforehand 163 | 164 | ## Quality Bar (applies to every phase) 165 | - Code-gen is deterministic and minimal 166 | - Clear, localized diagnostic messages with actionable fix-its 167 | - Tests: snapshot + behavioral for all new features and bug fixes 168 | - Performance: no regressions; micro-benchmarks for hot paths 169 | - Security: avoid unbounded allocations; limits and sanity checks in transcode paths 170 | 171 | ## Tracking and Contribution 172 | - Each roadmap item tracked as an issue with label `roadmap` 173 | - PRs should reference the roadmap item and include tests and docs updates 174 | - Discussion for prioritization in GitHub Discussions or issues 175 | 176 | ## Quick Checklist (per item) 177 | - [ ] Design note (if needed) 178 | - [ ] Implementation behind options / flags 179 | - [ ] Tests (snapshot + behavioral) 180 | - [ ] Docs (README + examples) 181 | - [ ] Benchmarks (if performance-sensitive) 182 | - [ ] Changelog entry 183 | 184 | --- 185 | 186 | If you have feature requests or feedback, please open an issue with context and examples. This roadmap evolves with community input. 187 | -------------------------------------------------------------------------------- /Tests/CodableKitTests/CodableMacroTests+diagnostics.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableMacroTests+Diagnostics.swift 3 | // CodableKit 4 | // 5 | // Created by WendellXY on 2024/5/27 6 | // Copyright © 2024 WendellXY. All rights reserved. 7 | // 8 | 9 | import SwiftSyntax 10 | import SwiftSyntaxBuilder 11 | import SwiftSyntaxMacros 12 | import SwiftSyntaxMacrosTestSupport 13 | import Testing 14 | 15 | @Suite struct CodableKitDiagnosticsTests { 16 | @Test func macroWithNoTypeAnnotation() throws { 17 | assertMacro( 18 | """ 19 | @Codable 20 | public struct User { 21 | let id: UUID 22 | let name: String 23 | var age = genSomeThing() 24 | } 25 | """, 26 | expandedSource: """ 27 | public struct User { 28 | let id: UUID 29 | let name: String 30 | var age = genSomeThing() 31 | } 32 | """, 33 | diagnostics: [ 34 | .init(message: "Properties must have a type annotation", line: 1, column: 1) 35 | ] 36 | ) 37 | } 38 | 39 | @Test func macroWithIgnoredPropertyTypeAnnotation() throws { 40 | 41 | assertMacro( 42 | """ 43 | @Codable 44 | public struct User { 45 | let id: UUID 46 | let name: String 47 | let age: Int 48 | @CodableKey(options: .ignored) 49 | var ignored: String = "Hello World" 50 | } 51 | """, 52 | expandedSource: """ 53 | public struct User { 54 | let id: UUID 55 | let name: String 56 | let age: Int 57 | var ignored: String = "Hello World" 58 | 59 | public func encode(to encoder: any Encoder) throws { 60 | var container = encoder.container(keyedBy: CodingKeys.self) 61 | try container.encode(id, forKey: .id) 62 | try container.encode(name, forKey: .name) 63 | try container.encode(age, forKey: .age) 64 | } 65 | } 66 | 67 | extension User: Codable { 68 | enum CodingKeys: String, CodingKey { 69 | case id 70 | case name 71 | case age 72 | } 73 | 74 | public init(from decoder: any Decoder) throws { 75 | let container = try decoder.container(keyedBy: CodingKeys.self) 76 | id = try container.decode(UUID.self, forKey: .id) 77 | name = try container.decode(String.self, forKey: .name) 78 | age = try container.decode(Int.self, forKey: .age) 79 | } 80 | } 81 | """ 82 | ) 83 | 84 | } 85 | 86 | @Test func macroWithStaticTypeAnnotation() throws { 87 | 88 | assertMacro( 89 | """ 90 | @Codable 91 | public struct User { 92 | let id: UUID 93 | let name: String 94 | let age: Int 95 | 96 | static let staticProperty = "Hello World" 97 | } 98 | """, 99 | expandedSource: """ 100 | public struct User { 101 | let id: UUID 102 | let name: String 103 | let age: Int 104 | 105 | static let staticProperty = "Hello World" 106 | 107 | public func encode(to encoder: any Encoder) throws { 108 | var container = encoder.container(keyedBy: CodingKeys.self) 109 | try container.encode(id, forKey: .id) 110 | try container.encode(name, forKey: .name) 111 | try container.encode(age, forKey: .age) 112 | } 113 | } 114 | 115 | extension User: Codable { 116 | enum CodingKeys: String, CodingKey { 117 | case id 118 | case name 119 | case age 120 | } 121 | 122 | public init(from decoder: any Decoder) throws { 123 | let container = try decoder.container(keyedBy: CodingKeys.self) 124 | id = try container.decode(UUID.self, forKey: .id) 125 | name = try container.decode(String.self, forKey: .name) 126 | age = try container.decode(Int.self, forKey: .age) 127 | } 128 | } 129 | """ 130 | ) 131 | 132 | } 133 | 134 | @Test func macroOnComputeProperty() throws { 135 | 136 | assertMacro( 137 | """ 138 | @Codable 139 | public struct User { 140 | let id: UUID 141 | let name: String 142 | var age: Int = 24 143 | @CodableKey("hello") 144 | var address: String { 145 | "A" 146 | } 147 | } 148 | """, 149 | expandedSource: """ 150 | public struct User { 151 | let id: UUID 152 | let name: String 153 | var age: Int = 24 154 | var address: String { 155 | "A" 156 | } 157 | 158 | public func encode(to encoder: any Encoder) throws { 159 | var container = encoder.container(keyedBy: CodingKeys.self) 160 | try container.encode(id, forKey: .id) 161 | try container.encode(name, forKey: .name) 162 | try container.encode(age, forKey: .age) 163 | } 164 | } 165 | 166 | extension User: Codable { 167 | enum CodingKeys: String, CodingKey { 168 | case id 169 | case name 170 | case age 171 | } 172 | 173 | public init(from decoder: any Decoder) throws { 174 | let container = try decoder.container(keyedBy: CodingKeys.self) 175 | id = try container.decode(UUID.self, forKey: .id) 176 | name = try container.decode(String.self, forKey: .name) 177 | age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 24 178 | } 179 | } 180 | """, 181 | diagnostics: [ 182 | .init(message: "Only variable declarations with no accessor block are supported", line: 6, column: 3) 183 | ] 184 | ) 185 | 186 | } 187 | 188 | @Test func macroOnStaticComputeProperty() throws { 189 | 190 | assertMacro( 191 | """ 192 | @Codable 193 | public struct User { 194 | let id: UUID 195 | let name: String 196 | var age: Int = 24 197 | @CodableKey("hello") 198 | static var address: String { 199 | "A" 200 | } 201 | } 202 | """, 203 | expandedSource: """ 204 | public struct User { 205 | let id: UUID 206 | let name: String 207 | var age: Int = 24 208 | static var address: String { 209 | "A" 210 | } 211 | 212 | public func encode(to encoder: any Encoder) throws { 213 | var container = encoder.container(keyedBy: CodingKeys.self) 214 | try container.encode(id, forKey: .id) 215 | try container.encode(name, forKey: .name) 216 | try container.encode(age, forKey: .age) 217 | } 218 | } 219 | 220 | extension User: Codable { 221 | enum CodingKeys: String, CodingKey { 222 | case id 223 | case name 224 | case age 225 | } 226 | 227 | public init(from decoder: any Decoder) throws { 228 | let container = try decoder.container(keyedBy: CodingKeys.self) 229 | id = try container.decode(UUID.self, forKey: .id) 230 | name = try container.decode(String.self, forKey: .name) 231 | age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 24 232 | } 233 | } 234 | """, 235 | diagnostics: [ 236 | .init(message: "Only variable declarations with no accessor block are supported", line: 6, column: 3) 237 | ] 238 | ) 239 | 240 | } 241 | 242 | @Test func macroOnStaticProperty() throws { 243 | 244 | assertMacro( 245 | """ 246 | @Codable 247 | public struct User { 248 | let id: UUID 249 | let name: String 250 | var age: Int = 24 251 | @CodableKey("hello") 252 | static var address: String = "A" 253 | } 254 | """, 255 | expandedSource: """ 256 | public struct User { 257 | let id: UUID 258 | let name: String 259 | var age: Int = 24 260 | static var address: String = "A" 261 | 262 | public func encode(to encoder: any Encoder) throws { 263 | var container = encoder.container(keyedBy: CodingKeys.self) 264 | try container.encode(id, forKey: .id) 265 | try container.encode(name, forKey: .name) 266 | try container.encode(age, forKey: .age) 267 | } 268 | } 269 | 270 | extension User: Codable { 271 | enum CodingKeys: String, CodingKey { 272 | case id 273 | case name 274 | case age 275 | } 276 | 277 | public init(from decoder: any Decoder) throws { 278 | let container = try decoder.container(keyedBy: CodingKeys.self) 279 | id = try container.decode(UUID.self, forKey: .id) 280 | name = try container.decode(String.self, forKey: .name) 281 | age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 24 282 | } 283 | } 284 | """, 285 | diagnostics: [ 286 | .init(message: "Only non-static variable declarations are supported", line: 6, column: 3) 287 | ] 288 | ) 289 | } 290 | 291 | @Test func nameKeyConflictA() async throws { 292 | assertMacro( 293 | """ 294 | @Codable 295 | public struct User { 296 | let id: UUID 297 | @CodableKey("id") let name: String 298 | } 299 | """, 300 | expandedSource: 301 | """ 302 | public struct User { 303 | let id: UUID 304 | let name: String 305 | } 306 | """, 307 | diagnostics: [ 308 | .init(message: "Key conflict found: id", line: 1, column: 1) 309 | ] 310 | ) 311 | } 312 | 313 | @Test func nameKeyConflictB() async throws { 314 | assertMacro( 315 | """ 316 | @Codable 317 | public struct User { 318 | @CodableKey("expireTime", options: .useDefaultOnFailure) private let _expireTime: Int? 319 | @CodableKey(options: .ignored) public private(set) var expireTime: Date? 320 | } 321 | """, 322 | expandedSource: 323 | """ 324 | public struct User { 325 | private let _expireTime: Int? 326 | public private(set) var expireTime: Date? 327 | 328 | public func encode(to encoder: any Encoder) throws { 329 | var container = encoder.container(keyedBy: CodingKeys.self) 330 | try container.encodeIfPresent(_expireTime, forKey: ._expireTime) 331 | } 332 | } 333 | 334 | extension User: Codable { 335 | enum CodingKeys: String, CodingKey { 336 | case _expireTime = "expireTime" 337 | } 338 | 339 | public init(from decoder: any Decoder) throws { 340 | let container = try decoder.container(keyedBy: CodingKeys.self) 341 | _expireTime = (try? container.decodeIfPresent(Int?.self, forKey: ._expireTime)) ?? nil 342 | } 343 | } 344 | """ 345 | ) 346 | } 347 | } 348 | --------------------------------------------------------------------------------