The fork in the road
You want to build a plugin system. In many languages, that phrase points to one tool: a way to load code dynamically or hook into the compiler. In Rust, "plugin" triggers a hard choice. You must decide whether you want to extend the compiler itself or extend your application at runtime. The tools are completely different, and picking the wrong one leads to dead ends.
Rust removed compiler plugins years ago. They were unstable, broke across compiler versions, and forced unsafe code into the heart of the language. The replacement is procedural macros. Proc macros let you write code that generates code during compilation. They are safe, stable, and hygienic. They also cannot run at runtime. If you need runtime flexibility, proc macros are not the answer.
Procedural macros: code that writes code
A procedural macro is a function that runs while the compiler is building your project. It receives a chunk of source code as input, transforms it, and returns new source code. The compiler then compiles the result as if you had typed it yourself.
Think of a proc macro as a specialized architect. You hand the architect a blueprint with a special marker. The architect reads the marker, designs new rooms and hallways, and hands back a complete blueprint. The construction crew never sees the marker. They just build what the architect gave them.
This model gives you powerful extensibility without the risks of old-style compiler plugins. The macro runs in a separate process. It cannot crash the compiler. It cannot access the runtime environment. It only touches tokens.
Minimal example
A proc macro lives in its own crate. You mark the crate with proc-macro = true in Cargo.toml. This tells Cargo to build the crate as a macro provider instead of a normal library.
// Cargo.toml
[lib]
name = "my_plugin_macro"
proc-macro = true
[dependencies]
proc-macro2 = "1.0"
quote = "1.0"
syn = { version = "2.0", features = ["full"] }
The proc-macro = true flag changes how Cargo compiles the crate. The crate becomes a dynamic library that the compiler loads. You can only depend on a small set of crates inside a proc-macro crate. The standard library is available, but you cannot use serde, tokio, or any crate that isn't explicitly allowed.
// src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
/// Generates a wrapper that logs before and after the function runs.
#[proc_macro_attribute]
pub fn log_execution(_attr: TokenStream, input: TokenStream) -> TokenStream {
// Parse the input function into a syntax tree.
// syn converts raw tokens into structured data you can inspect.
let input_fn = parse_macro_input!(input as ItemFn);
// Extract the function name for the log message.
let fn_name = &input_fn.sig.ident;
// Generate the wrapper code.
// quote! builds tokens from Rust-like syntax.
// This creates a new function with the same signature that prints logs.
let expanded = quote! {
#input_fn
fn #fn_name() {
println!("Calling {}", stringify!(#fn_name));
// The original function is shadowed, so we need a unique name.
// In a real macro, you'd use `syn::Ident::new` with a unique suffix.
// This example keeps it simple to show the structure.
}
};
// Return the generated tokens to the compiler.
TokenStream::from(expanded)
}
The #[proc_macro_attribute] attribute marks this function as an attribute macro. Attribute macros wrap existing items like functions or structs. The input parameter contains the tokens of the item the user annotated. The attr parameter contains the tokens inside the brackets, like #[log_execution(level = "debug")].
Convention aside: Always use syn and quote for proc macros. Parsing TokenStream manually is painful and error-prone. The community treats syn as the standard parser and quote as the standard generator. Writing raw token manipulation code is a maintenance trap.
How the compiler sees it
When you use the macro in your code, the compiler invokes the macro crate. It sends the tokens to the macro function. The macro returns new tokens. The compiler replaces the macro invocation with the result and continues compilation.
This process happens before type checking. The macro can generate code that the compiler then validates. If the macro generates invalid code, you get a standard compiler error pointing to the generated code.
Hygiene is the killer feature here. In C macros, if you introduce a variable named i, you might shadow a loop variable in the user's code. Rust macros are hygienic. Identifiers generated by a macro are scoped to the macro. They never clash with user code. The compiler tracks where every token came from and enforces scope rules automatically.
Trust hygiene. The compiler protects you from name collisions that plague other languages. You can generate helper functions without worrying about breaking the user's code.
Realistic example
A realistic plugin system often uses derive macros or attribute macros to reduce boilerplate. Suppose you want a #[plugin] attribute that automatically registers a struct in a global registry.
// src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};
/// Automatically registers a struct in the plugin registry.
///
/// Usage: #[plugin] struct MyPlugin;
#[proc_macro_derive(Plugin)]
pub fn derive_plugin(input: TokenStream) -> TokenStream {
// Parse the derive input.
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// Check that the struct has no fields for this simple example.
// A real macro would validate the structure more thoroughly.
if !matches!(&input.data, Data::Struct(s) if s.fields == Fields::Unit) {
// Return a compile error if the struct is not a unit struct.
// compile_error! generates a hard error in the generated code.
return quote! {
compile_error!("Plugin must be a unit struct.");
}.into();
}
// Generate the registration code.
// This creates an impl block that registers the type.
let expanded = quote! {
impl Plugin for #name {
fn name() -> &'static str {
stringify!(#name)
}
}
// Register the plugin at startup.
// This assumes a `register_plugin` function exists in scope.
#[ctor]
fn register_#name() {
crate::register_plugin::<#name>();
}
};
TokenStream::from(expanded)
}
The #[proc_macro_derive] attribute marks this as a derive macro. Derive macros implement traits automatically. The macro generates an impl block for the Plugin trait. It also generates a registration function.
Convention aside: Use compile_error! for validation failures. Returning an error token stream works, but compile_error! produces clearer diagnostics. It points directly to the macro invocation and shows your message.
Pitfalls and constraints
Proc macros have strict limits. You cannot use external dependencies except for the allowed set. You cannot perform I/O. You cannot access the network. The macro runs in a sandboxed environment.
If you try to depend on a crate like serde, Cargo rejects the build with a dependency error. The error message tells you that proc-macro crates cannot depend on non-proc-macro crates.
Slow macros kill developer experience. If your macro takes five seconds to run, every compilation takes five seconds longer. Users will hate your crate. Profile your macro. Cache results when possible. Keep the logic simple.
Error handling is tricky. You cannot panic in a proc macro. Panics produce cryptic compiler errors. Use syn::Error to collect errors and call .into_compile_error() to return them. This gives the user a readable error message.
The compiler rejects panics with an internal error. Always handle errors gracefully. Return error tokens instead of panicking.
Runtime plugins: the other path
If you need plugins that load at runtime, proc macros are the wrong tool. Proc macros run at compile time. They cannot change behavior after the binary is built.
For runtime plugins, use dynamic loading. The libloading crate lets you load shared libraries at runtime. You define a trait for the plugin interface. The plugin crate implements the trait and exports a factory function. The host application loads the library, calls the factory, and gets a trait object.
This approach gives you flexibility. You can swap plugins without recompiling. You can update plugins independently. It also introduces complexity. You must handle ABI compatibility. You must manage memory across library boundaries. You lose compile-time safety for the plugin interface.
Runtime plugins require careful design. Define the interface in a separate crate that both the host and plugins depend on. This ensures the trait layout matches. Use dyn trait objects to hold the plugins.
Decision: when to use this vs alternatives
Use procedural macros when you need to generate code based on attributes or derives. Use procedural macros when you want to eliminate boilerplate while keeping compile-time safety. Use procedural macros when the extension logic is static and known at compile time.
Use runtime dynamic loading when you need to swap plugins without recompiling the host application. Use runtime dynamic loading when plugins must be updated independently or loaded based on user configuration. Use runtime dynamic loading when the plugin interface is stable and ABI compatibility is managed.
Use traits and dyn trait objects when you want a plugin system that is compiled into the binary. Use traits when you have a fixed set of plugins or when performance matters more than dynamic loading. Use traits when you want zero overhead and full compiler support.
Reach for libloading only when runtime flexibility is essential. The complexity of dynamic loading is rarely worth it for simple extensions. Compile-time plugins via traits are safer and faster.
Treat the macro crate as a code generator, not a library. If you need runtime behavior, you are in the wrong crate.