[Idea] Macro Aliases

As Papyrus has been built out, I've found myself repeating a lot of similar code, particularly around things HTTP method annotations or request coding annotations.

For each of these, I find myself having to either:

  1. Define a separate macro and either create a bunch of separate macro types in the compiler plugin
  2. Have the macro's type run some custom logic based on the literal string of the macro definition

What do we think about something similar to a typealias for macros to reduce the amount of boilerplate required for similar macros and make them easier to extend? It could take some arguments and pass those through to another macro, like how typealises can pass types through to another type.

For example:

// using default arguments
@attached(peer)
macro HTTP(_ method: String) = #externalMacro(module: "MyPlugin", type: "HTTPMacro")

// more refined HTTP macros - no separate Plugin type needed
typealias GET = HTTP("GET")
typealias POST = HTTP("POST")
typealias DELETE = HTTP("DELETE")

// passing arguments to add a layer of type safety without an extra macro

@attached(peer)
macro Converter(_ requestEncoder: EncoderProtocol? = nil, _ responseDecoder: EncoderProtocol? = nil) = #externalMacro(module: "MyPlugin", type: "EndpointConverterMacro")

// more refined Converters - no separate Plugin type needed
typealias JSON(encoder: JSONEncoder, decoder: JSONDecoder) = Converter(encoder, decoder)
typealias URLForm(_ encoder: URLFormEncoder) = Converter(encoder)
typealias MultiPart(_ encoder: MultipartEncoder) = Converter(encoder)

This would also make it much easier to extend the library without adding additional macro types (that would inherently requiring a separate build plugin).

Continuing with the HTTP request library use case consider how easy additional functionality would be to add in separate libraries with something like this.

protocol RequestMiddleware {
    func handle(request: Request, next: (Request) async throws -> Response) async throws
}

/// Adds a middleware to run when a request is made.
@attached(peer)
macro Middleware(_ middleware: RequestMiddleware) 

/* ... and in separate library ... */

/// @Curl will add a `CurlLoggingMiddleware` using the `Middleware` macro.
typealias Curl = Middleware(CurlLoggingMiddleware())

struct CurlLoggingMiddleware: RequestMiddleware {
    func handle(request: Request, next: (Request) async throws -> Response) async throws {
        // log a cURL command of the request for debugging purposes
    }
}
3 Likes

If you don't need to pass any arguments to macro you can declare a macro that use other macro like so:

@freestanding(expression)
public macro stringify<T>(_ myMacroArgument: T) -> (T, String) = #externalMacro(
  module: "MyMacroMacros",
  type: "StringifyMacro"
)

@freestanding(expression)
public macro stringifyInt() -> (Int, String) = #stringify(0)

but if you would like to pass an argument like so:

@freestanding(expression)
public macro stringifyInt(
  _ myIntMacroArgument: Int
) -> (Int, String) = #stringify(myIntMacroArgument)

you will end up with a compiler crashing.

2 Likes

I believe this only works with freestanding macros? Can't seem to make it work with an @attached(peer) macro :thinking:

Rust has two different kinds of macros:

  • macros macro_rules!
  • procedural macros

The latter is akin to Swift macros, they require a separate Rust crate (think: Swift target) - with that same kind of friction (but less boilerplate) than Swift macros. Procedural macros are much more powerful than the former macros though.

The former (which is a macro in itself!) sounds like what you want for Swift - me too! - and they can be put in the same crate (target) as the rest of your code if you want. They involve no “ceremony” at all. Just declare, and use!

In less than a minute I will have written a macro which helps reduce boilerplate - without having to use any boilerplate to do so.

I think they have helped with Rusts heavy adoption and usage of Macros.

2 Likes