From 6901a2d6bb470a1e1cd8f474c2eb2c851a97bbf4 Mon Sep 17 00:00:00 2001 From: Phoenix Date: Sat, 28 Feb 2026 14:11:41 +0800 Subject: [PATCH] feat: support case style macros (@PascalCase, @SnakeCase, etc.) on enums 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 --- README.md | 38 ++++ README_CN.md | 38 ++++ .../MacroDeclarations/KeyCodingStrategy.swift | 6 +- .../MacroImplementations/CaseStyleImpl.swift | 10 +- Sources/ReerCodableMacros/TypeInfo.swift | 125 ++++++++++- Tests/ReerCodableTests/EnumTests.swift | 211 ++++++++++++++++++ 6 files changed, 422 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d7e84b3..11912f5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/README_CN.md b/README_CN.md index b185ae9..d82a6fb 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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. 生命周期回调 支持编解码的生命周期回调: diff --git a/Sources/ReerCodable/MacroDeclarations/KeyCodingStrategy.swift b/Sources/ReerCodable/MacroDeclarations/KeyCodingStrategy.swift index ae201de..33e83d6 100644 --- a/Sources/ReerCodable/MacroDeclarations/KeyCodingStrategy.swift +++ b/Sources/ReerCodable/MacroDeclarations/KeyCodingStrategy.swift @@ -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 diff --git a/Sources/ReerCodableMacros/MacroImplementations/CaseStyleImpl.swift b/Sources/ReerCodableMacros/MacroImplementations/CaseStyleImpl.swift index 15214da..d19339f 100644 --- a/Sources/ReerCodableMacros/MacroImplementations/CaseStyleImpl.swift +++ b/Sources/ReerCodableMacros/MacroImplementations/CaseStyleImpl.swift @@ -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") @@ -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 [] } } diff --git a/Sources/ReerCodableMacros/TypeInfo.swift b/Sources/ReerCodableMacros/TypeInfo.swift index 2815958..6cc8325 100644 --- a/Sources/ReerCodableMacros/TypeInfo.swift +++ b/Sources/ReerCodableMacros/TypeInfo.swift @@ -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: ","))" @@ -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 } @@ -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 } @@ -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..