Reglas de comprobación de scripts: cómo escribir la tuya

El editor lee el script mientras se escribe y marca un puñado de errores concretos que, en tiempo de ejecución, fallan de una forma que el usuario no sabe interpretar. Este documento explica cómo está montado y cómo añadir, afinar o quitar una regla.

Los tres ficheros implicados:

jmathanim-gui/src/main/java/com/jmathanimgui/dsl/ScriptChecker.java      las reglas
jmathanim-gui/src/main/java/com/jmathanimgui/editor/ScriptCheckParser.java  las pinta
jmathanim-gui/src/main/java/com/jmathanimgui/dsl/DslEditing.java          lee el texto

1. El recorrido completo

EditorTab
   └─ editor.addParser(new ScriptCheckParser())
         └─ RSyntaxTextArea llama a parse() tras una pausa al escribir
               └─ ScriptChecker.check(texto)  ->  List<Problem>
                     └─ cada Problem se convierte en un ParserNotice
                           ├─ subrayado ondulado sobre el texto exacto
                           ├─ el mensaje como tooltip
                           └─ una marca en el ErrorStrip, junto a la barra de scroll

Nada de esto ejecuta el script. Se lee el texto de la pestaña activa, una vez, en el hilo de eventos. No toca el camino por frame ni el renderizador.

ScriptChecker no depende de Swing a propósito: es texto que entra y una lista de problemas que sale, así que se puede probar sin abrir la interfaz.

2. Qué es una regla

Un método privado que recibe el texto y añade Problems a una lista. Un Problem son cuatro cosas:

new Problem(offset, length, Severity.WARNING, "qué escribir en su lugar")
  • offset y length son el tramo de texto que se subraya, en coordenadas del documento tal como lo ve el usuario. No hay prescript de por medio.
  • Severity.WARNING para una regla que no puede equivocarse razonablemente sobre código correcto. Se pinta en amarillo.
  • Severity.HINT para una heurística, que puede fallar de vez en cuando. Se pinta en azul.

El registro de reglas es el cuerpo de check(String text):

public static List<Problem> check(String text) {
    List<Problem> problems = new ArrayList<>();
    if (text == null || text.isEmpty()) {
        return problems;
    }

    String masked = maskLiterals(text);
    List<DslEditing.CallInfo> calls = DslEditing.findAllCalls(text);
    checkLateConfig(masked, calls, problems);
    checkAlphaWithoutColor(text, calls, problems);
    checkDegreeAngles(text, calls, problems);
    checkSplitCoordinateWrites(masked, problems);

    problems.sort(Comparator.comparingInt(p -> p.offset));
    return problems;
}

Añadir una regla es añadir una línea aquí y el método correspondiente. Quitarla es borrar su línea: nada más depende de ella.

calls y masked se calculan una sola vez y se reparten, así que una regla más no vuelve a recorrer el texto entero.

3. Las herramientas disponibles

Todo el análisis de texto vive en DslEditing, que ya sabe saltarse cadenas y comentarios. No escribas tu propio recorrido del texto: acabarás marcando algo que estaba dentro de un comentario.

Necesitas Llamas a
Todas las llamadas nombre(...) del script, anidadas incluidas DslEditing.findAllCalls(text)
Los parámetros de una llamada DslEditing.parseEntriesWithSpans(text, call.openParen + 1, call.closeParen)
Los de un mapa anidado clave: [...] DslEditing.parseEntriesWithSpans(text, entry.valueStart + 1, entry.valueEnd - 1)
Saber si un valor es un mapa literal DslEditing.isMapLiteral(entry.value)
Saltar una cadena o un comentario DslEditing.skipStringOrComment(text, i)
Buscar con expresión regular sin caer dentro de una cadena usa el texto de maskLiterals(text)
Saber si una llamada crea un objeto en la escena createsObject(nombre)
El offset donde empieza la clave de una entrada keyStart(text, entry)
El valor como número, y solo si está escrito como tal plainNumber(entry.value)

CallInfo trae name, nameStart, openParen y closeParen. Entry trae key (puede ser null en un argumento posicional), value, colonPos, valueStart, valueEnd y segEnd.

maskLiterals devuelve una copia del texto con las cadenas y los comentarios convertidos en espacios, conservando los offsets y los saltos de línea. Lo que casa en esa copia está en la misma posición en el original.

4. Ejemplo completo: stack sin destino

StackDSL exige exactamente uno de to, screen o point, y lanza una excepción si no. Adelantar ese aviso al editor evita la ejecución fallida.

Paso 1. El dato que la regla necesita, junto al resto de constantes:

/** Claves de un stack:[...], de las que tiene que haber exactamente una. */
private static final List<String> STACK_DESTINATIONS = List.of("to", "screen", "point");

Paso 2. La regla:

/**
 * 'stack' coloca un objeto contra un destino, y acepta exactamente uno:
 * otro objeto ('to'), una posición de pantalla ('screen') o un punto fijo
 * ('point'). Sin destino, o con dos, StackDSL lanza una excepción.
 */
private static void checkStackDestination(String text, List<DslEditing.CallInfo> calls, List<Problem> out) {
    for (DslEditing.CallInfo call : calls) {
        for (DslEditing.Entry entry : DslEditing.parseEntriesWithSpans(
                text, call.openParen + 1, call.closeParen)) {
            if (entry.key == null || !"stack".equalsIgnoreCase(entry.key)
                    || !DslEditing.isMapLiteral(entry.value)) {
                continue;
            }
            int destinations = 0;
            for (DslEditing.Entry inner : DslEditing.parseEntriesWithSpans(
                    text, entry.valueStart + 1, entry.valueEnd - 1)) {
                if (inner.key != null
                        && STACK_DESTINATIONS.contains(inner.key.toLowerCase(Locale.ROOT))) {
                    destinations++;
                }
            }
            if (destinations != 1) {
                out.add(new Problem(entry.valueStart, entry.valueEnd - entry.valueStart,
                        Severity.WARNING,
                        destinations == 0
                                ? "'stack' needs a destination: exactly one of 'to', 'screen' or 'point'."
                                : "'stack' takes only one destination: leave just one of"
                                        + " 'to', 'screen' or 'point'."));
            }
        }
    }
}

Paso 3. Registrarla en check:

    checkSplitCoordinateWrites(masked, problems);
    checkStackDestination(text, calls, problems);

Paso 4. Compilar y probar:

mvn -q -pl jmathanim-core,jmathanim-gui -am compile

Y en el editor, escribir el error y esperar la pausa:

shape(type: "square", stack: [to: otro, screen: "upper"])

Fíjate en lo que la regla no marca, que es tan importante como lo que marca: si el valor de stack no es un mapa literal (stack: MI_MAPA), no se sabe qué hay dentro y no se dice nada. Ante la duda, callar.

5. Cómo elegir la severidad y redactar el mensaje

Tres normas, y las tres vienen del público al que va dirigido esto: gente que enseña matemáticas, no gente que programa.

Un aviso equivocado cuesta más que un aviso ausente. Enseña a ignorar todos los demás. Si la regla puede saltar sobre código correcto, es HINT, no WARNING. Si no estás seguro de que sea correcta ni siquiera como pista, no la añadas.

El mensaje dice qué escribir, no qué está mal. Compara:

Mal Bien
"Modificación ineficiente de coordenadas" "Write the coordinates in one go: p.copyCoordinatesFrom(x, y)"
"Parámetro alpha inválido" "'drawAlpha' needs a colour beside it. Add 'drawColor' before it"
"config fuera de orden" "Move every 'config' call to the top of the script"

Los mensajes van en inglés, como el resto de la interfaz.

Nunca bloquean. F5 ejecuta siempre. Una comprobación es una sugerencia, no un permiso.

6. Afinar o desactivar lo que ya hay

Las cuatro reglas actuales se ajustan desde sus constantes, sin tocar la lógica:

Constante Qué controla
NOT_OBJECT_CREATING Bloques que no crean objeto, y por tanto no cierran la ventana de config
CONFIG_WRITE Qué cuenta como escribir en la configuración por la vía antigua config.xxx
DRAW_COLOR_KEYS, FILL_COLOR_KEYS Claves que dan valor a un color, y hacen segura a su alpha
ANGLE_KEYS Claves cuyo valor es un ángulo
SUSPICIOUS_ANGLE A partir de cuántos radianes se sospecha que son grados. Súbelo si molesta
COORDINATE_WRITE Qué se reconoce como escribir una coordenada
MAX_MAP_DEPTH Hasta qué profundidad se entra en mapas anidados

Qué llamadas crean un objeto no está escrito aquí: sale de transform-blocks y styling-blocks de jmathanim-core/src/main/resources/autocomplete/dsl-editing.yaml, menos NOT_OBJECT_CREATING. Un bloque nuevo del DSL entra solo en cuanto se declara allí. Un bloque que el core no clasifique se toma como que no crea nada, que es el lado seguro: se pierde una detección en vez de inventarse una.

Para desactivar una regla, borra su línea de check.

Para desactivar todas sin borrar nada, EditorTab tiene la bandera que las enciende, y que hoy está a false:

/** Script checks, off for now. See docs/dev/REGLAS_DE_COMPROBACION.md */
private static final boolean SCRIPT_CHECKS_ENABLED = false;

Con ella apagada no se registra el parser ni se añade la franja lateral, y ScriptChecker queda como código que compila y nadie llama. Ponerla a true lo devuelve todo.

7. Qué puede ver una regla y qué no

Esto lee texto, no un árbol sintáctico de Groovy. En concreto:

  • No conoce los tipos ni el valor de las variables. style: miMapa es opaco. Toda regla se escribe sobre lo que está literalmente escrito.
  • No sigue las llamadas. Si el error está dentro de un método de una clase propia, se ve el texto, pero no se sabe desde dónde se llama.
  • No ve los ficheros incluidos con import("otro.groovy"): cada pestaña se comprueba por su cuenta, con su propio parser.
  • No ve el prescript, que es justamente lo que hace que los offsets coincidan con lo que el usuario tiene delante.

Si una regla lanza una excepción, ScriptCheckParser la captura y la registra con JMathAnimScene.logger.warn. El editor sigue funcionando y las demás reglas también, pero conviene no depender de eso.

8. Si quisieras editar reglas sin recompilar

Hoy una regla es código Java y hace falta compilar. Las cuatro actuales necesitan lógica (contar claves, comparar posiciones, agrupar coincidencias), así que no saldrían de un fichero de configuración.

Buena parte de las reglas imaginables, en cambio, son del tipo "en el bloque X, la clave A necesita la clave B" o "la clave A no admite un número a secas". Esas sí caben en un YAML declarativo, en autocomplete/ junto al resto de las definiciones, leído por CoreDefinitions como los demás. Sería el camino si el número de reglas crece: el motor se queda con las cuatro que necesitan código, y la lista larga vive en datos.