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
6 changes: 3 additions & 3 deletions Sources/CodableKitMacros/CodableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public struct CodableMacro: ExtensionMacro {
DeclSyntax(
genInitDecoderDecl(
from: properties,
modifiers: [accessModifier],
modifiers: [accessModifier.witnessSafe],
codableOptions: codableOptions,
hasSuper: false,
tree: usingTree,
Expand Down Expand Up @@ -196,8 +196,8 @@ extension CodableMacro: MemberMacro {
encodeTree = sharedTree
}

var decodeModifiers = [accessModifier]
var encodeModifiers = [accessModifier]
var decodeModifiers = [accessModifier.witnessSafe]
var encodeModifiers = [accessModifier.witnessSafe]

// If the structure is a class and has a superclass, this should be set to true.
// This flag is used to determine if the encode and decode methods
Expand Down
14 changes: 14 additions & 0 deletions Sources/CodableKitMacros/SwiftSyntaxHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ extension TokenSyntax {
}
}

extension DeclModifierSyntax {
/// The access modifier to apply to synthesized protocol witnesses (`init(from:)` / `encode(to:)`).
///
/// A `private` member matching a protocol requirement must be "as accessible as its enclosing
/// type". For a `private` type the enclosing type is effectively `fileprivate`, so a `private`
/// witness fails to satisfy the `Codable` requirement. Promoting `private` to `fileprivate`
/// mirrors what the compiler does for its own synthesized `Codable` conformance. All other
/// access levels are already as accessible as the type and are left unchanged.
internal var witnessSafe: DeclModifierSyntax {
guard name.text == TokenSyntax.keyword(.private).text else { return self }
Comment on lines +50 to +58
return with(\.name, .keyword(.fileprivate, leadingTrivia: name.leadingTrivia, trailingTrivia: name.trailingTrivia))
}
}

extension LabeledExprListSyntax {
func getExpr(label: String?) -> LabeledExprSyntax? {
first(where: { $0.label?.text == label })
Expand Down
32 changes: 32 additions & 0 deletions Tests/CodableKitTests/CodableMacroTests+class.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,38 @@ import Testing
)
}

@Test func privateClassPromotesWitnessesToFileprivate() throws {
assertMacro(
"""
@Codable
private class Account {
let token: String
}
""",
expandedSource: """
private class Account {
let token: String

fileprivate required init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
token = try container.decode(String.self, forKey: .token)
}

fileprivate func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(token, forKey: .token)
}
}

extension Account: Codable {
enum CodingKeys: String, CodingKey {
case token
}
}
"""
)
}

@Test func macroWithDefaultValue() throws {

assertMacro(
Expand Down
69 changes: 69 additions & 0 deletions Tests/CodableKitTests/CodableMacroTests+struct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,75 @@ import Testing
)
}

@Test func privateStructPromotesWitnessesToFileprivate() throws {
assertMacro(
"""
@Codable
private struct UserInfo {
let name: String
let age: Int
}
""",
expandedSource: """
private struct UserInfo {
let name: String
let age: Int

fileprivate func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(age, forKey: .age)
}
}

extension UserInfo: Codable {
enum CodingKeys: String, CodingKey {
case name
case age
}

fileprivate init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
age = try container.decode(Int.self, forKey: .age)
}
}
"""
)
}

@Test func fileprivateStructKeepsFileprivateWitnesses() throws {
assertMacro(
"""
@Codable
fileprivate struct UserInfo {
let name: String
}
""",
expandedSource: """
fileprivate struct UserInfo {
let name: String

fileprivate func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
}
}

extension UserInfo: Codable {
enum CodingKeys: String, CodingKey {
case name
}

fileprivate init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
}
}
"""
)
}

@Test func optionSkipProtocolConformanceDoesNotAttachConformance() throws {
assertMacro(
"""
Expand Down
Loading