I wrote my first custom derive macro

For a solution to Exercism's Space Age exercise.

I stayed well clear of writing custom macros for a long time. They seemed awesome, but they must be really complicated, I thought. After all, they're more powerful than macro_rules! macros and those look like confusing.

Well, I finally tried it and it wasn't nearly as bad as I thought it would be. Several times I crossed my finger and ran cargo build to find... no errors, it just worked? So I'm very chuffed and thought I'd share the news.

The space-age-derive crate is on github.

I managed to run into and then avoid one pitfall. I needed an attribute to implement this trait, to fill in an associated constant. So I wanted to do basically

#[my_constant = 12]
struct Foo

and have that impl the trait for me. I did this using a bare attribute macro (which you can see in the history on github if you're curious). But to get it to work it needed a couple of janky things. One, to use crate::MyTrait to reference the trait I was implementing. So it only worked since the trait was defined in the crate consuming my macro. Otherwise it would complain about missing imports. The other thing was it returned both the struct and the trait impl which seemed wrong.

Then I realised/remembered that derive macro helper attributes were a thing i.e. #[proc_macro_derive(MyTrait, attributes(my_helper_attr))] and now it's a proper derive macro, much cleaner!

Also, the Syn and Quote crates are fantastic! They can be intimidating at first, but it's worth diving in, referring to examples and reading the docs for what you need to use - I don't think I could read through the whole Syn docs, it's a big crate :stuck_out_tongue:

There's lots about proc macros I don't understand yet, and my derive is very simple, but I did it and feel like I can add custom derives to my toolbox - yay! :smiley:

1 Like

First of all, .filter(...).next() is better expressed with just .find(...).

Secondly, is this a public macro, or is it just going to be used internally? If it's just internal the following issues don't apply:

  • quote! { impl crate::Planet won't work unless the user has a trait Planet in their crate root. Getting the name of the containing crate is difficult, but the best solution is to accept a special #[crate = ::path::to::crate] helper attribute, and default to ::crate_name if it's not provided.
  • Try to avoid panic! for error handling in proc macros; instead, expand to a ::core::compile_error! invocation. If you use quote_spanned! to give the compile_error! a specific span you can change where the error message points to in the compiler output.
  • The parse_meta match can be improved slightly with a custom parser:
let orbital_period = (|input| {
    input.parse::<Token![=]>()?;
    input.parse::<LitFloat>()
})
.parse2(attr.tokens.clone())
.unwrap();
3 Likes

Thank you for the feedback, these are good points.
I will switch to use find, that was rather lazy of me, the assumption was there is only one attr.

This isn't meant to be seriously used but I'm interested in learning how to make it suitable for public use.

I'm keen to try a few things out here. I didn't mean to use crate::Planet, I was hoping just space_age::Planet would work. EDIT: It does not

I'm glad there are better ways to handle errors in proc macros, I want sure what to do since the function doesn't return a result, now I know.

I'm curious how Dtolnay's heapsize example works then, on the surface it looks like naming the crate should "just work":

    let expanded = quote! {
        // The generated impl.
        impl #impl_generics heapsize::HeapSize for #name #ty_generics #where_clause {
            fn heap_size_of_children(&self) -> usize {
                #sum
            }
        }
    };

Oh I think I get the difference. heapsize is always in scope when you use [#derive(HeapSize)] because the trait you're implementing is part of the public API. Whereas I have made a derive crate for internal use and it seems I can't refer to my own crate's Trait in the same way. Maybe the heapsize example being a workspace also helps in some way.

Update...
I've juggled stuff around and now have a workspace with 3 crates:

  • 1 to hold the types, like the Planet trait I want to derive
  • 1 for the derive proc macro
  • 1 "consumer" that imports the derive macro and uses it to solve the exercise

This meant I could hardcode the crate name in the derive macro just like the heapsize example. I think this is probably a pretty safe bet if you want other people to be able to use your trait and also derive it. It seems less common that you'd write a derive macro for someone else's trait so I'm happy with the approach.

You should be able to use crate::Planet in the expansion of the derive macro if you only use the derive macro within the crate that has Planet defined in its crate root.

See:

for a description of the frontend (with traits, re-exporting proc-macro, …) + backend (proc-macro only) crates pattern.

If the frontend crate (say, heapsize, or space_age) happens to call the proc-macro itself, you can put extern crate self as space_age; at the root of its lib.rs to allow referring to your own crate as if it were an external one, making the proc-macro thus usable internally :slightly_smiling_face:

1 Like

I'm struggling to use compile_error!. My first attempt ended up raising compiler erros (perhaps unsurprisngly!) but then I found out it's a bodge until the Diagnostics API is stable.

The awkward thing is arranging it so that the compile_error! macro is only called inside quote_spanned!, whilst also having the span I want to use. The problem for me is I error before I get the LitFloat I want the span of... argh :smiley:

ah just what I needed, that is a handy trick. I'm not surprised rust/cargo had a way to do this. Not something I've come across needing before :slight_smile:

1 Like

To report errors, something along the following lines ought to work:

    // Parse the argument to the attribute as a float
    // let orbital_period: LitFloat = attr.parse_args().unwrap();
    let orbital_period: LitFloat = match attr.parse_meta().unwrap() {
        syn::Meta::NameValue(name_value) => {
            if let syn::Lit::Float(x) = name_value.lit {
                x
            } else {
-               panic!("expected a float value, e.g. #[orbital_period = 1.0]")
+               return syn::Error::new_spanned(
+                   &name_value.lit,
+                   "expected a float value, e.g. #[orbital_period = 1.0]",
+               )).to_compile_error().into()
            }
        },
-       _ => panic!("expected a name = value style syntax, e.g. #[orbital_period = 1.0]")
+       extraneous => return Error::new_spanned(
+           &extraneous,
+           "expected a name = value style syntax, e.g. #[orbital_period = 1.0]",
+       ).to_compile_error().into(),
    };

If you want a panic!-like interface, when using it inside a function returning a TokenStream, you can define:

macro_rules! bail {( $err_msg:expr $(=> $span:expr)? $(,)? ) => (
    {
        let mut _span = ::proc_macro2::Span::call_site();
        $( _span = $span; )?
        return ::syn::Error::new(_span, $err_msg.as_str())
                   .to_compile_error()
                   .into()
        ;
    }
)}

so as to write:

=> bail!("expected a (literal) float value, …" => name_value.lit.span())
…
=> bail!("expected a …, etc." => extraneous.span())

That feature was added precisely with (derive) proc-macros in mind :wink:

3 Likes

perfect! :slight_smile:

error: expected a float value, e.g. #[orbital_period = 1.0]
  --> space-age\src\lib.rs:14:20
   |
14 | #[orbital_period = 1]
   |                    ^

error: expected a name = value style syntax, e.g. #[orbital_period = 1.0]
  --> space-age\src\lib.rs:18:3
   |
18 | #[orbital_period(0.61519726)]
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^

Thank you so much for the help!

1 Like

I went back over this and tried the "backend - frontend" project structure. It worked well, and I think for libraries that provide traits with their own custom derives it is the best way to go, feels smoother only needing one (public) crate.

I've updated my space-age repo with what that looks like. I also used your bail! macro Yandros, feels very ergonomic, and it helped the proc_macro and proc_macro2 thing click for me. Proc macros really do have their own special rules:

Types from proc_macro are entirely specific to procedural macros and cannot ever exist in code outside of a procedural macro. Meanwhile proc_macro2 types may exist anywhere including non-macro code

from the proc_macro2 docs

proc_macro2 is needed in Yandros' bail! macro because it is in its own separate macro_rules macro and not part of the procedural macro. It is just a copy of proc_macro, just one you're allowed to use outside of procedural macros.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.