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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,44 @@ enum Phone: Codable {
}
```

- 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

```swift
@Codable
@PascalCase
enum Status {
case inProgress // encodes/decodes "InProgress"
case notStarted // encodes/decodes "NotStarted"
}
```

Per-case styles can override the enum-level style:

```swift
@Codable
@PascalCase
enum Event {
@CodingCase(match: .string("pgview"))
@SnakeCase
case pageView // decodes: ["pgview", "page_view", "PageView"], encodes: "pgview"

case buttonClick // decodes/encodes: "ButtonClick"
}
```

Associated value keys also follow the naming style:

```swift
@Codable
@SnakeCase
enum Action {
case doSomething(userId: Int, userName: String)
// JSON: {"do_something": {"user_id": 42, "user_name": "John"}}
}
```

> **Note**: `@CodingCase` with `at:` or `values:` parameter cannot be used together with naming style macros.

### 15. Lifecycle Callbacks

Support encoding/decoding lifecycle callbacks:
Expand Down
38 changes: 38 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,44 @@ enum Phone: Codable {
}
```

- 支持在枚举上使用 `@SnakeCase`、`@PascalCase` 等命名风格宏, 自动转换 case 名称用于编解码. 解码时取并集(多对一), 编码时使用最高优先级的值

```swift
@Codable
@PascalCase
enum Status {
case inProgress // 编码/解码 "InProgress"
case notStarted // 编码/解码 "NotStarted"
}
```

可以在单个 case 上覆盖 enum 级别的风格:

```swift
@Codable
@PascalCase
enum Event {
@CodingCase(match: .string("pgview"))
@SnakeCase
case pageView // 解码: ["pgview", "page_view", "PageView"], 编码: "pgview"

case buttonClick // 解码/编码: "ButtonClick"
}
```

关联值枚举的 key 也会自动遵循命名风格:

```swift
@Codable
@SnakeCase
enum Action {
case doSomething(userId: Int, userName: String)
// JSON: {"do_something": {"user_id": 42, "user_name": "John"}}
}
```

> **注意**: `@CodingCase` 含有 `at:` 或 `values:` 参数时, 不能与命名风格宏混用.

### 15. 生命周期回调

支持编解码的生命周期回调:
Expand Down
6 changes: 4 additions & 2 deletions Sources/ReerCodable/MacroDeclarations/KeyCodingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
/// Key Coding Strategy Macros
///
/// These macros provide various naming convention transformations for coding keys.
/// They can be applied at both the type level (struct/class) and property level,
/// and can be combined with `@CodingKey` for more flexible key customization.
/// They can be applied at the type level (struct/class/enum), property level, or enum case level,
/// and can be combined with `@CodingKey` / `@CodingCase` for more flexible key customization.
/// When used together with `@CodingKey`, the `@CodingKey` takes precedence.
/// For enums, `@CodingCase(match:)` values take the highest encoding priority, with style-converted
/// values added as additional decoding alternatives (union/many-to-one).
///
/// 1. Type-level usage (affects all properties):
/// ```swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ extension CaseStyleAttribute {
declaration.is(StructDeclSyntax.self)
|| declaration.is(ClassDeclSyntax.self)
|| declaration.is(VariableDeclSyntax.self)
|| declaration.is(EnumDeclSyntax.self)
|| declaration.is(EnumCaseDeclSyntax.self)
else {
throw MacroError(text: "@\(style.macroName) macro is only for `struct`, `class` or a property.")
throw MacroError(text: "@\(style.macroName) macro is only for `struct`, `class`, `enum`, a property or an enum case.")
}
if let structDecl = declaration.as(StructDeclSyntax.self),
!structDecl.attributes.containsAttribute(named: "Codable")
Expand All @@ -109,6 +111,12 @@ extension CaseStyleAttribute {
&& !classDecl.attributes.containsAttribute(named: "InheritedDecodable") {
throw MacroError(text: "@\(style.macroName) macro can only be used with @Decodable, @Encodable, @Codable, @InheritedCodable or @InheritedDecodable types.")
}
if let enumDecl = declaration.as(EnumDeclSyntax.self),
!enumDecl.attributes.containsAttribute(named: "Codable")
&& !enumDecl.attributes.containsAttribute(named: "Decodable")
&& !enumDecl.attributes.containsAttribute(named: "Encodable") {
throw MacroError(text: "@\(style.macroName) macro can only be used with @Decodable, @Encodable or @Codable types.")
}
return []
}
}
Expand Down
125 changes: 122 additions & 3 deletions Sources/ReerCodableMacros/TypeInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ struct PathValueMatch {
struct EnumCase {
var caseName: String
var rawValue: String
var hasExplicitRawValue: Bool = false
// [Type: Value]
var matches: [String: [String]] = [:]
var matchOrder: [String] = []
var keyPathMatches: [PathValueMatch] = []
var associatedMatch: [AssociatedMatch] = []
var associated: [AssociatedValue] = []
var caseStyles: [CaseStyle] = []

var initText: String {
let associated = "\(associated.compactMap { "\($0.label == nil ? $0.variableName : "\($0.variableName): \($0.variableName)")" }.joined(separator: ","))"
Expand Down Expand Up @@ -110,12 +112,15 @@ struct TypeInfo {
var index = 0
var lastIntRawValue: Int = 0
try enumDecl.memberBlock.members.forEach {
let casesStartCount = enumCases.count
try $0.decl.as(EnumCaseDeclSyntax.self)?.elements.forEach { caseElement in
let name = caseElement.name.trimmedDescription
var raw: String
var hasExplicitRaw = false
if let rawValueExpr = caseElement.rawValue?.value {
let rawValue = rawValueExpr.trimmedDescription
raw = rawValue
hasExplicitRaw = true
if let intRaw = rawValueExpr.as(IntegerLiteralExprSyntax.self) {
lastIntRawValue = Int(intRaw.trimmedDescription) ?? 0
}
Expand Down Expand Up @@ -144,7 +149,7 @@ struct TypeInfo {
associated.append(.init(label: label, type: type, index: paramIndex, defaultValue: defaultValue))
paramIndex += 1
}
enumCases.append(.init(caseName: name, rawValue: raw, associated: associated))
enumCases.append(.init(caseName: name, rawValue: raw, hasExplicitRawValue: hasExplicitRaw, associated: associated))
index += 1
}

Expand Down Expand Up @@ -218,6 +223,25 @@ struct TypeInfo {
}
}
}

if let caseDecl = $0.decl.as(EnumCaseDeclSyntax.self) {
let caseLevelStyles: [CaseStyle] = caseDecl.attributes.compactMap { attr in
guard let attrId = attr.as(AttributeSyntax.self)?
.attributeName.as(IdentifierTypeSyntax.self)?
.trimmedDescription else { return nil }
for style in CaseStyle.allCases {
if style.macroName == attrId {
return style
}
}
return nil
}
if !caseLevelStyles.isEmpty {
for i in casesStartCount..<enumCases.count {
enumCases[i].caseStyles = caseLevelStyles
}
}
}
}
}
try validateEnumCases(enumCases)
Expand All @@ -233,6 +257,10 @@ struct TypeInfo {
}
return nil
}
if isEnum {
try validateEnumCaseStyles()
enrichEnumCasesWithStyles()
}
if let attribute = decl.attributes.firstAttribute(named: "CodingContainer"),
let arguments = attribute.as(AttributeSyntax.self)?.arguments?.as(LabeledExprListSyntax.self) {
codingContainer = arguments
Expand Down Expand Up @@ -556,6 +584,87 @@ extension TypeInfo {
throw MacroError(text: "Only CaseMatcher with key path and .string() patterns are allowed for enum cases with associated values")
}
}

/// Validates that case style macros are used correctly on enum cases.
///
/// Rules enforced:
/// 1. Numeric raw type enums (Int, Double, etc.) cannot use case style macros.
/// 2. Case style macros cannot coexist with `@CodingCase` using the `at:` parameter in the same enum.
/// 3. `@CodingCase` with `values:` parameter on a specific case cannot coexist with any effective case style.
func validateEnumCaseStyles() throws {
let anyStyles = !caseStyles.isEmpty || enumCases.contains { !$0.caseStyles.isEmpty }
guard anyStyles else { return }

if let enumRawType, enumRawType != "String" {
throw MacroError(text: "Case style macros cannot be used with \(enumRawType) raw type enum. Only String or untyped enums are supported.")
}

if enumCases.contains(where: { !$0.keyPathMatches.isEmpty }) {
throw MacroError(text: "Case style macros cannot be used when @CodingCase with 'at:' parameter is present in the same enum.")
}

for enumCase in enumCases where !enumCase.associatedMatch.isEmpty {
let effectiveStyles = enumCase.caseStyles.uniqueMerged(with: caseStyles)
if !effectiveStyles.isEmpty {
let styleNames = effectiveStyles.map { "@\($0.macroName)" }.joined(separator: ", ")
throw MacroError(text: "@CodingCase with 'values:' parameter on case '\(enumCase.caseName)' cannot coexist with case style \(styleNames).")
}
}
}

/// Populates enum case `matches` with style-converted string values for decoding (union / many-to-one).
///
/// Values are appended in priority order:
/// - P1: Existing `@CodingCase` explicit values (already present from parsing)
/// - P2: Explicit rawValue that differs from the case name
/// - P3/P4: Case-level then enum-level style-converted names
/// - P5: Original case name (fallback, only used when P1–P4 produce nothing)
///
/// Encoding uses the first (highest priority) non-range value via `firstMatchValue(for:)`.
/// Cases with `at:` or `values:` parameters are skipped (handled separately and validated above).
mutating func enrichEnumCasesWithStyles() {
for i in enumCases.indices {
var ec = enumCases[i]

if !ec.keyPathMatches.isEmpty || !ec.associatedMatch.isEmpty {
continue
}

let effectiveStyles = ec.caseStyles.uniqueMerged(with: caseStyles)
if effectiveStyles.isEmpty { continue }

// P2: Add explicit rawValue when it differs from the case name
if ec.hasExplicitRawValue {
let quotedCaseName = "\"\(ec.caseName)\""
if ec.rawValue != quotedCaseName {
if !ec.matchOrder.contains("String") {
ec.matchOrder.append("String")
}
var values = ec.matches["String"] ?? []
if !values.contains(ec.rawValue) {
values.append(ec.rawValue)
}
ec.matches["String"] = values
}
}

// P3/P4: Add style-converted names (case-level styles first, then enum-level)
let converted = KeyConverter.convert(value: ec.caseName, caseStyles: effectiveStyles)
for name in converted {
let quotedName = "\"\(name)\""
if !ec.matchOrder.contains("String") {
ec.matchOrder.append("String")
}
var values = ec.matches["String"] ?? []
if !values.contains(quotedName) {
values.append(quotedName)
}
ec.matches["String"] = values
}

enumCases[i] = ec
}
}
}

// MARK: - Generate
Expand Down Expand Up @@ -929,6 +1038,11 @@ extension TypeInfo {
} else {
keys = theCase.associatedMatch.first { $0.index == "\(value.index)" }?.keys ?? []
}
let assocEffectiveStyles = theCase.caseStyles.uniqueMerged(with: self.caseStyles)
if !assocEffectiveStyles.isEmpty && theCase.associatedMatch.isEmpty {
let convertedKeys = KeyConverter.convert(value: value.variableName, caseStyles: assocEffectiveStyles)
keys.append(contentsOf: convertedKeys.map { "\"\($0)\"" })
}
keys.append("\"\(value.variableName)\"")
keys.removeDuplicates()
let hasDefault = value.defaultValue != nil
Expand Down Expand Up @@ -1008,6 +1122,8 @@ extension TypeInfo {
let hasPathValue = enumCases.contains { !$0.keyPathMatches.isEmpty }
let encodeCase = """
\(enumCases.compactMap {
let effectiveAssocStyles = $0.caseStyles.uniqueMerged(with: self.caseStyles)
let convertAssocKeys = !effectiveAssocStyles.isEmpty && $0.associatedMatch.isEmpty
let associated = "\($0.associated.compactMap { value in value.variableName }.joined(separator: ","))"
let postfix = $0.associated.isEmpty ? "\(associated)" : "(\(associated))"
let hasAssociated = !$0.associated.isEmpty
Expand All @@ -1030,8 +1146,11 @@ extension TypeInfo {
case\(hasAssociated ? " let" : "") .\($0.caseName)\(postfix):
\(encodeCase)
\($0.associated.compactMap { value in
"""
try \(hasPathValue ? "container" : "nestedContainer").encode(\(value.variableName), forKey: AnyCodingKey("\(value.variableName)"))
let encodingKey = convertAssocKeys
? KeyConverter.convert(value: value.variableName, caseStyle: effectiveAssocStyles.first!)
: value.variableName
return """
try \(hasPathValue ? "container" : "nestedContainer").encode(\(value.variableName), forKey: AnyCodingKey("\(encodingKey)"))
"""
}.joined(separator: "\n "))
"""
Expand Down
Loading
Loading