Bloques del DSL con closure: cómo se construyen

Tutorial práctico para añadir al DSL formas del tipo bloque(params) { ... }, que cambian lo que le pasa al código escrito dentro.

El caso que se desarrolla de principio a fin es defaults(...), un bloque que da valores por defecto a todas las animaciones que contiene:

defaults(runtime: 2, lambda: "smooth", effects: [alpha: 0.5]) {
    animShift(obj: a, dx: 2)
    animRotate(obj: b, angle: PI)
    play { appear(obj: c); appear(obj: d) }
}

Todas las referencias apuntan a jmathanim-core/src/main/groovy/com/jmathanim/core/groovy/dsl/.


1. Por qué un closure y no un mapa

Es la misma razón por la que existe play { } y está explicada en la cabecera de BlockDSL.groovy:10-52, pero conviene tenerla delante porque condiciona todo el diseño.

Groovy evalúa los argumentos antes de invocar el método. Si defaults fuese una llamada normal con una lista:

defaults(runtime: 2, anims: [animShift(obj: a, dx: 2), animRotate(obj: b, angle: PI)])

para cuando el cuerpo de defaults empieza a ejecutarse, las dos animaciones ya están construidas y reproducidas, con su runtime de 1 segundo. No hay bandera ni parámetro que arregle eso desde dentro: el daño ocurrió durante la evaluación de la lista de argumentos.

Un closure es un valor que todavía no se ha ejecutado. Al pasarlo, el bloque recibe el control de cuándo se construye lo de dentro, y por lo tanto puede preparar el contexto antes y limpiarlo después. Esa es la única herramienta que da Groovy para esto, y es la que usan play, sequence, skip, section y harryhausen.


2. Anatomía de un bloque

Todo bloque del DSL está hecho de las mismas cuatro piezas:

Pieza Dónde Para qué
Método de fachada JMAnimBaseScript.groovy Es lo que el script puede escribir. Delega en el *DSL
Espejo estático JMA.java El mismo método para código que no es cuerpo de script (clases Groovy propias, helpers)
Implementación un *DSL.groovy (normalmente BlockDSL) La lógica: abrir contexto, llamar al closure, cerrarlo
Contexto pila ThreadLocal en AbstractAnimDSL El estado que el bloque instala y que el resto del DSL consulta

Y hay tres arquetipos, según lo que el bloque haga con el closure:

Arquetipo Ejemplo Qué hace Contexto que instala
Recolector play, sequence Ejecuta el closure una vez y se queda con lo que se construyó dentro para reproducirlo junto Una lista donde finish() deposita en vez de reproducir
Modificador defaults, skip Ejecuta el closure una vez y cambia cómo se comporta lo de dentro Un mapa/bandera que se consulta en el camino normal
Repetidor harryhausen Ejecuta el closure N veces, haciendo algo entre pasada y pasada Un objeto que hace de delegate del closure (dt, t, frame)

defaults es del segundo tipo, el más barato de implementar y el que más cuidado exige al elegir dónde se consulta el contexto.


3. El recorrido de una animación del DSL

Antes de enganchar nada hay que saber por dónde pasa una llamada. Todas las animaciones siguen exactamente este camino (ejemplo con appear, AppearDSL.groovy:59-88):

script:      appear(obj: s, runtime: 2)
   |
   v
JMAnimBaseScript.appear(Map)          fachada, delega
   |
   v
AppearDSL.make(Map params)
   |  1. params = normalizeKeys(params)         BaseDSL.groovy:32   claves a minúsculas
   |  2. warnUnknownAnimParams("appear", ...)   AbstractAnimDSL:55  avisa de claves raras
   |  3. resolveAnimObjects(params.obj)         resuelve 'obj'
   |  4. double runtime = resolveRuntime(params) BaseDSL.groovy:77  <-- consume 'runtime'
   |  5. anim = Commands.fadeIn(runtime, objs)  construye la animación
   |  6. return finish(anim, params)            AbstractAnimDSL:143
   |
   v
finish(anim, params)
      applyEffects / applyDelay / applyAdvanced / noLogs / lambda
      si hay bloque abierto -> lo recolecta o lo salta
      si no -> play.run(anim)

Los dos únicos sitios por los que pasa el mapa de parámetros son el paso 1-2 (entrada) y el paso 6 (finish). Son los dos candidatos a punto de enganche.


4. Elegir el punto de enganche

La tentación es hacerlo todo en finish(), porque ya es "el sitio único" donde se aplican los parámetros comunes y donde se consulta la pila de bloques (AbstractAnimDSL.groovy:143-168). Tres líneas y listo:

// TRAMPA: parece que basta, y no basta
protected static Animation finish(Animation anim, Map params) {
    params = fillWithDefaults(params, currentDefaults())   // <-- aquí
    applyEffects(anim, params.effects)
    ...
}

No basta, y el motivo es el paso 4 del diagrama: runtime ya se ha consumido cuando finish recibe el mapa. La animación se construyó con Commands.fadeIn(1.0, objs) y el default de 2 segundos llega tarde. Lo mismo pasaría con cualquier clave que el builder lea por su cuenta (type, from, orientation).

El resultado sería el peor tipo de fallo: lambda y effects funcionarían, runtime se ignoraría en silencio, y el usuario no tendría forma de adivinar la regla.

Conclusión: el enganche va en la entrada, no en la salida. Los defaults se mezclan en el mapa antes de que el builder lea nada.

Segunda decisión, derivada de la misma idea: el enganche va en AbstractAnimDSL, no en BaseDSL.normalizeKeys. normalizeKeys lo usan también los constructores de objetos (shape, latex, group), y meterles un runtime: 2 les provocaría un aviso de parámetro desconocido en cada llamada.


5. Implementación paso a paso

Paso 1: la pila de contexto

En AbstractAnimDSL.groovy, junto a la pila BLOCKS que ya existe (AbstractAnimDSL.groovy:74). Se usa ThreadLocal por la misma razón: un script corre en un hilo y un bloque no debe filtrarse a nada más.

/**
 * Los mapas de defaults actualmente abiertos, el más interno primero. Cada
 * marco ya lleva mezclados los de fuera, así que consultar la cima basta.
 */
private static final ThreadLocal<Deque<Map>> DEFAULTS =
        ThreadLocal.withInitial({ new ArrayDeque<Map>() } as Supplier)

/** Los defaults en vigor, un mapa vacío cuando no hay ningún bloque abierto. */
protected static Map currentDefaults() {
    Map top = DEFAULTS.get().peek()
    return (top != null) ? top : Collections.EMPTY_MAP
}

/**
 * Abre un bloque de defaults. Siempre emparejado con {@link #closeDefaults} en
 * un finally: un marco que se quede abierto contamina el resto de la escena.
 *
 * @param params Parámetros del bloque, sin normalizar
 */
protected static void openDefaults(Map params) {
    // El marco nuevo hereda del que lo envuelve, y gana sobre él
    DEFAULTS.get().push(fillWithDefaults(normalizeKeys(params), currentDefaults()))
}

/** Cierra el bloque de defaults más interno. */
protected static void closeDefaults() {
    DEFAULTS.get().pop()
}

Fijarse en que openDefaults fusiona con lo que ya había. Así los bloques anidados acumulan en vez de sustituir, que es lo que se espera de unos valores por defecto:

defaults(runtime: 2, lambda: "smooth") {
    defaults(runtime: 5) {
        appear(obj: a)     // runtime 5, lambda "smooth"
    }
}

Esto es distinto de la regla de play/skip, donde gana el bloque más interno y punto (AbstractAnimDSL.groovy:153). Ahí la regla es "quién decide reproducir", que solo puede ser uno; aquí es "de dónde salen los valores que faltan", que se puede repartir.

Paso 2: la fusión

Una sola función sirve para los dos usos (rellenar una llamada y apilar un marco), porque en ambos la regla es la misma: lo más específico gana.

/**
 * Rellena con 'defaults' los huecos de 'explicit'. Lo escrito en la llamada
 * siempre gana. Los valores que son mapas (effects, advanced) se combinan clave
 * a clave en vez de sustituirse enteros, de modo que un default
 * effects:[alpha: 0.5] y una llamada con effects:[jump: 1] dan las dos cosas.
 *
 * @param explicit Mapa normalizado de la llamada
 * @param defaults Mapa normalizado de defaults en vigor
 * @return Un mapa nuevo; ni explicit ni defaults se modifican
 */
protected static Map fillWithDefaults(Map explicit, Map defaults) {
    if (defaults == null || defaults.isEmpty()) return explicit
    Map result = new LinkedHashMap(explicit)
    defaults.each { k, v ->
        def own = result[k]
        if (own == null) {
            // Copia del submapa: si no, dos llamadas comparten el mismo objeto y
            // la primera que lo modifique afecta a la segunda
            result[k] = (v instanceof Map) ? new LinkedHashMap(v as Map) : v
        } else if (own instanceof Map && v instanceof Map) {
            result[k] = fillWithDefaults(normalizeKeys(own as Map), normalizeKeys(v as Map))
        }
    }
    return result
}

Tres detalles que parecen menores y no lo son:

  • Se devuelve un mapa nuevo. El mapa del marco lo comparten todas las llamadas del bloque; mutarlo desde una de ellas rompería las siguientes.
  • Los submapas se copian. Mismo motivo, un nivel más abajo.
  • La comprobación es own == null, no la verdad de Groovy. Con truthiness, un runtime: 0 o un nologs: false explícitos se considerarían ausentes y el default los pisaría. Es exactamente la trampa recogida en el barrido de julio de 2026 sobre los setters numéricos del DSL.

Paso 3: el punto de entrada único

Ahora hay que hacer que los 21 sitios donde arranca un builder de animación pasen por la fusión. Hoy todos empiezan igual:

params = normalizeKeys(params)
warnUnknownAnimParams("appear", params, KNOWN_KEYS)

Se sustituyen esas dos líneas por una, que hace las tres cosas en el orden correcto:

/**
 * Prepara el mapa de parámetros de un builder de animación: lo normaliza, le
 * aplica los defaults del bloque 'defaults' en vigor y avisa de las claves que
 * el builder no entiende. Sustituye al par normalizeKeys/warnUnknownAnimParams
 * y es el único sitio por el que entran los defaults, que por eso llegan a
 * tiempo para 'runtime' y para las claves propias de cada builder.
 *
 * @param context Nombre del builder, para los mensajes (p.ej. "appear")
 * @param params  Mapa de parámetros tal como lo escribió el usuario
 * @param ownKeys Claves propias del builder, en minúsculas
 * @return El mapa normalizado y completado, listo para leer
 */
protected static Map animParams(String context, Map params, List<String> ownKeys) {
    Map p = normalizeKeys(params)
    Map defs = currentDefaults()
    if (!defs.isEmpty()) {
        // Sólo las claves que ESTE builder entiende: un defaults(type: "fadein")
        // debe llegar a appear y no colarse en animShift, que avisaría de una
        // clave desconocida en cada llamada
        List<String> accepted = ownKeys + COMMON_ANIM_KEYS
        p = fillWithDefaults(p, defs.findAll { k, v -> accepted.contains(k) })
    }
    warnUnknownAnimParams(context, p, ownKeys)
    return p
}

Ese filtrado por ownKeys es lo que hace que el bloque sea utilizable con claves no comunes. defaults(type: "fadein") afecta a appear y disappear, y es invisible para animShift. Sin el filtro habría que restringir defaults a COMMON_ANIM_KEYS y perder la mitad de la gracia.

Cada builder queda así (AppearDSL.groovy:59-61):

static Animation make(Map params) {
    params = animParams("appear", params, KNOWN_KEYS)
    ...

Son 21 llamadas en 17 ficheros (AlignDSL, AnimStateDSL, AnimStyleDSL, AnimationGroupDSL, AppearDSL, DisappearDSL, HighlightDSL, JoinAnimationDSL, MathTransformDSL, MorphDSL, MoveAlongPathDSL, ScalarAnimDSL, SetLayoutDSL, SoundDSL, StackToDSL, TransformAnimDSL con cinco, WaitAnimDSL). Todas tienen la misma forma, así que la migración es mecánica.

Variante de bajo coste, si no se quieren tocar los 17 ficheros: como warnUnknownAnimParams ya la llaman los 21 sitios con el mapa recién normalizado, y los mapas de Groovy son mutables, se pueden inyectar ahí los defaults con params.putAll(...) sobre las claves ausentes. Funciona, pero deja un método llamado "warn" que además modifica su argumento. Merece la pena el rato de sustitución.

Paso 4: el bloque

En BlockDSL.groovy, junto a los demás:

/** Parámetros que documenta {@code defaults}; acepta además las propias de cada builder. */
protected static final List<String> DEFAULTS_KEYS = COMMON_ANIM_KEYS

/**
 * Da valores por defecto a todas las animaciones construidas dentro del bloque.
 * Lo que la llamada escriba gana siempre sobre el default, y los bloques se
 * anidan acumulando: el interno hereda del externo y lo sobrescribe.
 *
 * <pre>
 *   defaults(runtime: 2, lambda: "smooth", effects: [alpha: 0.5]) {
 *       animShift(obj: a, dx: 2)          // runtime 2
 *       animRotate(obj: b, angle: PI, runtime: 0.5)  // runtime 0.5, gana lo escrito
 *       play { appear(obj: c); appear(obj: d) }      // los dos appear, runtime 2
 *   }
 * </pre>
 *
 * Los defaults se aplican a cada animación que se construye, no al contenedor
 * que un play/sequence de dentro fabrique con ellas. Para un grupo con su propio
 * runtime o lambda está animGroup(...).
 *
 * Una clave que no sea común se aplica sólo a los builders que la entienden, así
 * que defaults(type: "fadein") afecta a appear y disappear y no molesta al resto.
 *
 * @param params Parámetros por defecto, los mismos que acepta una animación
 * @param body El bloque de llamadas
 * @return Lo que devuelva el bloque
 */
static Object defaults(Map params, Closure body) {
    requireBody(body, "defaults")
    if (params == null || params.isEmpty()) {
        JMathAnimScene.logger.warn(
                "'defaults': the block sets no default at all, so it changes nothing.")
        return body.call()
    }
    openDefaults(params)
    try {
        return body.call()
    } finally {
        // En un finally: un marco que quede abierto porque el bloque falló
        // seguiría aplicándose al resto de la escena
        closeDefaults()
    }
}

El finally es el mismo salvavidas que en BlockDSL.collect (BlockDSL.groovy:279-283), y por el mismo motivo: el fallo sería silencioso y aparecería lejos del sitio que lo causó.

Paso 5: exponerlo

JMAnimBaseScript.groovy, junto a play/sequence/skip:

/**
 * Da valores por defecto a todas las animaciones escritas dentro del bloque.
 * Lo escrito en cada llamada gana, y los bloques anidados acumulan.
 *
 * <pre>
 *   defaults(runtime: 2, lambda: "smooth") {
 *       animShift(obj: a, dx: 2)
 *       play { appear(obj: c); appear(obj: d) }
 *   }
 * </pre>
 *
 * @param params Parámetros por defecto, los mismos que acepta una animación
 * @param body El bloque de llamadas
 * @return Lo que devuelva el bloque
 */
Object defaults(Map params, Closure body) {
    return BlockDSL.defaults(params, body)
}

Y su espejo en JMA.java (JMA.java:313 para el modelo con harryhausen):

/** Defaults for every animation built inside the block. */
public static Object defaults(Map params, groovy.lang.Closure body) {
    return BlockDSL.defaults(params, body);
}

Sin el espejo, DslConsistencyTest.jmaMirrorsTheScriptFacade falla: comprueba que todo método público de JMAnimBaseScript existe también en JMA.


6. Semántica: las decisiones y su porqué

Un bloque de este tipo tiene una docena de casos frontera. Estas son las respuestas que hacen que el conjunto sea coherente:

Caso Decisión Motivo
appear(runtime: 0.5) dentro de defaults(runtime: 2) Gana 0.5 Un default que pise lo escrito no es un default
defaults dentro de defaults Acumulan, el interno gana clave a clave Es "de dónde salen los valores que faltan", no "quién manda"
effects en el default y en la llamada Se fusionan clave a clave Si no, poner un jump perdería el alpha heredado sin avisar
play { } dentro de defaults Los hijos reciben los defaults; el AnimationGroup que fabrica play, no collect llama a finish(anim, [:]) (BlockDSL.groovy:305) y ese mapa no pasa por animParams. Si pasara, la lambda se aplicaría dos veces, al hijo y al grupo
defaults(run: false) Se permite run es una clave común más; sirve para construir en lote y combinar después
defaults(obj: a) Se permite, obj está en ownKeys de todos Discutible; si molesta, basta con excluir obj en animParams
harryhausen dentro de defaults No le afecta No pasa por animParams, usa warnUnknownParams. Su runtime es la duración del bloque, no la de una animación: heredarlo sería una sorpresa desagradable
defaults dentro de skip Compatible, son ejes distintos skip decide qué se reproduce, defaults con qué parámetros se construye
Bloque sin ninguna clave Aviso y se ejecuta igual Un bloque que no hace nada es casi siempre un error de escritura

Merece la pena escribir la cuarta fila en el javadoc del bloque: es la única que un usuario no puede deducir mirando el resultado.


7. Trampas de Groovy que aparecen en el camino

La firma (Map, Closure) no es negociable. Groovy empaqueta los argumentos con nombre en un Map que pasa como primer parámetro, y el closure escrito detrás del paréntesis va como último. Por eso defaults(runtime: 2) { ... } casa con defaults(Map, Closure) y con nada más. Si se declara defaults(Closure, Map) no compila la llamada, y si se declara solo defaults(Map) el closure se pierde.

El closure va fuera del paréntesis o no es un bloque. defaults(runtime: 2, { ... }) es un mapa con dos entradas raras, no una llamada con bloque. Es la misma trampa del parser que aparece al portar scripts antiguos.

Collections.EMPTY_MAP es inmutable a propósito. Devolverlo desde currentDefaults() garantiza que ningún sitio lo mute por accidente creyendo que apila algo.

ThreadLocal sin remove() está bien aquí. El Deque queda vacío tras cada bloque y el hilo del script es de vida corta; es el mismo tratamiento que recibe BLOCKS.

Cuidado con dar de alta el nombre. defaults no choca con nada del binding (scene, play, camera, config) ni con un método de Script. Para un nombre nuevo, comprobarlo con GroovyShell antes: play sí colisiona y funciona por un detalle de resolución (método frente a variable) que no conviene repetir a ciegas.


8. Cómo se comprueba

No hace falta renderizar nada. Con un script corto se ve todo, porque los builders devuelven la animación:

defaults(runtime: 2, lambda: "smooth", effects: [alpha: 0.5]) {
    def a1 = animShift(obj: a, dx: 2, run: false)
    def a2 = animShift(obj: b, dx: 2, runtime: 0.5, run: false)
    println "${a1.getRunTime()} ${a2.getRunTime()}"   // 2.0 0.5
    defaults(runtime: 5) {
        println animShift(obj: c, dx: 1, run: false).getRunTime()   // 5.0
    }
    println animShift(obj: d, dx: 1, run: false).getRunTime()       // 2.0, el marco se cerró
}
println animShift(obj: e, dx: 1, run: false).getRunTime()           // 1.0, fuera del bloque

Los cuatro casos que hay que ver pasar sí o sí:

  1. El default llega (2.0).
  2. Lo explícito gana (0.5).
  3. El anidamiento acumula y se deshace (5.0 y luego 2.0).
  4. Nada se filtra fuera del bloque (1.0). Repetir provocando una excepción dentro del bloque, para ejercitar el finally.

Y una comprobación de que no se aplica dos veces: defaults(lambda: { t -> t * t }) { play { appear(obj: a); appear(obj: b) } } debe dar un AnimationGroup sin lambda propia y dos hijos con la suya.


9. Checklist de integración

El bloque no está terminado hasta que el editor y la documentación lo conocen. Es la lista de new-dsl-definition-checklist aplicada a este caso:

# Fichero Qué añadir
1 BlockDSL.groovy defaults(Map, Closure) y DEFAULTS_KEYS
2 AbstractAnimDSL.groovy La pila, currentDefaults, fillWithDefaults, animParams
3 Los 17 builders params = animParams(...) en vez del par de líneas
4 JMAnimBaseScript.groovy La fachada
5 JMA.java El espejo estático
6 dsl-completions.yaml Entrada - name: defaults con las claves comunes y su descripción
7 DslConsistencyTest.java BLOCK_CLASSES.put("defaults", "BlockDSL"), si no el test avisa de un bloque del catálogo que no conoce
8 snippets.yaml Snippet kind: groovy en Animations > Containers, junto a "Play together"
9 docs/manual/cheatsheet/main.typ Entrada == defaults(...) con su tabla
10 docs/manual/05_Animations/*.md Mención en prosa, es una función de usuario, no una variante

Sobre el punto 7: acceptedKeysOf reúne todos los campos estáticos que acaban en KEYS de la clase y de sus superclases del paquete, así que BlockDSL acabará aceptando la unión de HARRYHAUSEN_KEYS, DEFAULTS_KEYS y COMMON_ANIM_KEYS. Ser generoso ahí no produce falsos avisos; quedarse corto, sí.


10. Plantilla para el siguiente bloque

Resumen operativo, para cuando toque el bloque número seis:

  1. Decidir el arquetipo (recolector, modificador, repetidor) con la tabla de la sección 2. Cambia qué contexto hay que instalar, no la estructura.
  2. Seguir el recorrido de la sección 3 y marcar dónde se consulta el contexto. Es la única decisión difícil. La pregunta que la resuelve es: ¿qué parámetros se consumen antes de llegar a mi punto de enganche? Si la respuesta no es "ninguno", el enganche está demasiado tarde.
  3. Escribir el contexto como pila ThreadLocal en AbstractAnimDSL, con su openX/closeX.
  4. Escribir el bloque en BlockDSL con requireBody, try y finally.
  5. Definir la regla de anidamiento y escribirla en el javadoc. "Gana el más interno" para lo que solo puede decidir uno; "acumulan" para lo que se reparte.
  6. Comprobar con un script los cuatro casos de la sección 8: llega, se sobrescribe, anida, no se filtra.
  7. Recorrer el checklist de la sección 9.

Y la regla que resume el diseño de defaults, la que conviene tener presente antes de escribir una línea: un bloque que cambia parámetros engancha a la entrada; uno que cambia lo que se reproduce engancha en finish.