wandres.dev
MACROS · metaprogramación

SwiftSyntax: manipular el árbol y escribir tu primera macro

Cómo representa `SwiftSyntax` un programa de Swift, qué son los nodos, los tokens y la trivia, cómo se navega y se construye sintaxis nueva, y una macro adjunta completa escrita de principio a fin con sus diagnósticos.

⏱ 20 min

Una macro es una función de sintaxis a sintaxis, así que todo el trabajo real ocurre en la biblioteca que representa esa sintaxis. SwiftSyntax es el parser oficial de Swift expuesto como paquete: el mismo que usa el compilador desde que se reescribió en Swift, con una propiedad que casi ningún parser tiene y que aquí resulta decisiva. Su árbol conserva todo lo que había en el archivo original —cada espacio, cada comentario, cada salto de línea— de modo que imprimir el árbol devuelve el fuente byte por byte. Esa fidelidad total es lo que permite que una herramienta reescriba un fragmento sin destrozar el formato del resto, y es también la razón de que la biblioteca tenga tantos tipos y tanta ceremonia. Aprender macros es, en la práctica, aprender a moverse por ese árbol con soltura.

🎯 Al terminar esta lección sabrás
  • Describir la estructura del árbol: nodos tipados, tokens y trivia, y qué implica la fidelidad total.
  • Navegar y consultar un árbol con conversiones seguras y colecciones especializadas.
  • Construir sintaxis nueva por interpolación distinguiendo raw de literal.
  • Escribir una macro adjunta completa con validación y diagnósticos propios.

El árbol: nodos, tokens y trivia

Todo en SwiftSyntax es un valor inmutable. Modificar un nodo devuelve un árbol nuevo que comparte con el anterior todo lo que no cambió, igual que una estructura persistente; no hay mutación en sitio y no hay riesgo de dejar un árbol a medias. Los tipos son estrictos y numerosos: una declaración de estructura es un StructDeclSyntax, un caso de enumeración es un EnumCaseDeclSyntax, una llamada a función es un FunctionCallExprSyntax. El tipo borrado Syntax sirve para hablar de cualquiera de ellos, y el protocolo SyntaxProtocol reúne lo común.

En las hojas están los TokenSyntax: las palabras clave, los identificadores, la puntuación. Y colgando de cada token está la trivia, todo lo que no significa nada para el compilador pero sí para quien lee: espacios, tabuladores, saltos de línea y comentarios. Cada token lleva la trivia de delante y la de detrás, y por eso el árbol puede reimprimirse sin pérdida alguna.

let arbol = Parser.parse(source: "let x = 1  // nota")
print(arbol.description == "let x = 1  // nota")   // true

Esa igualdad es la garantía de fidelidad, y conviene interiorizar lo que implica para una macro: si construyes un nodo sin trivia, el resultado se imprimirá pegado. Los espacios y saltos de línea que ves en un código generado legible están ahí porque alguien los puso.

Un árbol se recorre por dos vías. La directa consiste en pedir hijos por nombre y convertir el tipo con as, que devuelve un opcional y por tanto obliga a decidir qué pasa si no encaja.

guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { return [] }
let casos = enumDecl.memberBlock.members
    .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
    .flatMap { $0.elements }

Fíjate en la forma del camino: de la declaración al memberBlock, de ahí a members, que es una colección de MemberBlockItemSyntax, y de cada elemento a su decl, que es genérica y hay que convertir. Es verboso a propósito. Cada paso es un tipo distinto porque la gramática de Swift distingue esos niveles, y la verbosidad compra que el compilador te avise si el camino no existe.

La segunda vía es el recorrido genérico con SyntaxVisitor y su primo mutable SyntaxRewriter, que heredan de una clase con un método por cada tipo de nodo y sirven cuando no sabes dónde está lo que buscas. Y para las macros adjuntas hay un protocolo que conviene conocer: DeclGroupSyntax unifica todo lo que tiene un bloque de miembros —struct, class, enum, actor, extension, protocol—, y la firma de MemberMacro recibe precisamente un some DeclGroupSyntax, de modo que puedes leer los miembros sin saber aún qué clase de tipo te ha tocado.

Construir sintaxis nueva

Aquí es donde SwiftSyntaxBuilder cambia la vida. Los nodos son construibles por interpolación de cadenas, de manera que puedes escribir el código generado casi como lo escribirías a mano:

let nombre = "volumen"

// raw inserta codigo: el identificador aparece desnudo
let a: DeclSyntax = "var \(raw: nombre)Doble: Int { \(raw: nombre) * 2 }"

// literal inserta un dato ya escapado, con sus comillas
let b: DeclSyntax = "let etiqueta = \(literal: nombre)"

La distinción entre esas dos formas de interpolar es la fuente de errores más común y merece grabarse. raw inserta el texto tal cual y se usa para identificadores, tipos y fragmentos de código. literal inserta el valor convertido en un literal de Swift, con sus comillas y sus escapes, y se usa cuando quieres que en el código generado aparezca un dato. Y si interpolas un nodo de sintaxis sin adorno, se inserta su código fuente, que suele ser lo que quieres al reenviar un argumento recibido.

Existe también la vía estructurada, construyendo los nodos con sus inicializadores y con los resultBuilder de la biblioteca. Es más segura y muchísimo más verbosa; en la práctica se reserva para las transformaciones donde hay que preservar trivia con precisión, y la interpolación se lleva el resto.

flowchart TB
ent[Entrada la declaracion anotada] --> val[Validar la forma con as y guard]
val -->|no encaja| diag[Emitir diagnostico con context]
val -->|encaja| ext[Extraer los datos del arbol]
ext --> con[Construir DeclSyntax por interpolacion]
con --> sal[Devolver el array de declaraciones]
style ent fill:#cba6f7,color:#11111b
style diag fill:#f38ba8,color:#11111b
style sal fill:#a6e3a1,color:#11111b

Una macro completa

Vamos a escribir una macro adjunta que, dado un enum, añada una propiedad booleana por cada caso. Es el ejemplo canónico porque necesita leer el árbol, derivar nombres de los datos y validar la entrada. Primero la declaración, en el módulo público:

@attached(member, names: arbitrary)
public macro DeteccionDeCasos() =
    #externalMacro(module: "MisMacrosMacros", type: "DeteccionDeCasosMacro")

El arbitrary es obligado aquí: los nombres generados dependen de los casos del enum, así que no hay forma de anunciarlos. Ahora la implementación:

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftDiagnostics

public struct DeteccionDeCasosMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
            context.diagnose(Diagnostic(node: node, message: MensajeMacro.soloEnums))
            return []
        }

        let elementos = enumDecl.memberBlock.members
            .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
            .flatMap { $0.elements }

        return elementos.map { elemento in
            let caso = elemento.name.text
            let capitalizado = caso.prefix(1).uppercased() + caso.dropFirst()
            return """
                var es\(raw: capitalizado): Bool {
                    if case .\(raw: caso) = self { return true }
                    return false
                }
                """
        }
    }
}

Y el resultado, desde el lado de quien la usa:

@DeteccionDeCasos
enum Estado { case cargando, listo, fallo }

// Genera esCargando, esListo y esFallo
if pantalla.estado.esCargando { mostrarSpinner() }

Queda el detalle que separa una macro de juguete de una publicable: los diagnósticos. Lanzar un error desde expansion produce un mensaje genérico sin posición útil; lo correcto es emitirlo con context.diagnose sobre el nodo exacto que falla, con severidad y, si procede, con una corrección automática.

enum MensajeMacro: String, DiagnosticMessage {
    case soloEnums
    var message: String { "DeteccionDeCasos solo puede aplicarse a un enum" }
    var severity: DiagnosticSeverity { .error }
    var diagnosticID: MessageID { MessageID(domain: "MisMacros", id: rawValue) }
}

Con eso, aplicar la macro a un struct produce un error subrayado en el atributo, con tu texto, exactamente como lo haría el compilador. Devolver un array vacío después de diagnosticar es lo habitual: evita una cascada de errores derivados en el código que esperaba los miembros.

🍃

Fidelidad total

El árbol conserva espacios y comentarios, así que reimprimirlo devuelve el fuente exacto. Lo que generes sin trivia saldrá pegado.

🔀

raw frente a literal

raw inserta código, literal inserta un dato ya escapado. Confundirlos produce identificadores entrecomillados o cadenas sin comillas.

🩺

Diagnostica, no lances

context.diagnose señala el nodo culpable con tu mensaje. Lanzar un error deja al usuario sin saber qué parte de su código sobra.

La gramática como estructura de datos

Hay un cambio de perspectiva escondido en todo esto que vale más que la API completa. Estamos acostumbrados a pensar que el código es texto y que el compilador es una caja que lo consume; SwiftSyntax invierte esa relación al convertir la gramática del lenguaje en un tipo de datos que tu programa manipula como manipularía un JSON. Y no un tipo cualquiera: uno fiel, que preserva lo que el compilador desecharía. La decisión de conservar la trivia parece un capricho de formateadores y es en realidad lo que hace que el árbol sirva para todo el ecosistema —swift-format, los refactors del editor, las migraciones automáticas entre versiones del lenguaje, los linters— con un solo parser en vez de con cinco reimplementaciones que se desincronizan. Repara en la consecuencia política de eso: cuando Apple reescribió el parser de C++ a Swift y lo publicó como paquete, dejó de haber una versión privilegiada de la gramática. Tu macro parsea Swift con exactamente el mismo código que el compilador, no con una aproximación, y por eso una construcción nueva del lenguaje aparece en tu herramienta el día que aparece en el compilador. Compáralo con el mundo de C++, donde durante décadas cada herramienta tuvo su propio parser incompleto y donde la respuesta —libclang— tardó veinte años en llegar. Hay una segunda lección, más incómoda, en la verbosidad de la biblioteca: la gramática de Swift es genuinamente enorme, y la biblioteca no la esconde porque esconderla obligaría a mentir. Cada as que escribes con su guard correspondiente es un recordatorio de que estás programando contra una gramática real, con todos sus casos, y no contra una simplificación cómoda. Quien aprende a manipular ese árbol no aprende una API: aprende a ver su propio lenguaje como un dato, y ese cambio de mirada no se olvida.

📝
Lo esencial de esta lección

SwiftSyntax representa el programa como un árbol inmutable de nodos tipados cuyas hojas son tokens con trivia, y lo hace con fidelidad total al fuente. Se navega con as y colecciones, y se construye por interpolación distinguiendo raw de literal. Una macro publicable valida su entrada y, cuando falla, emite un diagnóstico con context.diagnose sobre el nodo culpable en vez de lanzar.

⚔️ Manipula el árbol
  1. Parsea un archivo tuyo con Parser.parse y comprueba que su description coincide byte por byte con el original.
  2. Extiende DeteccionDeCasos para que genere además una propiedad que devuelva el valor asociado como opcional cuando el caso tenga uno.
  3. Añade un diagnóstico de aviso, no de error, cuando el enum no tenga ningún caso, y comprueba dónde lo subraya Xcode.
  4. Reescribe la construcción de un miembro usando los inicializadores estructurados en vez de interpolación y compara ambas versiones.
  5. Provoca los dos errores clásicos usando literal donde iba raw y al revés, y anota qué código genera cada uno.