Retour

Rust: Macon, a new derive builder

As announced on Twitter/X:

I have released the first public release of my very first crate (name given to packages in Rust ecosystem): Macon.

It is pronounced: \ma.sɔ̃\ .

A story of yak

(If you miss the ref: https://en.wiktionary.org/wiki/yak_shaving)

Before talking about crate usage, I would like to speak a little bit about its story.

As I was working on a toy project, I needed to deal with Podman and found no crate to consume its API. So, I started to create one of my own. After writing down several large structs to represent API payloads, I hadn't enough strength to create whole bunch of getters and setters.

Knowing power of Rust macros, I started to find out existing crates: https://crates.io/keywords/builder?sort=downloads

There were two candidates:

Which one to pick !? Let's bench them. So, I started to write some blueprints of what I expect, to list wanted features/behaviors and to compare selected crates. If you are curious, result is published here: https://github.com/loganmzz/rust-benchmark-setter.

Honestly, I was a little bit disappointed by the lack of defaults. I had to add a lot of code (i.e. attributes) on several large structs. The conclusion was: I need my own crate with much more convention over configuration.

A definition of Rust macros

Let's delve into the technical details. In Rust, macros are a way to generate code. Being more precise, it's about to generate tokens. There are three kinds: function, derive and attribute. If you remember, I said derive macro.

A function macro is called like a function but with a !. There are few common ones every Rustacean (surname for people using Rust) knows: println, panic, assert, ... While call syntax looks like a function, it can appear almost anywhere.

A derive macro is attached to a struct (custom data type, similar to class in object-oriented language) and add code right after it. The most common use case is to generate default implementation for a trait. Examples from standard library: Debug, PartialEq, Defaut, ...

An attribute macro is used as an attribute attached to any element. It's the most complex one, as it replaces the attached element. The most used one is (or would be) surely test.

If you want to learn more about Rust macros, feel free to contact us. We have a talk for you !

A usage of Macon

So, Macon is a derive macro that generate struct builder:

use macon::Builder;

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Person {
  first_name: String,
  last_name: String,
  age: u8,
}

let builder: PersonBuilder = Person::builder();

Then, properties can be set using function (called setters) with same name as property ones. Such functions can be chained:

let builder: PersonBuilder = builder
  .first_name("Logan")
  .last_name("Mauzaize")
  .age(38);

There are few important things to note here:

  1. We passed &'static str for String properties. It's because setter parameters are generics over Into.
  2. Chain-call is implemented by consuming and returning the builder.

Finally, construct a new Person instance:

let author: Person = builder.build();

assert_eq!(
  Person {
    first_name: String::from("Logan"),
    last_name: String::from("Mauzaize"),
    age: 38,
  },
  author,
);

A favor of tuples

Person is a named struct: fields have name and are unordered. Tuple struct fields, on the opposite, just have order:

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Info(
  String,
  String,
  u8,
);

Instances can still be built in unordered manner using set${ORDER}:

let info = Info::builder()
  .set1("Mauzaize")
  .set2(38)
  .set0("Logan")
  .build();

assert_eq!(
  Info(
    String::from("Logan"),
    String::from("Mauzaize"),
    38,
  ),
  info,
);

But the preferred way is to use ordered setters:

let info = Info::builder()
  .set("Logan")
  .set("Mauzaize"
  .set(38)
  .build();

assert_eq!(
  Info(
    String::from("Logan"),
    String::from("Mauzaize"),
    38,
  ),
  info,
);

A lot of defaults

What happens if you try to build an incomplete instance? A compilation error. build() method is accessible only when mandatory values are set.

As stated, Macon supports convention over configuration. If following field types are detected, they are considered optional and initialize with default value:

  • primitive types (bool, usize, i32, ...)
  • strings: String, str
  • collections: Vec, HashMap, HashSet
  • Option

As macros does operate at token level. It doesn't have access to type information, only their name. Short or fully qualified names are supported (e.g. Option, ::core::option::Option, std::option::Option). Such behavior can be enforced (enable or disable), using builder attribute:

#[derive(Debug,Default,PartialEq)]
struct MyWrapper(String);

impl<T: ::core::convert::Into<String>> ::core::convert::From<T> for MyWrapper {
    fn from(value: T) -> Self {
        Self(value.into())
    }
}

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct DefaultOptions {
  #[builder(Default,)]
  wrapped: MyWrapper,
  #[builder(Default=!)]
  mandatory: String,
}

DefaultOptions::builder()
  .wrapped("optional")
  .build();               // Compilation error !

assert_eq!(
  DefaultOptions {
    wrapped: MyWrapper(String::from("")),
    mandatory: String::from("some value"),
  },
  DefaultOptions::builder()
    .mandatory("some value")
    .build(),
);

assert_eq!(
  DefaultOptions {
    wrapped: MyWrapper(String::from("")),
    mandatory: String::from("another value"),
  },
  DefaultOptions::builder()
    .wrapped_default()          // Explicit default
    .mandatory("another alue")
    .build(),
)

Finally, Default detection can apply on whole struct:

#[derive(Builder)]
#[derive(Debug, Default, PartialEq)]
struct AutoDefaultStruct {
  boolean: bool,
  numeric: usize,
  string: String,
  vec: Vec<usize>,
}

assert_eq!(
  AutoDefaultStruct {
    boolean: false,
    numeric: 0,
    string: String::from(""),
    vec: vec![],
  },
  AutoDefaultStruct::builder().build(),
);

And enforce with builder attribute at struct level:

#[derive(Builder, Debug, PartialEq)]
#[builder(Default,)]
struct EnforceDefaultStruct {
  boolean: bool,
  numeric: usize,
  string: String,
  vec: Vec<usize>,
}

impl ::core::default::Default for EnforceDefaultStruct {
    fn default() -> Self {
        Self {
            boolean: true,
            numeric: 42,
            string: String::from("default"),
            vec: vec![0, 1, 2 ,3],
        }
    }
}

assert_eq!(
  EnforceDefaultStruct {
    boolean: true,
    numeric: 42,
    string: String::from("default"),
    vec: vec![0, 1, 2, 3,],
  },
  EnforceDefaultStruct::builder()
    .build()
  ,
);

assert_eq!(
  EnforceDefaultStruct {
    boolean: true,
    numeric: 0,
    string: String::from("override"),
    vec: vec![0, 1, 2, 3],
  },
  EnforceDefaultStruct::builder()
    .boolean_keep()      // Keep value from default instance
    .numeric_default()   // Use default from field type
    .string("override")  // Override value
    .build()
  ,
);

Note: keep and default are also available for tuple ordered setter.

A piece of option

Option is not also a discretionary, but also treated differently. Builder setter is then generic over Into for wrapped type, not option one:

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct FullOption {
  string: Option<String>,
  list: Option<Vec<usize>>,
}

assert_eq!(
  FullOption {
    string: Some(String::from("optional")),
    list: Some(vec![0, 1, 1, 2, 3, 5, 8,]),
  },
  FullOption::builder()
    .string("optional")
    .list([0, 1, 1, 2, 3, 5, 8,])
    .build()
  ,
);

Just as in the case of Default, detection can be enforce with builder attribute. But in this case, you must specified wrapped type:

type OptString = Option<String>;

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct EnforceOption {
  #[builder(Option=String)]
  string: OptString,
}

assert_eq!(
  EnforceOption {
    string: Some(String::from("enforced optionnal")),
  },
  EnforceOption::builder()
    .string("enforced optionnal")
    .build()
  ,
);

And one more time, you can set explicitly to None:

#[derive(Builder)]
#[derive(Debug,PartialEq)]
struct OptionTuple(
  Option<String>,
);

assert_eq!(OptionTuple(None), OptionTuple::builder().build());
assert_eq!(OptionTuple(None), OptionTuple::builder().set0_none().build());
assert_eq!(OptionTuple(None), OptionTuple::builder().none().build());

A plan of features

There are some other features, I let you browse documentation to discover them.

By now, I want to talk about some coming features.

Support for collection

The idea is to provide helpers similar to types wrapped into Option, but for Extend (including HashMap):

//Disclaimer: may not reflect final syntax

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct ExtendSupport {
  listitems: Vec<String>,
  mapitems: HashMap<String, String>,
}

assert_eq!(
  ExtendSupport {
    listitems: vec![String::from("one"), String::from("two"), String::from("three"),],
    mapitems: HashMap::from([
      (String::from("A"), String::from("eɪ")),
      (String::from("B"), String::from("biː")),
      (String::from("C"), String::from("siː")),
      (String::from("D"), String::from("diː")),
    ]);
  },
  ExtendSupport::builder()
    .listitem("one")
    .listitem("two")
    .listitem("three")
    .mapitem("A", "eɪ")
    .mapitem("B", "biː")
    .mapitem("C", "siː")
    .mapitem("D", "diː")
    .build()
  ,
);

Support for buildable

The idea is to ease initialization of buildable nested structs:

//Disclaimer: may not reflect final syntax

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Nested {
  foo: String,
  bar: usize,
}

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Root {
  #[builder(Builder,)]
  nested: Nested,
}

assert_eq!(
  Root {
    nested: Nested {
      foo: String::from("nested foo"),
      bar: 0,
    },
  },
  Root::builder()
    .nested_build(|nested| nested.foo("nested foo"))
  ,
);

Support for enum variants

The idea is to select a variant when building an enum struct:

//Disclaimer: may not reflect final syntax

#[derive(Builder)]
#[derive(Debug,PartialEq,)]
enum Value {
  String(String),
  Numeric(u64),
  Point {
    x: i64,
    y: i64,
  },
}

assert_eq!(
  Value::Point {
    x: -2,
    y:  1,
  },
  Value::builder()
    .Point()
      .x(-2)
      .y( 1)
    .build()
  ,
);

A call for participation

If you wanna contribute to the project, the easiest way is to open RFEs (Request For Enhancement) or bug reports.

There are many levels of proposal:

  • Usage syntax
  • Blueprint concept
  • or even, macro implementation

See you soon on https://github.com/loganmzz/macon-rs/issues !