DEV Community

Francesco Leoni
Francesco Leoni

Posted on • Edited on

How to Create Swift Macros with Xcode 15

Here you can find this articles and many more about iOS and macOS Development.

What are Macros?

Swift Macros allow you to generate repetitive code at compile time, making your app's codebase more easier to read and less tedious to write.

There are two types of macros:

  • Freestanding macros stand in place of something else in your code. They always start with a hashtag (#) sign.
#caseDetection // Freestanding Macro
Enter fullscreen mode Exit fullscreen mode
  • Attached macros are used as attributes on declarations in your code. They start with an @ sign.
@CaseDetection // Attached Macro
Enter fullscreen mode Exit fullscreen mode

Create new Macro

Macros need to be created in a special Package that depends on swift-syntax library.

SwiftSyntax is a set of Swift libraries for parsing, inspecting, generating, and transforming Swift source code. Here is the GitHub repo.

To create a new Macro go to New -> Package and select Swift Macro.
Type the name of your Macro and create the Package.

Type only the actual name of the macro, without Macro suffix. Eg. for a Macro named AddAsync, type AddAsync not AddAsyncMacro.

Macro Package structure

Inside the newly created Package you will find some auto-generated files:

  • [Macro name].swift where you declare the signature of your Macro
  • main.swift where you can test the behaviour of the Macro
  • [Macro name]Macro.swift where you write the actual implementation of the Macro
  • [Macro name]Tests.swift where you write the tests of the Macro implementation

Macro roles

A single Macro can have multiple roles that will define its behaviour.
The available roles are:

@freestanding(expression)

Creates a piece of code that returns a value.

Protocol

ExpressionMacro

Declaration

@freestanding(expression)
Enter fullscreen mode Exit fullscreen mode

@freestanding(declaration)

Creates one or more declarations. Like struct, function, variable or type.

Protocol

DeclarationMacro

Declaration

@freestanding (declaration, names: arbitrary)
Enter fullscreen mode Exit fullscreen mode

@attached(peer)

Adds new declarations alongside the declaration it's applied to.

Protocol

PeerMacro

Declaration

@attached(peer, names: overloaded)
Enter fullscreen mode Exit fullscreen mode

@attached(accessor)

Adds accessors to a property. Eg. adds get and set to a var. For example the @State in SwiftUI.

Protocol

AccessorMacro

Declaration

@attached(accessor)
Enter fullscreen mode Exit fullscreen mode

@attached (memberAttribute)

Adds attributes to the declarations in the type/extension it's applied to.

Protocol

MamberAttributeMacro

Declaration

@attached(memberAttribute)
Enter fullscreen mode Exit fullscreen mode

@attached (member)

Adds new declarations inside the type/extension it's applied to. Eg. adds a custom init() inside a struct.

Protocol

MemberMacro

Declaration

@attached(member, names: named(init()))
Enter fullscreen mode Exit fullscreen mode

@attached(conformance)

Adds conformances to protocols.

Protocol

ConformanceMacro

Declaration

@attached(conformance)
Enter fullscreen mode Exit fullscreen mode

Build Macro

Signature

In this guide we will create a Macro that creates an async function off of a completion one.
To start building this Macro we need to create the Macro signature.
To do this, go to [Macro name].swift file and add.

@attached(peer, names: overloaded)
public macro AddAsync() = #externalMacro(module: "AddAsyncMacros", type: "AddAsyncMacro")
Enter fullscreen mode Exit fullscreen mode

Here you declare the name of the Macro (AddAsync), then in the #externalMacro you specify the module it is in and the type of the Macro.

Implementation

Then to implement the actual Macro, go to the [Macro name]Macro.swift file.
Create a public struct named accordingly with the name of the Macro and add conformance to protocols based on the signature you specified in the Macro signature.
So, inside newly created struct and add the required method.

public struct AddAsyncMacro: PeerMacro {

   public static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
      // Implement macro
   }
}
Enter fullscreen mode Exit fullscreen mode

If your Macro signature has more than one role you need to add conformance to each role, for example:

// Signature
@attached(accessor)
@attached(memberAttribute)
@attached(member, names: named(init()))
public macro // ...

// Implementation
public struct MyMacro: AccessorMacro, MamberAttributeMacro, MemberMacro { }
Enter fullscreen mode Exit fullscreen mode

To know the corresponding protocols see Macro roles section.

Exporting the Macro

Inside [Macro name]Macro.swift file add or edit this piece of code with to newly created Macro.

@main
struct AddAsyncMacroPlugin: CompilerPlugin {
  let providingMacros: [SwiftSyntaxMacros.Macro.Type] = [
    AddAsyncMacro.self
  ]
}
Enter fullscreen mode Exit fullscreen mode

Expansion method

The expansion method is responsible for generating the hidden code.
Here the piece of code the Macro (declaration) is attached on, is broken into pieces (TokenSyntax) and manipulated to generate the desired additional code.

To do this we have to cast the declaration to the desired syntax.
Eg. If the Macro can be attached to a struct we will cast it to StructDeclSyntax.
In this case the Macro can only be attached to a function so we will cast it to FunctionDeclSyntax.
So, inside the expansion method add:

guard let functionDecl = declaration.as(FunctionDeclSyntax.self) else {
   // TODO: Throw error
}

return []
Enter fullscreen mode Exit fullscreen mode

Now, before we continue, we need to write a test that checks whether the implementation of the Macro generates the code we expect.
So, in [Macro name]Tests.swift file add:

func test_AddAsync() {
    assertMacroExpansion(
        """
        @AddAsync
        func test(arg1: String, completion: (String?) -> Void) {

        }
        """,
        expandedSource: """

        func test(arg1: String, completion: (String?) -> Void) {

        }

        func test(arg1: String) async -> String? {
          await withCheckedContinuation { continuation in
            self.test(arg1: arg1) { object in
              continuation.resume(returning: object)
            }
          }
        }
        """,
        macros: testMacros
    )
}
Enter fullscreen mode Exit fullscreen mode

Once that is in place, let's add a breakpoint at return [] inside the expansion method and run the test.
Once we hit the breakpoint, run po functionDecl inside the debug console to get this long description:

FunctionDeclSyntax
├─attributes: AttributeListSyntax
│ ╰─[0]: AttributeSyntax
│   ├─atSignToken: atSign
│   ╰─attributeName: SimpleTypeIdentifierSyntax
│     ╰─name: identifier("AddAsync")
├─funcKeyword: keyword(SwiftSyntax.Keyword.func)
├─identifier: identifier("test")
├─signature: FunctionSignatureSyntax
│ ╰─input: ParameterClauseSyntax
│   ├─leftParen: leftParen
│   ├─parameterList: FunctionParameterListSyntax
│   │ ├─[0]: FunctionParameterSyntax
│   │ │ ├─firstName: identifier("arg1")
│   │ │ ├─colon: colon
│   │ │ ├─type: SimpleTypeIdentifierSyntax
│   │ │ │ ╰─name: identifier("String")
│   │ │ ╰─trailingComma: comma
│   │ ╰─[1]: FunctionParameterSyntax
│   │   ├─firstName: identifier("completion")
│   │   ├─colon: colon
│   │   ╰─type: FunctionTypeSyntax
│   │     ├─leftParen: leftParen
│   │     ├─arguments: TupleTypeElementListSyntax
│   │     │ ╰─[0]: TupleTypeElementSyntax
│   │     │   ╰─type: OptionalTypeSyntax
│   │     │     ├─wrappedType: SimpleTypeIdentifierSyntax
│   │     │     │ ╰─name: identifier("String")
│   │     │     ╰─questionMark: postfixQuestionMark
│   │     ├─rightParen: rightParen
│   │     ╰─output: ReturnClauseSyntax
│   │       ├─arrow: arrow
│   │       ╰─returnType: SimpleTypeIdentifierSyntax
│   │         ╰─name: identifier("Void")
│   ╰─rightParen: rightParen
╰─body: CodeBlockSyntax
  ├─leftBrace: leftBrace
  ├─statements: CodeBlockItemListSyntax
  ╰─rightBrace: rightBrace
Enter fullscreen mode Exit fullscreen mode

Here you can see every component of the function declaration.
And now you can pick the individual piece you need and use it to create you Macro-generated code.

Retrieve first argument name

For example, if you need to retrieve the first argument name of the function, you will write:

let signature = functionDecl.signature.as(FunctionSignatureSyntax.self)
let parameters = signature?.input.parameterList
let firstParameter = parameters?.first
let parameterName = firstParameter.firstName // -> arg1
Enter fullscreen mode Exit fullscreen mode

This is quite a long code just to retrieve a single string and it will be even more complex if you need to handle multiple function variations.
I think that Apple will improve this in the future, but for now let's stick with this.

Complete the implementation

Now, let's complete the AddAsync implementation.

if let signature = functionDecl.signature.as(FunctionSignatureSyntax.self) {
   let parameters = signature.input.parameterList

   // 1.
   if let completion = parameters.last,
      let completionType = completion.type.as(FunctionTypeSyntax.self)?.arguments.first,
   let remainPara = FunctionParameterListSyntax(parameters.removingLast()) {

   // 2. returns "arg1: String"
   let functionArgs = remainPara.map { parameter -> String in
      guard let paraType = parameter.type.as(SimpleTypeIdentifierSyntax.self)?.name else { return "" }
      return "\(parameter.firstName): \(paraType)"
   }.joined(separator: ", ")

   // 3. returns "arg1: arg1"
   let calledArgs = remainPara.map { "\($0.firstName): \($0.firstName)" }.joined(separator: ", ")

   // 4.
   return [
   """
   func \(functionDecl.identifier)(\(raw: functionArgs)) async -> \(completionType) {
      await withCheckedContinuation { continuation in
         self.\(functionDecl.identifier)(\(raw: calledArgs)) { object in
            continuation.resume(returning: object)
         }
      }
   }
   """
   ]
}
Enter fullscreen mode Exit fullscreen mode

In this block of code we:

  1. Retrieve the completion argument from the function signature
  2. Parse the function arguments except the completion
  3. Create the arguments that get passed into the called function
  4. Compose the async function

Show custom errors

Macros allow you to show custom errors to the user.
For example, in case the user placed the macro on a struct but that macro can only be used with functions.
In this case, you can throw an error and it will be automatically shown in Xcode.

enum AsyncError: Error, CustomStringConvertible {

  case onlyFunction

  var description: String {
    switch self {
    case .onlyFunction:
      return "@AddAsync can be attached only to functions."
    }
  }
}

// Inside `expansion` method. 
guard let functionDecl = declaration.as(FunctionDeclSyntax.self) else {
   throw AsyncError.onlyFunction // <- Error thrown here
}
Enter fullscreen mode Exit fullscreen mode

Test Macro usage

To test the behaviour of the AddAsync macro.
Go to the main.swift file and add:

struct AsyncFunctions {

  @AddAsync
  func test(arg1: String, completion: (String) -> Void) {

  }
}

func testing() async {
  let result = await AsyncFunctions().test(arg1: "Blob")
}
Enter fullscreen mode Exit fullscreen mode

As you can see the build completes with success.
!WARNING The autocompletion may not show the generated async function.

Show Macro generated code

To expand a Macro in code and see the automatically generated code, right click on the Macro and choose Expand Macro from the menu.
!WARNING The Expand Macro seems to not work always in Xcode 15.0 beta (15A5160n).

Breakpoint

Code generated by Macros can be debugged by adding breakpoints as you normally would.
To do this, right click on a Macro and choose Expand Macro from the menu.
Then add a breakpoints at the line you wish to debug.

Conclusion

Congratulations! You just created your first Macro.
Check out here the complete code.
As you saw, so far Macro implementations can be quite long event to perform a simple task.
But once you wrap your head around, they can be really useful and they can save you a lot of boilerplate code.
Macros are still in Beta so I think Apple will improve them by the time they will be available publicly.
Thank you for reading

Top comments (0)