From ad24b378260e0c17b651957d1fd65c5b0d64f298 Mon Sep 17 00:00:00 2001 From: soumyamahunt Date: Mon, 6 Oct 2025 23:33:48 +0530 Subject: [PATCH] feat: added support for `RawRepresentable` enum conformance generation --- .github/workflows/main.yml | 9 +- Package.swift | 3 +- Package@swift-5.swift | 3 +- Sources/HelperCoders/ValueCoders/Number.swift | 2 +- Sources/MacroPlugin/Definitions.swift | 16 +- .../MetaCodable.docc/Limitations.md | 6 +- .../MetaCodable.docc/MetaCodable.md | 1 - Sources/PluginCore/Attributes/CodedBy.swift | 9 +- .../Attributes/KeyPath/CodedAt.swift | 51 +- .../RawRepresentableEnumCondition.swift | 46 ++ .../Enum/Case/BasicEnumCaseVariable.swift | 2 +- .../Switcher/AdjacentlyTaggableSwitcher.swift | 136 +++- .../InternallyTaggedEnumSwitcher.swift | 106 ++- .../Variables/Type/EnumVariable.swift | 14 +- .../Variables/Type/MemberGroup.swift | 1 + Sources/ProtocolGen/Generate.swift | 6 +- Tests/MetaCodableTests/CodableTests.swift | 10 +- .../ConformCodableTests.swift | 27 - .../RawRepresentableEnumTests.swift | 755 ++++++++++++++++++ 19 files changed, 1067 insertions(+), 136 deletions(-) create mode 100644 Sources/PluginCore/Diagnostics/Condition/RawRepresentableEnumCondition.swift create mode 100644 Tests/MetaCodableTests/RawRepresentableEnumTests.swift diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 43928fff7..95a9ac4d7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -174,16 +174,17 @@ jobs: "swift-syntax": "601.0.1" } }, - { - "os": "ubuntu-latest", - "swift": "latest" - }, { "os": "macos-15", "swift": "6.1.2" } ] } + # disable until prebuilt macro issues are fixed + # { + # "os": "ubuntu-latest", + # "swift": "latest" + # }, cocoapods-test: name: CocoaPods diff --git a/Package.swift b/Package.swift index b7352bd3c..1ae5615a8 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( .plugin(name: "MetaProtocolCodable", targets: ["MetaProtocolCodable"]), ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.1.0"..<"602.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.1.0"..<"603.0.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), @@ -47,6 +47,7 @@ let package = Package( .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"), ] ), .target(name: "MetaCodable", dependencies: ["MacroPlugin"]), diff --git a/Package@swift-5.swift b/Package@swift-5.swift index b0a38bf06..0c9e959d9 100644 --- a/Package@swift-5.swift +++ b/Package@swift-5.swift @@ -20,7 +20,7 @@ let package = Package( .plugin(name: "MetaProtocolCodable", targets: ["MetaProtocolCodable"]), ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.1.0"..<"602.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.1.0"..<"603.0.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"), ], @@ -46,6 +46,7 @@ let package = Package( .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"), ] ), .target(name: "MetaCodable", dependencies: ["MacroPlugin"]), diff --git a/Sources/HelperCoders/ValueCoders/Number.swift b/Sources/HelperCoders/ValueCoders/Number.swift index 24a01c75e..60bcc5df0 100644 --- a/Sources/HelperCoders/ValueCoders/Number.swift +++ b/Sources/HelperCoders/ValueCoders/Number.swift @@ -3,7 +3,7 @@ protocol NumberCodingStrategy: ValueCodingStrategy where Value == Self {} public extension ValueCodingStrategy -where Value: Decodable & ExpressibleByIntegerLiteral & LosslessStringConvertible +where Value: Decodable & ExpressibleByIntegerLiteral & LosslessStringConvertible & Sendable { /// Decodes numeric data from the given `decoder`. /// diff --git a/Sources/MacroPlugin/Definitions.swift b/Sources/MacroPlugin/Definitions.swift index 60f003f0b..33ca04974 100644 --- a/Sources/MacroPlugin/Definitions.swift +++ b/Sources/MacroPlugin/Definitions.swift @@ -707,7 +707,7 @@ struct UnTagged: PeerMacro { /// implementation depending on the type of attached declaration: /// * `struct`/`class`/`enum`/`actor` types: Expansion of `Decodable` /// protocol conformance members. -public struct ConformDecodable: MemberMacro, ExtensionMacro { +struct ConformDecodable: MemberMacro, ExtensionMacro { /// Expand to produce members for `Decodable`. /// /// Membership macro expansion for `ConformDecodable` macro @@ -719,7 +719,7 @@ public struct ConformDecodable: MemberMacro, ExtensionMacro { /// - context: The context in which to perform the macro expansion. /// /// - Returns: Delegated member expansion from `PluginCore.ConformDecodable`. - public static func expansion( + static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext @@ -743,7 +743,7 @@ public struct ConformDecodable: MemberMacro, ExtensionMacro { /// - context: The context in which to perform the macro expansion. /// /// - Returns: Delegated member expansion from `PluginCore.ConformDecodable`. - public static func expansion( + static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], @@ -769,7 +769,7 @@ public struct ConformDecodable: MemberMacro, ExtensionMacro { /// - context: The context in which to perform the macro expansion. /// /// - Returns: Delegated extension expansion from `PluginCore.ConformDecodable`. - public static func expansion( + static func expansion( of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, @@ -791,7 +791,7 @@ public struct ConformDecodable: MemberMacro, ExtensionMacro { /// implementation depending on the type of attached declaration: /// * `struct`/`class`/`enum`/`actor` types: Expansion of `Encodable` /// protocol conformance members. -public struct ConformEncodable: MemberMacro, ExtensionMacro { +struct ConformEncodable: MemberMacro, ExtensionMacro { /// Expand to produce members for `Encodable`. /// /// Membership macro expansion for `ConformEncodable` macro @@ -803,7 +803,7 @@ public struct ConformEncodable: MemberMacro, ExtensionMacro { /// - context: The context in which to perform the macro expansion. /// /// - Returns: Delegated member expansion from `PluginCore.ConformEncodable`. - public static func expansion( + static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext @@ -827,7 +827,7 @@ public struct ConformEncodable: MemberMacro, ExtensionMacro { /// - context: The context in which to perform the macro expansion. /// /// - Returns: Delegated member expansion from `PluginCore.ConformEncodable`. - public static func expansion( + static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], @@ -853,7 +853,7 @@ public struct ConformEncodable: MemberMacro, ExtensionMacro { /// - context: The context in which to perform the macro expansion. /// /// - Returns: Delegated extension expansion from `PluginCore.ConformEncodable`. - public static func expansion( + static func expansion( of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, diff --git a/Sources/MetaCodable/MetaCodable.docc/Limitations.md b/Sources/MetaCodable/MetaCodable.docc/Limitations.md index 70700be63..a14421994 100644 --- a/Sources/MetaCodable/MetaCodable.docc/Limitations.md +++ b/Sources/MetaCodable/MetaCodable.docc/Limitations.md @@ -61,17 +61,13 @@ enum SomeEnum { } ``` -### Why enums with raw value aren't supported? - -`Swift` compiler by default generates `Codable` conformance for `enum`s with raw value and `MetaCodable` has nothing extra to add for these type of `enum`s. Hence, in this case the default compiler generated implementation can be used. - ### Why actor conformance to Encodable not generated? For `actor`s ``Codable(commonStrategies:)`` generates `Decodable` conformance, while `Encodable` conformance isn't generated, only `encode(to:)` method implementation is generated which is isolated to `actor`. To generate `Encodable` conformance, the `encode(to:)` method must be `nonisolated` to `actor`, and since `encode(to:)` method must be synchronous making it `nonisolated` will prevent accessing mutable properties. -Due to these limitations, `Encodable` conformance isn't generated, users has to implement the conformance manually. +Due to these limitations, `Encodable` conformance isn't generated, users have to implement the conformance manually. ### Why MetaProtocolCodable plugin can't scan Xcode target dependencies? diff --git a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md index bc4ced945..f2b5b7dcb 100644 --- a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md +++ b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md @@ -10,7 +10,6 @@ Supercharge `Swift`'s `Codable` implementations with macros. `MetaCodable` framework exposes custom macros which can be used to generate dynamic `Codable` implementations. The core of the framework is ``Codable(commonStrategies:)`` macro which generates the implementation aided by data provided with using other macros. - `MetaCodable` aims to supercharge your `Codable` implementations by providing these inbox features: - Allows custom `CodingKey` value declaration per variable with ``CodedAt(_:)`` passing single argument, instead of requiring you to write all the `CodingKey` values. diff --git a/Sources/PluginCore/Attributes/CodedBy.swift b/Sources/PluginCore/Attributes/CodedBy.swift index 4f391eb61..e60882a2f 100644 --- a/Sources/PluginCore/Attributes/CodedBy.swift +++ b/Sources/PluginCore/Attributes/CodedBy.swift @@ -54,8 +54,13 @@ package struct CodedBy: PropertyAttribute { isEnum || isProtocol, AggregatedDiagnosticProducer { mustBeCombined(with: Codable.self) - mustBeCombined( - with: CodedAt.self, or: DecodedAt.self, EncodedAt.self + `if`( + isRawRepresentableEnum, + mustBeCombined(with: Codable.self), + else: mustBeCombined( + with: CodedAt.self, + or: DecodedAt.self, EncodedAt.self + ) ) }, else: AggregatedDiagnosticProducer { diff --git a/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift b/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift index 4fcad1d29..23f6a2fa9 100644 --- a/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift +++ b/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift @@ -83,10 +83,13 @@ where Var == ExternallyTaggedEnumSwitcher, Decl == EnumDeclSyntax { /// /// If valid key paths are found, creates an `InternallyTaggedEnumSwitcher` with /// the specified configuration. The identifier type is determined from the `CodedAs` - /// attribute if present, otherwise defaults to `String`. + /// attribute if present, otherwise defaults to `String`. The `topDecode` and `topEncode` + /// flags are determined based on whether the respective key paths are empty. /// /// - Parameters: - /// - container: The container token for case variation encoding/decoding. + /// - decl: The enum declaration syntax for which code is being generated. + /// - coderPrefix: The prefix for coder variable names that will be used + /// to generate decoder and encoder variable names. /// - identifier: The identifier token name to use for tagging. /// - codingKeys: The coding keys map for key path resolution. /// - forceDecodingReturn: Whether to force explicit `return` statements in @@ -103,7 +106,8 @@ where Var == ExternallyTaggedEnumSwitcher, Decl == EnumDeclSyntax { /// applying both builder functions. Otherwise, returns the current registration /// with a type-erased variable, indicating external tagging should be used. func checkForInternalTagging( - container: TokenSyntax, identifier: TokenSyntax, + decl: EnumDeclSyntax, + coderPrefix: TokenSyntax, identifier: TokenSyntax, codingKeys: CodingKeysMap, forceDecodingReturn: Bool, context: some MacroExpansionContext, variableBuilder: @escaping ( @@ -120,19 +124,26 @@ where Var == ExternallyTaggedEnumSwitcher, Decl == EnumDeclSyntax { let path = tagAttr?.keyPath(withExisting: []) ?? [] let decodedPath = decodeTagAttr?.keyPath(withExisting: path) ?? path let encodedPath = encodeTagAttr?.keyPath(withExisting: path) ?? path + let rawRepresentable = + decl.inheritanceClause? + .inheritedTypes.contains { $0.type.isRawValueType } ?? false + && decl.codableMembers(input: codingKeys).allSatisfy { member in + member.element.parameterClause == nil + } guard - !decodedPath.isEmpty && !encodedPath.isEmpty + (!decodedPath.isEmpty && !encodedPath.isEmpty) || rawRepresentable else { return self.updating(with: variable.any) } let typeAttr = CodedAs(from: decl) let keyPath = PathKey(decoding: decodedPath, encoding: encodedPath) let variable = InternallyTaggedEnumSwitcher( - identifierDecodeContainer: container, - identifierEncodeContainer: container, + coderPrefix: coderPrefix, topDecode: keyPath.decoding.isEmpty, + topEncode: keyPath.encoding.isEmpty, identifier: identifier, identifierType: typeAttr?.type, keyPath: keyPath, codingKeys: codingKeys, decl: decl, context: context, forceDecodingReturn: forceDecodingReturn, + rawRepresentable: rawRepresentable, variableBuilder: variableBuilder ) @@ -140,3 +151,31 @@ where Var == ExternallyTaggedEnumSwitcher, Decl == EnumDeclSyntax { return newRegistration.updating(with: newRegistration.variable.any) } } + +extension TypeSyntax { + /// Determines if this type syntax represents a raw value type suitable for enum raw values. + /// + /// Raw value types are fundamental Swift types that can be used as the underlying + /// raw value type for enums. This includes all integer types, floating-point types, + /// boolean, string, and character types that Swift supports natively. + /// + /// The supported types include: + /// - **Boolean**: `Bool` + /// - **String types**: `String`, `Character` + /// - **Signed integers**: `Int`, `Int8`, `Int16`, `Int32`, `Int64`, `Int128` + /// - **Unsigned integers**: `UInt`, `UInt8`, `UInt16`, `UInt32`, `UInt64`, `UInt128` + /// - **Floating-point**: `Float`, `Float16`, `Float80`, `Double` + /// + /// This property is used to determine if an enum can be treated as RawRepresentable + /// and whether it should use raw value-based decoding/encoding strategies. + /// + /// - Returns: `true` if the type is a valid raw value type, `false` otherwise. + var isRawValueType: Bool { + [ + "Bool", "String", "Character", + "Int", "Int8", "Int16", "Int32", "Int64", "Int128", + "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "UInt128", + "Float", "Float16", "Float80", "Double", + ].contains(trimmedDescription) + } +} diff --git a/Sources/PluginCore/Diagnostics/Condition/RawRepresentableEnumCondition.swift b/Sources/PluginCore/Diagnostics/Condition/RawRepresentableEnumCondition.swift new file mode 100644 index 000000000..625eb03f3 --- /dev/null +++ b/Sources/PluginCore/Diagnostics/Condition/RawRepresentableEnumCondition.swift @@ -0,0 +1,46 @@ +import SwiftSyntax + +/// Validates provided syntax is a raw representable enum. +/// +/// Checks if the provided enum declaration has: +/// 1. An inheritance clause with a raw value type (String, Int, etc.) +/// 2. All enum cases have no associated values (parameter clauses) +struct RawRepresentableEnumCondition: DiagnosticCondition { + /// Determines whether provided syntax passes validation. + /// + /// This type checks the provided syntax with current data for validation. + /// Checks if the syntax is an enum declaration that conforms to RawRepresentable + /// by having a raw value type and no associated values in its cases. + /// + /// - Parameter syntax: The syntax to validate. + /// - Returns: Whether syntax passes validation. + func satisfied(by syntax: some SyntaxProtocol) -> Bool { + guard let enumDecl = syntax.as(EnumDeclSyntax.self) else { + return false + } + + // Check if enum has inheritance clause with raw value type + let hasRawValueType = + enumDecl.inheritanceClause? + .inheritedTypes.contains { $0.type.isRawValueType } ?? false + + // Check if all enum cases have no associated values + let allCasesHaveNoAssociatedValues = enumDecl.memberBlock.members + .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } + .allSatisfy { caseDecl in + caseDecl.elements.allSatisfy { element in + element.parameterClause == nil + } + } + + return hasRawValueType && allCasesHaveNoAssociatedValues + } +} + +extension Attribute { + /// Whether declaration is a raw representable enum. + /// + /// Uses `RawRepresentableEnumCondition` to check if the enum has a raw value type + /// and no associated values in its cases. + var isRawRepresentableEnum: RawRepresentableEnumCondition { .init() } +} diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 9782e91d4..b8a80c6d4 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -71,7 +71,7 @@ struct BasicEnumCaseVariable: EnumCaseVariable { /// - Parameters: /// - decl: The declaration to read data from. /// - context: The context in which to perform the macro expansion. - /// - node: The node at which variables are registered. + /// - switcher: The switcher variable for handling enum case variations. /// - builder: The builder action to use to update associated variables /// registration data. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index 0671aeb19..bc6e73478 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -148,9 +148,25 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { contentAt decoder: TokenSyntax ) -> CodeBlockItemListSyntax { let coder = location.coder - let container = self.variable.decodeContainer - let containerType = self.identifierContainerType() - let idetifierDecodingSyntax = + let container = self.variable.decoder + let (_, key) = identifierVariableAndKey( + identifier, withType: "_", context: context + ) + let decodingKeys = codingKeys.add( + keys: key.decoding, field: identifier, context: context + ) + + let containerType: TypeSyntax + let propLocation: PropertyCodingLocation + if let decodingKey = decodingKeys.last?.expr { + propLocation = .container(container, key: decodingKey, method: nil) + containerType = self.identifierContainerType() + } else { + propLocation = .coder(container, method: nil) + containerType = "any Decoder" + } + + var idetifierDecodingSyntax = EnumVariable.CaseValue.TypeOf.all( inheritedType: identifierType ).compactMap { type in @@ -165,22 +181,16 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { guard let switchExpr = switchExpr, switchExpr.cases.count > 1 else { return nil } - return CodeBlockItemListSyntax { - let typesyntax = type.syntax( - optional: identifierType == nil) - let (variable, key) = identifierVariableAndKey( - identifier, withType: typesyntax, context: context - ) - let decodingKey = codingKeys.add( - keys: key.decoding, field: identifier, context: context - ).last!.expr + let typesyntax = type.syntax( + optional: identifierType == nil + ) + let (variable, _) = identifierVariableAndKey( + identifier, withType: typesyntax, context: context + ) + return CodeBlockItemListSyntax { "let \(identifier): \(type.syntax(optional: identifierType == nil))" - variable.decoding( - in: context, - from: .container( - container, key: decodingKey, method: nil) - ) + variable.decoding(in: context, from: propLocation) switch variable.decodingFallback { case .ifMissing where identifierType == nil, @@ -198,6 +208,33 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { } } as [CodeBlockItemListSyntax] + if rawRepresentable { + let rawVariable = createRawValueVariable() + let decoding = rawVariable.decoding( + in: context, from: propLocation + ) + + idetifierDecodingSyntax.insert( + CodeBlockItemListSyntax { + "let rawValue: RawValue?" + """ + do { + \(decoding) + } catch { + rawValue = nil + } + """ + """ + if let rawValue = rawValue, let selfValue = Self(rawValue: rawValue) { + self = selfValue + return + } + """ + }, + at: 0 + ) + } + return CodeBlockItemListSyntax { if !idetifierDecodingSyntax.isEmpty { "var \(container): \(containerType)" @@ -219,7 +256,7 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { } let header: SyntaxNodeString = - topContainerOptional + topContainerOptional && !rawRepresentable ? "if let \(container) = \(container), let \(location.container) = \(location.container)" : "if let \(container) = \(container)" try! IfExprSyntax(header) { @@ -237,6 +274,28 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { } } + /// Creates a raw value variable for RawRepresentable enum decoding. + /// + /// Constructs a variable for handling raw values in RawRepresentable enums. + /// This method creates a basic property variable for raw value decoding, then + /// applies the variable builder to transform it into the appropriate variable + /// type for the specific enum implementation. + /// + /// - Returns: A variable configured for raw value decoding, transformed through + /// the variable builder to match the enum's variable type requirements. + func createRawValueVariable() -> Variable { + let rawVariable = BasicPropertyVariable( + name: "rawValue", type: "RawValue", value: nil, + decodePrefix: "", encodePrefix: "" + ) + let registration = Registration( + decl: decl, key: PathKey(decoding: [], encoding: []), + variable: rawVariable + ) + let output = self.variableBuilder(registration) + return output.variable + } + /// Determines the container type for identifier decoding. /// /// Creates a `KeyedDecodingContainer` type with the appropriate coding keys. @@ -254,9 +313,9 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { /// Creates an identifier variable and its associated key path. /// /// Constructs a property variable for the enum identifier with the specified - /// type and applies the variable builder transformation. If no explicit - /// identifier type is set, wraps the variable with default value handling - /// to gracefully handle missing or invalid identifiers by defaulting to `nil`. + /// type. If no explicit identifier type is set, wraps the variable with + /// default value handling to gracefully handle missing or invalid identifiers + /// by defaulting to `nil`. /// /// - Parameters: /// - identifier: The identifier token name for the variable. @@ -281,7 +340,7 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { else { return (output.variable.any, output.key) } let outVariable = DefaultValueVariable( - base: output.variable, + base: input.variable, options: .init(onMissingExpr: "nil", onErrorExpr: "nil") ).any return (outVariable, output.key) @@ -304,6 +363,21 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { contentAt encoder: TokenSyntax ) -> CodeBlockItemListSyntax { let coder = location.coder + let container = self.variable.encoder + let (_, key) = self.identifierVariableAndKey( + identifier, withType: "_", context: context + ) + let encodingKeys = codingKeys.add( + keys: key.encoding, field: identifier, context: context + ) + + let propLocation: PropertyCodingLocation + if let encodingKey = encodingKeys.last?.expr { + propLocation = .container(container, key: encodingKey, method: nil) + } else { + propLocation = .coder(container, method: nil) + } + return CodeBlockItemListSyntax { encodingNode.encoding( in: context, to: .withCoder(coder, keyType: location.keyType) @@ -312,19 +386,17 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { over: location.selfValue, at: location, from: encoder, in: context, withDefaultCase: location.hasDefaultCase ) { name in - let container = variable.encodeContainer - let (variable, key) = identifierVariableAndKey( + let (variable, _) = identifierVariableAndKey( name, withType: "_", context: context ) - let encodingKey = codingKeys.add( - keys: key.encoding, field: identifier, context: context - ).last!.expr - return variable.encoding( - in: context, - to: .container(container, key: encodingKey, method: nil) - ) + return variable.encoding(in: context, to: propLocation) } - if let switchExpr = switchExpr { + + if rawRepresentable { + createRawValueVariable().encoding( + in: context, to: propLocation + ) + } else if let switchExpr = switchExpr { switchExpr } } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index 2bd3e68de..cf8e94092 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -81,6 +81,12 @@ where /// This flag is typically set based on the code generation strategy or specific /// requirements for the generated decoding implementation. let forceDecodingReturn: Bool + /// Whether this enum should be treated as RawRepresentable. + /// + /// When `true`, indicates that the enum has no associated values and should + /// be encoded/decoded using RawRepresentable semantics. This affects how + /// the enum cases are processed during code generation. + let rawRepresentable: Bool /// Creates an internally tagged enum switcher and configures all components. /// @@ -90,10 +96,10 @@ where /// variable with the provided container names. /// /// - Parameters: - /// - identifierDecodeContainer: The token name for the decoding container variable - /// that will be exposed during decoding operations. - /// - identifierEncodeContainer: The token name for the encoding container variable - /// that will be exposed during encoding operations. + /// - coderPrefix: The prefix for coder variable names that will be used + /// to generate decoder and encoder variable names. + /// - topDecode: Whether the decoding is happening at the top level. + /// - topEncode: Whether the encoding is happening at the top level. /// - identifier: The identifier token name for the enum case identifier variable. /// - identifierType: The optional type syntax for the identifier variable. If nil, /// default fallback handling with nil values will be applied. @@ -105,23 +111,25 @@ where /// - forceDecodingReturn: Whether to force explicit `return` statements in generated /// decoding switch cases. When `true`, each case includes a `return` after assignment /// for early exit from the switch statement. + /// - rawRepresentable: Whether this enum should be treated as RawRepresentable. + /// When `true`, indicates the enum has no associated values and should use + /// RawRepresentable semantics for encoding/decoding. /// - variableBuilder: The builder function for transforming the basic property /// variable into the final variable type with custom processing. init( - identifierDecodeContainer: TokenSyntax, - identifierEncodeContainer: TokenSyntax, + coderPrefix: TokenSyntax, topDecode: Bool, topEncode: Bool, identifier: TokenSyntax, identifierType: TypeSyntax?, keyPath: PathKey, codingKeys: CodingKeysMap, decl: EnumDeclSyntax, context: some MacroExpansionContext, - forceDecodingReturn: Bool, + forceDecodingReturn: Bool, rawRepresentable: Bool, variableBuilder: @escaping VariableBuilder ) { - precondition(!keyPath.decoding.isEmpty && !keyPath.encoding.isEmpty) self.identifier = identifier self.identifierType = identifierType self.decl = decl self.variableBuilder = variableBuilder self.forceDecodingReturn = forceDecodingReturn + self.rawRepresentable = rawRepresentable var decodingNode = PropertyVariableTreeNode() var encodingNode = PropertyVariableTreeNode() @@ -145,8 +153,8 @@ where ) self.variable = ContainerVariable( - decodeContainer: identifierDecodeContainer, - encodeContainer: identifierEncodeContainer, + coderPrefix: coderPrefix, topDecode: topDecode, + topEncode: topEncode, base: output.variable, providedType: identifierType ) @@ -264,27 +272,33 @@ where } extension InternallyTaggedEnumSwitcher { - /// A variable value exposing encoding container. + /// A variable value exposing decoder and encoder. /// /// The `ContainerVariable` forwards decoding implementation - /// to underlying variable while exposing encoding container via variable - /// provided with `encodeContainer` name. + /// to underlying variable while exposing decoder and encoder via computed + /// properties based on coderPrefix and topLevel flag. struct ContainerVariable: PropertyVariable, ComposedVariable where Wrapped: PropertyVariable { /// The initialization type of this variable. /// /// Initialization type is the same as underlying wrapped variable. typealias Initialization = Wrapped.Initialization - /// The mapped name for decoder. + /// The prefix for coder variable names. + /// + /// This prefix is used to generate decoder and encoder variable names. + let coderPrefix: TokenSyntax + /// Whether the decoding is happening at the top level. /// - /// The decoder at location passed will be exposed - /// with this variable name. - let decodeContainer: TokenSyntax - /// The mapped name for encoder. + /// When `true`, indicates that the decoding is happening at the top level + /// (directly with decoder). When `false`, indicates that decoding + /// is happening within a container. + let topDecode: Bool + /// Whether the encoding is happening at the top level. /// - /// The encoder at location passed will be exposed - /// with this variable name. - let encodeContainer: TokenSyntax + /// When `true`, indicates that the encoding is happening at the top level + /// (directly with encoder). When `false`, indicates that encoding + /// is happening within a container. + let topEncode: Bool /// The value wrapped by this instance. /// /// The wrapped variable's type data is @@ -298,6 +312,28 @@ extension InternallyTaggedEnumSwitcher { /// gracefully. If non-optional or nil, different fallback strategies apply. let providedType: TypeSyntax? + /// The computed decoder variable name. + /// + /// Generates the decoder variable name based on the coderPrefix and topDecode flag. + /// When topDecode is true, appends "Decoder". When false, appends "Container". + var decoder: TokenSyntax { + guard topDecode else { + return "\(coderPrefix)Container" + } + return "\(coderPrefix)Decoder" + } + + /// The computed encoder variable name. + /// + /// Generates the encoder variable name based on the coderPrefix and topEncode flag. + /// When topEncode is true, appends "Encoder". When false, appends "Container". + var encoder: TokenSyntax { + guard topEncode else { + return "\(coderPrefix)Container" + } + return "\(coderPrefix)Encoder" + } + /// Whether the variable is to be decoded. /// /// This variable is always set as to be decoded. @@ -320,23 +356,23 @@ extension InternallyTaggedEnumSwitcher { /// /// Determines how to handle decoding failures based on the provided type: /// - When `providedType` is `nil`: Uses `.ifMissing` fallback for both missing - /// and error cases, setting the container to `nil`. + /// and error cases, setting the decoder to `nil`. /// - When `providedType` is optional: Uses `.onlyIfMissing` fallback, setting - /// the container to `nil` only when data is missing. + /// the decoder to `nil` only when data is missing. /// - When `providedType` is non-optional: Uses `.throw` strategy, propagating /// decoding errors without fallback handling. var decodingFallback: DecodingFallback { - let containerFallbackSyntax = CodeBlockItemListSyntax { - "\(decodeContainer) = nil" + let decoderFallbackSyntax = CodeBlockItemListSyntax { + "\(decoder) = nil" } return switch providedType { case .none: .ifMissing( - containerFallbackSyntax, ifError: containerFallbackSyntax + decoderFallbackSyntax, ifError: decoderFallbackSyntax ) case .some(let type) where type.isOptionalTypeSyntax == true: - .onlyIfMissing(containerFallbackSyntax) + .onlyIfMissing(decoderFallbackSyntax) default: .throw } @@ -345,8 +381,8 @@ extension InternallyTaggedEnumSwitcher { /// Provides the code syntax for decoding this variable /// at the provided location. /// - /// Assigns the decoding container passed in location to the variable - /// created with the `decodeContainer` name provided. + /// Assigns the decoder passed in location to the variable + /// created with the computed `decoder` name. /// /// - Parameters: /// - context: The context in which to perform the macro expansion. @@ -359,17 +395,17 @@ extension InternallyTaggedEnumSwitcher { ) -> CodeBlockItemListSyntax { switch location { case .coder(let decoder, _): - fatalError("Error encoding \(Self.self) to \(decoder)") + "\(self.decoder) = \(decoder)" case .container(let container, _, _): - "\(self.decodeContainer) = \(container)" + "\(self.decoder) = \(container)" } } /// Provides the code syntax for encoding this variable /// at the provided location. /// - /// Assigns the encoding container passed in location to the variable - /// created with the `encodeContainer` name provided. + /// Assigns the encoder passed in location to the variable + /// created with the computed `encoder` name. /// /// - Parameters: /// - context: The context in which to perform the macro expansion. @@ -382,9 +418,9 @@ extension InternallyTaggedEnumSwitcher { ) -> CodeBlockItemListSyntax { switch location { case .coder(let encoder, _): - fatalError("Error encoding \(Self.self) to \(encoder)") + "let \(self.encoder) = \(encoder)" case .container(let container, _, _): - "var \(self.encodeContainer) = \(container)" + "var \(self.encoder) = \(container)" } } } diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 3c621b995..780dde41f 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -88,7 +88,9 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { if !args.isEmpty { FunctionCallExprSyntax(callee: callee) { args } } else { - FunctionCallExprSyntax(calledExpression: callee) {} + FunctionCallExprSyntax( + calledExpression: callee, leftParen: nil, rightParen: nil + ) {} } return ExprSyntax(fExpr) } @@ -140,8 +142,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher: switcher, codingKeys: codingKeys ) { input in input.checkForInternalTagging( - container: Self.typeContainer, identifier: Self.type, - codingKeys: codingKeys, + decl: decl, coderPrefix: Self.typeCoderPrefix, + identifier: Self.type, codingKeys: codingKeys, forceDecodingReturn: forceInternalTaggingDecodingReturn, context: context ) { registration in @@ -854,10 +856,10 @@ package extension EnumVariable { } fileprivate extension EnumVariable { - /// The default name for identifier type root container. + /// The default prefix for identifier type coder variables. /// - /// This container is passed to each case for decoding. - static var typeContainer: TokenSyntax { "typeContainer" } + /// This prefix is used to generate coder variable names for each case. + static var typeCoderPrefix: TokenSyntax { Self.type } /// The default name for top-level root container. /// /// This container is passed to each case for decoding. diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index fe3f2c623..c109f105a 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -31,6 +31,7 @@ where /// - decl: The declaration to read data from. /// - context: The context in which to perform the macro expansion. /// - codingKeys: The map where `CodingKeys` maintained. + /// - memberInput: The input data for processing member declarations. /// - builder: The builder action to use to update member variables /// registration data. /// diff --git a/Sources/ProtocolGen/Generate.swift b/Sources/ProtocolGen/Generate.swift index 28cfb6b43..77d6c36eb 100644 --- a/Sources/ProtocolGen/Generate.swift +++ b/Sources/ProtocolGen/Generate.swift @@ -270,12 +270,14 @@ extension ProtocolGen { let dMethod = TypeCodingLocation.Method.decode(methodName: "decode") let dConform = TypeSyntax(stringLiteral: dMethod.protocol) let dLocation = TypeCodingLocation( - method: dMethod, conformance: dConform) + method: dMethod, conformance: dConform + ) let dGenerated = variable.decoding(in: context, from: dLocation) let eMethod = TypeCodingLocation.Method.encode let eConform = TypeSyntax(stringLiteral: eMethod.protocol) let eLocation = TypeCodingLocation( - method: eMethod, conformance: eConform) + method: eMethod, conformance: eConform + ) let eGenerated = variable.encoding(in: context, to: eLocation) let codingKeys = variable.codingKeys( confirmingTo: [dConform, eConform], in: context diff --git a/Tests/MetaCodableTests/CodableTests.swift b/Tests/MetaCodableTests/CodableTests.swift index 1f168dbd0..b57216cd8 100644 --- a/Tests/MetaCodableTests/CodableTests.swift +++ b/Tests/MetaCodableTests/CodableTests.swift @@ -78,6 +78,7 @@ struct CodableTests { } @Test + @available(*, deprecated, message: "Deprecated") func availableAttributeEncoding() throws { let original = SomeCodable(value: "deprecated_test") let encoded = try JSONEncoder().encode(original) @@ -87,6 +88,7 @@ struct CodableTests { } @Test + @available(*, deprecated, message: "Deprecated") func availableAttributeFromJSON() throws { let jsonStr = """ { @@ -564,7 +566,7 @@ struct CodableTests { #if canImport(MacroPlugin) @testable import MacroPlugin -let allMacros: [String: Macro.Type] = [ +let allMacros: [String: (Macro & Sendable).Type] = [ "CodedAt": MacroPlugin.CodedAt.self, "DecodedAt": MacroPlugin.DecodedAt.self, "EncodedAt": MacroPlugin.EncodedAt.self, @@ -577,8 +579,8 @@ let allMacros: [String: Macro.Type] = [ "IgnoreDecoding": MacroPlugin.IgnoreDecoding.self, "IgnoreEncoding": MacroPlugin.IgnoreEncoding.self, "Codable": MacroPlugin.Codable.self, - "ConformDecodable": ConformDecodable.self, - "ConformEncodable": ConformEncodable.self, + "ConformDecodable": MacroPlugin.ConformDecodable.self, + "ConformEncodable": MacroPlugin.ConformEncodable.self, "MemberInit": MacroPlugin.MemberInit.self, "CodingKeys": MacroPlugin.CodingKeys.self, "IgnoreCodingInitialized": MacroPlugin.IgnoreCodingInitialized.self, @@ -586,7 +588,7 @@ let allMacros: [String: Macro.Type] = [ "UnTagged": MacroPlugin.UnTagged.self, ] #else -let allMacros: [String: Macro.Type] = [ +let allMacros: [String: (Macro & Sendable).Type] = [ "CodedAt": CodedAt.self, "DecodedAt": DecodedAt.self, "EncodedAt": EncodedAt.self, diff --git a/Tests/MetaCodableTests/ConformCodableTests.swift b/Tests/MetaCodableTests/ConformCodableTests.swift index 6a8a793ce..c68962be8 100644 --- a/Tests/MetaCodableTests/ConformCodableTests.swift +++ b/Tests/MetaCodableTests/ConformCodableTests.swift @@ -29,15 +29,6 @@ struct ConformEncodableTests { } """, diagnostics: [ - .init( - id: ConformEncodable.misuseID, - message: - "@ConformEncodable can't be used in combination with @Codable", - line: 1, column: 1, - fixIts: [ - .init(message: "Remove @ConformEncodable attribute") - ] - ), .init( id: ConformEncodable.misuseID, message: @@ -97,24 +88,6 @@ struct ConformEncodableTests { .init(message: "Remove @ConformDecodable attribute") ] ), - .init( - id: ConformEncodable.misuseID, - message: - "@ConformEncodable can't be used in combination with @ConformDecodable", - line: 1, column: 1, - fixIts: [ - .init(message: "Remove @ConformEncodable attribute") - ] - ), - .init( - id: ConformDecodable.misuseID, - message: - "@ConformDecodable can't be used in combination with @ConformEncodable", - line: 2, column: 1, - fixIts: [ - .init(message: "Remove @ConformDecodable attribute") - ] - ), ] ) } diff --git a/Tests/MetaCodableTests/RawRepresentableEnumTests.swift b/Tests/MetaCodableTests/RawRepresentableEnumTests.swift new file mode 100644 index 000000000..e6e8418d5 --- /dev/null +++ b/Tests/MetaCodableTests/RawRepresentableEnumTests.swift @@ -0,0 +1,755 @@ +import Foundation +import HelperCoders +import MetaCodable +import Testing +import XCTest + +@testable import PluginCore + +struct RawRepresentableEnumTests { + struct StringRepresentation { + @Codable + enum Status: String, CaseIterable { + case active = "active" + case inactive = "inactive" + case pending = "pending" + } + + @Test + func expansion() throws { + assertMacroExpansion( + """ + @Codable + enum Status: String { + case active = "active" + case inactive = "inactive" + case pending = "pending" + } + """, + expandedSource: + """ + enum Status: String { + case active = "active" + case inactive = "inactive" + case pending = "pending" + } + + extension Status: Decodable { + init(from decoder: any Decoder) throws { + var typeDecoder: any Decoder + typeDecoder = decoder + let rawValue: RawValue? + do { + rawValue = try RawValue(from: typeDecoder) + } catch { + rawValue = nil + } + if let rawValue = rawValue, let selfValue = Self(rawValue: rawValue) { + self = selfValue + return + } + let typeString: String? + do { + typeString = try String??(from: typeDecoder) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "active": + self = .active + return + case "inactive": + self = .inactive + return + case "pending": + self = .pending + return + default: + break + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + } + + extension Status: Encodable { + func encode(to encoder: any Encoder) throws { + let typeEncoder = encoder + try rawValue.encode(to: typeEncoder) + } + } + """ + ) + } + + @Test(arguments: Status.allCases) + func decoding(status: Status) throws { + struct StatusRootType: Swift.Codable { + let type: StatusType + + struct StatusType: Swift.Codable { + let type: Status + } + } + + let jsonString = """ + {"type": {"type": "\(status.rawValue)"}} + """ + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode( + StatusRootType.self, from: jsonData) + + #expect(decoded.type.type == status) + } + + @Test(arguments: Status.allCases) + func allCasesRoundtrip(status: Status) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let encoded = try encoder.encode(status) + let decoded = try decoder.decode(Status.self, from: encoded) + #expect(decoded == status) + } + + @Test + func directDecoding() throws { + let jsonString = "\"active\"" + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode(Status.self, from: jsonData) + + #expect(decoded == .active) + } + } + + struct IntRepresentation { + @Codable + enum Priority: Int, CaseIterable { + case low = 1 + case medium = 2 + case high = 3 + } + + @Test + func expansion() throws { + assertMacroExpansion( + """ + @Codable + enum Priority: Int { + case low = 1 + case medium = 2 + case high = 3 + } + """, + expandedSource: + """ + enum Priority: Int { + case low = 1 + case medium = 2 + case high = 3 + } + + extension Priority: Decodable { + init(from decoder: any Decoder) throws { + var typeDecoder: any Decoder + typeDecoder = decoder + let rawValue: RawValue? + do { + rawValue = try RawValue(from: typeDecoder) + } catch { + rawValue = nil + } + if let rawValue = rawValue, let selfValue = Self(rawValue: rawValue) { + self = selfValue + return + } + let typeString: String? + do { + typeString = try String??(from: typeDecoder) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "low": + self = .low + return + case "medium": + self = .medium + return + case "high": + self = .high + return + default: + break + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + } + + extension Priority: Encodable { + func encode(to encoder: any Encoder) throws { + let typeEncoder = encoder + try rawValue.encode(to: typeEncoder) + } + } + """ + ) + } + + @Test(arguments: Priority.allCases) + func roundtrip(priority: Priority) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(priority) + let decoded = try decoder.decode(Priority.self, from: encoded) + + #expect(decoded == priority) + } + + @Test(arguments: Priority.allCases) + func encoding(priority: Priority) throws { + let encoder = JSONEncoder() + + let encoded = try encoder.encode(priority) + let jsonString = String(data: encoded, encoding: .utf8)! + + #expect(jsonString == "\(priority.rawValue)") + } + + @Test(arguments: Priority.allCases) + func decoding(priority: Priority) throws { + struct PriorityRootType: Swift.Codable { + let type: PriorityType + + struct PriorityType: Swift.Codable { + let type: Priority + } + } + + let jsonString = """ + {"type": {"type": \(priority.rawValue)}} + """ + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode( + PriorityRootType.self, from: jsonData) + + #expect(decoded.type.type == priority) + } + } + + struct WithCodedAt { + @Codable + @CodedAt("level") + enum Level: String, CaseIterable { + case beginner = "beginner" + case intermediate = "intermediate" + case advanced = "advanced" + } + + @Test + func expansion() throws { + assertMacroExpansion( + """ + @Codable + @CodedAt("status") + enum Status: String { + case active = "active" + case inactive = "inactive" + } + """, + expandedSource: + """ + enum Status: String { + case active = "active" + case inactive = "inactive" + } + + extension Status: Decodable { + init(from decoder: any Decoder) throws { + var typeContainer: KeyedDecodingContainer? + let container = try? decoder.container(keyedBy: CodingKeys.self) + if let container = container { + typeContainer = container + } else { + typeContainer = nil + } + if let typeContainer = typeContainer { + let rawValue: RawValue? + do { + rawValue = try typeContainer.decode(RawValue.self, forKey: CodingKeys.type) + } catch { + rawValue = nil + } + if let rawValue = rawValue, let selfValue = Self(rawValue: rawValue) { + self = selfValue + return + } + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "active": + self = .active + return + case "inactive": + self = .inactive + return + default: + break + } + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + } + + extension Status: Encodable { + func encode(to encoder: any Encoder) throws { + let container = encoder.container(keyedBy: CodingKeys.self) + var typeContainer = container + try typeContainer.encode(rawValue, forKey: CodingKeys.type) + } + } + + extension Status { + enum CodingKeys: String, CodingKey { + case type = "status" + } + } + """ + ) + } + + @Test(arguments: Level.allCases) + func roundtrip(level: Level) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(level) + let decoded = try decoder.decode(Level.self, from: encoded) + + #expect(decoded == level) + } + + @Test(arguments: Level.allCases) + func encoding(level: Level) throws { + let encoder = JSONEncoder() + + let encoded = try encoder.encode(level) + let jsonString = String(data: encoded, encoding: .utf8)! + + #expect(jsonString == "{\"level\":\"\(level.rawValue)\"}") + } + + @Test(arguments: Level.allCases) + func decoding(level: Level) throws { + let jsonString = """ + {"level": "\(level.rawValue)"} + """ + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode(Level.self, from: jsonData) + + #expect(decoded == level) + } + } + + struct WithCodedAs { + @Codable + enum Command: String { + @CodedAs("load", 12, true, 3.14, 15..<20, (-0.8)...) + case load + @CodedAs("store", 30, false, 7.15, 35...40, ..<(-1.5)) + case store + } + + @Codable + enum HTTPMethod: String, CaseIterable { + @CodedAs("GET") + case get = "get" + @CodedAs("POST") + case post = "post" + @CodedAs("PUT") + case put = "put" + @CodedAs("DELETE") + case delete = "delete" + } + + @Codable + enum ResponseCode: Int, CaseIterable { + @CodedAs(200, 201, 202) + case success = 200 + @CodedAs(400, 401, 403, 404) + case clientError = 400 + @CodedAs(500, 501, 502, 503) + case serverError = 500 + } + + @Codable + enum LogLevel: String, CaseIterable { + @CodedAs("DEBUG", "TRACE") + case debug = "debug" + @CodedAs("INFO", "INFORMATION") + case info = "info" + @CodedAs("WARN", "WARNING") + case warning = "warning" + @CodedAs("ERROR", "FATAL") + case error = "error" + } + + // MARK: - Basic CodedAs Tests + + @Test(arguments: HTTPMethod.allCases) + func httpMethodRoundtrip(method: HTTPMethod) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(method) + let decoded = try decoder.decode(HTTPMethod.self, from: encoded) + + #expect(decoded == method) + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeRoundtrip(code: ResponseCode) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(code) + let decoded = try decoder.decode(ResponseCode.self, from: encoded) + + #expect(decoded == code) + } + @Test(arguments: LogLevel.allCases) + func logLevelRoundtrip(level: LogLevel) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(level) + let decoded = try decoder.decode(LogLevel.self, from: encoded) + + #expect(decoded == level) + } + + @Test(arguments: HTTPMethod.allCases) + func httpMethodEncoding(method: HTTPMethod) throws { + let encoder = JSONEncoder() + + let encoded = try encoder.encode(method) + let jsonString = String(data: encoded, encoding: .utf8)! + + // Should encode using raw value, not CodedAs value + #expect(jsonString == "\"\(method.rawValue)\"") + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeEncoding(code: ResponseCode) throws { + let encoder = JSONEncoder() + + let encoded = try encoder.encode(code) + let jsonString = String(data: encoded, encoding: .utf8)! + + // Should encode using raw value + #expect(jsonString == "\(code.rawValue)") + } + + // MARK: - CodedAs Alternative Values Decoding + + @Test( + arguments: [ + ("\"GET\"", .get), + ("\"POST\"", .post), + ("\"PUT\"", .put), + ("\"DELETE\"", .delete), + ] as [(String, HTTPMethod)]) + func httpMethodCodedAsDecoding(value: String, method: HTTPMethod) throws + { + let decoder = JSONDecoder() + let jsonData = value.data(using: .utf8)! + let decoded = try decoder.decode(HTTPMethod.self, from: jsonData) + #expect(decoded == method) + } + + @Test( + arguments: [ + ("200", .success), + ("201", .success), + ("202", .success), + ("400", .clientError), + ("401", .clientError), + ("403", .clientError), + ("404", .clientError), + ("500", .serverError), + ("501", .serverError), + ("502", .serverError), + ("503", .serverError), + ] as [(String, ResponseCode)]) + func responseCodeMultipleValuesDecoding( + value: String, code: ResponseCode + ) throws { + let decoder = JSONDecoder() + let jsonData = value.data(using: .utf8)! + let decoded = try decoder.decode(ResponseCode.self, from: jsonData) + #expect(decoded == code) + } + + @Test( + arguments: [ + ("\"DEBUG\"", .debug), + ("\"TRACE\"", .debug), + ("\"INFO\"", .info), + ("\"INFORMATION\"", .info), + ("\"WARN\"", .warning), + ("\"WARNING\"", .warning), + ("\"ERROR\"", .error), + ("\"FATAL\"", .error), + ] as [(String, LogLevel)]) + func logLevelMultipleStringValuesDecoding( + value: String, level: LogLevel + ) throws { + let decoder = JSONDecoder() + let jsonData = value.data(using: .utf8)! + let decoded = try decoder.decode(LogLevel.self, from: jsonData) + #expect(decoded == level) + } + + // MARK: - Raw Value Fallback Tests + + @Test( + arguments: [ + ("\"get\"", .get), // Raw value instead of CodedAs "GET" + ("\"post\"", .post), // Raw value instead of CodedAs "POST" + ("\"put\"", .put), // Raw value instead of CodedAs "PUT" + ("\"delete\"", .delete), // Raw value instead of CodedAs "DELETE" + ] as [(String, HTTPMethod)]) + func httpMethodRawValueFallback( + value: String, method: HTTPMethod + ) throws { + let decoder = JSONDecoder() + let jsonData = value.data(using: .utf8)! + let decoded = try decoder.decode(HTTPMethod.self, from: jsonData) + #expect(decoded == method) + } + + @Test( + arguments: [ + ("200", .success), // Raw value instead of CodedAs values + ("400", .clientError), // Raw value instead of CodedAs values + ("500", .serverError), // Raw value instead of CodedAs values + ] as [(String, ResponseCode)]) + func responseCodeRawValueFallback( + value: String, code: ResponseCode + ) throws { + let jsonData = value.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode(ResponseCode.self, from: jsonData) + #expect(decoded == code) + } + + @Test( + arguments: [ + ("\"debug\"", .debug), // Raw value instead of CodedAs values + ("\"info\"", .info), // Raw value instead of CodedAs values + ("\"warning\"", .warning), // Raw value instead of CodedAs values + ("\"error\"", .error), // Raw value instead of CodedAs values + ] as [(String, LogLevel)]) + func logLevelRawValueFallback(value: String, level: LogLevel) throws { + let decoder = JSONDecoder() + let jsonData = value.data(using: .utf8)! + let decoded = try decoder.decode(LogLevel.self, from: jsonData) + #expect(decoded == level) + } + + // MARK: - Complex CodedAs Values Tests + + @Test( + arguments: [ + ("\"load\"", .load), // String value + ("12", .load), // Int value + ("true", .load), // Bool value + ("3.14", .load), // Double value + ("\"store\"", .store), // String value + ("30", .store), // Int value + ("false", .store), // Bool value + ("7.15", .load), // Double value - matches (-0.8)... range for .load + ("15", .load), // In range 15..<20 + ("16", .load), // In range 15..<20 + ("19", .load), // In range 15..<20 + ("35", .store), // In range 35...40 + ("38", .store), // In range 35...40 + ("40", .store), // In range 35...40 + ("-0.8", .load), // Exactly -0.8 from (-0.8)... + ("0.0", .load), // Greater than -0.8 + ("10.5", .load), // Greater than -0.8 + ("-2.0", .store), // Less than -1.5 from ..<(-1.5) + ("-3.7", .store), // Less than -1.5 + ] as [(String, Command)]) + func commandComplexCodedAsDecoding( + value: String, command: Command + ) throws { + let decoder = JSONDecoder() + let jsonData = value.data(using: .utf8)! + let decoded = try decoder.decode(Command.self, from: jsonData) + #expect(decoded == command) + } + + // MARK: - Error Cases + + @Test + func invalidCodedAsValueDecoding() throws { + let jsonString = "\"INVALID\"" + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + #expect(throws: DecodingError.self) { + try decoder.decode(HTTPMethod.self, from: jsonData) + } + } + + @Test + func invalidResponseCodeDecoding() throws { + let jsonString = "999" // Not in any CodedAs range + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + #expect(throws: DecodingError.self) { + try decoder.decode(ResponseCode.self, from: jsonData) + } + } + + // MARK: - Array and Collection Tests + + @Test + func httpMethodArrayDecoding() throws { + // Test that we can decode arrays with mixed CodedAs and raw values + let jsonString = """ + ["GET", "post", "PUT", "delete"] + """ + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode([HTTPMethod].self, from: jsonData) + let expected: [HTTPMethod] = [ + HTTPMethod.get, HTTPMethod.post, HTTPMethod.put, + HTTPMethod.delete, + ] + + #expect(decoded == expected) + } + + @Test + func responseCodeArrayDecoding() throws { + // Test decoding array of response codes with CodedAs values + let jsonString = """ + [200, 201, 400, 404, 500, 503] + """ + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode( + [ResponseCode].self, from: jsonData) + let expected: [ResponseCode] = [ + ResponseCode.success, // 200 + ResponseCode.success, // 201 + ResponseCode.clientError, // 400 + ResponseCode.clientError, // 404 + ResponseCode.serverError, // 500 + ResponseCode.serverError, // 503 + ] + + #expect(decoded == expected) + } + } + + struct WithCodedBy { + @Codable + @CodedBy(ValueCoder()) + enum ResponseCode: Int, CaseIterable { + case success = 200 + case clientError = 400 + case serverError = 500 + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeRoundtrip(code: ResponseCode) throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encoded = try encoder.encode(code) + let decoded = try decoder.decode(ResponseCode.self, from: encoded) + + #expect(decoded == code) + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeEncoding(code: ResponseCode) throws { + let encoder = JSONEncoder() + + let encoded = try encoder.encode(code) + let jsonString = String(data: encoded, encoding: .utf8)! + + // Should encode using raw value + #expect(jsonString == "\(code.rawValue)") + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeDecoding(code: ResponseCode) throws { + let jsonString = "\(code.rawValue)" + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode(ResponseCode.self, from: jsonData) + + #expect(decoded == code) + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeStringDecoding(code: ResponseCode) throws { + let jsonString = "\"\(code.rawValue)\"" + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode(ResponseCode.self, from: jsonData) + + #expect(decoded == code) + } + + @Test(arguments: ResponseCode.allCases) + func responseCodeDoubleDecoding(code: ResponseCode) throws { + let jsonString = "\(code.rawValue).00" + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let decoded = try decoder.decode(ResponseCode.self, from: jsonData) + + #expect(decoded == code) + } + } +}