Beginner's guide to Swift macros
Learn how to create and publish your very first macro using SPM and the brand new Macro APIs introduced in Swift 5.9.
Getting started
First of all, you’ll need to install Swift 5.9 to take advantage of the new macro system. You can download Xcode 15 (currently in beta) from Apple’s developer protal or you can get the latest snapshot version of the Swift toolchain from swift.org.
In order to create and use a macro you have to create a new Swift package, using the package manager. I’m going to do this without Xcode, I’ll place a Package.swift
file into a new macro-examples
folder.
To speed up the project creation process, just run the following command using the Terminal application. 🤓
mkdir -p macro-examples && cd $_
mkdir -p Sources
mkdir -p Sources/Examples
mkdir -p Sources/MyMacros
mkdir -p Sources/MyMacrosPlugin
mkdir -p Sources/MyMacrosPlugin/Macros
mkdir -p Tests
mkdir -p Tests/MyMacrosTests
touch Package.swift
touch Sources/Examples/main.swift
touch Sources/MyMacros/MyMacros.swift
touch Sources/MyMacrosPlugin/MyMacrosPlugin.swift
touch Sources/MyMacrosPlugin/Macros/InitMacro.swift
touch Tests/MyMacrosTests/MyMacrosTests.swift
Update the contents of the Package.swift
file. We’re going to add the brand new CompilerPluginSupport
framework, and the open source swift-syntax library as a dependency, this way we can setup a new macro target.
The Examples
target is literally just a sample target to try out the macros, the MyMacros
target will contain our macro definitions. The actual macro implementations will live in a separate macro target called MyMacroPlugins
. Of course we’re going to validate the macros, unit tests are going to be placed inside the MyMacrosTests
target. ✅
// swift-tools-version: 5.9
import CompilerPluginSupport
import PackageDescription
let package = Package(
name: "macro-examples",
platforms: [
.macOS(.v12),
],
products: [
.executable(
name: "Examples",
targets: ["Examples"]
),
.library(
name: "MyMacros",
targets: ["MyMacros"]
),
],
dependencies: [
.package(
url: "https://github.com/apple/swift-syntax",
branch: "main"
),
],
targets: [
.macro(
name: "MyMacrosPlugin",
dependencies: [
.product(
name: "SwiftSyntax",
package: "swift-syntax"
),
.product(
name: "SwiftSyntaxMacros",
package: "swift-syntax"
),
.product(
name: "SwiftOperators",
package: "swift-syntax"
),
.product(
name: "SwiftParser",
package: "swift-syntax"
),
.product(
name: "SwiftParserDiagnostics",
package: "swift-syntax"
),
.product(
name: "SwiftCompilerPlugin",
package: "swift-syntax"
),
]
),
.target(
name: "MyMacros",
dependencies: [
"MyMacrosPlugin"
]
),
.executableTarget(
name: "Examples",
dependencies: [
"MyMacros"
]
),
.testTarget(
name: "MyMacrosTests",
dependencies: [
"MyMacrosPlugin"
]
)
]
)
We’re going to create an simple @Init
macro, which can generate a public initializer for various objects based on the member properties. Feel free to place this code into the main.swift
file under the Examples
target.
import MyMacros
import Foundation
@Init
public struct Something: Codable {
let foo: String
let bar: Int
let hello: Bool?
}
There is a protocol for this purpose called MemberMacro
, which we have to implement in order to be able to access and extend the Swift syntax tree. Place the following contents into the InitMacro.swift
file.
import SwiftSyntax
import SwiftSyntaxMacros
public struct InitMacro: MemberMacro {
public static func expansion<D, C>(
of node: AttributeSyntax,
providingMembersOf decl: D,
in context: C
) throws -> [SwiftSyntax.DeclSyntax]
where D: DeclGroupSyntax, C: MacroExpansionContext {
let members = decl.memberBlock.members
var props: [(name: String, type: String)] = []
for member in members {
guard
let v = member.decl.as(VariableDeclSyntax.self),
let b = v.bindings.first,
let i = b.pattern.as(IdentifierPatternSyntax.self),
let t = b.typeAnnotation?.type
else {
continue
}
let n = i.identifier.text
let tv = t.description
props.append((name: n, type: tv))
}
let parameters = props
.map { "\($0.name): \($0.type)"}
.joined(separator: ",\n")
let assignments = props
.map { "self.\($0.name) = \($0.name)"}
.joined(separator: "\n")
return [
"""
public init(
\(raw: parameters)
) {
\(raw: assignments)
}
"""
]
}
}
As you can see we ask the declaration for the member properties and iterate through each member. If a member has a VariableDeclSyntax
it means it is a variable. We try to fetch the identifier using the IdentifierPatternSyntax
and the type through the typeAnnotation property of the bindings
. Don’t worry if you are not familiar with the swift-syntax library, you can easily print out (e.g. po decl
) the object graph including the type names if you put a breakpoint into the macro function implementation and run the unit tests using Xcode. po
actually works in Xcode 15. 😍
All right, we have the macro, but we still have to list it inside the plugin target using a special CompilerPlugin
protocol.
#if canImport(SwiftCompilerPlugin)
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct MyMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
InitMacro.self,
]
}
#endif
Now the macro plugin target is ready, we just have to define the macro implementation inside the MyMacros
target using #externalMacro
, and reference the target module & macro type. Our macro will be an @attached(member)
macro which is going to implement the init
method.
import Foundation
@attached(member, names: named(init))
public macro Init() = #externalMacro(
module: "MyMacrosPlugin",
type: "InitMacro"
)
You can learn more about the available macro atttributes from the WWDC23 session videos or you can read the vision for Swift macros document and correspoinding proposals on the Swift Evolution Dashboard.
The only thing remains is the unit test for the Swift macro. It’s relatively easy to write tests for macro declarations, we can simply compare the source with the generated code block.
import XCTest
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import MyMacrosPlugin
final class MyMacrosTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"Init": InitMacro.self,
]
func testApiObjects() throws {
let sf: SourceFileSyntax = """
@Init
public struct Something: Codable {
let foo: String
let bar: Int
let hello: Bool?
}
"""
let expectation = """
public struct Something: Codable {
let foo: String
let bar: Int
let hello: Bool?
public init(
foo: String,
bar: Int,
hello: Bool?
) {
self.foo = foo
self.bar = bar
self.hello = hello
}
}
"""
let context = BasicMacroExpansionContext(
sourceFiles: [
sf: .init(
moduleName: "TestModule",
fullFilePath: "test.swift"
)
]
)
let transformed = sf.expand(macros: testMacros, in: context)
XCTAssertEqual(transformed.formatted().description, expectation)
}
}
The output can be formatted
based on your configuration, but since this is a beginner’s guide tutorial we’re not going into the details right now. Macros are a powerful new feature in Swift 5.9. We’re going to play & experiment with them a lot more, that’s for sure. I hope you get the basic idea how to setup a macro project based on this quick tutorial. 🙏