How to add a protocol with a macro if it is not added yet?

Hi.

I just wrote my first Member Macro that adds a couple of properties to the struct/class that require a certain protocol to be applied. Now, I would like to automatically add this protocol to that class as well if it is not yet added. Any idea how?

(I am using Swift 6.0)

Thank you!

I have found the solution I was looking for by carefully reading through this evolution: swift-evolution/proposals/0407-member-macro-conformances.md at main · swiftlang/swift-evolution · GitHub

EDIT: Here is how I did it.

First, here is the task: I would like to add created_at and updated_at Fluent timestamp fields to models in my Vapor app. I will call this macro TrackedModel and I would like to add Model conformance if the class does not have it yet. I would like to do it with a custom macro.

Let's say I have two targets called App and Macros (two separate folders under the Sources folder)

Here is how I defined them in Package.swift:

// swift-tools-version:6.0
import PackageDescription
import CompilerPluginSupport

(later in the file)

.macro(
    name: "Macros",
    dependencies: [
        .product(name: "SwiftSyntax", package: "swift-syntax"),
        .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
        .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
    ]
 ),
 .executableTarget(
        name: "App",
        dependencies: [
            .product(name: "Fluent", package: "fluent"),
            .product(name: "Vapor", package: "vapor"),
            "Macros",
        ]
),

Then I created a Macros target with two files:
TrackedModelMacro.swift:

import SwiftSyntax
import SwiftSyntaxMacros

public struct TrackedModelMacro: MemberMacro, ExtensionMacro {
    public static func expansion(
        of _: AttributeSyntax,
        providingMembersOf _: some DeclGroupSyntax,
        conformingTo _: [TypeSyntax],
        in _: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        [
            "@Timestamp(key: \"created_at\", on: .create)",
            "var createdAt: Date?",
            "@Timestamp(key: \"updated_at\", on: .update)",
            "var updatedAt: Date?",
        ]
    }

    public static func expansion(
        of _: AttributeSyntax,
        attachedTo _: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in _: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        let containsProtocol = protocols.contains { p in
            if let protocolDecl = p.as(IdentifierTypeSyntax.self),
               protocolDecl.name.text == "Model"
            {
                return true
            }

            return false
        }

        guard containsProtocol else { return [] }

        let modelExtension = try ExtensionDeclSyntax("extension \(type.trimmed): Model {}")

        return [modelExtension]
    }
}

and macros.swift:

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct Macros: CompilerPlugin {
    var providingMacros: [Macro.Type] = [
        TrackedModelMacro.self,
    ]
}

And finally, I need to properly set up this macro in my App target. I created a separate file for this, called TrackedModel.swift:

import Fluent

@attached(member, names: named(createdAt), named(updatedAt))
@attached(extension, conformances: Model)
macro TrackedModel() = #externalMacro(module: "Macros", type: "TrackedModelMacro")

And now one can use it with or without declaring Model protocol. Here is an example usage inside the App target:

import Foundation
import Fluent

@TrackedModel
final class A: @unchecked Sendable {
    static let schema = "a"

    @ID
    var id: UUID?
}

@TrackedModel
final class B: Model, @unchecked Sendable {
    static let schema = "b"

    @ID
    var id: UUID?
}

In addition, I might later add a Diagnostic message that notifies me that an explicit model conformance is not needed.

1 Like

You can use @attached(member, conformances: YourProtocol) in your macro definition to automatically add the protocol if it’s not already there. Just make sure the macro expands correctly to enforce conformance.

I might be mistaken, but if I write TrackedModel.swift like this:

import Fluent

@attached(member, conformances: Model, names: named(createdAt), named(updatedAt))
//@attached(extension, conformances: Model, names: named(createdAt), named(updatedAt))
macro TrackedModel() = #externalMacro(module: "Macros", type: "TrackedModelMacro")

I am getting compiler errors. Am I misunderstanding you here?

I made a small logic error when checking for protocols array, as I misunderstood what goes into it. I fixed it now.

The protocols array will show MISSING protocols from the conformances declarations.

Next, be careful with manual testing the macros because it will not reapply a newer version of macros when the macro source is updated. It will only rerun the macro when the source that uses said macro changes. Therefore, one can make a runtime error in the macro, but it will not be shown unless the macro call site is changed too.

Finally, when the macro is declared in the App, declarations (@attached) do not have to repeat all the arguments. I did not need them all repeated in my case. I will soon edit the original answer to indicate a briefer option.