From fe403ce60422a5fecb7081c0de9d7d55d0bbca69 Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 26 May 2026 18:19:21 +0800 Subject: [PATCH] fix(core): promote private Codable witnesses to fileprivate A `private` type produced `private init(from:)` / `private func encode(to:)`, which the compiler rejects because a witness matching a protocol requirement must be as accessible as its enclosing type. A top-level `private` type is effectively `fileprivate` for conformance purposes, so the `private` witness fails to satisfy `Codable`. Add a `witnessSafe` helper that promotes `private` to `fileprivate` (preserving trivia) and leaves all other access levels unchanged, mirroring the compiler's own synthesized Codable conformance. Apply it in both the extension and member generation paths, covering @Codable, @Decodable, and @Encodable. Adds regression tests for private struct, fileprivate struct, and private class. --- Sources/CodableKitMacros/CodableMacro.swift | 6 +- .../CodableKitMacros/SwiftSyntaxHelper.swift | 14 ++++ .../CodableMacroTests+class.swift | 32 +++++++++ .../CodableMacroTests+struct.swift | 69 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/Sources/CodableKitMacros/CodableMacro.swift b/Sources/CodableKitMacros/CodableMacro.swift index ec6e49d..503ed4b 100644 --- a/Sources/CodableKitMacros/CodableMacro.swift +++ b/Sources/CodableKitMacros/CodableMacro.swift @@ -128,7 +128,7 @@ public struct CodableMacro: ExtensionMacro { DeclSyntax( genInitDecoderDecl( from: properties, - modifiers: [accessModifier], + modifiers: [accessModifier.witnessSafe], codableOptions: codableOptions, hasSuper: false, tree: usingTree, @@ -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 diff --git a/Sources/CodableKitMacros/SwiftSyntaxHelper.swift b/Sources/CodableKitMacros/SwiftSyntaxHelper.swift index a55a19b..a4eac40 100644 --- a/Sources/CodableKitMacros/SwiftSyntaxHelper.swift +++ b/Sources/CodableKitMacros/SwiftSyntaxHelper.swift @@ -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 } + return with(\.name, .keyword(.fileprivate, leadingTrivia: name.leadingTrivia, trailingTrivia: name.trailingTrivia)) + } +} + extension LabeledExprListSyntax { func getExpr(label: String?) -> LabeledExprSyntax? { first(where: { $0.label?.text == label }) diff --git a/Tests/CodableKitTests/CodableMacroTests+class.swift b/Tests/CodableKitTests/CodableMacroTests+class.swift index 7c1bc1d..7a96c87 100644 --- a/Tests/CodableKitTests/CodableMacroTests+class.swift +++ b/Tests/CodableKitTests/CodableMacroTests+class.swift @@ -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( diff --git a/Tests/CodableKitTests/CodableMacroTests+struct.swift b/Tests/CodableKitTests/CodableMacroTests+struct.swift index 2f6609f..b7dc1a9 100644 --- a/Tests/CodableKitTests/CodableMacroTests+struct.swift +++ b/Tests/CodableKitTests/CodableMacroTests+struct.swift @@ -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( """