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")
offsetylengthson el tramo de texto que se subraya, en coordenadas del documento tal como lo ve el usuario. No hay prescript de por medio.Severity.WARNINGpara una regla que no puede equivocarse razonablemente sobre código correcto. Se pinta en amarillo.Severity.HINTpara 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: miMapaes 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.