How to develop Swift Macros

👍
· Jan 10, 2023 · 6 mins read
How to develop Swift Macros

This tutorial is a step-by-step guide to develop Swift Macros. We will develop an attached Macro called Singleton , which helps reduce the tedious code of writing a singleton struct or type.

Singleton in Swift

For a proper singleton type definition, we usually need to make the initialiser private to prevent unintended instances of this type being created. Also we need to have a shared static instance that can be used.

In code, a singleton type definition would be like this.

struct SingletonStruct {  
    private init() {}  
    static let shared = Self()  
  
    let name = "Tim Wang"  
}

Then, we can access the shared instance using SingletonStruct.shared. The two lines of code private init() {} and static let shared = Self() are common to all the singleton definitions. If we write these code manually for each singleton type definitions, not only does it look repetitive, it’s error prone. Because if we forget the private access modifier on init , would end up with the chances of creating new instances of this singleton type. We will see how Swift Macros can help for this case.

New Macro package in Xcode

In Xcode, select File/New/Package menu item and select Swift Macro like below

New package for Swift Macro

This will create a Swift Macro template package. In the Package.swift there are four targets defined:

macro named MyMacroMacros: this is the target to hold the Macro expansion logic. We don’t need to import this into the code as the Macro logic is only needed in the compiling process.

target named MyMacro: this is the place to expose the Macro definition and is the target we need to import into the code where it is required.

executableTarget named MyMacroClient : this is the sample code using the Macro.

testTarget named MyMacroTests: this is the unit test code for the Macro.

 targets: [  
        .macro(  
            name: "MyMacroMacros",  
            dependencies: [  
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),  
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax")  
            ]  
        ),  
        .target(name: "MyMacro", dependencies: ["MyMacroMacros"]),  
  
        .executableTarget(name: "MyMacroClient", dependencies: ["MyMacro"]),  
  
        .testTarget(  
            name: "MyMacroTests",  
            dependencies: [  
                "MyMacroMacros",  
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),  
            ]  
        ),  
    ]

Update them to the names that you like, or just leave them for now to quickly start exploring.

Implement the Singleton Macro

Write the unit test

Let’s adopt test driven approach for this task to write expectations in the unit test first.

func testSingletonMacro() {  
        assertMacroExpansion(  
            """  
            @singleton  
            struct A {}  
            """,  
            expandedSource: """  
              
            struct A {  
                private init() {  
                }  
                static let shared = Self()  
            }  
            """,  
            macros: testMacros  
        )  
    }  
  
    func testPublicSingletonMacro() {  
        assertMacroExpansion(  
            """  
            @singleton  
            public struct A {}  
            """,  
            expandedSource: """  
  
            public struct A {  
                private init() {  
                }  
                public static let shared = Self()  
            }  
            """,  
            macros: testMacros  
        )  
    }

We have two test methods here: one is for internal struct expansion and another for public struct. For the later case, we need to make sure the shared variable have same access control level.

Choose the macro type

The macro is going to add an initialiser method and a variable, so it must be an attached member macro. Hence the following

// The macro definition  
@attached(member, names: named(init), named(shared))  
public macro Singleton() = #externalMacro(module: "Macros", type: "Singleton")  
  
// The macro expansion logic  
public struct Singleton: MemberMacro {}

Implement the expansion method

public static func expansion<Declaration: DeclGroupSyntax,  
                                 Context: MacroExpansionContext>(of node: AttributeSyntax,  
                                                                 providingMembersOf declaration: Declaration,  
                                                                 in context: Context) throws -> [DeclSyntax] {  
        guard [SwiftSyntax.SyntaxKind.classDecl, .structDecl].contains(declaration.kind) else {  
            throw MacroDiagnostics.errorMacroUsage(message: "Can only be applied to a struct or class")  
        }  
        let identifier = (declaration as? StructDeclSyntax)?.identifier ?? (declaration as? ClassDeclSyntax)?.identifier ?? ""  
        var override = ""  
        if let inheritedTypes = (declaration as? ClassDeclSyntax)?.inheritanceClause?.inheritedTypeCollection,  
           inheritedTypes.contains(where: { inherited in inherited.typeName.trimmedDescription == "NSObject" }) {  
            override = "override "  
        }  
  
        let initializer = try InitializerDeclSyntax("private \(raw: override)init()") {}  
  
        let selfToken: TokenSyntax = "\(raw: identifier.text)()"  
        let initShared = FunctionCallExprSyntax(calledExpression: IdentifierExprSyntax(identifier: selfToken)) {}  
        let sharedInitializer = InitializerClauseSyntax(equal: .equalToken(trailingTrivia: .space),  
                                                        value: initShared)  
  
        let staticToken: TokenSyntax = "static"  
        let staticModifier = DeclModifierSyntax(name: staticToken)  
        var modifiers = ModifierListSyntax([staticModifier])  
  
        let isPublicACL = declaration.modifiers?.compactMap(\.name.tokenKind.keyword).contains(.public) ?? false  
        if isPublicACL {  
            let publicToken: TokenSyntax = "public"  
            let publicModifier = DeclModifierSyntax(name: publicToken)  
            modifiers = modifiers.inserting(publicModifier, at: 0)  
        }  
  
        let shared = VariableDeclSyntax(modifiers: modifiers,  
                                        .let, name: "shared",  
                                        initializer: sharedInitializer)  
  
        return [DeclSyntax(initializer),  
                DeclSyntax(shared)]  
    }

The code above is pretty straightforward. We have done three things:

  1. Create the initializer : this is done by the first line of code in the method.
  2. Create shared variable. For this, we need to firstly decide if we need public access level control. Then create the static shared variable.
  3. If the type with public access level, we add public to the shared instance as well.

Run unit test

Run unit test now, it will succeed like below

Test macro in client code

In test client main.swift file, we can verify the macro. Right click the @Signleton macro name and choose “Expand Macro” like below.

Select “Expand Macro” menu item

The expanded code will like this:

The expanded macro code

Final touch — Handle Exception

So far we have covered all the happy flow for this Singleton macro. It works fine if we apply it to a struct or a class type. But what if we apply it to an enum definition? Well, this macro still expands the code to add init method and shared variable. But of course it wouldn’t compile. Ideally we should handle this misusage using Exception or Diagnostic . But since this article is focused on getting familiar with the Macro development workflow, let’s ignore this for now.

Use the Singleton Macro in an Xcode project

After having verified correctness of the macro, it’s time to use it in a real project.

First add package dependency to the Xcode project like below:

Secondly, we import this package and use the macro like below:

If we try to compile the project now, we will get a prompt like this to ask the confirmation to use this macro.

After Trust & Enable the macro, the code will compile. Meanwhile, we can expand the macro to verify it like below:

Use the Singleton Macro in a Swift Package project

If the project is a swift package, we need to do the following:

  1. Add package dependency
.package(url: "https://github.com/ShenghaiWang/SwiftMacros.git", from: "1.0.0")
  1. Add the dependency into target that uses the macro
.executableTarget(name: "Client", dependencies: ["SwiftMacros"])
  1. In the source code that belong to that target, import the package and use it, which is the same as in Xcode projects.

In this article, we have gone through the process of developing a new Swift Macro by implementing this Singleton Macro(Please find the full source code here.). We have left some details on purpose so that we can focus on the workflow first. Hope it would help to get you start. The following articles will move on to each type of the Swift Macros and explain them in detail.

Comments

Sharing is caring!