Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Sources/ReerCodable/Decoder+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>(using decode: (any Decoder) throws -> Value) throws -> Value {
try decode(self)
}
}
9 changes: 9 additions & 0 deletions Sources/ReerCodable/Encoder+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: Value, using encode: (any Encoder, Value) throws -> Void) throws {
try encode(self, value)
}
}
4 changes: 4 additions & 0 deletions Sources/ReerCodable/MacroDeclarations/CustomCoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
/// )
/// var customValue: Int
/// ```
///
/// Closures do not have to throw. An empty `encode` closure skips encoding that property.
/// Combine `decode` with `@EncodingIgnored`, or `encode` with `@DecodingIgnored`,
/// for decode-only / encode-only custom logic.
@attached(peer)
public macro CustomCoding<Value>(
decode: ((_ decoder: any Decoder) throws -> Value)? = nil,
Expand Down
23 changes: 23 additions & 0 deletions Sources/ReerCodableMacros/AttributeListSyntax+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ public struct CustomCoding: PeerMacro {
if variable.attributes.count > 1 {
let incompatibleMacros = [
"CodingIgnored",
"EncodingIgnored",
"DecodingIgnored",
"Base64Coding",
"DateCoding",
"CompactDecoding",
Expand All @@ -50,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 []
}
}
29 changes: 18 additions & 11 deletions Sources/ReerCodableMacros/TypeInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions Tests/ReerCodableTests/CustomCodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(
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<Bool>(
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<Bool>(
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
}
Loading
Loading