zyn — a template engine for Rust proc macros
I kept rebuilding the same proc macro scaffolding across my own crates — syn for parsing, quote for codegen, heck for case conversion, proc-macro-error for diagnostics, hand-rolled attribute parsing, and a pile of helper functions returning TokenStream. Every project was the same patchwork. zyn started as a way to stop repeating myself, and turned into a framework that replaces all of it with a single crate.
cargo add zyn
What it looks like
Templates with control flow
With quote!, every conditional or loop forces you out of the template:
let fields_ts: = fields
.iter
.map
.collect;
quote!
With zyn:
zyn! : ,
}
}
}
// generates: struct User { name: String, age: u32, }
@if, @for, and @match all work inline. No .iter().map().collect().
Case conversion and formatting
Before:
use ToSnakeCase;
let getter = format_ident!;
After:
// HelloWorld -> get_hello_world
13 built-in pipes: snake, camel, pascal, screaming, kebab, upper, lower, str, trim, plural, singular, ident, fmt. They chain.
Reusable components
#[zyn::element] turns a template into a callable component:
zyn!
// generates:
// impl User {
// pub fn get_name(&self) -> &String { &self.name }
// pub fn get_age(&self) -> &u32 { &self.age }
// }
Elements accept typed parameters, can receive children blocks, and compose with each other.
Proc macro entry points
#[zyn::derive] and #[zyn::attribute] replace the raw #[proc_macro_derive] / #[proc_macro_attribute] annotations. Input is auto-parsed and extractors pull what you need:
Users write #[derive(MyGetters)] — the function name auto-converts to PascalCase:
// generates:
// impl User {
// pub fn get_name(&self) -> &String { &self.name }
// pub fn get_age(&self) -> &u32 { &self.age }
// }
Diagnostics
error!, warn!, note!, help!, and bail! work inside #[zyn::element], #[zyn::derive], and #[zyn::attribute] bodies:
The compiler output:
error: at least one field is required
--> src/main.rs:3:10
|
3 | #[derive(MyDerive)]
| ^^^^^^^^
No syn::Error ceremony, no external crate for warnings.
Typed attribute parsing
#[derive(Attribute)] generates a typed struct from helper attributes:
zyn::Attr<BuilderConfig> auto-resolves from the input context — fields are parsed and defaulted automatically. Users write #[builder(skip)] or #[builder(method = "create")] on their structs.
Full feature list
zyn!template macro with{{ }}interpolation@if/@for/@matchcontrol flow- 13 built-in pipes + custom pipes via
#[zyn::pipe] #[zyn::element]— reusable template components with typed params and children#[zyn::derive]/#[zyn::attribute]— proc macro entry points with auto-parsed input- Extractor system:
Extract<T>,Attr<T>,Fields,Variants,Data<T> error!,warn!,note!,help!,bail!diagnostics#[derive(Attribute)]for typed attribute parsingzyn::debug!— drop-inzyn!replacement that prints expansions (pretty,raw,astmodes)- Case conversion functions available outside templates (
zyn::case::to_snake(), etc.) - Re-exports
syn,quote, andproc-macro2— one dependency in yourCargo.toml
Benchmarks
zyn is benchmarked against darling for attribute parsing, vanilla syn + quote for
macro expansion, and heck for case conversion. Attribute extraction is faster than
darling and faster than the raw syn::parse2 baseline. The full derive pipeline adds
~660 ns of overhead over vanilla on a 5-field struct — entirely at compile time.
Full results, methodology, and charts: benches/RESULTS.md
I'd appreciate any feedback — on the API design, the template syntax, the docs, or anything else. Happy to answer questions.
License
MIT