In this lesson, you will learn how to orchestrate state sharing between decoupled procedural macros by leveraging the compiler’s filesystem-based side channels.
The "Stateless" Challenge
In Rust, procedural macros are isolated; each expansion invocation happens in a clean, stateless environment. They cannot store variables in global memory or share mutable state across different crate components. However, when building complex frameworks—such as a distributed service where an #[entity] macro needs to inform a #[registry] macro about its existence—you need a way to pass data between them.
The File-System Side Channel
Since we cannot rely on memory, we use the OUT_DIR environment variable to create a shared buffer. The strategy works in two steps:
- The Producer: An attribute macro emits code and writes metadata (like a JSON blob or a simple symbol) into a file inside
OUT_DIR. - The Consumer: A separate macro (often a custom derive or a function-like macro) reads that specific file during compilation to aggregate the gathered data.
// Inside the procedural macro crate:
let out_dir = std::env::var("OUT_DIR").expect("No OUT_DIR");
let path = std::path::Path::new(&out_dir).join("macro_registry.json");
// Append the entity's data to the shared file
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{}", entity_name)?;
Bridging via Hidden Attributes
To ensure the consumer macro knows where to look or what to include, you can have the producer inject a "hidden" attribute—a #[doc(hidden)] entry—that the consumer then parses using syn. By emitting a dummy item with an attribute containing the serialized state, you can force the compiler to pass the information through the TokenStream itself, effectively "communicating" the state without needing a permanent database.
While file-based communication is powerful, it is inherently sensitive to compilation order. Always ensure your build script or top-level entry point guarantees that the producer macros have finished writing their buffers before the final consumer macro attempts to read them, typically by anchoring the consumer to a mod.rs scan.
Key Takeaway: Because procedural macros lack shared memory, you must treat the OUT_DIR as a temporary state store or inject metadata into the TokenStream to communicate context between separate expansion phases.