wandres.dev
MACROS · metaprogramación

Depurar e inspeccionar: expandir, testear y conocer los límites

Cómo ver la expansión de una macro en Xcode y desde la terminal, cómo escribir pruebas con `assertMacroExpansion` incluidas las de diagnóstico, cómo poner un punto de ruptura dentro del plugin, y qué cosas una macro no puede saber ni hacer nunca.

⏱ 19 min

Una macro tiene un problema de observabilidad que ningún otro código de tu proyecto tiene: su salida no existe en ningún archivo. Cuando algo falla —y falla, porque construir código a base de interpolación es exactamente tan frágil como suena— el error aparece sobre un búfer virtual que el compilador fabricó y descartó, señalando una línea que nadie escribió. Por eso la práctica de las macros se sostiene sobre tres herramientas que conviene aprender antes que la propia SwiftSyntax: una forma de ver la expansión, una forma de fijarla en una prueba y una forma de detener el plugin en medio de su trabajo. Con esas tres, escribir macros deja de ser adivinación. Y queda una cuarta cosa, más importante todavía: saber de antemano qué le vas a pedir a una macro que jamás va a poder darte.

🎯 Al terminar esta lección sabrás
  • Inspeccionar la expansión de una macro desde Xcode y desde la línea de comandos.
  • Escribir pruebas de expansión y de diagnóstico con assertMacroExpansion.
  • Depurar la implementación con puntos de ruptura y con la salida de error.
  • Enumerar los límites estructurales de las macros y decidir cuándo no usarlas.

Ver la expansión

En Xcode, la vía directa es el menú contextual sobre el uso de la macro: aparece una opción de expansión que abre un panel con el código generado, navegable y con resaltado. Para las macros adjuntas con varios roles verás cada rol por separado, lo cual es la forma más rápida de entender qué está aportando cada uno. La expansión también se puede insertar en el fuente, y ese gesto —desplegar, leer, deshacer— es la mejor manera de auditar una macro ajena antes de confiar en ella.

Cuando algo se rompe dentro del código generado, el navegador de errores permite saltar al búfer de expansión y ver la línea culpable en su contexto. Es un archivo virtual, no editable, pero se lee igual que cualquier otro y ahí es donde suelen aparecer los paréntesis descolocados y los identificadores mal interpolados.

Desde la terminal, la opción equivalente se pide al frontend del compilador:

swift build -Xswiftc -Xfrontend -Xswiftc -dump-macro-expansions

Sale por la salida de error el texto de cada expansión conforme se produce. Es más ruidoso que el panel de Xcode y tiene una ventaja decisiva: funciona en integración continua y se puede filtrar, guardar y comparar entre commits.

Testear con assertMacroExpansion

La prueba de una macro no ejecuta nada: compara el fuente de entrada con el fuente esperado a la salida. SwiftSyntaxMacrosTestSupport trae la función que lo hace.

import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import MisMacrosMacros

final class DeteccionDeCasosTests: XCTestCase {
    let macros: [String: any Macro.Type] = ["DeteccionDeCasos": DeteccionDeCasosMacro.self]

    func testGeneraUnaPropiedadPorCaso() {
        assertMacroExpansion(
            """
            @DeteccionDeCasos
            enum Estado {
                case cargando
            }
            """,
            expandedSource: """
            enum Estado {
                case cargando

                var esCargando: Bool {
                    if case .cargando = self { return true }
                    return false
                }
            }
            """,
            macros: macros
        )
    }
}

Dos observaciones sobre esa forma. El diccionario macros asocia el nombre escrito por el usuario con el tipo de implementación, y por eso las pruebas no necesitan el plugin ni el objetivo público: la macro se ejecuta dentro del proceso de pruebas. Y la comparación es sobre texto normalizado, así que la indentación del literal esperado importa lo suficiente como para ser la causa número uno de fallos falsos.

Las pruebas que de verdad separan una macro seria de una amateur son las de diagnóstico, porque los errores son parte de su API pública:

func testRechazaStructs() {
    assertMacroExpansion(
        """
        @DeteccionDeCasos
        struct Estado {}
        """,
        expandedSource: """
        struct Estado {}
        """,
        diagnostics: [
            DiagnosticSpec(
                message: "DeteccionDeCasos solo puede aplicarse a un enum",
                line: 1,
                column: 1
            )
        ],
        macros: macros
    )
}

Escribir a mano el fuente esperado se vuelve tedioso en cuanto la macro genera mucho, y existe una alternativa muy extendida en la comunidad: la biblioteca swift-macro-testing, que graba automáticamente la salida en la primera ejecución y la trata como una instantánea. Cuando cambias la macro, el diff aparece en la prueba y decides si lo aceptas. Es la ergonomía correcta para este problema.

flowchart TB
esc[Escribo o cambio la implementacion] --> test[Prueba con assertMacroExpansion]
test -->|falla| ver[Miro el diff del fuente esperado]
ver --> brk[Punto de ruptura dentro del plugin]
brk --> esc
test -->|pasa| xc[Expando en Xcode sobre codigo real]
xc --> aud[Auditoria de lo generado]
style test fill:#89b4fa,color:#11111b
style brk fill:#f9e2af,color:#11111b
style aud fill:#a6e3a1,color:#11111b

Depurar el plugin

Aquí está el truco que cambia la experiencia y que casi nadie descubre solo. Durante una build normal, la implementación corre en un proceso lanzado por el compilador, al que puedes adjuntar un depurador con esfuerzo y poca comodidad. Pero en las pruebas corre dentro del proceso de test, así que un punto de ruptura dentro de expansion funciona como en cualquier otro código: paras, inspeccionas el árbol de entrada nodo a nodo y ves qué estás construyendo. La consecuencia práctica es una regla de trabajo: no se depura una macro compilando el proyecto que la usa, se depura reproduciendo el caso en una prueba.

Cuando lo que quieres es rastreo en vez de inspección, recuerda que la salida estándar es el canal del protocolo entre compilador y plugin. Escribir ahí corrompe la comunicación y produce fallos absurdos. La traza va a la salida de error:

FileHandle.standardError.write(
    Data("nodo recibido: \(declaration.debugDescription)\n".utf8)
)

Y la propiedad más útil de todo SyntaxProtocol es justamente debugDescription, que imprime el árbol con su estructura y sus tipos en vez de reimprimir el código. Leerlo una vez con calma enseña más sobre la gramática de Swift que cualquier documentación.

Los límites que no se pasan

Conviene tener la lista completa antes de diseñar, porque cada punto ha hecho descarrilar el diseño de alguien.

Una macro no ve tipos, solo sintaxis. Si recibes una propiedad declarada como var fecha: Fecha, sabes que el texto del tipo es Fecha, y nada más: no sabes si es un struct o un alias, ni a qué protocolos conforma, ni si existe. Todo lo que dependa de resolución de tipos está fuera de alcance, y la mayoría de las ideas ambiciosas que se estrellan lo hacen contra esta pared.

Una macro no ve más allá de su invocación. No puede leer otro archivo, ni consultar el resto del módulo, ni saber qué otras macros se han aplicado, ni ver las expansiones de sus vecinas. Cada expansión es una función pura de un fragmento aislado.

Una macro no puede tocar el disco ni la red, y debe ser determinista: la misma entrada, la misma salida, siempre. La caché de compilación depende de ello, y una macro que consulte la hora o un servidor produce builds irreproducibles.

Una macro solo añade. No borra ni reescribe lo anotado, con la única grieta de los roles recientes sobre cuerpos de función. Si tu diseño necesita cambiar el significado de algo que el usuario escribió, no es un problema de macros.

Y quedan los costes: swift-syntax alarga las builds, arbitrary desactiva optimizaciones de búsqueda de nombres, las expansiones anidadas tienen un límite de profundidad, y todo error dentro del código generado le aparece al usuario sobre líneas que no escribió.

🔬

Depura en las pruebas

En un test la implementación corre en proceso, así que los puntos de ruptura funcionan. Nunca depures compilando el proyecto consumidor.

📷

Instantáneas, no literales

Escribir a mano el fuente esperado no escala. Graba la expansión como instantánea y revisa el diff cuando cambies la macro.

🚧

Sintaxis, nunca tipos

La macro ve texto con estructura, no significado. Cualquier diseño que necesite saber a qué conforma un tipo está condenado.

El precio de la magia se paga en el depurador

Hay una asimetría que atraviesa toda la metaprogramación y que las macros hacen especialmente visible: el beneficio lo cobra quien escribe la macro, y el coste lo paga quien la depura, que suele ser otra persona y en otro momento. Una macro bien hecha borra cien líneas repetidas de cada uno de los cincuenta tipos del proyecto, y esa cuenta es tan favorable que resulta difícil resistirse. Lo que la cuenta no incluye es el día en que alguien nuevo abre el archivo, ve un atributo, no encuentra el código que lo implementa, expande, lee doscientas líneas generadas y tiene que reconstruir mentalmente por qué su cambio inocente ha roto una conformidad que nadie escribió. Ese coste es real, es diferido y no aparece en ninguna métrica. De ahí sale el criterio que separa a quien usa macros con juicio de quien las usa por entusiasmo, y cabe en tres preguntas. ¿Se repite de verdad? Una macro para dos usos es un multiplicador de complejidad sin numerador; la repetición tiene que ser genuina y creciente. ¿El código generado es aburrido? Una macro que produce exactamente lo que habrías escrito a mano se audita en un vistazo; una que produce algo ingenioso es una deuda con intereses. ¿El error tiene buen aspecto? Si aplicarla mal produce un mensaje del compilador sobre un búfer virtual, la macro no está terminada: los diagnósticos no son un extra, son la mitad de su interfaz. Merece la pena fijarse en que las macros del propio Apple superan las tres pruebas con holgura. @Observable resuelve un problema que aparece en cada modelo de cada aplicación, genera código aburridísimo y muy predecible, y falla con mensajes precisos. No es casualidad: es el estándar que hay que ponerse. Y el corolario incómodo, el que de verdad hay que llevarse de este nivel, es que la decisión más frecuente y más correcta sobre una macro es no escribirla, porque la repetición que te molesta casi siempre cede antes ante un genérico, un protocolo con extensión por defecto o simplemente ante aceptar que escribir dos veces algo claro es mejor que escribir una vez algo mágico.

📝
Lo esencial de esta lección

La expansión se inspecciona desde el menú contextual de Xcode o con -dump-macro-expansions en la terminal. Se fija con assertMacroExpansion, que compara fuentes y también diagnósticos, y que ejecuta la macro dentro del proceso de pruebas, donde los puntos de ruptura sí funcionan. La traza va a la salida de error, nunca a la estándar. Y una macro nunca ve tipos, nunca ve otros archivos, nunca toca el disco y nunca borra lo que escribiste.

⚔️ Cierra el ciclo
  1. Expande en Xcode una macro de sistema como @Observable sobre una clase tuya y cuenta cuántas líneas genera por propiedad.
  2. Escribe tres pruebas para tu macro: expansión correcta, entrada inválida con diagnóstico y caso límite sin miembros.
  3. Pon un punto de ruptura dentro de expansion y recorre el árbol de entrada imprimiendo debugDescription de cada nivel.
  4. Rompe la macro interpolando con literal donde iba raw, ejecuta la prueba y comprueba qué revela el diff.
  5. Toma una idea de macro que se te haya ocurrido y sométela a las tres preguntas del criterio; si falla alguna, escribe la alternativa sin macro.