Procedural Macros, pt. 6: Attributes

We’ve built our code, and it’s passed all our tests. But, we’ve just realized that we’d rather not have a getter generated for our age property. Thinking about it, the ability to skip generation of getters was included in our original functional spec.

Enter, attributes.

Attributes provide a means to pass options or parameters to our derive macro code. These options allow us to tweak the generation of code as needed. To skip the generation of a getter, we are going to attach an optional skip attribute to a field.

#[derive(propertese)]
struct Person {
    #[propertese(skip)]
    name: String,
    age: u32
}

The attribute can be passed in with a value if needed. For example, if our derive macro were to be generating setters along with getters, we could pass in #[propertese(skip="getter")]. Alternatively, we could pass in a string literal like #[propertese("skip_getter")]. These variants could possibly instruct our macro generation code to skip the generation of a getter while including a setter. Depending on the form we choose, synstructure will parse these out into different types of meta items on syn::Field.

Each of these are found in some nested property of an enum syn::NestedMeta. syn::Path will suffice for our code generation. So, let’s add code to work with that. To start with, let’s write up a function extract_attributes that will return any attributes attached to a field as a Vec<String>.

fn extract_attributes(
  field: &syn::Field,
  span: proc_macro2::Span,
) -> Result<Vec<String>, ()> {
  let mut attributes: Vec<String> = Vec::new();
  let meta: Vec<syn::NestedMeta> = field
      .attrs
      .iter()
      .filter\(|attr|
      attr.path.is_ident("propertese"))
      .map\(|attr| attr.parse\_meta\())
      .filter_map(Result::ok)
      .flat\_map\(|meta| match meta {
      syn::Meta::List(meta_list) => meta_list.nested.into_iter().collect(),
      _ => {
            emit_error!(span, "expected #[propertese(...)]");
            vec![]
           }
      })
      .collect();

  for meta_item in meta {
    match &meta_item {
      syn::NestedMeta::Meta(meta) => match meta {
        syn::Meta::Path(path) => {
          let attribute_name = path.get_ident().unwrap().to_string();
          attributes.push(attribute_name);
        }
        _ => emit_error!(
              span,
              format!("#[propertese(...)] has invalid data: {:?}", meta)
             ),
        },
      _ => emit_error!(
              span,
              format!("literal strings as attributes are not supported")
             ),
    }
  }

  Ok(attributes)
}

While the function looks like a bit much, it basically does the following

Armed with this code, we can modify our function derive_propertese to use the provided attributes.

match &s.ast().data {
  syn::Data::Struct(data_struct) => match &data_struct.fields {
    syn::Fields::Named\(fields\_named) => fields\_named.named.iter\().for\_each\(|field| {
      let attributes = extract_attributes(field, span).unwrap();
      if !attributes.iter\().any\(|e| e == \"skip\") {
        let getter = derive_getter(field);
        propertese_token_stream.extend(getter);
      }
    }),
    _ => emit_error!(
      span,
      "macro propertese can only be called on structs with named fileds"
    ),
  },
  _ => emit_error!(span, "macro propertese can only be called on structs"),
}

With the above snippet, we first extract all the attributes (if any) that have been attached to a field. We then check to see if the extracted list contains a skip. If it does, we skip generating the getter for that field.

Test again

Let’s add on to our unit tests to ensure the changes we’ve made work (as well as ensure we haven’t broken anything).

#[test]
fn test_getter_skip() {
  synstructure::test_derive! {
   derive_propertese {
    struct Person {
      #[propertese(skip)]
      name: String,
      age: u32
    }
  }
  expands to {
    impl Person {
      fn get_age(&self) -> u32 {
        self.age
      }
    }
  }
  no_build
  }
}

```

The above test verifies the macro generates the correct code. Let’s add an usage test as well. Create file tests/ui/test_skip.rs with the following content

#[macro_use]
extern crate propertese;

#[derive(propertese)]
struct Person {
    #[propertese(skip)]
    name: String,
    age: u32
}

fn main() {
    let frank = Person {
        name: "Frank".to_string(),
        age: 40
    };

    assert_eq!(40, frank.get_age());
    assert_eq!("Frank", frank.get_name());
}

Looking at the code, we see that we are trying to call frank.get_name(). This should generate a compilation error as we’ve instructed propertese to skip the generation of a getter for name. To validate this, we will use the compile_fail feature of trybuild. Edit tests/compile_tests.rs to add this check.

t.compile_fail("tests/ui/test_skip.rs");

The first time we run cargo test we should see the test failing. trybuild will create a file wip/test_skip.stderr with the error that was returned by the compiler. Examining it we see

error[E0599]: no method named `get_name` found for struct `Person` in the current scope
  --> tests/ui/test_skip.rs\:18\:31
   |
5  | struct Person {
   | ------------- method `get_name` not found for this
...
18 |     assert_eq!("Frank", frank.get_name());
   |                               ^^^^^^^^ help: there is an associated function with a similar name: `get_age`

This is along expected lines. The error message hints to the availability of get_age() while stating that get_name() does not exist. If the error were not along expected lines, we’d edit our code until the compiler were to generate the error we are looking for. Since, we have the right error, we can move test_skip.stderr to tests/ui/test_skip.stderr. In subsequent tests runs, trybuild will compare the error message the compiler generates against the contents of this file. If they match, it will pass the test case.

And, with that, we conclude our short series to writing procedural derive macros in Rust.