Come On Nut…

4 minute read

56 Days Until I Can Walk

Ok, so, I thought today was going to run much smoother than it ultimately did. I’m on the final few tasks of this builder workshop, and the remaining problems seem like they should be very straightforward:

  • Use attributes to generate a setter for collection fields that allow users to add one item at a time
  • Generate a compiler error message if there is a typo in the attribute
  • Handle redefined preludes

I have spent more or less all of today working on the first two. Let’s start by considering the first challenge above.

If a field in a struct is annotated with the #[builder(each = "...")] attribute, then I can assume that this field is a Vec type. I must therefore generate a setter which allows the user to insert items into this collection one at a time. The name of the setter is the string value given in quotes. See an example below:

#[derive(Builder)]
pub struct Command {
    executable: String,
    #[builder(each = "arg")]
    args: Vec<String>,
    #[builder(each = "env")]
    env: Vec<String>,
    current_dir: Option<String>,
}

The problem really is more of a conceptual one than anything to do with how challenging the task is. When inspecting a field in a DerivedInput struct, each field has an attr property containing attributes. Extracting this information is trivially simple. Parsing it, however, is not. builder is itself an attribute, and each is an attribute nested under it. Therefore, what we seemingly want to use is the parse_nested_meta method on the Attribute struct. This method takes a closure as input and lets us cycle through and inspect the attributes nested under the parent. The below code will work to solve the first challenge:

fn get_inert_attribute(field: &Field) -> Option<String> {
  let mut result: Option<String> = None;

  for attr in &field.attrs {
    if attr.path().is_ident("builder") {
      let _ = attr.parse_nested_meta(|meta| {
        if meta.path.is_ident("each") {
          let value = meta.value()?;
          let s: LitStr = value.parse()?;
          result = Some(s.value());
          return Ok(());
        }

        Err(syn::Error.new_spanned(attr, "Unrecognized attribute"))
      });
    }
  }

  result
}

Given a field as input, we iterate over the field’s attributes. If we encounter an attribute with the identity “builder”, then we inspect the attributes nested beneath it. If we find the key/value pair “each”, then we parse the value as a string literal and store it in a result variable defined in the outside the scope of the closure. We return an empty Ok result to signal that all went well.

As mentioned, this works! And should extend easily for the case where we need to handle an erroneous key/value pair, right? Seemingly not so. If we extend this just a little bit to solve the second challenge, I arrive at the below solution:

fn get_inert_attribute(field: &Field) -> Option<String> {
  let mut result: Option<String> = None;

  for attr in &field.attrs {
    if attr.path().is_ident("builder") {
      let err = attr.parse_nested_meta(|meta| {
        if meta.path.is_ident("each") {
          let value = meta.value()?;
          let s: LitStr = value.parse()?;
          result = Some(s.value());
          return Ok(());
        }

        Err(syn::Error.new_spanned(attr, "Unrecognized attribute"))
      });

      if let std::result::Result::Err(msg) = err {
        // throw compiler error somehow
      }
    }
  }

  result
}

I would expect the above to work, once the issue of throwing the compiler error is resolved, but it does not. In fact, what I found is that parse_nested_meta is returning an error here for each, indicating that the equals sign used is a syntax error, and a comma was expected instead. This is very strange, considering syn can parse the attribute and correctly pass its value back to me. So even though I can parse and use this attribute correctly, the compiler error is still throwing, even though the attribute is correctly formatted by my definition. What’s going on here? I’m not sure yet.

I am aware that there is another method—parse_args—wherein I believe it is up to me to manually parse the contents of the attribute. This seems overly laborious. Doable! But laborious. So I’m a little stumped here.

However, it was interesting to see how to inject compiler errors into the AST using syn::Error. Instead of calling the compile_error! macro, triggering something like a panic (I believe), what we actually want to do is insert the compiler error directly into the AST and let the compiler itself trigger the panic. So we can’t just throw a compile error from anywhere. Instead we still need our derive function to return a TokenStream. But this should contain the tokens for a compile error instead of the tokens we were planning to produce.

This might look something akin to the below:

fn derive(input:: proc_macro::TokenStream) -> proc_macro::TokenStream {
  let input = parse_macro_input!(input as DeriveInput);
  let err = syn::Error::new_spanned(input, "expected `builder(each = \"...\")`");
  return msg.to_compile_error();
}

Ultimately I was able to generate a compiler error message, but my message doesn’t exactly match the expected one (I’m underlining a little bit too much code in the output). I’ve learned a lot about macros from this exercise, and I’m going to keep chipping away at this tomorrow. But at this stage, this feels like diminishing returns. Last week I had no concept of how macros work. This week, I actually feel like I have a foundational understanding, although I’m clearly no expert.

I think the best thing to do will be to get as much done tomorrow as I can, and then jump back into the ray caster on Monday. At the very least, I am happy that I know how to use declarative macros to clean my code a little, and avoid unnecessary function calls.