├── .github └── workflows │ └── build.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── NOTICE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Codable │ └── Codable.swift ├── CodableClient │ └── main.swift └── CodableMacros │ ├── Codable │ ├── CodableIgnoredMacro.swift │ ├── CodableKeyMacro.swift │ ├── CodableMacro.swift │ ├── CodableMacroError.swift │ ├── CodingContainer.swift │ ├── CodingPath.swift │ ├── CustomDecodedMacro.swift │ ├── DecodableMacro.swift │ ├── EncodableMacro.swift │ ├── PropertyDefinition.swift │ ├── SwiftSyntax+Extensions.swift │ └── TypeDefinition.swift │ ├── CodablePlugin.swift │ └── MemberwiseInitializable │ ├── MemberwiseInitializableMacro.swift │ └── MemberwiseInitializableMacroError.swift └── Tests └── CodableTests ├── CodableTests.swift ├── DecodableTests.swift ├── EncodableTests.swift ├── MemberwiseInitializableTests.swift └── XCTest+AssertAndCompileMacroExpansion.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Schibsted News Media AB. 2 | # Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | name: Build 5 | 6 | on: 7 | workflow_call: 8 | pull_request: 9 | types: ["opened", "reopened", "synchronize"] 10 | push: 11 | branches: 12 | - main 13 | 14 | env: 15 | NSUnbufferedIO: YES 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | build_ios: 23 | name: "Build & Test" 24 | runs-on: macos-latest 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | 30 | - name: Select Xcode Version 31 | run: sudo xcode-select -switch /Applications/Xcode_16.1.app 32 | 33 | - name: Build & Test 34 | id: build-and-test 35 | run: swift test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Schibsted News Media AB. 2 | # Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | .DS_Store 5 | 6 | ## Build generated 7 | build/ 8 | DerivedData/ 9 | BuildSystem/.fl* 10 | 11 | ## Xcode 12 | *.xcproj 13 | *.xcworkspace 14 | *.xctimeline 15 | 16 | ## Other 17 | *.moved-aside 18 | *.orig 19 | .autoformatter 20 | 21 | ## Obj-C/Swift specific 22 | *.hmap 23 | *.ipa 24 | *.dSYM.zip 25 | *.dSYM 26 | 27 | # Swift Package Manager 28 | .build/ 29 | .swiftpm 30 | .swiftlint 31 | Packages/ 32 | .swiftpm/configuration/registries.json 33 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 34 | .netrc 35 | 36 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @app-foundation/apps-ios-dev 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Schibsted News Media AB. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Schibsted News Media AB. 2 | 3 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 4 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 5 | 6 | See the License for the specific language governing permissions and limitations under the License. 7 | 8 | Apache Commons Lang 9 | LICENSE: Apache 2.0 10 | NOTICE: https://github.com/apache/commons-lang/blob/master/NOTICE.txt 11 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-syntax-xcframeworks", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/schibsted/swift-syntax-xcframeworks.git", 7 | "state" : { 8 | "revision" : "f85d4fcb36469f494f765b51d4e30045b3f49a8c", 9 | "version" : "600.0.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | // Copyright 2025 Schibsted News Media AB. 4 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 5 | 6 | import PackageDescription 7 | import CompilerPluginSupport 8 | 9 | let package = Package( 10 | name: "Codable", 11 | platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], 12 | products: [ 13 | .library( 14 | name: "Codable", 15 | targets: ["Codable"] 16 | ), 17 | .executable( 18 | name: "CodableClient", 19 | targets: ["CodableClient"] 20 | ), 21 | ], 22 | dependencies: [ 23 | // We use a pre-built dependency on the swift-syntax package (https://github.com/swiftlang/swift-syntax) 24 | // in order to prevent excessive slow compilation. 25 | // See https://forums.swift.org/t/compilation-extremely-slow-since-macros-adoption/67921/132 for details. 26 | .package(url: "https://github.com/schibsted/swift-syntax-xcframeworks.git", from: "600.0.1"), 27 | ], 28 | targets: [ 29 | .macro( 30 | name: "CodableMacros", 31 | dependencies: [ 32 | .product(name: "SwiftSyntaxWrapper", package: "swift-syntax-xcframeworks"), 33 | ] 34 | ), 35 | .target(name: "Codable", dependencies: ["CodableMacros"]), 36 | .executableTarget(name: "CodableClient", dependencies: ["Codable"]), 37 | .testTarget( 38 | name: "CodableTests", 39 | dependencies: [ 40 | "CodableMacros", 41 | .product(name: "SwiftSyntaxWrapper", package: "swift-syntax-xcframeworks"), 42 | ] 43 | ), 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codable-macro 2 | A Swift macro that can generate Codable implementations. 3 | 4 | ## Motivation 5 | 6 | Using `Codable` is the standard approach to serialization in Swift (for a number of reasons). In simple cases, using it 7 | is as simple as conforming the type to the `Codable` and letting the compiler synthesize all the boilerplate. 8 | 9 | In real-world projects, however, things are rarely that simple. The JSON data that needs to be deserialized often has a 10 | different structure, different key names, invalid values, etc. `Codable` tries to accommodate these issues (for example, 11 | by supporting custom decoding strategies for keys), but often this is not enough which means having to write massive 12 | amounts of boilerplate code by hand. 13 | 14 | Another feature of `Codable` that may cause issues is error handling. By default, decoding errors are propagated all the 15 | way up, which means a single type deep down the object tree failing to decode causes the entire object tree to fail to 16 | decode. Generally, this is a reasonable error handling strategy which is consistent with the Swift philosophy of failing 17 | early, but it is not always optimal. Sometimes potential incompleteness of the decoded data is acceptable and even 18 | preferrable over breaking features for hundreds of thousands of users, but the only way to make the decoding logic more 19 | robust is to implement it by hand. 20 | 21 | ## Goal 22 | 23 | The goal of this project is to provide an intuitive, easy to use way to generate robust serialization logic. 24 | 25 | ## Features 26 | 27 | ### Conform a type to `Codable` 28 | 29 | To make a type codable, apply the `@Codable` macro to it: 30 | 31 | ```swift 32 | @Codable 33 | public struct Foo { 34 | let bar: String 35 | } 36 | ``` 37 | 38 |
39 | Macro expansion 40 | 41 | ```swift 42 | @Codable 43 | struct Foo { 44 | let bar: String 45 | 46 | init( 47 | bar: String 48 | ) { 49 | self.bar = bar 50 | } 51 | 52 | init(from decoder: Decoder) throws { 53 | let container = try decoder.container(keyedBy: CodingKeys.self) 54 | 55 | bar = try container.decode(String.self, forKey: .bar) 56 | } 57 | 58 | func encode(to encoder: Encoder) throws { 59 | var container = encoder.container(keyedBy: CodingKeys.self) 60 | 61 | try container.encode(bar, forKey: .bar) 62 | } 63 | 64 | enum CodingKeys: String, CodingKey { 65 | case bar 66 | } 67 | } 68 | 69 | extension Foo: Codable { 70 | } 71 | ``` 72 | 73 |
74 | 75 | #### Examples 76 | 77 | JSON | Decoded value 78 | -|- 79 | `{ "bar": "hello world" }` | `Foo(bar: "hello world")` 80 | 81 | **NOTE:** If you only need `Decodable` or `Encodable` conformance, you can use the `@Decodable` or `@Encodable` macros 82 | instead. 83 | 84 | ### Optional properties 85 | 86 | ```swift 87 | @Codable 88 | public struct Foo { 89 | let bar: String? 90 | } 91 | ``` 92 | 93 |
94 | Macro expansion 95 | 96 | ```swift 97 | @Codable 98 | struct Foo { 99 | let bar: String 100 | 101 | init( 102 | bar: String? = nil 103 | ) { 104 | self.bar = bar 105 | } 106 | 107 | init(from decoder: Decoder) throws { 108 | let container = try decoder.container(keyedBy: CodingKeys.self) 109 | 110 | do { 111 | bar = try container.decode(String.self, forKey: .bar) 112 | } catch { 113 | bar = nil 114 | } 115 | } 116 | 117 | func encode(to encoder: Encoder) throws { 118 | var container = encoder.container(keyedBy: CodingKeys.self) 119 | 120 | try container.encodeIfPresent(bar, forKey: .bar) 121 | } 122 | 123 | enum CodingKeys: String, CodingKey { 124 | case bar 125 | } 126 | } 127 | 128 | extension Foo: Codable { 129 | } 130 | ``` 131 | 132 |
133 | 134 | **NOTE:** If an optional property fails to decode for some reason, the generated decoding logic will fall back to `nil` 135 | instead of throwing the error. Also, `nil` will be the default value of the corresponding parameter of the generated 136 | memberwise initializer. 137 | 138 | #### Examples 139 | 140 | JSON | Decoded value 141 | -|- 142 | `{ "bar": "hello world" }` | `Foo(bar: "hello world")` 143 | `{ "bar": null }` | `Foo(bar: nil)` 144 | `{ "bar": 0 }` | `Foo(bar: nil)` 145 | 146 | ### Default values 147 | 148 | If you would like to specify a default value to use during decoding, you can do it just like you normally would for 149 | non-codable types: 150 | 151 | ```swift 152 | @Codable 153 | public struct Foo { 154 | var bar: String = "some default value" 155 | } 156 | ``` 157 | 158 |
159 | Macro expansion 160 | 161 | ```swift 162 | @Codable 163 | struct Foo { 164 | let bar: String 165 | 166 | init( 167 | bar: String = "some default value" 168 | ) { 169 | self.bar = bar 170 | } 171 | 172 | init(from decoder: Decoder) throws { 173 | let container = try decoder.container(keyedBy: CodingKeys.self) 174 | 175 | do { 176 | bar = try container.decode(String.self, forKey: .bar) 177 | } catch { 178 | bar = "some default value" 179 | } 180 | } 181 | 182 | func encode(to encoder: Encoder) throws { 183 | var container = encoder.container(keyedBy: CodingKeys.self) 184 | 185 | try container.encode(bar, forKey: .bar) 186 | } 187 | 188 | enum CodingKeys: String, CodingKey { 189 | case bar 190 | } 191 | } 192 | 193 | extension Foo: Codable { 194 | } 195 | ``` 196 | 197 |
198 | 199 | **NOTE:** Similarly to the way optional properties are handled, if a property with a default value fails to decode for 200 | some reason, the generated decoding logic will fall back to the default value instead of throwing the error. Also, the 201 | default value will be used in the generated memberwise initializer. 202 | 203 | #### Examples 204 | 205 | JSON | Decoded value 206 | -|- 207 | `{ "bar": "hello world" }` | `Foo(bar: "hello world")` 208 | `{ "bar": null }` | `Foo(bar: "some default value")` 209 | `{ "bar": 0 }` | `Foo(bar: "some default value")` 210 | 211 | ### Lossy decoding of collection types 212 | 213 | If an item inside a JSON array fails to decode, it is quietly discarded. This applies to dictionary values as well. 214 | 215 | ```swift 216 | @Codable 217 | public struct Foo { 218 | let bar: [String] 219 | } 220 | ``` 221 | 222 |
223 | Macro expansion 224 | 225 | ```swift 226 | @Codable 227 | public struct Foo { 228 | let bar: [String] 229 | 230 | init( 231 | bar: Array 232 | ) { 233 | self.bar = bar 234 | } 235 | 236 | init(from decoder: Decoder) throws { 237 | let container = try decoder.container(keyedBy: CodingKeys.self) 238 | 239 | bar = try container.decode([FailableContainer].self, forKey: .bar).compactMap { 240 | $0.wrappedValue 241 | } 242 | } 243 | 244 | func encode(to encoder: Encoder) throws { 245 | var container = encoder.container(keyedBy: CodingKeys.self) 246 | 247 | try container.encode(bar, forKey: .bar) 248 | } 249 | 250 | enum CodingKeys: String, CodingKey { 251 | case bar 252 | } 253 | 254 | private struct FailableContainer: Decodable where T: Decodable { 255 | var wrappedValue: T? 256 | 257 | init(from decoder: Decoder) throws { 258 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 259 | } 260 | } 261 | } 262 | ``` 263 | 264 |
265 | 266 | #### Examples 267 | 268 | JSON | Decoded value 269 | -|- 270 | `{ "bar": ["hello world"] }` | `Foo(bar: ["hello world"])` 271 | `{ "bar": ["I'm a string", 42] }` | `Foo(bar: ["I'm a string"])` 272 | 273 | ### Custom coding keys 274 | 275 | If the name of a property doesn't match the JSON, you can specify the JSON name using the `@CodableKey("name")` macro. 276 | If you need to decode a property from a nested object, you can specify the key path to the data using the familiar 277 | dot-separated key syntax: `@CodableKey("path.to.name")`. 278 | 279 | ```swift 280 | @Codable 281 | struct Foo { 282 | @CodableKey("__baz") 283 | var baz: Int 284 | 285 | @CodableKey("qux.bar") 286 | var bar: String 287 | } 288 | ``` 289 | 290 |
291 | Macro expansion 292 | 293 | ```swift 294 | struct Foo { 295 | @CodableKey("__baz") 296 | var baz: Int 297 | 298 | @CodableKey("qux.bar") 299 | var bar: String 300 | 301 | init( 302 | baz: Int, 303 | bar: String 304 | ) { 305 | self.baz = baz 306 | self.bar = bar 307 | } 308 | 309 | init(from decoder: Decoder) throws { 310 | let container = try decoder.container(keyedBy: CodingKeys.self) 311 | 312 | baz = try container.decode(Int.self, forKey: .baz) 313 | 314 | do { 315 | let quxContainer = try container.nestedContainer(keyedBy: CodingKeys.QuxCodingKeys.self, forKey: .qux) 316 | bar = try quxContainer.decode(String.self, forKey: .bar) 317 | } catch { 318 | throw error 319 | } 320 | } 321 | 322 | func encode(to encoder: Encoder) throws { 323 | var container = encoder.container(keyedBy: CodingKeys.self) 324 | var quxContainer = container.nestedContainer(keyedBy: CodingKeys.QuxCodingKeys.self, forKey: .qux) 325 | 326 | try container.encode(baz, forKey: .baz) 327 | try quxContainer.encode(bar, forKey: .bar) 328 | } 329 | 330 | enum CodingKeys: String, CodingKey { 331 | case baz = "__baz", qux 332 | 333 | enum QuxCodingKeys: String, CodingKey { 334 | case bar 335 | } 336 | } 337 | } 338 | 339 | extension Foo: Codable { 340 | } 341 | ``` 342 | 343 |
344 | 345 | #### Examples 346 | 347 | JSON | Decoded value 348 | -|- 349 | `{ "__baz": 11, "qux": { "bar": "a deeply nested string" } }` | `Foo(baz: 11, bar: "a deeply nested string")` 350 | 351 | ### Ignore certain properties 352 | 353 | If you need to ignore certain properties, apply the `@CodableIgnored` macro to them. 354 | 355 | ```swift 356 | @Codable 357 | struct Foo { 358 | @CodableIgnored 359 | var uuid: UUID = UUID() 360 | var bar: String 361 | } 362 | ``` 363 | 364 |
365 | Macro expansion 366 | 367 | ```swift 368 | @Codable 369 | struct Foo { 370 | @CodableIgnored 371 | var uuid: UUID = UUID() 372 | var bar: String 373 | 374 | init( 375 | uuid: UUID = UUID(), 376 | bar: String 377 | ) { 378 | self.uuid = uuid 379 | self.bar = bar 380 | } 381 | 382 | init(from decoder: Decoder) throws { 383 | let container = try decoder.container(keyedBy: CodingKeys.self) 384 | 385 | bar = try container.decode(String.self, forKey: .bar) 386 | } 387 | 388 | func encode(to encoder: Encoder) throws { 389 | var container = encoder.container(keyedBy: CodingKeys.self) 390 | 391 | try container.encode(bar, forKey: .bar) 392 | } 393 | 394 | enum CodingKeys: String, CodingKey { 395 | case bar 396 | } 397 | } 398 | 399 | extension Foo: Codable { 400 | } 401 | ``` 402 | 403 |
404 | 405 | #### Examples 406 | 407 | JSON | Decoded value 408 | -|- 409 | `{ "bar": "hello world" }` | `Foo(uuid: 57FCCD12-7DE6-4BE9-9F16-A5B164A47D8F, bar: "hello world")` 410 | 411 | ### Specify custom decoding logic for certain properties 412 | 413 | If simply decoding a property is not enough and you need to transform it in some way, mark it with the `@CustomDecoded` 414 | macro and provide the custom decoding logic in a static throwing function named `decodeXXX`: 415 | 416 | ```swift 417 | @Codable 418 | struct Foo { 419 | var qux: Int 420 | 421 | @CustomDecoded 422 | var bar: String 423 | 424 | static func decodeBar(from decoder: Decoder) throws -> String { 425 | let container = try decoder.container(keyedBy: CodingKeys.self) 426 | let value = try container.decode(String.self, forKey: .bar) 427 | return "Fancy custom decoded \(value)!" 428 | } 429 | } 430 | ``` 431 | 432 |
433 | Macro expansion 434 | 435 | ```swift 436 | @Codable 437 | struct Foo { 438 | var qux: Int 439 | 440 | @CustomDecoded 441 | var bar: String 442 | 443 | static func decodeBar(from decoder: Decoder) throws -> String { 444 | let container = try decoder.container(keyedBy: CodingKeys.self) 445 | let value = try container.decode(String.self, forKey: .bar) 446 | return "Fancy custom decoded \(value)!" 447 | } 448 | 449 | init( 450 | qux: Int, 451 | bar: String 452 | ) { 453 | self.qux = qux 454 | self.bar = bar 455 | } 456 | 457 | init(from decoder: Decoder) throws { 458 | let container = try decoder.container(keyedBy: CodingKeys.self) 459 | 460 | qux = try container.decode(Int.self, forKey: .qux) 461 | bar = try Self.decodeBar(from: decoder) 462 | } 463 | 464 | func encode(to encoder: Encoder) throws { 465 | var container = encoder.container(keyedBy: CodingKeys.self) 466 | 467 | try container.encode(qux, forKey: .qux) 468 | try container.encode(bar, forKey: .bar) 469 | } 470 | 471 | enum CodingKeys: String, CodingKey { 472 | case bar, qux 473 | } 474 | } 475 | 476 | extension Foo: Codable { 477 | } 478 | ``` 479 | 480 |
481 | 482 | #### Examples 483 | 484 | JSON | Decoded value 485 | -|- 486 | `{ "qux": 42, "bar": "hello world" }` | `Foo(qux: 42, bar: "Fancy custom decoded hello world!")` 487 | 488 | ### Custom validation logic 489 | 490 | If you need to provide additional validation logic for your codable types, use the `needsValidation` parameter: 491 | `@Codable(needsValidation: true)` (or `@Codable(needsValidation: true)`) and place your validation logic in the computed 492 | property named `isValid`: 493 | 494 | ```swift 495 | @Codable(needsValidation: true) 496 | struct Foo { 497 | var qux: Int 498 | 499 | var isValid: Bool { 500 | qux <= 9000 501 | } 502 | } 503 | ``` 504 | 505 |
506 | Macro expansion 507 | 508 | ```swift 509 | @Codable(needsValidation: true) 510 | struct Foo { 511 | var qux: Int 512 | 513 | var isValid: Bool { 514 | qux <= 9000 515 | } 516 | 517 | init( 518 | qux: Int 519 | ) { 520 | self.qux = qux 521 | } 522 | 523 | init(from decoder: Decoder) throws { 524 | let container = try decoder.container(keyedBy: CodingKeys.self) 525 | 526 | qux = try container.decode(Int.self, forKey: .qux) 527 | 528 | if !self.isValid { 529 | throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Validation failed")) 530 | } 531 | } 532 | 533 | func encode(to encoder: Encoder) throws { 534 | var container = encoder.container(keyedBy: CodingKeys.self) 535 | 536 | try container.encode(qux, forKey: .qux) 537 | } 538 | 539 | enum CodingKeys: String, CodingKey { 540 | case qux 541 | } 542 | } 543 | 544 | extension Foo: Codable { 545 | } 546 | ``` 547 | 548 |
549 | 550 | #### Examples 551 | 552 | JSON | Decoded value 553 | -|- 554 | `{ "qux": 42 }` | `Foo(qux: 42)` 555 | `{ "qux": 9001 }` | `DecodingError.dataCorrupted(debugDescription: "Validation failed")` 556 | 557 | 558 | ### Generate a memberwise initializer 559 | 560 | Applying `@Codable` or `@Decodable` macros to a type generates a memberwise initializer as well, with the same access 561 | level as the type. You can also generate a memberwise initializer by applying the `@MemberwiseInitializable` macro to 562 | the type: 563 | 564 | ```swift 565 | @MemberwiseInitializable 566 | public struct Foo { 567 | let bar: String 568 | } 569 | ``` 570 | 571 |
572 | Macro expansion 573 | 574 | ```swift 575 | @MemberwiseInitializable 576 | public struct Foo { 577 | let bar: String 578 | 579 | public init( 580 | bar: String 581 | ) { 582 | self.bar = bar 583 | } 584 | } 585 | ``` 586 | 587 |
588 | 589 | You can also specify the desired access level: 590 | 591 | ```swift 592 | @MemberwiseInitializable(.fileprivate) 593 | public struct Foo { 594 | let bar: String 595 | } 596 | ``` 597 | 598 |
599 | Macro expansion 600 | 601 | ```swift 602 | @MemberwiseInitializable(.fileprivate) 603 | public struct Foo { 604 | let bar: String 605 | 606 | fileprivate init( 607 | bar: String 608 | ) { 609 | self.bar = bar 610 | } 611 | } 612 | ``` 613 | 614 |
615 | 616 | See **main.swift** for more examples. 617 | 618 | ## NOTICE 619 | 620 | Copyright 2025 Schibsted News Media AB. 621 | 622 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 623 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 624 | 625 | See the License for the specific language governing permissions and limitations under the License. 626 | 627 | -------------------------------------------------------------------------------- /Sources/Codable/Codable.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | // The Swift Programming Language 5 | // https://docs.swift.org/swift-book 6 | 7 | @attached(extension, conformances: Codable) 8 | @attached(member, names: named(init), named(init(from:)), named(encode(to:)), named(CodingKeys), named(FailableContainer)) 9 | public macro Codable(needsValidation: Bool = false) = #externalMacro(module: "CodableMacros", type: "CodableMacro") 10 | 11 | @attached(extension, conformances: Decodable) 12 | @attached(member, names: named(init), named(init(from:)), named(CodingKeys), named(FailableContainer)) 13 | public macro Decodable(needsValidation: Bool = false) = #externalMacro(module: "CodableMacros", type: "DecodableMacro") 14 | 15 | @attached(extension, conformances: Encodable) 16 | @attached(member, names: named(encode(to:)), named(CodingKeys)) 17 | public macro Encodable() = #externalMacro(module: "CodableMacros", type: "EncodableMacro") 18 | 19 | @attached(peer) 20 | public macro CodableKey(_ key: String) = #externalMacro(module: "CodableMacros", type: "CodableKeyMacro") 21 | 22 | @attached(peer) 23 | public macro CodableIgnored() = #externalMacro(module: "CodableMacros", type: "CodableIgnoredMacro") 24 | 25 | @attached(peer) 26 | public macro CustomDecoded() = #externalMacro(module: "CodableMacros", type: "CustomDecodedMacro") 27 | 28 | @attached(member, names: named(init)) 29 | public macro MemberwiseInitializable(_ accessLevel: MemberAccessLevel? = nil) = #externalMacro(module: "CodableMacros", type: "MemberwiseInitializableMacro") 30 | 31 | public enum MemberAccessLevel { 32 | case `public`, `internal`, `fileprivate`, `private` 33 | } 34 | -------------------------------------------------------------------------------- /Sources/CodableClient/main.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import Foundation 5 | import Codable 6 | 7 | @Codable(needsValidation: true) @MemberwiseInitializable 8 | public struct Foo: Equatable { 9 | @CodableKey("beer.doo") 10 | var bar: String 11 | 12 | @CodableKey("beer.fus") 13 | var fus: String 14 | 15 | @CodableKey("ro.duh.dah") 16 | var dah: String 17 | 18 | @CodableKey("booz") 19 | var baz: Int? 20 | 21 | @CodableKey("qox") 22 | var qux: [Foo.Qux] = [.one] 23 | 24 | var array: [String] = [] 25 | 26 | var optionalArray: [Int]? 27 | 28 | var someSet: Set 29 | 30 | var dict: [String: Int] 31 | 32 | @CodableIgnored 33 | var neverMindMe: String = "some value" 34 | 35 | @CustomDecoded // needs to be all caps 36 | var specialProperty: String? 37 | 38 | @Codable 39 | public enum Qux: String, Equatable { 40 | case one, two 41 | } 42 | 43 | private static func decodeSpecialProperty(from decoder: Decoder) throws -> String { 44 | try decoder 45 | .container(keyedBy: CodingKeys.self) 46 | .decode(String.self, forKey: .specialProperty) 47 | .uppercased() 48 | } 49 | 50 | private var isValid: Bool { optionalArray?.isEmpty != true } 51 | } 52 | 53 | @Decodable 54 | struct SomeDecodable { 55 | let bar: String 56 | } 57 | 58 | @Encodable 59 | struct SomeEncodable { 60 | let bar: String 61 | } 62 | 63 | @Decodable 64 | struct Outer { 65 | @Decodable 66 | struct Inner { 67 | @Decodable 68 | struct Innermost { 69 | } 70 | } 71 | 72 | let thing: Outer.Inner.Innermost 73 | } 74 | 75 | let subjects: [String: Foo] = [ 76 | "vanilla": Foo(bar: "bar", fus: "hello", dah: "world", baz: 1, qux: [.two], array: ["a"], optionalArray: [1, 2], someSet: ["a", "b"], dict: [:]), 77 | "with optional": Foo(bar: "bar", fus: "hello", dah: "world", baz: nil, qux: [.two], array: [], optionalArray: nil, someSet: [], dict: [:]), 78 | "invalid": Foo(bar: "bar", fus: "hello", dah: "world", baz: nil, qux: [.two], array: [], optionalArray: [], someSet: [], dict: [:]) 79 | ] 80 | 81 | print("\nENCODING AND DECODING BACK:") 82 | 83 | try subjects.forEach { (key, foo) in 84 | print("\n'\(key)':") 85 | let encoder = JSONEncoder() 86 | encoder.outputFormatting = .prettyPrinted 87 | 88 | let json = try encoder.encode(foo) 89 | print(String(data: json, encoding: .utf8)!) 90 | 91 | do { 92 | let foo2 = try JSONDecoder().decode(Foo.self, from: json) 93 | assert(foo == foo2, "\(key) failed the equality check") 94 | } catch { 95 | print("Failed to decode '\(key)': \(error)") 96 | } 97 | } 98 | 99 | print("\nDECODING:") 100 | 101 | let jsons = [ 102 | """ 103 | { 104 | "beer" : { 105 | "doo": "I'm a string", 106 | "fus": "Me too" 107 | }, 108 | "ro": { 109 | "duh": { 110 | "dah": "Hello world" 111 | } 112 | }, 113 | "booz": 1, 114 | "qox": ["1", "two"], 115 | "optionalArray": [1, 2, 3], 116 | "someSet": ["a", "a", "a"], 117 | "specialProperty": "some value", 118 | "dict": { 119 | "foo": 42, 120 | "fii": "not an Int" 121 | } 122 | } 123 | """ 124 | ] 125 | 126 | try jsons.forEach { json in 127 | print("\njson: ", json) 128 | let foo = try JSONDecoder().decode(Foo.self, from: json.data(using: .utf8)!) 129 | print("\ndecoded: ", foo) 130 | } 131 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CodableIgnoredMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct CodableIgnoredMacro {} 10 | 11 | extension CodableIgnoredMacro: PeerMacro { 12 | static let attributeName = "CodableIgnored" 13 | 14 | public static func expansion( 15 | of node: AttributeSyntax, 16 | providingPeersOf declaration: some DeclSyntaxProtocol, 17 | in context: some MacroExpansionContext 18 | ) throws -> [DeclSyntax] { 19 | [] // This macro doesn't generate any code 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CodableKeyMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct CodableKeyMacro {} 10 | 11 | extension CodableKeyMacro: PeerMacro { 12 | static let attributeName = "CodableKey" 13 | 14 | public static func expansion( 15 | of node: AttributeSyntax, 16 | providingPeersOf declaration: some DeclSyntaxProtocol, 17 | in context: some MacroExpansionContext 18 | ) throws -> [DeclSyntax] { 19 | [] // This macro doesn't generate any code 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CodableMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct CodableMacro {} 10 | 11 | extension CodableMacro: ExtensionMacro { 12 | 13 | public static func expansion( 14 | of node: AttributeSyntax, 15 | attachedTo declaration: some DeclGroupSyntax, 16 | providingExtensionsOf type: some TypeSyntaxProtocol, 17 | conformingTo protocols: [TypeSyntax], 18 | in context: some MacroExpansionContext 19 | ) throws -> [ExtensionDeclSyntax] { 20 | if declaration is ProtocolDeclSyntax || declaration is ActorDeclSyntax { 21 | return [] 22 | } 23 | 24 | if declaration.attributes.containsMultipleCodableMacros { 25 | return [] 26 | } 27 | 28 | return [try ExtensionDeclSyntax("extension \(type): Codable {}")] 29 | } 30 | } 31 | 32 | extension CodableMacro: MemberMacro { 33 | 34 | public static func expansion( 35 | of node: AttributeSyntax, 36 | providingMembersOf declaration: some DeclGroupSyntax, 37 | in context: some MacroExpansionContext 38 | ) throws -> [DeclSyntax] { 39 | guard !(declaration is ActorDeclSyntax) else { 40 | throw CodableMacroError.notApplicableToActor 41 | } 42 | 43 | guard !(declaration is ProtocolDeclSyntax) else { 44 | throw CodableMacroError.notApplicableToProtocol 45 | } 46 | 47 | guard !declaration.attributes.containsMultipleCodableMacros else { 48 | throw CodableMacroError.moreThanOneCodableMacroApplied 49 | } 50 | 51 | if declaration is EnumDeclSyntax { 52 | return [] 53 | } 54 | 55 | let storedProperties = try declaration.memberBlock.members 56 | .compactMap { try PropertyDefinition(declaration: $0.decl) } 57 | .filter { !$0.isExcludedFromCodable } 58 | 59 | if storedProperties.isEmpty { 60 | return [] 61 | } 62 | 63 | let shouldIncludeFailableContainer = storedProperties 64 | .contains(where: { $0.type.isCollection }) 65 | 66 | guard let codingContainer = CodingContainer(paths: storedProperties.map { $0.codingPath }) else { 67 | fatalError("Failed to generate coding keys") 68 | } 69 | 70 | var memberwiseInitializableMacroDeclaration = [DeclSyntax]() 71 | if !declaration.attributes.containsMemberwiseInitializableMacro { 72 | var nodeWithoutArguments = node 73 | nodeWithoutArguments.arguments = nil 74 | 75 | memberwiseInitializableMacroDeclaration = try MemberwiseInitializableMacro.expansion(of: nodeWithoutArguments, providingMembersOf: declaration, in: context) 76 | } 77 | 78 | return memberwiseInitializableMacroDeclaration + [ 79 | DeclSyntax(decoderWithCodingContainer: codingContainer, properties: storedProperties, isPublic: declaration.isPublic, needsValidation: node.needsValidation), 80 | DeclSyntax(encoderWithCodingContainer: codingContainer, properties: storedProperties, isPublic: declaration.isPublic), 81 | try codingContainer.codingKeysDeclaration, 82 | shouldIncludeFailableContainer ? .failableContainer() : nil 83 | ] 84 | .compactMap { $0 } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CodableMacroError.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import Foundation 5 | 6 | enum CodableMacroError: Error, CustomStringConvertible { 7 | case moreThanOneCodableMacroApplied 8 | case propertyTypeNotSpecified(propertyName: String) 9 | case customDecodingNotApplicableToExcludedProperty(propertyName: String) 10 | case notApplicableToActor 11 | case notApplicableToProtocol 12 | 13 | var description: String { 14 | switch self { 15 | case .moreThanOneCodableMacroApplied: 16 | "Only one codable macro can be applied at the same time" 17 | case .propertyTypeNotSpecified(let propertyName): 18 | "Property '\(propertyName)' must have explicit type" 19 | case .customDecodingNotApplicableToExcludedProperty(let propertyName): 20 | "\(CustomDecodedMacro.attributeName) cannot be applied to '\(propertyName)' because it's not decodable" 21 | case .notApplicableToActor: 22 | "@Codable cannot be applied to an actor" 23 | case .notApplicableToProtocol: 24 | "@Codable cannot be applied to a protocol" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CodingContainer.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | final class CodingContainer { 10 | let name: String? 11 | let cases: [String] 12 | let nestedContainers: [CodingContainer] 13 | weak var parent: CodingContainer? 14 | 15 | var sortingKey: String { name ?? "" } 16 | 17 | init?(name: String? = nil, paths: [CodingPath]) { 18 | guard !paths.isEmpty else { return nil } 19 | 20 | self.name = name 21 | 22 | var cases: [String] = [] 23 | var nestedContainers: [CodingContainer] = [] 24 | 25 | Dictionary(grouping: paths, by: { $0.firstComponent }) 26 | .forEach { (caseName: String, codingPaths: [CodingPath]) in 27 | codingPaths 28 | .filter { $0.isTerminal } 29 | .forEach { 30 | cases.append($0.firstComponent == $0.propertyName ? $0.propertyName : "\($0.propertyName) = \"\($0.firstComponent)\"") 31 | } 32 | 33 | let nestedPaths = codingPaths 34 | .filter { !$0.isTerminal } 35 | .compactMap { $0.droppingFirstComponent() } 36 | 37 | if let keys = CodingContainer(name: caseName, paths: nestedPaths) { 38 | nestedContainers.append(keys) 39 | if !cases.contains(caseName) { 40 | cases.append(caseName) 41 | } 42 | } 43 | } 44 | 45 | self.cases = cases.sorted() 46 | self.nestedContainers = nestedContainers.sorted(by: { $0.sortingKey < $1.sortingKey }) 47 | 48 | nestedContainers.forEach { $0.parent = self } 49 | } 50 | 51 | var typeName: String { 52 | "\(name?.uppercasingFirstLetter ?? "")CodingKeys" 53 | } 54 | 55 | var fullyQualifiedTypeName: String { 56 | [parent?.fullyQualifiedTypeName, typeName] 57 | .compactMap { $0 } 58 | .joined(separator: ".") 59 | } 60 | 61 | var containerVariableName: String { 62 | if let parent, let name { 63 | "\(parent.containerVariableName.dropLast("container".count))\(name.uppercasingFirstLetter)Container".lowercasingFirstLetter 64 | } else { 65 | "container" 66 | } 67 | } 68 | 69 | var codingKeysDeclaration: DeclSyntax { 70 | get throws { 71 | let caseDeclaration = MemberBlockItemSyntax( 72 | decl: try EnumCaseDeclSyntax("case \(raw: cases.joined(separator: ", "))") 73 | ) 74 | 75 | let nestedTypeDeclarations = try nestedContainers 76 | .map { try $0.codingKeysDeclaration } 77 | .map { MemberBlockItemSyntax(decl: $0) } 78 | 79 | let allMembers = ([caseDeclaration] + nestedTypeDeclarations) 80 | .compactMap { $0?.withTrailingTrivia(.newlines(2)) } 81 | 82 | let declarationCode = DeclSyntax(stringLiteral: 83 | "enum \(typeName): String, CodingKey {" + 84 | "\(MemberBlockItemListSyntax(allMembers).trimmed)" + 85 | "}") 86 | 87 | return declarationCode 88 | } 89 | } 90 | 91 | func containerDeclaration(ofKind containerKind: ContainerKind) -> CodeBlockItemSyntax { 92 | let declarationCode: String 93 | 94 | if let parent, let name { 95 | declarationCode = "\(containerKind.declarationKeyword) \(containerVariableName) = \(containerKind.tryPrefix)\(parent.containerVariableName).nestedContainer(keyedBy: \(fullyQualifiedTypeName).self, forKey: .\(name))" 96 | } else { 97 | declarationCode = "\(containerKind.declarationKeyword) \(containerVariableName) = \(containerKind.tryPrefix)\(containerKind.coderName).container(keyedBy: \(fullyQualifiedTypeName).self)" 98 | } 99 | 100 | return CodeBlockItemSyntax(stringLiteral: declarationCode) 101 | .withTrailingTrivia(.newline) 102 | } 103 | 104 | func nestedCodingContainers(along codingPath: CodingPath) -> [CodingContainer] { 105 | if let nestedContainer = nestedContainers.first(where: { $0.name == codingPath.firstComponent }), 106 | let nestedCodingPath = codingPath.droppingFirstComponent() { 107 | [nestedContainer] + nestedContainer.nestedCodingContainers(along: nestedCodingPath) 108 | } else { 109 | [] 110 | } 111 | } 112 | 113 | func allCodingContainers() -> [CodingContainer] { 114 | [self] + nestedContainers 115 | .map { $0.allCodingContainers() } 116 | .joined() 117 | } 118 | } 119 | 120 | struct ContainerKind { 121 | static let decode = ContainerKind(declarationKeyword: "let", tryPrefix: "try ", coderName: "decoder") 122 | static let encode = ContainerKind(declarationKeyword: "var", tryPrefix: "", coderName: "encoder") 123 | 124 | let declarationKeyword: String 125 | let tryPrefix: String 126 | let coderName: String 127 | 128 | private init(declarationKeyword: String, tryPrefix: String, coderName: String) { 129 | self.declarationKeyword = declarationKeyword 130 | self.tryPrefix = tryPrefix 131 | self.coderName = coderName 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CodingPath.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import Foundation 5 | 6 | struct CodingPath { 7 | let propertyName: String 8 | let firstComponent: String 9 | let remainingComponents: [String] 10 | let isTerminal: Bool 11 | 12 | var codingContainerName: String { 13 | if isTerminal { 14 | return "container" 15 | } 16 | 17 | let prefix = ([firstComponent] + remainingComponents.dropLast()) 18 | .map { $0.uppercasingFirstLetter } 19 | .joined() 20 | .lowercasingFirstLetter 21 | 22 | return "\(prefix)Container" 23 | } 24 | 25 | var containerkey: String { propertyName } 26 | 27 | init?(components: [String], propertyName: String) { 28 | guard let firstComponent = components.first else { 29 | return nil 30 | } 31 | 32 | self.propertyName = propertyName 33 | self.firstComponent = firstComponent 34 | self.remainingComponents = Array(components.dropFirst()) 35 | self.isTerminal = remainingComponents.isEmpty 36 | } 37 | 38 | func droppingFirstComponent() -> CodingPath? { 39 | CodingPath(components: remainingComponents, propertyName: propertyName) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/CustomDecodedMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct CustomDecodedMacro {} 10 | 11 | extension CustomDecodedMacro: PeerMacro { 12 | static let attributeName = "CustomDecoded" 13 | 14 | public static func expansion( 15 | of node: AttributeSyntax, 16 | providingPeersOf declaration: some DeclSyntaxProtocol, 17 | in context: some MacroExpansionContext 18 | ) throws -> [DeclSyntax] { 19 | [] // This macro doesn't generate any code 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/DecodableMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct DecodableMacro {} 10 | 11 | extension DecodableMacro: ExtensionMacro { 12 | 13 | public static func expansion( 14 | of node: AttributeSyntax, 15 | attachedTo declaration: some DeclGroupSyntax, 16 | providingExtensionsOf type: some TypeSyntaxProtocol, 17 | conformingTo protocols: [TypeSyntax], 18 | in context: some MacroExpansionContext 19 | ) throws -> [ExtensionDeclSyntax] { 20 | if declaration is ProtocolDeclSyntax || declaration is ActorDeclSyntax { 21 | return [] 22 | } 23 | 24 | if declaration.attributes.containsMultipleCodableMacros { 25 | return [] 26 | } 27 | 28 | return [try ExtensionDeclSyntax("extension \(type): Decodable {}")] 29 | } 30 | } 31 | 32 | extension DecodableMacro: MemberMacro { 33 | 34 | public static func expansion( 35 | of node: AttributeSyntax, 36 | providingMembersOf declaration: some DeclGroupSyntax, 37 | in context: some MacroExpansionContext 38 | ) throws -> [DeclSyntax] { 39 | guard !(declaration is ActorDeclSyntax) else { 40 | throw CodableMacroError.notApplicableToActor 41 | } 42 | 43 | guard !(declaration is ProtocolDeclSyntax) else { 44 | throw CodableMacroError.notApplicableToProtocol 45 | } 46 | 47 | guard !declaration.attributes.containsMultipleCodableMacros else { 48 | throw CodableMacroError.moreThanOneCodableMacroApplied 49 | } 50 | 51 | if declaration is EnumDeclSyntax { 52 | return [] 53 | } 54 | 55 | let storedProperties = try declaration.memberBlock.members 56 | .compactMap { try PropertyDefinition(declaration: $0.decl) } 57 | .filter { !$0.isExcludedFromCodable } 58 | 59 | if storedProperties.isEmpty { 60 | return [] 61 | } 62 | 63 | let shouldIncludeFailableContainer = storedProperties 64 | .contains(where: { $0.type.isCollection }) 65 | 66 | guard let rootCodingContainer = CodingContainer(paths: storedProperties.map { $0.codingPath }) else { 67 | fatalError("Failed to generate coding keys") 68 | } 69 | 70 | var memberwiseInitializableMacroDeclaration = [DeclSyntax]() 71 | if !declaration.attributes.containsMemberwiseInitializableMacro { 72 | var nodeWithoutArguments = node 73 | nodeWithoutArguments.arguments = nil 74 | 75 | memberwiseInitializableMacroDeclaration = try MemberwiseInitializableMacro.expansion(of: nodeWithoutArguments, providingMembersOf: declaration, in: context) 76 | } 77 | 78 | return memberwiseInitializableMacroDeclaration + [ 79 | DeclSyntax(decoderWithCodingContainer: rootCodingContainer, properties: storedProperties, isPublic: declaration.isPublic, needsValidation: node.needsValidation), 80 | try rootCodingContainer.codingKeysDeclaration, 81 | shouldIncludeFailableContainer ? .failableContainer() : nil 82 | ] 83 | .compactMap { $0 } 84 | } 85 | } 86 | 87 | extension DeclSyntax { 88 | 89 | static func failableContainer() -> DeclSyntax { 90 | .init(stringLiteral: 91 | "private struct FailableContainer: Decodable where T: Decodable { " + 92 | "var wrappedValue: T?\n\n" + 93 | "init(from decoder: Decoder) throws {" + 94 | "wrappedValue = try? decoder.singleValueContainer().decode(T.self) " + 95 | "}" + 96 | "}" 97 | ) 98 | } 99 | 100 | init(decoderWithCodingContainer codingContainer: CodingContainer, properties: [PropertyDefinition], isPublic: Bool, needsValidation: Bool) { 101 | let rootCodingContainerDeclaration = if properties.contains(where: { !$0.needsCustomDecoding }) { 102 | "\(codingContainer.containerDeclaration(ofKind: .decode).withLeadingTrivia(.newline).withTrailingTrivia(.newline))" 103 | } else { 104 | "" 105 | } 106 | 107 | let propertyDecodeStatements = properties 108 | .map { $0.decodeStatement(rootCodingContainer: codingContainer) } 109 | 110 | let propertyDecodeBlock = CodeBlockItemListSyntax(propertyDecodeStatements) 111 | .withLeadingTrivia(.newline) 112 | .withTrailingTrivia(.newline) 113 | 114 | let validationBlock = needsValidation 115 | ? "\(CodeBlockItemSyntax.validationBlock.withLeadingTrivia(.newline).withTrailingTrivia(.newline))" 116 | : "" 117 | 118 | self.init(stringLiteral: 119 | "\(isPublic ? "public " : "")init(from decoder: Decoder) throws { " + 120 | "\(rootCodingContainerDeclaration)" + 121 | "\(propertyDecodeBlock)" + 122 | "\(validationBlock)" + 123 | "}" 124 | ) 125 | } 126 | } 127 | 128 | extension AttributeSyntax { 129 | var needsValidation: Bool { 130 | guard 131 | case .argumentList(let argumentList) = arguments, 132 | let needsValidationArgument = argumentList.first(where: { $0.label?.trimmedDescription == "needsValidation" }) 133 | else { 134 | return false 135 | } 136 | 137 | let value = needsValidationArgument.expression.trimmedDescription 138 | 139 | guard ["true", "false"].contains(value) else { 140 | fatalError("Expected 'needsValidation' to be either 'true' or 'false'") 141 | } 142 | 143 | return value == "true" 144 | } 145 | } 146 | 147 | private extension CodeBlockItemSyntax { 148 | static var validationBlock: Self { 149 | .init(stringLiteral: 150 | "if !self.isValid {" + 151 | "throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: \"Validation failed\"))" + 152 | "}" 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/EncodableMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct EncodableMacro {} 10 | 11 | extension EncodableMacro: ExtensionMacro { 12 | 13 | public static func expansion( 14 | of node: AttributeSyntax, 15 | attachedTo declaration: some DeclGroupSyntax, 16 | providingExtensionsOf type: some TypeSyntaxProtocol, 17 | conformingTo protocols: [TypeSyntax], 18 | in context: some MacroExpansionContext 19 | ) throws -> [ExtensionDeclSyntax] { 20 | if declaration is ProtocolDeclSyntax || declaration is ActorDeclSyntax { 21 | return [] 22 | } 23 | 24 | if declaration.attributes.containsMultipleCodableMacros { 25 | return [] 26 | } 27 | 28 | return [try ExtensionDeclSyntax("extension \(type): Encodable {}")] 29 | } 30 | } 31 | 32 | extension EncodableMacro: MemberMacro { 33 | 34 | public static func expansion( 35 | of node: AttributeSyntax, 36 | providingMembersOf declaration: some DeclGroupSyntax, 37 | in context: some MacroExpansionContext 38 | ) throws -> [DeclSyntax] { 39 | guard !(declaration is ActorDeclSyntax) else { 40 | throw CodableMacroError.notApplicableToActor 41 | } 42 | 43 | guard !(declaration is ProtocolDeclSyntax) else { 44 | throw CodableMacroError.notApplicableToProtocol 45 | } 46 | 47 | guard !declaration.attributes.containsMultipleCodableMacros else { 48 | throw CodableMacroError.moreThanOneCodableMacroApplied 49 | } 50 | 51 | if declaration is EnumDeclSyntax { 52 | return [] 53 | } 54 | 55 | let storedProperties: [PropertyDefinition] = try declaration.memberBlock.members 56 | .compactMap { try PropertyDefinition(declaration: $0.decl) } 57 | .filter { !$0.isExcludedFromCodable } 58 | 59 | if storedProperties.isEmpty { 60 | return [] 61 | } 62 | 63 | guard let rootCodingContainer = CodingContainer(paths: storedProperties.map { $0.codingPath }) else { 64 | fatalError("Failed to generate coding keys") 65 | } 66 | 67 | return [ 68 | DeclSyntax(encoderWithCodingContainer: rootCodingContainer, properties: storedProperties, isPublic: declaration.isPublic), 69 | try rootCodingContainer.codingKeysDeclaration 70 | ] 71 | } 72 | } 73 | 74 | extension DeclSyntax { 75 | init(encoderWithCodingContainer codingContainer: CodingContainer, properties: [PropertyDefinition], isPublic: Bool) { 76 | let containerDeclarations = codingContainer 77 | .allCodingContainers() 78 | .map { $0.containerDeclaration(ofKind: .encode) } 79 | 80 | self.init(stringLiteral: 81 | "\(isPublic ? "public " : "")func encode(to encoder: Encoder) throws {" + 82 | "\(CodeBlockItemListSyntax(containerDeclarations).withTrailingTrivia(.newlines(2)))" + 83 | "\(CodeBlockItemListSyntax(properties.map { $0.encodeStatement }).trimmed)" + 84 | "}" 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/PropertyDefinition.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | struct PropertyDefinition { 10 | let name: String 11 | let type: TypeDefinition 12 | let codingPath: CodingPath 13 | let defaultValue: String? 14 | let isImmutable: Bool 15 | 16 | // marked with @CodableIgnored 17 | let isExplicitlyExcludedFromCodable: Bool 18 | 19 | // marked with @CustomDecoded 20 | let needsCustomDecoding: Bool 21 | 22 | init?(declaration: DeclSyntax) throws { 23 | guard 24 | let property = declaration.as(VariableDeclSyntax.self), 25 | !property.isStatic, 26 | let patternBinding = property.bindings.first, 27 | patternBinding.accessorBlock == nil, 28 | let name = patternBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text 29 | else { 30 | return nil 31 | } 32 | 33 | guard let type = patternBinding.typeAnnotation.flatMap({ TypeDefinition(type: $0.type) }) else { 34 | throw CodableMacroError.propertyTypeNotSpecified(propertyName: name) 35 | } 36 | 37 | let propertyAttributes = property.attributes 38 | .compactMap { $0.as(AttributeSyntax.self) } 39 | 40 | let pathFragments = propertyAttributes 41 | .first(where: { $0.isCodableKey }) 42 | .flatMap { $0.codableKey } 43 | .map { $0.split(separator: ".", omittingEmptySubsequences: true).map { String($0) } } 44 | ?? [name] 45 | 46 | guard let codingPath = CodingPath(components: pathFragments, propertyName: name) else { 47 | return nil 48 | } 49 | 50 | self.name = name 51 | self.type = type 52 | self.codingPath = codingPath 53 | self.defaultValue = patternBinding.initializer?.value.trimmedDescription 54 | self.isImmutable = property.isImmutable 55 | self.isExplicitlyExcludedFromCodable = propertyAttributes.contains(where: { $0.isCodableIgnored }) 56 | self.needsCustomDecoding = propertyAttributes.contains(where: { $0.isCustomDecoded }) 57 | 58 | if isExcludedFromCodable && needsCustomDecoding { 59 | throw CodableMacroError.customDecodingNotApplicableToExcludedProperty(propertyName: name) 60 | } 61 | } 62 | 63 | var isExcludedFromCodable: Bool { 64 | isExplicitlyExcludedFromCodable || isImmutableWithDefaultValue 65 | } 66 | 67 | var isImmutableWithDefaultValue: Bool { 68 | (isImmutable && defaultValue != nil) // Assigning an immutable property with a default value is a compiler error 69 | } 70 | 71 | var customDecodeFunctionName: String { 72 | "decode\(name.uppercasingFirstLetter)" 73 | } 74 | 75 | func decodeStatement(rootCodingContainer: CodingContainer) -> CodeBlockItemSyntax { 76 | let nestedContainerDeclarations = rootCodingContainer 77 | .nestedCodingContainers(along: codingPath) 78 | .map { $0.containerDeclaration(ofKind: .decode) } 79 | 80 | let decodeStatement = 81 | if needsCustomDecoding { 82 | CodeBlockItemSyntax(stringLiteral: "\(name) = try Self" + 83 | ".\(customDecodeFunctionName)(from: decoder)") 84 | } else if let arrayElementType = type.arrayElementType { 85 | CodeBlockItemSyntax(stringLiteral: "\(name) = try \(codingPath.codingContainerName)" + 86 | ".decode([FailableContainer<\(arrayElementType)>].self, forKey: .\(codingPath.containerkey))" + 87 | ".compactMap { $0.wrappedValue }") 88 | } else if let setElementType = type.setElementType { 89 | CodeBlockItemSyntax(stringLiteral: "\(name) = Set(try \(codingPath.codingContainerName)" + 90 | ".decode([FailableContainer<\(setElementType)>].self, forKey: .\(codingPath.containerkey))" + 91 | ".compactMap { $0.wrappedValue })") 92 | } else if let dictionaryElementType = type.dictionaryElementType { 93 | CodeBlockItemSyntax(stringLiteral: "\(name) = try \(codingPath.codingContainerName)" + 94 | ".decode([\(dictionaryElementType.key): FailableContainer<\(dictionaryElementType.value)>].self, forKey: .\(codingPath.containerkey))" + 95 | ".compactMapValues { $0.wrappedValue }") 96 | .withLeadingTrivia(.newline) 97 | } else { 98 | CodeBlockItemSyntax(stringLiteral: "\(name) = try \(codingPath.codingContainerName)" + 99 | ".decode(\(type.decodableTypeName).self, forKey: .\(codingPath.containerkey))") 100 | } 101 | 102 | var errorHandlingStatement: CodeBlockItemSyntax? { 103 | let statement: String? = if let defaultValue { 104 | "\(name) = \(defaultValue)" 105 | } else if type.isOptional { 106 | "\(name) = nil" 107 | } else if !nestedContainerDeclarations.isEmpty { 108 | "throw error" 109 | } else { 110 | nil 111 | } 112 | 113 | return statement 114 | .map { CodeBlockItemSyntax(stringLiteral: $0) } 115 | } 116 | 117 | if let errorHandlingStatement { 118 | let decodeBlock = CodeBlockItemListSyntax(nestedContainerDeclarations + [decodeStatement]) 119 | 120 | return CodeBlockItemSyntax(stringLiteral: "do { \(decodeBlock) } catch { \(errorHandlingStatement) }") 121 | .withLeadingTrivia(.newline) 122 | .withTrailingTrivia(.newline) 123 | } else { 124 | return decodeStatement 125 | .withTrailingTrivia(.newline) 126 | } 127 | } 128 | 129 | var encodeStatement: CodeBlockItemSyntax { 130 | let encodeFunction = type.isOptional ? "encodeIfPresent" : "encode" 131 | 132 | var encodeStatement = CodeBlockItemSyntax(stringLiteral: "try \(codingPath.codingContainerName)" + 133 | ".\(encodeFunction)(\(name), forKey: .\(codingPath.containerkey))") 134 | encodeStatement.trailingTrivia = .newline 135 | 136 | return encodeStatement 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/SwiftSyntax+Extensions.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | extension VariableDeclSyntax { 10 | var isImmutable: Bool { 11 | bindingSpecifier.tokenKind == .keyword(.let) 12 | } 13 | 14 | var isStatic: Bool { 15 | modifiers.contains(where: { $0.name.tokenKind == .keyword(.static) }) 16 | } 17 | } 18 | 19 | extension AttributeListSyntax { 20 | var containsMultipleCodableMacros: Bool { 21 | let codableMacros: Set = ["@Codable", "@Decodable", "@Encodable"] 22 | 23 | return self 24 | .filter { codableMacros.contains($0.trimmedDescription) } 25 | .count > 1 26 | } 27 | 28 | var containsMemberwiseInitializableMacro: Bool { 29 | self 30 | .contains { $0.trimmedDescription.hasPrefix("@MemberwiseInitializable") } 31 | } 32 | } 33 | 34 | extension AttributeSyntax { 35 | var isCodableIgnored: Bool { 36 | attributeName.as(IdentifierTypeSyntax.self)?.trimmedDescription == CodableIgnoredMacro.attributeName 37 | } 38 | 39 | var isCustomDecoded: Bool { 40 | attributeName.as(IdentifierTypeSyntax.self)?.trimmedDescription == CustomDecodedMacro.attributeName 41 | } 42 | 43 | var isCodableKey: Bool { 44 | attributeName.as(IdentifierTypeSyntax.self)?.trimmedDescription == CodableKeyMacro.attributeName 45 | } 46 | 47 | var codableKey: String? { 48 | arguments?.as(LabeledExprListSyntax.self)?.first?.expression.description 49 | .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) 50 | } 51 | } 52 | 53 | extension SyntaxProtocol { 54 | 55 | func withLeadingTrivia(_ trivia: Trivia) -> Self { 56 | var syntax = self 57 | syntax.leadingTrivia = trivia 58 | return syntax 59 | } 60 | 61 | func withTrailingTrivia(_ trivia: Trivia) -> Self { 62 | var syntax = self 63 | syntax.trailingTrivia = trivia 64 | return syntax 65 | } 66 | } 67 | 68 | extension DeclGroupSyntax { 69 | var isPublic: Bool { 70 | let keywords: [TokenKind] = [.keyword(.public), .keyword(.open)] 71 | return modifiers.contains(where: { keywords.contains($0.name.tokenKind) }) 72 | } 73 | } 74 | 75 | extension String { 76 | 77 | var uppercasingFirstLetter: String { 78 | prefix(1).uppercased() + dropFirst() 79 | } 80 | 81 | var lowercasingFirstLetter: String { 82 | prefix(1).lowercased() + dropFirst() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/CodableMacros/Codable/TypeDefinition.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | final class TypeDefinition: CustomStringConvertible { 10 | enum Kind { 11 | case optional(wrappedType: TypeDefinition) 12 | case array(elementType: String) 13 | case set(elementType: String) 14 | case dictionary(keyType: String, valueType: String) 15 | case identifier(name: String) 16 | } 17 | 18 | let kind: Kind 19 | let baseTypeName: String? 20 | 21 | init?(type: TypeSyntax) { 22 | if type.is(MemberTypeSyntax.self) || type.is(IdentifierTypeSyntax.self) { 23 | let (typeName, genericArgumentClause): (String?, GenericArgumentClauseSyntax?) = 24 | if let identifier = type.as(IdentifierTypeSyntax.self) { 25 | (identifier.name.text, identifier.genericArgumentClause) 26 | } else if let member = type.as(MemberTypeSyntax.self) { 27 | (member.name.text, member.genericArgumentClause) 28 | } else { 29 | (nil, nil) 30 | } 31 | 32 | guard let typeName else { return nil } 33 | 34 | if let genericArgumentClause { 35 | let genericParameterTypes = genericArgumentClause.arguments.map { $0.argument } 36 | let genericParameterNames = genericArgumentClause.arguments.map { $0.trimmedDescription } 37 | 38 | switch typeName { 39 | case "Array": 40 | guard let elementType = genericParameterNames.first else { 41 | return nil 42 | } 43 | 44 | kind = .array(elementType: elementType) 45 | 46 | case "Set": 47 | guard let elementType = genericParameterNames.first else { 48 | return nil 49 | } 50 | 51 | kind = .set(elementType: elementType) 52 | 53 | case "Dictionary": 54 | guard genericParameterNames.count == 2 else { 55 | return nil 56 | } 57 | 58 | kind = .dictionary(keyType: genericParameterNames[0], valueType: genericParameterNames[1]) 59 | 60 | case "Optional": 61 | guard let wrappedType = genericParameterTypes.first.flatMap({ TypeDefinition(type: $0) }) else { 62 | return nil 63 | } 64 | 65 | kind = .optional(wrappedType: wrappedType) 66 | 67 | default: 68 | kind = .identifier(name: "\(typeName)\(genericArgumentClause.trimmedDescription)") 69 | } 70 | } else { 71 | kind = .identifier(name: typeName) 72 | } 73 | 74 | baseTypeName = type.as(MemberTypeSyntax.self)?.baseType.trimmedDescription 75 | } else if let optional = type.as(OptionalTypeSyntax.self), 76 | let wrappedDeclaration = TypeDefinition(type: optional.wrappedType) { 77 | kind = .optional(wrappedType: wrappedDeclaration) 78 | baseTypeName = nil 79 | } else if let array = type.as(ArrayTypeSyntax.self) { 80 | kind = .array(elementType: array.element.trimmedDescription) 81 | baseTypeName = nil 82 | } else if let dictionary = type.as(DictionaryTypeSyntax.self) { 83 | kind = .dictionary( 84 | keyType: dictionary.key.trimmedDescription, 85 | valueType: dictionary.value.trimmedDescription 86 | ) 87 | baseTypeName = nil 88 | } else { 89 | return nil 90 | } 91 | } 92 | 93 | var decodableTypeName: String { 94 | let name = switch kind { 95 | case let .identifier(name): 96 | name 97 | case let .array(elementType): 98 | "Array<\(elementType)>" 99 | case let .set(elementType): 100 | "Set<\(elementType)>" 101 | case .dictionary(let keyType, let elementType): 102 | "Dictionary<\(keyType): \(elementType)>" 103 | case let .optional(wrappedType): 104 | wrappedType.decodableTypeName 105 | } 106 | 107 | return [baseTypeName, name] 108 | .compactMap { $0 } 109 | .joined(separator: ".") 110 | } 111 | 112 | var isCollection: Bool { 113 | switch kind { 114 | case .array, .set, .dictionary: 115 | true 116 | case let .optional(wrappedType): 117 | wrappedType.isCollection 118 | default: 119 | false 120 | } 121 | } 122 | 123 | var arrayElementType: String? { 124 | switch kind { 125 | case let .array(elementType): 126 | elementType 127 | case let .optional(wrappedType): 128 | wrappedType.arrayElementType 129 | default: 130 | nil 131 | } 132 | } 133 | 134 | var setElementType: String? { 135 | switch kind { 136 | case let .set(elementType): 137 | elementType 138 | case let .optional(wrappedType): 139 | wrappedType.setElementType 140 | default: 141 | nil 142 | } 143 | } 144 | 145 | var dictionaryElementType: (key: String, value: String)? { 146 | switch kind { 147 | case let .dictionary(keyType, elementType): 148 | (keyType, elementType) 149 | case let .optional(wrappedType): 150 | wrappedType.dictionaryElementType 151 | default: 152 | nil 153 | } 154 | } 155 | 156 | var isOptional: Bool { 157 | switch kind { 158 | case .optional: 159 | true 160 | default: 161 | false 162 | } 163 | } 164 | 165 | var description: String { 166 | let description = switch kind { 167 | case let .identifier(name): 168 | name 169 | case let .optional(wrappedType): 170 | "\(wrappedType.description)?" 171 | case let .array(elementType): 172 | "Array<\(elementType.description)>" 173 | case let .set(elementType): 174 | "Set<\(elementType.description)>" 175 | case .dictionary(let keyType, let elementType): 176 | "Dictionary<\(keyType.description), \(elementType.description)>" 177 | } 178 | 179 | return [baseTypeName, description] 180 | .compactMap { $0 } 181 | .joined(separator: ".") 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Sources/CodableMacros/CodablePlugin.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntaxMacros 5 | import SwiftCompilerPlugin 6 | 7 | @main 8 | struct CodablePlugin: CompilerPlugin { 9 | let providingMacros: [Macro.Type] = [ 10 | CodableMacro.self, 11 | DecodableMacro.self, 12 | EncodableMacro.self, 13 | CodableKeyMacro.self, 14 | CodableIgnoredMacro.self, 15 | CustomDecodedMacro.self, 16 | MemberwiseInitializableMacro.self 17 | ] 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Sources/CodableMacros/MemberwiseInitializable/MemberwiseInitializableMacro.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntax 5 | import SwiftSyntaxBuilder 6 | import SwiftSyntaxMacros 7 | import Foundation 8 | 9 | public struct MemberwiseInitializableMacro {} 10 | 11 | extension MemberwiseInitializableMacro: MemberMacro { 12 | 13 | public static func expansion( 14 | of node: AttributeSyntax, 15 | providingMembersOf declaration: some DeclGroupSyntax, 16 | in context: some MacroExpansionContext 17 | ) throws -> [DeclSyntax] { 18 | guard !(declaration is ProtocolDeclSyntax) else { 19 | throw MemberwiseInitializableMacroError.notApplicableToProtocol 20 | } 21 | 22 | guard !(declaration is EnumDeclSyntax) else { 23 | throw MemberwiseInitializableMacroError.notApplicableToEnum 24 | } 25 | 26 | let storedProperties: [PropertyDefinition] = try declaration.memberBlock.members 27 | .compactMap { try PropertyDefinition(declaration: $0.decl) } 28 | .filter { !$0.isImmutableWithDefaultValue } 29 | 30 | if storedProperties.isEmpty { 31 | throw MemberwiseInitializableMacroError.noStoredProperties 32 | } 33 | 34 | let accessLevel: String? 35 | if let arguments = node.arguments { 36 | guard 37 | case .argumentList(let argumentList) = arguments, 38 | let firstArgument = argumentList.first, 39 | argumentList.count == 1 40 | else { 41 | fatalError("Expected 1 argument") 42 | } 43 | 44 | guard let level = firstArgument.expression.as(MemberAccessExprSyntax.self)?.declName.trimmedDescription else { 45 | fatalError("Expected access level") 46 | } 47 | 48 | accessLevel = level 49 | } else if let firstModifier = declaration.accessLevelModifiers.first { 50 | let isOpen = firstModifier.name.tokenKind == .keyword(.open) 51 | accessLevel = isOpen ? "public" : firstModifier.trimmedDescription 52 | } else { 53 | accessLevel = nil 54 | } 55 | 56 | return [ 57 | """ 58 | \(raw: accessLevel.map { "\($0) " } ?? "")init( 59 | \(raw: storedProperties 60 | .map { 61 | "\($0.name): \($0.type.description)\(($0.defaultValue ?? $0.type.appropriateInitialValue).map { " = \($0)" } ?? "")" 62 | } 63 | .joined(separator: ",\n") 64 | ) 65 | ) { 66 | \(raw: storedProperties 67 | .map { 68 | "self.\($0.name) = \($0.name)" 69 | } 70 | .joined(separator: "\n") 71 | ) 72 | } 73 | """ 74 | ] 75 | } 76 | } 77 | 78 | private extension DeclGroupSyntax { 79 | 80 | var accessLevelModifiers: DeclModifierListSyntax { 81 | modifiers.filter { 82 | switch $0.name.tokenKind { 83 | case .keyword(.open), .keyword(.public), .keyword(.private), .keyword(.internal), .keyword(.fileprivate): 84 | return true 85 | default: 86 | return false 87 | } 88 | } 89 | } 90 | } 91 | 92 | private extension TypeDefinition { 93 | 94 | var appropriateInitialValue: String? { 95 | switch kind { 96 | case .optional: "nil" 97 | case .identifier(let name) where name.starts(with: "Optional<"): "nil" 98 | 99 | default: nil 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/CodableMacros/MemberwiseInitializable/MemberwiseInitializableMacroError.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import Foundation 5 | 6 | enum MemberwiseInitializableMacroError: Error, CustomStringConvertible { 7 | case notApplicableToProtocol 8 | case notApplicableToEnum 9 | case noStoredProperties 10 | 11 | var description: String { 12 | switch self { 13 | case .notApplicableToProtocol: 14 | "@MemberwiseInitializable cannot be applied to a protocol" 15 | case .notApplicableToEnum: 16 | "@MemberwiseInitializable cannot be applied to an enum" 17 | case .noStoredProperties: 18 | "Type has no stored properties" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/CodableTests/CodableTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntaxMacros 5 | import SwiftSyntaxMacrosTestSupport 6 | import XCTest 7 | 8 | @testable import CodableMacros 9 | 10 | final class CodableTests: XCTestCase { 11 | let testMacros: [String: Macro.Type] = [ 12 | "Codable": CodableMacro.self, 13 | "Decodable": DecodableMacro.self, 14 | "Encodable": EncodableMacro.self, 15 | "CodableKey": CodableKeyMacro.self, 16 | "CodableIgnored": CodableIgnoredMacro.self, 17 | "MemberwiseInitializable": MemberwiseInitializableMacro.self 18 | ] 19 | 20 | func testCodableMacro_withSimpleType() throws { 21 | assertAndCompileMacroExpansion( 22 | """ 23 | @Codable 24 | struct Foo { 25 | var bar: String 26 | } 27 | """, 28 | expandedSource: """ 29 | 30 | struct Foo { 31 | var bar: String 32 | 33 | init( 34 | bar: String 35 | ) { 36 | self.bar = bar 37 | } 38 | 39 | init(from decoder: Decoder) throws { 40 | let container = try decoder.container(keyedBy: CodingKeys.self) 41 | 42 | bar = try container.decode(String.self, forKey: .bar) 43 | } 44 | 45 | func encode(to encoder: Encoder) throws { 46 | var container = encoder.container(keyedBy: CodingKeys.self) 47 | 48 | try container.encode(bar, forKey: .bar) 49 | } 50 | 51 | enum CodingKeys: String, CodingKey { 52 | case bar 53 | } 54 | } 55 | 56 | extension Foo: Codable { 57 | } 58 | """, 59 | macros: testMacros 60 | ) 61 | } 62 | 63 | func testCodableMacro_withPublicType_generatedDeclarationsArePublic() throws { 64 | assertAndCompileMacroExpansion( 65 | """ 66 | @Codable 67 | public struct Foo { 68 | public var bar: String 69 | } 70 | """, 71 | expandedSource: """ 72 | 73 | public struct Foo { 74 | public var bar: String 75 | 76 | public init( 77 | bar: String 78 | ) { 79 | self.bar = bar 80 | } 81 | 82 | public init(from decoder: Decoder) throws { 83 | let container = try decoder.container(keyedBy: CodingKeys.self) 84 | 85 | bar = try container.decode(String.self, forKey: .bar) 86 | } 87 | 88 | public func encode(to encoder: Encoder) throws { 89 | var container = encoder.container(keyedBy: CodingKeys.self) 90 | 91 | try container.encode(bar, forKey: .bar) 92 | } 93 | 94 | enum CodingKeys: String, CodingKey { 95 | case bar 96 | } 97 | } 98 | 99 | extension Foo: Codable { 100 | } 101 | """, 102 | macros: testMacros 103 | ) 104 | } 105 | 106 | func testCodableMacro_whenPropertyHasDefaultValue_setToDefaultValueIfDecodingFails() throws { 107 | assertAndCompileMacroExpansion( 108 | """ 109 | @Codable 110 | struct Foo { 111 | var bar: String = "something" 112 | } 113 | """, 114 | expandedSource: """ 115 | 116 | struct Foo { 117 | var bar: String = "something" 118 | 119 | init( 120 | bar: String = "something" 121 | ) { 122 | self.bar = bar 123 | } 124 | 125 | init(from decoder: Decoder) throws { 126 | let container = try decoder.container(keyedBy: CodingKeys.self) 127 | 128 | do { 129 | bar = try container.decode(String.self, forKey: .bar) 130 | } catch { 131 | bar = "something" 132 | } 133 | } 134 | 135 | func encode(to encoder: Encoder) throws { 136 | var container = encoder.container(keyedBy: CodingKeys.self) 137 | 138 | try container.encode(bar, forKey: .bar) 139 | } 140 | 141 | enum CodingKeys: String, CodingKey { 142 | case bar 143 | } 144 | } 145 | 146 | extension Foo: Codable { 147 | } 148 | """, 149 | macros: testMacros 150 | ) 151 | } 152 | 153 | func testCodableMacro_whenPropertyIsOptional_setToNilIfDecodingFails() throws { 154 | assertAndCompileMacroExpansion( 155 | """ 156 | @Codable 157 | struct Foo { 158 | var bar: String? 159 | } 160 | """, 161 | expandedSource: """ 162 | 163 | struct Foo { 164 | var bar: String? 165 | 166 | init( 167 | bar: String? = nil 168 | ) { 169 | self.bar = bar 170 | } 171 | 172 | init(from decoder: Decoder) throws { 173 | let container = try decoder.container(keyedBy: CodingKeys.self) 174 | 175 | do { 176 | bar = try container.decode(String.self, forKey: .bar) 177 | } catch { 178 | bar = nil 179 | } 180 | } 181 | 182 | func encode(to encoder: Encoder) throws { 183 | var container = encoder.container(keyedBy: CodingKeys.self) 184 | 185 | try container.encodeIfPresent(bar, forKey: .bar) 186 | } 187 | 188 | enum CodingKeys: String, CodingKey { 189 | case bar 190 | } 191 | } 192 | 193 | extension Foo: Codable { 194 | } 195 | """, 196 | macros: testMacros 197 | ) 198 | } 199 | 200 | func testCodableMacro_whenPropertyIsOptionalAndHasDefaultValue_setToDefaultValueIfDecodingFails() throws { 201 | assertAndCompileMacroExpansion( 202 | """ 203 | @Codable 204 | struct Foo { 205 | var bar: String? = "something" 206 | } 207 | """, 208 | expandedSource: """ 209 | 210 | struct Foo { 211 | var bar: String? = "something" 212 | 213 | init( 214 | bar: String? = "something" 215 | ) { 216 | self.bar = bar 217 | } 218 | 219 | init(from decoder: Decoder) throws { 220 | let container = try decoder.container(keyedBy: CodingKeys.self) 221 | 222 | do { 223 | bar = try container.decode(String.self, forKey: .bar) 224 | } catch { 225 | bar = "something" 226 | } 227 | } 228 | 229 | func encode(to encoder: Encoder) throws { 230 | var container = encoder.container(keyedBy: CodingKeys.self) 231 | 232 | try container.encodeIfPresent(bar, forKey: .bar) 233 | } 234 | 235 | enum CodingKeys: String, CodingKey { 236 | case bar 237 | } 238 | } 239 | 240 | extension Foo: Codable { 241 | } 242 | """, 243 | macros: testMacros 244 | ) 245 | } 246 | 247 | func testCodableMacro_withArrayProperty_generatesHelperContainerType() throws { 248 | assertAndCompileMacroExpansion( 249 | """ 250 | @Codable 251 | struct Foo { 252 | var bar: [String] 253 | } 254 | """, 255 | expandedSource: """ 256 | 257 | struct Foo { 258 | var bar: [String] 259 | 260 | init( 261 | bar: Array 262 | ) { 263 | self.bar = bar 264 | } 265 | 266 | init(from decoder: Decoder) throws { 267 | let container = try decoder.container(keyedBy: CodingKeys.self) 268 | 269 | bar = try container.decode([FailableContainer].self, forKey: .bar).compactMap { 270 | $0.wrappedValue 271 | } 272 | } 273 | 274 | func encode(to encoder: Encoder) throws { 275 | var container = encoder.container(keyedBy: CodingKeys.self) 276 | 277 | try container.encode(bar, forKey: .bar) 278 | } 279 | 280 | enum CodingKeys: String, CodingKey { 281 | case bar 282 | } 283 | 284 | private struct FailableContainer: Decodable where T: Decodable { 285 | var wrappedValue: T? 286 | 287 | init(from decoder: Decoder) throws { 288 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 289 | } 290 | } 291 | } 292 | 293 | extension Foo: Codable { 294 | } 295 | """, 296 | macros: testMacros 297 | ) 298 | } 299 | 300 | func testCodableMacro_withDictionaryProperty_generatesHelperContainerType() throws { 301 | assertAndCompileMacroExpansion( 302 | """ 303 | @Codable 304 | struct Foo { 305 | var bar: [String: String] 306 | } 307 | """, 308 | expandedSource: """ 309 | 310 | struct Foo { 311 | var bar: [String: String] 312 | 313 | init( 314 | bar: Dictionary 315 | ) { 316 | self.bar = bar 317 | } 318 | 319 | init(from decoder: Decoder) throws { 320 | let container = try decoder.container(keyedBy: CodingKeys.self) 321 | 322 | bar = try container.decode([String: FailableContainer].self, forKey: .bar).compactMapValues { 323 | $0.wrappedValue 324 | } 325 | } 326 | 327 | func encode(to encoder: Encoder) throws { 328 | var container = encoder.container(keyedBy: CodingKeys.self) 329 | 330 | try container.encode(bar, forKey: .bar) 331 | } 332 | 333 | enum CodingKeys: String, CodingKey { 334 | case bar 335 | } 336 | 337 | private struct FailableContainer: Decodable where T: Decodable { 338 | var wrappedValue: T? 339 | 340 | init(from decoder: Decoder) throws { 341 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 342 | } 343 | } 344 | } 345 | 346 | extension Foo: Codable { 347 | } 348 | """, 349 | macros: testMacros 350 | ) 351 | } 352 | 353 | func testCodableMacro_withCustomCodingKey_generatesCorrectCodingKeys() throws { 354 | assertAndCompileMacroExpansion( 355 | """ 356 | @Codable 357 | struct Foo { 358 | @CodableKey("baz") 359 | var bar: String 360 | } 361 | """, 362 | expandedSource: """ 363 | 364 | struct Foo { 365 | var bar: String 366 | 367 | init( 368 | bar: String 369 | ) { 370 | self.bar = bar 371 | } 372 | 373 | init(from decoder: Decoder) throws { 374 | let container = try decoder.container(keyedBy: CodingKeys.self) 375 | 376 | bar = try container.decode(String.self, forKey: .bar) 377 | } 378 | 379 | func encode(to encoder: Encoder) throws { 380 | var container = encoder.container(keyedBy: CodingKeys.self) 381 | 382 | try container.encode(bar, forKey: .bar) 383 | } 384 | 385 | enum CodingKeys: String, CodingKey { 386 | case bar = "baz" 387 | } 388 | } 389 | 390 | extension Foo: Codable { 391 | } 392 | """, 393 | macros: testMacros 394 | ) 395 | } 396 | 397 | func testCodableMacro_whenPropertyIsExplicitlyIgnored_isExcludedFromGeneratedCode() throws { 398 | assertAndCompileMacroExpansion( 399 | """ 400 | @Codable 401 | struct Foo { 402 | var bar: String 403 | 404 | @CodableIgnored 405 | var baz: Int = 42 406 | } 407 | """, 408 | expandedSource: """ 409 | 410 | struct Foo { 411 | var bar: String 412 | var baz: Int = 42 413 | 414 | init( 415 | bar: String, 416 | baz: Int = 42 417 | ) { 418 | self.bar = bar 419 | self.baz = baz 420 | } 421 | 422 | init(from decoder: Decoder) throws { 423 | let container = try decoder.container(keyedBy: CodingKeys.self) 424 | 425 | bar = try container.decode(String.self, forKey: .bar) 426 | } 427 | 428 | func encode(to encoder: Encoder) throws { 429 | var container = encoder.container(keyedBy: CodingKeys.self) 430 | 431 | try container.encode(bar, forKey: .bar) 432 | } 433 | 434 | enum CodingKeys: String, CodingKey { 435 | case bar 436 | } 437 | } 438 | 439 | extension Foo: Codable { 440 | } 441 | """, 442 | macros: testMacros 443 | ) 444 | } 445 | 446 | func testCodableMacro_whenHasMemberwiseInitializableMacro_generatesMemberwiseIntializerOnce() throws { 447 | assertAndCompileMacroExpansion( 448 | """ 449 | @Codable 450 | @MemberwiseInitializable 451 | struct Foo { 452 | var bar: String 453 | } 454 | """, 455 | expandedSource: """ 456 | 457 | struct Foo { 458 | var bar: String 459 | 460 | init(from decoder: Decoder) throws { 461 | let container = try decoder.container(keyedBy: CodingKeys.self) 462 | 463 | bar = try container.decode(String.self, forKey: .bar) 464 | } 465 | 466 | func encode(to encoder: Encoder) throws { 467 | var container = encoder.container(keyedBy: CodingKeys.self) 468 | 469 | try container.encode(bar, forKey: .bar) 470 | } 471 | 472 | enum CodingKeys: String, CodingKey { 473 | case bar 474 | } 475 | 476 | init( 477 | bar: String 478 | ) { 479 | self.bar = bar 480 | } 481 | } 482 | 483 | extension Foo: Codable { 484 | } 485 | """, 486 | 487 | macros: testMacros 488 | ) 489 | } 490 | 491 | func testCodableMacro_whenHasMemberwiseInitializableMacroWithAccessLevel_generatesMemberwiseIntializerOnce() throws { 492 | assertMacroExpansion( 493 | """ 494 | @Codable 495 | @MemberwiseInitializable(.private) 496 | struct Foo { 497 | var bar: String 498 | } 499 | """, 500 | expandedSource: """ 501 | 502 | struct Foo { 503 | var bar: String 504 | 505 | init(from decoder: Decoder) throws { 506 | let container = try decoder.container(keyedBy: CodingKeys.self) 507 | 508 | bar = try container.decode(String.self, forKey: .bar) 509 | } 510 | 511 | func encode(to encoder: Encoder) throws { 512 | var container = encoder.container(keyedBy: CodingKeys.self) 513 | 514 | try container.encode(bar, forKey: .bar) 515 | } 516 | 517 | enum CodingKeys: String, CodingKey { 518 | case bar 519 | } 520 | 521 | private init( 522 | bar: String 523 | ) { 524 | self.bar = bar 525 | } 526 | } 527 | 528 | extension Foo: Codable { 529 | } 530 | """, 531 | 532 | macros: testMacros 533 | ) 534 | } 535 | 536 | func testCodableMacro_whenImmutablePropertyHasDefaultValue_isExcludedFromGeneratedCode() throws { 537 | assertAndCompileMacroExpansion( 538 | """ 539 | @Codable 540 | struct Foo { 541 | let bar: String 542 | let baz: Int = 42 543 | } 544 | """, 545 | expandedSource: """ 546 | 547 | struct Foo { 548 | let bar: String 549 | let baz: Int = 42 550 | 551 | init( 552 | bar: String 553 | ) { 554 | self.bar = bar 555 | } 556 | 557 | init(from decoder: Decoder) throws { 558 | let container = try decoder.container(keyedBy: CodingKeys.self) 559 | 560 | bar = try container.decode(String.self, forKey: .bar) 561 | } 562 | 563 | func encode(to encoder: Encoder) throws { 564 | var container = encoder.container(keyedBy: CodingKeys.self) 565 | 566 | try container.encode(bar, forKey: .bar) 567 | } 568 | 569 | enum CodingKeys: String, CodingKey { 570 | case bar 571 | } 572 | } 573 | 574 | extension Foo: Codable { 575 | } 576 | """, 577 | macros: testMacros 578 | ) 579 | } 580 | 581 | func testCodableMacro_whenDecodingFromNestedContainer_generatesNestedCodingKeys() throws { 582 | assertAndCompileMacroExpansion( 583 | """ 584 | @Codable 585 | struct Foo { 586 | @CodableKey("baz.qux") 587 | var bar: String 588 | } 589 | """, 590 | expandedSource: """ 591 | 592 | struct Foo { 593 | var bar: String 594 | 595 | init( 596 | bar: String 597 | ) { 598 | self.bar = bar 599 | } 600 | 601 | init(from decoder: Decoder) throws { 602 | let container = try decoder.container(keyedBy: CodingKeys.self) 603 | 604 | do { 605 | let bazContainer = try container.nestedContainer(keyedBy: CodingKeys.BazCodingKeys.self, forKey: .baz) 606 | bar = try bazContainer.decode(String.self, forKey: .bar) 607 | } catch { 608 | throw error 609 | } 610 | } 611 | 612 | func encode(to encoder: Encoder) throws { 613 | var container = encoder.container(keyedBy: CodingKeys.self) 614 | var bazContainer = container.nestedContainer(keyedBy: CodingKeys.BazCodingKeys.self, forKey: .baz) 615 | 616 | try bazContainer.encode(bar, forKey: .bar) 617 | } 618 | 619 | enum CodingKeys: String, CodingKey { 620 | case baz 621 | 622 | enum BazCodingKeys: String, CodingKey { 623 | case bar = "qux" 624 | } 625 | } 626 | } 627 | 628 | extension Foo: Codable { 629 | } 630 | """, 631 | macros: testMacros 632 | ) 633 | } 634 | 635 | func testCodableMacro_whenAppliedToEnum() throws { 636 | assertAndCompileMacroExpansion( 637 | """ 638 | @Codable 639 | enum Foo { 640 | case bar 641 | } 642 | """, 643 | expandedSource: """ 644 | 645 | enum Foo { 646 | case bar 647 | } 648 | 649 | extension Foo: Codable { 650 | } 651 | """, 652 | macros: testMacros 653 | ) 654 | } 655 | 656 | func testCodableMacro_whenAppliedToEmptyType() throws { 657 | assertAndCompileMacroExpansion( 658 | """ 659 | @Codable 660 | struct Foo { 661 | } 662 | """, 663 | expandedSource: """ 664 | 665 | struct Foo { 666 | } 667 | 668 | extension Foo: Codable { 669 | } 670 | """, 671 | macros: testMacros 672 | ) 673 | } 674 | 675 | func testCodableMacro_whenValidationNeeded_includesValidationCode() throws { 676 | assertAndCompileMacroExpansion( 677 | """ 678 | @Codable(needsValidation: true) 679 | struct Foo { 680 | let bar: String 681 | 682 | var isValid: Bool { !bar.isEmpty } 683 | } 684 | """, 685 | expandedSource: """ 686 | 687 | struct Foo { 688 | let bar: String 689 | 690 | var isValid: Bool { !bar.isEmpty } 691 | 692 | init( 693 | bar: String 694 | ) { 695 | self.bar = bar 696 | } 697 | 698 | init(from decoder: Decoder) throws { 699 | let container = try decoder.container(keyedBy: CodingKeys.self) 700 | 701 | bar = try container.decode(String.self, forKey: .bar) 702 | 703 | if !self.isValid { 704 | throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Validation failed")) 705 | } 706 | } 707 | 708 | func encode(to encoder: Encoder) throws { 709 | var container = encoder.container(keyedBy: CodingKeys.self) 710 | 711 | try container.encode(bar, forKey: .bar) 712 | } 713 | 714 | enum CodingKeys: String, CodingKey { 715 | case bar 716 | } 717 | } 718 | 719 | extension Foo: Codable { 720 | } 721 | """, 722 | macros: testMacros 723 | ) 724 | } 725 | 726 | 727 | func testCodableMacro_whenPropertyTypeIsOmitted_throwsError() throws { 728 | assertMacroExpansion( 729 | """ 730 | @Codable 731 | struct Foo { 732 | var bar = false 733 | } 734 | """, 735 | expandedSource: """ 736 | 737 | struct Foo { 738 | var bar = false 739 | } 740 | 741 | extension Foo: Codable { 742 | } 743 | """, 744 | diagnostics: [ 745 | DiagnosticSpec(message: CodableMacroError.propertyTypeNotSpecified(propertyName: "bar").description, line: 1, column: 1) 746 | ], 747 | macros: testMacros 748 | ) 749 | } 750 | 751 | func testCodableMacro_whenAppliedToActor_throwsError() throws { 752 | assertMacroExpansion( 753 | """ 754 | @Codable 755 | actor Foo { 756 | } 757 | """, 758 | expandedSource: """ 759 | 760 | actor Foo { 761 | } 762 | """, 763 | diagnostics: [ 764 | DiagnosticSpec(message: CodableMacroError.notApplicableToActor.description, line: 1, column: 1) 765 | ], 766 | macros: testMacros 767 | ) 768 | } 769 | 770 | func testCodableMacro_whenAppliedToProtocol_throwsError() throws { 771 | assertMacroExpansion( 772 | """ 773 | @Codable 774 | protocol Foo { 775 | } 776 | """, 777 | expandedSource: """ 778 | 779 | protocol Foo { 780 | } 781 | """, 782 | diagnostics: [ 783 | DiagnosticSpec(message: CodableMacroError.notApplicableToProtocol.description, line: 1, column: 1) 784 | ], 785 | macros: testMacros 786 | ) 787 | } 788 | 789 | func testCodableMacro_whenCombinedWithAnotherCodableMacro_throwsError() throws { 790 | assertMacroExpansion( 791 | """ 792 | @Codable @Encodable 793 | struct Foo { 794 | var bar: String 795 | } 796 | """, 797 | expandedSource: """ 798 | 799 | struct Foo { 800 | var bar: String 801 | } 802 | """, 803 | diagnostics: [ 804 | DiagnosticSpec(message: CodableMacroError.moreThanOneCodableMacroApplied.description, line: 1, column: 1), 805 | DiagnosticSpec(message: CodableMacroError.moreThanOneCodableMacroApplied.description, line: 1, column: 10) 806 | ], 807 | macros: testMacros 808 | ) 809 | } 810 | 811 | func testCodableMacro_whenPropertyHasSameNameAsComponentOfCustomCodingKey() { 812 | assertMacroExpansion( 813 | """ 814 | @Codable 815 | struct Foo { 816 | var bar: String 817 | @CodableKey("bar.baz") var baz: Int 818 | } 819 | """, 820 | expandedSource: """ 821 | 822 | struct Foo { 823 | var bar: String 824 | var baz: Int 825 | 826 | init( 827 | bar: String, 828 | baz: Int 829 | ) { 830 | self.bar = bar 831 | self.baz = baz 832 | } 833 | 834 | init(from decoder: Decoder) throws { 835 | let container = try decoder.container(keyedBy: CodingKeys.self) 836 | 837 | bar = try container.decode(String.self, forKey: .bar) 838 | 839 | do { 840 | let barContainer = try container.nestedContainer(keyedBy: CodingKeys.BarCodingKeys.self, forKey: .bar) 841 | baz = try barContainer.decode(Int.self, forKey: .baz) 842 | } catch { 843 | throw error 844 | } 845 | } 846 | 847 | func encode(to encoder: Encoder) throws { 848 | var container = encoder.container(keyedBy: CodingKeys.self) 849 | var barContainer = container.nestedContainer(keyedBy: CodingKeys.BarCodingKeys.self, forKey: .bar) 850 | 851 | try container.encode(bar, forKey: .bar) 852 | try barContainer.encode(baz, forKey: .baz) 853 | } 854 | 855 | enum CodingKeys: String, CodingKey { 856 | case bar 857 | 858 | enum BarCodingKeys: String, CodingKey { 859 | case baz 860 | } 861 | } 862 | } 863 | 864 | extension Foo: Codable { 865 | } 866 | """, 867 | macros: testMacros 868 | ) 869 | } 870 | 871 | func testCodableMacro_withNonTrivialType() throws { 872 | assertAndCompileMacroExpansion( 873 | """ 874 | @Codable 875 | public struct Foo: Equatable { 876 | @CodableKey("beer.doo") public var bar: String 877 | @CodableKey("beer.fus") public var fus: String 878 | @CodableKey("ro.duh.dah") public var dah: String 879 | @CodableKey("booz") public var baz: Int? 880 | @CodableKey("qox") public var qux: [Qux] = [.one] 881 | 882 | public var array: [String] = [] 883 | public var optionalArray: [Int]? 884 | public var dict: [String: Int] 885 | 886 | @CodableIgnored public var neverMindMe: String = "some value" 887 | public let immutable: Int = 0 888 | } 889 | 890 | public enum Qux: String, Codable, Equatable { 891 | case one, two 892 | } 893 | """, 894 | expandedSource: """ 895 | 896 | public struct Foo: Equatable { 897 | public var bar: String 898 | public var fus: String 899 | public var dah: String 900 | public var baz: Int? 901 | public var qux: [Qux] = [.one] 902 | 903 | public var array: [String] = [] 904 | public var optionalArray: [Int]? 905 | public var dict: [String: Int] 906 | 907 | public var neverMindMe: String = "some value" 908 | public let immutable: Int = 0 909 | 910 | public init( 911 | bar: String, 912 | fus: String, 913 | dah: String, 914 | baz: Int? = nil, 915 | qux: Array = [.one], 916 | array: Array = [], 917 | optionalArray: Array? = nil, 918 | dict: Dictionary, 919 | neverMindMe: String = "some value" 920 | ) { 921 | self.bar = bar 922 | self.fus = fus 923 | self.dah = dah 924 | self.baz = baz 925 | self.qux = qux 926 | self.array = array 927 | self.optionalArray = optionalArray 928 | self.dict = dict 929 | self.neverMindMe = neverMindMe 930 | } 931 | 932 | public init(from decoder: Decoder) throws { 933 | let container = try decoder.container(keyedBy: CodingKeys.self) 934 | 935 | do { 936 | let beerContainer = try container.nestedContainer(keyedBy: CodingKeys.BeerCodingKeys.self, forKey: .beer) 937 | bar = try beerContainer.decode(String.self, forKey: .bar) 938 | } catch { 939 | throw error 940 | } 941 | 942 | do { 943 | let beerContainer = try container.nestedContainer(keyedBy: CodingKeys.BeerCodingKeys.self, forKey: .beer) 944 | fus = try beerContainer.decode(String.self, forKey: .fus) 945 | } catch { 946 | throw error 947 | } 948 | 949 | do { 950 | let roContainer = try container.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.self, forKey: .ro) 951 | let roDuhContainer = try roContainer.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.DuhCodingKeys.self, forKey: .duh) 952 | dah = try roDuhContainer.decode(String.self, forKey: .dah) 953 | } catch { 954 | throw error 955 | } 956 | 957 | do { 958 | baz = try container.decode(Int.self, forKey: .baz) 959 | } catch { 960 | baz = nil 961 | } 962 | 963 | do { 964 | qux = try container.decode([FailableContainer].self, forKey: .qux).compactMap { 965 | $0.wrappedValue 966 | } 967 | } catch { 968 | qux = [.one] 969 | } 970 | 971 | do { 972 | array = try container.decode([FailableContainer].self, forKey: .array).compactMap { 973 | $0.wrappedValue 974 | } 975 | } catch { 976 | array = [] 977 | } 978 | 979 | do { 980 | optionalArray = try container.decode([FailableContainer].self, forKey: .optionalArray).compactMap { 981 | $0.wrappedValue 982 | } 983 | } catch { 984 | optionalArray = nil 985 | } 986 | 987 | dict = try container.decode([String: FailableContainer].self, forKey: .dict).compactMapValues { 988 | $0.wrappedValue 989 | } 990 | } 991 | 992 | public func encode(to encoder: Encoder) throws { 993 | var container = encoder.container(keyedBy: CodingKeys.self) 994 | var beerContainer = container.nestedContainer(keyedBy: CodingKeys.BeerCodingKeys.self, forKey: .beer) 995 | var roContainer = container.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.self, forKey: .ro) 996 | var roDuhContainer = roContainer.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.DuhCodingKeys.self, forKey: .duh) 997 | 998 | try beerContainer.encode(bar, forKey: .bar) 999 | try beerContainer.encode(fus, forKey: .fus) 1000 | try roDuhContainer.encode(dah, forKey: .dah) 1001 | try container.encodeIfPresent(baz, forKey: .baz) 1002 | try container.encode(qux, forKey: .qux) 1003 | try container.encode(array, forKey: .array) 1004 | try container.encodeIfPresent(optionalArray, forKey: .optionalArray) 1005 | try container.encode(dict, forKey: .dict) 1006 | } 1007 | 1008 | enum CodingKeys: String, CodingKey { 1009 | case array, baz = "booz", beer, dict, optionalArray, qux = "qox", ro 1010 | 1011 | enum BeerCodingKeys: String, CodingKey { 1012 | case bar = "doo", fus 1013 | } 1014 | 1015 | enum RoCodingKeys: String, CodingKey { 1016 | case duh 1017 | 1018 | enum DuhCodingKeys: String, CodingKey { 1019 | case dah 1020 | } 1021 | } 1022 | } 1023 | 1024 | private struct FailableContainer: Decodable where T: Decodable { 1025 | var wrappedValue: T? 1026 | 1027 | init(from decoder: Decoder) throws { 1028 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 1029 | } 1030 | } 1031 | } 1032 | 1033 | public enum Qux: String, Codable, Equatable { 1034 | case one, two 1035 | } 1036 | 1037 | extension Foo: Codable { 1038 | } 1039 | """, 1040 | macros: testMacros 1041 | ) 1042 | } 1043 | } 1044 | -------------------------------------------------------------------------------- /Tests/CodableTests/DecodableTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntaxMacros 5 | import SwiftSyntaxMacrosTestSupport 6 | import XCTest 7 | 8 | @testable import CodableMacros 9 | 10 | final class DecodableTests: XCTestCase { 11 | let testMacros: [String: Macro.Type] = [ 12 | "Codable": CodableMacro.self, 13 | "Decodable": DecodableMacro.self, 14 | "Encodable": EncodableMacro.self, 15 | "CodableKey": CodableKeyMacro.self, 16 | "CodableIgnored": CodableIgnoredMacro.self, 17 | "CustomDecoded": CustomDecodedMacro.self, 18 | "MemberwiseInitializable": MemberwiseInitializableMacro.self 19 | ] 20 | 21 | func testDecodableMacro_withNonTrivialType() throws { 22 | assertAndCompileMacroExpansion( 23 | """ 24 | @Decodable 25 | public struct Foo: Equatable { 26 | @CodableKey("beer.doo") public var bar: Swift.String 27 | @CodableKey("beer.fus") public var fus: Swift.String 28 | @CodableKey("ro.duh.dah") public var dah: Swift.String 29 | @CodableKey("booz") public var baz: Int? 30 | @CodableKey("qox") public var qux: [Qux] = [.one] 31 | 32 | public var array: [Swift.String] = [] 33 | public var optionalArray: [Int]? 34 | public var dict: [Swift.String: Int] 35 | 36 | @CodableIgnored public var neverMindMe: Swift.String = "some value" 37 | public let immutable: Int = 0 38 | public static var booleanValue = false 39 | } 40 | 41 | public enum Qux: String, Codable, Equatable { 42 | case one, two 43 | } 44 | """, 45 | expandedSource: """ 46 | 47 | public struct Foo: Equatable { 48 | public var bar: Swift.String 49 | public var fus: Swift.String 50 | public var dah: Swift.String 51 | public var baz: Int? 52 | public var qux: [Qux] = [.one] 53 | 54 | public var array: [Swift.String] = [] 55 | public var optionalArray: [Int]? 56 | public var dict: [Swift.String: Int] 57 | 58 | public var neverMindMe: Swift.String = "some value" 59 | public let immutable: Int = 0 60 | public static var booleanValue = false 61 | 62 | public init( 63 | bar: Swift.String, 64 | fus: Swift.String, 65 | dah: Swift.String, 66 | baz: Int? = nil, 67 | qux: Array = [.one], 68 | array: Array = [], 69 | optionalArray: Array? = nil, 70 | dict: Dictionary, 71 | neverMindMe: Swift.String = "some value" 72 | ) { 73 | self.bar = bar 74 | self.fus = fus 75 | self.dah = dah 76 | self.baz = baz 77 | self.qux = qux 78 | self.array = array 79 | self.optionalArray = optionalArray 80 | self.dict = dict 81 | self.neverMindMe = neverMindMe 82 | } 83 | 84 | public init(from decoder: Decoder) throws { 85 | let container = try decoder.container(keyedBy: CodingKeys.self) 86 | 87 | do { 88 | let beerContainer = try container.nestedContainer(keyedBy: CodingKeys.BeerCodingKeys.self, forKey: .beer) 89 | bar = try beerContainer.decode(Swift.String.self, forKey: .bar) 90 | } catch { 91 | throw error 92 | } 93 | 94 | do { 95 | let beerContainer = try container.nestedContainer(keyedBy: CodingKeys.BeerCodingKeys.self, forKey: .beer) 96 | fus = try beerContainer.decode(Swift.String.self, forKey: .fus) 97 | } catch { 98 | throw error 99 | } 100 | 101 | do { 102 | let roContainer = try container.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.self, forKey: .ro) 103 | let roDuhContainer = try roContainer.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.DuhCodingKeys.self, forKey: .duh) 104 | dah = try roDuhContainer.decode(Swift.String.self, forKey: .dah) 105 | } catch { 106 | throw error 107 | } 108 | 109 | do { 110 | baz = try container.decode(Int.self, forKey: .baz) 111 | } catch { 112 | baz = nil 113 | } 114 | 115 | do { 116 | qux = try container.decode([FailableContainer].self, forKey: .qux).compactMap { 117 | $0.wrappedValue 118 | } 119 | } catch { 120 | qux = [.one] 121 | } 122 | 123 | do { 124 | array = try container.decode([FailableContainer].self, forKey: .array).compactMap { 125 | $0.wrappedValue 126 | } 127 | } catch { 128 | array = [] 129 | } 130 | 131 | do { 132 | optionalArray = try container.decode([FailableContainer].self, forKey: .optionalArray).compactMap { 133 | $0.wrappedValue 134 | } 135 | } catch { 136 | optionalArray = nil 137 | } 138 | 139 | dict = try container.decode([Swift.String: FailableContainer].self, forKey: .dict).compactMapValues { 140 | $0.wrappedValue 141 | } 142 | } 143 | 144 | enum CodingKeys: String, CodingKey { 145 | case array, baz = "booz", beer, dict, optionalArray, qux = "qox", ro 146 | 147 | enum BeerCodingKeys: String, CodingKey { 148 | case bar = "doo", fus 149 | } 150 | 151 | enum RoCodingKeys: String, CodingKey { 152 | case duh 153 | 154 | enum DuhCodingKeys: String, CodingKey { 155 | case dah 156 | } 157 | } 158 | } 159 | 160 | private struct FailableContainer: Decodable where T: Decodable { 161 | var wrappedValue: T? 162 | 163 | init(from decoder: Decoder) throws { 164 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 165 | } 166 | } 167 | } 168 | 169 | public enum Qux: String, Codable, Equatable { 170 | case one, two 171 | } 172 | 173 | extension Foo: Decodable { 174 | } 175 | """, 176 | macros: testMacros 177 | ) 178 | } 179 | 180 | func testDecodableMacro_whenAppliedToEnum() throws { 181 | assertAndCompileMacroExpansion( 182 | """ 183 | @Decodable 184 | enum Foo { 185 | case bar 186 | } 187 | """, 188 | expandedSource: """ 189 | 190 | enum Foo { 191 | case bar 192 | } 193 | 194 | extension Foo: Decodable { 195 | } 196 | """, 197 | macros: testMacros 198 | ) 199 | } 200 | 201 | 202 | func testDecodableMacro_whenAppliedToEmptyType() throws { 203 | assertAndCompileMacroExpansion( 204 | """ 205 | @Decodable 206 | struct Foo { 207 | } 208 | """, 209 | expandedSource: """ 210 | 211 | struct Foo { 212 | } 213 | 214 | extension Foo: Decodable { 215 | } 216 | """, 217 | macros: testMacros 218 | ) 219 | } 220 | 221 | func testDecodableMacro_whenPropertyTypeIsNested() throws { 222 | assertAndCompileMacroExpansion( 223 | """ 224 | @Decodable 225 | struct Outer { 226 | @Decodable 227 | struct Inner { 228 | @Decodable 229 | struct Innermost { 230 | } 231 | } 232 | 233 | let thing: Outer.Inner.Innermost 234 | } 235 | """, 236 | expandedSource: """ 237 | struct Outer { 238 | struct Inner { 239 | struct Innermost { 240 | } 241 | } 242 | 243 | let thing: Outer.Inner.Innermost 244 | 245 | init( 246 | thing: Outer.Inner.Innermost 247 | ) { 248 | self.thing = thing 249 | } 250 | 251 | init(from decoder: Decoder) throws { 252 | let container = try decoder.container(keyedBy: CodingKeys.self) 253 | 254 | thing = try container.decode(Outer.Inner.Innermost.self, forKey: .thing) 255 | } 256 | 257 | enum CodingKeys: String, CodingKey { 258 | case thing 259 | } 260 | } 261 | 262 | extension Outer.Inner.Innermost: Decodable { 263 | } 264 | 265 | extension Outer.Inner: Decodable { 266 | } 267 | 268 | extension Outer: Decodable { 269 | } 270 | """, 271 | macros: testMacros 272 | ) 273 | } 274 | 275 | func testDecodableMacro_whenDecodingArray() throws { 276 | assertAndCompileMacroExpansion( 277 | """ 278 | @Decodable 279 | struct Foo { 280 | var bar: [String] 281 | } 282 | """, 283 | expandedSource: """ 284 | 285 | struct Foo { 286 | var bar: [String] 287 | 288 | init( 289 | bar: Array 290 | ) { 291 | self.bar = bar 292 | } 293 | 294 | init(from decoder: Decoder) throws { 295 | let container = try decoder.container(keyedBy: CodingKeys.self) 296 | 297 | bar = try container.decode([FailableContainer].self, forKey: .bar).compactMap { 298 | $0.wrappedValue 299 | } 300 | } 301 | 302 | enum CodingKeys: String, CodingKey { 303 | case bar 304 | } 305 | 306 | private struct FailableContainer: Decodable where T: Decodable { 307 | var wrappedValue: T? 308 | 309 | init(from decoder: Decoder) throws { 310 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 311 | } 312 | } 313 | } 314 | 315 | extension Foo: Decodable { 316 | } 317 | """, 318 | macros: testMacros 319 | ) 320 | } 321 | 322 | func testDecodableMacro_whenDecodingSet() throws { 323 | assertAndCompileMacroExpansion( 324 | """ 325 | @Decodable 326 | struct Foo { 327 | var bar: Set 328 | } 329 | """, 330 | expandedSource: """ 331 | 332 | struct Foo { 333 | var bar: Set 334 | 335 | init( 336 | bar: Set 337 | ) { 338 | self.bar = bar 339 | } 340 | 341 | init(from decoder: Decoder) throws { 342 | let container = try decoder.container(keyedBy: CodingKeys.self) 343 | 344 | bar = Set(try container.decode([FailableContainer].self, forKey: .bar).compactMap { 345 | $0.wrappedValue 346 | }) 347 | } 348 | 349 | enum CodingKeys: String, CodingKey { 350 | case bar 351 | } 352 | 353 | private struct FailableContainer: Decodable where T: Decodable { 354 | var wrappedValue: T? 355 | 356 | init(from decoder: Decoder) throws { 357 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 358 | } 359 | } 360 | } 361 | 362 | extension Foo: Decodable { 363 | } 364 | """, 365 | macros: testMacros 366 | ) 367 | } 368 | 369 | func testDecodableMacro_whenDecodingDictionary() throws { 370 | assertAndCompileMacroExpansion( 371 | """ 372 | @Decodable 373 | struct Foo { 374 | var bar: [String: Int] 375 | } 376 | """, 377 | expandedSource: """ 378 | 379 | struct Foo { 380 | var bar: [String: Int] 381 | 382 | init( 383 | bar: Dictionary 384 | ) { 385 | self.bar = bar 386 | } 387 | 388 | init(from decoder: Decoder) throws { 389 | let container = try decoder.container(keyedBy: CodingKeys.self) 390 | 391 | bar = try container.decode([String: FailableContainer].self, forKey: .bar).compactMapValues { 392 | $0.wrappedValue 393 | } 394 | } 395 | 396 | enum CodingKeys: String, CodingKey { 397 | case bar 398 | } 399 | 400 | private struct FailableContainer: Decodable where T: Decodable { 401 | var wrappedValue: T? 402 | 403 | init(from decoder: Decoder) throws { 404 | wrappedValue = try? decoder.singleValueContainer().decode(T.self) 405 | } 406 | } 407 | } 408 | 409 | extension Foo: Decodable { 410 | } 411 | """, 412 | macros: testMacros 413 | ) 414 | } 415 | 416 | func testDecodableMacro_whenValidationNeeded_includesValidationCode() throws { 417 | assertAndCompileMacroExpansion( 418 | """ 419 | @Decodable(needsValidation: true) 420 | struct Foo { 421 | let bar: String 422 | 423 | var isValid: Bool { !bar.isEmpty } 424 | } 425 | """, 426 | expandedSource: """ 427 | 428 | struct Foo { 429 | let bar: String 430 | 431 | var isValid: Bool { !bar.isEmpty } 432 | 433 | init( 434 | bar: String 435 | ) { 436 | self.bar = bar 437 | } 438 | 439 | init(from decoder: Decoder) throws { 440 | let container = try decoder.container(keyedBy: CodingKeys.self) 441 | 442 | bar = try container.decode(String.self, forKey: .bar) 443 | 444 | if !self.isValid { 445 | throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Validation failed")) 446 | } 447 | } 448 | 449 | enum CodingKeys: String, CodingKey { 450 | case bar 451 | } 452 | } 453 | 454 | extension Foo: Decodable { 455 | } 456 | """, 457 | macros: testMacros 458 | ) 459 | } 460 | 461 | func testDecodableMacro_whenCustomDecodedApplied_callsCustomDecodingFunction() throws { 462 | assertAndCompileMacroExpansion( 463 | """ 464 | @Decodable 465 | struct Foo { 466 | @CustomDecoded let specialProperty: String 467 | 468 | static func decodeSpecialProperty(from decoder: Decoder) throws -> String { "custom decoded value" } 469 | } 470 | """, 471 | expandedSource: """ 472 | 473 | struct Foo { 474 | let specialProperty: String 475 | 476 | static func decodeSpecialProperty(from decoder: Decoder) throws -> String { "custom decoded value" } 477 | 478 | init( 479 | specialProperty: String 480 | ) { 481 | self.specialProperty = specialProperty 482 | } 483 | 484 | init(from decoder: Decoder) throws { 485 | specialProperty = try Self.decodeSpecialProperty(from: decoder) 486 | } 487 | 488 | enum CodingKeys: String, CodingKey { 489 | case specialProperty 490 | } 491 | } 492 | 493 | extension Foo: Decodable { 494 | } 495 | """, 496 | macros: testMacros 497 | ) 498 | } 499 | 500 | func testDecodableMacro_whenCustomDecodedAppliedToImplicitlyIgnoredProperty_throwsError() throws { 501 | assertMacroExpansion( 502 | """ 503 | @Decodable 504 | struct Foo { 505 | @CustomDecoded let specialProperty: String = "" 506 | 507 | static func decodeSpecialProperty(from decoder: Decoder) throws -> Bool { "custom decoded value" } 508 | } 509 | """, 510 | expandedSource: """ 511 | 512 | struct Foo { 513 | let specialProperty: String = "" 514 | 515 | static func decodeSpecialProperty(from decoder: Decoder) throws -> Bool { "custom decoded value" } 516 | } 517 | 518 | extension Foo: Decodable { 519 | } 520 | """, 521 | diagnostics: [ 522 | DiagnosticSpec(message: CodableMacroError.customDecodingNotApplicableToExcludedProperty(propertyName: "specialProperty").description, line: 1, column: 1) 523 | ], 524 | macros: testMacros 525 | ) 526 | } 527 | 528 | 529 | func testDecodableMacro_whenCustomDecodedAppliedToExplicitlyIgnoredProperty_throwsError() throws { 530 | assertMacroExpansion( 531 | """ 532 | @Decodable 533 | struct Foo { 534 | @CustomDecoded @CodableIgnored let specialProperty: String 535 | 536 | static func decodeSpecialProperty(from decoder: Decoder) throws -> Bool { "custom decoded value" } 537 | } 538 | """, 539 | expandedSource: """ 540 | 541 | struct Foo { 542 | let specialProperty: String 543 | 544 | static func decodeSpecialProperty(from decoder: Decoder) throws -> Bool { "custom decoded value" } 545 | } 546 | 547 | extension Foo: Decodable { 548 | } 549 | """, 550 | diagnostics: [ 551 | DiagnosticSpec(message: CodableMacroError.customDecodingNotApplicableToExcludedProperty(propertyName: "specialProperty").description, line: 1, column: 1) 552 | ], 553 | macros: testMacros 554 | ) 555 | } 556 | 557 | func testDecodableMacro_whenPropertyTypeIsOmitted_throwsError() throws { 558 | assertMacroExpansion( 559 | """ 560 | @Decodable 561 | struct Foo { 562 | var bar = false 563 | } 564 | """, 565 | expandedSource: """ 566 | 567 | struct Foo { 568 | var bar = false 569 | } 570 | 571 | extension Foo: Decodable { 572 | } 573 | """, 574 | diagnostics: [ 575 | DiagnosticSpec(message: CodableMacroError.propertyTypeNotSpecified(propertyName: "bar").description, line: 1, column: 1) 576 | ], 577 | macros: testMacros 578 | ) 579 | } 580 | 581 | func testDecodableMacro_whenPropertyIsStatic_isIgnored() throws { 582 | assertAndCompileMacroExpansion( 583 | """ 584 | @Decodable 585 | struct Foo { 586 | static var foo: Int = 0 587 | static var bar = false 588 | } 589 | """, 590 | expandedSource: """ 591 | 592 | struct Foo { 593 | static var foo: Int = 0 594 | static var bar = false 595 | } 596 | 597 | extension Foo: Decodable { 598 | } 599 | """, 600 | macros: testMacros 601 | ) 602 | } 603 | 604 | func testDecodableMacro_whenAppliedToActor_throwsError() throws { 605 | assertMacroExpansion( 606 | """ 607 | @Decodable 608 | actor Foo { 609 | } 610 | """, 611 | expandedSource: """ 612 | 613 | actor Foo { 614 | } 615 | """, 616 | diagnostics: [ 617 | DiagnosticSpec(message: CodableMacroError.notApplicableToActor.description, line: 1, column: 1) 618 | ], 619 | macros: testMacros 620 | ) 621 | } 622 | 623 | func testDecodableMacro_whenAppliedToProtocol_throwsError() throws { 624 | assertMacroExpansion( 625 | """ 626 | @Decodable 627 | protocol Foo { 628 | } 629 | """, 630 | expandedSource: """ 631 | 632 | protocol Foo { 633 | } 634 | """, 635 | diagnostics: [ 636 | DiagnosticSpec(message: CodableMacroError.notApplicableToProtocol.description, line: 1, column: 1) 637 | ], 638 | macros: testMacros 639 | ) 640 | } 641 | 642 | func testDecodableMacro_whenCombinedWithAnotherCodableMacro_throwsError() throws { 643 | assertMacroExpansion( 644 | """ 645 | @Decodable @Encodable 646 | struct Foo { 647 | var bar: String 648 | } 649 | """, 650 | expandedSource: """ 651 | 652 | struct Foo { 653 | var bar: String 654 | } 655 | """, 656 | diagnostics: [ 657 | DiagnosticSpec(message: CodableMacroError.moreThanOneCodableMacroApplied.description, line: 1, column: 1), 658 | DiagnosticSpec(message: CodableMacroError.moreThanOneCodableMacroApplied.description, line: 1, column: 12) 659 | ], 660 | macros: testMacros 661 | ) 662 | } 663 | 664 | func testDecodableMacro_whenPropertyHasSameNameAsComponentOfCustomCodingKey() { 665 | assertAndCompileMacroExpansion( 666 | """ 667 | @Decodable 668 | struct Foo { 669 | var bar: String 670 | @CodableKey("bar.baz") var baz: Int 671 | } 672 | """, 673 | expandedSource: """ 674 | 675 | struct Foo { 676 | var bar: String 677 | var baz: Int 678 | 679 | init( 680 | bar: String, 681 | baz: Int 682 | ) { 683 | self.bar = bar 684 | self.baz = baz 685 | } 686 | 687 | init(from decoder: Decoder) throws { 688 | let container = try decoder.container(keyedBy: CodingKeys.self) 689 | 690 | bar = try container.decode(String.self, forKey: .bar) 691 | 692 | do { 693 | let barContainer = try container.nestedContainer(keyedBy: CodingKeys.BarCodingKeys.self, forKey: .bar) 694 | baz = try barContainer.decode(Int.self, forKey: .baz) 695 | } catch { 696 | throw error 697 | } 698 | } 699 | 700 | enum CodingKeys: String, CodingKey { 701 | case bar 702 | 703 | enum BarCodingKeys: String, CodingKey { 704 | case baz 705 | } 706 | } 707 | } 708 | 709 | extension Foo: Decodable { 710 | } 711 | """, 712 | macros: testMacros 713 | ) 714 | } 715 | 716 | func testDecodableMacro_whenHasMemberwiseInitializableMacro_generatesMemberwiseIntializerOnce() throws { 717 | assertMacroExpansion( 718 | """ 719 | @Decodable 720 | @MemberwiseInitializable 721 | struct Foo { 722 | var bar: String 723 | } 724 | """, 725 | expandedSource: """ 726 | 727 | struct Foo { 728 | var bar: String 729 | 730 | init(from decoder: Decoder) throws { 731 | let container = try decoder.container(keyedBy: CodingKeys.self) 732 | 733 | bar = try container.decode(String.self, forKey: .bar) 734 | } 735 | 736 | enum CodingKeys: String, CodingKey { 737 | case bar 738 | } 739 | 740 | init( 741 | bar: String 742 | ) { 743 | self.bar = bar 744 | } 745 | } 746 | 747 | extension Foo: Decodable { 748 | } 749 | """, 750 | macros: testMacros 751 | ) 752 | } 753 | 754 | func testDecodableMacro_whenHasMemberwiseInitializableMacroWithAccessLevel_generatesMemberwiseIntializerOnce() throws { 755 | assertAndCompileMacroExpansion( 756 | """ 757 | @Decodable 758 | @MemberwiseInitializable(.private) 759 | struct Foo { 760 | var bar: String 761 | } 762 | """, 763 | expandedSource: """ 764 | 765 | struct Foo { 766 | var bar: String 767 | 768 | init(from decoder: Decoder) throws { 769 | let container = try decoder.container(keyedBy: CodingKeys.self) 770 | 771 | bar = try container.decode(String.self, forKey: .bar) 772 | } 773 | 774 | enum CodingKeys: String, CodingKey { 775 | case bar 776 | } 777 | 778 | private init( 779 | bar: String 780 | ) { 781 | self.bar = bar 782 | } 783 | } 784 | 785 | extension Foo: Decodable { 786 | } 787 | """, 788 | macros: testMacros 789 | ) 790 | } 791 | } 792 | -------------------------------------------------------------------------------- /Tests/CodableTests/EncodableTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntaxMacros 5 | import SwiftSyntaxMacrosTestSupport 6 | import XCTest 7 | 8 | @testable import CodableMacros 9 | 10 | final class EncodableTests: XCTestCase { 11 | let testMacros: [String: Macro.Type] = [ 12 | "Codable": CodableMacro.self, 13 | "Decodable": DecodableMacro.self, 14 | "Encodable": EncodableMacro.self, 15 | "CodableKey": CodableKeyMacro.self, 16 | "CodableIgnored": CodableIgnoredMacro.self 17 | ] 18 | 19 | func testEncodableMacro_withNonTrivialType() throws { 20 | assertAndCompileMacroExpansion( 21 | """ 22 | @Encodable 23 | public struct Foo: Equatable { 24 | @CodableKey("beer.doo") public var bar: String 25 | @CodableKey("beer.fus") public var fus: String 26 | @CodableKey("ro.duh.dah") public var dah: String 27 | @CodableKey("booz") public var baz: Int? 28 | @CodableKey("qox") public var qux: [Qux] = [.one] 29 | 30 | public var array: [String] = [] 31 | public var optionalArray: [Int]? 32 | public var dict: [String: Int] 33 | 34 | @CodableIgnored public var neverMindMe: String = "some value" 35 | public let immutable: Int = 0 36 | public static var booleanValue = false 37 | } 38 | 39 | public enum Qux: String, Encodable, Equatable { 40 | case one, two 41 | } 42 | """, 43 | expandedSource: """ 44 | 45 | public struct Foo: Equatable { 46 | public var bar: String 47 | public var fus: String 48 | public var dah: String 49 | public var baz: Int? 50 | public var qux: [Qux] = [.one] 51 | 52 | public var array: [String] = [] 53 | public var optionalArray: [Int]? 54 | public var dict: [String: Int] 55 | 56 | public var neverMindMe: String = "some value" 57 | public let immutable: Int = 0 58 | public static var booleanValue = false 59 | 60 | public func encode(to encoder: Encoder) throws { 61 | var container = encoder.container(keyedBy: CodingKeys.self) 62 | var beerContainer = container.nestedContainer(keyedBy: CodingKeys.BeerCodingKeys.self, forKey: .beer) 63 | var roContainer = container.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.self, forKey: .ro) 64 | var roDuhContainer = roContainer.nestedContainer(keyedBy: CodingKeys.RoCodingKeys.DuhCodingKeys.self, forKey: .duh) 65 | 66 | try beerContainer.encode(bar, forKey: .bar) 67 | try beerContainer.encode(fus, forKey: .fus) 68 | try roDuhContainer.encode(dah, forKey: .dah) 69 | try container.encodeIfPresent(baz, forKey: .baz) 70 | try container.encode(qux, forKey: .qux) 71 | try container.encode(array, forKey: .array) 72 | try container.encodeIfPresent(optionalArray, forKey: .optionalArray) 73 | try container.encode(dict, forKey: .dict) 74 | } 75 | 76 | enum CodingKeys: String, CodingKey { 77 | case array, baz = "booz", beer, dict, optionalArray, qux = "qox", ro 78 | 79 | enum BeerCodingKeys: String, CodingKey { 80 | case bar = "doo", fus 81 | } 82 | 83 | enum RoCodingKeys: String, CodingKey { 84 | case duh 85 | 86 | enum DuhCodingKeys: String, CodingKey { 87 | case dah 88 | } 89 | } 90 | } 91 | } 92 | 93 | public enum Qux: String, Encodable, Equatable { 94 | case one, two 95 | } 96 | 97 | extension Foo: Encodable { 98 | } 99 | """, 100 | macros: testMacros 101 | ) 102 | } 103 | 104 | func testEncodableMacro_whenAppliedToEnum() throws { 105 | assertAndCompileMacroExpansion( 106 | """ 107 | @Encodable 108 | enum Foo { 109 | case bar 110 | } 111 | """, 112 | expandedSource: """ 113 | 114 | enum Foo { 115 | case bar 116 | } 117 | 118 | extension Foo: Encodable { 119 | } 120 | """, 121 | macros: testMacros 122 | ) 123 | } 124 | 125 | func testEncodableMacro_whenAppliedToEmptyType() throws { 126 | assertAndCompileMacroExpansion( 127 | """ 128 | @Encodable 129 | struct Foo { 130 | } 131 | """, 132 | expandedSource: """ 133 | 134 | struct Foo { 135 | } 136 | 137 | extension Foo: Encodable { 138 | } 139 | """, 140 | macros: testMacros 141 | ) 142 | } 143 | 144 | func testEncodableMacro_whenPropertyIsStatic_isIgnored() throws { 145 | assertAndCompileMacroExpansion( 146 | """ 147 | @Encodable 148 | struct Foo { 149 | static var foo: Int = 0 150 | static var bar = false 151 | } 152 | """, 153 | expandedSource: """ 154 | 155 | struct Foo { 156 | static var foo: Int = 0 157 | static var bar = false 158 | } 159 | 160 | extension Foo: Encodable { 161 | } 162 | """, 163 | macros: testMacros 164 | ) 165 | } 166 | 167 | func testDecodableMacro_whenPropertyTypeIsNested() throws { 168 | assertAndCompileMacroExpansion( 169 | """ 170 | @Encodable 171 | struct Outer { 172 | @Encodable 173 | struct Inner { 174 | @Encodable 175 | struct Innermost { 176 | } 177 | } 178 | 179 | let thing: Outer.Inner.Innermost 180 | } 181 | """, 182 | expandedSource: """ 183 | struct Outer { 184 | struct Inner { 185 | struct Innermost { 186 | } 187 | } 188 | 189 | let thing: Outer.Inner.Innermost 190 | 191 | func encode(to encoder: Encoder) throws { 192 | var container = encoder.container(keyedBy: CodingKeys.self) 193 | 194 | try container.encode(thing, forKey: .thing) 195 | } 196 | 197 | enum CodingKeys: String, CodingKey { 198 | case thing 199 | } 200 | } 201 | 202 | extension Outer.Inner.Innermost: Encodable { 203 | } 204 | 205 | extension Outer.Inner: Encodable { 206 | } 207 | 208 | extension Outer: Encodable { 209 | } 210 | """, 211 | macros: testMacros 212 | ) 213 | } 214 | 215 | func testEncodableMacro_whenAppliedToActor_throwsError() throws { 216 | assertMacroExpansion( 217 | """ 218 | @Encodable 219 | actor Foo { 220 | } 221 | """, 222 | expandedSource: """ 223 | 224 | actor Foo { 225 | } 226 | """, 227 | diagnostics: [ 228 | DiagnosticSpec(message: CodableMacroError.notApplicableToActor.description, line: 1, column: 1) 229 | ], 230 | macros: testMacros 231 | ) 232 | } 233 | 234 | func testEncodableMacro_whenAppliedToProtocol_throwsError() throws { 235 | assertMacroExpansion( 236 | """ 237 | @Encodable 238 | protocol Foo { 239 | } 240 | """, 241 | expandedSource: """ 242 | 243 | protocol Foo { 244 | } 245 | """, 246 | diagnostics: [ 247 | DiagnosticSpec(message: CodableMacroError.notApplicableToProtocol.description, line: 1, column: 1) 248 | ], 249 | macros: testMacros 250 | ) 251 | } 252 | 253 | func testEncodableMacro_whenCombinedWithAnotherCodableMacro_throwsError() throws { 254 | assertMacroExpansion( 255 | """ 256 | @Encodable @Decodable 257 | struct Foo { 258 | var bar: String 259 | } 260 | """, 261 | expandedSource: """ 262 | 263 | struct Foo { 264 | var bar: String 265 | } 266 | """, 267 | diagnostics: [ 268 | DiagnosticSpec(message: CodableMacroError.moreThanOneCodableMacroApplied.description, line: 1, column: 1), 269 | DiagnosticSpec(message: CodableMacroError.moreThanOneCodableMacroApplied.description, line: 1, column: 12) 270 | ], 271 | macros: testMacros 272 | ) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /Tests/CodableTests/MemberwiseInitializableTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import SwiftSyntaxMacros 5 | import SwiftSyntaxMacrosTestSupport 6 | import XCTest 7 | 8 | @testable import CodableMacros 9 | 10 | final class MemberwiseInitializableTests: XCTestCase { 11 | let testMacros: [String: Macro.Type] = [ 12 | "MemberwiseInitializable": MemberwiseInitializableMacro.self 13 | ] 14 | 15 | func testMemberwiseInitializableMacro_withSimpleType() throws { 16 | assertAndCompileMacroExpansion( 17 | """ 18 | @MemberwiseInitializable 19 | struct Foo { 20 | var bar: String 21 | } 22 | """, 23 | expandedSource: """ 24 | 25 | struct Foo { 26 | var bar: String 27 | 28 | init( 29 | bar: String 30 | ) { 31 | self.bar = bar 32 | } 33 | } 34 | """, 35 | macros: testMacros 36 | ) 37 | } 38 | 39 | func testMemberwiseInitializableMacro_whenPropertyTypeHasGenerics() throws { 40 | assertAndCompileMacroExpansion( 41 | """ 42 | @MemberwiseInitializable 43 | struct Foo { 44 | var bar: Generic 45 | } 46 | 47 | struct Generic { 48 | } 49 | """, 50 | expandedSource: """ 51 | 52 | struct Foo { 53 | var bar: Generic 54 | 55 | init( 56 | bar: Generic 57 | ) { 58 | self.bar = bar 59 | } 60 | } 61 | 62 | struct Generic { 63 | } 64 | """, 65 | macros: testMacros 66 | ) 67 | } 68 | 69 | func testMemberwiseInitializableMacro_withDefaultPropertyValues() throws { 70 | assertAndCompileMacroExpansion( 71 | """ 72 | @MemberwiseInitializable 73 | struct Foo { 74 | var bar: String = "default value" 75 | } 76 | """, 77 | expandedSource: """ 78 | 79 | struct Foo { 80 | var bar: String = "default value" 81 | 82 | init( 83 | bar: String = "default value" 84 | ) { 85 | self.bar = bar 86 | } 87 | } 88 | """, 89 | macros: testMacros 90 | ) 91 | } 92 | 93 | func testMemberwiseInitializableMacro_ifImmutablePropertyHasDefaultValue_isNotIncluded() throws { 94 | assertAndCompileMacroExpansion( 95 | """ 96 | @MemberwiseInitializable 97 | struct Foo { 98 | var bar: String 99 | var mutable: String = "something" 100 | let immutable: String = "something else" 101 | } 102 | """, 103 | expandedSource: """ 104 | 105 | struct Foo { 106 | var bar: String 107 | var mutable: String = "something" 108 | let immutable: String = "something else" 109 | 110 | init( 111 | bar: String, 112 | mutable: String = "something" 113 | ) { 114 | self.bar = bar 115 | self.mutable = mutable 116 | } 117 | } 118 | """, 119 | macros: testMacros 120 | ) 121 | } 122 | 123 | func testMemberwiseInitializableMacro_withPublicType_generatesPublicInitializer() throws { 124 | assertAndCompileMacroExpansion( 125 | """ 126 | @MemberwiseInitializable 127 | public struct Foo { 128 | public var bar: String 129 | } 130 | """, 131 | expandedSource: """ 132 | 133 | public struct Foo { 134 | public var bar: String 135 | 136 | public init( 137 | bar: String 138 | ) { 139 | self.bar = bar 140 | } 141 | } 142 | """, 143 | macros: testMacros 144 | ) 145 | } 146 | 147 | func testMemberwiseInitializableMacro_withOpenType_generatesPublicInitializer() throws { 148 | assertAndCompileMacroExpansion( 149 | """ 150 | @MemberwiseInitializable 151 | open class Foo { 152 | public var bar: String 153 | } 154 | """, 155 | expandedSource: """ 156 | 157 | open class Foo { 158 | public var bar: String 159 | 160 | public init( 161 | bar: String 162 | ) { 163 | self.bar = bar 164 | } 165 | } 166 | """, 167 | macros: testMacros 168 | ) 169 | } 170 | 171 | func testMemberwiseInitializableMacro_whenTypeHasInitialValue_usesItAsDefaultValue() throws { 172 | assertAndCompileMacroExpansion( 173 | """ 174 | @MemberwiseInitializable 175 | class Foo { 176 | var something: String? = "default value" 177 | var p1: String? 178 | var p2: Optional 179 | var p3: String 180 | } 181 | """, 182 | expandedSource: """ 183 | 184 | class Foo { 185 | var something: String? = "default value" 186 | var p1: String? 187 | var p2: Optional 188 | var p3: String 189 | 190 | init( 191 | something: String? = "default value", 192 | p1: String? = nil, 193 | p2: String? = nil, 194 | p3: String 195 | ) { 196 | self.something = something 197 | self.p1 = p1 198 | self.p2 = p2 199 | self.p3 = p3 200 | } 201 | } 202 | """, 203 | macros: testMacros 204 | ) 205 | } 206 | 207 | func testMemberwiseInitializableMacro_withAccessLevel_generatesInitializerWithSpecifiedAccessLevel() throws { 208 | assertAndCompileMacroExpansion( 209 | """ 210 | @MemberwiseInitializable(.fileprivate) 211 | public struct Foo { 212 | public var bar: String 213 | } 214 | """, 215 | expandedSource: """ 216 | 217 | public struct Foo { 218 | public var bar: String 219 | 220 | fileprivate init( 221 | bar: String 222 | ) { 223 | self.bar = bar 224 | } 225 | } 226 | """, 227 | macros: testMacros 228 | ) 229 | } 230 | 231 | func testMemberwiseInitializableMacro_whenAppliedToFinalType() throws { 232 | assertAndCompileMacroExpansion( 233 | """ 234 | @MemberwiseInitializable 235 | final class Foo { 236 | let bar: String 237 | } 238 | """, 239 | expandedSource: """ 240 | 241 | final class Foo { 242 | let bar: String 243 | 244 | init( 245 | bar: String 246 | ) { 247 | self.bar = bar 248 | } 249 | } 250 | """, 251 | macros: testMacros 252 | ) 253 | } 254 | 255 | func testMemberwiseInitializableMacro_whenAppliedToFinalPublicType_handlesNonStandardKeywordOrder() throws { 256 | assertAndCompileMacroExpansion( 257 | """ 258 | @MemberwiseInitializable 259 | final public class Foo { 260 | let bar: String 261 | } 262 | """, 263 | expandedSource: """ 264 | 265 | final public class Foo { 266 | let bar: String 267 | 268 | public init( 269 | bar: String 270 | ) { 271 | self.bar = bar 272 | } 273 | } 274 | """, 275 | macros: testMacros 276 | ) 277 | } 278 | 279 | func testMemberwiseInitializableMacro_withNonTrivialType() throws { 280 | assertAndCompileMacroExpansion( 281 | """ 282 | @MemberwiseInitializable(.private) 283 | struct Foo { 284 | var bar: String = "" 285 | var boo: Int? 286 | var fus: [String] 287 | var dah: [String?: Int?]? 288 | } 289 | """, 290 | expandedSource: """ 291 | 292 | struct Foo { 293 | var bar: String = "" 294 | var boo: Int? 295 | var fus: [String] 296 | var dah: [String?: Int?]? 297 | 298 | private init( 299 | bar: String = "", 300 | boo: Int? = nil, 301 | fus: Array, 302 | dah: Dictionary? = nil 303 | ) { 304 | self.bar = bar 305 | self.boo = boo 306 | self.fus = fus 307 | self.dah = dah 308 | } 309 | } 310 | """, 311 | macros: testMacros 312 | ) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /Tests/CodableTests/XCTest+AssertAndCompileMacroExpansion.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2025 Schibsted News Media AB. 2 | // Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. 3 | 4 | import Foundation 5 | import SwiftSyntaxMacros 6 | import SwiftSyntaxMacrosTestSupport 7 | import XCTest 8 | 9 | func assertAndCompileMacroExpansion( 10 | _ originalSource: String, 11 | expandedSource expectedExpandedSource: String, 12 | macros: [String : any Macro.Type], 13 | treatWarningsAsErrors: Bool = true, 14 | file: StaticString = #filePath, 15 | line: UInt = #line 16 | ) { 17 | do { 18 | let (exitCode, output) = try compileSourceCode( 19 | expectedExpandedSource, 20 | treatWarningsAsErrors: treatWarningsAsErrors 21 | ) 22 | 23 | guard exitCode == 0 else { 24 | XCTFail("Expanded source did not compile, swiftc exit code: \(exitCode), output: \(output ?? "nil")", file: file, line: line) 25 | return 26 | } 27 | 28 | assertMacroExpansion(originalSource, expandedSource: expectedExpandedSource, macros: macros, file: file, line: line) 29 | } catch { 30 | XCTFail("Failed to invoke the compile command: \(error)", file: file, line: line) 31 | } 32 | } 33 | 34 | private func compileSourceCode(_ sourceCode: String, treatWarningsAsErrors: Bool) throws -> (exitCode: Int32, output: String?) { 35 | let task = Process() 36 | let outputPipe = Pipe() 37 | 38 | task.standardOutput = outputPipe 39 | task.standardError = outputPipe 40 | task.arguments = ["-c", "echo '\(sourceCode)' | swiftc \(treatWarningsAsErrors ? "-warnings-as-errors" : "") -"] 41 | task.launchPath = "/bin/zsh" 42 | task.standardInput = nil 43 | try task.run() 44 | task.waitUntilExit() 45 | 46 | let data = outputPipe.fileHandleForReading.readDataToEndOfFile() 47 | let output = String(data: data, encoding: .utf8) 48 | 49 | return (task.terminationStatus, output) 50 | } 51 | --------------------------------------------------------------------------------