Skip to content

Commit e4f297a

Browse files
authored
feat: support case style macros (@PascalCase, @snakecase, etc.) on enums (#38)
Allow naming convention macros to be applied at the enum level and individual enum case level, automatically converting case names for encoding/decoding. Decoding accepts a union of all applicable values (many-to-one), encoding uses the highest priority value. Priority order: @CodingCase > explicit rawValue > case-level style > enum-level style > caseName fallback. Constraints: @CodingCase with 'at:' or 'values:' parameter cannot coexist with case style macros. Numeric raw type enums are also disallowed. Closes #36 Made-with: Cursor
1 parent 8c45e2f commit e4f297a

6 files changed

Lines changed: 422 additions & 6 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,44 @@ enum Phone: Codable {
656656
}
657657
```
658658

659+
- Support using naming style macros like `@SnakeCase`, `@PascalCase` on enums to automatically convert case names for encoding/decoding. Decoding accepts a union of all applicable values (many-to-one), encoding uses the highest priority value
660+
661+
```swift
662+
@Codable
663+
@PascalCase
664+
enum Status {
665+
case inProgress // encodes/decodes "InProgress"
666+
case notStarted // encodes/decodes "NotStarted"
667+
}
668+
```
669+
670+
Per-case styles can override the enum-level style:
671+
672+
```swift
673+
@Codable
674+
@PascalCase
675+
enum Event {
676+
@CodingCase(match: .string("pgview"))
677+
@SnakeCase
678+
case pageView // decodes: ["pgview", "page_view", "PageView"], encodes: "pgview"
679+
680+
case buttonClick // decodes/encodes: "ButtonClick"
681+
}
682+
```
683+
684+
Associated value keys also follow the naming style:
685+
686+
```swift
687+
@Codable
688+
@SnakeCase
689+
enum Action {
690+
case doSomething(userId: Int, userName: String)
691+
// JSON: {"do_something": {"user_id": 42, "user_name": "John"}}
692+
}
693+
```
694+
695+
> **Note**: `@CodingCase` with `at:` or `values:` parameter cannot be used together with naming style macros.
696+
659697
### 15. Lifecycle Callbacks
660698

661699
Support encoding/decoding lifecycle callbacks:

README_CN.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,44 @@ enum Phone: Codable {
651651
}
652652
```
653653

654+
- 支持在枚举上使用 `@SnakeCase``@PascalCase` 等命名风格宏, 自动转换 case 名称用于编解码. 解码时取并集(多对一), 编码时使用最高优先级的值
655+
656+
```swift
657+
@Codable
658+
@PascalCase
659+
enum Status {
660+
case inProgress // 编码/解码 "InProgress"
661+
case notStarted // 编码/解码 "NotStarted"
662+
}
663+
```
664+
665+
可以在单个 case 上覆盖 enum 级别的风格:
666+
667+
```swift
668+
@Codable
669+
@PascalCase
670+
enum Event {
671+
@CodingCase(match: .string("pgview"))
672+
@SnakeCase
673+
case pageView // 解码: ["pgview", "page_view", "PageView"], 编码: "pgview"
674+
675+
case buttonClick // 解码/编码: "ButtonClick"
676+
}
677+
```
678+
679+
关联值枚举的 key 也会自动遵循命名风格:
680+
681+
```swift
682+
@Codable
683+
@SnakeCase
684+
enum Action {
685+
case doSomething(userId: Int, userName: String)
686+
// JSON: {"do_something": {"user_id": 42, "user_name": "John"}}
687+
}
688+
```
689+
690+
> **注意**: `@CodingCase` 含有 `at:``values:` 参数时, 不能与命名风格宏混用.
691+
654692
### 15. 生命周期回调
655693

656694
支持编解码的生命周期回调:

Sources/ReerCodable/MacroDeclarations/KeyCodingStrategy.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
/// Key Coding Strategy Macros
2323
///
2424
/// These macros provide various naming convention transformations for coding keys.
25-
/// They can be applied at both the type level (struct/class) and property level,
26-
/// and can be combined with `@CodingKey` for more flexible key customization.
25+
/// They can be applied at the type level (struct/class/enum), property level, or enum case level,
26+
/// and can be combined with `@CodingKey` / `@CodingCase` for more flexible key customization.
2727
/// When used together with `@CodingKey`, the `@CodingKey` takes precedence.
28+
/// For enums, `@CodingCase(match:)` values take the highest encoding priority, with style-converted
29+
/// values added as additional decoding alternatives (union/many-to-one).
2830
///
2931
/// 1. Type-level usage (affects all properties):
3032
/// ```swift

Sources/ReerCodableMacros/MacroImplementations/CaseStyleImpl.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ extension CaseStyleAttribute {
9292
declaration.is(StructDeclSyntax.self)
9393
|| declaration.is(ClassDeclSyntax.self)
9494
|| declaration.is(VariableDeclSyntax.self)
95+
|| declaration.is(EnumDeclSyntax.self)
96+
|| declaration.is(EnumCaseDeclSyntax.self)
9597
else {
96-
throw MacroError(text: "@\(style.macroName) macro is only for `struct`, `class` or a property.")
98+
throw MacroError(text: "@\(style.macroName) macro is only for `struct`, `class`, `enum`, a property or an enum case.")
9799
}
98100
if let structDecl = declaration.as(StructDeclSyntax.self),
99101
!structDecl.attributes.containsAttribute(named: "Codable")
@@ -109,6 +111,12 @@ extension CaseStyleAttribute {
109111
&& !classDecl.attributes.containsAttribute(named: "InheritedDecodable") {
110112
throw MacroError(text: "@\(style.macroName) macro can only be used with @Decodable, @Encodable, @Codable, @InheritedCodable or @InheritedDecodable types.")
111113
}
114+
if let enumDecl = declaration.as(EnumDeclSyntax.self),
115+
!enumDecl.attributes.containsAttribute(named: "Codable")
116+
&& !enumDecl.attributes.containsAttribute(named: "Decodable")
117+
&& !enumDecl.attributes.containsAttribute(named: "Encodable") {
118+
throw MacroError(text: "@\(style.macroName) macro can only be used with @Decodable, @Encodable or @Codable types.")
119+
}
112120
return []
113121
}
114122
}

Sources/ReerCodableMacros/TypeInfo.swift

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ struct PathValueMatch {
5353
struct EnumCase {
5454
var caseName: String
5555
var rawValue: String
56+
var hasExplicitRawValue: Bool = false
5657
// [Type: Value]
5758
var matches: [String: [String]] = [:]
5859
var matchOrder: [String] = []
5960
var keyPathMatches: [PathValueMatch] = []
6061
var associatedMatch: [AssociatedMatch] = []
6162
var associated: [AssociatedValue] = []
63+
var caseStyles: [CaseStyle] = []
6264

6365
var initText: String {
6466
let associated = "\(associated.compactMap { "\($0.label == nil ? $0.variableName : "\($0.variableName): \($0.variableName)")" }.joined(separator: ","))"
@@ -110,12 +112,15 @@ struct TypeInfo {
110112
var index = 0
111113
var lastIntRawValue: Int = 0
112114
try enumDecl.memberBlock.members.forEach {
115+
let casesStartCount = enumCases.count
113116
try $0.decl.as(EnumCaseDeclSyntax.self)?.elements.forEach { caseElement in
114117
let name = caseElement.name.trimmedDescription
115118
var raw: String
119+
var hasExplicitRaw = false
116120
if let rawValueExpr = caseElement.rawValue?.value {
117121
let rawValue = rawValueExpr.trimmedDescription
118122
raw = rawValue
123+
hasExplicitRaw = true
119124
if let intRaw = rawValueExpr.as(IntegerLiteralExprSyntax.self) {
120125
lastIntRawValue = Int(intRaw.trimmedDescription) ?? 0
121126
}
@@ -144,7 +149,7 @@ struct TypeInfo {
144149
associated.append(.init(label: label, type: type, index: paramIndex, defaultValue: defaultValue))
145150
paramIndex += 1
146151
}
147-
enumCases.append(.init(caseName: name, rawValue: raw, associated: associated))
152+
enumCases.append(.init(caseName: name, rawValue: raw, hasExplicitRawValue: hasExplicitRaw, associated: associated))
148153
index += 1
149154
}
150155

@@ -218,6 +223,25 @@ struct TypeInfo {
218223
}
219224
}
220225
}
226+
227+
if let caseDecl = $0.decl.as(EnumCaseDeclSyntax.self) {
228+
let caseLevelStyles: [CaseStyle] = caseDecl.attributes.compactMap { attr in
229+
guard let attrId = attr.as(AttributeSyntax.self)?
230+
.attributeName.as(IdentifierTypeSyntax.self)?
231+
.trimmedDescription else { return nil }
232+
for style in CaseStyle.allCases {
233+
if style.macroName == attrId {
234+
return style
235+
}
236+
}
237+
return nil
238+
}
239+
if !caseLevelStyles.isEmpty {
240+
for i in casesStartCount..<enumCases.count {
241+
enumCases[i].caseStyles = caseLevelStyles
242+
}
243+
}
244+
}
221245
}
222246
}
223247
try validateEnumCases(enumCases)
@@ -233,6 +257,10 @@ struct TypeInfo {
233257
}
234258
return nil
235259
}
260+
if isEnum {
261+
try validateEnumCaseStyles()
262+
enrichEnumCasesWithStyles()
263+
}
236264
if let attribute = decl.attributes.firstAttribute(named: "CodingContainer"),
237265
let arguments = attribute.as(AttributeSyntax.self)?.arguments?.as(LabeledExprListSyntax.self) {
238266
codingContainer = arguments
@@ -556,6 +584,87 @@ extension TypeInfo {
556584
throw MacroError(text: "Only CaseMatcher with key path and .string() patterns are allowed for enum cases with associated values")
557585
}
558586
}
587+
588+
/// Validates that case style macros are used correctly on enum cases.
589+
///
590+
/// Rules enforced:
591+
/// 1. Numeric raw type enums (Int, Double, etc.) cannot use case style macros.
592+
/// 2. Case style macros cannot coexist with `@CodingCase` using the `at:` parameter in the same enum.
593+
/// 3. `@CodingCase` with `values:` parameter on a specific case cannot coexist with any effective case style.
594+
func validateEnumCaseStyles() throws {
595+
let anyStyles = !caseStyles.isEmpty || enumCases.contains { !$0.caseStyles.isEmpty }
596+
guard anyStyles else { return }
597+
598+
if let enumRawType, enumRawType != "String" {
599+
throw MacroError(text: "Case style macros cannot be used with \(enumRawType) raw type enum. Only String or untyped enums are supported.")
600+
}
601+
602+
if enumCases.contains(where: { !$0.keyPathMatches.isEmpty }) {
603+
throw MacroError(text: "Case style macros cannot be used when @CodingCase with 'at:' parameter is present in the same enum.")
604+
}
605+
606+
for enumCase in enumCases where !enumCase.associatedMatch.isEmpty {
607+
let effectiveStyles = enumCase.caseStyles.uniqueMerged(with: caseStyles)
608+
if !effectiveStyles.isEmpty {
609+
let styleNames = effectiveStyles.map { "@\($0.macroName)" }.joined(separator: ", ")
610+
throw MacroError(text: "@CodingCase with 'values:' parameter on case '\(enumCase.caseName)' cannot coexist with case style \(styleNames).")
611+
}
612+
}
613+
}
614+
615+
/// Populates enum case `matches` with style-converted string values for decoding (union / many-to-one).
616+
///
617+
/// Values are appended in priority order:
618+
/// - P1: Existing `@CodingCase` explicit values (already present from parsing)
619+
/// - P2: Explicit rawValue that differs from the case name
620+
/// - P3/P4: Case-level then enum-level style-converted names
621+
/// - P5: Original case name (fallback, only used when P1–P4 produce nothing)
622+
///
623+
/// Encoding uses the first (highest priority) non-range value via `firstMatchValue(for:)`.
624+
/// Cases with `at:` or `values:` parameters are skipped (handled separately and validated above).
625+
mutating func enrichEnumCasesWithStyles() {
626+
for i in enumCases.indices {
627+
var ec = enumCases[i]
628+
629+
if !ec.keyPathMatches.isEmpty || !ec.associatedMatch.isEmpty {
630+
continue
631+
}
632+
633+
let effectiveStyles = ec.caseStyles.uniqueMerged(with: caseStyles)
634+
if effectiveStyles.isEmpty { continue }
635+
636+
// P2: Add explicit rawValue when it differs from the case name
637+
if ec.hasExplicitRawValue {
638+
let quotedCaseName = "\"\(ec.caseName)\""
639+
if ec.rawValue != quotedCaseName {
640+
if !ec.matchOrder.contains("String") {
641+
ec.matchOrder.append("String")
642+
}
643+
var values = ec.matches["String"] ?? []
644+
if !values.contains(ec.rawValue) {
645+
values.append(ec.rawValue)
646+
}
647+
ec.matches["String"] = values
648+
}
649+
}
650+
651+
// P3/P4: Add style-converted names (case-level styles first, then enum-level)
652+
let converted = KeyConverter.convert(value: ec.caseName, caseStyles: effectiveStyles)
653+
for name in converted {
654+
let quotedName = "\"\(name)\""
655+
if !ec.matchOrder.contains("String") {
656+
ec.matchOrder.append("String")
657+
}
658+
var values = ec.matches["String"] ?? []
659+
if !values.contains(quotedName) {
660+
values.append(quotedName)
661+
}
662+
ec.matches["String"] = values
663+
}
664+
665+
enumCases[i] = ec
666+
}
667+
}
559668
}
560669

561670
// MARK: - Generate
@@ -929,6 +1038,11 @@ extension TypeInfo {
9291038
} else {
9301039
keys = theCase.associatedMatch.first { $0.index == "\(value.index)" }?.keys ?? []
9311040
}
1041+
let assocEffectiveStyles = theCase.caseStyles.uniqueMerged(with: self.caseStyles)
1042+
if !assocEffectiveStyles.isEmpty && theCase.associatedMatch.isEmpty {
1043+
let convertedKeys = KeyConverter.convert(value: value.variableName, caseStyles: assocEffectiveStyles)
1044+
keys.append(contentsOf: convertedKeys.map { "\"\($0)\"" })
1045+
}
9321046
keys.append("\"\(value.variableName)\"")
9331047
keys.removeDuplicates()
9341048
let hasDefault = value.defaultValue != nil
@@ -1008,6 +1122,8 @@ extension TypeInfo {
10081122
let hasPathValue = enumCases.contains { !$0.keyPathMatches.isEmpty }
10091123
let encodeCase = """
10101124
\(enumCases.compactMap {
1125+
let effectiveAssocStyles = $0.caseStyles.uniqueMerged(with: self.caseStyles)
1126+
let convertAssocKeys = !effectiveAssocStyles.isEmpty && $0.associatedMatch.isEmpty
10111127
let associated = "\($0.associated.compactMap { value in value.variableName }.joined(separator: ","))"
10121128
let postfix = $0.associated.isEmpty ? "\(associated)" : "(\(associated))"
10131129
let hasAssociated = !$0.associated.isEmpty
@@ -1030,8 +1146,11 @@ extension TypeInfo {
10301146
case\(hasAssociated ? " let" : "") .\($0.caseName)\(postfix):
10311147
\(encodeCase)
10321148
\($0.associated.compactMap { value in
1033-
"""
1034-
try \(hasPathValue ? "container" : "nestedContainer").encode(\(value.variableName), forKey: AnyCodingKey("\(value.variableName)"))
1149+
let encodingKey = convertAssocKeys
1150+
? KeyConverter.convert(value: value.variableName, caseStyle: effectiveAssocStyles.first!)
1151+
: value.variableName
1152+
return """
1153+
try \(hasPathValue ? "container" : "nestedContainer").encode(\(value.variableName), forKey: AnyCodingKey("\(encodingKey)"))
10351154
"""
10361155
}.joined(separator: "\n "))
10371156
"""

0 commit comments

Comments
 (0)