Messing with Macros
June 25, 2023
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.