Compile-Time Code Generation
Rust offers four complementary mechanisms for compile-time code generation: const evaluation (const fn / const {}), declarative macros (macro_rules!), procedural macros, and Cargo build scripts (build.rs + include!). Selecting the simplest mechanism that fits the problem keeps build times fast and error messages clear.
Comparing the Four Compile-Time Code Generation Mechanisms
Do you need to generate new AST items/types or only compute values?
|
+-- Compute constant values/tables of an existing type ---> `const fn` / `const { ... }`
|
+-- Generate repetitive Rust syntax / trait impls:
+-- Pattern-based substitution on tokens ----------> `macro_rules!`
+-- Inspecting struct/enum fields or custom DSL ---> Procedural Macro (`#[derive]`, `#[proc_macro]`)
+-- Reading external schemas (.proto, C headers, SQLite schema, git rev)
+--------------------------------------------> `build.rs` + `include!(concat!(env!("OUT_DIR"), ...))`
| Mechanism | Can Read External Files / Env? | Can Generate New Types / Impls? | IDE (rust-analyzer) Support |
Compile-Time Cost |
|---|---|---|---|---|
const fn / const {} |
Only via include_bytes! / env! |
No (values only) | Full (normal type-checked Rust) | Lowest |
macro_rules! |
No | Yes | High (built-in expander) | Very low |
| Procedural Macro | Sandboxed (should not perform I/O!) | Yes (inspects AST) | High (via proc-macro server) | Moderate (compiles syn + host dylib) |
build.rs + include! |
Yes (cargo:rerun-if-changed=...) |
Yes (writes .rs file to OUT_DIR) |
Good when cargo check runs build.rs |
Moderate (runs build binary before crate) |
Generating Static Code Tables with macro_rules!
When generating both an enum, its string parser, and its metadata table from a single source of truth so they can never drift out of sync, a single declarative macro generates all three items at once:
macro_rules! define_http_methods {
( $( $Variant:ident => $wire_str:literal (idempotent = $idem:literal) ),+ $(,)? ) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
$( $Variant ),+
}
impl HttpMethod {
pub const ALL: &'static [Self] = &[ $( Self::$Variant ),+ ];
pub const fn as_str(self) -> &'static str {
match self {
$( Self::$Variant => $wire_str ),+
}
}
pub const fn is_idempotent(self) -> bool {
match self {
$( Self::$Variant => $idem ),+
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
$( $wire_str => Some(Self::$Variant), )+
_ => None,
}
}
}
};
}
define_http_methods! {
Get => "GET" (idempotent = true),
Post => "POST" (idempotent = false),
Put => "PUT" (idempotent = true),
Delete => "DELETE" (idempotent = true),
}
fn main() {
for &method in HttpMethod::ALL {
println!(
"{:<6} -> idempotent = {:<5} | roundtrip = {:?}",
method.as_str(),
method.is_idempotent(),
HttpMethod::from_str(method.as_str())
);
}
}
GET -> idempotent = true | roundtrip = Some(Get)
POST -> idempotent = false | roundtrip = Some(Post)
PUT -> idempotent = true | roundtrip = Some(Put)
DELETE -> idempotent = true | roundtrip = Some(Delete)
Why Procedural Macros Must Not Perform Arbitrary File or Network I/O
Unlike build.rs (which emits cargo:rerun-if-changed=path directives so Cargo knows when to re-run the build script), procedural macros have no stable dependency-tracking protocol for external files or network calls. If a procedural macro reads an external file directly using std::fs::read_to_string, Cargo and rust-analyzer will not know to re-expand the macro when that file changes, leading to stale incremental builds. If a macro needs file contents, pass include_str!("path") into the macro so the compiler tracks the file dependency automatically!