Skip to content

Commit c78459d

Browse files
committed
add case style for whole Codable object
1 parent 3340436 commit c78459d

16 files changed

Lines changed: 400 additions & 112 deletions

Package.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,24 @@ let package = Package(
3636
),
3737
],
3838
targets: [
39+
.target(
40+
name: "MacroCodableKitShared"
41+
),
42+
3943
// Macro declaration
4044
.target(
4145
name: "MacroCodableKit",
42-
dependencies: ["Macro"]
46+
dependencies: [
47+
"MacroCodableKitShared",
48+
"Macro",
49+
]
4350
),
4451

4552
// Macros
4653
.macro(
4754
name: "Macro",
4855
dependencies: [
56+
"MacroCodableKitShared",
4957
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
5058
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
5159
.product(name: "MacroToolkit", package: "swift-macro-toolkit"),

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ struct User {
6969
}
7070
```
7171

72+
You can also derive default coding keys from property names:
73+
74+
```swift
75+
@Codable(caseStyle: .snakeCase)
76+
struct ExamplePayload {
77+
let primaryValue: String
78+
let secondaryLabel: String?
79+
80+
@CodingKey("ManualName")
81+
let customField: String?
82+
}
83+
```
84+
85+
This generates coding keys like `"primary_value"` and `"secondary_label"`, while `customField` still uses `"ManualName"`.
86+
7287
Let's convert `birthday` to `Date`, change coding key of `isVerified` and make it default to `false`
7388

7489
> **Note**
@@ -284,6 +299,8 @@ struct User {
284299
}
285300
```
286301

302+
When `@Codable(caseStyle: ...)`, `@Decodable(caseStyle: ...)`, or `@Encodable(caseStyle: ...)` is used, `@CodingKey(...)` still takes precedence for that property.
303+
287304
#### @OmitCoding
288305

289306
Skip coding for a specific property with `@OmitCoding()` annotation

Sources/Macro/Macro/Codable/CodableMacroBase.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import SwiftSyntaxMacros
1313

1414
struct CodableMacroBase {
1515
static func expansion(
16-
of _: AttributeSyntax,
16+
of node: AttributeSyntax,
1717
attachedTo declaration: some DeclGroupSyntax,
1818
providingExtensionsOf type: some TypeSyntaxProtocol,
1919
conformancesToGenerate: Set<Conformance>,
@@ -36,10 +36,16 @@ struct CodableMacroBase {
3636
)
3737

3838
let expander = InstanceExpander(codableFactory: DefaultCodableBuilderFactoryImpl())
39+
let config = CodableMacroConfig(node: node)
3940

4041
let buildingData: CodableBuildingData
4142
do {
42-
buildingData = try expander.verify(declaration: declaration, strategy: .codingKeys, conformances: conformancesToGenerate)
43+
buildingData = try expander.verify(
44+
declaration: declaration,
45+
strategy: .codingKeys,
46+
conformances: conformancesToGenerate,
47+
config: config
48+
)
4349
} catch {
4450
return []
4551
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// CodableMacroConfig.swift
3+
//
4+
//
5+
// Created by OpenAI Codex on 20.04.26.
6+
//
7+
8+
import Foundation
9+
import MacroCodableKitShared
10+
import SwiftSyntax
11+
12+
struct CodableMacroConfig {
13+
struct CaseStyleTransformer {
14+
let memberName: String
15+
16+
func transform(_ propertyName: String) -> String {
17+
guard let style = CaseStyle(rawValue: memberName) else { return propertyName }
18+
return CaseConverter.format(propertyName, to: style)
19+
}
20+
}
21+
22+
let caseStyle: CaseStyleTransformer
23+
24+
init(caseStyle: CaseStyleTransformer = CaseStyleTransformer(memberName: "verbatim")) {
25+
self.caseStyle = caseStyle
26+
}
27+
28+
init(node: AttributeSyntax) {
29+
guard case let .argumentList(args) = node.arguments else {
30+
self.init()
31+
return
32+
}
33+
34+
if let styleArg = args.first(where: { $0.label?.text == "caseStyle" }),
35+
let member = styleArg.expression.as(MemberAccessExprSyntax.self) {
36+
self.init(caseStyle: CaseStyleTransformer(memberName: member.declName.baseName.text))
37+
} else {
38+
self.init()
39+
}
40+
}
41+
}

Sources/Macro/Macro/TaggedEnum/TaggedEnumMacroBase.swift

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import MacroToolkit
22
import Foundation
3+
import MacroCodableKitShared
34
import SwiftDiagnostics
45
import SwiftSyntax
56
import SwiftSyntaxMacros
@@ -20,63 +21,11 @@ enum TaggedEnumMacroBase {
2021
let memberName: String
2122

2223
func transform(_ caseName: String) -> String {
23-
switch memberName {
24-
case "verbatim": return caseName
25-
case "camelCase": return CaseConverter.format(caseName, to: .camelCase)
26-
case "pascalCase": return CaseConverter.format(caseName, to: .pascalCase)
27-
case "snakeCase": return CaseConverter.format(caseName, to: .snakeCase)
28-
case "screamingSnakeCase": return CaseConverter.format(caseName, to: .screamingSnakeCase)
29-
case "kebabCase": return CaseConverter.format(caseName, to: .kebabCase)
30-
default: return caseName
31-
}
32-
}
33-
}
34-
35-
struct CaseConverter {
36-
static func tokenize(_ input: String) -> [String] {
37-
let splitCamel = input.replacingOccurrences(
38-
of: "([a-z])([A-Z])",
39-
with: "$1 $2",
40-
options: .regularExpression
41-
)
42-
43-
let separators = CharacterSet.alphanumerics.inverted
44-
let components = splitCamel.components(separatedBy: separators)
45-
46-
return components
47-
.filter { !$0.isEmpty }
48-
.map { $0.lowercased() }
49-
}
50-
51-
static func format(_ string: String, to style: Style) -> String {
52-
let tokens = tokenize(string)
53-
guard !tokens.isEmpty else { return string }
54-
55-
switch style {
56-
case .camelCase:
57-
let first = tokens[0]
58-
let rest = tokens.dropFirst().map(\.capitalized)
59-
return first + rest.joined()
60-
case .pascalCase:
61-
return tokens.map(\.capitalized).joined()
62-
case .snakeCase:
63-
return tokens.joined(separator: "_")
64-
case .screamingSnakeCase:
65-
return tokens.joined(separator: "_").uppercased()
66-
case .kebabCase:
67-
return tokens.joined(separator: "-")
68-
}
24+
guard let style = CaseStyle(rawValue: memberName) else { return caseName }
25+
return CaseConverter.format(caseName, to: style)
6926
}
7027
}
7128

72-
enum Style {
73-
case camelCase
74-
case pascalCase
75-
case snakeCase
76-
case screamingSnakeCase
77-
case kebabCase
78-
}
79-
8029
// MARK: - Entry Point
8130

8231
static func expansion(

Sources/Macro/Misc/CodableBuilders/CodingKeysBuilder+Instance.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import MacroToolkit
1010
extension CodingKeysBuilder {
1111
static func verify(
1212
accessModifier: AccessModifier?,
13-
instance: Instance
13+
instance: Instance,
14+
caseStyle: CodableMacroConfig.CaseStyleTransformer
1415
) throws -> CodingKeysBuilder.BuildingData {
1516
CodingKeysBuilder.BuildingData(
1617
accessModifier: accessModifier,
@@ -45,9 +46,26 @@ extension CodingKeysBuilder {
4546

4647
return CodingKeysBuilder.BuildingData.Item(
4748
identifier: identifier,
48-
customCodingKey: (knownAttributes[.codingKey]?.first as? CodingKey)?.key
49+
customCodingKey: makeCodingKey(
50+
identifier: identifier,
51+
knownAttributes: knownAttributes,
52+
caseStyle: caseStyle
53+
)
4954
)
5055
}
5156
)
5257
}
58+
59+
private static func makeCodingKey(
60+
identifier: String,
61+
knownAttributes: Variable.AllKnownAttributes,
62+
caseStyle: CodableMacroConfig.CaseStyleTransformer
63+
) -> String? {
64+
if let explicitKey = (knownAttributes[.codingKey]?.first as? CodingKey)?.key {
65+
return explicitKey
66+
}
67+
68+
let transformedKey = caseStyle.transform(identifier)
69+
return transformedKey == identifier ? nil : transformedKey
70+
}
5371
}

Sources/Macro/Misc/InstanceExpander.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ final class InstanceExpander {
1717
func verify(
1818
declaration: some DeclGroupSyntax,
1919
strategy: CodableStrategy,
20-
conformances: Set<Conformance>
20+
conformances: Set<Conformance>,
21+
config: CodableMacroConfig = .init()
2122
) throws -> CodableBuildingData {
2223
guard let instance = InstanceImpl(declaration: declaration), instance.isStruct else {
2324
MacroConfiguration.current.context.diagnose(
@@ -34,7 +35,8 @@ final class InstanceExpander {
3435
if strategy == .codingKeys {
3536
codingKeysBuildingData = try CodingKeysBuilder.verify(
3637
accessModifier: accessModifier,
37-
instance: instance
38+
instance: instance,
39+
caseStyle: config.caseStyle
3840
)
3941
} else {
4042
codingKeysBuildingData = nil
Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,7 @@
1-
import Foundation
1+
@_exported import MacroCodableKitShared
22

3-
public enum CaseStyle {
4-
case verbatim
5-
case camelCase
6-
case pascalCase
7-
case snakeCase
8-
case screamingSnakeCase
9-
case kebabCase
10-
}
11-
12-
public struct CaseConverter {
13-
public static func tokenize(_ input: String) -> [String] {
14-
let splitCamel = input.replacingOccurrences(
15-
of: "([a-z])([A-Z])",
16-
with: "$1 $2",
17-
options: .regularExpression
18-
)
19-
20-
let separators = CharacterSet.alphanumerics.inverted
21-
let components = splitCamel.components(separatedBy: separators)
22-
23-
return components
24-
.filter { !$0.isEmpty }
25-
.map { $0.lowercased() }
26-
}
27-
28-
public static func format(_ string: String, to style: CaseStyle) -> String {
29-
let tokens = tokenize(string)
30-
guard !tokens.isEmpty else { return string }
31-
32-
switch style {
33-
case .verbatim:
34-
return string
35-
case .camelCase:
36-
let first = tokens[0]
37-
let rest = tokens.dropFirst().map(\.capitalized)
38-
return first + rest.joined()
39-
case .pascalCase:
40-
return tokens.map(\.capitalized).joined()
41-
case .snakeCase:
42-
return tokens.joined(separator: "_")
43-
case .screamingSnakeCase:
44-
return tokens.joined(separator: "_").uppercased()
45-
case .kebabCase:
46-
return tokens.joined(separator: "-")
47-
}
48-
}
49-
}
3+
public typealias CaseStyle = MacroCodableKitShared.CaseStyle
4+
public typealias CaseConverter = MacroCodableKitShared.CaseConverter
505

516
@available(*, deprecated, renamed: "CaseStyle")
527
public typealias TaggedCodableCaseStyle = CaseStyle

Sources/MacroCodableKit/Macros/Codable.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
/// }
1818
/// ```
1919
///
20+
/// Use `caseStyle:` to derive default coding keys from stored property names:
21+
/// ```swift
22+
/// @Codable(caseStyle: .snakeCase)
23+
/// struct ExamplePayload {
24+
/// let primaryValue: String
25+
/// let secondaryLabel: String?
26+
/// }
27+
/// ```
28+
///
2029
/// If you need only `Decodable` or `Encodable` use ``Decodable()`` or ``Encodable()`` respectively
2130
///
2231
/// **Adjust coding behaviour**:
@@ -92,7 +101,7 @@
92101
/// - Handle decoding errors with ``CustomCodingDecoding/errorHandler`` in ``CustomCodingDecoding``
93102
/// - Handle encoding errors with ``CustomCodingEncoding/errorHandler`` in ``CustomCodingEncoding``
94103
@attached(extension, conformances: Decodable, Encodable, names: named(CodingKeys), named(init(from:)), named(encode(to:)))
95-
public macro Codable() = #externalMacro(module: "Macro", type: "CodableMacro")
104+
public macro Codable(caseStyle: CaseStyle = .verbatim) = #externalMacro(module: "Macro", type: "CodableMacro")
96105

97106
/// Generates `Decodable` conformance respecting property annotations.
98107
///
@@ -105,8 +114,10 @@ public macro Codable() = #externalMacro(module: "Macro", type: "CodableMacro")
105114
/// let property: String
106115
/// }
107116
/// ```
117+
///
118+
/// `caseStyle:` applies to generated default keys here as well, while `@CodingKey(...)` still wins for explicitly annotated properties.
108119
@attached(extension, conformances: Decodable, names: named(CodingKeys), named(init(from:)))
109-
public macro Decodable() = #externalMacro(module: "Macro", type: "DecodableMacro")
120+
public macro Decodable(caseStyle: CaseStyle = .verbatim) = #externalMacro(module: "Macro", type: "DecodableMacro")
110121

111122
/// Generates `Encodable` conformance respecting property annotations.
112123
///
@@ -119,5 +130,7 @@ public macro Decodable() = #externalMacro(module: "Macro", type: "DecodableMacro
119130
/// let property: String
120131
/// }
121132
/// ```
133+
///
134+
/// `caseStyle:` applies to generated default keys here as well, while `@CodingKey(...)` still wins for explicitly annotated properties.
122135
@attached(extension, conformances: Encodable, names: named(CodingKeys), named(encode(to:)))
123-
public macro Encodable() = #externalMacro(module: "Macro", type: "EncodableMacro")
136+
public macro Encodable(caseStyle: CaseStyle = .verbatim) = #externalMacro(module: "Macro", type: "EncodableMacro")

0 commit comments

Comments
 (0)