[Pitch] Attached macros

I have played a little with the attached macros implementation. My goal was to see if it is feasible to implement Codable as a macro. I am aware that witness macros are a better fit for this job, but those are not implemented yet, so I used an attached macro. Here is the result:

@CustomCodable
struct Party: Codable {
  enum Theme: Codable {
    case halloween
    case birthday
    case piratesAndMonstersAndUnicornsAndEverythingElse
  }
  
  @CustomKey("host_name")
  var hostName: String
  var theme: Theme
  var numberOfGuests: Int = 0 {
    didSet {
      print("Someone came! Or went, I don't know...")
    }
  }
  
  var description: String {
    "\(hostName) has invited \(numberOfGuests) \(theme == .halloween ? "ghosts" : "guests")!"
  }
}

let party = Party(
  hostName: "Jim",
  theme: .halloween,
  numberOfGuests: 2
)
let encoder = JSONEncoder()
let partyData = try encoder.encode(party)
print(String(data: partyData, encoding: .utf8)!)
// Prints {"theme":{"halloween":{}},"host_name":"Jim","numberOfGuests":2}

let decoder = JSONDecoder()
let partyCopy = try decoder.decode(Party.self, from: partyData)
print(partyCopy.description) // Prints Jim has invited 2 ghosts!
Full implementation

In MacroExamplesLib:

@attached(member, names: named(encode), named(CodingKeys))
public macro CustomCodable() = #externalMacro(module: "MacroExamplesPlugin", type: "CustomCodableMacro")

/// This macro does nothing. It is only used as a hint for `@CustomCodable`.
@attached(accessor)
public macro CustomKey(_ key: String) = #externalMacro(module: "MacroExamplesPlugin", type: "CustomKeyDummyMacro")

public extension KeyedDecodingContainer {
  func _decodeInferredType<Value: Decodable>(forKey key: KeyedDecodingContainer<K>.Key) throws -> Value {
    try self.decode(Value.self, forKey: key)
  }
}

In MacroExamplesPlugin:

public enum CustomCodableMacro {}

extension CustomCodableMacro: MemberMacro {
  public static func expansion(
    of node: AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
  ) throws -> [DeclSyntax] {
    
    let storedProperties = declaration.members.members.compactMap {
      StoredProperty(potentialPropertyDeclaration: $0.decl, diagnosticHandler: context.diagnose)
    }
    
    let codingKeyDefinitions = storedProperties.map { property in
      "\(property.name) = \(property.keyLiteral)"
    }
    
    let codingKeysSyntax: DeclSyntax = """
      enum CodingKeys: String, CodingKey {
        case \(raw: codingKeyDefinitions.joined(separator: ", "))
      }
      """
    
    let encodingCode = storedProperties
      .map { property in
        "  try container.encode(\(property.name), forKey: .\(property.name))"
      }
      .joined(separator: "\n")
    
    let encodeFunctionSyntax: DeclSyntax = """
      func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
      \(raw: encodingCode)
      }
      """
    
    let decodingCode = storedProperties
      .map { property in
        "  self.\(property.name) = try container._decodeInferredType(forKey: .\(property.name))"
      }
      .joined(separator: "\n")
    
    let decodingInitializerSyntax: DeclSyntax = """
      init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
      \(raw: decodingCode)
      }
      """
    
    let propertiesWithTypeAnnotations = storedProperties.filter { $0.typeName != nil }
    
    let parameterList = propertiesWithTypeAnnotations
      .map { property in
        "\(property.name): \(property.typeName!)\(property.initializer.map { " " + $0 } ?? "")"
      }
      .joined(separator: ", ")
    
    let propertyAssignmentList = propertiesWithTypeAnnotations
      .map { property in
        "self.\(property.name) = \(property.name)"
      }
      .joined(separator: "\n  ")
    
    let memberwiseInitializer: DeclSyntax = """
      init(
        \(raw: parameterList)
      ) {
        \(raw: propertyAssignmentList)
      }
      """
    
    return [codingKeysSyntax, encodeFunctionSyntax, decodingInitializerSyntax, memberwiseInitializer]
  }
}

// MARK: - StoredProperty

fileprivate struct StoredProperty {
  var name: String
  var typeName: String?
  var keyLiteral: String
  var initializer: String?
}

extension StoredProperty {
  init?(potentialPropertyDeclaration: DeclSyntax, diagnosticHandler: (Diagnostic) -> Void) {
    guard let property = potentialPropertyDeclaration.as(VariableDeclSyntax.self) else {
      return nil
    }
    
    self.init(property, diagnosticHandler: diagnosticHandler)
  }
  
  init?(_ property: VariableDeclSyntax, diagnosticHandler: (Diagnostic) -> Void) {
    guard
      property.isStoredProperty,
      let binding = property.bindings.first,
      let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier
    else {
      return nil
    }
    
    let attributes = property.attributes ?? []
    let key = attributes
      .compactMap { syntax -> String? in
        guard
          let attributeSyntax = syntax.as(AttributeSyntax.self),
          attributeSyntax.attributeName.trimmedDescription == "CustomKey",
          let argument = attributeSyntax.argument
        else {
          return nil
        }
        
        return argument.trimmedDescription
      }
      .first
    
    self.name = identifier.text
    self.typeName = binding.typeAnnotation?.type.trimmedDescription
    self.keyLiteral = key ?? "\"\(identifier.text)\""
    self.initializer = binding.initializer?.trimmedDescription
    
    if typeName == nil {
      let message = SimpleDiagnosticMessage(
        message: "@CustomCodable requires that all properties have an explicit type annotation",
        diagnosticID: .init(domain: "test", id: "error"),
        severity: .warning
      )
      diagnosticHandler(.init(node: binding.initializer?.as(Syntax.self) ?? property.as(Syntax.self)!, message: message))
    }
  }
}

// MARK: - CustomKeyDummyMacro

/// This macro does nothing. It is only used as a hint for `CustomCodableMacro`.
public enum CustomKeyDummyMacro {}

extension CustomKeyDummyMacro: AccessorMacro {
  public static func expansion(
    of node: AttributeSyntax,
    providingAccessorsOf declaration: some DeclSyntaxProtocol,
    in context: some MacroExpansionContext
  ) throws -> [AccessorDeclSyntax] {
    return []
  }
}

The macro only replaces CodingKeys, encode(to:) and init(from:) and uses the other parts of Codable as is. However, I think this is enough to see if macros are feasible for the job. As a test case, I have added the ability to use a custom key using @CustomKey (see hostName in the example), which I believe is not possible with the default Codable implementation.

I have learned several things. First, the positive:

  • It works!

  • Implementing CodingKeys and encode(to:) was straightforward (apart from finding the types and properties I needed in SwiftSyntax).

  • When using the current snapshot, if there is a compilation error or crash, Xcode directly points to the expanded code in a buffer. This is really great for debugging!

Now the points that could be improved:

  • When generating init(from:), the macro can't just use KeyedDecodingContainer.decode(_:forKey:) because it does not know the type of properties that have a default value and use type inference. I have circumvented the issue by defining a helper method and (mis)using type inference:

    public extension KeyedDecodingContainer {
      func _decodeInferredType<Value: Decodable>(forKey key: KeyedDecodingContainer<K>.Key) throws -> Value {
        try self.decode(Value.self, forKey: key)
      }
    }
    

    This seems like a brittle solution.

  • Because the macro has to generate init(from:), the default memberwise initializer is no longer automatically generated. I think this is a problem. My macro could have used a peer macro for this if they had already been implemented. However, the canonical macro type for Codable would be witness macros, which are from my understanding not able to generate a peer. We definitely need a solution for this problem!

    I ended up needing to generate the memberwise initializer myself. Here, again, the problem were properties that have a default value and use type inference because the macro didn't know the types of these. I decided to only add initializer parameters for the properties with explicit types and generate warnings for those properties without. Generating the warnings was easy.

Implementing this macro reinforced my impressions that the proposed macro system is very powerful but what it misses most is type information. For this macro, it would have sufficed to provide a list of stored properties to the macro as proposed for witness macros. However, other macros may need other types of type information.

4 Likes