Swift Macro Guideline
Macros, we usually use in C/C++ codes for simplifying redundant code we should write by hand. Swift macros are similar, but significantly more powerful. Compared to C macros, Swift macros have some features helping us writing more complex and safer code.
- Swift macros run in the build phase. Actually, the real macro parser is an individual program running inside a sandbox and communicate with the Xcode.
- Swift macros are similar as normal functions rather than simple string replacements. That means they are type-safe guaranteed by the Swift compiler.
- Swift macros respectively have multiple different types in different circumstances.
- Macros add new code, but they never delete or modify existing code.
Building your fist macro
First, we create a simple macro to learn how to declare a macro and what file architectures it has.
Open Xcode 15 and click the menu File -> New -> Package
to create a Swift Macro
package.
Then we will get an example macro project generated by Xcode. The macro project has a build-in macro stringify
producing both a value and a string containing the source code that generated the value. For example,
1 | let x = 100, y = 200 |
Look up the Package.swift
, there are four targets about our macro project
1 | import PackageDescription |
To create a Swift macro, we need to dependency the swift-syntax
package. The swift-syntax
is a syntax tool provided by Apple for parsing Swift source code to AST or generating source code from structured syntax.
There is a new target type macro
used to claim the target is a Xcode plugin for expanding macros. The target MyMacrosMacros
isn’t used to declare new macros, it contains the implementation of macros declared in the target MyMacros
.
1 | // MyMacros.swift |
As you can see, we use public macro
to declare a macro stringify
that receives any value and returns a tuple. Declaring a macro is similar to declare a function. The @freestanding(expression)
is used to specify the macro type of the stringify
. There are two main categories of macros, freestanding
and attached
, we will learn them in subsequent chapters.
In above code, the macro stringify
was assign to the value #externalMacro(module: "MyMacrosMacros", type: "StringifyMacro")
. The #externalMacro
refers the macros implementation to the module MyMacrosMacros
and the specified type StringifyMacro
. Let’s see detail code in the MyMacrosMacro.swift
.
1 | public struct StringifyMacro: ExpressionMacro { |
The code declare a struct StringifyMacro
conforming to the ExpressionMacro
protocol means the StringifyMacro
is an expression macro. Every macro type has to implement an expansion
function that takes several params containing source code and code context and returns a Swift syntax or throws a parsing error.
As said above, Macros are individual applications running in a special sandbox and communicates with Xcode during complication. So we need create a struct conforming to the CompilerPlugin
protocol to be the entry of the macro plugin. It requires a property providingMacros
where we list all types of macros we want to use in other modules.
Note
Only macros declared in the property providingMacros
can be accessed by outer modules.
After writing macros, we need crate an unite test to ensure the macro behaves as we expect. The Swift provides us with some new asserts
to help testing macro expansions.
1 | #if canImport(MyMacrosMacros) |
We passed source code and expected expanded code to the assertMacroExpansion
function, the function will match all macros the testMacros
contains in the source code we passed in and compare it with the expandedSource
.
The testMacros
map's keys are string names of macros we use in source code.
Beside in unit tests, we can quickly expand macros in our source code to check generated code actually compiled by the Swift compiler.
ShortCut
Xcode 15 has a new shortcut cmd+shift+A
to open the Quick Actions
sheet view.
Kinds of macros
Swift has two kinds of macros:
Freestanding
macros appears on their own, without being attached to a declaration.Attached
macros modify the declaration that they’re attached to.
Call a freestanding macro, we write a number sign #
before its name. If calling an attached macro, we use an at sign @
.
Every kind of macros has multiple roles. We should select appropriate roles for our custom macros. Most roles indicate available code positions where we can use the macro.
Before learning various macros, there are some limitations to macros we should know. Not any code we can generate by macros.
import
declarations can never be produced by a macro.- A type with the
@main
attribute cannot be produced by a macro. - extension declarations can never be produced by a macro.
- operator and precedencegroup declarations can never be produced by a macro .
- macro declarations can never be produced by a macro.
- Top-level default literal type overrides, including IntegerLiteralType, FloatLiteralType, BooleanLiteralType, ExtendedGraphemeClusterLiteralType, UnicodeScalarLiteralType, and StringLiteralType, can never be produced by a macro.
Note
Details about why code above we can not generate with macros are there.
freestanding(expression)
Describes a macro that is explicitly expanded as an expression.
1 | public protocol ExpressionMacro: FreestandingMacro { |
Example stringify
1
2
3// Declaration
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacrosMacros", type: "StringifyMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13// Implement
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let argument = node.argumentList.first?.expression else {
fatalError("compiler bug: the macro does not have any arguments")
}
return "((argument), (literal: argument.description))"
}
}
freestanding(declaration)
Describes a macro that forms declarations.
1 | public protocol DeclarationMacro: FreestandingMacro { |
Example myError
Trigger a custom compiler error.
1
2
3// Declaration
@freestanding(declaration)
public macro myError(_ message: Any) = #externalMacro(module: "MyMacrosMacros", type: "ErrorMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// Implementation
struct ErrorMacro: DeclarationMacro {
static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let firstElement = node.argumentList.first,
let stringLiteral = firstElement.expression
.as(StringLiteralExprSyntax.self),
stringLiteral.segments.count == 1,
case let .stringSegment(messageString) = stringLiteral.segments.first
else {
throw MacroExpansionErrorMessage("#error macro requires a string literal")
}
context.diagnose(
Diagnostic(
node: Syntax(node),
message: MacroExpansionErrorMessage(messageString.content.description)
)
)
return []
}
}
freestanding(codeItem)
Describes a macro that forms code items in a function or closure body.
1 | public protocol CodeItemMacro: FreestandingMacro { |
Experimental feature
Until to Xcode 15.0, CodeItem macros are an experimental feature that is not enabled.
attached(peer)
These macros produce new declarations in the same scope as the declaration that the macro is attached to. For example, applying a peer macro to a method of a structure can define additional methods and properties on that structure.
1 | public protocol PeerMacro: AttachedMacro { |
Example getter
Generate getter methods for properties.
1
2
3// Declaration
@attached(peer, names: arbitrary)
public macro getter() = #externalMacro(module: "MyMacrosMacros", type: "GetterMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// Implementation
public struct GetterMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard
let variable = declaration.as(VariableDeclSyntax.self),
let binding = variable.bindings.first,
let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self),
let typeAnnotationType = binding.typeAnnotation?.type
else {
throw MacroExpansionErrorMessage("Must attached to variables with explicit type declarations")
}
let identifierName = identifierPattern.identifier.text
let newFunction = try FunctionDeclSyntax("public func get(raw: identifierName.prefix(1).capitalized + identifierName.dropFirst())() -> (typeAnnotationType)", bodyBuilder: {
ExprSyntax(stringLiteral: identifierName)
})
return [DeclSyntax(newFunction)]
}
}
attached(member)
These macros produce new declarations that are members of the type or extension that the macro is attached to. For example, applying a member macro to a structure declaration can define additional methods and properties on that structure.
1 | // Describes a macro that can add members to the declaration it's attached to. |
Example deinitLog
Add a deinit method to the attached class.
1
2
3// Declaration
@attached(member, names: arbitrary)
public macro deinitLog() = #externalMacro(module: "MyMacrosMacros", type: "DeinitLogMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// Implementation
public struct DeinitLogMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard declaration.as(ClassDeclSyntax.self) != nil else {
throw MacroExpansionErrorMessage("Can only declare classes.")
}
return [
#"deinit { print("(self) was deinited.") }"#
]
}
}
attached(memberAttribute)
These macros add attributes to members of the type or extension that the macro is attached to.
1 | /// Describes a macro that can add attributes to the members inside the |
Example getterMembers
Add a macro @getter for every property of the attached type.
1
2
3// Declaration
@attached(memberAttribute)
public macro getterMembers() = #externalMacro(module: "MyMacrosMacros", type: "GetterMembersMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// Implementation
public struct GetterMembersMacro: MemberAttributeMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingAttributesFor member: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AttributeSyntax] {
guard
let variable = member.as(VariableDeclSyntax.self),
let binding = variable.bindings.first,
binding.typeAnnotation?.type != nil
else {
return []
}
return ["@getter"]
}
}
attached(accessor)
These macros add accessors to the stored property they’re attached to, turning it into a computed property.
1 | /// Describes a macro that adds accessors to a given declaration. |
Example @storageBacked
Convert a stored property of optional types to a computed property that saves or gets values from an map _storage.
1
2
3
4// Declaration
@attached(accessor, names: arbitrary)
public macro storageBacked() = #externalMacro(module: "MyMacrosMacros", type: "StorageBackedMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30// Implementation
public struct StorageBackedMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
guard
let variable = declaration.as(VariableDeclSyntax.self),
let binding = variable.bindings.first,
let identifierPattern = binding.pattern.as(IdentifierPatternSyntax.self),
let typeAnnotationType = binding.typeAnnotation?.type.as(OptionalTypeSyntax.self),
let typeIdentifier = typeAnnotationType.wrappedType.as(IdentifierTypeSyntax.self)?.name
else {
throw MacroExpansionErrorMessage("Must attached to variables with explicit optional type declarations")
}
let identifierName = identifierPattern.identifier.text
return [
#"""
set {
_storage["#(raw: identifierName)"] = newValue
}
get {
_storage["#(raw: identifierName)"] as? #(typeIdentifier)
}
"""#
]
}
}
attached(extension)
These macros add protocol conformance to the type they’re attached to.
1 | /// Describes a macro that can add extensions to the declaration it's |
Example @errorType
Extend the type the macro attached to conform to the Error protocol.
1
2
3// Declaration
@attached(extension, conformances: Error)
public macro errorType() = #externalMacro(module: "MyMacrosMacros", type: "ErrorTypeMacro")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// Implementation
public struct ErrorTypeMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
let errorExtension: DeclSyntax =
"""
extension (type.trimmed): Error {}
"""
guard let extensionDecl = errorExtension.as(ExtensionDeclSyntax.self) else {
return []
}
return [extensionDecl]
}
}
The peer
, member
, and accessor
macro roles require a names argument, listing the names of the symbols that the macro generates. The freestanding macro role declaration
can also set up names for new generated symbols.
There are five kinds of names we can use.
named(<#name#>)
where name is that fixed symbol name, for a name that’s known in advance.overloaded
for a name that’s the same as an existing symbol.prefixed(<#prefix#>)
where prefix is prepended to the symbol name, for a name that starts with a fixed string.suffixed(<#suffix#>)
where suffix is appended to the symbol name, for a name that ends with a fixed string.arbitrary
for a name that can’t be determined until macro expansion.
Tip
As a special case, you can write prefixed($) for a macro that behaves similar to a property wrapper.
Restrictions on arbitrary names
Macros with arbitrary names cannot be used at top-level scope. Detail
Nested macros
If we make a macro expression as a parameter of another macro, nested macro-expansion expressions expand from the outside in. In code the outer macro expands first and the unexpanded inner macro appears in the abstract syntax tree that outer macro receives as its input.
For example, we define a macro stringConnect
that connects multiple strings to one with the separator “+”.
1 | @freestanding(expression) |
See code above, the stringConnect
macro receives a variable parameter values
and returns a string as the result. The implementation of the macro is also simple, we just connect every string expression with the char +
.
Let’s write a simple nested macro expression.
1 | let str = #stringConnect("hello", "world", #stringConnect("hello", "macro")) |
If we try to run the code above, the compiler will throw an error: Recursive expansion of macro 'stringConnect'
You can see that the outer macro was expanded firstly, and the inner macro kept the original expression. Actually, the code above won’t trigger any recursive expansion. That maybe some limitations that Apple doesn’t resolve.
Warning
We cannot use two same macros in a nested macro expression, that would produce recursive expansions.
For running the expression successfully, we can define a new macro anotherStringConnect
having the same implementation from the StringConnect
1 | @freestanding(expression) |
Then we rewrite the code above to:
1 | let str = #stringConnect("hello", "world", #anotherStringConnect("hello", "macro")) |
As shown above, the nested macro expression expanded twice.
Attached macros with multiple roles
Since a role of macros can only expand a part of code in some special positions, Swift allows us declaring one macro with multiple roles for expanding its functions and available areas.
There is an important rule that every role can only receive original code as its input. The sort of multiple roles doesn’t influence any paring result. For example, a member macro adds some properties to a struct, another memberAccessor macro can only get original properties the struct defined, without properties the member macro extended.
Import Swift macros without SPM
After having learned how to develop Swift macros, the next question is how we can import them to our projects. If your project is based on Swift package manager, you are lucky that the Apple official recommends to integrate Macros with SPM. But majority of iOS projects are managed through CocoaPods. We need to dive deeper to seek a way for integrating Macros to our CocoaPods projects.
There are two ways that we can import Macros to our exist projects without using SPM.
The first way is to directly import the macro binary to our main project. This way is adaptable to any projects whatever they are managed by.
The second way is making your macro an individual pods framework. You can reintegrate it to other pods projects. If your project is multi-component architecture, that would be the best way.
Integrate Macro to Main Project
As you can see, the Macro is an executable file that runs during code compilation. We need to copy the executable file to our project for executing macro parsing.
Run swift build -c release
in your macro project directory, the command would build the project and output an .build/release
folder. You can find a executable file named MyMacrosMacros
. The name is same as the .macro
target in your Package.swift file.
Copying the file to your project root directory. Then searching the Other Swift Flags
settings under the targets’ Build Settings
. Add two string, -load-plugin-executable
and MyMacrosMacros#MyMacrosMacros
.
Creating a file for declaring these macros, you are now ready to use those macros in you project.
1 | @attached(extension, conformances: Error) |
Integrate Macro as an Individual Pod
Creating a Swift Macro Library
Using command swift package init --name JYMacros --type macro
to quickly create a Macro example.
I have written an example for integrating a SwiftMacro Pod to projects.
Creating Podspec and Makefile
First, we need crate a JYMacros.podspec
file as the pods’ configuration file.
We make a directory named macros
to save our released binary executable file.
For easily building, a Makefile
can be the best choice.
Then, our project directory looks like this:
1 | bin = "JYMacrosMacros" |
The Makefile
defines two commands build
and clean
. We can directly invoke make build
to build the macro with release mode and copy its building products to the macros
directory created before.
Invoking make clean
is to clean the old .build
folder.
If you want to rebuild the macro every times before the integrating project building, you can write some shell in the project’s Podfile
to execute the make build
command.
1 | Pod::Spec.new do |s| |
source
: Using SPM to create a Swift Macro library, macro declarations is in theSources/JYMacros
folder.preserve_paths
: The executable file is the key to assist XCode to expand our macros. We need to carry it as part of our pod.pod_start_xcconfig
&user_target_xcconfig
: Loading our macro executable file as xcode plugins.
Integrating to Projects
Just as normal cocoaPods libraries, add pod 'JYMacros'
to you project’s Podfile file.
1 | # Uncomment the next line to define a global platform for your project |
import JYMacros
and then you can use the #stringify
macro in your code.
Recompiling Macros while building project
In our above cases, we need to compile the macro binary manually before integrating it to our project. If we want to recompile the macro every time we build the project, we can add a shell script in the Podspec
file.
Wanting to compile the macro, we need to preserve the macro source code in the preserve_paths
. The old binary folder macros
isn’t required and will be created at every project building.
1 | s.preserve_paths = [ |
As you can see, the s.script_phase
is a shell script that will be executed before the project compiling. The script will change the directory to the macro project and execute the make
command to build the macro binary.
For easy to develop the macro locally, we can add a condition to judge the current path. If the path starts with /Users
, meaning the macro is integrated in development mode, the script will use the local path to build the macro. Otherwise, it will use the PODS_ROOT
path.
In addition to config the s.script_phase
, we can use the s.prepare_command
to execute the make
command before the pod building.
The s.prepare_command
will be executed before every pod install
or pod update
, before copying source files to the Pods directory.
1 | s.prepare_command = <<~SCRIPT |
References
Online Tool: Swift AST explorer
SE-0389: attached macros
A possible vision for macros in Swift
Macro-Expansion Expression
SE-0389: restrictions on arbitrary names
Using init-accessors
in Swift macros
How to import Swift macros without using Swift Package Manager
Distributing a Swift Macro to CocoaPods
Support Swift Macros with CocoaPods