The Blog

Messing with Macros

« WWDC Predictions 2023

Next »

I had a simple problem that I figured one of those new-fangled Swift macros could help me with.

After all, macros are a new-to-us tool that can be used to eliminate repetitive boilerplate code when a function just won't do.

So here was my simple example.

I wanted to generate the code for creating an AsyncStream.

Say I want an AsyncStream of Ints called "numbers".

BTW - we're going to come back to that preceding line in a minute.

The code I want is:

public var numbers: AsyncStream<Int> { _numbers } private let (_numbers, _numbersContinuation) = AsyncStream.makeStream(Int.self)

It was here that I encountered my first problem.

What sort of macro do I want to create?

My first thought was to create something that looks at

public var numbers: AsyncStream<Int>

It would verify the public, var, and AsyncStream and extract numbers as the name and Int as the type.

The extractions were mostly beyond me and I also concluded (perhaps incorrectly) that this wasn't the sort of macro to build.

Someone suggested I write a Declaration Macro and with a generous amount of help from Paul Hudson I was able to get

#createAsyncStream(of: Int, named: "numbers")

to create the code I wanted.

Sadly, Xcode looked at the generated code and told me that although the expanded macro was symbol for symbol what I would have typed in myself, it had no idea what those variables represented.

So I decided that what I really needed was a MemberMacro.

And by decided, I mean that based on no information and guided by no intuition, I figured that I'm trying to add new members to the type so maybe this was it.

It turned out to be the right decision.

You call it like this:

@CreateAsyncStream(of: Int, named: "numbers") class MyClass { }

and it adds the stored and computed properties to MyClass.

Of course the type of node for a MemberMacro is different than node for a DeclarationMacro so I couldn't figure out how to parse the node. I decided - for now - to remove "of: " and a trailing space from the first argument and "named: ", quotation marks, and spaces from the second arguments with String methods from Foundation.

I'd appreciate any suggestions on the following hideous implementation.

Here's the declaration:

@attached(member, names: arbitrary) public macro CreateAsyncStream<T>(of: T, named: String) -> (AsyncStream<T>, AsyncStream<T>.Continuation) = #externalMacro(module: "CreateAsyncStreamMacros", type: "CreateAsyncStreamMacro")

And here's the definition of the expansion.

public struct CreateAsyncStreamMacro: MemberMacro { public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] { guard let arguments = Syntax(node.argument)?.children(viewMode: .sourceAccurate), let typeArgument = arguments.first, let nameArgument = arguments.dropFirst().first else { fatalError("who knows what happened") } let type = typeArgument.description .replacingOccurrences(of: "of: ", with: "") .replacingOccurrences(of: ", ", with: "") let name = nameArgument.description .replacingOccurrences(of: "named:", with: "") .replacingOccurrences(of: "\"", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) return [ "public var \(raw: name): AsyncStream<\(raw: type)> { _\(raw: name)}", "private let (_\(raw: name), _\(raw: name)Continuation) = AsyncStream.makeStream(of: \(raw: type).self)" ] } }

I'd love to have a cleaner way of getting name and type.

I should check that we're decorating a class.

Other thoughts?

You can find code on GitHub.

Blog Index and Subscription Information