From b7fb8a4e42b2c9f46ac0dd3eca3f5445b73e1875 Mon Sep 17 00:00:00 2001 From: phoenix Date: Sun, 30 Aug 2026 13:49:17 +0800 Subject: [PATCH 1/2] fix: silence CustomCoding try warnings for non-throwing closures Route generated CustomCoding through throwing helpers, skip empty encode closures, and allow combining with EncodingIgnored/DecodingIgnored. Co-authored-by: Cursor --- Sources/ReerCodable/Decoder+Extensions.swift | 9 ++ Sources/ReerCodable/Encoder+Extensions.swift | 9 ++ .../MacroDeclarations/CustomCoding.swift | 3 + .../CustomCodingImpl.swift | 2 - Sources/ReerCodableMacros/TypeInfo.swift | 52 +++++++-- .../ReerCodableTests/CustomCodingTests.swift | 72 ++++++++++++ Tests/ReerCodableTests/MacroExpandTests.swift | 108 +++++++++++++++++- 7 files changed, 238 insertions(+), 17 deletions(-) diff --git a/Sources/ReerCodable/Decoder+Extensions.swift b/Sources/ReerCodable/Decoder+Extensions.swift index aa2d868..a18cf00 100644 --- a/Sources/ReerCodable/Decoder+Extensions.swift +++ b/Sources/ReerCodable/Decoder+Extensions.swift @@ -76,4 +76,13 @@ public extension Decoder { let container = try container(keyedBy: AnyCodingKey.self) return try container.decode(type: Value.self, keys: keys.map { .init($0, $0.contains(".")) }) } + + /// Invokes a `@CustomCoding` decode closure. + /// + /// The method is declared as `throws` so generated `try` / `try?` stays valid + /// even when the user closure itself does not throw (for example it only uses `try?`). + @inlinable + func customDecode(using decode: (any Decoder) throws -> Value) throws -> Value { + try decode(self) + } } diff --git a/Sources/ReerCodable/Encoder+Extensions.swift b/Sources/ReerCodable/Encoder+Extensions.swift index dc0ff9a..8da11cf 100644 --- a/Sources/ReerCodable/Encoder+Extensions.swift +++ b/Sources/ReerCodable/Encoder+Extensions.swift @@ -74,4 +74,13 @@ public extension Encoder { treatDotAsNested: treatDotAsNested ) } + + /// Invokes a `@CustomCoding` encode closure. + /// + /// The method is declared as `throws` so generated `try` stays valid + /// even when the user closure itself does not throw. + @inlinable + func customEncode(_ value: Value, using encode: (any Encoder, Value) throws -> Void) throws { + try encode(self, value) + } } diff --git a/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift b/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift index 25a0677..78a515a 100644 --- a/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift +++ b/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift @@ -34,6 +34,9 @@ /// ) /// var customValue: Int /// ``` +/// +/// Closures do not have to throw. An empty `encode` closure skips encoding that property. +/// Combine with `@EncodingIgnored` or `@DecodingIgnored` for decode-only / encode-only custom logic. @attached(peer) public macro CustomCoding( decode: ((_ decoder: any Decoder) throws -> Value)? = nil, diff --git a/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift b/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift index 667e838..a0aea4f 100644 --- a/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift +++ b/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift @@ -34,8 +34,6 @@ public struct CustomCoding: PeerMacro { if variable.attributes.count > 1 { let incompatibleMacros = [ "CodingIgnored", - "EncodingIgnored", - "DecodingIgnored", "Base64Coding", "DateCoding", "CompactDecoding", diff --git a/Sources/ReerCodableMacros/TypeInfo.swift b/Sources/ReerCodableMacros/TypeInfo.swift index c902a4f..447e8ca 100644 --- a/Sources/ReerCodableMacros/TypeInfo.swift +++ b/Sources/ReerCodableMacros/TypeInfo.swift @@ -368,15 +368,22 @@ extension TypeInfo { if let customCoding = variable.attributes.firstAttribute(named: "CustomCoding"), let attribute = customCoding.as(AttributeSyntax.self), let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) { - property.customDecoder = arguments - .first(where: { $0.label?.identifier?.name == "decode" })? - .expression.trimmedDescription - - property.customEncoder = arguments - .first(where: { $0.label?.identifier?.name == "encode" })? - .expression.trimmedDescription - - if property.customDecoder == nil, property.customEncoder == nil { + let decodeArg = arguments.first(where: { $0.label?.identifier?.name == "decode" }) + let encodeArg = arguments.first(where: { $0.label?.identifier?.name == "encode" }) + + if let decodeArg { + property.customDecoder = decodeArg.expression.trimmedDescription + } + + if let encodeArg { + if encodeArg.expression.isEmptyClosure { + property.ignoreEncoding = true + } else { + property.customEncoder = encodeArg.expression.trimmedDescription + } + } + + if decodeArg == nil, encodeArg == nil { property.customByType = arguments.trimmedDescription } } @@ -743,7 +750,7 @@ extension TypeInfo { // custom decode else if let customDecoder = property.customDecoder { body = """ - \(customDecoder)(decoder) + decoder.customDecode(using: \(customDecoder)) """ } // custom decode by type @@ -869,7 +876,7 @@ extension TypeInfo { // custom encode else if let customEncoder = property.customEncoder { return """ - let _ = try \(customEncoder)(encoder, \(valueExpr)) + try encoder.customEncode(\(valueExpr), using: \(customEncoder)) """ } // custom encode by type @@ -1225,6 +1232,29 @@ extension Array where Element: Hashable { } } +private extension ExprSyntax { + var isEmptyClosure: Bool { + var expr = self + while true { + if let tuple = expr.as(TupleExprSyntax.self), + tuple.elements.count == 1, + let inner = tuple.elements.first?.expression { + expr = inner + continue + } + if let asExpr = expr.as(AsExprSyntax.self) { + expr = asExpr.expression + continue + } + break + } + guard let closure = expr.as(ClosureExprSyntax.self) else { + return false + } + return closure.statements.isEmpty + } +} + extension String { func removingSuffix(_ suffix: String) -> String { guard self.hasSuffix(suffix) else { return self } diff --git a/Tests/ReerCodableTests/CustomCodingTests.swift b/Tests/ReerCodableTests/CustomCodingTests.swift index ba740f0..d890615 100644 --- a/Tests/ReerCodableTests/CustomCodingTests.swift +++ b/Tests/ReerCodableTests/CustomCodingTests.swift @@ -88,4 +88,76 @@ extension TestReerCodable { #expect(dict.int("TEST~~Case") == 3) #expect(dict.string("testGeneric") == "prefix-helloworld") } + + @Test + func customCodingNonThrowingAndSkipEncode() throws { + let json = """ + { + "id": 1, + "artist": { "name": "Jay" }, + "noCopyrightRcmd": { "type": 1 }, + "pc": { "id": 9 } + } + """.data(using: .utf8)! + + let model = try JSONDecoder().decode(CustomCodingWarningModel.self, from: json) + #expect(model.id == 1) + #expect(model.artistName == "Jay") + #expect(model.noCopyright == true) + #expect(model.isCloud == true) + + let encoded = try JSONEncoder().encode(model) + let dict = encoded.stringAnyDictionary + #expect(dict.string("artistName") == "Jay") + #expect(dict?["noCopyright"] == nil) + #expect(dict?["isCloud"] == nil) + #expect(dict?["noCopyrightRcmd"] == nil) + #expect(dict?["pc"] == nil) + + let missingKeys = try JSONDecoder().decode( + CustomCodingWarningModel.self, + from: #"{"id": 2}"#.data(using: .utf8)! + ) + #expect(missingKeys.artistName == "") + #expect(missingKeys.noCopyright == false) + #expect(missingKeys.isCloud == false) + } +} + +@Codable +struct CustomCodingWarningModel { + var id: Int + + @CustomCoding( + decode: { decoder in + if let name: String = try? decoder.value(forKeys: "artist.name"), !name.isEmpty { + return name + } + return "" + }, + encode: { encoder, value in + try encoder.set(value, forKey: "artistName") + } + ) + var artistName: String = "" + + @CustomCoding( + decode: { decoder in + let container = try decoder.container(keyedBy: AnyCodingKey.self) + let key = AnyCodingKey("noCopyrightRcmd") + return container.contains(key) && (try? container.decodeNil(forKey: key)) == false + } + ) + @EncodingIgnored + var noCopyright: Bool = false + + @CustomCoding( + decode: { decoder in + let container = try decoder.container(keyedBy: AnyCodingKey.self) + let key = AnyCodingKey("pc") + return container.contains(key) && (try? container.decodeNil(forKey: key)) == false + }, + encode: { _, _ in } + ) + var isCloud: Bool = false } diff --git a/Tests/ReerCodableTests/MacroExpandTests.swift b/Tests/ReerCodableTests/MacroExpandTests.swift index 335c4fb..33dec40 100644 --- a/Tests/ReerCodableTests/MacroExpandTests.swift +++ b/Tests/ReerCodableTests/MacroExpandTests.swift @@ -994,9 +994,9 @@ final class ReerCodableTests: XCTestCase { strategy: .secondsSince1970 ) self.array = try container.compactDecodeArray(type: [String].self, keys: [AnyCodingKey("array.xxx", true), AnyCodingKey("ARRAY", false)]) - self.custom = try { decoder in + self.custom = try decoder.customDecode(using: { decoder in return 222222 - }(decoder) + }) self.customBy = try IntTransformer.self.decode(by: decoder, keys: ["CUSTOM-BY"]) try self.didDecode(from: decoder) } @@ -1009,9 +1009,9 @@ final class ReerCodableTests: XCTestCase { try container.encode(value: self.height, key: AnyCodingKey("HEIGHT", false), treatDotAsNested: true) try container.encodeDate(value: self.date, key: AnyCodingKey("DATE", false), treatDotAsNested: true, strategy: .secondsSince1970) try container.encode(value: self.array, key: AnyCodingKey("array.xxx", true), treatDotAsNested: true) - let _ = try { encoder, value in + try encoder.customEncode(self.custom, using: { encoder, value in print(333333) - }(encoder, self.custom) + }) try IntTransformer.self.encode(by: encoder, key: "CUSTOM-BY", value: self.customBy) } @@ -1289,4 +1289,104 @@ final class ReerCodableTests: XCTestCase { throw XCTSkip("macros are only supported when running tests for the host platform") #endif } + + func testCustomCodingEmptyEncodeAndFallback() throws { + #if canImport(ReerCodableMacros) + assertMacroExpansion( + """ + @Codable + struct AlbumSummary { + var id: Int + + @CustomCoding( + decode: { decoder in + if let name: String = try? decoder.value(forKeys: "artist.name"), !name.isEmpty { + return name + } + return "" + }, + encode: { encoder, value in + try encoder.set(value, forKey: "artistName") + } + ) + var artistName: String = "" + + @CustomCoding( + decode: { decoder in + let container = try decoder.container(keyedBy: AnyCodingKey.self) + let key = AnyCodingKey("pc") + return container.contains(key) && (try? container.decodeNil(forKey: key)) == false + }, + encode: { _, _ in } + ) + var isCloud: Bool = false + + @CustomCoding( + decode: { decoder in + return true + } + ) + @EncodingIgnored + var noCopyright: Bool = false + } + """, + expandedSource: """ + struct AlbumSummary { + var id: Int + var artistName: String = "" + var isCloud: Bool = false + var noCopyright: Bool = false + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + self.id = try container.decode(Int.self, forKey: AnyCodingKey("id", false)) + self.artistName = (try? decoder.customDecode(using: { decoder in + if let name: String = try? decoder.value(forKeys: "artist.name"), !name.isEmpty { + return name + } + return "" + })) ?? ("") + self.isCloud = (try? decoder.customDecode(using: { decoder in + let container = try decoder.container(keyedBy: AnyCodingKey.self) + let key = AnyCodingKey("pc") + return container.contains(key) && (try? container.decodeNil(forKey: key)) == false + })) ?? (false) + self.noCopyright = (try? decoder.customDecode(using: { decoder in + return true + })) ?? (false) + try self.didDecode(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try self.willEncode(to: encoder) + var container = encoder.container(keyedBy: AnyCodingKey.self) + try container.encode(value: self.id, key: AnyCodingKey("id", false), treatDotAsNested: true) + try encoder.customEncode(self.artistName, using: { encoder, value in + try encoder.set(value, forKey: "artistName") + }) + } + + init( + id: Int, + artistName: String = "", + isCloud: Bool = false, + noCopyright: Bool = false + ) { + self.id = id + self.artistName = artistName + self.isCloud = isCloud + self.noCopyright = noCopyright + } + } + + extension AlbumSummary: Codable, ReerCodableDelegate { + } + """, + macros: testMacros, + indentationWidth: .spaces(4) + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } } From 76327da1dcb74acdf9e4b73b9860809fd7dee30b Mon Sep 17 00:00:00 2001 From: phoenix Date: Sun, 30 Aug 2026 17:38:18 +0800 Subject: [PATCH 2/2] fix: reject conflicting CustomCoding ignore annotations Co-authored-by: Cursor --- .../MacroDeclarations/CustomCoding.swift | 3 +- .../AttributeListSyntax+Extensions.swift | 23 ++++ .../CustomCodingImpl.swift | 12 ++ Sources/ReerCodableMacros/TypeInfo.swift | 23 ---- Tests/ReerCodableTests/MacroExpandTests.swift | 103 ++++++++++++++++++ 5 files changed, 140 insertions(+), 24 deletions(-) diff --git a/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift b/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift index 78a515a..2bc73c5 100644 --- a/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift +++ b/Sources/ReerCodable/MacroDeclarations/CustomCoding.swift @@ -36,7 +36,8 @@ /// ``` /// /// Closures do not have to throw. An empty `encode` closure skips encoding that property. -/// Combine with `@EncodingIgnored` or `@DecodingIgnored` for decode-only / encode-only custom logic. +/// Combine `decode` with `@EncodingIgnored`, or `encode` with `@DecodingIgnored`, +/// for decode-only / encode-only custom logic. @attached(peer) public macro CustomCoding( decode: ((_ decoder: any Decoder) throws -> Value)? = nil, diff --git a/Sources/ReerCodableMacros/AttributeListSyntax+Extensions.swift b/Sources/ReerCodableMacros/AttributeListSyntax+Extensions.swift index c787ae3..423419d 100644 --- a/Sources/ReerCodableMacros/AttributeListSyntax+Extensions.swift +++ b/Sources/ReerCodableMacros/AttributeListSyntax+Extensions.swift @@ -46,3 +46,26 @@ extension AttributeListSyntax { }.count } } + +extension ExprSyntax { + var isEmptyClosure: Bool { + var expr = self + while true { + if let tuple = expr.as(TupleExprSyntax.self), + tuple.elements.count == 1, + let inner = tuple.elements.first?.expression { + expr = inner + continue + } + if let asExpr = expr.as(AsExprSyntax.self) { + expr = asExpr.expression + continue + } + break + } + guard let closure = expr.as(ClosureExprSyntax.self) else { + return false + } + return closure.statements.isEmpty + } +} diff --git a/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift b/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift index a0aea4f..f42cdcd 100644 --- a/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift +++ b/Sources/ReerCodableMacros/MacroImplementations/CustomCodingImpl.swift @@ -48,6 +48,18 @@ public struct CustomCoding: PeerMacro { throw MacroError(text: "@CustomCoding macro cannot be used together with @\(conflictingMacros.joined(separator: ", @")).") } } + if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { + let decodeArg = arguments.first { $0.label?.identifier?.name == "decode" } + let encodeArg = arguments.first { $0.label?.identifier?.name == "encode" } + if decodeArg != nil, variable.attributes.containsAttribute(named: "DecodingIgnored") { + throw MacroError(text: "@CustomCoding decode cannot be used together with @DecodingIgnored.") + } + if let encodeArg, + !encodeArg.expression.isEmptyClosure, + variable.attributes.containsAttribute(named: "EncodingIgnored") { + throw MacroError(text: "@CustomCoding encode cannot be used together with @EncodingIgnored.") + } + } return [] } } diff --git a/Sources/ReerCodableMacros/TypeInfo.swift b/Sources/ReerCodableMacros/TypeInfo.swift index 447e8ca..918716f 100644 --- a/Sources/ReerCodableMacros/TypeInfo.swift +++ b/Sources/ReerCodableMacros/TypeInfo.swift @@ -1232,29 +1232,6 @@ extension Array where Element: Hashable { } } -private extension ExprSyntax { - var isEmptyClosure: Bool { - var expr = self - while true { - if let tuple = expr.as(TupleExprSyntax.self), - tuple.elements.count == 1, - let inner = tuple.elements.first?.expression { - expr = inner - continue - } - if let asExpr = expr.as(AsExprSyntax.self) { - expr = asExpr.expression - continue - } - break - } - guard let closure = expr.as(ClosureExprSyntax.self) else { - return false - } - return closure.statements.isEmpty - } -} - extension String { func removingSuffix(_ suffix: String) -> String { guard self.hasSuffix(suffix) else { return self } diff --git a/Tests/ReerCodableTests/MacroExpandTests.swift b/Tests/ReerCodableTests/MacroExpandTests.swift index 33dec40..4536f7b 100644 --- a/Tests/ReerCodableTests/MacroExpandTests.swift +++ b/Tests/ReerCodableTests/MacroExpandTests.swift @@ -1389,4 +1389,107 @@ final class ReerCodableTests: XCTestCase { throw XCTSkip("macros are only supported when running tests for the host platform") #endif } + + func testCustomCodingSameSideIgnoredDiagnostics() throws { + #if canImport(ReerCodableMacros) + assertMacroExpansion( + """ + @Codable + struct User { + @CustomCoding( + decode: { _ in true } + ) + @DecodingIgnored + var enabled: Bool = false + } + """, + expandedSource: """ + struct User { + var enabled: Bool = false + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + self.enabled = false + try self.didDecode(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try self.willEncode(to: encoder) + var container = encoder.container(keyedBy: AnyCodingKey.self) + try container.encode(value: self.enabled, key: AnyCodingKey("enabled", false), treatDotAsNested: true) + } + + init( + enabled: Bool = false + ) { + self.enabled = enabled + } + } + + extension User: Codable, ReerCodableDelegate { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@CustomCoding decode cannot be used together with @DecodingIgnored.", + line: 3, + column: 5 + ) + ], + macros: testMacros, + indentationWidth: .spaces(4) + ) + assertMacroExpansion( + """ + @Codable + struct User { + @CustomCoding( + encode: { _, _ in + print("encode") + } + ) + @EncodingIgnored + var enabled: Bool = false + } + """, + expandedSource: """ + struct User { + var enabled: Bool = false + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: AnyCodingKey.self) + self.enabled = (try? container.decode(Bool.self, forKey: AnyCodingKey("enabled", false))) ?? (false) + try self.didDecode(from: decoder) + } + + func encode(to encoder: any Encoder) throws { + try self.willEncode(to: encoder) + var container = encoder.container(keyedBy: AnyCodingKey.self) + + } + + init( + enabled: Bool = false + ) { + self.enabled = enabled + } + } + + extension User: Codable, ReerCodableDelegate { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@CustomCoding encode cannot be used together with @EncodingIgnored.", + line: 3, + column: 5 + ) + ], + macros: testMacros, + indentationWidth: .spaces(4) + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } }