syn y quote: parsear los tokens, generar el código
Nadie manipula un TokenStream crudo a mano. syn lo eleva a un AST tipado que puedes recorrer y consultar; quote! construye tokens nuevos con una plantilla que interpola variables y repite fragmentos. Juntos definen el flujo canónico de toda proc-macro: parsear la entrada, transformar el AST, emitir el código. El par de crates sobre el que descansa el ecosistema entero.
Un TokenStream es la moneda que cruza la frontera con el compilador, pero es una moneda incómoda de contar: recorrer tokens a mano —distinguir un identificador de una llave de apertura, emparejar delimitadores, reconstruir la forma de un struct— es tan tedioso como escribir un parser desde cero cada vez. La comunidad resolvió el problema con dos crates que hoy son casi parte del lenguaje: syn, que parsea el TokenStream de entrada a un árbol sintáctico tipado y navegable, y quote, cuya macro quote! genera un TokenStream de salida a partir de una plantilla de código con huecos interpolables. La una lee, la otra escribe. Entre ambas queda tu lógica: la transformación. Ese triple movimiento —parsear, transformar, emitir— es el esqueleto de toda macro procedural, desde el derive de tres líneas hasta el framework más elaborado.
- Usar
synpara parsear unTokenStreama un AST tipado comoDeriveInputoItemFn. - Usar
quote!para construir unTokenStreamcon interpolación#vary repetición#( ... )*. - Distinguir
proc_macro::TokenStreamdeproc_macro2::TokenStreamy saber por qué existen los dos. - Encadenar los tres pasos —parsear, transformar, emitir— en el flujo canónico de una proc-macro.
syn: del token crudo al árbol tipado
syn es un parser completo de la gramática de Rust escrito como biblioteca. Le das tokens y te devuelve estructuras de datos con nombres reconocibles: un DeriveInput para lo que decora un derive, un ItemFn para una función, una Expr para una expresión. Cada nodo es un tipo Rust normal que puedes desestructurar con match, recorrer y consultar. La puerta de entrada habitual en una proc-macro es la macro parse_macro_input!, que parsea o, si la sintaxis es inválida, emite por ti un error de compilación bien situado.
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Resumen)]
pub fn derivar_resumen(input: TokenStream) -> TokenStream {
// Parseo: los tokens del struct/enum anotado se elevan a un AST tipado.
let ast: DeriveInput = parse_macro_input!(input as DeriveInput);
let nombre = &ast.ident; // el identificador del tipo, p. ej. Usuario
let generics = &ast.generics; // sus parametros genericos, si los hay
let datos = &ast.data; // Data::Struct, Data::Enum o Data::Union
// ... aqui iria la transformacion y la emision ...
let _ = (nombre, generics, datos);
TokenStream::new()
}
Con el AST en la mano, interrogar la estructura del tipo es cuestión de navegar campos. ast.ident te da el nombre; ast.data distingue struct de enum; dentro de un struct, Fields::Named te entrega la lista de campos con su identificador y su tipo. Esa introspección es justo lo que macro_rules! no puede hacer: una macro declarativa empareja patrones sintácticos, pero no puede preguntar “¿cuántos campos tiene este struct y de qué tipo es cada uno?”. syn sí.
syn es modular y su coste de compilación depende de qué actives. Para un derive suele bastar con las features por defecto más derive; si vas a manipular expresiones o cuerpos de función necesitarás full, que es más pesada. Declara en el Cargo.toml únicamente lo imprescindible —por ejemplo syn = { version = "2", features = ["full"] } cuando de verdad parsees ítems completos— porque cada feature extra se paga en tiempo de compilación de tu macro, y por transitividad, en el de quien la use.
quote: de las variables al token nuevo
Del lado de la salida, escribir tokens a mano sería aún peor. quote! te deja redactar el código generado como si lo teclearas, con dos operaciones de plantilla. La interpolación #variable inserta el valor de una variable Rust —normalmente un fragmento de AST o un identificador— en ese punto del código. La repetición #( ... )* recorre un iterable y repite el fragmento interior una vez por elemento, como un for dentro de la plantilla.
use quote::quote;
use proc_macro2::TokenStream as TokenStream2;
fn generar(nombre: &syn::Ident, campos: &[syn::Ident]) -> TokenStream2 {
quote! {
impl #nombre {
fn describir(&self) -> String {
let mut partes: Vec<String> = Vec::new();
#(
partes.push(format!("{}={:?}", stringify!(#campos), self.#campos));
)*
partes.join(", ")
}
}
}
}
Fíjate en la mezcla de niveles. #nombre se sustituye una vez por el identificador del tipo. El bloque #( ... )* se repite tantas veces como identificadores haya en campos, y dentro de él #campos toma en cada vuelta el valor correspondiente. Lo que quote! devuelve no es texto ni un String: es un proc_macro2::TokenStream, ya tokenizado, listo para volver al compilador. La plantilla se lee como el código que produce, y esa transparencia es la mitad del valor de la herramienta.
Los dos TokenStream y el flujo completo
Aquí aparece una sutileza que confunde a todo principiante: hay dos tipos TokenStream. El de la biblioteca proc_macro lo provee el compilador y solo existe dentro de una proc-macro —no puedes usarlo en tests ni en código normal—. El de proc_macro2 es un espejo independiente del compilador, y es el que usan syn y quote. La regla práctica: recibes y devuelves proc_macro::TokenStream en la firma pública, pero por dentro trabajas con proc_macro2, convirtiendo en las fronteras con .into().
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
use quote::quote;
#[proc_macro_derive(Resumen)]
pub fn derivar_resumen(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput); // 1. PARSEAR (syn)
let nombre = &ast.ident;
let salida = quote! { // 2. TRANSFORMAR + 3. EMITIR (quote)
impl #nombre {
fn resumen() -> &'static str {
stringify!(#nombre)
}
}
};
salida.into() // proc_macro2::TokenStream -> proc_macro::TokenStream
}
Este esqueleto —parse_macro_input! para entrar, tu lógica en medio, quote! seguido de .into() para salir— es el patrón que reconocerás en el noventa por ciento de las proc-macros del ecosistema. Cambia el AST que parseas y el código que generas; el andamiaje es idéntico.
flowchart LR A[proc_macro TokenStream de entrada] --> B[syn parsea a un AST tipado] B --> C[Tu logica recorre y transforma el AST] C --> D[quote genera un proc_macro2 TokenStream] D --> E[into vuelve a proc_macro TokenStream] E --> F[El compilador recibe el codigo generado] style B fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b style D fill:#cba6f7,color:#11111b style F fill:#a6e3a1,color:#11111b
Vale la pena entender por qué estos dos crates, y no otra cosa, se volvieron el cimiento indiscutido de la metaprogramación en Rust. Toda proc-macro es, en el fondo, una función entre dos representaciones del mismo código: entra en forma de tokens y sale en forma de tokens, pero para pensar sobre él necesitas una representación más rica, un árbol donde un struct sea un struct y una función sea una función. syn es la lente de subida: convierte la secuencia plana e inexpresiva de tokens en una jerarquía tipada donde cada construcción del lenguaje tiene su nodo, y donde el sistema de tipos de Rust te obliga a contemplar los casos —un Data es struct, enum o union, y el compilador no te deja olvidar ninguno—. quote es la lente de bajada: convierte tu intención, redactada como código legible, de nuevo en tokens, e interpola con # los fragmentos de AST que has calculado, de modo que lo generado se lee casi idéntico a lo que produce. Lo elegante es la simetría. syn reconstruye estructura a partir de tokens; quote destila tokens a partir de estructura. Entre las dos lentes se abre el espacio donde vive tu creatividad: puedes contar campos, inspeccionar tipos, ramificar según la forma del dato, sintetizar diez impl a partir de uno. Y hay una razón sutil para que existan dos tipos TokenStream: el del compilador es un recurso mágico que solo materializa dentro de la expansión, mientras que el de proc_macro2 es un valor corriente que puedes construir, comparar e imprimir en un test unitario. Esa dualidad es la que hace testeable la generación de código: escribes tu lógica contra proc_macro2, la ejercitas fuera del compilador, y solo tocas el TokenStream mágico en la fina cáscara de la firma pública. Interioriza el par —lente de subida, lente de bajada, y un espejo testeable entre ambas— y habrás interiorizado la anatomía de cada proc-macro que leas a partir de ahora.
syn parsea el TokenStream de entrada a un AST tipado —DeriveInput, ItemFn, Expr— que puedes recorrer y consultar; parse_macro_input! parsea y emite un error limpio si la sintaxis falla. quote! genera el TokenStream de salida desde una plantilla con interpolación #var y repetición #( ... )*, y devuelve un proc_macro2::TokenStream. Existen dos tipos TokenStream: el de proc_macro, mágico y solo válido dentro de la macro, y el de proc_macro2, un valor normal y testeable que usan syn y quote; se convierte entre ambos con .into(). El flujo canónico de toda proc-macro es tres pasos: parsear, transformar, emitir.
- Escribe la cáscara de un derive que parsee su entrada con
parse_macro_input!(input as DeriveInput)y extraiga y devuelva, por ahora, solo el nombre del tipo dentro de un método generado. - Usa
quote!con una repetición#( ... )*para generar una línea por cada elemento de unVecde identificadores; explica qué papel juega el*final. - Explica por qué recibes
proc_macro::TokenStreamen la firma pero trabajas conproc_macro2::TokenStreampor dentro, y dónde exactamente colocas los.into(). - Argumenta qué puede hacer
synquemacro_rules!no puede: pon un ejemplo concreto de introspección —contar campos, leer un tipo— imposible con reglas de patrones. - Describe cómo testearías la salida de tu generación de código sin arrancar el compilador, y por qué
proc_macro2es lo que lo hace posible.