diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..0b5e0e2d52 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +# Code owners must always be teams, never individuals. + +# Global rule: +* @euc-eng/g-euc-github-continuity-admins diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67b9cc8daa..97c033b0c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,16 +11,34 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: monthly + interval: weekly commit-message: prefix: 'ci(Deps): ' + cooldown: + default-days: 5 + include: + - '*' + groups: + all: + applies-to: version-updates + patterns: + - '*' - package-ecosystem: npm directory: / schedule: - interval: monthly + interval: daily commit-message: prefix: 'ci(Deps): ' + cooldown: + default-days: 3 + include: + - '*' + groups: + all: + applies-to: version-updates + patterns: + - '*' - package-ecosystem: devcontainers directory: / diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b65f6103ae..44666686fd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,7 +50,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - name: Spellcheck Action - uses: rojopolis/spellcheck-github-actions@0.60.0 + uses: step-security/spellcheck-github-actions@158f059d02d7d87190bb83f9cdd99922317ce067 # v0.66.0 with: config_path: .github/config/spellcheck.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 197ea18508..c72b1f9824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +### 🚀 Features + +* added opt-in strict decoding that rejects unsupported keys with + `rejectUnknownKeys` on `Codable` and `ConformDecodable` + ## [1.6.2](https://github.com/SwiftyLab/MetaCodable/compare/v1.6.1...v1.6.2) (2026-10-23) @@ -215,4 +222,3 @@ ### 💄 Styles * add development with vscode support ([323a94a](https://github.com/SwiftyLab/MetaCodable/commit/323a94a3a1e824a2b93c1aecb51d47a90c0ec3e3)) - diff --git a/README.md b/README.md index 121a3a5b92..dae4629397 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Supercharge `Swift`'s `Codable` implementations with macros. - 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 opt-in rejection of unsupported decoding keys with the `rejectUnknownKeys` parameter on ``Codable(commonStrategies:rejectUnknownKeys:)`` and ``ConformDecodable(commonStrategies:rejectUnknownKeys:)``. - Allows specifying different case values with ``CodedAs(_:_:)`` and case value/protocol type identifier type different from `String` with ``CodedAs()``. - 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()``. diff --git a/Sources/MetaCodable/Codable/Codable.swift b/Sources/MetaCodable/Codable/Codable.swift index 56c47d5a59..fbcf3bfc42 100644 --- a/Sources/MetaCodable/Codable/Codable.swift +++ b/Sources/MetaCodable/Codable/Codable.swift @@ -50,6 +50,8 @@ /// - Parameters: /// - commonStrategies: An array of CodableCommonStrategy values specifying /// type conversion strategies to be automatically applied to all properties of the type. +/// - rejectUnknownKeys: Whether decoding should fail when a keyed container +/// contains keys that are not recognized by the generated implementation. /// /// - Important: The attached declaration must be of a `struct`, `class`, `enum` /// or `actor` type. [See the limitations for this macro](). @@ -63,7 +65,10 @@ names: named(CodingKeys), named(init(from:)), named(encode(to:)) ) @available(swift 5.9) -public macro Codable(commonStrategies: [CodableCommonStrategy] = []) = +public macro Codable( + commonStrategies: [CodableCommonStrategy] = [], + rejectUnknownKeys: Bool = false +) = #externalMacro(module: "MacroPlugin", type: "Codable") /// Indicates whether super class conforms to `Codable` or not. diff --git a/Sources/MetaCodable/Codable/Decodable.swift b/Sources/MetaCodable/Codable/Decodable.swift index 337ee6c6fb..b4da6cd7a1 100644 --- a/Sources/MetaCodable/Codable/Decodable.swift +++ b/Sources/MetaCodable/Codable/Decodable.swift @@ -44,6 +44,8 @@ /// - Parameters: /// - commonStrategies: An array of CodableCommonStrategy values specifying /// type conversion strategies to be automatically applied to all properties of the type. +/// - rejectUnknownKeys: Whether decoding should fail when a keyed container +/// contains keys that are not recognized by the generated implementation. /// /// - Important: The attached declaration must be of a `struct`, `class`, `enum` /// or `actor` type. [See the limitations for this macro](). @@ -56,5 +58,8 @@ names: named(CodingKeys), named(init(from:)) ) @available(swift 5.9) -public macro ConformDecodable(commonStrategies: [CodableCommonStrategy] = []) = +public macro ConformDecodable( + commonStrategies: [CodableCommonStrategy] = [], + rejectUnknownKeys: Bool = false +) = #externalMacro(module: "MacroPlugin", type: "ConformDecodable") diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift new file mode 100644 index 0000000000..718ca124df --- /dev/null +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -0,0 +1,510 @@ +import Foundation + +private protocol MetaCodableDictionary {} + +extension Dictionary: MetaCodableDictionary {} + +/// A decoder wrapper that carries inherited strict-decoding key metadata. +public struct MetaCodableStrictDecoder: Decoder { + private let base: any Decoder + fileprivate let state: State + + /// Creates a decoder wrapper with additional keys accepted at each path. + /// + /// - Parameters: + /// - decoder: The decoder to wrap. + /// - allowedKeysByPath: Additional accepted keys keyed by dot-separated + /// coding paths. The empty string represents the root container. + public init( + decoder: any Decoder, + allowedKeysByPath: [String: [String]] + ) { + if let decoder = decoder as? MetaCodableStrictDecoder { + self.base = decoder.base + let codingPath = decoder.codingPath.map(\.stringValue) + if codingPath == decoder.state.rootCodingPath { + self.state = decoder.state + self.state.merge(allowedKeysByPath: allowedKeysByPath) + } else { + self.state = .init( + allowedKeysByPath: allowedKeysByPath, + rootCodingPath: codingPath + ) + } + } else { + self.base = decoder + self.state = .init( + allowedKeysByPath: allowedKeysByPath, + rootCodingPath: decoder.codingPath.map(\.stringValue) + ) + } + } + + /// Decodes an optional value while preserving strict-decoding metadata. + public static func decodeOptional( + _ type: Value.Type, + from decoder: any Decoder + ) throws -> Value? { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + return nil + } + let isStrictDecoder = decoder is MetaCodableStrictDecoder + if !isStrictDecoder + || Value.self == Date.self + || Value.self == Data.self + || Value.self == Decimal.self + || Value.self is any MetaCodableDictionary.Type + { + return try container.decode(Value.self) + } + return try Value(from: decoder) + } + + /// Begins an isolated decoding attempt for backtracking decoders. + public static func beginAttempt(for decoder: any Decoder) -> Attempt { + guard let strictDecoder = decoder as? MetaCodableStrictDecoder + else { + return Attempt(decoder: decoder) + } + + let isolatedState = strictDecoder.state.copy() + let isolatedDecoder = MetaCodableStrictDecoder( + base: strictDecoder.base, + state: isolatedState + ) + return Attempt( + decoder: isolatedDecoder, + commit: { + strictDecoder.state.replace(with: isolatedState) + } + ) + } + + /// An isolated decoding attempt whose state is committed only on success. + public final class Attempt { + /// The decoder used by this attempt. + public let decoder: any Decoder + private let commitAction: (() -> Void)? + private var finished = false + + fileprivate init(decoder: any Decoder) { + self.decoder = decoder + self.commitAction = nil + } + + fileprivate init( + decoder: any Decoder, + commit: @escaping () -> Void + ) { + self.decoder = decoder + self.commitAction = commit + } + + /// Commits observed metadata and consumed keys to the parent decoder. + public func commit() { + guard !finished else { return } + finished = true + commitAction?() + } + + /// Discards all metadata and consumed keys observed by the attempt. + public func rollback() { + finished = true + } + } + + fileprivate init(base: any Decoder, state: State) { + self.base = base + self.state = state + } + + /// The coding path of the wrapped decoder. + public var codingPath: [any CodingKey] { base.codingPath } + /// The user information of the wrapped decoder. + public var userInfo: [CodingUserInfoKey: Any] { base.userInfo } + + /// Creates a keyed container that preserves inherited key metadata. + public func container( + keyedBy type: Key.Type + ) throws -> KeyedDecodingContainer { + let container = try base.container(keyedBy: type) + state.recordContainer(at: container.codingPath) + return KeyedDecodingContainer( + StrictKeyedDecodingContainer(base: container, state: state) + ) + } + + /// Creates an unkeyed container from the wrapped decoder. + public func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + try base.unkeyedContainer() + } + + /// Creates a single-value container from the wrapped decoder. + public func singleValueContainer() throws + -> any SingleValueDecodingContainer + { + try base.singleValueContainer() + } + + fileprivate func additionalKeys(at path: [any CodingKey]) -> Set { + state.additionalKeys(at: path) + } + + fileprivate final class State { + var allowedKeysByPath: [String: Set] + var observedKeysByPath: [String: Set] = [:] + var observedContainerPaths: Set = [] + var terminalDecoderPaths: Set = [] + let rootCodingPath: [String] + + init( + allowedKeysByPath: [String: [String]], + rootCodingPath: [String] + ) { + self.allowedKeysByPath = allowedKeysByPath.mapValues(Set.init) + self.rootCodingPath = rootCodingPath + } + + func record(key: any CodingKey, at path: [any CodingKey]) { + let path = self.path(for: path) + observedKeysByPath[path, default: []].insert(key.stringValue) + } + + func recordContainer(at path: [any CodingKey]) { + observedContainerPaths.insert(self.path(for: path)) + } + + func recordTerminalDecoder(at path: [any CodingKey]) { + terminalDecoderPaths.insert(self.path(for: path)) + } + + func clearTerminalDecoder(at path: [any CodingKey]) { + terminalDecoderPaths.remove(self.path(for: path)) + } + + func hasContainerPath(_ path: String) -> Bool { + observedContainerPaths.contains(path) + } + + func hasTerminalDecoder(at path: [any CodingKey]) -> Bool { + terminalDecoderPaths.contains(self.path(for: path)) + } + + func additionalKeys(at path: [any CodingKey]) -> Set { + let path = self.path(for: path) + return (allowedKeysByPath[path] ?? []).union( + observedKeysByPath[path] ?? [] + ) + } + + func merge(allowedKeysByPath: [String: [String]]) { + for (path, keys) in allowedKeysByPath { + self.allowedKeysByPath[path, default: []].formUnion(keys) + } + } + + func copy() -> State { + let copy = State( + allowedKeysByPath: allowedKeysByPath.mapValues(Array.init), + rootCodingPath: rootCodingPath + ) + copy.observedKeysByPath = observedKeysByPath + copy.observedContainerPaths = observedContainerPaths + copy.terminalDecoderPaths = terminalDecoderPaths + return copy + } + + func replace(with other: State) { + allowedKeysByPath = other.allowedKeysByPath + observedKeysByPath = other.observedKeysByPath + observedContainerPaths = other.observedContainerPaths + terminalDecoderPaths = other.terminalDecoderPaths + } + + fileprivate func path(for codingPath: [any CodingKey]) -> String { + let components = codingPath.map(\.stringValue) + let relative = + components.starts(with: rootCodingPath) + ? Array(components.dropFirst(rootCodingPath.count)) + : components + return relative.map { component in + "\(component.utf8.count):\(component)" + }.joined(separator: "|") + } + } +} + +private struct StrictKeyedDecodingContainer: + KeyedDecodingContainerProtocol +{ + let base: KeyedDecodingContainer + let state: MetaCodableStrictDecoder.State + + var codingPath: [any CodingKey] { base.codingPath } + var allKeys: [Key] { base.allKeys } + + func contains(_ key: Key) -> Bool { + state.record(key: key, at: codingPath) + return base.contains(key) + } + + func decodeNil(forKey key: Key) throws -> Bool { + state.record(key: key, at: codingPath) + return try base.decodeNil(forKey: key) + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + #if swift(>=6) + @available( + macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, * + ) + func decode(_ type: Int128.Type, forKey key: Key) throws -> Int128 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + @available( + macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, * + ) + func decode(_ type: UInt128.Type, forKey key: Key) throws -> UInt128 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + #endif + + func decode( + _ type: T.Type, + forKey key: Key + ) throws -> T { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer { + state.record(key: key, at: codingPath) + state.recordContainer(at: codingPath + [key]) + let nested = try base.nestedContainer(keyedBy: type, forKey: key) + return KeyedDecodingContainer( + StrictKeyedDecodingContainer(base: nested, state: state) + ) + } + + func nestedUnkeyedContainer( + forKey key: Key + ) throws -> any UnkeyedDecodingContainer { + state.record(key: key, at: codingPath) + return try base.nestedUnkeyedContainer(forKey: key) + } + + func superDecoder() throws -> any Decoder { + let decoder = try base.superDecoder() + if decoder.codingPath.count > codingPath.count, + let key = decoder.codingPath.last + { + state.record(key: key, at: codingPath) + } + return MetaCodableStrictDecoder(base: decoder, state: state) + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + state.record(key: key, at: codingPath) + let decoder = try base.superDecoder(forKey: key) + state.recordTerminalDecoder(at: decoder.codingPath) + return MetaCodableStrictDecoder(base: decoder, state: state) + } +} + +public extension Decoder { + /// Throws when the keyed value contains keys outside the provided set. + /// + /// - Parameters: + /// - allowedKeys: The string values recognized by the generated decoder + /// at this container level. + /// - type: The type being decoded, used in the error context. + /// - Throws: ``DecodingError/dataCorrupted(_:)`` when unsupported keys + /// are present or when the container cannot be inspected. + func rejectUnknownKeys( + allowedKeys: [String], + type: Any.Type + ) throws { + if let strictDecoder = self as? MetaCodableStrictDecoder { + strictDecoder.state.clearTerminalDecoder(at: codingPath) + } + let rawContainer = try container( + keyedBy: DynamicCodableIdentifier.self + ) + let inheritedKeys = + (self as? MetaCodableStrictDecoder)?.additionalKeys( + at: codingPath + ) ?? [] + let allowedKeys = Set(allowedKeys).union(inheritedKeys) + let unknownKeys = rawContainer.allKeys + .map(\.stringValue) + .filter { !allowedKeys.contains($0) } + .sorted() + guard !unknownKeys.isEmpty else { return } + + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: + "Unsupported keys found while decoding \(type): " + + unknownKeys.joined(separator: ", ") + ) + throw DecodingError.dataCorrupted(context) + } + + /// Validates all keyed paths generated for a strict inherited class. + func rejectUnknownKeys( + allowedKeysByPath: [String: [String]], + type: Any.Type + ) throws { + let rawContainer: + KeyedDecodingContainer< + DynamicCodableIdentifier + > + do { + rawContainer = try container( + keyedBy: DynamicCodableIdentifier.self + ) + } catch let error as DecodingError { + switch error { + case .typeMismatch, .valueNotFound: + return + default: + throw error + } + } + try rawContainer.rejectUnknownKeys( + atPath: "", + allowedKeysByPath: allowedKeysByPath, + type: type, + state: (self as? MetaCodableStrictDecoder)?.state + ) + } +} + +private extension KeyedDecodingContainer +where Key == DynamicCodableIdentifier { + func rejectUnknownKeys( + atPath path: String, + allowedKeysByPath: [String: [String]], + type: Any.Type, + state: MetaCodableStrictDecoder.State? + ) throws { + let inheritedKeys = + state?.additionalKeys(at: codingPath) ?? [] + let allowedKeys = Set(allowedKeysByPath[path] ?? []) + .union(inheritedKeys) + let unknownKeys = + allKeys + .map(\.stringValue) + .filter { !allowedKeys.contains($0) } + .sorted() + guard unknownKeys.isEmpty else { + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: + "Unsupported keys found while decoding \(type): " + + unknownKeys.joined(separator: ", ") + ) + throw DecodingError.dataCorrupted(context) + } + + for key in allKeys { + if state?.hasTerminalDecoder( + at: codingPath + [key] + ) == true { + continue + } + let childPath = + state?.path(for: codingPath + [key]) ?? path + guard + allowedKeysByPath[childPath] != nil + || state?.hasContainerPath(childPath) == true, + let nested = try? nestedContainer( + keyedBy: DynamicCodableIdentifier.self, + forKey: key + ) + else { continue } + try nested.rejectUnknownKeys( + atPath: childPath, + allowedKeysByPath: allowedKeysByPath, + type: type, + state: state + ) + } + } +} diff --git a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md index f2b5b7dcb4..d53d7549e5 100644 --- a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md +++ b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md @@ -19,6 +19,7 @@ Supercharge `Swift`'s `Codable` implementations with macros. - 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 opt-in rejection of unsupported decoding keys with the `rejectUnknownKeys` parameter on ``Codable(commonStrategies:rejectUnknownKeys:)`` and ``ConformDecodable(commonStrategies:rejectUnknownKeys:)``. - 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()``. diff --git a/Sources/PluginCore/Expansion/AttributeExpander.swift b/Sources/PluginCore/Expansion/AttributeExpander.swift index a0d493659b..b132a30209 100644 --- a/Sources/PluginCore/Expansion/AttributeExpander.swift +++ b/Sources/PluginCore/Expansion/AttributeExpander.swift @@ -1,5 +1,6 @@ import SwiftSyntax import SwiftSyntaxBuilder +import SwiftDiagnostics import SwiftSyntaxMacros /// A type managing registrations of variable and `CodingKey` data. @@ -37,7 +38,26 @@ struct AttributeExpander { let typeVar = variable as? any TypeVariable else { return nil } self.variable = typeVar - self.options = .init(for: declaration) + let options = Options(for: declaration) + if let invalid = options.invalidRejectUnknownKeysArgument { + context.diagnose( + .init( + node: invalid, + message: MetaCodableMessage( + macro: invalid, + message: + "rejectUnknownKeys must be a Boolean literal " + + "or a parenthesized/prefix Boolean literal.", + messageID: Codable.messageID( + "invalid-reject-unknown-keys" + ), + severity: .error + ), + fixIts: [] + ) + ) + } + self.options = options } /// Generates extension declarations for `Codable` macro. @@ -101,7 +121,8 @@ struct AttributeExpander { ) -> ExtensionDeclSyntax? { let method = TypeCodingLocation.Method.decode() let location = TypeCodingLocation( - method: method, conformance: `protocol` + method: method, conformance: `protocol`, + rejectUnknownKeys: options.rejectUnknownKeys ) guard let generated = variable.decoding(in: context, from: location) diff --git a/Sources/PluginCore/Expansion/Options/Options.swift b/Sources/PluginCore/Expansion/Options/Options.swift index 5b5cad3b6b..f9f3ee9ce2 100644 --- a/Sources/PluginCore/Expansion/Options/Options.swift +++ b/Sources/PluginCore/Expansion/Options/Options.swift @@ -14,6 +14,10 @@ extension AttributeExpander { /// The list of `@available` attributes attached to /// original expanded declaration. let availableAttributes: [AttributeSyntax] + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// The invalid strict-decoding argument, if one was provided. + let invalidRejectUnknownKeysArgument: AttributeSyntax? /// Memberwise initialization generator with provided options. /// @@ -43,6 +47,41 @@ extension AttributeExpander { return nil } } + let strictArguments = decl.attributes.compactMap { + attribute -> (AttributeSyntax, Bool?)? in + guard + case .attribute(let attr) = attribute, + let arguments = attr.arguments? + .as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { + $0.label?.text == "rejectUnknownKeys" + }) + else { return nil } + return (attr, Self.booleanValue(argument.expression)) + } + self.rejectUnknownKeys = strictArguments.first?.1 ?? false + self.invalidRejectUnknownKeysArgument = + strictArguments.first { $0.1 == nil }?.0 + } + + /// Resolves boolean expressions supported by the macro argument. + private static func booleanValue(_ expression: ExprSyntax) -> Bool? { + if let literal = expression.as(BooleanLiteralExprSyntax.self) { + return literal.literal.tokenKind == .keyword(.true) + } + if let expression = expression.as(TupleExprSyntax.self), + expression.elements.count == 1, + expression.elements.first?.label == nil + { + return booleanValue(expression.elements.first!.expression) + } + if let expression = expression.as(PrefixOperatorExprSyntax.self), + expression.operator.text == "!", + let value = booleanValue(expression.expression) + { + return !value + } + return nil } } } diff --git a/Sources/PluginCore/Variables/ComposedVariable.swift b/Sources/PluginCore/Variables/ComposedVariable.swift index 95f1aa7cba..9c5ab00fff 100644 --- a/Sources/PluginCore/Variables/ComposedVariable.swift +++ b/Sources/PluginCore/Variables/ComposedVariable.swift @@ -146,6 +146,11 @@ where Self: PropertyVariable, Wrapped: PropertyVariable { func depends(on variable: Variable) -> Bool { base.depends(on: variable) } + + /// Provides additional decoding keys from the underlying variable. + var additionalDecodingKeys: [String] { + base.additionalDecodingKeys + } } extension ComposedVariable @@ -180,6 +185,18 @@ where Self: EnumCaseVariable, Wrapped: EnumCaseVariable { /// /// Provides associated variables of the underlying variable value. var variables: [any AssociatedVariable] { base.variables } + /// Provides decoding keys from the underlying enum case. + var decodingAllowedKeys: [String] { + base.decodingAllowedKeys + } + /// Provides keyed-decoding requirements from the underlying enum case. + var usesKeyedDecoding: Bool { + base.usesKeyedDecoding + } + /// Provides direct-decoding capability from the underlying enum case. + var usesDirectDecoding: Bool { + base.usesDirectDecoding + } } extension ComposedVariable where Self: TypeVariable, Wrapped: TypeVariable { diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index b8a80c6d4b..cda8034e1d 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -65,6 +65,20 @@ struct BasicEnumCaseVariable: EnumCaseVariable { /// /// It generates keys for associated variables decoding/encoding. let codingKeys: CodingKeysMap + /// Keys recognized by decoding associated values at the current level. + var decodingAllowedKeys: [String] { + Array(decodingData.allowedKeys) + } + /// Whether associated values require a keyed decoding container. + var usesKeyedDecoding: Bool { + variables.contains { $0.label != nil } + } + /// Whether associated values include an unlabeled direct value. + var usesDirectDecoding: Bool { + let hasDecodedValue = variables.contains { $0.decode ?? true } + return hasDecodedValue && !usesKeyedDecoding + || decodingData.variables.contains { $0.decode ?? true } + } /// Creates a new enum-case variable from provided data. /// @@ -116,8 +130,14 @@ struct BasicEnumCaseVariable: EnumCaseVariable { let decodingKeys = codingKeys.add( keys: path.decoding, field: name, context: context ) - decodingData.register(variable: variable, keyPath: decodingKeys) - decodingNode.register(variable: variable, keyPath: decodingKeys) + decodingData.register( + variable: variable, keyPath: decodingKeys, + additionalKeys: variable.additionalDecodingKeys + ) + decodingNode.register( + variable: variable, keyPath: decodingKeys, + additionalKeys: variable.additionalDecodingKeys + ) } if variable.encode ?? true { @@ -168,11 +188,35 @@ struct BasicEnumCaseVariable: EnumCaseVariable { } } ) + let directDecoder = context.makeUniqueName("strictDecoder") + var directAllowedKeys = location.additionalAllowedKeys + for key in decodingAllowedKeys + where !directAllowedKeys.contains(key) { + directAllowedKeys.append(key) + } + let usesStrictDirectDecoder = + location.rejectUnknownKeys + && usesDirectDecoding + && !directAllowedKeys.isEmpty + let coder = + usesStrictDirectDecoder ? directDecoder : location.coder let generated = decodingNode.decoding( with: decodingData, in: context, - from: .withCoder(location.coder, keyType: codingKeys.type) + from: .withCoder( + coder, keyType: codingKeys.type, + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: location.additionalAllowedKeys, + preserveDecoderMetadata: usesStrictDirectDecoder, + skipCurrentContainerValidation: usesDirectDecoding + ) ) let newSyntax = CodeBlockItemListSyntax { + if usesStrictDirectDecoder { + let metadata = strictDecodingMetadataSyntax( + ["": directAllowedKeys] + ) + "let \(directDecoder) = MetaCodableStrictDecoder(decoder: \(location.coder), allowedKeysByPath: \(metadata))" + } for variable in variables where variable.decode ?? true { "let \(variable.name): \(variable.type)" } diff --git a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift index c38b3fe2cf..b6f863c8f3 100644 --- a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift @@ -12,9 +12,22 @@ where CodingLocation == EnumCaseCodingLocation, Generated == EnumCaseGenerated { /// Represents all the associated variable data available in this /// enum-case declaration data. var variables: [any AssociatedVariable] { get } + /// Keys recognized by decoding associated values at the current level. + var decodingAllowedKeys: [String] { get } + /// Whether associated values require a keyed decoding container. + var usesKeyedDecoding: Bool { get } + /// Whether associated values include a direct decoding value. + var usesDirectDecoding: Bool { get } } extension EnumCaseVariable { + /// No associated decoding keys are exposed by default. + var decodingAllowedKeys: [String] { [] } + /// Associated values decode directly by default. + var usesKeyedDecoding: Bool { false } + /// Cases without keyed labels have direct decoding values. + var usesDirectDecoding: Bool { false } + /// The arguments passed to encoding condition. /// /// All associated variables are passed as argument. @@ -43,6 +56,10 @@ struct EnumCaseCodingLocation { /// Represents the actual values that will be decoded. /// Only the first value will be encoded. let values: [ExprSyntax] + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// Additional keys accepted by the current decoding container. + let additionalAllowedKeys: [String] } /// Represents the syntax generated by `EnumCaseVariable`. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index d6806f1d8b..49c87394fa 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -7,6 +7,9 @@ import SwiftSyntaxMacros /// Only internally tagged enums can have adjacent tagging, while externally /// tagged enums can't have adjacent tagging. protocol AdjacentlyTaggableSwitcher: EnumSwitcherVariable { + /// The discriminator path used by the wrapped switcher. + var discriminatorDecodingPath: [String] { get } + /// Provides the syntax for decoding at the provided location and decoder. /// /// The generated implementation decodes the identifier variable from @@ -240,11 +243,24 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { "var \(container): \(containerType)" decodingNode.decoding( in: context, - from: .withCoder(coder, keyType: location.keyType) + from: .withCoder( + coder, keyType: location.keyType, + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: + location.caseDecodingSharesRootContainer + ? location.rootDecodingKeys : [], + additionalAllowedKeysByPath: + location.additionalAllowedKeysByPath, + skipUnknownKeyValidationPaths: + location.skipUnknownKeyValidationPaths, + skipCurrentContainerValidation: + location.skipRootUnknownKeyValidation + ) ).combined() if containerType.isOptionalTypeSyntax { - let needsContainer = location.cases.contains { variable, _ in + let needsContainer = location.cases.contains { + variable, _ in variable.variables.contains { $0.label != nil } } let topContainerOptional = decodingNode.children @@ -259,7 +275,8 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { } let header: SyntaxNodeString = - needsContainer && topContainerOptional && !rawRepresentable + needsContainer && topContainerOptional + && !rawRepresentable ? "if let \(container) = \(container), let \(location.container) = \(location.container)" : "if let \(container) = \(container)" try! IfExprSyntax(header) { diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index 5b426e2737..817362c92b 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -20,6 +20,45 @@ where Wrapped: AdjacentlyTaggableSwitcher { /// This variable is used to map the nested container /// for associated variables to a pre-defined name. let variable: CoderVariable + /// Whether the wrapped switcher decodes from a keyed container. + var usesKeyedContainer: Bool { base.usesKeyedContainer } + /// The exact root keys used by the wrapped switcher when available. + var rootDecodingKeys: [String]? { base.rootDecodingKeys } + /// Discriminator keys from the wrapped switcher. + var discriminatorDecodingKeys: [String] { + base.discriminatorDecodingKeys + } + /// Adjacent case values are decoded from the content container. + var caseDecodingSharesRootContainer: Bool { false } + /// Adjacent tagging wraps an internally tagged discriminator. + var isInternallyTagged: Bool { base.isInternallyTagged } + /// Adjacent direct payloads share validation only when content and tag + /// paths are nested in the same container. + var directPayloadSharesValidationContainer: Bool { + base.caseDecodingSharesRootContainer + || (base.discriminatorDecodingPath.count + > contentDecodingPath.count + && base.discriminatorDecodingPath.starts( + with: contentDecodingPath + )) + } + /// Keys from a nested discriminator container accepted by direct cases. + var caseDecodingAdditionalAllowedKeys: [String] { + guard + base.discriminatorDecodingPath.count > contentDecodingPath.count, + base.discriminatorDecodingPath.starts( + with: contentDecodingPath + ) + else { return [] } + let relativePath = base.discriminatorDecodingPath.dropFirst( + contentDecodingPath.count + ) + guard let key = relativePath.first + else { return [] } + return [key] + } + /// The path used for adjacent case content. + let contentDecodingPath: [String] /// Creates switcher variable with provided data. /// @@ -41,6 +80,7 @@ where Wrapped: AdjacentlyTaggableSwitcher { let encodingKeys = codingKeys.add( keys: keyPath.encoding, context: context) self.variable = .init(decoder: contentDecoder, encoder: contentEncoder) + self.contentDecodingPath = keyPath.decoding base.registering(variable: variable, decodingKeyPath: decodingKeys) base.registering(variable: variable, encodingKeyPath: encodingKeys) self.base = base @@ -110,7 +150,25 @@ where Wrapped: AdjacentlyTaggableSwitcher { in context: some MacroExpansionContext, from location: EnumSwitcherLocation ) -> CodeBlockItemListSyntax { - base.decoding( + var location = location + if base.discriminatorDecodingPath.count + > contentDecodingPath.count, + base.discriminatorDecodingPath.starts( + with: contentDecodingPath + ) + { + let path = strictDecodingPathKey(contentDecodingPath) + let caseKeys = location.cases.flatMap { + $0.variable.decodingAllowedKeys + } + if !caseKeys.isEmpty { + location.additionalAllowedKeysByPath[path] = caseKeys + } + if location.skipRootUnknownKeyValidation { + location.skipUnknownKeyValidationPaths.insert(path) + } + } + return base.decoding( in: context, from: location, contentAt: variable.decoder ) } @@ -215,7 +273,7 @@ extension AdjacentlyTaggedEnumSwitcher { from location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let decoder, _): + case .coder(let decoder, _), .coderWithMetadata(let decoder, _): "let \(self.decoder) = \(decoder)" case .container(let container, let key, _): "let \(self.decoder) = try \(container).superDecoder(forKey: \(key))" @@ -238,7 +296,7 @@ extension AdjacentlyTaggedEnumSwitcher { to location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let encoder, _): + case .coder(let encoder, _), .coderWithMetadata(let encoder, _): "let \(self.encoder) = \(encoder)" case .container(let container, let key, _): "let \(self.encoder) = \(container).superEncoder(forKey: \(key))" diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 27f2e07316..59a66ee320 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -13,6 +13,30 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { /// to its original type using type casting /// operators (`as?`, `as!`, or `as`). let base: any EnumSwitcherVariable + /// Whether the wrapped switcher decodes from a keyed container. + var usesKeyedContainer: Bool { base.usesKeyedContainer } + /// The exact root keys used by the wrapped switcher when available. + var rootDecodingKeys: [String]? { base.rootDecodingKeys } + /// Discriminator keys from the wrapped switcher. + var discriminatorDecodingKeys: [String] { + base.discriminatorDecodingKeys + } + /// Additional keys accepted by direct case decoders. + var caseDecodingAdditionalAllowedKeys: [String] { + base.caseDecodingAdditionalAllowedKeys + } + /// Whether case values share the wrapped switcher's root container. + var caseDecodingSharesRootContainer: Bool { + base.caseDecodingSharesRootContainer + } + /// Whether the wrapped switcher uses an internal discriminator. + var isInternallyTagged: Bool { base.isInternallyTagged } + /// Whether direct payloads share the wrapped validation container. + var directPayloadSharesValidationContainer: Bool { + base.directPayloadSharesValidationContainer + } + /// Whether the wrapped switcher allows direct decoding cases. + var allowsDirectDecoding: Bool { base.allowsDirectDecoding } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 983a791455..3da8dc879c 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -9,6 +9,22 @@ protocol EnumSwitcherVariable: Variable where CodingLocation == EnumSwitcherLocation, Generated == CodeBlockItemListSyntax { + /// Whether decoding starts from a keyed container. + var usesKeyedContainer: Bool { get } + /// The exact root keys used by the switcher when available. + var rootDecodingKeys: [String]? { get } + /// Keys used by the switcher itself before case values are decoded. + var discriminatorDecodingKeys: [String] { get } + /// Additional keys accepted by direct case decoders. + var caseDecodingAdditionalAllowedKeys: [String] { get } + /// Whether case values are decoded in the same root container. + var caseDecodingSharesRootContainer: Bool { get } + /// Whether the switcher uses an internal discriminator. + var isInternallyTagged: Bool { get } + /// Whether direct payloads share the container being strictly validated. + var directPayloadSharesValidationContainer: Bool { get } + /// Whether some cases may decode directly from scalar or unkeyed values. + var allowsDirectDecoding: Bool { get } /// Provides node at which case associated variables are registered. /// /// The returned node can be used by enum-case variables to register @@ -64,6 +80,23 @@ where } extension EnumSwitcherVariable { + /// Tagged switchers decode from keyed containers by default. + var usesKeyedContainer: Bool { true } + /// Switchers use the type coding key map by default. + var rootDecodingKeys: [String]? { nil } + /// Switchers have no separate discriminator keys by default. + var discriminatorDecodingKeys: [String] { [] } + /// Direct case decoders have no additional keys by default. + var caseDecodingAdditionalAllowedKeys: [String] { [] } + /// Case values are nested unless a switcher opts into sharing the root. + var caseDecodingSharesRootContainer: Bool { false } + /// Switchers are not internally tagged by default. + var isInternallyTagged: Bool { false } + /// Direct payloads do not share an envelope container by default. + var directPayloadSharesValidationContainer: Bool { false } + /// Switchers do not mix direct and keyed representations by default. + var allowsDirectDecoding: Bool { false } + /// Update provided variable data. /// /// Provided variable is returned as it is, without any data update. @@ -121,4 +154,20 @@ package struct EnumSwitcherLocation { /// Based on type of tagging in enums the default case generation for /// both decoding and encoding can be customized. let hasDefaultCase: Bool + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// Keys accepted at the enum root container. + let rootDecodingKeys: [String] + /// Keys accepted by the discriminator/case switcher itself. + let discriminatorDecodingKeys: [String] + /// Whether associated case values share the enum root container. + let caseDecodingSharesRootContainer: Bool + /// Additional keys accepted by direct case decoders. + var caseDecodingAdditionalAllowedKeys: [String] + /// Additional keys accepted at nested decoding container paths. + var additionalAllowedKeysByPath: [String: [String]] + /// Paths whose generated validation is deferred. + var skipUnknownKeyValidationPaths: Set + /// Whether root switcher validation is deferred for delegated cases. + let skipRootUnknownKeyValidation: Bool } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift index 09b776d004..de61dffde0 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift @@ -19,6 +19,10 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { /// /// This encoder is passed to each case for encoding. let contentEncoder: TokenSyntax + /// The enum tag keys recognized by external tagging. + var rootDecodingKeys: [String]? { decodingKeys.allKeys } + /// External tags are the switcher's discriminator keys. + var discriminatorDecodingKeys: [String] { decodingKeys.allKeys } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index cf8e940921..791ffb9891 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -12,6 +12,26 @@ where Variable: PropertyVariable, Variable.Initialization == RequiredInitialization { + /// Whether this enum has a keyed discriminator container. + var usesKeyedContainer: Bool { !keyPath.decoding.isEmpty } + /// The discriminator keys used at the enum root. + var rootDecodingKeys: [String]? { Array(decodingNode.allowedKeys) } + /// The discriminator keys used by internal tagging. + var discriminatorDecodingKeys: [String] { + Array(decodingNode.allowedKeys) + } + /// The discriminator path used for internal tagging. + var discriminatorDecodingPath: [String] { keyPath.decoding } + /// Internally tagged direct cases share discriminator keys. + var caseDecodingAdditionalAllowedKeys: [String] { + discriminatorDecodingKeys + } + /// Internally tagged case values share the discriminator container. + var caseDecodingSharesRootContainer: Bool { true } + /// This switcher uses an internal discriminator. + var isInternallyTagged: Bool { true } + /// Internal direct payloads share the root validation container. + var directPayloadSharesValidationContainer: Bool { true } /// The identifier variable build action type. /// /// Used to build the identifier data and pass in encoding callback. @@ -394,7 +414,7 @@ extension InternallyTaggedEnumSwitcher { from location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let decoder, _): + case .coder(let decoder, _), .coderWithMetadata(let decoder, _): "\(self.decoder) = \(decoder)" case .container(let container, _, _): "\(self.decoder) = \(container)" @@ -417,7 +437,7 @@ extension InternallyTaggedEnumSwitcher { to location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let encoder, _): + case .coder(let encoder, _), .coderWithMetadata(let encoder, _): "let \(self.encoder) = \(encoder)" case .container(let container, _, _): "var \(self.encoder) = \(container)" diff --git a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift index 6c461d1b91..716361d87f 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift @@ -52,7 +52,10 @@ extension TaggedEnumSwitcherVariable { // container. if !values.isEmpty { let cLocation = EnumCaseCodingLocation( - coder: coder, values: values + coder: coder, values: values, + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: + location.caseDecodingAdditionalAllowedKeys ) let generated = `case`.decoding( in: context, from: cLocation @@ -137,7 +140,8 @@ extension EnumSwitcherVariable { let _ = { switchable = true }() let values = value.encodeExprs.map(\.syntax) let cLocation = EnumCaseCodingLocation( - coder: coder, values: values + coder: coder, values: values, + rejectUnknownKeys: false, additionalAllowedKeys: [] ) let generated = `case`.encoding(in: context, to: cLocation) @@ -153,7 +157,8 @@ extension EnumSwitcherVariable { let generatedCode = generated.code.combined() SwitchCaseSyntax(label: .case(label)) { - let preSyntax = preSyntax(("\(values.first!)", !generatedCode.isEmpty)) + let preSyntax = preSyntax( + ("\(values.first!)", !generatedCode.isEmpty)) preSyntax if !generatedCode.isEmpty { generatedCode diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index fba3ed3791..7beb122bdd 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -5,6 +5,18 @@ import SwiftSyntaxMacros /// /// Tries decoding associated variables for each case until success. struct UnTaggedEnumSwitcher: EnumSwitcherVariable { + /// Untagged cases may use scalar, unkeyed, or keyed representations. + var usesKeyedContainer: Bool { false } + /// Untagged cases do not have a single root schema. + var rootDecodingKeys: [String]? { nil } + /// Untagged case values share their direct decoder. + var caseDecodingSharesRootContainer: Bool { true } + /// Untagged enums have no discriminator keys. + var discriminatorDecodingKeys: [String] { [] } + /// Untagged enums may mix direct and keyed cases. + var allowsDirectDecoding: Bool { true } + /// Untagged payloads are decoded directly from the validated root. + var directPayloadSharesValidationContainer: Bool { true } /// The node at which variables are registered. /// /// Associated variables for all cases are registered with the path @@ -94,8 +106,17 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { from location: EnumSwitcherLocation ) -> CodeBlockItemListSyntax { var cases = location.cases - let result = decodingSyntax(for: &cases, from: location, in: context) + let strictError = + location.rejectUnknownKeys + ? context.makeUniqueName("strictDecodingError") : nil + let result = decodingSyntax( + for: &cases, from: location, in: context, + strictError: strictError + ) return CodeBlockItemListSyntax { + if let strictError { + "var \(strictError): DecodingError?" + } if result.usesError { """ let context = DecodingError.Context( @@ -103,6 +124,11 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { debugDescription: "Couldn't decode any case." ) """ + if let strictError { + try! IfExprSyntax("if let \(strictError)") { + "throw \(strictError)" + } + } "let \(error) = DecodingError.typeMismatch(Self.self, context)" } result.syntax @@ -123,27 +149,75 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { private func decodingSyntax( for cases: inout [EnumVariable.Case], from location: EnumSwitcherLocation, - in context: some MacroExpansionContext + in context: some MacroExpansionContext, + strictError: TokenSyntax? ) -> (usesError: Bool, syntax: CodeBlockItemListSyntax) { - guard !cases.isEmpty else { return (true, "throw \(error)") } + guard !cases.isEmpty else { + if let strictError { + return ( + true, + CodeBlockItemListSyntax { + try! IfExprSyntax("if let \(strictError)") { + "throw \(strictError)" + } + "throw \(error)" + } + ) + } + return (true, "throw \(error)") + } let `case` = cases.removeFirst() - let coder = location.coder - let cLocation = EnumCaseCodingLocation(coder: coder, values: []) + let attempt = context.makeUniqueName("strictAttempt") + let attemptDecoder = context.makeUniqueName("strictAttemptDecoder") + let coder = + location.rejectUnknownKeys ? attemptDecoder : location.coder + let cLocation = EnumCaseCodingLocation( + coder: coder, values: [], + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: + `case`.variable.decodingAllowedKeys + ) let generated = `case`.variable.decoding(in: context, from: cLocation) - let nResult = decodingSyntax(for: &cases, from: location, in: context) + let nResult = decodingSyntax( + for: &cases, from: location, in: context, + strictError: strictError + ) let catchClauses = CatchClauseListSyntax { - CatchClauseSyntax { - nResult.syntax + if let strictError { + CatchClauseSyntax { + try! IfExprSyntax( + "if let decodingError = error as? DecodingError, case .dataCorrupted = decodingError" + ) { + "\(strictError) = decodingError" + } + if location.rejectUnknownKeys { + "\(attempt).rollback()" + } + nResult.syntax + } + } else { + CatchClauseSyntax { + if location.rejectUnknownKeys { + "\(attempt).rollback()" + } + nResult.syntax + } } } let eCase = `case`.variable let tVisitor = ThrowingSyntaxVisitor(viewMode: .sourceAccurate) let doBlock = CodeBlockItemListSyntax { + if location.rejectUnknownKeys { + generated.code.containerSyntax + } generated.code.codingSyntax generated.code.conditionalSyntax location.codeExpr(eCase.name, eCase.variables) + if location.rejectUnknownKeys { + "\(attempt).commit()" + } "return" } @@ -156,7 +230,13 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { eVisitor.walk(doBlock) let syntax = CodeBlockItemListSyntax { - generated.code.containerSyntax + if !location.rejectUnknownKeys { + generated.code.containerSyntax + } + if location.rejectUnknownKeys { + "let \(attempt) = MetaCodableStrictDecoder.beginAttempt(for: \(location.coder))" + "let \(attemptDecoder) = \(attempt).decoder" + } if tVisitor.throws { DoStmtSyntax(catchClauses: catchClauses) { doBlock diff --git a/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift b/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift index 99379b7966..1125109eb9 100644 --- a/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift @@ -20,6 +20,10 @@ where Wrapped: PropertyVariable { /// Represents all the additional `CodingKey`s that /// this variable could be encoded at. let additionalKeys: OrderedSet + /// Additional keys accepted while decoding this variable. + var additionalDecodingKeys: [String] { + additionalKeys.map(\.description) + } /// Provides the code syntax for decoding this variable /// at the provided location. @@ -39,11 +43,11 @@ where Wrapped: PropertyVariable { ) -> CodeBlockItemListSyntax { guard !additionalKeys.isEmpty, - case let .container(container, key, method) = location + case .container(let container, let key, let method) = location else { return base.decoding(in: context, from: location) } var allKeys = [key] let additionalKeys = additionalKeys.filter { aKey in - return aKey.expr.trimmedDescription != key.trimmedDescription + aKey.expr.trimmedDescription != key.trimmedDescription } allKeys.append(contentsOf: additionalKeys.map(\.expr)) let keysName: ExprSyntax = "\(CodingKeysMap.Key.name(for: name))Keys" diff --git a/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift b/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift index 2a1ff93df2..2ff3b655bd 100644 --- a/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift @@ -40,6 +40,10 @@ where Initialization: VariableInitialization { /// /// Provides underlying variable value encode prefix. var encodePrefix: TokenSyntax { base.encodePrefix } + /// Provides additional decoding keys from the underlying variable. + var additionalDecodingKeys: [String] { + base.additionalDecodingKeys + } /// Whether the variable is to be decoded. /// diff --git a/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift b/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift index 7eb39bca53..385b0b833c 100644 --- a/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift @@ -164,6 +164,28 @@ struct BasicPropertyVariable: DefaultPropertyVariable, DeclaredVariable { \(decodePrefix)\(name) = try \(type)\(optionalToken)(from: \(decoder)) """ } + case .coderWithMetadata(let decoder, let passedMethod): + let optionalToken: TokenSyntax = + passedMethod?.trimmedDescription == "decodeIfPresent" ? "?" : "" + var type = type + if let implicitlyUnwrappedType = type.as( + ImplicitlyUnwrappedOptionalTypeSyntax.self + ) { + type = TypeSyntax( + OptionalTypeSyntax( + wrappedType: implicitlyUnwrappedType.wrappedType + ) + ) + } + return CodeBlockItemListSyntax { + if hasOptionalType || optionalToken == "?" { + "\(decodePrefix)\(name) = try MetaCodableStrictDecoder.decodeOptional(\(type.wrappedType).self, from: \(decoder))" + } else { + """ + \(decodePrefix)\(name) = try \(type)\(optionalToken)(from: \(decoder)) + """ + } + } case .container(let container, let key, let passedMethod): let (type, defMethod) = codingTypeMethod(forMethod: "decode") let method = passedMethod ?? defMethod @@ -195,7 +217,7 @@ struct BasicPropertyVariable: DefaultPropertyVariable, DeclaredVariable { to location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let encoder, _): + case .coder(let encoder, _), .coderWithMetadata(let encoder, _): return CodeBlockItemListSyntax { """ try \(encodePrefix)\(name).encode(to: \(encoder)) diff --git a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift index 453b6d54b4..0db3418e85 100644 --- a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift +++ b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift @@ -27,6 +27,16 @@ package enum DecodingFallback { /// value while second syntax will be used for all the other errors. case ifMissing(CodeBlockItemListSyntax, ifError: CodeBlockItemListSyntax) + /// Whether decoding uses an optional nested container. + var isOptional: Bool { + switch self { + case .throw: + false + case .onlyIfMissing, .ifMissing: + true + } + } + /// Represents container for decoding/encoding properties. typealias Container = PropertyVariableTreeNode.CodingLocation.Context .Container @@ -41,6 +51,16 @@ package enum DecodingFallback { /// - nestedContainer: The nested container name to use if present. /// - nestedContainerHasVariables: Whether nested container has /// some variables as direct children. + /// - allowedKeys: The keys recognized by the current container, or + /// `nil` when strict validation is disabled. + /// - scopeCodingSyntax: Whether coding syntax is scoped to the + /// represented container. + /// - trailingValidationSyntax: Validation emitted after decoding. + /// - forceNonOptionalContainer: Whether optional fallback handling is + /// suppressed for the represented container. + /// - childContainerOptional: Whether child properties use an optional + /// container expression. + /// - containerName: An explicit represented container name. /// - decoding: The nested container decoding /// code block generator. /// @@ -49,6 +69,12 @@ package enum DecodingFallback { context: PropertyVariableTreeNode.CodingLocation.Context, nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, + allowedKeys: [String]?, + scopeCodingSyntax: Bool = false, + trailingValidationSyntax: CodeBlockItemListSyntax = [], + forceNonOptionalContainer: Bool = false, + childContainerOptional: Bool = false, + containerName: TokenSyntax? = nil, nestedDecoding decoding: (Container) -> Generated ) -> Generated { switch context { @@ -57,6 +83,12 @@ package enum DecodingFallback { decoder: coder, keyType: kType, nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, + allowedKeys: allowedKeys, + scopeCodingSyntax: scopeCodingSyntax, + trailingValidationSyntax: trailingValidationSyntax, + forceNonOptionalContainer: forceNonOptionalContainer, + childContainerOptional: childContainerOptional, + containerName: containerName, nestedDecoding: decoding ) case .container(let container, let key): @@ -64,6 +96,11 @@ package enum DecodingFallback { decodingContainer: container, fromKey: key, nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, + allowedKeys: allowedKeys, + scopeCodingSyntax: scopeCodingSyntax, + trailingValidationSyntax: trailingValidationSyntax, + forceNonOptionalContainer: forceNonOptionalContainer, + childContainerOptional: childContainerOptional, nestedDecoding: decoding ) } @@ -78,6 +115,16 @@ package enum DecodingFallback { /// - nestedContainer: The nested container name to use if present. /// - nestedContainerHasVariables: Whether nested container has /// some variables as direct children. + /// - allowedKeys: The keys recognized by the current container, or + /// `nil` when strict validation is disabled. + /// - scopeCodingSyntax: Whether coding syntax is scoped to the + /// represented container. + /// - trailingValidationSyntax: Validation emitted after decoding. + /// - forceNonOptionalContainer: Whether optional fallback handling is + /// suppressed for the represented container. + /// - childContainerOptional: Whether child properties use an optional + /// container expression. + /// - containerName: An explicit represented container name. /// - decoding: The nested container decoding /// code block generator. /// @@ -87,6 +134,12 @@ package enum DecodingFallback { keyType type: ExprSyntax, nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, + allowedKeys: [String]?, + scopeCodingSyntax: Bool, + trailingValidationSyntax: CodeBlockItemListSyntax, + forceNonOptionalContainer: Bool, + childContainerOptional: Bool, + containerName: TokenSyntax?, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let isOptional: Bool @@ -100,45 +153,77 @@ package enum DecodingFallback { fallbacks = [] } - let cToken = nestedContainer ?? "container" - let container = Container(name: cToken, isOptional: isOptional) + let cToken = containerName ?? nestedContainer ?? "container" + let logicalOptional = + forceNonOptionalContainer ? false : isOptional + let container = Container( + name: cToken, + isOptional: childContainerOptional || logicalOptional + ) + let optionalContainer = nestedContainer ?? container.name let generated = decoding(container) + let generatedCodingSyntax = + scopeCodingSyntax + ? CodeBlockItemListSyntax {} : generated.codingSyntax + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: "\(decoder)" as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } let containerSyntax = CodeBlockItemListSyntax { if nestedContainer == nil { - let `try`: TokenSyntax = isOptional ? "try?" : "try" + let `try`: TokenSyntax = logicalOptional ? "try?" : "try" """ let \(container.name) = \(`try`) \(decoder).container(keyedBy: \(type)) """ + if !logicalOptional { + validation + } } generated.containerSyntax } let ifSyntax = generated.conditionalSyntax let cBinding = - if nestedContainerHasVariables && !ifSyntax.isEmpty { + if allowedKeys != nil + || nestedContainerHasVariables && !ifSyntax.isEmpty + { container.name } else { "_" as TokenSyntax } let conditionalSyntax = CodeBlockItemListSyntax { - if isOptional { + if logicalOptional { try! IfExprSyntax( """ - if let \(cBinding) = \(container.name) + if let \(cBinding) = \(optionalContainer) """ ) { + validation + if scopeCodingSyntax { + generated.codingSyntax + } ifSyntax + trailingValidationSyntax } else: { fallbacks } } else { + if scopeCodingSyntax { + generated.codingSyntax + } ifSyntax + trailingValidationSyntax } } return .init( containerSyntax: containerSyntax, - codingSyntax: generated.codingSyntax, + codingSyntax: generatedCodingSyntax, conditionalSyntax: conditionalSyntax ) } @@ -152,6 +237,15 @@ package enum DecodingFallback { /// - nestedContainer: The nested container name to use if present. /// - nestedContainerHasVariables: Whether nested container has /// some variables as direct children. + /// - allowedKeys: The keys recognized by the current container, or + /// `nil` when strict validation is disabled. + /// - scopeCodingSyntax: Whether coding syntax is scoped to the + /// represented container. + /// - trailingValidationSyntax: Validation emitted after decoding. + /// - forceNonOptionalContainer: Whether optional fallback handling is + /// suppressed for the represented container. + /// - childContainerOptional: Whether child properties use an optional + /// container expression. /// - decoding: The nested container decoding /// code block generator. /// @@ -161,6 +255,11 @@ package enum DecodingFallback { fromKey key: CodingKeysMap.Key, nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, + allowedKeys: [String]?, + scopeCodingSyntax: Bool, + trailingValidationSyntax: CodeBlockItemListSyntax, + forceNonOptionalContainer: Bool, + childContainerOptional: Bool, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let nContainer = nestedContainer ?? "\(key.raw)_\(container.name)" @@ -172,21 +271,46 @@ package enum DecodingFallback { let generated = decoding( .init(name: nContainer, isOptional: container.isOptional) ) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: + "\(container.name).superDecoder(forKey: \(key.expr))" + as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } codingSyntax = generated.codingSyntax containerSyntax = CodeBlockItemListSyntax { if nestedContainer == nil { """ let \(nContainer) = try \(container.syntax).nestedContainer(keyedBy: \(key.type), forKey: \(key.expr)) """ + validation } generated.containerSyntax } conditionalSyntax = generated.conditionalSyntax case .onlyIfMissing(let fallbacks): let generated = decoding(.init(name: nContainer, isOptional: true)) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: + "\(container.name).superDecoder(forKey: \(key.expr))" + as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } let ifSyntax = generated.conditionalSyntax let nContainerBinding = - if nestedContainerHasVariables && !ifSyntax.isEmpty { + if allowedKeys != nil + || nestedContainerHasVariables && !ifSyntax.isEmpty + { nContainer } else { "_" as TokenSyntax @@ -207,16 +331,30 @@ package enum DecodingFallback { if let \(nContainerBinding) = \(nContainer) """ ) { + validation ifSyntax } else: { fallbacks } } - case let .ifMissing(fallbacks, ifError: eFallbacks): + case .ifMissing(let fallbacks, ifError: let eFallbacks): let generated = decoding(.init(name: nContainer, isOptional: true)) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: + "\(container.name).superDecoder(forKey: \(key.expr))" + as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } let ifSyntax = generated.conditionalSyntax let nContainerBinding = - if nestedContainerHasVariables && !ifSyntax.isEmpty { + if allowedKeys != nil + || nestedContainerHasVariables && !ifSyntax.isEmpty + { nContainer } else { "_" as TokenSyntax @@ -248,6 +386,7 @@ package enum DecodingFallback { try! IfExprSyntax( "if let \(nContainerBinding) = \(nContainer)", bodyBuilder: { + validation ifSyntax }, elseIf: IfExprSyntax("if \(containerMissing)") { @@ -305,7 +444,10 @@ extension DecodingFallback { fallbacks.append(contentsOf: rf) } return .onlyIfMissing(fallbacks) - case let (.ifMissing(lf, ifError: lef), .ifMissing(rf, ifError: ref)): + case ( + .ifMissing(let lf, ifError: let lef), + .ifMissing(let rf, ifError: let ref) + ): var fallbacks = lf if !hasEarlyExit(in: fallbacks) { fallbacks.append(contentsOf: rf) diff --git a/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift b/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift index cda8f36617..985886a74c 100644 --- a/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift +++ b/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift @@ -100,6 +100,8 @@ where switch location { case .coder(let decoder, _): .coder(decoder, method: method) + case .coderWithMetadata(let decoder, _): + .coderWithMetadata(decoder, method: method) case .container(let container, let key, _): .container(container, key: key, method: method) } diff --git a/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift b/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift index b6e72e41dc..c5ed1aeed2 100644 --- a/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift +++ b/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift @@ -64,9 +64,9 @@ where Wrapped: DefaultPropertyVariable { /// and used to generate assisted decoding/encoding syntax. var helperExpr: ExprSyntax { switch self { - case let .helper(expr): + case .helper(let expr): return expr - case let .helperAction(action, parameters): + case .helperAction(let action, let parameters): let args = LabeledExprListSyntax { for param in parameters { LabeledExprSyntax(expression: param.asArg) @@ -107,9 +107,9 @@ where Wrapped: DefaultPropertyVariable { /// helper action to create the helper expression syntax. var asArg: ExprSyntax { switch self { - case let .argument(expr): + case .argument(let expr): expr - case let .property(comps): + case .property(let comps): "self\(comps)" } } @@ -169,7 +169,8 @@ where Wrapped: DefaultPropertyVariable { ) -> CodeBlockItemListSyntax { let (_, defMethod) = codingTypeMethod(forMethod: "decode") switch location { - case .coder(let decoder, let passedMethod): + case .coder(let decoder, let passedMethod), + .coderWithMetadata(let decoder, let passedMethod): let method = passedMethod ?? defMethod return CodeBlockItemListSyntax { """ @@ -208,7 +209,8 @@ where Wrapped: DefaultPropertyVariable { ) -> CodeBlockItemListSyntax { let (_, defMethod) = codingTypeMethod(forMethod: "encode") switch location { - case .coder(let encoder, let passedMethod): + case .coder(let encoder, let passedMethod), + .coderWithMetadata(let encoder, let passedMethod): let method = passedMethod ?? defMethod return CodeBlockItemListSyntax { """ @@ -257,7 +259,7 @@ where Wrapped: DefaultPropertyVariable { switch options { case .helper: return base.depends(on: variable) - case let .helperAction(_, parameters): + case .helperAction(_, let parameters): return parameters.firstIndex { parameter in switch parameter { case .property(let components): diff --git a/Sources/PluginCore/Variables/Property/PropertyVariable.swift b/Sources/PluginCore/Variables/Property/PropertyVariable.swift index 93d7ee0762..619229efb7 100644 --- a/Sources/PluginCore/Variables/Property/PropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/PropertyVariable.swift @@ -68,6 +68,8 @@ where /// The number of variables that this variable depends /// on to be decoded first, before decoding this variable. var dependenciesCount: UInt { get } + /// Additional keys accepted while decoding this variable. + var additionalDecodingKeys: [String] { get } /// Checks whether this variable is dependent on the provided variable. /// /// Whether provided variable needs to be decoded first, @@ -92,6 +94,9 @@ package enum PropertyCodingLocation { /// - coder: The decoder/encoder for decoding/encoding. /// - method: The method to use for decoding/encoding. case coder(_ coder: TokenSyntax, method: ExprSyntax?) + /// A top-level location that preserves strict decoder metadata for direct + /// optional decoding. + case coderWithMetadata(_ coder: TokenSyntax, method: ExprSyntax?) /// Represents decoding/encoding location at a `CodingKey` /// for a container. /// @@ -119,6 +124,9 @@ where Self: ComposedVariable, Self.Wrapped: ConditionalVariable { } extension PropertyVariable { + /// No additional decoding keys are accepted by default. + var additionalDecodingKeys: [String] { [] } + /// The arguments passed to encoding condition. /// /// Passes current variable as single argument. @@ -165,7 +173,12 @@ extension PropertyVariable { gArgs.count == 1, let type = gArgs.first?.argument.as(TypeSyntax.self) { - dType = type + guard let castType = TypeSyntax(type) else { + preconditionFailure( + "Unable to create TypeSyntax from '\(type.trimmedDescription)'" + ) + } + dType = castType dMethod = "\(method)IfPresent" } else { dType = type diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift index b5ab95813c..ed5b7c778d 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift @@ -18,6 +18,8 @@ extension PropertyVariableTreeNode { var variables: [any PropertyVariable] = [] /// Nested registration node associated with keys. var children = OrderedDictionary() + /// Keys recognized by decoding at this container level. + var allowedKeys: OrderedSet = [] /// Whether the encoding container variable linked to this node /// should be declared as immutable. /// @@ -32,6 +34,8 @@ protocol VariableTreeNode { var variables: [any PropertyVariable] { get set } /// Nested registration node associated with keys. var children: OrderedDictionary { get set } + /// Keys recognized by decoding at this container level. + var allowedKeys: OrderedSet { get set } /// Whether the encoding container variable linked to this node /// should be declared as immutable. /// @@ -45,6 +49,24 @@ protocol VariableTreeNode { } extension VariableTreeNode { + /// Returns decoding keys grouped by their container path. + func allowedKeysByPath( + prefix: [String] = [] + ) -> [String: [String]] { + var result: [String: [String]] = [:] + if prefix.isEmpty || !children.isEmpty { + result[strictDecodingPathKey(prefix)] = Array(allowedKeys) + } + for (key, child) in children { + result.merge( + child.allowedKeysByPath( + prefix: prefix + [key.description] + ) + ) { _, new in new } + } + return result + } + /// List of all the linked variables registered. /// /// Gets all variables at current node @@ -63,13 +85,17 @@ extension VariableTreeNode { /// additional macro metadata. /// - keyPath: The `CodingKey` path where the value /// will be decode/encoded. + /// - additionalKeys: Additional decoding keys accepted at the + /// variable's container level. /// - immutableEncodeContainer: Whether the encoding container variable /// direct parent of `variable` should be declared as immutable. mutating func register( variable: Variable, keyPath: [CodingKeysMap.Key], + additionalKeys: [String] = [], immutableEncodeContainer: Bool = false ) { if keyPath.isEmpty { + allowedKeys.append(contentsOf: additionalKeys) let depIndex = variables.firstIndex { $0.depends(on: variable) } if let index = depIndex { variables.insert(variable, at: index) @@ -80,6 +106,10 @@ extension VariableTreeNode { } let key = keyPath.first! + allowedKeys.append(key.description) + if keyPath.count == 1 { + allowedKeys.append(contentsOf: additionalKeys) + } if children[key] == nil { children[key] = .init() } @@ -95,6 +125,7 @@ extension VariableTreeNode { children[key]!.register( variable: variable, keyPath: Array(keyPath.dropFirst()), + additionalKeys: additionalKeys, immutableEncodeContainer: immutableEncodeContainer ) } diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index a6276cd6c5..5802589a1f 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -10,6 +10,23 @@ extension PropertyVariableTreeNode { struct CodingLocation: Variable { /// The context representing the nesting level. let context: Context + let keyType: ExprSyntax + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// Whether strict validation is emitted after superclass decoding. + let deferUnknownKeyValidation: Bool + /// Additional keys accepted by the current decoding container. + let additionalAllowedKeys: [String] + /// Additional keys accepted at nested decoding container paths. + let additionalAllowedKeysByPath: [String: [String]] + /// The coding path represented by the current decoding location. + let codingPath: [String] + /// Paths whose generated validation is deferred. + let skipUnknownKeyValidationPaths: Set + /// Whether direct decoding must preserve strict decoder metadata. + let preserveDecoderMetadata: Bool + /// Whether validation for the current root container is deferred. + let skipCurrentContainerValidation: Bool /// The decoding/encoding progress. /// /// All the decoded/encoded variables and @@ -21,9 +38,41 @@ extension PropertyVariableTreeNode { /// - Parameters: /// - context: The context representing the nesting level. /// - progress: The decoding/encoding progress. - private init(context: Context, progress: Progress) { + /// - keyType: The `CodingKey` type for the current decoder. + /// - rejectUnknownKeys: Whether generated decoding rejects + /// unsupported keys. + /// - deferUnknownKeyValidation: Whether strict validation is + /// emitted after superclass decoding. + /// - additionalAllowedKeys: Keys allowed in the current container. + /// - additionalAllowedKeysByPath: Keys allowed at nested paths. + /// - codingPath: The normalized coding path for this location. + /// - skipUnknownKeyValidationPaths: Paths exempt from validation. + /// - preserveDecoderMetadata: Whether decoder metadata is preserved. + /// - skipCurrentContainerValidation: Whether the current container + /// is exempt from validation. + private init( + context: Context, progress: Progress, keyType: ExprSyntax, + rejectUnknownKeys: Bool, + deferUnknownKeyValidation: Bool, additionalAllowedKeys: [String], + additionalAllowedKeysByPath: [String: [String]], + codingPath: [String], + skipUnknownKeyValidationPaths: Set, + preserveDecoderMetadata: Bool, + skipCurrentContainerValidation: Bool + ) { self.context = context + self.keyType = keyType self.progress = progress + self.rejectUnknownKeys = rejectUnknownKeys + self.deferUnknownKeyValidation = deferUnknownKeyValidation + self.additionalAllowedKeys = additionalAllowedKeys + self.additionalAllowedKeysByPath = additionalAllowedKeysByPath + self.codingPath = codingPath + self.skipUnknownKeyValidationPaths = + skipUnknownKeyValidationPaths + self.preserveDecoderMetadata = preserveDecoderMetadata + self.skipCurrentContainerValidation = + skipCurrentContainerValidation } /// Add provided variable to list of already decoded/encoded variable. @@ -43,6 +92,11 @@ extension PropertyVariableTreeNode { progress.pending.append((context, variable)) } + /// Defers validation until all pending variables are decoded. + func deferValidation(_ syntax: CodeBlockItemListSyntax) { + progress.deferredValidations.append(syntax) + } + /// Creates a new location with provided context. /// /// Creates new location with the provided context as top-level context. @@ -50,13 +104,41 @@ extension PropertyVariableTreeNode { /// - Parameters: /// - coder: The decoder/encoder for decoding/encoding. /// - keyType: The `CodingKey` type. + /// - rejectUnknownKeys: Whether generated decoding rejects + /// unsupported keys. + /// - deferUnknownKeyValidation: Whether strict validation is + /// emitted after superclass decoding. + /// - additionalAllowedKeys: Keys allowed in the current container. + /// - additionalAllowedKeysByPath: Keys allowed at nested paths. + /// - skipUnknownKeyValidationPaths: Paths exempt from validation. + /// - preserveDecoderMetadata: Whether decoder metadata is preserved. + /// - skipCurrentContainerValidation: Whether the current container + /// is exempt from validation. /// /// - Returns: The new location updated with provided context. static func withCoder( - _ coder: TokenSyntax, keyType: ExprSyntax + _ coder: TokenSyntax, keyType: ExprSyntax, + rejectUnknownKeys: Bool = false, + deferUnknownKeyValidation: Bool = false, + additionalAllowedKeys: [String] = [], + additionalAllowedKeysByPath: [String: [String]] = [:], + skipUnknownKeyValidationPaths: Set = [], + preserveDecoderMetadata: Bool = false, + skipCurrentContainerValidation: Bool = false ) -> Self { .init( - context: .coder(coder, keyType: keyType), progress: .init() + context: .coder(coder, keyType: keyType), progress: .init(), + keyType: keyType, + rejectUnknownKeys: rejectUnknownKeys, + deferUnknownKeyValidation: deferUnknownKeyValidation, + additionalAllowedKeys: additionalAllowedKeys, + additionalAllowedKeysByPath: additionalAllowedKeysByPath, + codingPath: [], + skipUnknownKeyValidationPaths: + skipUnknownKeyValidationPaths, + preserveDecoderMetadata: preserveDecoderMetadata, + skipCurrentContainerValidation: + skipCurrentContainerValidation ) } @@ -69,12 +151,62 @@ extension PropertyVariableTreeNode { /// - Returns: The new location updated with provided context. func withContainer( _ container: Context.Container, key: CodingKeysMap.Key + ) -> Self { + let codingPath = codingPath + [key.description] + return .init( + context: .container(container, key: key), progress: progress, + keyType: keyType, + rejectUnknownKeys: rejectUnknownKeys, + deferUnknownKeyValidation: deferUnknownKeyValidation, + additionalAllowedKeys: + additionalAllowedKeysByPath[ + strictDecodingPathKey(codingPath) + ] ?? [], + additionalAllowedKeysByPath: additionalAllowedKeysByPath, + codingPath: codingPath, + skipUnknownKeyValidationPaths: + skipUnknownKeyValidationPaths, + preserveDecoderMetadata: preserveDecoderMetadata, + skipCurrentContainerValidation: false + ) + } + + /// Rebinds the current decoder while preserving strict metadata. + func withCoder( + _ coder: TokenSyntax, + preserveDecoderMetadata: Bool ) -> Self { .init( - context: .container(container, key: key), progress: progress + context: .coder(coder, keyType: keyType), progress: progress, + keyType: keyType, + rejectUnknownKeys: rejectUnknownKeys, + deferUnknownKeyValidation: deferUnknownKeyValidation, + additionalAllowedKeys: additionalAllowedKeys, + additionalAllowedKeysByPath: additionalAllowedKeysByPath, + codingPath: codingPath, + skipUnknownKeyValidationPaths: + skipUnknownKeyValidationPaths, + preserveDecoderMetadata: preserveDecoderMetadata, + skipCurrentContainerValidation: + skipCurrentContainerValidation ) } + /// The decoding/encoding location for an individual variable. + var forVariable: PropertyCodingLocation { + switch context { + case .coder(let coder, keyType: _): + if preserveDecoderMetadata { + return .coderWithMetadata(coder, method: nil) + } + return .coder(coder, method: nil) + case .container(let container, let key): + return .container( + container.name, key: key.expr, method: nil + ) + } + } + /// Provides the syntax for decoding at the provided location. /// /// Provides syntax for remaining pending variables that @@ -87,16 +219,27 @@ extension PropertyVariableTreeNode { /// - Returns: The generated decoding syntax. func decoding( in context: some MacroExpansionContext, from location: () = () + ) -> CodeBlockItemListSyntax { + decoding( + in: context, includeDeferredValidations: true + ) + } + + /// Provides pending decoding syntax with optional deferred + /// validation emission. + func decoding( + in context: some MacroExpansionContext, + includeDeferredValidations: Bool ) -> CodeBlockItemListSyntax { let pending = progress.pending.sorted { pending1, pending2 in - return pending2.variable.depends(on: pending1.variable) + pending2.variable.depends(on: pending1.variable) } return CodeBlockItemListSyntax { for (lContext, variable) in pending { let location = lContext.forVariable let syntax = variable.decoding(in: context, from: location) switch lContext { - case let .container(container, key: _) + case .container(let container, key: _) where container.isOptional: switch variable.decodingFallback { case .onlyIfMissing(let fallbacks): @@ -109,7 +252,7 @@ extension PropertyVariableTreeNode { } else: { fallbacks } - case let .ifMissing(fallbacks, ifError: eFallbacks): + case .ifMissing(let fallbacks, ifError: let eFallbacks): try! IfExprSyntax( "if let \(container.name) = \(container.name)", bodyBuilder: { @@ -130,6 +273,20 @@ extension PropertyVariableTreeNode { syntax } } + if includeDeferredValidations { + for validation in progress.deferredValidations { + validation + } + } + } + } + + /// Returns validations deferred by generated decoding. + func deferredDecoding() -> CodeBlockItemListSyntax { + CodeBlockItemListSyntax { + for validation in progress.deferredValidations { + validation + } } } @@ -163,6 +320,9 @@ extension PropertyVariableTreeNode { /// The decoding syntax for these variables can be created /// from the location instance. fileprivate(set) var pending: [Pending] = [] + /// Validations emitted after all pending variables decode. + fileprivate(set) var deferredValidations: + [CodeBlockItemListSyntax] = [] } /// Represents the context for decoding/encoding that the node needs diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 1bff26cac7..74f23ca09c 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -18,6 +18,8 @@ final class PropertyVariableTreeNode: Variable, VariableTreeNode { /// Nested registration node associated with keys. var children: OrderedDictionary = [:] + /// Keys recognized by decoding at this container level. + var allowedKeys: OrderedSet = [] /// The container for decoding variables linked to this node. /// @@ -48,19 +50,10 @@ extension PropertyVariableTreeNode { in context: some MacroExpansionContext, from location: CodingLocation ) -> Generated { - let varLocation = location.context.forVariable - let decodingSyntax = CodeBlockItemListSyntax { - for variable in data?.variables ?? variables - where variable.decode ?? true { - if variable.dependenciesCount == 0 { - variable.decoding(in: context, from: varLocation) - let _ = location.coded(variable) - } else { - let _ = location.pending(variable) - } - } + let directVariables = data?.variables ?? variables + let hasDirectDecodableVariables = directVariables.contains { + $0.decode ?? true } - let childrenDecodable = data?.children.contains { _, node in node.linkedVariables.contains { $0.decode ?? true } @@ -68,17 +61,77 @@ extension PropertyVariableTreeNode { ?? children.contains { _, node in node.linkedVariables.contains { $0.decode ?? true } } - guard !(data?.children.isEmpty ?? children.isEmpty), childrenDecodable - else { - return .init( - containerSyntax: "", codingSyntax: decodingSyntax, - conditionalSyntax: "" + let currentPath = strictDecodingPathKey(location.codingPath) + let skipsValidation = + location.skipCurrentContainerValidation + || location.skipUnknownKeyValidationPaths.contains(currentPath) + let hasMixedStrictDecoding = + location.rejectUnknownKeys + && !skipsValidation + && hasDirectDecodableVariables + && childrenDecodable + let hasMixedStrictValidation = + hasMixedStrictDecoding && !location.deferUnknownKeyValidation + let directLocation: CodingLocation = + if hasMixedStrictDecoding { + location.withCoder( + context.makeUniqueName("strictDecoder"), + preserveDecoderMetadata: true + ) + } else { + location + } + let strictDecoder: TokenSyntax? = + if hasMixedStrictDecoding, + case .coder(let directDecoder, _) = directLocation.context + { + directDecoder + } else { + nil + } + let strictValidation = + location.rejectUnknownKeys + && !location.deferUnknownKeyValidation + && !skipsValidation + && !hasDirectDecodableVariables + let currentAllowedKeys = + Array(data?.allowedKeys ?? self.allowedKeys) + + location.additionalAllowedKeys + let allowedKeys: [String]? = + strictValidation ? currentAllowedKeys : nil + let deferredAllowedKeys: [String]? = + hasMixedStrictDecoding ? currentAllowedKeys : nil + let deferredDecoderStorage: TokenSyntax? = + location.deferUnknownKeyValidation && hasMixedStrictDecoding + ? context.makeUniqueName("deferredStrictDecoder") : nil + let wrapperSyntax: CodeBlockItemListSyntax = { + guard let strictDecoder else { return [] } + let decoder: ExprSyntax + switch location.context { + case .coder(let decoderExpression, _): + decoder = "\(decoderExpression)" + case .container(let container, let key): + decoder = + "try \(container.name).superDecoder(forKey: \(key.expr))" + } + let metadata = strictDecodingMetadataSyntax( + ["": deferredAllowedKeys ?? []] ) - } - + let declaration = + "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(decoder), allowedKeysByPath: \(metadata))" + if let deferredDecoderStorage { + return CodeBlockItemListSyntax { + CodeBlockItemSyntax(stringLiteral: declaration) + "\(deferredDecoderStorage) = \(strictDecoder)" + } + } + return CodeBlockItemListSyntax { + CodeBlockItemSyntax(stringLiteral: declaration) + } + }() let decodableChildren = children.lazy .filter { data?.hasKey($0.key) ?? true } - return decodableChildren.lazy + let decodingFallback = decodableChildren.lazy .flatMap(\.value.linkedVariables) .map { variable in switch variable.decodingFallback { @@ -91,36 +144,157 @@ extension PropertyVariableTreeNode { } } .reduce(.ifMissing([], ifError: []), +) - .represented( - context: location.context, nestedContainer: decodingContainer, - nestedContainerHasVariables: !self.children.lazy - .flatMap(\.value.variables) - .filter { $0.decode ?? true }.isEmpty - ) { container in - self.decodingContainer = container.name - let generated = decodableChildren.map { cKey, node in - return node.decoding( - with: data?.children[cKey], - in: context, - from: location.withContainer(container, key: cKey) + let mixedContainerOptional = + hasMixedStrictDecoding && decodingFallback.isOptional + let scopeMixedCoding = + hasMixedStrictDecoding && decodingFallback.isOptional + let mixedValidationSyntax: CodeBlockItemListSyntax = { + guard let strictDecoder, let deferredAllowedKeys + else { return [] } + let metadata = strictDecodingMetadataSyntax( + ["": deferredAllowedKeys] + ) + if let deferredDecoderStorage { + return CodeBlockItemListSyntax { + try! IfExprSyntax( + "if let \(deferredDecoderStorage) = \(deferredDecoderStorage)" + ) { + "try \(deferredDecoderStorage).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + } + } + } + return + "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + }() + if !hasMixedStrictValidation, !mixedValidationSyntax.isEmpty { + directLocation.deferValidation(mixedValidationSyntax) + } + let mixedContainerName: TokenSyntax? = + hasMixedStrictDecoding + ? context.makeUniqueName("strictContainer") : nil + let mixedContainerOptionalName: TokenSyntax? = + mixedContainerOptional + ? context.makeUniqueName("strictContainerOptional") : nil + let decodingSyntax = CodeBlockItemListSyntax { + if hasMixedStrictDecoding, + let strictDecoder, + let mixedContainerName = + mixedContainerOptionalName ?? mixedContainerName + { + wrapperSyntax + let `try`: TokenSyntax = + mixedContainerOptional ? "try?" : "try" + "let \(mixedContainerName) = \(`try`) \(strictDecoder).container(keyedBy: \(location.keyType))" + } + for variable in directVariables + where variable.decode ?? true { + if variable.dependenciesCount == 0 { + variable.decoding( + in: context, from: directLocation.forVariable ) - }.reduce( - .init( - containerSyntax: "", codingSyntax: "", - conditionalSyntax: "" - ), + - ) + let _ = location.coded(variable) + } else { + let _ = directLocation.pending(variable) + } + } + } + guard !(data?.children.isEmpty ?? children.isEmpty), childrenDecodable + else { + if strictValidation, !hasDirectDecodableVariables, + case .coder(let decoder, _) = location.context + { return .init( containerSyntax: CodeBlockItemListSyntax { - generated.containerSyntax + strictDecodingValidationSyntax( + decoder: "\(decoder)" as ExprSyntax, + allowedKeys: allowedKeys + ) }, codingSyntax: decodingSyntax, - conditionalSyntax: CodeBlockItemListSyntax { - generated.codingSyntax - generated.conditionalSyntax - } + conditionalSyntax: "" ) } + return .init( + containerSyntax: "", codingSyntax: decodingSyntax, + conditionalSyntax: "" + ) + } + + let generated = decodingFallback.represented( + context: + hasMixedStrictDecoding + ? directLocation.context : location.context, + nestedContainer: + hasMixedStrictDecoding + ? (mixedContainerOptionalName ?? mixedContainerName) + : (location.rejectUnknownKeys + && !location.deferUnknownKeyValidation + ? nil : decodingContainer), + nestedContainerHasVariables: + location.skipCurrentContainerValidation + || !self.children.lazy + .flatMap(\.value.variables) + .filter { $0.decode ?? true }.isEmpty, + allowedKeys: allowedKeys, + scopeCodingSyntax: scopeMixedCoding, + trailingValidationSyntax: + hasMixedStrictValidation ? mixedValidationSyntax : [], + forceNonOptionalContainer: mixedContainerOptional, + childContainerOptional: mixedContainerOptional, + containerName: + mixedContainerOptional + ? mixedContainerOptionalName : nil + ) { container in + self.decodingContainer = container.name + let childContainer = + if hasMixedStrictDecoding && mixedContainerOptional { + PropertyVariableTreeNode.CodingLocation.Context.Container( + name: "\(container.name)?", isOptional: false + ) + } else { + container + } + let generated = decodableChildren.map { cKey, node in + node.decoding( + with: data?.children[cKey], + in: context, + from: (hasMixedStrictDecoding + ? directLocation : location).withContainer( + childContainer, key: cKey) + ) + }.reduce( + .init( + containerSyntax: "", codingSyntax: "", + conditionalSyntax: "" + ), + + ) + let scopedCodingSyntax = CodeBlockItemListSyntax { + decodingSyntax + generated.containerSyntax + } + return .init( + containerSyntax: + hasMixedStrictDecoding + ? "" : generated.containerSyntax, + codingSyntax: + hasMixedStrictDecoding + ? scopedCodingSyntax : decodingSyntax, + conditionalSyntax: CodeBlockItemListSyntax { + generated.codingSyntax + generated.conditionalSyntax + } + ) + } + return .init( + containerSyntax: CodeBlockItemListSyntax { + if let deferredDecoderStorage { + "var \(deferredDecoderStorage): MetaCodableStrictDecoder?" + } + generated.containerSyntax + }, + codingSyntax: generated.codingSyntax, + conditionalSyntax: generated.conditionalSyntax + ) } /// Provides the code block list syntax for decoding individual diff --git a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift new file mode 100644 index 0000000000..9641b29ce2 --- /dev/null +++ b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift @@ -0,0 +1,69 @@ +import SwiftSyntax +import SwiftSyntaxBuilder + +private func escapedStringLiteral(_ value: String) -> String { + value.reduce(into: "") { result, character in + switch character { + case "\\": + result += "\\\\" + case "\"": + result += "\\\"" + case "\n": + result += "\\n" + case "\r": + result += "\\r" + case "\t": + result += "\\t" + default: + result.append(character) + } + } +} + +package func strictDecodingPathKey(_ components: [String]) -> String { + components.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") +} + +/// Generates validation for unsupported keys at a keyed decoding container. +package func strictDecodingValidationSyntax( + decoder: ExprSyntax, + allowedKeys: [String]? +) -> CodeBlockItemListSyntax { + guard let allowedKeys else { return [] } + let keys = allowedKeys.map { + "\"\(escapedStringLiteral($0))\"" + }.joined(separator: ", ") + return CodeBlockItemListSyntax { + "try \(decoder).rejectUnknownKeys(allowedKeys: [\(raw: keys)], type: Self.self)" + } +} + +/// Generates conditional validation for mixed scalar/keyed enum decoding. +package func strictDecodingConditionalValidationSyntax( + decoder: ExprSyntax, + keyType: ExprSyntax, + allowedKeys: [String] +) -> CodeBlockItemListSyntax { + let validation = strictDecodingValidationSyntax( + decoder: decoder, allowedKeys: allowedKeys + ) + return CodeBlockItemListSyntax { + try! IfExprSyntax( + "if (try? \(decoder).container(keyedBy: \(keyType))) != nil" + ) { + validation + } + } +} +/// Generates a dictionary literal for strict decoding key metadata. +package func strictDecodingMetadataSyntax( + _ metadata: [String: [String]] +) -> ExprSyntax { + let entries = metadata.keys.sorted().map { path in + let keys = metadata[path, default: []].map { + "\"\(escapedStringLiteral($0))\"" + }.joined(separator: ", ") + return "\"\(escapedStringLiteral(path))\": [\(keys)]" + }.joined(separator: ", ") + return ExprSyntax(stringLiteral: "[\(entries)]") +} diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index 95f27ac1a0..5280109080 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -19,7 +19,7 @@ struct ClassVariable: TypeVariable, DeclaredVariable { var inherits: Inherits? { for attribute in decl.attributes { guard - case let .attribute(attribute) = attribute, + case .attribute(let attribute) = attribute, let attr = Inherits(from: attribute) else { continue } return attr @@ -127,9 +127,26 @@ struct ClassVariable: TypeVariable, DeclaredVariable { var modifiers: DeclModifierListSyntax = [.init(name: "required")] let method = location.method let conformance = TypeSyntax(stringLiteral: method.protocol) + let strictDecoder: TokenSyntax = "strictDecoder" if location.conformance == nil, implementDecodable(location: location) { - newLocation = .init(method: method, conformance: conformance) + let overriddenMethod = + if inherits?.decodable ?? true { + TypeCodingLocation.Method( + name: method.name, protocol: method.protocol, + argLabel: method.argLabel, arg: strictDecoder, + argType: method.argType + ) + } else { + method + } + newLocation = .init( + method: overriddenMethod, conformance: conformance, + rejectUnknownKeys: location.rejectUnknownKeys, + deferUnknownKeyValidation: + location.rejectUnknownKeys + && (inherits?.decodable ?? true) + ) overridden = inherits?.decodable ?? true } else { newLocation = location @@ -139,13 +156,30 @@ struct ClassVariable: TypeVariable, DeclaredVariable { guard let generated = group.decoding(in: context, from: newLocation) else { return nil } - var code: CodeBlockItemListSyntax = - if overridden { - "try super.\(method.name)(\(method.argLabel): \(method.arg))" - } else { - "" + let metadataMap = group.decodingNode.allowedKeysByPath() + var code: CodeBlockItemListSyntax + if overridden { + let metadata = strictDecodingMetadataSyntax( + metadataMap + ) + code = CodeBlockItemListSyntax { + "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(method.arg), allowedKeysByPath: \(metadata))" + generated.code + "try super.\(method.name)(\(method.argLabel): \(strictDecoder))" + group.deferredDecoding(in: context) + if location.rejectUnknownKeys { + "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + } } - code.insert(contentsOf: generated.code, at: code.startIndex) + } else { + code = + if overridden { + "try super.\(method.name)(\(method.argLabel): \(method.arg))" + } else { + "" + } + code.insert(contentsOf: generated.code, at: code.startIndex) + } modifiers.append(contentsOf: generated.modifiers) return .init( code: code, modifiers: modifiers, diff --git a/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift b/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift index f7b17b0f18..a800ed76eb 100644 --- a/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift +++ b/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift @@ -43,6 +43,10 @@ package final class CodingKeysMap { /// in generated code syntax and must be present as case /// in generated `CodingKey` enum declaration. private var usedKeys: Set = [] + /// All raw keys registered with this map. + var allKeys: [String] { + Array(data.keys) + } /// Creates a new case-map with provided enum name. /// diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 780dde41f2..9f4836c354 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -89,7 +89,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { FunctionCallExprSyntax(callee: callee) { args } } else { FunctionCallExprSyntax( - calledExpression: callee, leftParen: nil, rightParen: nil + calledExpression: callee, leftParen: nil, + rightParen: nil ) {} } return ExprSyntax(fExpr) @@ -270,13 +271,73 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let selfType: ExprSyntax = "\(name).self" let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.decode ?? true }) { + let hasDirectDecodingCase = + cases.contains { $0.variable.usesDirectDecoding } + || (switcher.allowsDirectDecoding + && cases.contains { + !$0.variable.usesKeyedDecoding + }) + let hasDelegatedDirectCase = + hasDirectDecodingCase + && switcher.directPayloadSharesValidationContainer + let hasAssociatedValues = cases.contains { + !$0.variable.variables.isEmpty + } + let deferRootUnknownKeyValidation = + hasDelegatedDirectCase + || (switcher.isInternallyTagged + && switcher.caseDecodingSharesRootContainer + && hasAssociatedValues) + let shouldPreflightKeyedContainer = + !deferRootUnknownKeyValidation + && (switcher.usesKeyedContainer + || switcher.allowsDirectDecoding && !hasDirectDecodingCase) + let shouldConditionallyPreflightKeyedContainer = + false + var rootDecodingKeys = + switcher.rootDecodingKeys ?? codingKeys.allKeys + if switcher.caseDecodingSharesRootContainer { + for key in cases.flatMap({ + $0.variable.decodingAllowedKeys + }) where !rootDecodingKeys.contains(key) { + rootDecodingKeys.append(key) + } + } let switcherLoc = EnumSwitcherLocation( coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: "_", cases: cases, codeExpr: caseDecodeExpr, - hasDefaultCase: forceDefault + hasDefaultCase: forceDefault, + rejectUnknownKeys: location.rejectUnknownKeys, + rootDecodingKeys: rootDecodingKeys, + discriminatorDecodingKeys: + switcher.discriminatorDecodingKeys, + caseDecodingSharesRootContainer: + switcher.caseDecodingSharesRootContainer, + caseDecodingAdditionalAllowedKeys: + switcher.caseDecodingAdditionalAllowedKeys, + additionalAllowedKeysByPath: [:], + skipUnknownKeyValidationPaths: [], + skipRootUnknownKeyValidation: + deferRootUnknownKeyValidation ) code = CodeBlockItemListSyntax { + if location.rejectUnknownKeys { + if shouldPreflightKeyedContainer { + strictDecodingValidationSyntax( + decoder: "\(location.method.arg)" as ExprSyntax, + allowedKeys: rootDecodingKeys + ) + } else if shouldConditionallyPreflightKeyedContainer, + !rootDecodingKeys.isEmpty + { + strictDecodingConditionalValidationSyntax( + decoder: "\(location.method.arg)" as ExprSyntax, + keyType: codingKeys.type, + allowedKeys: rootDecodingKeys + ) + } + } switcher.decoding(in: context, from: switcherLoc) } } else { @@ -328,7 +389,15 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: expr, cases: cases, codeExpr: caseEncodeExpr, - hasDefaultCase: forceDefault + hasDefaultCase: forceDefault, + rejectUnknownKeys: false, + rootDecodingKeys: [], + discriminatorDecodingKeys: [], + caseDecodingSharesRootContainer: false, + caseDecodingAdditionalAllowedKeys: [], + additionalAllowedKeysByPath: [:], + skipUnknownKeyValidationPaths: [], + skipRootUnknownKeyValidation: false ) code = CodeBlockItemListSyntax { switcher.encoding(in: context, to: switcherLocation) @@ -525,7 +594,7 @@ extension EnumVariable { case (.bool, .bool), (.int, .int), (.double, .double), (.string, .string): return true - case let (.unknown(lhsType), .unknown(rhsType)): + case (.unknown(let lhsType), .unknown(let rhsType)): return lhsType.trimmedDescription == rhsType.trimmedDescription default: diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index c109f105a2..8ad06c04a5 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -12,6 +12,11 @@ where Decl: MemberGroupSyntax & GenericTypeDeclSyntax & AttributableDeclSyntax, Decl.MemberSyntax: VariableSyntax, Decl.MemberSyntax.Variable: NamedVariable { + /// Stores the latest decoder location for deferred class validation. + private final class DecodingState { + var location: PropertyVariableTreeNode.CodingLocation? + } + /// The declaration members syntax type. typealias MemberSyntax = Decl.MemberSyntax @@ -24,6 +29,10 @@ where /// The `CodingKeys` map containing keys /// and generated case names. let codingKeys: CodingKeysMap + private let decodingState: DecodingState + /// Whether direct optional decoding may receive a strict superclass + /// decoder. + let preservesDecoderMetadata: Bool /// Creates a new member group from provided data. /// @@ -61,7 +70,10 @@ where if variable.decode ?? true { let keys = codingKeys.add( keys: decodingPath, field: name, context: context) - decodingNode.register(variable: variable, keyPath: keys) + decodingNode.register( + variable: variable, keyPath: keys, + additionalKeys: variable.additionalDecodingKeys + ) } if variable.encode ?? true { @@ -73,6 +85,8 @@ where self.decodingNode = decodingNode self.encodingNode = encodingNode self.codingKeys = codingKeys + self.preservesDecoderMetadata = Decl.self == ClassDeclSyntax.self + self.decodingState = .init() } /// Provides the syntax for decoding at the provided location. @@ -90,12 +104,23 @@ where from location: TypeCodingLocation ) -> TypeGenerated? { guard let conformance = location.conformance else { return nil } + let nLocation = PropertyVariableTreeNode.CodingLocation.withCoder( + location.method.arg, keyType: codingKeys.type, + rejectUnknownKeys: location.rejectUnknownKeys, + deferUnknownKeyValidation: + location.deferUnknownKeyValidation, + preserveDecoderMetadata: + location.rejectUnknownKeys + || preservesDecoderMetadata + ) + decodingState.location = nLocation let syntax = CodeBlockItemListSyntax { - let nLocation = PropertyVariableTreeNode.CodingLocation.withCoder( - location.method.arg, keyType: codingKeys.type - ) decodingNode.decoding(in: context, from: nLocation).combined() - nLocation.decoding(in: context) + nLocation.decoding( + in: context, + includeDeferredValidations: + !location.deferUnknownKeyValidation + ) } return .init( code: syntax, modifiers: [], @@ -107,6 +132,13 @@ where ) } + /// Returns validations that must run after superclass decoding. + func deferredDecoding( + in context: some MacroExpansionContext + ) -> CodeBlockItemListSyntax { + decodingState.location?.deferredDecoding() ?? "" + } + /// Provides the syntax for encoding at the provided location. /// /// If conformance type provided is `nil` no expansion performed. diff --git a/Sources/PluginCore/Variables/Type/TypeVariable.swift b/Sources/PluginCore/Variables/Type/TypeVariable.swift index 89196b4746..43a48b6b8c 100644 --- a/Sources/PluginCore/Variables/Type/TypeVariable.swift +++ b/Sources/PluginCore/Variables/Type/TypeVariable.swift @@ -150,6 +150,10 @@ package struct TypeCodingLocation { /// Can be `Decodable`/`Encodable` type /// or `null` if type already has conformance. let conformance: TypeSyntax? + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// Whether strict validation is emitted after superclass decoding. + let deferUnknownKeyValidation: Bool /// Creates type decoding/encoding data from provided data. /// @@ -157,8 +161,31 @@ package struct TypeCodingLocation { /// - method: The method data. /// - conformance: The conformed type. package init(method: Method, conformance: TypeSyntax?) { + self.init( + method: method, conformance: conformance, + rejectUnknownKeys: false, + deferUnknownKeyValidation: false + ) + } + + /// Creates type coding location with decoding strictness. + /// + /// - Parameters: + /// - method: The method data. + /// - conformance: The conformed type. + /// - rejectUnknownKeys: Whether generated decoding rejects unsupported + /// keys. + /// - deferUnknownKeyValidation: Whether strict validation is emitted + /// after superclass decoding. + package init( + method: Method, conformance: TypeSyntax?, + rejectUnknownKeys: Bool, + deferUnknownKeyValidation: Bool = false + ) { self.method = method self.conformance = conformance + self.rejectUnknownKeys = rejectUnknownKeys + self.deferUnknownKeyValidation = deferUnknownKeyValidation } } diff --git a/Tests/MetaCodableTests/CodableInheritanceTests.swift b/Tests/MetaCodableTests/CodableInheritanceTests.swift index 80a381fab5..bf2c0a307e 100644 --- a/Tests/MetaCodableTests/CodableInheritanceTests.swift +++ b/Tests/MetaCodableTests/CodableInheritanceTests.swift @@ -174,9 +174,10 @@ struct CodableInheritanceTests { override init() { super.init() } required init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let strictDecoder = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try strictDecoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: decoder) + try super.init(from: strictDecoder) } override func encode(to encoder: any Encoder) throws { @@ -249,9 +250,10 @@ struct CodableInheritanceTests { override init() { super.init() } required init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let strictDecoder = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try strictDecoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: decoder) + try super.init(from: strictDecoder) } func encode(to encoder: any Encoder) throws { diff --git a/Tests/MetaCodableTests/CodableTests.swift b/Tests/MetaCodableTests/CodableTests.swift index 81f8168f8f..5899b1e0f1 100644 --- a/Tests/MetaCodableTests/CodableTests.swift +++ b/Tests/MetaCodableTests/CodableTests.swift @@ -447,9 +447,10 @@ struct CodableTests { } required init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let strictDecoder = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try strictDecoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: decoder) + try super.init(from: strictDecoder) } override func encode(to encoder: any Encoder) throws { @@ -582,7 +583,7 @@ struct CodableTests { enum Foo { case foo } - + extension Foo: Decodable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: DecodingKeys.self) @@ -600,7 +601,7 @@ struct CodableTests { } } } - + extension Foo: Encodable { func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -610,7 +611,7 @@ struct CodableTests { } } } - + extension Foo { enum CodingKeys: String, CodingKey { case foo = "foo" @@ -639,7 +640,9 @@ struct CodableTests { func encodingToJSON() throws { let original = Foo.foo let encoded = try JSONEncoder().encode(original) - let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + let json = + try JSONSerialization.jsonObject(with: encoded) + as? [String: Any] #expect((json?["foo"] as? [String: Any])?.isEmpty == true) } } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift new file mode 100644 index 0000000000..44791b915e --- /dev/null +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -0,0 +1,1602 @@ +import Foundation +import HelperCoders +import MetaCodable +import Testing + +@testable import PluginCore + +struct StrictDecodingTests { + @Codable(rejectUnknownKeys: true) + struct StrictRecord: Equatable { + let name: String + let count: Int + } + + @Codable + struct PermissiveRecord { + let name: String + } + + @Codable + struct PermissivePayload { + let name: String + } + + struct PermissivePayloadCoder: HelperCoder { + typealias Coded = PermissivePayload + + func decode(from decoder: Decoder) throws -> PermissivePayload { + try PermissivePayload(from: decoder) + } + } + + @Test + func strictInternalEnumsValidateWildcardKeyedPayloads() throws { + try expectDataCorrupted( + StrictWildcardKeyedInternalCommand.self, + json: #"{"type":"item","name":"n","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictInternalEnumsAllowDiscriminatorKeysInWildcardPayloads() throws { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictWildcardDelegatedInternalCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + #expect(value.count == 1) + } else { + Issue.record("Expected wildcard delegated item case") + } + } + + @Test + func strictOptionalPayloadsPreserveDateAndDataStrategies() throws { + enum StrategyKeys: String, CodingKey { + case date + case data + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.container(keyedBy: StrategyKeys.self) + let timestamp = try container.decode( + Double.self, forKey: .date + ) + return Date(timeIntervalSince1970: timestamp) + } + decoder.dataDecodingStrategy = .custom { decoder in + let container = try decoder.container(keyedBy: StrategyKeys.self) + let value = try container.decode(String.self, forKey: .data) + return try #require(Data(base64Encoded: value)) + } + + let date = try decoder.decode( + StrictOptionalStrategyCommand.self, + from: Data(#"{"type":"date","date":123}"#.utf8) + ) + if case .date(let value) = date { + #expect(value?.timeIntervalSince1970 == 123) + } else { + Issue.record("Expected date case") + } + + let data = try decoder.decode( + StrictOptionalStrategyCommand.self, + from: Data(#"{"type":"data","data":"AQI="}"#.utf8) + ) + if case .data(let value) = data { + #expect(value == Data([1, 2])) + } else { + Issue.record("Expected data case") + } + } + + @Test + func strictInternalEnumsAllowDiscriminatorKeysInStrictDirectPayloads() + throws + { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictStrictDelegatedInternalCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + #expect(value.count == 1) + } else { + Issue.record("Expected strict delegated item case") + } + } + + @Test + func strictInternalEnumsAllowDiscriminatorKeysInOptionalPayloads() + throws + { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictOptionalDelegatedInternalCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value?.name == "n") + #expect(value?.count == 1) + } else { + Issue.record("Expected optional delegated item case") + } + } + + @Test + func strictInternalEnumsAllowMixedDirectAndKeyedPayloads() throws { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let permissive = try JSONDecoder().decode( + StrictMixedInternalCommand.self, + from: data + ) + if case .item(let payload, let count) = permissive { + #expect(payload.name == "n") + #expect(count == 1) + } else { + Issue.record("Expected mixed delegated item case") + } + + let strict = try JSONDecoder().decode( + StrictMixedStrictInternalCommand.self, + from: data + ) + if case .item(let payload, let count) = strict { + #expect(payload.name == "n") + #expect(count == 1) + } else { + Issue.record("Expected mixed strict delegated item case") + } + } + + @Test + func strictNestedAdjacentEnumsAllowDelegatedPayloadKeys() throws { + let data = Data( + #"{"meta":{"kind":"item","name":"n"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedAdjacentCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + } else { + Issue.record("Expected nested adjacent item case") + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveNestedClassBase { + var nestedOnly: String = "" + + init() {} + } + + @Codable + @Inherits(decodable: true, encodable: true) + class PermissiveNestedClass: PermissiveNestedClassBase { + override init() { + super.init() + } + } + + struct PermissiveNestedClassCoder: HelperCoder { + typealias Coded = PermissiveNestedClass + + func decode(from decoder: Decoder) throws -> PermissiveNestedClass { + try PermissiveNestedClass(from: decoder) + } + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedDirectRecord { + @CodedAt + let payload: StrictDecodingTests.PermissivePayload + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictOptionalMixedDirectRecord { + @CodedAt + let payload: StrictDecodingTests.PermissivePayload? + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedDescendantRecord { + @CodedAt + let payload: StrictDecodingTests.PermissivePayload + @CodedIn("details") + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedNestedDirectRecord { + @CodedAt("payload") + let payload: StrictDecodingTests.PermissivePayload + @CodedIn("payload") + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedNestedDefaultRecord { + @Default(StrictDecodingTests.PermissivePayload(name: "fallback")) + @CodedAt("payload") + let payload: StrictDecodingTests.PermissivePayload + @Default(0) + @CodedIn("payload") + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedNestedOptionalRecord { + @CodedAt("payload") + let payload: StrictDecodingTests.PermissivePayload? + @CodedIn("payload") + let count: Int? + } + + @Codable(rejectUnknownKeys: true) + struct StrictOptionalDictionaryRecord { + @CodedAt + let payload: [String: Int]? + } + + @ConformDecodable(rejectUnknownKeys: true) + struct StrictDecodableRecord { + let value: String + } + + @ConformEncodable + struct StrictEncodableRecord { + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictNestedRecord { + @CodedIn("payload") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictRenamedRecord { + @CodedAt("wire_value") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictDirectRecord { + @CodedAt + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictDecodedAtRecord { + @DecodedAt("payload", "wire_value") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictAliasRecord { + @CodedAs("legacy_value") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictDefaultNestedRecord { + @Default("fallback") + @CodedIn("payload") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictIgnoredRecord { + let value: String + @IgnoreDecoding + var ignored: String = "default" + } + + @Codable( + commonStrategies: [.codedBy(.valueCoder())], + rejectUnknownKeys: true + ) + struct StrictStrategyRecord { + let value: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictArrayRecord { + let values: [StrictDecodingTests.StrictRecord] + } + + @Codable + struct StrictEnvelope { + let item: StrictDecodingTests.StrictNestedChild + } + + @Codable(rejectUnknownKeys: true) + struct StrictEmptyRecord {} + + @Codable(rejectUnknownKeys: true) + struct StrictClass { + var value: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictChild: StrictBase { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictBaseForPermissiveChild { + var base: String = "" + + init() {} + } + + @Codable + @Inherits(decodable: true, encodable: true) + class PermissiveStrictChild: StrictBaseForPermissiveChild { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictScalarBase { + @CodedAt + var value: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictScalarChild: StrictScalarBase { + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictOptionalDirectBase { + @CodedAt + var payload: StrictRecord? = nil + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictOptionalDirectChild: StrictOptionalDirectBase { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveOptionalDirectBase { + @CodedAt + var payload: StrictRecord? = nil + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictOptionalDirectChildOfPermissive: + PermissiveOptionalDirectBase + { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictMixedDirectBase { + var base: String = "" + @CodedAt("payload") + var payload: StrictDecodingTests.PermissivePayload = + .init(name: "") + @CodedIn("payload") + var count: Int = 0 + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictMixedDirectChild: StrictMixedDirectBase { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictOptionalScalarBase { + @CodedAt + var value: String? = nil + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictOptionalScalarChild: StrictOptionalScalarBase { + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictNestedBase { + @CodedIn("payload") + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictNestedChild: StrictNestedBase { + @CodedIn("payload") + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictStructuralBase { + @CodedIn("payload") + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictStructuralChild: StrictStructuralBase { + @CodedIn("payload", "details") + var value: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictNestedGrandChild: StrictNestedChild { + @CodedIn("payload") + var leaf: String = "" + + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictPermissiveChild: PermissiveBase { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveEmptyBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictEmptyChild: PermissiveEmptyBase { + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class EmptyPermissiveBase { + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictEmptyHierarchyChild: EmptyPermissiveBase { + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveHelperBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictHelperChild: PermissiveHelperBase { + @CodedBy(PermissivePayloadCoder()) + var payload: PermissivePayload = .init(name: "") + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictNestedScopeBase { + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictNestedScopeChild: StrictNestedScopeBase { + @CodedBy(PermissiveNestedClassCoder()) + var payload: PermissiveNestedClass = .init() + + override init() { + super.init() + } + } + + struct ManualSuperPayload: Swift.Decodable { + let value: String + } + + class ManualSuper: Swift.Decodable { + var superValue: String = "" + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ManualKeys.self) + superValue = try ManualSuperPayload( + from: container.superDecoder() + ).value + } + + enum ManualKeys: String, CodingKey { + case superValue = "super" + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: false) + class StrictManualSuperChild: ManualSuper { + var child: String = "" + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveNestedBase { + @CodedIn("payload") + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictPermissiveNestedChild: PermissiveNestedBase { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + actor StrictActor { + let value: String + } + + @Codable(rejectUnknownKeys: true) + enum StrictExternalCommand: Equatable { + case load(key: String) + case store(key: String, value: Int) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictInternalCommand: Equatable { + case load(key: String) + case store(key: String, value: Int) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictDelegatedInternalCommand { + case item(StrictDecodingTests.PermissivePayload) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictStrictDelegatedInternalCommand { + case item(StrictDecodingTests.StrictRecord) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictWildcardDelegatedInternalCommand { + case item(_: StrictDecodingTests.StrictRecord) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictWildcardKeyedInternalCommand { + case item(_ name: String) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictOptionalDelegatedInternalCommand { + case item(StrictDecodingTests.StrictRecord?) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictOptionalStrategyCommand { + case date(Date?) + case data(Data?) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictMixedInternalCommand { + case item( + StrictDecodingTests.PermissivePayload, + count: Int + ) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictMixedStrictInternalCommand { + case item( + StrictDecodingTests.StrictRecord, + count: Int + ) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + @ContentAt("content") + enum StrictAdjacentCommand: Equatable { + case load(key: String) + case store(key: String, value: Int) + } + + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictUntaggedCommand { + case first(a: Int) + case second(b: String) + } + + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictMixedUntaggedCommand: Equatable { + case text(String) + case record(value: Int) + } + + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictDelegatedUntaggedCommand { + case record(StrictDecodingTests.StrictRecord) + case number(value: Int) + } + + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictTransactionalUntaggedCommand { + case number(value: Int) + case record(StrictDecodingTests.PermissivePayload, count: Int) + } + + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictOptionalDecimalCommand { + case decimal(Decimal?) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("meta", "kind") + @ContentAt("meta") + enum StrictNestedAdjacentCommand { + case item(StrictDecodingTests.PermissivePayload) + } + + @Codable(rejectUnknownKeys: true) + enum StrictRawCommand: String { + case ready + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("meta", "kind") + enum StrictNestedRawCommand: String { + case ready + } + + @Test + func strictExpansionAddsValidation() throws { + assertMacroExpansion( + """ + @Codable(rejectUnknownKeys: true) + struct Model { + let value: String + } + """, + expandedSource: + """ + struct Model { + let value: String + } + + extension Model: Decodable { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try decoder.rejectUnknownKeys(allowedKeys: ["value"], type: Self.self) + self.value = try container.decode(String.self, forKey: CodingKeys.value) + } + } + + extension Model: Encodable { + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.value, forKey: CodingKeys.value) + } + } + + extension Model { + enum CodingKeys: String, CodingKey { + case value = "value" + } + } + """ + ) + } + + @Test + func strictInternalEnumsRejectKeysFromOtherCases() throws { + try expectDataCorrupted( + StrictInternalCommand.self, + json: #"{"type":"load","key":"key","value":123}"#, + containing: ["value"] + ) + } + + @Test + func strictInternalEnumsAllowDelegatedPayloadKeys() throws { + let data = Data( + #"{"type":"item","name":"n"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictDelegatedInternalCommand.self, from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + } else { + Issue.record("Expected delegated item case") + } + } + + @Test + func strictRecordRejectsUnknownKeysAndReportsAllKeys() throws { + try expectDataCorrupted( + StrictRecord.self, + json: """ + { + "name": "value", + "count": 42, + "first_extra": true, + "second_extra": false + } + """, + containing: ["first_extra", "second_extra"] + ) + } + + @Test + func strictChildOfPermissiveInheritanceAcceptsBaseKeys() throws { + let data = Data(#"{"base":"base","child":"child"}"#.utf8) + let decoded = try JSONDecoder().decode( + PermissiveStrictChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + } + + @Test + func strictMixedDirectAndKeyedRecordsAllowDelegatedKeys() throws { + let data = Data(#"{"name":"n","count":1}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictMixedDirectRecord.self, from: data + ) + #expect(decoded.payload.name == "n") + #expect(decoded.count == 1) + + try expectDataCorrupted( + StrictMixedDirectRecord.self, + json: #"{"name":"n","count":1,"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictMixedOptionalAndDescendantRecordsCompileAndDecode() throws { + let optional = try JSONDecoder().decode( + StrictOptionalMixedDirectRecord.self, + from: Data(#"{"name":"n","count":1}"#.utf8) + ) + #expect(optional.payload?.name == "n") + #expect(optional.count == 1) + + let descendant = try JSONDecoder().decode( + StrictMixedDescendantRecord.self, + from: Data(#"{"name":"n","details":{"count":1}}"#.utf8) + ) + #expect(descendant.payload.name == "n") + #expect(descendant.count == 1) + } + + @Test + func strictMixedNestedDirectAndKeyedRecordsAllowDelegatedKeys() throws { + let data = Data( + #"{"payload":{"name":"n","count":1}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictMixedNestedDirectRecord.self, + from: data + ) + #expect(decoded.payload.name == "n") + #expect(decoded.count == 1) + + try expectDataCorrupted( + StrictMixedNestedDirectRecord.self, + json: #"{"payload":{"name":"n","count":1,"extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func strictMixedNestedDefaultsAndOptionalsPreserveFallbacks() throws { + let defaults = try JSONDecoder().decode( + StrictMixedNestedDefaultRecord.self, + from: Data("{}".utf8) + ) + #expect(defaults.payload.name == "fallback") + #expect(defaults.count == 0) + + let nulls = try JSONDecoder().decode( + StrictMixedNestedOptionalRecord.self, + from: Data(#"{"payload":null}"#.utf8) + ) + #expect(nulls.payload == nil) + #expect(nulls.count == nil) + + try expectDataCorrupted( + StrictMixedNestedDefaultRecord.self, + json: #"{"payload":{"name":"n","count":1,"extra":true}}"#, + containing: ["extra"] + ) + try expectDataCorrupted( + StrictMixedNestedOptionalRecord.self, + json: #"{"payload":{"name":"n","count":1,"extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func strictScalarClassHierarchiesRemainScalar() throws { + let data = Data(#""scalar""#.utf8) + let decoded = try JSONDecoder().decode( + StrictScalarChild.self, from: data + ) + #expect(decoded.value == "scalar") + } + + @Test + func strictOptionalScalarClassHierarchiesAllowNull() throws { + let decoded = try JSONDecoder().decode( + StrictOptionalScalarChild.self, + from: Data("null".utf8) + ) + #expect(decoded.value == nil) + } + + @Test + func strictInheritedOptionalDirectPayloadPreservesMetadata() throws { + let data = Data( + #"{"name":"n","count":1,"child":"c"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictOptionalDirectChild.self, + from: data + ) + #expect(decoded.payload?.name == "n") + #expect(decoded.payload?.count == 1) + #expect(decoded.child == "c") + } + + @Test + func + strictInheritedOptionalDirectPayloadFromPermissiveBasePreservesMetadata() + throws + { + let data = Data( + #"{"name":"n","count":1,"child":"c"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictOptionalDirectChildOfPermissive.self, + from: data + ) + #expect(decoded.payload?.name == "n") + #expect(decoded.payload?.count == 1) + #expect(decoded.child == "c") + } + + @Test + func strictInheritedMixedDirectPayloadPreservesMetadata() throws { + let decoded = try JSONDecoder().decode( + StrictMixedDirectChild.self, + from: Data( + #"{"base":"b","payload":{"name":"n","count":1},"child":"c"}"# + .utf8 + ) + ) + #expect(decoded.payload.name == "n") + #expect(decoded.count == 1) + #expect(decoded.base == "b") + #expect(decoded.child == "c") + } + + @Test + func strictOptionalDictionaryPayloadPreservesKeyDecodingStrategy() throws { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decoded = try decoder.decode( + StrictOptionalDictionaryRecord.self, + from: Data(#"{"some_key":1}"#.utf8) + ) + #expect(decoded.payload?["some_key"] == 1) + } + + @Test + func strictRecordDecodesKnownKeys() throws { + let data = Data( + """ + { + "name": "value", + "count": 42 + } + """.utf8 + ) + let decoded = try JSONDecoder().decode(StrictRecord.self, from: data) + #expect(decoded == StrictRecord(name: "value", count: 42)) + } + + @Test + func conformDecodableRejectsUnknownKeys() throws { + try expectDataCorrupted( + StrictDecodableRecord.self, + json: """ + { + "value": "decoded", + "extra": true + } + """, + containing: ["extra"] + ) + } + + @Test + func strictNestedInheritedHelperUsesItsOwnKeyScope() throws { + let validData = Data( + #"{"payload":{"nestedOnly":"nested"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedScopeChild.self, + from: validData + ) + #expect(decoded.payload.nestedOnly == "nested") + + try expectDataCorrupted( + StrictNestedScopeChild.self, + json: #"{"payload":{"nestedOnly":"nested"},"nestedOnly":"root"}"#, + containing: ["nestedOnly"] + ) + } + + @Test + func strictManualSuperclassSuperDecoderKeysRemainSupported() throws { + let data = Data( + #"{"super":{"value":"super"},"child":"child"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictManualSuperChild.self, + from: data + ) + #expect(decoded.superValue == "super") + #expect(decoded.child == "child") + } + + @Test + func permissiveDecodingRemainsUnchanged() throws { + let data = Data( + """ + { + "name": "value", + "extra": true + } + """.utf8 + ) + let decoded = try JSONDecoder().decode( + PermissiveRecord.self, from: data + ) + #expect(decoded.name == "value") + } + + @Test + func nestedContainersRejectUnknownKeysAtTheirOwnLevel() throws { + let validData = Data( + """ + { + "payload": { + "value": "decoded" + } + } + """.utf8 + ) + let valid = try JSONDecoder().decode( + StrictNestedRecord.self, from: validData + ) + #expect(valid.value == "decoded") + + try expectDataCorrupted( + StrictNestedRecord.self, + json: """ + { + "payload": { + "value": "decoded", + "extra": true + } + } + """, + containing: ["extra"] + ) + + try expectDataCorrupted( + StrictNestedRecord.self, + json: #"{"payload":{"value":"decoded","payload":true}}"#, + containing: ["payload"] + ) + } + + @Test + func renamedAndAliasKeysRemainSupported() throws { + let renamedData = Data(#"{"wire_value":"renamed"}"#.utf8) + let renamed = try JSONDecoder().decode( + StrictRenamedRecord.self, from: renamedData + ) + #expect(renamed.value == "renamed") + + let aliasData = Data(#"{"legacy_value":"aliased"}"#.utf8) + let alias = try JSONDecoder().decode( + StrictAliasRecord.self, from: aliasData + ) + #expect(alias.value == "aliased") + + try expectDataCorrupted( + StrictAliasRecord.self, + json: #"{"legacy_value":"aliased","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictModeDoesNotRequireKeyedContainersForDirectValues() throws { + let data = Data(#""direct_value""#.utf8) + let decoded = try JSONDecoder().decode( + StrictDirectRecord.self, from: data + ) + #expect(decoded.value == "direct_value") + } + + @Test + func decodedAtNestedKeysRejectUnknownKeys() throws { + let validData = Data( + #"{"payload":{"wire_value":"decoded"}}"#.utf8 + ) + let valid = try JSONDecoder().decode( + StrictDecodedAtRecord.self, from: validData + ) + #expect(valid.value == "decoded") + + try expectDataCorrupted( + StrictDecodedAtRecord.self, + json: #"{"payload":{"wire_value":"decoded","extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func strictValidationIsNotSwallowedByDefaultFallbacks() throws { + let missingData = Data(#"{}"#.utf8) + let missing = try JSONDecoder().decode( + StrictDefaultNestedRecord.self, from: missingData + ) + #expect(missing.value == "fallback") + + try expectDataCorrupted( + StrictDefaultNestedRecord.self, + json: #"{"payload":{"extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func ignoredKeysAreUnsupported() throws { + try expectDataCorrupted( + StrictIgnoredRecord.self, + json: #"{"value":"decoded","ignored":"input"}"#, + containing: ["ignored"] + ) + } + + @Test + func commonStrategiesDoNotDisableStrictValidation() throws { + let validData = Data(#"{"value":"42"}"#.utf8) + let valid = try JSONDecoder().decode( + StrictStrategyRecord.self, from: validData + ) + #expect(valid.value == 42) + + try expectDataCorrupted( + StrictStrategyRecord.self, + json: #"{"value":"42","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func nestedStrictTypesRejectUnknownKeysInsideCollections() throws { + try expectDataCorrupted( + StrictArrayRecord.self, + json: #"{"values":[{"name":"value","count":1,"extra":true}]}"#, + containing: ["extra"] + ) + } + + @Test + func emptyStrictRecordsRejectPresentKeys() throws { + let validData = Data(#"{}"#.utf8) + _ = try JSONDecoder().decode(StrictEmptyRecord.self, from: validData) + + try expectDataCorrupted( + StrictEmptyRecord.self, + json: #"{"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictClassesRejectUnknownKeys() throws { + try expectDataCorrupted( + StrictClass.self, + json: #"{"value":"decoded","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictClassInheritanceAcceptsTheCombinedKeySet() throws { + let data = Data( + #"{"base":"base","child":"child"}"#.utf8 + ) + let decoded = try JSONDecoder().decode(StrictChild.self, from: data) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + + try expectDataCorrupted( + StrictChild.self, + json: #"{"base":"base","child":"child","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictNestedClassInheritanceMergesContainerPaths() throws { + let data = Data( + #"{"payload":{"base":"base","child":"child"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + } + + @Test + func strictStructuralInheritanceDoesNotSkipDeeperPaths() throws { + try expectDataCorrupted( + StrictStructuralChild.self, + json: """ + { + "payload": { + "base": "base", + "details": { + "value": "value", + "extra": true + } + } + } + """, + containing: ["extra"] + ) + } + + @Test + func strictNestedClassValidationNormalizesEnclosingCodingPaths() throws { + try expectDataCorrupted( + StrictEnvelope.self, + json: """ + { + "item": { + "payload": { + "base": "base", + "child": "child", + "extra": true + } + } + } + """, + containing: ["extra"] + ) + } + + @Test + func strictThreeLevelClassInheritancePreservesDecoderMetadata() throws { + let data = Data( + #"{"payload":{"base":"base","child":"child","leaf":"leaf"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedGrandChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + #expect(decoded.leaf == "leaf") + } + + @Test + func strictChildOfPermissiveClassAcceptsSuperclassKeys() throws { + let data = Data(#"{"base":"base","child":"child"}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictPermissiveChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + } + + @Test + func strictEmptyChildValidatesInheritedKeyedContainers() throws { + let data = Data(#"{"base":"base"}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictEmptyChild.self, from: data + ) + #expect(decoded.base == "base") + + try expectDataCorrupted( + StrictEmptyChild.self, + json: #"{"base":"base","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictEmptyHierarchyChildRejectsObjectKeys() throws { + _ = try JSONDecoder().decode( + StrictEmptyHierarchyChild.self, + from: Data(#"{}"#.utf8) + ) + + try expectDataCorrupted( + StrictEmptyHierarchyChild.self, + json: #"{"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictHelperPayloadOwnsItsNestedKeys() throws { + let data = Data( + #"{"base":"base","payload":{"name":"name","extra":true}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictHelperChild.self, from: data + ) + #expect(decoded.payload.name == "name") + + try expectDataCorrupted( + StrictHelperChild.self, + json: #"{"base":"base","payload":{"name":"name"},"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictChildOfPermissiveClassValidatesInheritedNestedContainers() throws + { + try expectDataCorrupted( + StrictPermissiveNestedChild.self, + json: #"{"child":"c","payload":{"base":"b","extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func strictExternalEnumsRejectUnknownKeys() throws { + let data = try encodedData( + StrictExternalCommand.load(key: "key"), + addingRootKey: "extra" + ) + try expectDataCorrupted( + StrictExternalCommand.self, + data: data, + containing: ["extra"] + ) + } + + @Test + func strictInternalEnumsRejectUnknownKeys() throws { + let data = try encodedData( + StrictInternalCommand.load(key: "key"), + addingRootKey: "extra" + ) + try expectDataCorrupted( + StrictInternalCommand.self, + data: data, + containing: ["extra"] + ) + } + + @Test + func strictAdjacentEnumsRejectUnknownContentKeys() throws { + let encoded = try JSONEncoder().encode( + StrictAdjacentCommand.load(key: "key") + ) + var object = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + var content = try #require(object["content"] as? [String: Any]) + content["extra"] = true + object["content"] = content + let data = try JSONSerialization.data(withJSONObject: object) + + try expectDataCorrupted( + StrictAdjacentCommand.self, + data: data, + containing: ["extra"] + ) + + var rootObject = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + rootObject["value"] = 123 + let rootData = try JSONSerialization.data( + withJSONObject: rootObject + ) + try expectDataCorrupted( + StrictAdjacentCommand.self, + data: rootData, + containing: ["value"] + ) + } + + @Test + func strictUntaggedEnumsValidateEachAttemptedCase() throws { + try expectDataCorrupted( + StrictUntaggedCommand.self, + json: #"{"b":"ok","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictMixedUntaggedEnumsAllowScalarCases() throws { + let data = Data(#""hello""#.utf8) + let decoded = try JSONDecoder().decode( + StrictMixedUntaggedCommand.self, from: data + ) + #expect(decoded == .text("hello")) + } + + @Test + func strictMixedUntaggedEnumsPreserveUnknownKeyErrors() throws { + try expectDataCorrupted( + StrictMixedUntaggedCommand.self, + json: #"{"value":1,"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictUntaggedEnumsAllowDelegatedStrictObjects() throws { + let data = Data(#"{"name":"n","count":1}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictDelegatedUntaggedCommand.self, from: data + ) + if case .record(let value) = decoded { + #expect(value.name == "n") + #expect(value.count == 1) + } else { + Issue.record("Expected delegated record case") + } + } + + @Test + func strictUntaggedAttemptsRollbackFailedMetadata() throws { + let data = Data(#"{"name":"n","count":1}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictTransactionalUntaggedCommand.self, + from: data + ) + if case .record(let payload, let count) = decoded { + #expect(payload.name == "n") + #expect(count == 1) + } else { + Issue.record("Expected transactional record case") + } + } + + @Test + func strictOptionalDecimalPayloadUsesSingleValueDecoding() throws { + let decoded = try JSONDecoder().decode( + StrictOptionalDecimalCommand.self, + from: Data("123.45".utf8) + ) + if case .decimal(let value) = decoded { + #expect(value == Decimal(string: "123.45")) + } else { + Issue.record("Expected decimal case") + } + } + + @Test + func strictRawValueEnumsRemainScalar() throws { + let data = Data(#""ready""#.utf8) + let decoded = try JSONDecoder().decode( + StrictRawCommand.self, from: data + ) + #expect(decoded == .ready) + } + + @Test + func strictNestedRawValueEnumsValidateTagContainers() throws { + let validData = Data(#"{"meta":{"kind":"ready"}}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictNestedRawCommand.self, from: validData + ) + #expect(decoded == .ready) + + try expectDataCorrupted( + StrictNestedRawCommand.self, + json: #"{"meta":{"kind":"ready","extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func actorStrictMacroExpansionCompiles() throws { + _ = StrictActor.self + } + + private func expectDataCorrupted( + _ type: T.Type, + json: String, + containing keys: [String] + ) throws { + try expectDataCorrupted( + type, + data: Data(json.utf8), + containing: keys + ) + } + + private func expectDataCorrupted( + _ type: T.Type, + data: Data, + containing keys: [String] + ) throws { + do { + _ = try JSONDecoder().decode(type, from: data) + Issue.record("Expected decoding to reject unsupported keys") + } catch let DecodingError.dataCorrupted(context) { + for key in keys { + #expect(context.debugDescription.contains(key)) + } + } catch { + Issue.record("Expected dataCorrupted, got \(error)") + } + } + + private func encodedData( + _ value: T, + addingRootKey key: String + ) throws -> Data { + var object = try #require( + JSONSerialization.jsonObject( + with: JSONEncoder().encode(value) + ) as? [String: Any] + ) + object[key] = true + return try JSONSerialization.data(withJSONObject: object) + } +}