Traiting Harder

2026-02-01

Generating Trait impls with an Attribute Macro

#[template("post.html")]

Writing a crate that implements the trait described in the previous post and supplies a macro to make implementing that trait easier.

We'll make use of the syn and quote crates to write our macro, and deconstruct all the different parts of the macro so you can see how it works.

The Problem

In the previous post we introduced the following trait:

trait Templated: Serialize {
    const HTML: &str;
    const TEMPLATE: &OnceLock<Template>;
    fn template() -> &'static Template {
        Self::TEMPLATE.get_or_init(|| {
            mustache::compile_str(Self::HTML).unwrap()
        })
    }
    fn render<W: Write>(&self, w: &mut W) {
        Self::template().render(w, &self).unwrap()
    }
}

The exact details aren't too important (see the previous post if you're interested) but it suffices to say that because TEMPLATE is const, it isn't possible to define a default value as any static is separate from the block of code that contains it.

trait Templated: Serialize {
    //...
    // This doesn't work as there is only one `TEMPLATE`
    // static that becomes shared between all
    // implementations of the trait.
    const TEMPLATE: &OnceLock<Template> = {
        static TEMPLATE: OnceLock<Template> = OnceLock::new();
        &OnceLock;
    };
    //...
}

What we're gong to do in this post is look at one workaround where we'll write an attribute macro that looks like #[template("./template.html")] which we can apply to structs to generate an implementation with a unique static TEMPLATE.

The Solution

We want to create a macro, template that can take struct definitions like this:

#[template("./template.html")]
struct Thingo<'a> {
    title: Cow<'a, str>,
    description: Cow<'a, str>
}

and turn it into something like this

#[derive(serde::Serialize)]
struct Thingo<'a> {
    title: Cow<'a, str>,
    description: Cow<'a, str>'
}

impl<'a> Templated for Thingo<'a> {
    const HTML: &'static str = include_str!("./template.html");
    const TEMPLATE: &'static std::sync::OnceLock<maddi_templating::mustache::Template> = {
        static TEMPLATE: std::sync::OnceLock<maddi_templating::mustache::Template> =
            std::sync::OnceLock::new();
        &TEMPLATE
    };
}

The syn and quote crates

The syn crate exists to parse rust source code. It's useful for us so we don't have to write any heavy parsing logic to parse the definitions of structs like Thingo<'a>.

The quote crate exists to generated token streams. It's like fmt but for generating token streams.

In an aside, the existence of the [`proc_macro2`](https://crates.io/crates/proc_macro2) crate is just genius, kudos to everyone who has contributed to that project or any of the downstream or upstream crates.

The Macro

Macros interestingly need to reside in their own crate with lib.proc-macro = true. I don't yet know why this needs to be but it seems an interesting point to jump back in and study later.

Procedural macros in Rust are sort of just functions that are run at compile-time. They map TokenStreams into other TokenStreams that are parsed by the rust compiler as if they were code in the source file.

The crate that defines our macro, maddi-templating-proc-macro is actually so small that I can comfortably just dump the entire lib.rs below.

extern crate proc_macro;

use proc_macro::TokenStream;
use quote::quote;
use syn::{
    // The two types we'll be parsing our two
    ItemStruct,
    LitStr,
    // A macro for parsing values into types
    parse_macro_input
};

// A `proc_macro_attribute` is an attribute we can attach
// to an item. Here, our attribute is `template`.
//
// `attr` here is the stream of tokens supplied inside the
// parentheses to the right of the macro name when invoking
// the macro. For example, they are the `"./template.html"`
// in `#[template("./template.html")]`. Here, we're
// expecting a single string literal.
//
// `item` is the token stream of the item the attribute is
// attached to. Here, we're expecting a struct definition.
#[proc_macro_attribute]
pub fn template(attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse `attr` to ensure it's a string literal
    let template_path = parse_macro_input!(attr as LitStr);
    // Parse `item` as a struct definition
    let struct_ = parse_macro_input!(item as ItemStruct);
    // `split_for_impl` is an amazing function that
    // generates the generics for the `impl`, `for` and
    // `where` clauses that we'll need when we're generating
    // an `impl` for a trait.
    let (impl_generics, ty_generics, where_clause) = struct_.generics.split_for_impl();
    let name = struct_.ident.clone();
    // This is the quote macro. This allows us to generate
    // a `TokenStream`. Interpolation is done with `#` as
    // you can see here with `#impl_generics`, `#struct_`,
    // etc.
    quote!(
        // First we'll need to spit out the original struct
        // definition unchanged (though with a derive for
        // Serialize). We still want to create the struct
        // after all.
        #[derive(serde::Serialize)]
        #struct_

        // Next, we use the generics and where clause
        // generated by `syn`, plus the identifier of the
        // struct and the path to the template, to generate
        // an implementation of `Templated`.
        impl #impl_generics maddi_templating::Templated for #name #ty_generics #where_clause {
            // Sourcing the template file
            const HTML: &'static str = include_str!(#template_path);
            // This is the boilerplate this macro exists
            // to remove.
            const TEMPLATE: &'static std::sync::OnceLock<maddi_templating::mustache::Template> = {
                static TEMPLATE: std::sync::OnceLock<maddi_templating::mustache::Template> =
                    std::sync::OnceLock::new();
                &TEMPLATE
            };
        }
    )
    .into()
}

A Random Thought

Maybe we could create a wrapping struct called Child<T: Templated>(T) that implements serde's Serialize trait by writing itself out to a string thus allowing us to easily use templates inside other templates....