Traiting Harder
#[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.
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....