Swift Macro Toolkit: Making macro development a breeze

A powerful toolkit for creating concise and expressive Swift macros

Disclaimer

This library is still very much WIP so the API is subject to change. Currently I'd say the library is in a proof of concept phase where not all of SwiftSyntax is wrapped, and APIs such as destructuring and normalisation haven't been brought to all the places that they could be. However, you can always access the underlying SwiftSyntax syntax nodes without hassle when you find something that's missing.

Also, I'm a bit busy so development won't be consistent, but I'll always be available to review PRs! (contributions are extremely welcome)

Motivation

To set the scene, take a read of this motivating example from the readme.

Did you know that -0xF_ep-0_2 is a valid floating point literal in Swift? Well you probably didn't (it's equal to -63.5), and as a macro author you shouldn't even have to care! Among many things, Macro Toolkit shields you from edge cases so that users can use your macros in whatever weird (but correct) manners they may desire.

The SwiftSyntax API understandably exposes all the nuances of Swift's syntax. But for a macro developer, these exposed nuances usually just lead to bugs or fragile macros!

Consider a greet macro which prints a greeting for a specified person a given number of times: #greet("stackotter", 2) -> print("Hi stackotter\nHi stackotter"). If you implemented that, would your implementation handle being supplied a count of 0xFF or 1_000? Probably not. And would it correctly handle the name "st\u{61}ckotter"? Again probably not (unless you went out of your way to parse unicode escape sequences). Swift Macro Toolkit handles these edge cases for you simply with the handy intLiteral.value and stringLiteral.value!

Destructuring

One of the features that I'm most pleased with is destructuring. It's best explained with an example; let's say you're writing a macro to transform a result-returning function into a throwing-function. Now consider how you might parse and validate the function's return type.

Without Macro Toolkit

// We're expecting the return type to look like `Result<A, B>`
guard
    let simpleReturnType = returnType.as(SimpleTypeIdentifierSyntax.self),
    simpleReturnType.name.description == "Result",
    let genericArguments = (simpleReturnType.genericArgumentClause?.arguments).map(Array.init),
    genericArguments.count == 2
else {
    throw MacroError("Invalid return type")
}
let successType = genericArguments[0]
let failureType = genericArguments[1]

With Macro Toolkit

// We're expecting the return type to look like `Result<A, B>`
guard case let .simple("Result", (successType, failureType))? = destructure(returnType) else {
    throw MacroError("Invalid return type")
}

Much simpler!

Diagnostics

Another great quality of life improvement is the DiagnosticBuilder API for succinctly creating diagnostics. I'm sure that I speak for many developers when I say that I'd be much more likely to write macro implementations with rich diagnostics if diagnostics didn't take 23 lines to create.

Without Macro Toolkit

let diagnostic = Diagnostic(
    node: Syntax(funcDecl.funcKeyword),
    message: SimpleDiagnosticMessage(
        message: "can only add a completion-handler variant to an 'async' function",
        diagnosticID: MessageID(domain: "AddCompletionHandlerMacro", id: "MissingAsync"),
        severity: .error
    ),
    fixIts: [
        FixIt(
            message: SimpleDiagnosticMessage(
                message: "add 'async'",
                diagnosticID: MessageID(domain: "AddCompletionHandlerMacro", id: "MissingAsync"),
                severity: .error
            ),
            changes: [
                FixIt.Change.replace(
                    oldNode: Syntax(funcDecl.signature),
                    newNode: Syntax(newSignature)
                )
            ]
        ),
    ]
)

With Macro Toolkit

let diagnostic = DiagnosticBuilder(for: function._syntax.funcKeyword)
    .message("can only add a completion-handler variant to an 'async' function")
    .messageID(domain: "AddCompletionHandlerMacro", id: "MissingAsync")
    .suggestReplacement(
        "add 'async'",
        old: function._syntax.signature,
        new: newSignature
    )
    .build()

2.5x shorter, and much more expressive!

Contributing

I'm looking for contributors to help me bring this library to a semi-stable release! If you want to help out with development and want ideas for what to work on don't hesitate to contact me (@stackotter, stackotter@stackotter.dev, or by opening a GitHub issue). I'm always available to help contributors even when I don't have enough time to develop the project myself :)

Main tasks

I may have forgotten some, but here are the main tasks that I have in mind for the near future.

  • Create a robust type normalisation API (e.g. Int? -> Optional<Int>, () -> Void, and (Int) -> Int). Macro developers often need to write tedious code simply to check if a type is equivalent to Int? and this should help alleviate that.
  • Bring type destructuring to the rest of the type types (e.g. destructuring of tuple types should be possible as seen below)
    guard case let .tuple((firstType, secondType))? = destructure(tupleType) else {
        ...
    
  • Create nicer APIs for common code generation patterns (at the moment I've mostly just implemented random helper methods that I found useful; see examples)
  • Use protocols to reduce boilerplate within the library itself (I rapidly prototyped the library, and there is still a significant amount of boilerplate code repeated for types wrapping similar types of syntax)
43 Likes

How much of this would you think is suitable for incorporation to SwiftSyntax?

As someone who had to implement a macro - some of these facilities would have been VERY welcomed; thanks for writing it!

12 Likes

It’s quite hard to tell, I’d love to hear the opinion of someone who’s more closely affiliated with SwiftSyntax! But in my opinion, something like the diagnostic builder could fit quite well into SwiftSyntax, but other features like type destructuring may be a bit too specialist? Some of the simplified APIs I’ve created also directly clash with the existing APIs of SwiftSyntax and could be difficult to reconcile (because SwiftSyntax must still be catered towards technical use cases).

I was very surprised when there was no way to get the values of literals, I think that is a very core operation one might want to do when parsing Swift code, and could make a good addition to SwiftSyntax’s core API. The code for correctly parsing floating point literals would be ridiculous for any old macro implementation to contain if it were to not use the toolkit :sweat_smile:

Thanks for writing this library and sharing it @stackotter

I think libraries like these that build on top of swift-syntax are great to have because they solve real-world problems in an easy manner. Furthermore, they are great experimentation areas for improvements to swift-syntax because they are more flexible and need to worry less about source stability that swift-syntax has to, because of it’s larger user base.

That being said, I think there are some features in your package that would be a perfect fit for swift-syntax.

And sorry for the late comment. I have wanted to leave a reply since I saw the post a couple weeks ago but never found the time.

4 Likes

Awesome, that PR looks like a great improvement already! What's your opinion on the prospect of adding ExpressibleByStringLiteral to MacroExpansionFixItMessage and MacroExpansionErrorMessage? My main reason being that those names are quite verbose for what most macro devs will essentially see as wanting to set a message string.

Sounds good, I'll try to create a PR when I have the time, might be a little while until I get around to it.

Amazing, that'll be useful.

If there are any areas you would like me to experiment with in the package just let me know, I'm happy to try out experimental ideas that may not be fit for SwiftSyntax.

I don’t think that would help because Diagnostic.init takes a DiagnosticMessage and not a MacroExpansionErrorMessage, so the compiler wouldn’t know that it should use the string interpolation from MacroExpansionErrorMessage. Also, you need to specify whether you want to create an error or a warning.

There’s the option of adding a new initializer to Diagnostic that takes a string as an argument but there’s always a balance between increasing the API surface area, which also quickly goes exponential (should we add two initializers, one that takes an array of FixIt and one that takes a single Fix-It) and the usability gains. I have to admit that I am currently torn here.

Ah right, for the first issue I was thinking you could just add an overload for the initializer, but I didn't realise that there were both Error and Warning variants. I agree, probably the best that you can do in that circumstance.

I can think of a few other things like adding static functions to the protocol in an extension to be able to write .error("message") and .warning("message"), but that feels like it's probably not suitable for a library such as SwiftSyntax. (I haven't tested that idea, but I remember seeing something similar done in the FlyingSocks package iirc)

That’s a very interesting idea and I would say worth a PR. We could fine tune it there.

1 Like