Do I have to manually check macro parameters to be literals?

After doing some experiments to get a feel of how to develop a real macro I came upon a problem: I was trying to develop an @AddCompletionHandler macro (as the one mentioned in the WWDC talk) and I tried to pass the completion parameter name as a parameter to the macro:

@attached(peer, names: overloaded)
public macro AddCompletionHandler(completionName: String = "onCompletion") = #externalMacro(...)

I'm supposed to get a String that I'd use both to build a new parameter to be added to a function signature:

let completionParameter: FunctionParameterSyntax = "\(raw: completionParameterName): @escaping (\(returnType)) -> Void"

and to invoke the completion inside the block of the newly generated function:

let newCode: CodeBlockItemListSyntax = """
    Task.detached {
        await \(raw: completionParameterName)(\(functionDeclaration.wrappedInvocation))
    }
    """

The problem with this is that the macro declaration accepts any expression which returns a String, so you can invoke it like this:

@AddCompletionHandler(completionName: "on" + "Completion")

and I found myself manually checking that the AttributeSyntax tree contains an argument named completionName which expression is of type StringLiteralExprSyntax with exactly one segment and finally extracting the value as the .content.text of that unique segment. If any of these steps fail I emit a diagnostic asking for the value to be a literal.

Is this how this is supposed to work? It seems an extremely cumbersome process for a seemingly common use case. Am I missing anything here?

Yes that is how it's supposed to work

It might be better to take a StaticString instead of a String.

1 Like

StaticString may provide a false sense of security here—it'll catch some obviously-dynamic situations like constructing new String values on the fly with +, it doesn't totally obviate the need to dig into the syntax if your goal is "the only valid syntactic form for this macro argument is a single string literal":

func f(_: StaticString) {}

f("hello")
f("hello, " + "world") // 💥
f(true ? "hello" : "world") // valid since result type of ternary is 'StaticString'
1 Like

EDIT: Never mind, this doesn't actually solve the problem. Leaving my shame, though.

This is kind of a roundabout way of achieving it, but you could do the following:

Define a custom type that conforms to ExpressibleByStringLiteral, and use that type as the argument to the macro instead of String. Users can still pass a string literal directly to the macro invocation, but they can't do anything that's close-to-but-not-a-literal, like "hello" + "world".

But, that would still let someone do this, which you don't want because you have no way to evaluate x:

let x: YourCustomType = "onCompletion"
@AddCompletionHandler(completionName: x)

So the next trick is, when you implement init(stringLiteral:) in your custom type, just make it fatalError(). This will prevent anyone from trying to create an instance of it and stash it somewhere. But the type will still work in the macro usage because the macro doesn't actually call init(stringLiteral:) when it's used in the macro invocation. All it has to do is type-check that it's valid, which it is. (If someone does try to create an explicit instance somewhere, that error won't be caught until runtime, though.)

What would make this easier is the some kind of must-be-const feature for arguments, which has been discussed on these forums before.

1 Like

I don't think that's a good idea. By doing that instead of having the macro throw a compile-time error, you move the failure to runtime, which macros are designed to avoid.

This is exactly the kind of problem that macro diagnostics were designed to solve.

The runtime error only occurs if users try to directly instantiate the new type, which is otherwise hidden from them except being named in the macro signature. Name it something like CompletionHandlerNameLiteral so there's no confusion about its purpose.

No compile-time failures are being moved to runtime, because it's strictly preventing usage that the compiler would previously have allowed: now instead of the compiler allowing an expression like "hello" + "world" and requiring the macro to check for it, the compiler stops it and the macro no longer has to check for it.

It's not a perfect solution, but I can appreciate that users don't all want to have to write the same checks for things like "is this a literal" so it's good to let the compiler do the work for you when possible. In the absence of a const-value enforcement feature, SwiftSyntax would be a good home for such helper APIs to at least ease the burden.

While that's kind of clever there's a big problem with it.

// you can still call the macro like this
#myMacro(isItTrue ? "hello" : "world")

In that example, the user never uses the type name CompletionHandlerNameLiteral, which means your macro will still have to check for this happening and throw a diagnostic. And if your macro already has to do that anyways, which is a check at compile time, why bother adding an additional runtime check? Even if a user is unlikely to do the above, you still need to write the macro to take that into account.

1 Like

Oof, you're right, the type checker still allows that ternary expression since both sides would type-check as the custom ExpressibleByStringLiteral type. I thought I tested that case but I must have not!