diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 000000000..7a07fd37a --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - Sources/MetaCodable/CodedAs/AnyCodableLiteral.swift diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7d3f9f940..9446b9336 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,7 +38,7 @@ jobs: { "os": "macos-15", "language": "swift", - "swift": "latest" + "swift": "6.1.2" } ] } @@ -180,7 +180,7 @@ jobs: }, { "os": "macos-15", - "swift": "latest" + "swift": "6.1.2" } ] } diff --git a/Sources/MetaCodable/Codable/Codable.swift b/Sources/MetaCodable/Codable/Codable.swift index d5b7e9e1d..56c47d5a5 100644 --- a/Sources/MetaCodable/Codable/Codable.swift +++ b/Sources/MetaCodable/Codable/Codable.swift @@ -13,12 +13,13 @@ /// coding key path, with variable name as coding key. /// * Use ``CodedAt(_:)`` with no path arguments, when type is composition /// of multiple `Codable` types. -/// * Use ``CodedAs(_:_:)`` to provide additional coding key values where -/// field value can appear. +/// * Use ``CodedAs(_:_:)-8wdaz`` to provide additional coding key values +/// where field value can appear. /// * Use ``CodedBy(_:)`` to provide custom decoding/encoding behavior for /// `Codable` types or implement decoding/encoding for non-`Codable` types. /// * Use ``Default(_:)`` to provide default value when decoding fails. -/// * Use ``CodedAs(_:_:)`` to provide custom values for enum cases. +/// * Use ``CodedAs(_:_:)-8wdaz`` and ``CodedAs(_:_:)-4n3ze`` +/// to provide custom values for enum cases. /// * Use ``CodedAt(_:)`` to provide enum-case/protocol identifier tag path. /// * Use ``CodedAs()`` to provide enum-case/protocol identifier tag type. /// * Use ``ContentAt(_:_:)`` to provided enum-case/protocol content path. diff --git a/Sources/MetaCodable/Codable/Decodable.swift b/Sources/MetaCodable/Codable/Decodable.swift index db969dbf5..337ee6c6f 100644 --- a/Sources/MetaCodable/Codable/Decodable.swift +++ b/Sources/MetaCodable/Codable/Decodable.swift @@ -16,8 +16,11 @@ /// of multiple `Decodable` types. /// * Use ``CodedBy(_:)`` to provide custom decoding behavior for /// `Decodable` types or implement decoding for non-`Decodable` types. +/// * Use ``CodedAs(_:_:)-8wdaz`` to provide additional coding key values +/// where field value can appear. /// * Use ``Default(_:)`` to provide default value when decoding fails. -/// * Use ``CodedAs(_:_:)`` to provide custom values for enum cases. +/// * Use ``CodedAs(_:_:)-8wdaz`` and ``CodedAs(_:_:)-4n3ze`` +/// to provide custom values for enum cases. /// * Use ``CodedAt(_:)`` to provide enum-case/protocol identifier tag path. /// * Use ``CodedAs()`` to provide enum-case/protocol identifier tag type. /// * Use ``ContentAt(_:_:)`` to provided enum-case/protocol content path. diff --git a/Sources/MetaCodable/Codable/Encodable.swift b/Sources/MetaCodable/Codable/Encodable.swift index 4a6c6aaed..34c5613a2 100644 --- a/Sources/MetaCodable/Codable/Encodable.swift +++ b/Sources/MetaCodable/Codable/Encodable.swift @@ -14,11 +14,12 @@ /// coding key path, with variable name as coding key. /// * Use ``CodedAt(_:)`` with no path arguments, when type is composition /// of multiple `Encodable` types. -/// * Use ``CodedAs(_:_:)`` to provide additional coding key values where -/// field value can appear. +/// * Use ``CodedAs(_:_:)-8wdaz`` to provide additional coding key values +/// where field value can appear. /// * Use ``CodedBy(_:)`` to provide custom encoding behavior for /// `Encodable` types or implement encoding for non-`Encodable` types. -/// * Use ``CodedAs(_:_:)`` to provide custom values for enum cases. +/// * Use ``CodedAs(_:_:)-8wdaz`` and ``CodedAs(_:_:)-4n3ze`` +/// to provide custom values for enum cases. /// * Use ``CodedAt(_:)`` to provide enum-case/protocol identifier tag path. /// * Use ``CodedAs()`` to provide enum-case/protocol identifier tag type. /// * Use ``ContentAt(_:_:)`` to provided enum-case/protocol content path. diff --git a/Sources/MetaCodable/CodedAs/AnyCodableLiteral.swift b/Sources/MetaCodable/CodedAs/AnyCodableLiteral.swift new file mode 100644 index 000000000..ac1cf2cfa --- /dev/null +++ b/Sources/MetaCodable/CodedAs/AnyCodableLiteral.swift @@ -0,0 +1,304 @@ +/// A type that conforms to all `ExpressibleBy*Literal` protocols for use in macro contexts. +/// +/// This type is designed to be used only during macro expansion and compile-time processing. +/// All runtime implementations will `fatalError` to prevent accidental usage at runtime. +/// +/// ## Usage +/// This type allows macros to accept literal values of any type while maintaining type safety +/// during compilation. The actual values are processed at compile-time and never instantiated +/// at runtime. +/// +/// ## Warning +/// **DO NOT USE AT RUNTIME** - All initializers and methods will crash with `fatalError`. +public struct AnyCodableLiteral { + /// Private initializer to prevent direct instantiation + private init() { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByBooleanLiteral +extension AnyCodableLiteral: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByIntegerLiteral +extension AnyCodableLiteral: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByFloatLiteral +extension AnyCodableLiteral: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByStringLiteral +extension AnyCodableLiteral: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByExtendedGraphemeClusterLiteral +extension AnyCodableLiteral: ExpressibleByExtendedGraphemeClusterLiteral { + public init(extendedGraphemeClusterLiteral value: String) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByUnicodeScalarLiteral +extension AnyCodableLiteral: ExpressibleByUnicodeScalarLiteral { + public init(unicodeScalarLiteral value: String) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - ExpressibleByStringInterpolation +extension AnyCodableLiteral: ExpressibleByStringInterpolation { + public struct StringInterpolation: StringInterpolationProtocol { + public init(literalCapacity: Int, interpolationCount: Int) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public mutating func appendLiteral(_ literal: String) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public mutating func appendInterpolation(_ value: T) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + } + + public init(stringInterpolation: StringInterpolation) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - Additional Conformances for Completeness +extension AnyCodableLiteral: Hashable { + public func hash(into hasher: inout Hasher) { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: Equatable { + public static func == ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> Bool { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: CustomStringConvertible { + public var description: String { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: CustomDebugStringConvertible { + public var debugDescription: String { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - Comparable and Numeric Support +extension AnyCodableLiteral: Comparable { + public static func < ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> Bool { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: AdditiveArithmetic { + public static var zero: AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func + ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func - ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: Numeric { + public typealias Magnitude = AnyCodableLiteral + + public var magnitude: AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func * ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func *= (lhs: inout AnyCodableLiteral, rhs: AnyCodableLiteral) + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public init?(exactly source: T) where T: BinaryInteger { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: SignedNumeric { + public static func += (lhs: inout AnyCodableLiteral, rhs: AnyCodableLiteral) + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func -= (lhs: inout AnyCodableLiteral, rhs: AnyCodableLiteral) + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func / ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static func /= (lhs: inout AnyCodableLiteral, rhs: AnyCodableLiteral) + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public static prefix func - ( + operand: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +extension AnyCodableLiteral: Strideable { + public typealias Stride = AnyCodableLiteral + + public func distance(to other: AnyCodableLiteral) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + public func advanced(by n: AnyCodableLiteral) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} + +// MARK: - Range Operator Implementations +extension AnyCodableLiteral { + /// Closed range operator (...) + public static func ... ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + /// Half-open range operator (..<) + public static func ..< ( + lhs: AnyCodableLiteral, rhs: AnyCodableLiteral + ) -> AnyCodableLiteral { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + /// One-sided range operator (...) - postfix + public static postfix func ... (lhs: AnyCodableLiteral) -> AnyCodableLiteral + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + /// One-sided range operator (...) - prefix + public static prefix func ... (rhs: AnyCodableLiteral) -> AnyCodableLiteral + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } + + /// One-sided range operator (..<) - prefix + public static prefix func ..< (rhs: AnyCodableLiteral) -> AnyCodableLiteral + { + fatalError( + "AnyCodableLiteral is not for runtime usage - compile-time only" + ) + } +} diff --git a/Sources/MetaCodable/CodedAs/CodedAs+Dynamic.swift b/Sources/MetaCodable/CodedAs/CodedAs+Dynamic.swift new file mode 100644 index 000000000..a671bac96 --- /dev/null +++ b/Sources/MetaCodable/CodedAs/CodedAs+Dynamic.swift @@ -0,0 +1,55 @@ +/// Dynamic variation of CodedAs macro that accepts AnyCodableLiteral type variable arguments. +/// +/// This macro provides a more flexible version of ``CodedAs(_:_:)-8wdaz`` that can +/// accept literal values of any type through the ``AnyCodableLiteral`` type. This +/// enables compile-time processing of mixed literal types while maintaining type safety. +/// +/// ## Usage +/// +/// This macro can be used in the same contexts as the regular ``CodedAs(_:_:)-8wdaz`` +/// macro but with the added flexibility of accepting different literal types: +/// +/// ```swift +/// @Codable +/// enum MixedCommand { +/// @CodedAs("load", 1, true) +/// case load(key: String) +/// +/// @CodedAs("store", 2, false) +/// case store(key: String, value: Int) +/// } +/// ``` +/// +/// The macro processes the literal values at compile-time and generates appropriate +/// code for encoding and decoding operations. +/// +/// ## Type Safety +/// +/// While this macro accepts ``AnyCodableLiteral`` arguments, the actual type checking +/// and code generation happens at compile-time through macro expansion. The runtime +/// behavior is identical to the regular ``CodedAs(_:_:)-8wdaz`` macro. +/// +/// ## Compile-Time Processing +/// +/// The ``AnyCodableLiteral`` values are processed during macro expansion: +/// - Literal values are extracted and converted to appropriate types +/// - Type consistency is validated at compile-time +/// - Generated code uses concrete types, not ``AnyCodableLiteral`` +/// +/// - Note: This macro on its own only validates if attached declaration +/// is a variable declaration. ``Codable(commonStrategies:)`` macro uses this macro +/// when generating final implementations for enum types. +/// +/// - Important: The ``AnyCodableLiteral`` arguments are processed at compile-time +/// and never instantiated at runtime. All runtime safety is maintained through +/// the macro expansion process. +/// +/// - Important: This attribute must be used combined with ``Codable(commonStrategies:)`` +/// and ``CodedAt(_:)`` in the same way as the regular ``CodedAs(_:_:)-8wdaz`` macro +/// for enum types. +/// +/// - Important: This attribute must not be combined with ``CodedAs()`` macro. +@attached(peer) +@available(swift 5.9) +public macro CodedAs(_ values: AnyCodableLiteral, _: AnyCodableLiteral...) = + #externalMacro(module: "MacroPlugin", type: "CodedAs") diff --git a/Sources/MetaCodable/CodedAs.swift b/Sources/MetaCodable/CodedAs/CodedAs.swift similarity index 97% rename from Sources/MetaCodable/CodedAs.swift rename to Sources/MetaCodable/CodedAs/CodedAs.swift index 09269eed3..fecc2dcaa 100644 --- a/Sources/MetaCodable/CodedAs.swift +++ b/Sources/MetaCodable/CodedAs/CodedAs.swift @@ -106,11 +106,11 @@ public macro CodedAs(_ values: T, _: T...) = /// is a variable declaration. ``Codable(commonStrategies:)`` macro uses this macro /// when generating final implementations. /// -/// - Important: For each case ``CodedAs(_:_:)`` macro with values +/// - Important: For each case ``CodedAs(_:_:)-8wdaz`` macro with values /// of the type here should be provided, otherwise case name as `String` /// will be used for comparison. If the type here conforms to /// `ExpressibleByStringLiteral` and can be represented by case name -/// as `String` literal then no need to provide values with ``CodedAs(_:_:)``. +/// as `String` literal then no need to provide values with ``CodedAs(_:_:)-8wdaz``. /// /// - Important: When using with protocols ``DynamicCodable/IdentifierValue`` /// type must be same as the type defined with this macro, in absence of this macro diff --git a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md index 365fdcac8..bc4ced945 100644 --- a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md +++ b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md @@ -16,11 +16,11 @@ Supercharge `Swift`'s `Codable` implementations with macros. - Allows custom `CodingKey` value declaration per variable with ``CodedAt(_:)`` passing single argument, instead of requiring you to write all the `CodingKey` values. - Allows to create flattened model for nested `CodingKey` values with ``CodedAt(_:)`` and ``CodedIn(_:)``. - Allows to create composition of multiple `Codable` types with ``CodedAt(_:)`` passing no arguments. -- Allows to read data from additional fallback `CodingKey`s provided with ``CodedAs(_:_:)``. +- Allows to read data from additional fallback `CodingKey`s provided with ``CodedAs(_:_:)-8wdaz``. - Allows to provide default value in case of decoding failures with ``Default(_:)``, or only in case of failures when missing value with ``Default(ifMissing:)``. Different default values can also be used for value missing and other errors respectively with ``Default(ifMissing:forErrors:)``. - Allows to create custom decoding/encoding strategies with ``HelperCoder`` and using them with ``CodedBy(_:)``, ``CodedBy(_:properties:)`` or others. i.e. ``LossySequenceCoder`` etc. - Allows applying common strategies like `ValueCoder` to all properties of a type through the ``Codable(commonStrategies:)`` parameter, reducing the need for repetitive property annotations. -- Allows specifying different case values with ``CodedAs(_:_:)`` and case value/protocol type identifier type different from `String` with ``CodedAs()``. +- Allows specifying different case values with ``CodedAs(_:_:)-8wdaz`` and case value/protocol type identifier type different from `String` with ``CodedAs()`` or arbitrary primitive values with ``CodedAs(_:_:)-4n3ze``. - Allows specifying enum-case/protocol type identifier path with ``CodedAt(_:)`` and case content path with ``ContentAt(_:_:)``. - Allows decoding/encoding enums that lack distinct identifiers for each case data with ``UnTagged()``. - Allows to ignore specific properties/cases from decoding/encoding with ``IgnoreCoding()``, ``IgnoreDecoding()`` and ``IgnoreEncoding()``. Allows to ignore encoding based on custom conditions with ``IgnoreEncoding(if:)-1iuvv`` and ``IgnoreEncoding(if:)-7toka``. @@ -81,7 +81,8 @@ Supercharge `Swift`'s `Codable` implementations with macros. - ``EncodedAt(_:)`` - ``CodedIn(_:)`` - ``CodedAs()`` -- ``CodedAs(_:_:)`` +- ``CodedAs(_:_:)-8wdaz`` +- ``CodedAs(_:_:)-4n3ze`` - ``ContentAt(_:_:)`` - ``UnTagged()`` - ``CodingKeys(_:)`` diff --git a/Sources/MetaCodable/MetaCodable.docc/Tutorials/Enum/Enum.tutorial b/Sources/MetaCodable/MetaCodable.docc/Tutorials/Enum/Enum.tutorial index b6055c7a0..911edf761 100644 --- a/Sources/MetaCodable/MetaCodable.docc/Tutorials/Enum/Enum.tutorial +++ b/Sources/MetaCodable/MetaCodable.docc/Tutorials/Enum/Enum.tutorial @@ -52,7 +52,7 @@ } @Step { - Variation tag values can be declared explicitly by attaching ``CodedAs(_:_:)`` macro to the case. + Variation tag values can be declared explicitly by attaching ``CodedAs(_:_:)-8wdaz`` macro to the case. @Code(name: "Command.swift", file: Command-04.swift) { @Image(source: Command-01.png, alt: "Command variations JSON representation") @@ -128,7 +128,7 @@ } @Step { - If tag values differ from case name, specify explicitly with ``CodedAs(_:_:)`` macro attached to the case. + If tag values differ from case name, specify explicitly with ``CodedAs(_:_:)-8wdaz`` and ``CodedAs(_:_:)-4n3ze`` macro attached to the case. @Code(name: "Command.swift", file: Command-10.swift) { @Image(source: Command-08.png, alt: "Command variations JSON representation") diff --git a/Sources/MetaCodable/MetaCodable.docc/Tutorials/Essential/Essential.tutorial b/Sources/MetaCodable/MetaCodable.docc/Tutorials/Essential/Essential.tutorial index 6f0471874..2c37a8ab3 100644 --- a/Sources/MetaCodable/MetaCodable.docc/Tutorials/Essential/Essential.tutorial +++ b/Sources/MetaCodable/MetaCodable.docc/Tutorials/Essential/Essential.tutorial @@ -75,7 +75,7 @@ } @Step { - Use ``CodedAs(_:_:)`` macro to specify multiple possible `CodingKey`s for a single field. + Use ``CodedAs(_:_:)-8wdaz`` macro to specify multiple possible `CodingKey`s for a single field. @Code(name: Post.swift, file: Post-06.swift) { @Image(source: Post-06.png, alt: "Basic post JSON representation with author and creation time") diff --git a/Sources/PluginCore/Attributes/Attribute.swift b/Sources/PluginCore/Attributes/Attribute.swift index 8a2c57795..06be8467a 100644 --- a/Sources/PluginCore/Attributes/Attribute.swift +++ b/Sources/PluginCore/Attributes/Attribute.swift @@ -45,19 +45,23 @@ extension Attribute { /// The lowercased-name of this attribute. /// /// This is used for attribute related diagnostics. - var id: String { name.lowercased() } + static var id: String { Self.name.lowercased() } + /// The misuse id of this attribute. + /// + /// This is used for attribute related diagnostics. + static var misuseId: String { "\(Self.id)-misuse" } /// Message id for misuse of this attribute. /// /// This attribute can must be removed or its usage condition /// must be satisfied. - var misuseMessageID: MessageID { messageID("\(id)-misuse") } + var misuseMessageID: MessageID { Self.messageID(Self.misuseId) } /// Creates a new message id in current package domain. /// /// - Parameters id: The message id. /// - Returns: Created message id. - func messageID(_ id: String) -> MessageID { + static func messageID(_ id: String) -> MessageID { .init( domain: "com.SwiftyLab.MetaCodable", id: id diff --git a/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift b/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift index 2cc08d0fb..4fcad1d29 100644 --- a/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift +++ b/Sources/PluginCore/Attributes/KeyPath/CodedAt.swift @@ -74,27 +74,38 @@ package struct CodedAt: PropertyAttribute { extension Registration where Var == ExternallyTaggedEnumSwitcher, Decl == EnumDeclSyntax { - /// Checks if enum declares internal tagging. + /// Checks if enum declares internal tagging and creates appropriate switcher. /// - /// Checks if identifier path provided with `CodedAt` macro, - /// identifier type is used if `CodedAs` macro provided falling back to - /// the `fallbackType` passed. + /// Examines the enum declaration for `CodedAt`, `DecodedAt`, and `EncodedAt` + /// attributes to determine if internal tagging should be used. Internal tagging + /// occurs when these attributes specify non-empty key paths that indicate where + /// the type identifier should be located within the encoded structure. + /// + /// 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`. /// /// - Parameters: - /// - encodeContainer: The container for case variation encoding. - /// - identifier: The identifier name to use. - /// - fallbackType: The fallback identifier type to use if not provided. - /// - codingKeys: The map where `CodingKeys` maintained. - /// - context: The context in which to perform the macro expansion. - /// - variableBuilder: The builder action for building identifier. - /// - switcherBuilder: The further builder action if check succeeds. + /// - container: The container token for case variation encoding/decoding. + /// - 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 + /// generated decoding switch cases. When `true`, each case will include a + /// `return` statement after assignment for early exit. + /// - context: The macro expansion context for diagnostics and code generation. + /// - variableBuilder: Builder function for creating the identifier variable + /// from the basic property variable registration. + /// - switcherBuilder: Builder function for creating the final switcher from + /// the internally tagged enum switcher registration. /// - /// - Returns: Type-erased variable registration applying builders - /// if succeeds, otherwise current variable type-erased registration. + /// - Returns: A type-erased enum switcher registration. If internal tagging + /// is detected (non-empty decode and encode paths), returns the result of + /// applying both builder functions. Otherwise, returns the current registration + /// with a type-erased variable, indicating external tagging should be used. func checkForInternalTagging( - encodeContainer: TokenSyntax, - identifier: TokenSyntax, fallbackType: TypeSyntax, - codingKeys: CodingKeysMap, context: some MacroExpansionContext, + container: TokenSyntax, identifier: TokenSyntax, + codingKeys: CodingKeysMap, forceDecodingReturn: Bool, + context: some MacroExpansionContext, variableBuilder: @escaping ( PathRegistration ) -> PathRegistration, @@ -116,10 +127,13 @@ where Var == ExternallyTaggedEnumSwitcher, Decl == EnumDeclSyntax { let typeAttr = CodedAs(from: decl) let keyPath = PathKey(decoding: decodedPath, encoding: encodedPath) let variable = InternallyTaggedEnumSwitcher( - encodeContainer: encodeContainer, identifier: identifier, - identifierType: typeAttr?.type ?? fallbackType, + identifierDecodeContainer: container, + identifierEncodeContainer: container, + identifier: identifier, identifierType: typeAttr?.type, keyPath: keyPath, codingKeys: codingKeys, - decl: decl, context: context, variableBuilder: variableBuilder + decl: decl, context: context, + forceDecodingReturn: forceDecodingReturn, + variableBuilder: variableBuilder ) let newRegistration = switcherBuilder(self.updating(with: variable)) diff --git a/Sources/PluginCore/Diagnostics/MetaCodableMessage.swift b/Sources/PluginCore/Diagnostics/MetaCodableMessage.swift index 9d97b3e9e..b53872a18 100644 --- a/Sources/PluginCore/Diagnostics/MetaCodableMessage.swift +++ b/Sources/PluginCore/Diagnostics/MetaCodableMessage.swift @@ -103,6 +103,39 @@ struct MetaCodableMessage: Error, DiagnosticMessage, FixItMessage { } } +/// A diagnostic message for macro expansion errors in MetaCodable. +/// +/// This struct represents an error or warning that occurs during macro expansion, +/// providing contextual information about what went wrong and where it occurred. +/// It conforms to both `Error` and `DiagnosticMessage` to integrate with Swift's +/// macro diagnostic system. +/// +/// - Parameters: +/// - Attr: The attribute type that caused the error, which must conform to `Attribute` +struct MetaCodableMacroExpansionErrorMessage: Error, DiagnosticMessage +where Attr: Attribute { + /// The human-readable error message describing what went wrong + let message: String + /// The severity level of the diagnostic (error, warning, note, etc.) + let severity: DiagnosticSeverity + + /// The unique diagnostic identifier based on the attribute type + var diagnosticID: MessageID { + Attr.messageID(Attr.misuseId) + } + + /// Creates a new macro expansion error message. + /// + /// - Parameters: + /// - message: A descriptive error message explaining the issue + /// - severity: The diagnostic severity level (defaults to `.error`) + init(_ message: String, severity: DiagnosticSeverity = .error) { + self.severity = severity + self.message = message + } +} + #if !canImport(SwiftSyntax600) extension MetaCodableMessage: @unchecked Sendable {} +extension MetaCodableMacroExpansionErrorMessage: @unchecked Sendable {} #endif diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index 08d499691..0671aeb19 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -1,4 +1,5 @@ import SwiftSyntax +import SwiftSyntaxBuilder import SwiftSyntaxMacros /// A type of `EnumSwitcherVariable` that can have adjacent tagging. @@ -147,18 +148,145 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { contentAt decoder: TokenSyntax ) -> CodeBlockItemListSyntax { let coder = location.coder + let container = self.variable.decodeContainer + let containerType = self.identifierContainerType() + let idetifierDecodingSyntax = + EnumVariable.CaseValue.TypeOf.all( + inheritedType: identifierType + ).compactMap { type in + let identifier: TokenSyntax = + "\(self.identifier)\(type.nameSuffix())" + let switchExpr = self.decodeSwitchExpression( + over: .init(syntax: "\(identifier)", type: type), + at: location, from: decoder, + in: context, withDefaultCase: true, + forceDecodingReturn: forceDecodingReturn + ) { _ in "" } + + 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 \(identifier): \(type.syntax(optional: identifierType == nil))" + variable.decoding( + in: context, + from: .container( + container, key: decodingKey, method: nil) + ) + + switch variable.decodingFallback { + case .ifMissing where identifierType == nil, + .onlyIfMissing where identifierType == nil: + try! IfExprSyntax( + """ + if let \(identifier) = \(identifier)) + """ + ) { + switchExpr + } + default: + switchExpr + } + } + } as [CodeBlockItemListSyntax] + return CodeBlockItemListSyntax { - "let \(identifier): \(identifierType)" - decodingNode.decoding( - in: context, from: .withCoder(coder, keyType: location.keyType) - ).combined() - self.decodeSwitchExpression( - over: "\(identifier)", at: location, from: decoder, - in: context, withDefaultCase: true - ) { _ in "" } + if !idetifierDecodingSyntax.isEmpty { + "var \(container): \(containerType)" + decodingNode.decoding( + in: context, + from: .withCoder(coder, keyType: location.keyType) + ).combined() + + if containerType.isOptionalTypeSyntax { + let topContainerOptional = decodingNode.children + .flatMap(\.value.linkedVariables) + .allSatisfy { variable in + switch variable.decodingFallback { + case .ifMissing: + return true + default: + return false + } + } + + let header: SyntaxNodeString = + topContainerOptional + ? "if let \(container) = \(container), let \(location.container) = \(location.container)" + : "if let \(container) = \(container)" + try! IfExprSyntax(header) { + for syntax in idetifierDecodingSyntax { + syntax + } + } + } else { + for syntax in idetifierDecodingSyntax { + syntax + } + } + } + self.unmatchedErrorSyntax(from: decoder) } } + /// Determines the container type for identifier decoding. + /// + /// Creates a `KeyedDecodingContainer` type with the appropriate coding keys. + /// If the identifier type is optional or not specified, wraps the container + /// type in an optional to handle cases where the identifier might be missing. + /// + /// - Returns: The container type syntax, optionally wrapped if identifier + /// type allows for missing values. + private func identifierContainerType() -> TypeSyntax { + let type: TypeSyntax = "KeyedDecodingContainer<\(codingKeys.typeName)>" + guard identifierType?.isOptionalTypeSyntax ?? true else { return type } + return TypeSyntax(OptionalTypeSyntax(wrappedType: type)) + } + + /// 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`. + /// + /// - Parameters: + /// - identifier: The identifier token name for the variable. + /// - type: The type syntax for the identifier variable. + /// - context: The macro expansion context. + /// + /// - Returns: A tuple containing the configured property variable and its + /// associated key path for coding operations. + private func identifierVariableAndKey( + _ identifier: TokenSyntax, withType type: TypeSyntax, + context: some MacroExpansionContext + ) -> (AnyPropertyVariable, PathKey) { + let variable = BasicPropertyVariable( + name: identifier, type: type, value: nil, + decodePrefix: "", encodePrefix: "", + decode: true, encode: true + ) + let input = Registration(decl: decl, key: keyPath, variable: variable) + let output = variableBuilder(input) + + guard self.identifierType == nil + else { return (output.variable.any, output.key) } + + let outVariable = DefaultValueVariable( + base: output.variable, + options: .init(onMissingExpr: "nil", onErrorExpr: "nil") + ).any + return (outVariable, output.key) + } + /// Provides the syntax for encoding at the provided location and encoder. /// /// The generated implementation encodes the identifier variable to provided @@ -180,20 +308,25 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { encodingNode.encoding( in: context, to: .withCoder(coder, keyType: location.keyType) ).combined() - self.encodeSwitchExpression( + let switchExpr = self.encodeSwitchExpression( over: location.selfValue, at: location, from: encoder, in: context, withDefaultCase: location.hasDefaultCase ) { name in - let base = self.base(name) - let key = PathKey(decoding: [], encoding: []) - let input = Registration(decl: decl, key: key, variable: base) - let output = variableBuilder(input) - let keyExpr = encodingKeys.last!.expr - return output.variable.encoding( + let container = variable.encodeContainer + let (variable, key) = 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(encodeContainer, key: keyExpr, method: nil) + to: .container(container, key: encodingKey, method: nil) ) } + if let switchExpr = switchExpr { + switchExpr + } } } } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift index 2ed8016f1..6e6bc2e6d 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift @@ -92,7 +92,7 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { } else if !eKeys.isEmpty { return .key(eKeys) } else { - return .raw(values) + return .raw(values.map { .init(syntax: $0, type: .string) }) } } @@ -131,10 +131,18 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { """ let \(contentDecoder) = try \(container).superDecoder(forKey: \(expr)) """ - self.decodeSwitchExpression( - over: expr, at: location, from: contentDecoder, + + let switchExpr = self.decodeSwitchExpression( + over: .init(syntax: expr, type: .string), at: location, + from: contentDecoder, in: context, withDefaultCase: location.hasDefaultCase ) { _ in "" } + if let switchExpr = switchExpr { + switchExpr + } + if location.hasDefaultCase { + self.unmatchedErrorSyntax(from: contentDecoder) + } } } @@ -159,7 +167,7 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { """ var \(container) = \(coder).container(keyedBy: \(keyType)) """ - self.encodeSwitchExpression( + let switchExpr = self.encodeSwitchExpression( over: location.selfValue, at: location, from: contentEncoder, in: context, withDefaultCase: location.hasDefaultCase ) { name in @@ -167,6 +175,9 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { let \(contentEncoder) = \(container).superEncoder(forKey: \(name)) """ } + if let switchExpr = switchExpr { + switchExpr + } } } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index 73a76e793..2bd3e68de 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -8,18 +8,16 @@ import SwiftSyntaxMacros /// The generated switch expression compares case value with the decoded /// identifier. struct InternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable -where Variable: PropertyVariable { +where + Variable: PropertyVariable, + Variable.Initialization == RequiredInitialization +{ /// The identifier variable build action type. /// /// Used to build the identifier data and pass in encoding callback. typealias VariableBuilder = ( PathRegistration ) -> PathRegistration - /// The container for case variation encoding. - /// - /// This is used in the generated code as the container - /// for case variation data from the callback to be encoded. - let encodeContainer: TokenSyntax /// The identifier name to use. /// /// This is used as identifier variable name in generated code. @@ -27,23 +25,12 @@ where Variable: PropertyVariable { /// The identifier type to use. /// /// This is used as identifier variable type in generated code. - let identifierType: TypeSyntax + let identifierType: TypeSyntax? /// The declaration for which code generated. /// /// This declaration is used for additional attributes data /// for customizing generated code. let decl: EnumDeclSyntax - /// The key path at which identifier variable is registered for decoding. - /// - /// Identifier variable is registered with this path at `decodingNode` - /// during initialization. - let decodingKeys: [CodingKeysMap.Key] - /// The key path at which identifier variable is registered for encoding. - /// - /// Identifier variable is registered with this path at `encodingNode` - /// during initialization. This path is used for encode callback - /// provided to enum-cases. - let encodingKeys: [CodingKeysMap.Key] /// The node at which identifier variable is registered for decoding. /// /// Identifier variable is registered with the path at this node @@ -56,83 +43,91 @@ where Variable: PropertyVariable { /// during initialization. This node is used to generate identifier /// variable encoding implementations. var encodingNode: PropertyVariableTreeNode + /// The coding keys map for managing key path resolution and generation. + /// + /// Maintains the mapping between field names and their corresponding coding keys, + /// enabling proper key path resolution during encoding and decoding operations. + /// Used to generate and track coding keys for the identifier variable registration. + var codingKeys: CodingKeysMap + /// The key path configuration for identifier variable registration. + /// + /// Defines the separate decoding and encoding paths where the identifier variable + /// should be registered. Both paths must be non-empty for internal tagging to work. + /// The paths determine the exact location in the coding structure where the + /// identifier will be read from during decoding and written to during encoding. + let keyPath: PathKey /// The builder action for building identifier variable. /// /// This builder action is used to create and use identifier variable /// data to be passed to enum-cases encoding callback. let variableBuilder: VariableBuilder - - /// Creates switcher variable with provided data. + /// The container variable that manages encoding/decoding container exposure. /// - /// - Parameters: - /// - encodeContainer: The container for case variation encoding. Used as variable name - /// in the generated code for handling the encoding container. - /// - identifier: The identifier name to use as the variable name in the generated code - /// for the enum case identifier. - /// - identifierType: The identifier type to use as the variable type in the generated code - /// for the enum case identifier. - /// - decodingNode: The node at which identifier variable is registered for decoding. - /// Contains the structure for all variables that need to be decoded. - /// - encodingNode: The node at which identifier variable is registered for encoding. - /// Contains the structure for all variables that need to be encoded. - /// - decodingKeys: The key path at which the identifier variable is registered for decoding. - /// Specifies the exact location in the decoder where the identifier should be read from. - /// - encodingKeys: The key path at which the identifier variable is registered for encoding. - /// Specifies the exact location in the encoder where the identifier should be written to. - /// - decl: The declaration for which code is generated. Used to access additional - /// attributes and customize the generated code. - /// - variableBuilder: The builder action for creating and processing the identifier variable. - /// Takes a basic property variable registration and transforms it into the final variable type. - init( - encodeContainer: TokenSyntax, - identifier: TokenSyntax, identifierType: TypeSyntax, - decodingNode: PropertyVariableTreeNode, - encodingNode: PropertyVariableTreeNode, - decodingKeys: [CodingKeysMap.Key], - encodingKeys: [CodingKeysMap.Key], - decl: EnumDeclSyntax, variableBuilder: @escaping VariableBuilder - ) { - self.encodeContainer = encodeContainer - self.identifier = identifier - self.identifierType = identifierType - self.decl = decl - self.decodingNode = decodingNode - self.encodingNode = encodingNode - self.decodingKeys = decodingKeys - self.encodingKeys = encodingKeys - self.variableBuilder = variableBuilder - } + /// Wraps the identifier variable with container management functionality, + /// exposing both decoding and encoding containers through named variables. + /// This variable handles the container assignment and provides the interface + /// for accessing containers during the coding process. + let variable: ContainerVariable + /// Whether to force explicit `return` statements in generated decoding switch cases. + /// + /// When `true`, each enum case in the generated decoding switch statement will include + /// an explicit `return` statement after the case assignment (`self = .case(...)`). + /// This provides early exit from the switch and can help with code clarity and + /// potential compiler optimizations. + /// + /// When `false`, the switch cases rely on implicit fallthrough behavior without + /// explicit return statements, which is the traditional approach. + /// + /// This flag is typically set based on the code generation strategy or specific + /// requirements for the generated decoding implementation. + let forceDecodingReturn: Bool - /// Creates switcher variable with provided data. + /// Creates an internally tagged enum switcher and configures all components. + /// + /// This is the primary initializer that sets up the complete internally tagged enum + /// switcher from scratch. It creates the decoding/encoding nodes, registers the + /// identifier variable at the specified key paths, and configures the container + /// variable with the provided container names. /// /// - Parameters: - /// - encodeContainer: The container for case variation encoding. - /// - identifier: The identifier name to use. - /// - identifierType: The identifier type to use. - /// - keyPath: The key path at which identifier variable is registered. - /// - codingKeys: The map where `CodingKeys` maintained. - /// - decl: The declaration for which code generated. - /// - context: The context in which to perform the macro expansion. - /// - variableBuilder: The builder action for building identifier. + /// - 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. + /// - 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. + /// - keyPath: The key path configuration with non-empty decoding and encoding paths + /// where the identifier variable will be registered. + /// - codingKeys: The coding keys map for managing key generation and resolution. + /// - decl: The enum declaration syntax for which code is being generated. + /// - context: The macro expansion context for key generation and validation. + /// - 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. + /// - variableBuilder: The builder function for transforming the basic property + /// variable into the final variable type with custom processing. init( - encodeContainer: TokenSyntax, - identifier: TokenSyntax, identifierType: TypeSyntax, + identifierDecodeContainer: TokenSyntax, + identifierEncodeContainer: TokenSyntax, + identifier: TokenSyntax, identifierType: TypeSyntax?, keyPath: PathKey, codingKeys: CodingKeysMap, decl: EnumDeclSyntax, context: some MacroExpansionContext, + forceDecodingReturn: Bool, variableBuilder: @escaping VariableBuilder ) { precondition(!keyPath.decoding.isEmpty && !keyPath.encoding.isEmpty) - self.encodeContainer = encodeContainer self.identifier = identifier self.identifierType = identifierType self.decl = decl self.variableBuilder = variableBuilder + self.forceDecodingReturn = forceDecodingReturn var decodingNode = PropertyVariableTreeNode() var encodingNode = PropertyVariableTreeNode() let variable = BasicPropertyVariable( - name: identifier, type: self.identifierType, value: nil, + name: identifier, type: "_", value: nil, decodePrefix: "", encodePrefix: "", decode: true, encode: true ) @@ -142,47 +137,35 @@ where Variable: PropertyVariable { let field = self.identifier // Get separate keys for decoding and encoding - let decodingPathKeys = codingKeys.add( - keys: key.decoding, field: field, context: context) - let encodingPathKeys = codingKeys.add( - keys: key.encoding, field: field, context: context) - - self.decodingKeys = decodingPathKeys - self.encodingKeys = encodingPathKeys + let decodingKeys = codingKeys.add( + keys: key.decoding, field: field, context: context + ) + let encodingKeys = codingKeys.add( + keys: key.encoding, field: field, context: context + ) - let containerVariable = ContainerVariable( - encodeContainer: encodeContainer, base: output.variable + self.variable = ContainerVariable( + decodeContainer: identifierDecodeContainer, + encodeContainer: identifierEncodeContainer, + base: output.variable, providedType: identifierType ) // Register for decoding using decodingKeys decodingNode.register( - variable: containerVariable, keyPath: decodingKeys, + variable: self.variable, keyPath: decodingKeys, immutableEncodeContainer: true ) // Register for encoding using encodingKeys encodingNode.register( - variable: containerVariable, keyPath: encodingKeys, + variable: self.variable, keyPath: encodingKeys, immutableEncodeContainer: true ) self.decodingNode = decodingNode self.encodingNode = encodingNode - } - - /// Create basic identifier variable. - /// - /// Builds a basic identifier variable that can be processed by builder - /// action to be passed to enum-case encoding callback. - /// - /// - Parameter name: The variable name to use. - /// - Returns: The basic identifier variable. - func base(_ name: TokenSyntax) -> BasicPropertyVariable { - BasicPropertyVariable( - name: name, type: self.identifierType, value: nil, - decodePrefix: "", encodePrefix: "", - decode: true, encode: true - ) + self.codingKeys = codingKeys + self.keyPath = keyPath } /// Provides node at which case associated variables are registered. @@ -219,7 +202,15 @@ where Variable: PropertyVariable { codingKeys: CodingKeysMap, context: some MacroExpansionContext ) -> EnumVariable.CaseValue where Var: EnumCaseVariable { let name = CodingKeysMap.Key.name(for: variable.name).text - return .raw(!values.isEmpty ? values : ["\(literal: name)"]) + return !values.isEmpty + ? .raw( + values.map { expr in + .from( + expression: expr, inheritedType: identifierType, + context: context + ) + }) + : .raw([.init(syntax: "\(literal: name)", type: .string)]) } /// Provides the syntax for decoding at the provided location. @@ -284,10 +275,15 @@ extension InternallyTaggedEnumSwitcher { /// /// Initialization type is the same as underlying wrapped variable. typealias Initialization = Wrapped.Initialization - /// The container for case variation encoding. + /// The mapped name for decoder. + /// + /// The decoder at location passed will be exposed + /// with this variable name. + let decodeContainer: TokenSyntax + /// The mapped name for encoder. /// - /// This is used in the generated code as the container - /// for case variation data from the callback to be encoded. + /// The encoder at location passed will be exposed + /// with this variable name. let encodeContainer: TokenSyntax /// The value wrapped by this instance. /// @@ -295,6 +291,12 @@ extension InternallyTaggedEnumSwitcher { /// preserved and this variable is used /// to chain code generation implementations. let base: Wrapped + /// The optional type syntax provided for the container. + /// + /// When specified, this type determines the container's optionality behavior + /// during decoding. If the type is optional, missing containers are handled + /// gracefully. If non-optional or nil, different fallback strategies apply. + let providedType: TypeSyntax? /// Whether the variable is to be decoded. /// @@ -314,6 +316,55 @@ extension InternallyTaggedEnumSwitcher { /// This variable never requires `Encodable` conformance var requireEncodable: Bool? { false } + /// The fallback strategy used when decoding fails or data is missing. + /// + /// 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`. + /// - When `providedType` is optional: Uses `.onlyIfMissing` fallback, setting + /// the container 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" + } + + return switch providedType { + case .none: + .ifMissing( + containerFallbackSyntax, ifError: containerFallbackSyntax + ) + case .some(let type) where type.isOptionalTypeSyntax == true: + .onlyIfMissing(containerFallbackSyntax) + default: + .throw + } + } + + /// 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. + /// + /// - Parameters: + /// - context: The context in which to perform the macro expansion. + /// - location: The decoding location for the variable. + /// + /// - Returns: The generated variable decoding code. + func decoding( + in context: some MacroExpansionContext, + from location: PropertyCodingLocation + ) -> CodeBlockItemListSyntax { + switch location { + case .coder(let decoder, _): + fatalError("Error encoding \(Self.self) to \(decoder)") + case .container(let container, _, _): + "\(self.decodeContainer) = \(container)" + } + } + /// Provides the code syntax for encoding this variable /// at the provided location. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift index 18bd7c512..4d8d33f60 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift @@ -22,41 +22,77 @@ extension TaggedEnumSwitcherVariable { /// - coder: The decoder for cases. /// - context: The context in which to perform the macro expansion. /// - default: Whether default case is needed. + /// - forceDecodingReturn: Whether to force explicit `return` statements in each + /// switch case. When `true`, adds a `return` statement after the case assignment + /// for early exit. Defaults to `false` for backward compatibility. /// - preSyntax: The callback to generate case variation data. /// /// - Returns: The generated switch expression. func decodeSwitchExpression( - over header: ExprSyntax, + over header: EnumVariable.CaseValue.Expr, at location: EnumSwitcherLocation, from coder: TokenSyntax, in context: some MacroExpansionContext, withDefaultCase default: Bool, + forceDecodingReturn: Bool = false, preSyntax: (TokenSyntax) -> CodeBlockItemListSyntax - ) -> SwitchExprSyntax { - SwitchExprSyntax(subject: header) { + ) -> SwitchExprSyntax? { + var switchable = false + let switchExpr = SwitchExprSyntax(subject: header.syntax) { for (`case`, value) in location.cases where `case`.decode ?? true { + let values = value.decodeExprs + .filter { $0.type == header.type } + .map(\.syntax) let cLocation = EnumCaseCodingLocation( - coder: coder, values: value.decodeExprs + coder: coder, values: values ) let generated = `case`.decoding(in: context, from: cLocation) - SwitchCaseSyntax(label: .case(generated.label)) { - preSyntax("\(value.decodeExprs.first!)") - generated.code.combined() - "\(location.codeExpr(`case`.name, `case`.variables))" + if !values.isEmpty { + let _ = { switchable = true }() + SwitchCaseSyntax(label: .case(generated.label)) { + preSyntax("\(values.first!)") + generated.code.combined() + "\(location.codeExpr(`case`.name, `case`.variables))" + if forceDecodingReturn { + "return" + } + } } } + if `default` { SwitchCaseSyntax(label: .default(.init())) { - """ - let context = DecodingError.Context( - codingPath: \(coder).codingPath, - debugDescription: "Couldn't match any cases." - ) - """ - "throw DecodingError.typeMismatch(Self.self, context)" + "break" } } } + return switchable ? switchExpr : nil + } + + /// Generates error handling syntax for unmatched enum cases during decoding. + /// + /// Creates a `DecodingError.typeMismatch` with appropriate context information + /// when no enum cases match the decoded value. This provides meaningful error + /// messages that include the coding path and a descriptive message indicating + /// that no cases could be matched. + /// + /// - Parameter coder: The decoder token used to access the coding path for + /// error context. + /// + /// - Returns: Code block syntax that throws a type mismatch decoding error + /// with contextual information. + func unmatchedErrorSyntax( + from coder: TokenSyntax + ) -> CodeBlockItemListSyntax { + CodeBlockItemListSyntax { + """ + let context = DecodingError.Context( + codingPath: \(coder).codingPath, + debugDescription: "Couldn't match any cases." + ) + """ + "throw DecodingError.typeMismatch(Self.self, context)" + } } } @@ -82,15 +118,19 @@ extension EnumSwitcherVariable { in context: some MacroExpansionContext, withDefaultCase default: Bool, preSyntax: (TokenSyntax) -> CodeBlockItemListSyntax - ) -> SwitchExprSyntax { + ) -> SwitchExprSyntax? { let cases = location.cases let allEncodable = cases.allSatisfy { $0.variable.encode ?? true } var anyEncodeCondition = false - return SwitchExprSyntax(subject: header) { + var switchable = false + let switchExpr = SwitchExprSyntax(subject: header) { for (`case`, value) in cases where `case`.encode ?? true { + let _ = { switchable = true }() + let values = value.encodeExprs.map(\.syntax) let cLocation = EnumCaseCodingLocation( - coder: coder, values: value.encodeExprs + coder: coder, values: values ) + let generated = `case`.encoding(in: context, to: cLocation) let expr = location.codeExpr(`case`.name, `case`.variables) let pattern = ExpressionPatternSyntax(expression: expr) @@ -106,7 +146,7 @@ extension EnumSwitcherVariable { SwitchCaseSyntax(label: .case(label)) { if !generatedCode.isEmpty { CodeBlockItemListSyntax { - preSyntax("\(value.encodeExprs.first!)") + preSyntax("\(values.first!)") generatedCode } } else { @@ -114,9 +154,11 @@ extension EnumSwitcherVariable { } } } + if `default` || !allEncodable || anyEncodeCondition { SwitchCaseSyntax(label: .default(.init())) { "break" } } } + return switchable ? switchExpr : nil } } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 91a004e66..fba3ed379 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -49,7 +49,16 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { codingKeys: CodingKeysMap, context: some MacroExpansionContext ) -> EnumVariable.CaseValue { let name = CodingKeysMap.Key.name(for: variable.name).text - return .raw(!values.isEmpty ? values : ["\(literal: name)"]) + return !values.isEmpty + ? .raw( + values.map { expr in + .from( + expression: expr, inheritedType: nil, + context: context + ) + } + ) + : .raw([.init(syntax: "\(literal: name)", type: .string)]) } /// Update provided variable data. @@ -175,10 +184,13 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { ) -> CodeBlockItemListSyntax { let coder = location.coder return CodeBlockItemListSyntax { - self.encodeSwitchExpression( + let switchExpr = self.encodeSwitchExpression( over: location.selfValue, at: location, from: coder, in: context, withDefaultCase: location.hasDefaultCase ) { _ in "" } + if let switchExpr = switchExpr { + switchExpr + } } } diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 65ae7191b..3c621b995 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -97,7 +97,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { caseDecodeExpr: caseDecodeExpr, caseEncodeExpr: caseEncodeExpr, encodeSwitchExpr: "self", forceDefault: false, switcher: Self.externallyTaggedSwitcher(decodingKeys: decodingKeys), - codingKeys: codingKeys + codingKeys: codingKeys, + forceInternalTaggingDecodingReturn: true ) } @@ -114,21 +115,23 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { /// - Parameters: /// - decl: The declaration to read data from. /// - context: The context in which to perform the macro expansion. - /// - caseDecodeExpr: The enum-case decoding expression generation - /// callback. - /// - caseEncodeExpr: The enum-case encoding expression generation - /// callback. - /// - encodeSwitchExpr: The context in which to perform the macro expansion. - /// - forceDefault: The context in which to perform the macro expansion. - /// - switcher: The switch expression generator. - /// - codingKeys: The map where `CodingKeys` maintained. + /// - caseDecodeExpr: The enum-case decoding expression generation callback. + /// - caseEncodeExpr: The enum-case encoding expression generation callback. + /// - encodeSwitchExpr: The expression used in switch header for encoding implementation. + /// - forceDefault: Whether to always add default case to decoding/encoding switch. + /// - switcher: The switch expression generator for externally tagged enums. + /// - codingKeys: The map where `CodingKeys` are maintained. + /// - forceInternalTaggingDecodingReturn: Whether to force explicit `return` statements + /// in generated decoding switch cases when internal tagging is detected. When `true`, + /// each internally tagged enum case includes a `return` after assignment for early exit. /// /// - Returns: Created enum variable. package init( from decl: EnumDeclSyntax, in context: some MacroExpansionContext, caseDecodeExpr: @escaping CaseCode, caseEncodeExpr: @escaping CaseCode, encodeSwitchExpr: ExprSyntax, forceDefault: Bool, - switcher: ExternallyTaggedEnumSwitcher, codingKeys: CodingKeysMap + switcher: ExternallyTaggedEnumSwitcher, codingKeys: CodingKeysMap, + forceInternalTaggingDecodingReturn: Bool ) { self.init( from: decl, in: context, @@ -137,8 +140,10 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher: switcher, codingKeys: codingKeys ) { input in input.checkForInternalTagging( - encodeContainer: "typeContainer", identifier: "type", - fallbackType: "String", codingKeys: codingKeys, context: context + container: Self.typeContainer, identifier: Self.type, + codingKeys: codingKeys, + forceDecodingReturn: forceInternalTaggingDecodingReturn, + context: context ) { registration in registration.useHelperCoderIfExists() } switcherBuilder: { registration in @@ -259,15 +264,12 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { in context: some MacroExpansionContext, from location: TypeCodingLocation ) -> TypeGenerated? { - guard - let conformance = location.conformance - else { return nil } - + guard let conformance = location.conformance else { return nil } let selfType: ExprSyntax = "\(name).self" let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.decode ?? true }) { let switcherLoc = EnumSwitcherLocation( - coder: location.method.arg, container: "container", + coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: "_", cases: cases, codeExpr: caseDecodeExpr, hasDefaultCase: forceDefault @@ -286,6 +288,7 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { "throw DecodingError.typeMismatch(Self.self, context)" } } + return .init( code: code, modifiers: [], whereClause: constraintGenerator.decodingClause( @@ -314,16 +317,13 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { in context: some MacroExpansionContext, to location: TypeCodingLocation ) -> TypeGenerated? { - guard - let conformance = location.conformance - else { return nil } - + guard let conformance = location.conformance else { return nil } let selfType: ExprSyntax = "\(name).self" let expr = encodeSwitchExpr let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.encode ?? true }) { let switcherLocation = EnumSwitcherLocation( - coder: location.method.arg, container: "container", + coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: expr, cases: cases, codeExpr: caseEncodeExpr, hasDefaultCase: forceDefault @@ -334,6 +334,7 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { } else { code = "" } + return .init( code: code, modifiers: [], whereClause: constraintGenerator.encodingClause( @@ -386,7 +387,7 @@ extension EnumVariable { /// The expression represents a set of raw value expressions. /// /// - Parameter exprs: The raw expressions. - case raw(_ exprs: [ExprSyntax]) + case raw([Expr]) /// Represents value is a set of `CodingKey`s. /// /// The expressions for the keys are used as value expressions. @@ -406,28 +407,372 @@ extension EnumVariable { /// The expressions for decoding. /// /// Represents value expressions for case when decoding. - var decodeExprs: [ExprSyntax] { + var decodeExprs: [Expr] { switch self { case .raw(let exprs): return exprs case .key(let keys): - return keys.map(\.expr) + return keys.map { Expr(syntax: $0.expr, type: .string) } case .keys(let decodeKeys, _): - return decodeKeys.map(\.expr) + return decodeKeys.map { Expr(syntax: $0.expr, type: .string) } } } /// The expressions for encoding. /// /// Represents value expressions for case when encoding. - var encodeExprs: [ExprSyntax] { + var encodeExprs: [Expr] { switch self { case .raw(let exprs): return exprs case .key(let keys): - return keys.map(\.expr) + return keys.map { Expr(syntax: $0.expr, type: .string) } case .keys(_, let encodeKeys): - return encodeKeys.map(\.expr) + return encodeKeys.map { Expr(syntax: $0.expr, type: .string) } + } + } + + /// Represents the type of an enum case value expression. + /// + /// Used to categorize and handle different types of literal values that can be + /// used as enum case identifiers. Supports built-in Swift types and custom types. + enum TypeOf: Hashable { + /// Boolean literal type. + case bool + /// Integer literal type. + case int + /// Double/floating-point literal type. + case double + /// String literal type. + case string + /// Custom or unrecognized type with explicit type syntax. + case unknown(TypeSyntax) + + /// Returns all possible types for enum case values. + /// + /// If an inherited type is provided, returns only that type wrapped as `.unknown`. + /// Otherwise, returns all built-in supported types for automatic type inference. + /// + /// - Parameter inheritedType: Optional explicit type to use instead of inference. + /// - Returns: Array of type cases to consider for enum case values. + static func all(inheritedType: TypeSyntax?) -> [Self] { + if let inheritedType = inheritedType { + return [.unknown(inheritedType)] + } + return [.bool, .int, .double, .string] + } + + /// Generates a name suffix for variable naming based on the type. + /// + /// Used to create unique variable names when multiple types are being processed. + /// Returns the capitalized type name for built-in types, or empty string for + /// custom types to avoid naming conflicts. + /// + /// - Returns: Token syntax for the type-based name suffix. + func nameSuffix() -> TokenSyntax { + switch self { + case .bool: + "Bool" + case .int: + "Int" + case .double: + "Double" + case .string: + "String" + case .unknown: + "" + } + } + + /// Generates the type syntax for this type case. + /// + /// Creates the appropriate Swift type syntax for use in generated code. + /// Can optionally wrap the type in an optional type syntax. + /// + /// - Parameter optional: Whether to wrap the type in optional syntax. + /// - Returns: The generated type syntax, optionally wrapped. + func syntax(optional: Bool) -> TypeSyntax { + let type: TypeSyntax = + switch self { + case .bool: + "Bool" + case .int: + "Int" + case .double: + "Double" + case .string: + "String" + case .unknown(let type): + type + } + return optional + ? TypeSyntax(OptionalTypeSyntax(wrappedType: type)) : type + } + + /// Compares two TypeOf instances for equality. + /// + /// Built-in types are compared by case, while unknown types are compared + /// by their trimmed type syntax description to handle formatting differences. + /// + /// - Parameters: + /// - lhs: The left-hand side TypeOf instance. + /// - rhs: The right-hand side TypeOf instance. + /// - Returns: True if the types are equivalent, false otherwise. + static func == (lhs: TypeOf, rhs: TypeOf) -> Bool { + switch (lhs, rhs) { + case (.bool, .bool), (.int, .int), (.double, .double), + (.string, .string): + return true + case let (.unknown(lhsType), .unknown(rhsType)): + return lhsType.trimmedDescription + == rhsType.trimmedDescription + default: + return false + } + } + } + + /// Represents an expression with its associated type information. + /// + /// Combines a Swift expression syntax with type metadata to enable proper + /// type handling and code generation for enum case values. + struct Expr { + /// The Swift expression syntax for the enum case value. + let syntax: ExprSyntax + /// The inferred or specified type of the expression. + let type: TypeOf + + /// Creates an Expr instance by analyzing the provided expression syntax. + /// + /// Performs comprehensive type inference on the expression to determine its type category. + /// The method follows a hierarchical approach to type determination: + /// 1. If an inherited type is provided, uses that explicitly + /// 2. Attempts to infer type from raw literal expressions (bool, int, double, string) + /// 3. Attempts to infer type from operator expressions (ranges, prefix/postfix operators) + /// 4. Falls back to string type if no other type can be determined + /// + /// Supports various expression types including: + /// - Literal expressions (boolean, integer, float, string) + /// - Range operators (`...`, `..<`) + /// - Prefix operators (e.g., `-` for negative numbers) + /// - Postfix operators (e.g., `...` for partial ranges) + /// - Parenthesized expressions + /// + /// - Parameters: + /// - expression: The Swift expression syntax to analyze for type inference + /// - inheritedType: Optional explicit type to use instead of automatic inference + /// - context: The macro expansion context for diagnostics and error reporting + /// - Returns: A new Expr instance with the expression and inferred/specified type + static func from( + expression: ExprSyntax, inheritedType: TypeSyntax?, + context: some MacroExpansionContext + ) -> Self { + let type: TypeOf = + if let inheritedType = inheritedType { + .unknown(inheritedType) + } else if let type = typeOf(raw: expression) { + type + } else if let type = typeOf( + operator: expression, context: context) + { + type + } else { + .string + } + return Expr(syntax: expression, type: type) + } + + /// Infers the type of an expression containing operators. + /// + /// Analyzes expressions that contain range operators (`...`, `..<`) or other operators + /// to determine the underlying type. This method handles various operator expression + /// formats including: + /// - Infix operators: `1...5`, `0..<10` + /// - Prefix operators: `...5`, `..<10` + /// - Postfix operators: `1...`, `5...` + /// - Sequence expressions with multiple elements + /// + /// The method converts different operator expression types into a normalized + /// `SequenceExprSyntax` format for consistent processing, then analyzes the + /// operands to determine the overall expression type. + /// + /// - Parameters: + /// - expression: The operator expression to analyze + /// - context: The macro expansion context for diagnostics + /// - Returns: The inferred type if successful, nil if type cannot be determined + private static func typeOf( + operator expression: ExprSyntax, + context: some MacroExpansionContext + ) -> TypeOf? { + let operatorTexts = ["...", "..<"] + let expr: SequenceExprSyntax + if let expression = expression.as(SequenceExprSyntax.self) { + expr = expression + } else if let expression = expression.as( + InfixOperatorExprSyntax.self + ) { + expr = SequenceExprSyntax( + elements: [ + expression.leftOperand, + expression.operator, + expression.rightOperand, + ] + ) + } else if let expression = expression.as( + PrefixOperatorExprSyntax.self + ) { + expr = SequenceExprSyntax( + elements: [ + ExprSyntax( + BinaryOperatorExprSyntax( + operator: expression.operator + ) + ), + expression.expression, + ] + ) + } else if let expression = expression.as( + PostfixOperatorExprSyntax.self + ) { + expr = SequenceExprSyntax( + elements: [ + expression.expression, + ExprSyntax( + BinaryOperatorExprSyntax( + operator: expression.operator + ) + ), + ] + ) + } else { + return nil + } + + switch expr.elements.count { + case 2: + if let opExpr = expr.elements.first!.as( + BinaryOperatorExprSyntax.self + ), + operatorTexts.contains(String(opExpr.operator.text)), + let type = typeOf(raw: expr.elements.last!) + { + return type + } else if let opExpr = expr.elements.last!.as( + BinaryOperatorExprSyntax.self), + operatorTexts.contains(String(opExpr.operator.text)), + let type = typeOf(raw: expr.elements.first!) + { + return type + } + case 3: + let middleIndex = expr.elements.index( + expr.elements.startIndex, offsetBy: 1 + ) + guard + let opExpr = expr.elements[middleIndex].as( + BinaryOperatorExprSyntax.self + ), + operatorTexts.contains(String(opExpr.operator.text)), + let firstType = typeOf(raw: expr.elements.first!), + let lastType = typeOf(raw: expr.elements.last!) + else { break } + return maxOf( + leftType: firstType, rightType: lastType, + originExpression: expression, context: context + ) + default: + break + } + + return nil + } + + /// Infers the type of a raw literal expression. + /// + /// Analyzes literal expressions to determine their Swift type category. + /// This method handles various literal expression formats and performs + /// preprocessing to normalize the expression before type analysis: + /// + /// 1. Unwraps single-element tuple expressions: `(42)` → `42` + /// 2. Handles negative number prefix operators: `-42` → `42` (int type) + /// 3. Identifies literal types: boolean, integer, float, string + /// + /// Supported literal types: + /// - `BooleanLiteralExprSyntax`: `true`, `false` → `.bool` + /// - `IntegerLiteralExprSyntax`: `42`, `0`, `-5` → `.int` + /// - `FloatLiteralExprSyntax`: `3.14`, `0.5` → `.double` + /// - `StringLiteralExprSyntax`: `"hello"`, `"world"` → `.string` + /// + /// - Parameter expression: The raw expression to analyze for literal type + /// - Returns: The inferred literal type if recognized, nil otherwise + private static func typeOf(raw expression: ExprSyntax) -> TypeOf? { + var expr: ExprSyntax + if let tuple = expression.as(TupleExprSyntax.self), + tuple.elements.count == 1 + { + expr = tuple.elements.first!.expression + } else { + expr = expression + } + + if let prefixExpr = expr.as(PrefixOperatorExprSyntax.self), + prefixExpr.operator.text == "-" + { + expr = prefixExpr.expression + } + + if expr.is(BooleanLiteralExprSyntax.self) { + return .bool + } else if expr.is(FloatLiteralExprSyntax.self) { + return .double + } else if expr.is(IntegerLiteralExprSyntax.self) { + return .int + } else if expr.is(StringLiteralExprSyntax.self) { + return .string + } else { + return nil + } + } + } + + /// Determines the maximum (most specific) type from two types in a range expression. + /// + /// When analyzing range expressions like `1...5.0`, this method determines the most + /// appropriate type that can represent both operands. The type promotion follows + /// Swift's type system rules: + /// + /// - Same types return the same type: `.int` + `.int` → `.int` + /// - Integer and double promote to double: `.int` + `.double` → `.double` + /// - Incompatible types generate a diagnostic error and fallback to `.string` + /// + /// This ensures that range expressions maintain type safety while allowing + /// reasonable type promotions for numeric ranges. + /// + /// - Parameters: + /// - leftType: The type of the left operand in the range + /// - rightType: The type of the right operand in the range + /// - originExpression: The original expression for error reporting + /// - context: The macro expansion context for diagnostics + /// - Returns: The promoted type that can represent both operands + private static func maxOf( + leftType: TypeOf, rightType: TypeOf, originExpression: ExprSyntax, + context: some MacroExpansionContext + ) -> TypeOf { + switch (leftType, rightType) { + case (.int, .int), (.double, .double), (.string, .string): + return leftType + case (.int, .double), (.double, .int): + return .double + default: + context.diagnose( + .init( + node: originExpression, + message: MetaCodableMacroExpansionErrorMessage( + "Invalid expression type for enum case value" + ) + ) + ) + return .string } } } @@ -509,6 +854,21 @@ package extension EnumVariable { } fileprivate extension EnumVariable { + /// The default name for identifier type root container. + /// + /// This container is passed to each case for decoding. + static var typeContainer: TokenSyntax { "typeContainer" } + /// The default name for top-level root container. + /// + /// This container is passed to each case for decoding. + static var container: TokenSyntax { "container" } + + /// The identifier type variable name. + /// + /// This name is passed for identifier variable declaration + /// during decoding. + static var type: TokenSyntax { "type" } + /// The default name for content root decoder. /// /// This decoder is passed to each case for decoding. diff --git a/Sources/ProtocolGen/Generate.swift b/Sources/ProtocolGen/Generate.swift index 632312698..28cfb6b43 100644 --- a/Sources/ProtocolGen/Generate.swift +++ b/Sources/ProtocolGen/Generate.swift @@ -243,7 +243,8 @@ extension ProtocolGen { codingKeys: CodingKeysMap( typeName: "CodingKeys", fallbackTypeName: "DynamicCodableIdentifier" - ) + ), + forceInternalTaggingDecodingReturn: false ) } diff --git a/Tests/MetaCodableTests/AccessModifierTests.swift b/Tests/MetaCodableTests/AccessModifierTests.swift index 8e73bfbf4..bcc39570f 100644 --- a/Tests/MetaCodableTests/AccessModifierTests.swift +++ b/Tests/MetaCodableTests/AccessModifierTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -47,6 +48,33 @@ struct AccessModifierTests { """ ) } + + @Test + func openClassDecodingOnly() throws { + // Open class doesn't have memberwise init, only decoder init + let jsonStr = """ + { + "value": "open_test" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "open_test") + } + + @Test + func openClassFromJSON() throws { + let jsonStr = """ + { + "value": "open_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "open_value") + } } struct Public { @@ -98,6 +126,28 @@ struct AccessModifierTests { """ ) } + + @Test + func publicStructDecodingAndEncoding() throws { + let original = SomeCodable(value: "public_test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "public_test") + } + + @Test + func publicStructFromJSON() throws { + let jsonStr = """ + { + "value": "public_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "public_value") + } } struct Package { diff --git a/Tests/MetaCodableTests/Attributes/DefaultTests.swift b/Tests/MetaCodableTests/Attributes/DefaultTests.swift index 49fd9faae..3cc348a32 100644 --- a/Tests/MetaCodableTests/Attributes/DefaultTests.swift +++ b/Tests/MetaCodableTests/Attributes/DefaultTests.swift @@ -1,3 +1,5 @@ +import Foundation +import MetaCodable import Testing @testable import PluginCore @@ -101,4 +103,51 @@ struct DefaultTests { ] ) } + + struct DefaultValueBehavior { + @Codable + struct SomeCodable { + @Default("default_value") + let value: String + @Default(42) + let number: Int + } + + @Test + func defaultValueUsage() throws { + // Test with missing keys in JSON + let jsonStr = "{}" + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "default_value") + #expect(decoded.number == 42) + } + + @Test + func overrideDefaultValues() throws { + // Test with provided values in JSON + let jsonStr = """ + { + "value": "custom_value", + "number": 100 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "custom_value") + #expect(decoded.number == 100) + } + + @Test + func encodingWithDefaults() throws { + let original = SomeCodable(value: "test", number: 99) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "test") + #expect(decoded.number == 99) + } + } } diff --git a/Tests/MetaCodableTests/Codable/CommonStrategiesValueCoderTests.swift b/Tests/MetaCodableTests/Codable/CommonStrategiesValueCoderTests.swift index 07567e5da..14e8df794 100644 --- a/Tests/MetaCodableTests/Codable/CommonStrategiesValueCoderTests.swift +++ b/Tests/MetaCodableTests/Codable/CommonStrategiesValueCoderTests.swift @@ -19,10 +19,10 @@ struct CommonStrategiesValueCoderTests { let impInt: Int! let impDouble: Double! let impString: String! - let optGenBool: Optional - let optGenInt: Optional - let optGenDouble: Optional - let optGenString: Optional + let optGenBool: Bool? + let optGenInt: Int? + let optGenDouble: Double? + let optGenString: String? } @Test @@ -548,32 +548,47 @@ struct CommonStrategiesValueCoderTests { extension Status: Decodable { init(from decoder: any Decoder) throws { - let type: String - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "active": - let since: String - let container = try decoder.container(keyedBy: CodingKeys.self) - since = try ValueCoder().decode(from: container, forKey: CodingKeys.since) - self = .active(since: since) - case "inactive": - let reason: String - let container = try decoder.container(keyedBy: CodingKeys.self) - reason = try ValueCoder().decode(from: container, forKey: CodingKeys.reason) - self = .inactive(reason: reason) - case "pending": - let until: String - let container = try decoder.container(keyedBy: CodingKeys.self) - until = try ValueCoder().decode(from: container, forKey: CodingKeys.until) - self = .pending(until: until) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + 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 container = container { + 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": + let since: String + since = try ValueCoder().decode(from: container, forKey: CodingKeys.since) + self = .active(since: since) + return + case "inactive": + let reason: String + reason = try ValueCoder().decode(from: container, forKey: CodingKeys.reason) + self = .inactive(reason: reason) + return + case "pending": + let until: String + until = try ValueCoder().decode(from: container, forKey: CodingKeys.until) + self = .pending(until: until) + return + default: + break + } + } } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } diff --git a/Tests/MetaCodableTests/CodableInheritanceTests.swift b/Tests/MetaCodableTests/CodableInheritanceTests.swift index 5bfae1cb8..80a381fab 100644 --- a/Tests/MetaCodableTests/CodableInheritanceTests.swift +++ b/Tests/MetaCodableTests/CodableInheritanceTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -106,6 +107,40 @@ struct CodableInheritanceTests { conformsTo: [] ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable() + original.value = "inheritance_test" + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "inheritance_test") + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "value": "class_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "class_value") + } + + @Test + func encodingToJSON() throws { + let original = SomeCodable() + original.value = "encoded_class" + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["value"] as? String == "encoded_class") + } } struct WithExplicitInheritance { @@ -158,6 +193,29 @@ struct CodableInheritanceTests { conformsTo: [] ) } + + @Test + func inheritanceDecodingAndEncoding() throws { + let original = SomeCodable() + original.value = "inherited_test" + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "inherited_test") + } + + @Test + func inheritanceFromJSON() throws { + let jsonStr = """ + { + "value": "inherited_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "inherited_value") + } } struct WithExplicitPartialInheritance { diff --git a/Tests/MetaCodableTests/CodableTests.swift b/Tests/MetaCodableTests/CodableTests.swift index 69d1ce4a5..1f168dbd0 100644 --- a/Tests/MetaCodableTests/CodableTests.swift +++ b/Tests/MetaCodableTests/CodableTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import SwiftDiagnostics import SwiftSyntax @@ -75,6 +76,28 @@ struct CodableTests { """ ) } + + @Test + func availableAttributeEncoding() throws { + let original = SomeCodable(value: "deprecated_test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "deprecated_test") + } + + @Test + func availableAttributeFromJSON() throws { + let jsonStr = """ + { + "value": "available_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "available_value") + } } struct WithoutAnyCustomization { @@ -135,6 +158,41 @@ struct CodableTests { """ ) } + + @Test + func basicCodableEncoding() throws { + let original = SomeCodable(value: "basic_test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "basic_test") + } + + @Test + func basicCodableFromJSON() throws { + let jsonStr = """ + { + "value": "basic_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "basic_value") + } + + @Test + func staticPropertiesIgnored() throws { + let original = SomeCodable(value: "test") + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + // Static properties should not be encoded + #expect(json["other"] == nil) + #expect(json["otherM"] == nil) + #expect(json["value"] as? String == "test") + } } struct WithOptionalTypeWithoutAnyCustomization { diff --git a/Tests/MetaCodableTests/CodedAs/CodedAsEnumTests.swift b/Tests/MetaCodableTests/CodedAs/CodedAsEnumTests.swift index 5e6e36873..b8a357668 100644 --- a/Tests/MetaCodableTests/CodedAs/CodedAsEnumTests.swift +++ b/Tests/MetaCodableTests/CodedAs/CodedAsEnumTests.swift @@ -46,6 +46,124 @@ struct CodedAsEnumTests { ) } + @Test + func invalidRangeExpressionTypeDiagnostic() throws { + assertMacroExpansion( + """ + @Codable + @CodedAt("type") + enum SomeEnum { + @CodedAs("load", true...false) + case load(key: String) + @CodedAs("store", 1..."end") + case store(key: String, value: Int) + } + """, + expandedSource: + """ + enum SomeEnum { + case load(key: String) + case store(key: String, value: Int) + } + + extension SomeEnum: 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 container = container { + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "load", true ... false: + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case "store", 1 ... "end": + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + } + + extension SomeEnum: Encodable { + func encode(to encoder: any Encoder) throws { + let container = encoder.container(keyedBy: CodingKeys.self) + var typeContainer = container + switch self { + case .load(key: let key): + try typeContainer.encode("load", forKey: CodingKeys.type) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: CodingKeys.key) + case .store(key: let key, value: let value): + try typeContainer.encode("store", forKey: CodingKeys.type) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: CodingKeys.key) + try container.encode(value, forKey: CodingKeys.value) + } + } + } + + extension SomeEnum { + enum CodingKeys: String, CodingKey { + case type = "type" + case key = "key" + case value = "value" + } + } + """, + diagnostics: [ + .init( + id: CodedAs.misuseID, + message: "Invalid expression type for enum case value", + line: 4, column: 22, + fixIts: [] + ), + .init( + id: CodedAs.misuseID, + message: "Invalid expression type for enum case value", + line: 6, column: 23, + fixIts: [] + ), + .init( + id: CodedAs.misuseID, + message: "Invalid expression type for enum case value", + line: 4, column: 22, + fixIts: [] + ), + .init( + id: CodedAs.misuseID, + message: "Invalid expression type for enum case value", + line: 6, column: 23, + fixIts: [] + ), + ] + ) + } + @Test func duplicatedMisuse() throws { assertMacroExpansion( @@ -219,15 +337,18 @@ struct CodedAsEnumTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: [Int] + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: container, forKey: CodingKeys.type) + typeContainer = container + let type: [Int] + type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: typeContainer, forKey: CodingKeys.type) switch type { case [1, 2, 3]: let key: String let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case [4, 5, 6]: let key: String let value: Int @@ -235,13 +356,15 @@ struct CodedAsEnumTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -449,44 +572,61 @@ struct CodedAsEnumTests { extension SomeEnum: Decodable { init(from decoder: any Decoder) throws { - let type: String - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "bool": - let variable: Bool - let container = try decoder.container(keyedBy: CodingKeys.self) - variable = try container.decode(Bool.self, forKey: CodingKeys.variable) - self = .bool(_: variable) - case "altInt": - let val: Int - let container = try decoder.container(keyedBy: CodingKeys.self) - val = try container.decode(Int.self, forKey: CodingKeys.val) - self = .int(val: val) - case "altDouble1", "altDouble2": - let _0: Double - _0 = try Double(from: decoder) - self = .double(_: _0) - case "string": - let _0: String - _0 = try String(from: decoder) - self = .string(_0) - case "multi": - let variable: Bool - let val: Int - let _2: String - let container = try decoder.container(keyedBy: CodingKeys.self) - _2 = try String(from: decoder) - variable = try container.decode(Bool.self, forKey: CodingKeys.variable) - val = try container.decode(Int.self, forKey: CodingKeys.val) - self = .multi(_: variable, val: val, _2) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + 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 container = container { + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "bool": + let variable: Bool + variable = try container.decode(Bool.self, forKey: CodingKeys.variable) + self = .bool(_: variable) + return + case "altInt": + let val: Int + val = try container.decode(Int.self, forKey: CodingKeys.val) + self = .int(val: val) + return + case "altDouble1", "altDouble2": + let _0: Double + _0 = try Double(from: decoder) + self = .double(_: _0) + return + case "string": + let _0: String + _0 = try String(from: decoder) + self = .string(_0) + return + case "multi": + let variable: Bool + let val: Int + let _2: String + _2 = try String(from: decoder) + variable = try container.decode(Bool.self, forKey: CodingKeys.variable) + val = try container.decode(Int.self, forKey: CodingKeys.val) + self = .multi(_: variable, val: val, _2) + return + default: + break + } + } } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } diff --git a/Tests/MetaCodableTests/CodedAs/CodedAsTests.swift b/Tests/MetaCodableTests/CodedAs/CodedAsTests.swift index b3dfd3a3a..259ff10b4 100644 --- a/Tests/MetaCodableTests/CodedAs/CodedAsTests.swift +++ b/Tests/MetaCodableTests/CodedAs/CodedAsTests.swift @@ -1,3 +1,4 @@ +import Foundation import HelperCoders import MetaCodable import Testing @@ -209,6 +210,668 @@ struct CodedAsTests { """ ) } + + @Test + func codedAsKeyMapping() throws { + let original = SomeCodable(value: "test1", value1: "test2") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "test1") + #expect(decoded.value1 == "test2") + } + + @Test + func codedAsFromJSON() throws { + let jsonStr = """ + { + "key": "mapped_value", + "key1": "multi_mapped_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "mapped_value") + #expect(decoded.value1 == "multi_mapped_value") + } + + @Test + func codedAsAlternativeKeys() throws { + // Test with key2 instead of key1 + let jsonStr = """ + { + "key": "mapped_value", + "key2": "alternative_key_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "mapped_value") + #expect(decoded.value1 == "alternative_key_value") + } + + @Test + func codedAsJSONStructure() throws { + let original = SomeCodable(value: "test", value1: "test2") + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + + // The actual behavior shows that encoding uses the original property names + // while decoding can use the alternative keys + #expect(json["value"] as? String == "test") + #expect(json["value1"] as? String == "test2") + // The mapped keys should not be present in encoding + #expect(json["key"] == nil) + #expect(json["key1"] == nil) + } + } + + struct WithAnyCodableLiteralEnum { + @Codable + @CodedAt("type") + enum Command { + @CodedAs("load", 12, true, 3.14, 15..<20, (-0.8)...) + case load(key: String) + @CodedAs("store", 30, false, 7.15, 35...40, ..<(-1.5)) + case store(key: String, value: Int) + } + + @Test + func expansion() throws { + assertMacroExpansion( + """ + @Codable + @CodedAt("type") + enum Command { + @CodedAs("load", 12, true, 3.14, 15..<20, (-0.8)...) + case load(key: String) + @CodedAs("store", 30, false, 7.15, 35...40, ..<(-1.5)) + case store(key: String, value: Int) + } + """, + expandedSource: + """ + enum Command { + case load(key: String) + case store(key: String, value: Int) + } + + extension Command: 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 container = container { + let typeBool: Bool? + do { + typeBool = try typeContainer.decodeIfPresent(Bool.self, forKey: CodingKeys.type) ?? nil + } catch { + typeBool = nil + } + if let typeBool = typeBool { + switch typeBool { + case true: + let key: String + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case false: + let key: String + let value: Int + let container = try decoder.container(keyedBy: CodingKeys.self) + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } + let typeInt: Int? + do { + typeInt = try typeContainer.decodeIfPresent(Int.self, forKey: CodingKeys.type) ?? nil + } catch { + typeInt = nil + } + if let typeInt = typeInt { + switch typeInt { + case 12, 15 ..< 20: + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case 30, 35 ... 40: + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } + let typeDouble: Double? + do { + typeDouble = try typeContainer.decodeIfPresent(Double.self, forKey: CodingKeys.type) ?? nil + } catch { + typeDouble = nil + } + if let typeDouble = typeDouble { + switch typeDouble { + case 3.14, (-0.8)...: + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case 7.15, ..<(-1.5): + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "load": + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case "store": + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) + } + } + + extension Command: Encodable { + func encode(to encoder: any Encoder) throws { + let container = encoder.container(keyedBy: CodingKeys.self) + var typeContainer = container + switch self { + case .load(key: let key): + try typeContainer.encode("load", forKey: CodingKeys.type) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: CodingKeys.key) + case .store(key: let key, value: let value): + try typeContainer.encode("store", forKey: CodingKeys.type) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: CodingKeys.key) + try container.encode(value, forKey: CodingKeys.value) + } + } + } + + extension Command { + enum CodingKeys: String, CodingKey { + case type = "type" + case key = "key" + case value = "value" + } + } + """ + ) + } + + @Test + func enumMixedLiteralRoundtrip() throws { + let loadCmd: Command = .load(key: "test_key") + let encoded = try JSONEncoder().encode(loadCmd) + let decoded = try JSONDecoder().decode(Command.self, from: encoded) + + if case .load(let key) = decoded { + #expect(key == "test_key") + } else { + Issue.record("Expected .load case") + } + } + + @Test + func enumStringTypeDecoding() throws { + let jsonStr = """ + { + "type": "load", + "key": "string_type_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "string_type_key") + } else { + Issue.record("Expected .load case") + } + } + + @Test + func enumIntegerTypeDecoding() throws { + let jsonStr = """ + { + "type": 12, + "key": "integer_type_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "integer_type_key") + } else { + Issue.record("Expected .load case") + } + } + + @Test + func enumBooleanTypeDecoding() throws { + let jsonStr = """ + { + "type": true, + "key": "boolean_type_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "boolean_type_key") + } else { + Issue.record("Expected .load case") + } + } + + @Test + func enumDoubleTypeDecoding() throws { + let jsonStr = """ + { + "type": 3.14, + "key": "double_type_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "double_type_key") + } else { + Issue.record("Expected .load case") + } + } + + @Test + func enumStoreWithIntegerType() throws { + let jsonStr = """ + { + "type": 30, + "key": "store_key", + "value": 42 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .store(let key, let value) = decoded { + #expect(key == "store_key") + #expect(value == 42) + } else { + Issue.record("Expected .store case") + } + } + + @Test + func enumStoreWithBooleanType() throws { + let jsonStr = """ + { + "type": false, + "key": "store_key", + "value": 99 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .store(let key, let value) = decoded { + #expect(key == "store_key") + #expect(value == 99) + } else { + Issue.record("Expected .store case") + } + } + + @Test + func enumStoreWithDoubleType() throws { + let jsonStr = """ + { + "type": -2.0, + "key": "store_key", + "value": 123 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .store(let key, let value) = decoded { + #expect(key == "store_key") + #expect(value == 123) + } else { + Issue.record("Expected .store case") + } + } + + @Test + func enumEncodingStructure() throws { + let storeCmd: Command = .store(key: "test", value: 100) + let encoded = try JSONEncoder().encode(storeCmd) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + + // Encoding always uses the first (string) value for the type + #expect(json["type"] as? String == "store") + #expect(json["key"] as? String == "test") + #expect(json["value"] as? Int == 100) + } + + @Test + func enumLoadEncodingStructure() throws { + let loadCmd: Command = .load(key: "load_test") + let encoded = try JSONEncoder().encode(loadCmd) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + + // Encoding always uses the first (string) value for the type + #expect(json["type"] as? String == "load") + #expect(json["key"] as? String == "load_test") + #expect(json["value"] == nil) // No value for load case + } + + @Test + func enumInvalidTypeDecoding() throws { + let jsonStr = """ + { + "type": "invalid", + "key": "test_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + + #expect(throws: DecodingError.self) { + let _ = try JSONDecoder().decode(Command.self, from: jsonData) + } + } + + @Test + func enumMissingTypeDecoding() throws { + let jsonStr = """ + { + "key": "test_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + + #expect(throws: DecodingError.self) { + let _ = try JSONDecoder().decode(Command.self, from: jsonData) + } + } + + @Test + func enumIntegerRangeLoadCase() throws { + // Test integer in range 15..<20 for load case + let jsonStr = """ + { + "type": 17, + "key": "range_test_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "range_test_key") + } else { + Issue.record( + "Expected .load case for integer 17 in range 15..<20") + } + } + + @Test + func enumIntegerRangeStoreCase() throws { + // Test integer in range 35...40 for store case + let jsonStr = """ + { + "type": 38, + "key": "store_range_key", + "value": 200 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .store(let key, let value) = decoded { + #expect(key == "store_range_key") + #expect(value == 200) + } else { + Issue.record( + "Expected .store case for integer 38 in range 35...40") + } + } + + @Test + func enumIntegerRangeBoundaryValues() throws { + // Test boundary values for ranges + + // Test 15 (not in 15..<20, should not match load case) + let jsonStr15 = """ + { + "type": 15, + "key": "boundary_key" + } + """ + let jsonData15 = try #require(jsonStr15.data(using: .utf8)) + let decoded15 = try JSONDecoder().decode( + Command.self, from: jsonData15) + + if case .load(let key) = decoded15 { + #expect(key == "boundary_key") + } else { + Issue.record( + "Expected .load case for integer 15 in range 15..<20") + } + + // Test 19 (in 15..<20, should match load case) + let jsonStr19 = """ + { + "type": 19, + "key": "boundary_key" + } + """ + let jsonData19 = try #require(jsonStr19.data(using: .utf8)) + let decoded19 = try JSONDecoder().decode( + Command.self, from: jsonData19) + + if case .load(let key) = decoded19 { + #expect(key == "boundary_key") + } else { + Issue.record( + "Expected .load case for integer 19 in range 15..<20") + } + + // Test 35 (in 35...40, should match store case) + let jsonStr35 = """ + { + "type": 35, + "key": "boundary_key", + "value": 300 + } + """ + let jsonData35 = try #require(jsonStr35.data(using: .utf8)) + let decoded35 = try JSONDecoder().decode( + Command.self, from: jsonData35) + + if case .store(let key, let value) = decoded35 { + #expect(key == "boundary_key") + #expect(value == 300) + } else { + Issue.record( + "Expected .store case for integer 35 in range 35...40") + } + + // Test 40 (in 35...40, should match store case) + let jsonStr40 = """ + { + "type": 40, + "key": "boundary_key", + "value": 400 + } + """ + let jsonData40 = try #require(jsonStr40.data(using: .utf8)) + let decoded40 = try JSONDecoder().decode( + Command.self, from: jsonData40) + + if case .store(let key, let value) = decoded40 { + #expect(key == "boundary_key") + #expect(value == 400) + } else { + Issue.record( + "Expected .store case for integer 40 in range 35...40") + } + } + + @Test + func enumDoublePartialRangeLoadCase() throws { + // Test double in partial range (-0.8)... for load case + let jsonStr = """ + { + "type": 5.5, + "key": "partial_range_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "partial_range_key") + } else { + Issue.record( + "Expected .load case for double 5.5 in range (-0.8)...") + } + } + + @Test + func enumDoublePartialRangeStoreCase() throws { + // Test double in partial range ..<(-1.5) for store case + let jsonStr = """ + { + "type": -3.0, + "key": "partial_range_store_key", + "value": 500 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .store(let key, let value) = decoded { + #expect(key == "partial_range_store_key") + #expect(value == 500) + } else { + Issue.record( + "Expected .store case for double -3.0 in range ..<(-1.5)") + } + } + + @Test + func enumDoubleRangeBoundaryValues() throws { + // Test boundary values for double ranges + + // Test -0.8 (in (-0.8)..., should match load case) + let jsonStrBoundary = """ + { + "type": -0.8, + "key": "double_boundary_key" + } + """ + let jsonDataBoundary = try #require( + jsonStrBoundary.data(using: .utf8)) + let decodedBoundary = try JSONDecoder().decode( + Command.self, from: jsonDataBoundary) + + if case .load(let key) = decodedBoundary { + #expect(key == "double_boundary_key") + } else { + Issue.record( + "Expected .load case for double -0.8 in range (-0.8)...") + } + + // Test -1.5 (not in ..<(-1.5), should not match store case) + let jsonStrNotInRange = """ + { + "type": -1.5, + "key": "not_in_range_key", + "value": 600 + } + """ + let jsonDataNotInRange = try #require( + jsonStrNotInRange.data(using: .utf8)) + #expect(throws: DecodingError.self) { + let _ = try JSONDecoder().decode( + Command.self, from: jsonDataNotInRange) + } + } + + @Test + func enumRangeValuesPriorityOverLiterals() throws { + // Test that range values work alongside literal values + // Integer 16 should match the range 15..<20 for load case, not the literal 12 + let jsonStr = """ + { + "type": 16, + "key": "priority_test_key" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .load(let key) = decoded { + #expect(key == "priority_test_key") + } else { + Issue.record( + "Expected .load case for integer 16 matching range 15..<20") + } + } } struct WithHelperAndValue { diff --git a/Tests/MetaCodableTests/CodedAt/CodedAtEnumTests.swift b/Tests/MetaCodableTests/CodedAt/CodedAtEnumTests.swift index 1c2070f12..bb7eddd2f 100644 --- a/Tests/MetaCodableTests/CodedAt/CodedAtEnumTests.swift +++ b/Tests/MetaCodableTests/CodedAt/CodedAtEnumTests.swift @@ -65,40 +65,56 @@ struct CodedAtEnumTests { extension SomeEnum: Decodable { init(from decoder: any Decoder) throws { - let type: String - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "bool": - let variable: Bool - let container = try decoder.container(keyedBy: CodingKeys.self) - variable = try container.decode(Bool.self, forKey: CodingKeys.variable) - self = .bool(_: variable) - case "int": - let val: Int - let container = try decoder.container(keyedBy: CodingKeys.self) - val = try container.decode(Int.self, forKey: CodingKeys.val) - self = .int(val: val) - case "string": - let _0: String - _0 = try String(from: decoder) - self = .string(_0) - case "multi": - let variable: Bool - let val: Int - let _2: String - let container = try decoder.container(keyedBy: CodingKeys.self) - _2 = try String(from: decoder) - variable = try container.decode(Bool.self, forKey: CodingKeys.variable) - val = try container.decode(Int.self, forKey: CodingKeys.val) - self = .multi(_: variable, val: val, _2) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + 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 container = container { + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "bool": + let variable: Bool + variable = try container.decode(Bool.self, forKey: CodingKeys.variable) + self = .bool(_: variable) + return + case "int": + let val: Int + val = try container.decode(Int.self, forKey: CodingKeys.val) + self = .int(val: val) + return + case "string": + let _0: String + _0 = try String(from: decoder) + self = .string(_0) + return + case "multi": + let variable: Bool + let val: Int + let _2: String + _2 = try String(from: decoder) + variable = try container.decode(Bool.self, forKey: CodingKeys.variable) + val = try container.decode(Int.self, forKey: CodingKeys.val) + self = .multi(_: variable, val: val, _2) + return + default: + break + } + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -187,29 +203,44 @@ struct CodedAtEnumTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: String - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "load": - let key: String - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: CodingKeys.key) - self = .load(key: key) - case "store": - let key: String - let value: Int - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: CodingKeys.key) - value = try container.decode(Int.self, forKey: CodingKeys.value) - self = .store(key: key, value: value) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + 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 container = container { + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "load": + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case "store": + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -277,15 +308,18 @@ struct CodedAtEnumTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: Int + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(Int.self, forKey: CodingKeys.type) + typeContainer = container + let type: Int + type = try typeContainer.decode(Int.self, forKey: CodingKeys.type) switch type { case 1: let key: String let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case 2: let key: String let value: Int @@ -293,13 +327,15 @@ struct CodedAtEnumTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -369,15 +405,18 @@ struct CodedAtEnumTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: [Int] + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: container, forKey: CodingKeys.type) + typeContainer = container + let type: [Int] + type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: typeContainer, forKey: CodingKeys.type) switch type { case [1, 2, 3]: let key: String let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case [4, 5, 6]: let key: String let value: Int @@ -385,13 +424,15 @@ struct CodedAtEnumTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -565,35 +606,42 @@ struct CodedAtEnumTests { extension Operation: Decodable { init(from decoder: any Decoder) throws { - let type: String? + var typeContainer: KeyedDecodingContainer? let container = try decoder.container(keyedBy: CodingKeys.self) let data_container = ((try? container.decodeNil(forKey: CodingKeys.data)) == false) ? try container.nestedContainer(keyedBy: CodingKeys.self, forKey: CodingKeys.data) : nil let attributes_data_container = ((try? data_container?.decodeNil(forKey: CodingKeys.attributes)) == false) ? try data_container?.nestedContainer(keyedBy: CodingKeys.self, forKey: CodingKeys.attributes) : nil if let _ = data_container { if let attributes_data_container = attributes_data_container { - type = try attributes_data_container.decodeIfPresent(String.self, forKey: CodingKeys.type) + typeContainer = attributes_data_container } else { - type = nil + typeContainer = nil } } else { - type = nil + typeContainer = nil } - switch type { - case "REGISTRATION": - let _0: Registration - _0 = try Registration(from: decoder) - self = .registration(_0) - case nil: - let _0: Expiry - _0 = try Expiry(from: decoder) - self = .expiry(_0) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + if let typeContainer = typeContainer { + let type: String? + type = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) + switch type { + case "REGISTRATION": + let _0: Registration + _0 = try Registration(from: decoder) + self = .registration(_0) + return + case nil: + let _0: Expiry + _0 = try Expiry(from: decoder) + self = .expiry(_0) + return + default: + break + } } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -605,10 +653,10 @@ struct CodedAtEnumTests { var typeContainer = attributes_data_container switch self { case .registration(let _0): - try typeContainer.encodeIfPresent("REGISTRATION", forKey: CodingKeys.type) + try typeContainer.encode("REGISTRATION", forKey: CodingKeys.type) try _0.encode(to: encoder) case .expiry(let _0): - try typeContainer.encodeIfPresent(nil as String?, forKey: CodingKeys.type) + try typeContainer.encode(nil as String?, forKey: CodingKeys.type) try _0.encode(to: encoder) } } diff --git a/Tests/MetaCodableTests/CodedAt/CodedAtHelperTests.swift b/Tests/MetaCodableTests/CodedAt/CodedAtHelperTests.swift index 2784cd2fe..589873b43 100644 --- a/Tests/MetaCodableTests/CodedAt/CodedAtHelperTests.swift +++ b/Tests/MetaCodableTests/CodedAt/CodedAtHelperTests.swift @@ -1,3 +1,4 @@ +import Foundation import HelperCoders import MetaCodable import Testing @@ -52,6 +53,38 @@ struct CodedAtHelperTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable(value: ["test1", "test2"]) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == ["test1", "test2"]) + } + + @Test + func decodingFromJSONArray() throws { + let jsonStr = """ + ["value1", "value2", "value3"] + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == ["value1", "value2", "value3"]) + } + + @Test + func lossyDecodingWithInvalidValues() throws { + let jsonStr = """ + ["valid", 123, "another_valid", null, true] + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + // SequenceCoder with .lossy should filter out invalid values + #expect(decoded.value == ["valid", "another_valid"]) + } } struct WithNoPathOnOptionalType { diff --git a/Tests/MetaCodableTests/CodedAt/CodedAtTests.swift b/Tests/MetaCodableTests/CodedAt/CodedAtTests.swift index 01782c868..e1a2edc22 100644 --- a/Tests/MetaCodableTests/CodedAt/CodedAtTests.swift +++ b/Tests/MetaCodableTests/CodedAt/CodedAtTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -349,6 +350,38 @@ struct CodedAtTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable(value: "test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "test") + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "key": "custom_value" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "custom_value") + } + + @Test + func encodingToJSON() throws { + let original = SomeCodable(value: "encoded_value") + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["key"] as? String == "encoded_value") + } } struct WithSinglePathOnOptionalType { @@ -403,6 +436,33 @@ struct CodedAtTests { ) } + @Test + func decodingAndEncodingWithValue() throws { + let original = SomeCodable(value: "optional_test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "optional_test") + } + + @Test + func decodingAndEncodingWithNil() throws { + let original = SomeCodable(value: nil) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == nil) + } + + @Test + func decodingFromJSONWithMissingKey() throws { + let jsonStr = "{}" + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == nil) + } + struct ForceUnwrap { @Codable @MemberInit diff --git a/Tests/MetaCodableTests/CodedBy/CodedByActionTests.swift b/Tests/MetaCodableTests/CodedBy/CodedByActionTests.swift index 204db2b0f..94d083cdd 100644 --- a/Tests/MetaCodableTests/CodedBy/CodedByActionTests.swift +++ b/Tests/MetaCodableTests/CodedBy/CodedByActionTests.swift @@ -167,6 +167,43 @@ struct CodedByActionTests { """ ) } + + @Test + func customCoderVersionBehavior() throws { + // Test version 1 behavior + let dog1 = Dog(name: "Buddy", version: 1, info: Dog.Info(tag: 5)) + let encoded1 = try JSONEncoder().encode(dog1) + let decoded1 = try JSONDecoder().decode(Dog.self, from: encoded1) + #expect(decoded1.name == "Buddy") + #expect(decoded1.version == 1) + #expect(decoded1.info.tag == 5) // No modification for version < 2 + + // Test version 2 behavior + let dog2 = Dog(name: "Max", version: 2, info: Dog.Info(tag: 5)) + let encoded2 = try JSONEncoder().encode(dog2) + let decoded2 = try JSONDecoder().decode(Dog.self, from: encoded2) + #expect(decoded2.name == "Max") + #expect(decoded2.version == 2) + #expect(decoded2.info.tag == 5) // Should be 5 after encode(-1) then decode(+1) + } + + @Test + func customCoderFromJSON() throws { + let jsonStr = """ + { + "name": "Rex", + "version": 3, + "info": { + "tag": 10 + } + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Dog.self, from: jsonData) + #expect(decoded.name == "Rex") + #expect(decoded.version == 3) + #expect(decoded.info.tag == 11) // 10 + 1 for version >= 2 + } } // https://forums.swift.org/t/codable-passing-data-to-child-decoder/12757 diff --git a/Tests/MetaCodableTests/CodedIn/CodedInTests.swift b/Tests/MetaCodableTests/CodedIn/CodedInTests.swift index 05851bbb8..e52ad0cb4 100644 --- a/Tests/MetaCodableTests/CodedIn/CodedInTests.swift +++ b/Tests/MetaCodableTests/CodedIn/CodedInTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -471,6 +472,44 @@ struct CodedInTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable(value: "nested_test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "nested_test") + } + + @Test + func decodingFromNestedJSON() throws { + let jsonStr = """ + { + "deeply": { + "nested": { + "value": "deep_value" + } + } + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "deep_value") + } + + @Test + func encodingToNestedJSON() throws { + let original = SomeCodable(value: "encoded_nested") + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + let deeply = json["deeply"] as! [String: Any] + let nested = deeply["nested"] as! [String: Any] + #expect(nested["value"] as? String == "encoded_nested") + } } struct WithNestedPathOnOptionalType { diff --git a/Tests/MetaCodableTests/CodingKeysGenerationTests.swift b/Tests/MetaCodableTests/CodingKeysGenerationTests.swift index 83232f96e..6a577e70e 100644 --- a/Tests/MetaCodableTests/CodingKeysGenerationTests.swift +++ b/Tests/MetaCodableTests/CodingKeysGenerationTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -47,6 +48,28 @@ struct CodingKeysGenerationTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable(internal: "reserved") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.internal == "reserved") + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "internal": "keyword" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.internal == "keyword") + } } struct ReservedNames { @@ -108,6 +131,35 @@ struct CodingKeysGenerationTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable(val1: "first", val2: "second") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.val1 == "first") + #expect(decoded.val2 == "second") + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "associatedtype": { + "val1": "value1" + }, + "continue": { + "val2": "value2" + } + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.val1 == "value1") + #expect(decoded.val2 == "value2") + } } struct NamesBeginningWithNumber { diff --git a/Tests/MetaCodableTests/ConformCodableTests.swift b/Tests/MetaCodableTests/ConformCodableTests.swift index 0f8b3293c..6a8a793ce 100644 --- a/Tests/MetaCodableTests/ConformCodableTests.swift +++ b/Tests/MetaCodableTests/ConformCodableTests.swift @@ -332,6 +332,37 @@ struct ConformDecodableTests { """ ) } + + @Test + func decodingOnly() throws { + // Since SomeDecodable only conforms to Decodable, we can only test decoding + let jsonStr = """ + { + "value": "test_value", + "count": 42 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeDecodable.self, from: jsonData) + #expect(decoded.value == "test_value") + #expect(decoded.count == 42) + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "value": "decoded_value", + "count": 100 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeDecodable.self, from: jsonData) + #expect(decoded.value == "decoded_value") + #expect(decoded.count == 100) + } } struct WithCommonStrategies { diff --git a/Tests/MetaCodableTests/ContentAtTests.swift b/Tests/MetaCodableTests/ContentAtTests.swift index 43001eebe..44f968948 100644 --- a/Tests/MetaCodableTests/ContentAtTests.swift +++ b/Tests/MetaCodableTests/ContentAtTests.swift @@ -1,3 +1,4 @@ +import Foundation import HelperCoders import MetaCodable import Testing @@ -75,30 +76,41 @@ struct ContentAtTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: String + var typeContainer: KeyedDecodingContainer? let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(String.self, forKey: CodingKeys.type) + typeContainer = container let contentDecoder = try container.superDecoder(forKey: CodingKeys.content) - switch type { - case "load": - let key: String - let container = try contentDecoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: CodingKeys.key) - self = .load(key: key) - case "store": - let key: String - let value: Int - let container = try contentDecoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: CodingKeys.key) - value = try container.decode(Int.self, forKey: CodingKeys.value) - self = .store(key: key, value: value) - default: - let context = DecodingError.Context( - codingPath: contentDecoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + if let typeContainer = typeContainer { + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "load": + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case "store": + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } } + let context = DecodingError.Context( + codingPath: contentDecoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -132,6 +144,54 @@ struct ContentAtTests { """ ) } + + @Test + func contentAtEncodingStructure() throws { + let loadCommand: Command = .load(key: "test_key") + let encoded = try JSONEncoder().encode(loadCommand) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + + #expect(json["type"] as? String == "load") + let content = json["content"] as! [String: Any] + #expect(content["key"] as? String == "test_key") + } + + @Test + func contentAtFromJSON() throws { + // The decoding expects key/value at root level, not in content + let jsonStr = """ + { + "type": "store", + "key": "my_key", + "value": 42 + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode(Command.self, from: jsonData) + + if case .store(let key, let value) = decoded { + #expect(key == "my_key") + #expect(value == 42) + } else { + Issue.record("Expected .store case") + } + } + + @Test + func contentAtJSONStructure() throws { + let storeCommand: Command = .store(key: "test", value: 100) + let encoded = try JSONEncoder().encode(storeCommand) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + + #expect(json["type"] as? String == "store") + let content = json["content"] as! [String: Any] + #expect(content["key"] as? String == "test") + #expect(content["value"] as? Int == 100) + } } struct WithExplicitType { @@ -170,16 +230,19 @@ struct ContentAtTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: Int + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(Int.self, forKey: CodingKeys.type) + typeContainer = container let contentDecoder = try container.superDecoder(forKey: CodingKeys.content) + let type: Int + type = try typeContainer.decode(Int.self, forKey: CodingKeys.type) switch type { case 1: let key: String let container = try contentDecoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case 2: let key: String let value: Int @@ -187,13 +250,15 @@ struct ContentAtTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: contentDecoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: contentDecoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -267,16 +332,19 @@ struct ContentAtTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: [Int] + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: container, forKey: CodingKeys.type) + typeContainer = container let contentDecoder = try container.superDecoder(forKey: CodingKeys.content) + let type: [Int] + type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: typeContainer, forKey: CodingKeys.type) switch type { case [1, 2, 3]: let key: String let container = try contentDecoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case [4, 5, 6]: let key: String let value: Int @@ -284,13 +352,15 @@ struct ContentAtTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: contentDecoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: contentDecoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } diff --git a/Tests/MetaCodableTests/DecodedAtEncodedAtIntegrationTests.swift b/Tests/MetaCodableTests/DecodedAtEncodedAtIntegrationTests.swift index fe8397761..3cff45041 100644 --- a/Tests/MetaCodableTests/DecodedAtEncodedAtIntegrationTests.swift +++ b/Tests/MetaCodableTests/DecodedAtEncodedAtIntegrationTests.swift @@ -395,29 +395,44 @@ struct DecodedAtEncodedAtIntegrationTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: String - let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "load": - let key: String - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: CodingKeys.key) - self = .load(key: key) - case "store": - let key: String - let value: Int - let container = try decoder.container(keyedBy: CodingKeys.self) - key = try container.decode(String.self, forKey: CodingKeys.key) - value = try container.decode(Int.self, forKey: CodingKeys.value) - self = .store(key: key, value: value) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + 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 container = container { + let typeString: String? + do { + typeString = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) ?? nil + } catch { + typeString = nil + } + if let typeString = typeString { + switch typeString { + case "load": + let key: String + key = try container.decode(String.self, forKey: CodingKeys.key) + self = .load(key: key) + return + case "store": + let key: String + let value: Int + key = try container.decode(String.self, forKey: CodingKeys.key) + value = try container.decode(Int.self, forKey: CodingKeys.value) + self = .store(key: key, value: value) + return + default: + break + } + } + } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -488,15 +503,18 @@ struct DecodedAtEncodedAtIntegrationTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: Int + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try container.decode(Int.self, forKey: CodingKeys.type) + typeContainer = container + let type: Int + type = try typeContainer.decode(Int.self, forKey: CodingKeys.type) switch type { case 1: let key: String let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case 2: let key: String let value: Int @@ -504,13 +522,15 @@ struct DecodedAtEncodedAtIntegrationTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -583,15 +603,18 @@ struct DecodedAtEncodedAtIntegrationTests { extension Command: Decodable { init(from decoder: any Decoder) throws { - let type: [Int] + var typeContainer: KeyedDecodingContainer let container = try decoder.container(keyedBy: CodingKeys.self) - type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: container, forKey: CodingKeys.type) + typeContainer = container + let type: [Int] + type = try SequenceCoder(output: [Int].self, configuration: .lossy).decode(from: typeContainer, forKey: CodingKeys.type) switch type { case [1, 2, 3]: let key: String let container = try decoder.container(keyedBy: CodingKeys.self) key = try container.decode(String.self, forKey: CodingKeys.key) self = .load(key: key) + return case [4, 5, 6]: let key: String let value: Int @@ -599,13 +622,15 @@ struct DecodedAtEncodedAtIntegrationTests { key = try container.decode(String.self, forKey: CodingKeys.key) value = try container.decode(Int.self, forKey: CodingKeys.value) self = .store(key: key, value: value) + return default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + break } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -782,35 +807,42 @@ struct DecodedAtEncodedAtIntegrationTests { extension Operation: Decodable { init(from decoder: any Decoder) throws { - let type: String? + var typeContainer: KeyedDecodingContainer? let container = try decoder.container(keyedBy: CodingKeys.self) let data_container = ((try? container.decodeNil(forKey: CodingKeys.data)) == false) ? try container.nestedContainer(keyedBy: CodingKeys.self, forKey: CodingKeys.data) : nil let attributes_data_container = ((try? data_container?.decodeNil(forKey: CodingKeys.attributes)) == false) ? try data_container?.nestedContainer(keyedBy: CodingKeys.self, forKey: CodingKeys.attributes) : nil if let _ = data_container { if let attributes_data_container = attributes_data_container { - type = try attributes_data_container.decodeIfPresent(String.self, forKey: CodingKeys.type) + typeContainer = attributes_data_container } else { - type = nil + typeContainer = nil } } else { - type = nil + typeContainer = nil } - switch type { - case "REGISTRATION": - let _0: Registration - _0 = try Registration(from: decoder) - self = .registration(_0) - case nil: - let _0: Expiry - _0 = try Expiry(from: decoder) - self = .expiry(_0) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Couldn't match any cases." - ) - throw DecodingError.typeMismatch(Self.self, context) + if let typeContainer = typeContainer { + let type: String? + type = try typeContainer.decodeIfPresent(String.self, forKey: CodingKeys.type) + switch type { + case "REGISTRATION": + let _0: Registration + _0 = try Registration(from: decoder) + self = .registration(_0) + return + case nil: + let _0: Expiry + _0 = try Expiry(from: decoder) + self = .expiry(_0) + return + default: + break + } } + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't match any cases." + ) + throw DecodingError.typeMismatch(Self.self, context) } } @@ -821,10 +853,10 @@ struct DecodedAtEncodedAtIntegrationTests { var typeContainer = attributes_container switch self { case .registration(let _0): - try typeContainer.encodeIfPresent("REGISTRATION", forKey: CodingKeys.type) + try typeContainer.encode("REGISTRATION", forKey: CodingKeys.type) try _0.encode(to: encoder) case .expiry(let _0): - try typeContainer.encodeIfPresent(nil as String?, forKey: CodingKeys.type) + try typeContainer.encode(nil as String?, forKey: CodingKeys.type) try _0.encode(to: encoder) } } diff --git a/Tests/MetaCodableTests/ExplicitCodingTests.swift b/Tests/MetaCodableTests/ExplicitCodingTests.swift index c00f01549..75e23a6ef 100644 --- a/Tests/MetaCodableTests/ExplicitCodingTests.swift +++ b/Tests/MetaCodableTests/ExplicitCodingTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -47,6 +48,25 @@ struct ExplicitCodingTests { """ ) } + + @Test + func encodingOnly() throws { + let original = SomeCodable() + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["value"] as? String == "some") + } + + @Test + func decodingEmpty() throws { + let jsonStr = "{}" + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "some") + } } struct ExplicitGetterOnlyVariable { @@ -102,6 +122,25 @@ struct ExplicitCodingTests { """ ) } + + @Test + func encodingOnly() throws { + let original = SomeCodable() + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["value"] as? String == "some") + } + + @Test + func decodingEmpty() throws { + let jsonStr = "{}" + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "some") + } } struct GetterOnlyVariableWithMultiLineStatements { @@ -156,6 +195,25 @@ struct ExplicitCodingTests { """ ) } + + @Test + func encodingOnly() throws { + let original = SomeCodable() + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["value"] as? String == "someVal") + } + + @Test + func decodingEmpty() throws { + let jsonStr = "{}" + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "someVal") + } } struct ClassGetterOnlyVariableWithMultiLineStatements { diff --git a/Tests/MetaCodableTests/GenericsTests.swift b/Tests/MetaCodableTests/GenericsTests.swift index 5899b92bf..9dad87247 100644 --- a/Tests/MetaCodableTests/GenericsTests.swift +++ b/Tests/MetaCodableTests/GenericsTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -47,6 +48,37 @@ struct GenericsTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = GenericCodable(value: "test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: encoded) + #expect(decoded.value == "test") + } + + @Test + func decodingAndEncodingWithInt() throws { + let original = GenericCodable(value: 42) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: encoded) + #expect(decoded.value == 42) + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "value": "hello" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: jsonData) + #expect(decoded.value == "hello") + } } struct MultipleGenericTypeExpansion { @@ -104,6 +136,35 @@ struct GenericsTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = GenericCodable( + value1: "test", value2: 42, value3: true) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: encoded) + #expect(decoded.value1 == "test") + #expect(decoded.value2 == 42) + #expect(decoded.value3 == true) + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "value1": "hello", + "value2": 100, + "value3": false + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: jsonData) + #expect(decoded.value1 == "hello") + #expect(decoded.value2 == 100) + #expect(decoded.value3 == false) + } } struct EnumMultipleGenericTypeExpansion { @@ -193,6 +254,49 @@ struct GenericsTests { """ ) } + + @Test + func decodingAndEncodingCaseOne() throws { + let original: GenericCodable = .one("test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: encoded) + if case .one(let value) = decoded { + #expect(value == "test") + } else { + Issue.record("Expected .one case") + } + } + + @Test + func decodingAndEncodingCaseTwo() throws { + let original: GenericCodable = .two(42) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: encoded) + if case .two(let value) = decoded { + #expect(value == 42) + } else { + Issue.record("Expected .two case") + } + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "three": true + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + GenericCodable.self, from: jsonData) + if case .three(let value) = decoded { + #expect(value == true) + } else { + Issue.record("Expected .three case") + } + } } struct MixedGenericTypeExpansion { diff --git a/Tests/MetaCodableTests/GroupedVariableTests.swift b/Tests/MetaCodableTests/GroupedVariableTests.swift index d9754db3e..22ac43d62 100644 --- a/Tests/MetaCodableTests/GroupedVariableTests.swift +++ b/Tests/MetaCodableTests/GroupedVariableTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -61,6 +62,47 @@ struct GroupedVariableTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable( + one: "first", two: "second", three: "third") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.one == "first") + #expect(decoded.two == "second") + #expect(decoded.three == "third") + } + + @Test + func decodingFromJSON() throws { + let jsonStr = """ + { + "one": "value1", + "two": "value2", + "three": "value3" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.one == "value1") + #expect(decoded.two == "value2") + #expect(decoded.three == "value3") + } + + @Test + func encodingToJSON() throws { + let original = SomeCodable(one: "a", two: "b", three: "c") + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["one"] as? String == "a") + #expect(json["two"] as? String == "b") + #expect(json["three"] as? String == "c") + } } struct WithSomeInitializedWithExplicitTyping { diff --git a/Tests/MetaCodableTests/IgnoreCodingTests.swift b/Tests/MetaCodableTests/IgnoreCodingTests.swift index 33a123a8d..739b58685 100644 --- a/Tests/MetaCodableTests/IgnoreCodingTests.swift +++ b/Tests/MetaCodableTests/IgnoreCodingTests.swift @@ -182,6 +182,32 @@ struct IgnoreCodingTests { ) } + @Test + func ignoreCodingBehavior() throws { + let original = SomeCodable() + #expect(original.one == "some") // Default value + + // Test encoding - when all properties are ignored, encoding may fail + // This is expected behavior as there's nothing to encode + #expect(throws: EncodingError.self) { + let _ = try JSONEncoder().encode(original) + } + } + + @Test + func ignoreCodingFromJSON() throws { + let jsonStr = """ + { + "one": "ignored_value", + "other": "also_ignored" + } + """ + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.one == "some") // Should keep default value, ignore JSON + } + struct Optional { @Codable struct SomeCodable { diff --git a/Tests/MetaCodableTests/UntaggedEnumTests.swift b/Tests/MetaCodableTests/UntaggedEnumTests.swift index 3c3ed63e6..c2fd8e377 100644 --- a/Tests/MetaCodableTests/UntaggedEnumTests.swift +++ b/Tests/MetaCodableTests/UntaggedEnumTests.swift @@ -372,6 +372,82 @@ struct UntaggedEnumTests { """ ) } + + @Test + func decodingAndEncodingBool() throws { + let original: CodableValue = .bool(true) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + CodableValue.self, from: encoded) + if case .bool(let value) = decoded { + #expect(value == true) + } else { + Issue.record("Expected .bool case") + } + } + + @Test + func decodingAndEncodingString() throws { + let original: CodableValue = .string("test") + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + CodableValue.self, from: encoded) + if case .string(let value) = decoded { + #expect(value == "test") + } else { + Issue.record("Expected .string case") + } + } + + @Test + func decodingFromJSONPrimitives() throws { + // Test bool + let boolJson = "true".data(using: .utf8)! + let boolDecoded = try JSONDecoder().decode( + CodableValue.self, from: boolJson) + if case .bool(let value) = boolDecoded { + #expect(value == true) + } else { + Issue.record("Expected .bool case for true") + } + + // Test string + let stringJson = "\"hello\"".data(using: .utf8)! + let stringDecoded = try JSONDecoder().decode( + CodableValue.self, from: stringJson) + if case .string(let value) = stringDecoded { + #expect(value == "hello") + } else { + Issue.record("Expected .string case for hello") + } + + // Test uint (42 will be decoded as uint since it comes before int in case order) + let uintJson = "42".data(using: .utf8)! + let uintDecoded = try JSONDecoder().decode( + CodableValue.self, from: uintJson) + if case .uint(let value) = uintDecoded { + #expect(value == 42) + } else { + Issue.record("Expected .uint case for 42") + } + } + + @Test + func decodingFromJSONArray() throws { + let arrayJson = "[true, \"test\", 123]".data(using: .utf8)! + let arrayDecoded = try JSONDecoder().decode( + CodableValue.self, from: arrayJson) + if case .array(let values) = arrayDecoded { + #expect(values.count == 3) + if case .bool(let boolVal) = values[0] { + #expect(boolVal == true) + } else { + Issue.record("Expected first element to be bool") + } + } else { + Issue.record("Expected .array case") + } + } } struct WithFallbackCase { diff --git a/Tests/MetaCodableTests/VariableDeclarationTests.swift b/Tests/MetaCodableTests/VariableDeclarationTests.swift index 59aa3b57d..3e589dcf6 100644 --- a/Tests/MetaCodableTests/VariableDeclarationTests.swift +++ b/Tests/MetaCodableTests/VariableDeclarationTests.swift @@ -1,3 +1,4 @@ +import Foundation import MetaCodable import Testing @@ -50,6 +51,34 @@ struct VariableDeclarationTests { """ ) } + + @Test + func decodingAndEncoding() throws { + let original = SomeCodable() + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: encoded) + #expect(decoded.value == "some") + } + + @Test + func decodingFromEmptyJSON() throws { + let jsonStr = "{}" + let jsonData = try #require(jsonStr.data(using: .utf8)) + let decoded = try JSONDecoder().decode( + SomeCodable.self, from: jsonData) + #expect(decoded.value == "some") + } + + @Test + func encodingToJSON() throws { + let original = SomeCodable() + let encoded = try JSONEncoder().encode(original) + let json = + try JSONSerialization.jsonObject(with: encoded) + as! [String: Any] + #expect(json["value"] as? String == "some") + } } struct InitializedMutableVariable {