diff --git a/Sources/MetaCodable/Default.swift b/Sources/MetaCodable/Default.swift index 6c75e9ef..01c0c617 100644 --- a/Sources/MetaCodable/Default.swift +++ b/Sources/MetaCodable/Default.swift @@ -27,3 +27,33 @@ @available(swift 5.9) public macro Default(_ default: T) = #externalMacro(module: "MacroPlugin", type: "Default") + +/// Provides a `default` value to be used when decoding fails and +/// when not initialized explicitly in memberwise initializer(s). +/// +/// If the value is missing or has incorrect data type, the default value +/// will be used instead of throwing error and terminating decoding. +/// i.e. for a field declared as: +/// ```swift +/// @Default("some", 10) +/// let field: String, field2: Int +/// ``` +/// if empty json provided or type at `CodingKey` is different +/// ```json +/// { "field": 5 } // or {} +/// ``` +/// the default value provided in this case `some` will be used as +/// `field`'s value, `10` will be used as `field2`'s value. +/// +/// - Parameter defaults: The default values to use. +/// +/// - Note: This macro on its own only validates if attached declaration +/// is a variable declaration. ``Codable()`` macro uses this macro +/// when generating final implementations. +/// +/// - Important: The field type must confirm to `Codable` and +/// default value type `T` must be the same as field type. +@attached(peer) +@available(swift 5.9) +public macro Default(_ defaults: repeat each T) = + #externalMacro(module: "MacroPlugin", type: "Default") diff --git a/Sources/PluginCore/Attributes/Default.swift b/Sources/PluginCore/Attributes/Default.swift index fcdabde7..4e46e763 100644 --- a/Sources/PluginCore/Attributes/Default.swift +++ b/Sources/PluginCore/Attributes/Default.swift @@ -12,8 +12,14 @@ package struct Default: PropertyAttribute { /// The default value expression provided. var expr: ExprSyntax { - return node.arguments! - .as(LabeledExprListSyntax.self)!.first!.expression + exprs.first! + } + + /// The default value expressions provided. + var exprs: [ExprSyntax] { + node.arguments!.as(LabeledExprListSyntax.self)!.map { + $0.expression + } } /// Creates a new instance with the provided node. @@ -47,7 +53,7 @@ package struct Default: PropertyAttribute { /// - Returns: The built diagnoser instance. func diagnoser() -> DiagnosticProducer { return AggregatedDiagnosticProducer { - expect(syntaxes: VariableDeclSyntax.self) + DefaultAttributeDeclaration(self) attachedToNonStaticVariable() cantDuplicate() cantBeCombined(with: IgnoreCoding.self) @@ -77,6 +83,38 @@ where } } +extension Registration +where + Decl == PropertyDeclSyntax, Var: PropertyVariable, + Var.Initialization == RequiredInitialization +{ + /// Update registration with default value if exists. + /// + /// New registration is updated with default expression data that will be + /// used for decoding failure and memberwise initializer(s), if provided. + /// + /// - Returns: Newly built registration with default expression data. + func addDefaultValueIfExists() -> Registration> { + guard let attr = Default(from: self.decl) + else { return self.updating(with: self.variable.any) } + + var i: Int = 0 + for (index, binding) in self.decl.decl.bindings.enumerated() { + if binding.pattern == self.decl.binding.pattern { + i = index + break + } + } + + if i < attr.exprs.count { + let newVar = self.variable.with(default: attr.exprs[i]) + return self.updating(with: newVar.any) + } + + return self.updating(with: self.variable.any) + } +} + fileprivate extension PropertyVariable where Initialization == RequiredInitialization { /// Update variable data with the default value expression provided. @@ -90,3 +128,95 @@ where Initialization == RequiredInitialization { return .init(base: self, options: .init(expr: expr)) } } + +@_implementationOnly import SwiftDiagnostics +@_implementationOnly import SwiftSyntaxMacros +/// A diagnostic producer type that can validate the ``Default`` attribut's number of parameters. +/// +/// - Note: This producer also validates passed syntax is of variable +/// declaration type. No need to pass additional diagnostic producer +/// to validate this. +fileprivate struct DefaultAttributeDeclaration: DiagnosticProducer { + /// The attribute for which + /// validation performed. + /// + /// Uses this attribute name + /// in generated diagnostic + /// messages. + let attr: Attr + + /// Underlying producer that validates passed syntax is variable + /// declaration. + /// + /// This diagnostic producer is used first to check if passed declaration is + /// variable declaration. If validation failed, then further validation by + /// this type is terminated. + let base: InvalidDeclaration + + /// Creates a grouped variable declaration validation instance + /// with provided attribute. + /// + /// Underlying variable declaration validation instance is created + /// and used first. Post the success of base validation this type + /// performs validation. + /// + /// - Parameter attr: The attribute for which + /// validation performed. + /// - Returns: Newly created diagnostic producer. + init(_ attr: Attr) { + self.attr = attr + self.base = .init(attr, expect: [VariableDeclSyntax.self]) + } + + /// Validates and produces diagnostics for the passed syntax + /// in the macro expansion context provided. + /// + /// Check whether the number of parameters of the application's ``Default`` attribute corresponds to the number of declared variables. + /// + /// - Parameters: + /// - syntax: The syntax to validate and produce diagnostics for. + /// - context: The macro expansion context diagnostics produced in. + /// + /// - Returns: True if syntax fails validation, false otherwise. + @discardableResult + func produce( + for syntax: some SyntaxProtocol, + in context: some MacroExpansionContext + ) -> Bool { + guard !base.produce(for: syntax, in: context) else { return true } + let decl = syntax.as(VariableDeclSyntax.self)! + let bindingsCount = decl.bindings.count + + let attributeArgumentsCount = self.attr.node.arguments?.as(LabeledExprListSyntax.self)?.count ?? 0 + + guard bindingsCount != attributeArgumentsCount + else { return false } + + var msg: String + if bindingsCount - attributeArgumentsCount < 0 { + msg = "@\(attr.name) expect \(bindingsCount) default \(bindingsCount > 1 ? "values" : "value") but found \(attributeArgumentsCount) !" + } else if bindingsCount - attributeArgumentsCount == 1 { + msg = "@\(attr.name) missing default value for variable " + } else { + msg = "@\(attr.name) missing default values for variables " + } + + for (i, binding) in decl.bindings.enumerated() where binding.pattern.is(IdentifierPatternSyntax.self) { + if i >= attributeArgumentsCount { + msg += "'\(binding.pattern.trimmed.description)'" + if i < decl.bindings.count - 1 { + msg += ", " + } + } + } + + let message = attr.diagnostic( + message: + msg, + id: attr.misuseMessageID, + severity: .error + ) + attr.diagnose(message: message, in: context) + return true + } +} diff --git a/Tests/MetaCodableTests/Attributes/DefaultTests.swift b/Tests/MetaCodableTests/Attributes/DefaultTests.swift index ccd7b820..7305a645 100644 --- a/Tests/MetaCodableTests/Attributes/DefaultTests.swift +++ b/Tests/MetaCodableTests/Attributes/DefaultTests.swift @@ -101,5 +101,81 @@ final class DefaultTests: XCTestCase { ] ) } + + func testMissingDefaultValue() throws { + assertMacroExpansion( + """ + struct SomeCodable { + @Default() + let one: String + } + """, + expandedSource: + """ + struct SomeCodable { + let one: String + } + """, + diagnostics: [ + .init( + id: Default.misuseID, + message: + "@Default missing default value for variable 'one'", + line: 2, column: 5, + fixIts: [ + .init(message: "Remove @Default attribute") + ] + ), + ] + ) + } + + func testMissingDefaultValues() throws { + assertMacroExpansion( + """ + struct SomeCodable { + @Default("hello") + let one: String, two: Int + } + """, + expandedSource: + """ + struct SomeCodable { + let one: String, two: Int + } + """, + diagnostics: [ + .multiBinding(line: 2, column: 5) + ] + ) + } + + func testTooManyDefaultValueParameters() throws { + assertMacroExpansion( + """ + struct SomeCodable { + @Default("hello", 10) + let one: String + } + """, + expandedSource: + """ + struct SomeCodable { + let one: String + } + """, + diagnostics: [ + .init( + id: Default.misuseID, + message: + "@Default expect 1 default value but found 2 !", + line: 2, column: 5, + fixIts: [ + .init(message: "Remove @Default attribute") + ] + ), + ] + ) + } } #endif