wandres.dev
MACROS PROCEDURALES · derive y attribute

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.

⏱ 20 min

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.

🎯 Al terminar esta lección sabrás
  • Usar syn para parsear un TokenStream a un AST tipado como DeriveInput o ItemFn.
  • Usar quote! para construir un TokenStream con interpolación #var y repetición #( ... )*.
  • Distinguir proc_macro::TokenStream de proc_macro2::TokenStream y 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í.

💡
Activa solo las features de syn que uses

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
syn y quote son las dos mitades de una lente entre representaciones

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.

📝
Lo esencial de syn y quote

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.

⚔️ Ensambla el flujo de tres pasos
  1. 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.
  2. Usa quote! con una repetición #( ... )* para generar una línea por cada elemento de un Vec de identificadores; explica qué papel juega el * final.
  3. Explica por qué recibes proc_macro::TokenStream en la firma pero trabajas con proc_macro2::TokenStream por dentro, y dónde exactamente colocas los .into().
  4. Argumenta qué puede hacer syn que macro_rules! no puede: pon un ejemplo concreto de introspección —contar campos, leer un tipo— imposible con reglas de patrones.
  5. Describe cómo testearías la salida de tu generación de código sin arrancar el compilador, y por qué proc_macro2 es lo que lo hace posible.