Packet Circuit

Rust Syn Crate Tutorial: Automate Builder Patterns with Custom Macros

Published on November 05, 2024

Rust's macro system is powerful, allowing developers to generate code at compile-time. However, writing macros, especially procedural macros, can be challenging due to Rust’s strict syntax and type checking. This is where the Syn crate comes in—a powerful library that makes parsing Rust code and building macros easier. Let’s explore what Syn does, why it's special, and how you can use it effectively.

What is Syn?

Syn is a parser for Rust source code, used primarily for creating procedural macros. It allows you to parse, manipulate, and generate Rust code at a high level, making it easier to handle complex code transformations without manually parsing token streams.

Why Use Syn? Syn shines in enabling metaprogramming. If you've worked with declarative macros (e.g., macro_rules!), you know they're powerful but limited in flexibility. Procedural macros, on the other hand, allow more control and customization, but they require a way to interpret Rust syntax structures. Syn abstracts much of this parsing complexity.

Unique Aspects of Syn

  1. Rich AST (Abstract Syntax Tree): Syn provides a well-organized AST, representing Rust syntax elements that make transformations and analyses straightforward.
  2. Integration with Quote: Syn pairs well with the quote crate, which lets you turn Rust code into tokens easily and back again, making code generation smooth.
  3. Robust Parsing: Syn handles many edge cases and complex syntax patterns in Rust, reducing the risk of parser errors when handling procedural macros.

Setting Up Syn

To get started, add Syn and Quote to your dependencies in Cargo.toml:

[dependencies]
syn = "2.0"
quote = "1.0"

Basic Example: Writing a Simple Macro

Suppose we want a procedural macro that takes a struct definition and generates a method to count the fields in the struct. Here’s how we might approach this with Syn.

Step 1: Import Dependencies

use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
use quote::quote;

Step 2: Define the Macro

The macro will take a struct and return an implementation of a method that counts its fields.

#[proc_macro_derive(FieldCounter)]
pub fn field_counter(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = parse_macro_input!(input as DeriveInput);

    // Generate code based on the parsed input
    impl_field_counter(&input)
}

Step 3: Implement the Macro Logic

We’ll implement a helper function impl_field_counter to generate the actual code.

fn impl_field_counter(input: &DeriveInput) -> TokenStream {
    // Extract the struct name
    let name = &input.ident;

    // Count the number of fields if the input is a struct
    let count = if let syn::Data::Struct(data) = &input.data {
        data.fields.iter().count()
    } else {
        0 // Only structs are supported in this example
    };

    // Generate the method to count fields
    let expanded = quote! {
        impl #name {
            pub fn field_count() -> usize {
                #count
            }
        }
    };

    // Convert the generated code into a TokenStream
    TokenStream::from(expanded)
}

Explanation

  1. Parsing with Syn: parse_macro_input! takes in the input tokens and parses them into a DeriveInput structure, which contains information about the type (in this case, a struct) and its fields.

  2. AST Manipulation: We check if the input is a struct and count its fields. If it’s not a struct, we set the field count to zero.

  3. Code Generation with Quote: The quote! macro generates Rust code as a token stream. We define an impl block for our struct, adding a field_count method that returns the field count.

Testing the Macro

#[derive(FieldCounter)]
struct Example {
    field1: i32,
    field2: String,
}

fn main() {
    println!("Field count: {}", Example::field_count()); // Output: Field count: 2
}

1. Generating Default Implementations for Structs

Imagine you want every struct to have a default new() function that initializes its fields to default values. Syn can parse the struct definition and generate this function automatically.

Example

#[proc_macro_derive(DefaultNew)]
pub fn default_new(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    impl_default_new(&input)
}

fn impl_default_new(input: &DeriveInput) -> TokenStream {
    let name = &input.ident;

    let expanded = quote! {
        impl #name {
            pub fn new() -> Self {
                Default::default()
            }
        }
    };

    TokenStream::from(expanded)
}

This macro, when applied to a struct, will generate a new() method that initializes the struct to its default values.

Struct Definition and Usage

Using the DefaultNew macro we defined, let’s create a struct that automatically generates a new() function.

#[derive(DefaultNew)]
struct Config {
    host: String,
    port: u16,
}

fn main() {
    // Uses the `new()` function generated by the macro
    let config = Config::new();
    println!("Config - host: {}, port: {}", config.host, config.port);
}

When this runs, Config::new() will initialize host and port to their default values ("" and 0 respectively).


2. Adding Custom Getter Methods

Sometimes, you want to generate getter functions for each field in a struct without manually writing them. Syn allows you to read the struct’s fields and dynamically generate these functions.

Example

#[proc_macro_derive(Getters)]
pub fn getters(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    impl_getters(&input)
}

fn impl_getters(input: &DeriveInput) -> TokenStream {
    let name = &input.ident;
    
    let getters = if let syn::Data::Struct(data) = &input.data {
        data.fields.iter().map(|field| {
            let field_name = &field.ident;
            let field_type = &field.ty;
            
            quote! {
                pub fn #field_name(&self) -> &#field_type {
                    &self.#field_name
                }
            }
        })
    } else {
        return TokenStream::new();
    };

    let expanded = quote! {
        impl #name {
            #(#getters)*
        }
    };

    TokenStream::from(expanded)
}

Applying this macro to a struct generates a public getter method for each field in the struct, enabling read-only access to its fields.

Struct Definition and Usage

This example demonstrates how the Getters macro can generate getter methods for each field in a struct.

#[derive(Getters)]
struct User {
    username: String,
    age: u8,
}

fn main() {
    let user = User { username: "Alice".to_string(), age: 30 };

    // Access fields using generated getter methods
    println!("Username: {}", user.username());
    println!("Age: {}", user.age());
}

The Getters macro generates username() and age() methods, making each field accessible while keeping the struct immutable.


3. Custom Error Messages for Attribute Macros

Syn allows for custom attribute macros that validate inputs at compile-time. For example, suppose you want to enforce that a #[min_length] attribute is applied only to fields of type String or Vec. Syn can help generate compile-time errors for incorrect usage.

Example

#[proc_macro_attribute]
pub fn min_length(attr: TokenStream, item: TokenStream) -> TokenStream {
    let min_length: usize = syn::parse(attr).expect("expected a length value");
    let input = parse_macro_input!(item as syn::ItemStruct);

    let errors = input.fields.iter().filter_map(|field| {
        let field_type = &field.ty;
        
        if !matches!(field_type, syn::Type::Path(ref path) if path.path.is_ident("String") || path.path.is_ident("Vec")) {
            Some(quote_spanned! { field.span() => 
                compile_error!("The #[min_length] attribute can only be used with fields of type String or Vec.");
            })
        } else {
            None
        }
    });

    let expanded = quote! {
        #input
        #(#errors)*
    };

    TokenStream::from(expanded)
}

In this example, if #[min_length] is applied to a field of any other type than String or Vec, it generates a compile-time error message.

Struct Definition with Validation

Suppose we want to enforce a minimum length for certain fields in a struct. Here’s how to use the #[min_length] attribute.

#[min_length = "3"]
struct Product {
    #[min_length = 3]
    name: String,
}

fn main() {
    let product = Product { name: "Toy".to_string() };

    println!("Product Name: {}", product.name);
}

If name is assigned a string shorter than the specified length, the compiler will generate an error due to the custom validation in min_length.


4. Automatic Logging for Function Calls

With Syn, you can create a macro that automatically logs each function call in a struct. This can be helpful for debugging purposes.

Example

#[proc_macro_attribute]
pub fn log_calls(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as syn::ItemFn);
    let func_name = &input.sig.ident;
    let func_block = &input.block;

    let expanded = quote! {
        fn #func_name() {
            println!("Calling function: {}", stringify!(#func_name));
            #func_block
        }
    };

    TokenStream::from(expanded)
}

Applying #[log_calls] to any function will automatically insert a println! statement at the start, logging the function's name when called.

Function with Logging

The log_calls macro will automatically log the name of each function call. Here’s an example of it in action.

#[log_calls]
fn perform_action() {
    println!("Action performed.");
}

fn main() {
    perform_action();
}

When perform_action() is called, the output will be:

Calling function: perform_action
Action performed.

This is useful for debugging by tracking when each function is called.


5. Implementing Trait Methods Based on Field Types

This example demonstrates how you could use Syn to implement certain trait methods conditionally based on the types of fields in a struct. Suppose you want structs with Option fields to automatically implement the Default trait by setting all Option fields to None.

Example

#[proc_macro_derive(OptionalDefaults)]
pub fn optional_defaults(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    impl_optional_defaults(&input)
}

fn impl_optional_defaults(input: &DeriveInput) -> TokenStream {
    let name = &input.ident;

    let defaults = if let syn::Data::Struct(data) = &input.data {
        data.fields.iter().map(|field| {
            let field_name = &field.ident;
            if let syn::Type::Path(type_path) = &field.ty {
                if type_path.path.segments[0].ident == "Option" {
                    quote! { #field_name: None }
                } else {
                    quote! { #field_name: Default::default() }
                }
            } else {
                quote! { #field_name: Default::default() }
            }
        })
    } else {
        return TokenStream::new();
    };

    let expanded = quote! {
        impl Default for #name {
            fn default() -> Self {
                Self {
                    #(#defaults),*
                }
            }
        }
    };

    TokenStream::from(expanded)
}

With this macro, any struct with Option fields will automatically set those fields to None by implementing the Default trait.

Struct Definition and Usage

This example demonstrates using the OptionalDefaults macro to set all Option fields to None.

#[derive(OptionalDefaults)]
struct Preferences {
    theme: Option<String>,
    notifications: bool,
}

fn main() {
    let prefs = Preferences::default();

    println!("Theme: {:?}", prefs.theme); // Should print "Theme: None"
    println!("Notifications: {}", prefs.notifications); // Should print the default value of `notifications`, e.g., `false`
}

With the OptionalDefaults macro, Preferences::default() initializes theme as None without needing to specify it manually.


Here’s a unique example using Syn to create a procedural macro that auto-generates a to_json method for structs, serializing their fields into a JSON string. This can be useful for quickly adding JSON serialization to custom types without using a full serialization library.

Example: to_json Macro

The to_json macro will:

  • Parse each field in the struct.
  • Automatically generate a to_json() method that serializes the struct’s fields into a JSON-like format.
  • Use the field names and values to construct the JSON string.

Step 1: Define the Macro

In this code, the to_json macro will be applied to a struct, and it will generate a to_json method.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(ToJson)]
pub fn to_json(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    let name = &input.ident;

    let json_fields = if let Data::Struct(data) = &input.data {
        data.fields.iter().map(|field| {
            let field_name = field.ident.as_ref().unwrap();
            let field_str = field_name.to_string();
            quote! {
                json.push_str(&format!("\"{}\": \"{}\",", #field_str, self.#field_name));
            }
        })
    } else {
        return TokenStream::new();
    };

    let expanded = quote! {
        impl #name {
            pub fn to_json(&self) -> String {
                let mut json = String::from("{");
                #(#json_fields)*
                json.pop(); // Remove the last comma
                json.push('}');
                json
            }
        }
    };

    TokenStream::from(expanded)
}

Step 2: Using the Macro

Define a struct with several fields and apply the ToJson macro to it.

#[derive(ToJson)]
struct User {
    username: String,
    email: String,
    age: u32,
}

fn main() {
    let user = User {
        username: "Alice".to_string(),
        email: "[email protected]".to_string(),
        age: 30,
    };

    println!("{}", user.to_json());
}

Explanation

  • Parsing Fields: Syn parses the struct’s fields and retrieves each field’s name.
  • Generating JSON: For each field, the macro generates code to push the field name and value as a JSON key-value pair to a string.
  • Formatting: The generated to_json method starts with {, appends each field in JSON format, removes the trailing comma, and ends with }.

Output

When user.to_json() is called, the output will look like this:

{"username": "Alice", "email": "[email protected]", "age": "30"}

This to_json macro is simple but useful for lightweight JSON serialization without external dependencies. It can be extended to handle different data types, nested structs, or optional fields.


Here's another unique example using Syn to create a procedural macro that generates builder patterns for structs. This pattern can be helpful for constructing instances of complex structs with many optional fields, as it provides a clean and readable API for setting fields step-by-step.

Example: Builder Macro

The Builder macro will:

  • Parse each field in the struct.
  • Generate a separate builder struct with setter methods for each field.
  • Implement a build() method that returns an instance of the original struct with all the values set.

Step 1: Define the Macro

In this macro, Builder will generate a builder pattern for any struct it’s applied to.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(Builder)]
pub fn builder_macro(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let builder_name = syn::Ident::new(&format!("{}Builder", name), name.span());

    let fields = if let Data::Struct(data) = &input.data {
        if let Fields::Named(fields) = &data.fields {
            fields.named.iter().map(|field| {
                let field_name = &field.ident;
                let field_type = &field.ty;
                quote! {
                    #field_name: Option<#field_type>
                }
            })
        } else {
            return TokenStream::new(); // Only named fields are supported in this example
        }
    } else {
        return TokenStream::new();
    };

    let setters = if let Data::Struct(data) = &input.data {
        if let Fields::Named(fields) = &data.fields {
            fields.named.iter().map(|field| {
                let field_name = &field.ident;
                let field_type = &field.ty;
                quote! {
                    pub fn #field_name(&mut self, value: #field_type) -> &mut Self {
                        self.#field_name = Some(value);
                        self
                    }
                }
            })
        } else {
            return TokenStream::new();
        }
    } else {
        return TokenStream::new();
    };

    let build_fields = if let Data::Struct(data) = &input.data {
        if let Fields::Named(fields) = &data.fields {
            fields.named.iter().map(|field| {
                let field_name = &field.ident;
                quote! {
                    #field_name: self.#field_name.take().expect("Field missing")
                }
            })
        } else {
            return TokenStream::new();
        }
    } else {
        return TokenStream::new();
    };

    let expanded = quote! {
        pub struct #builder_name {
            #(#fields),*
        }

        impl #builder_name {
            pub fn new() -> Self {
                Self {
                    #(#fields),*
                }
            }

            #(#setters)*

            pub fn build(&mut self) -> #name {
                #name {
                    #(#build_fields),*
                }
            }
        }

        impl #name {
            pub fn builder() -> #builder_name {
                #builder_name::new()
            }
        }
    };

    TokenStream::from(expanded)
}

Step 2: Using the Macro

Define a struct with several fields and apply the Builder macro to it.

#[derive(Builder)]
struct User {
    username: String,
    email: String,
    age: u32,
}

fn main() {
    let user = User::builder()
        .username("Alice".to_string())
        .email("[email protected]".to_string())
        .age(30)
        .build();

    println!("User: {:?}", user);
}

Explanation

  • Generating the Builder Struct: The macro generates a separate struct called UserBuilder with Option fields, allowing each field to be set or remain unset.
  • Setter Methods: Each field in the original struct has a corresponding setter method in the builder, which returns a mutable reference for method chaining.
  • Build Method: The build() method checks each field to ensure it has been set (using Option::take()), and then constructs an instance of the original struct.

Output

When this code is run, it constructs the User instance with all fields initialized:

User: User { username: "Alice", email: "[email protected]", age: 30 }

Benefits and Applications

The builder pattern is highly useful for constructing complex structs, especially when some fields are optional or may need to be set conditionally. By using Syn to auto-generate the builder pattern, you can simplify code and improve readability without needing to write the builder pattern manually for each struct.


Why Syn is Special

Syn’s ability to parse complex syntax and generate new code makes it ideal for frameworks and libraries. Libraries like Diesel use Syn to provide compile-time safety in SQL query building, and Rocket uses Syn to handle complex routing attributes in web applications. With Syn, you can create macros that save time, reduce errors, and add new functionality.

Resources