El problema de las expresiones regulares
Por qué el resaltado clásico de Vim se equivoca: los lenguajes regulares no pueden contar anidamiento, el motor de sintaxis adivina el contexto al hacer scroll, y cada consumidor reimplementa sus propios patrones. Qué cambia cuando el editor tiene un parser de verdad.
Durante treinta años Vim coloreó código con expresiones regulares, y funcionó lo bastante bien como para que casi nadie preguntase por qué a veces fallaba. La respuesta no es que el motor estuviera mal escrito: es que la tarea que se le pedía era formalmente imposible. Un lenguaje de programación no es un lenguaje regular, y ninguna cantidad de patrones bien afinados convierte un autómata finito en algo que entienda anidamiento. Treesitter no es un resaltador más bonito; es la sustitución de una aproximación por un modelo.
- Situar el resaltado por patrones en la jerarquía de Chomsky y ver por qué fracasa con el anidamiento.
- Reconocer los síntomas concretos: colores que cambian al hacer scroll, cadenas anidadas rotas, motores de regex que se atascan.
- Entender que un
parserproduce un árbol y no una secuencia de tramos coloreados. - Explicar por qué un solo árbol alimenta resaltado, plegado, indentación y objetos de texto.
Un lenguaje regular no puede contar
La jerarquía de Chomsky ordena las gramáticas por poder expresivo. En el escalón más bajo están los lenguajes regulares, exactamente los que reconoce un autómata finito: una máquina con un número fijo de estados y ninguna memoria auxiliar. Ese es el poder de una expresión regular clásica. Un escalón por encima están los lenguajes libres de contexto, que necesitan una pila para reconocerse, y ahí es donde vive prácticamente todo lenguaje de programación.
La diferencia se ve en el caso más simple imaginable: emparejar delimitadores anidados. Para decidir si los paréntesis de una expresión están balanceados hay que recordar cuántos llevas abiertos, y ese contador no tiene cota superior. Un autómata finito tiene, por definición, un número finito de estados: no puede almacenar un número arbitrariamente grande. El lema de bombeo formaliza la intuición: todo lenguaje regular admite, a partir de cierta longitud, repetir un fragmento intermedio indefinidamente sin salirse del lenguaje, y el anidamiento balanceado no tolera eso.
El motor de sintaxis de Vim esquiva el límite con regiones, que simulan a mano una pila poco profunda:
syntax region cComment start="/\*" end="\*/" contains=cTodo
syntax region cString start=+"+ skip=+\\"+ end=+"+ contains=cFormat
syntax match cNumber "\<\d\+\>"
highlight link cComment Comment
Los casos que rompen el esquema no son rebuscados; están en cualquier repositorio actual:
// tres niveles de anidamiento con el mismo delimitador
const q = `select ${tabla.map((t) => `"${t.nombre}"`).join(", ")} from x`;
// el numero de almohadillas no tiene cota: hay que contarlas
let s = r##"contiene "comillas" y una almohadilla # suelta"##;
En ambos, decidir dónde acaba la cadena exige recordar con qué se abrió y cuántos niveles llevas. Es contar, otra vez, y contar es justo lo que un autómata finito no hace.
Puedes ver esa maquinaria entera con :syntax list en cualquier buffer: cientos de reglas, regiones y enlaces escritos a mano, lenguaje por lenguaje, por voluntarios distintos a lo largo de treinta años. No hay ninguna gramática detrás; hay un catálogo de patrones que alguien fue ampliando cada vez que aparecía un caso mal coloreado.
Las palabras clave contains, contained y containedin construyen una jerarquía declarada por el autor del archivo de sintaxis. Es una pila enumerada a mano, no derivada de la gramática: funciona para los tres o cuatro niveles que alguien previó, y se rompe en cuanto el código real anida más de lo previsto. Una plantilla de JavaScript con una interpolación que contiene otra plantilla, una cadena literal cruda de Rust con almohadillas, o una macro de C que abre un bloque sin cerrarlo en la misma línea son los casos donde el castillo se cae.
Colorear es solo la manifestación visible. La misma limitación afecta a foldexpr, a indentexpr y a matchit: todos preguntan lo mismo —cuál es la estructura de este texto— y todos responden con heurísticas independientes. Cuando el resaltado se equivoca, casi siempre la indentación también se equivoca en el mismo punto, porque comparten la misma ceguera estructural.
Adivinar el contexto: sincronización y retroceso
Al límite teórico se suman dos problemas prácticos que explican síntomas que ya has visto sin saber nombrarlos.
El primero es la sincronización. Para colorear la línea que tienes en pantalla, el motor necesita saber en qué estado sintáctico está: dentro de un comentario, dentro de una cadena, en código normal. Ese estado solo se conoce con certeza leyendo desde el principio del archivo, lo que sería carísimo en un fichero de cincuenta mil líneas. Vim lo aproxima:
" retroceder 200 lineas y esperar acertar
syntax sync minlines=200 maxlines=500
" correcto pero potencialmente lentisimo
syntax sync fromstart
minlines es literalmente una apuesta: si el comentario de bloque en el que estás empezó 300 líneas más arriba, el motor no lo verá y coloreará mal. Por eso el código cambia de color cuando haces scroll rápido y vuelve a su sitio al redibujar: no estabas viendo un fallo de dibujado, estabas viendo dos apuestas distintas sobre el mismo texto.
Observa la naturaleza del compromiso: minlines demasiado bajo produce colores incorrectos, y demasiado alto produce lentitud. No existe un valor bueno, solo un punto elegido en una curva de intercambio entre exactitud y latencia. Cualquier sistema que te obligue a ajustar a mano ese equilibrio te está diciendo, sin decirlo, que no conoce la respuesta correcta.
Y no hace falta creerlo por fe: el propio Vim trae un perfilador que mide cuánto tarda cada patrón en redibujar la pantalla, y basta con abrir un archivo grande para ver a un puñado de reglas concentrando casi todo el tiempo.
:syntime on
" desplazate por el archivo unos segundos
:syntime report
El segundo es el retroceso. Vim incorpora dos motores de expresiones regulares, seleccionables con :set regexpengine=0, 1 o 2: el clásico con backtracking y el basado en autómata. El primero puede degradar a tiempo exponencial ante ciertos patrones, y es la causa habitual de que un archivo grande de TypeScript o de JSON deje el editor a rastras. La solución tradicional era desactivar el resaltado, es decir, renunciar a la funcionalidad para recuperar la latencia.
Si al abrir un fichero notas colores que se corrigen solos al desplazarte, cadenas que tiñen medio buffer tras una comilla sin cerrar, o un retardo proporcional al tamaño del archivo, no estás ante un archivo de sintaxis mal escrito. Estás ante los tres fallos estructurales del enfoque: estado desconocido, anidamiento no representable y coste de emparejado sin cota.
Qué aporta un parser de verdad
Un parser generado por tree-sitter no busca patrones: reconoce la gramática. Recorre el texto y construye un árbol sintáctico concreto que representa cada nodo del programa con su posición exacta, desde la raíz hasta el último identificador. Ese cambio de salida —de una secuencia de tramos coloreados a una estructura de datos consultable— es el que arrastra todo lo demás.
Tres propiedades lo hacen viable dentro de un editor. Es incremental: al escribir una tecla reparsea solo la parte afectada, no el archivo entero. Es tolerante a errores: como el código está roto la mayor parte del tiempo mientras lo escribes, la gramática produce nodos ERROR y MISSING localizados en vez de rendirse, y el resto del árbol sigue siendo útil. Y es agnóstico del consumidor: el mismo árbol responde a preguntas distintas mediante consultas declarativas en archivos como highlights.scm, folds.scm, indents.scm y textobjects.scm.
flowchart TD S[Texto del buffer] --> R[Motor de patrones] S --> P[Parser de treesitter] R --> T[Tramos coloreados con estado adivinado] T --> H1[Resaltado aproximado] R --> I1[Indentacion con sus propios patrones] R --> F1[Plegado con otros patrones mas] P --> A[Arbol sintactico concreto] A --> Q[Consultas declarativas] Q --> H2[Resaltado] Q --> F2[Plegado] Q --> I2[Indentacion] Q --> O2[Objetos de texto] style T fill:#f38ba8,color:#11111b style A fill:#a6e3a1,color:#11111b style Q fill:#89b4fa,color:#11111b
Estructura, no apariencia
El parser sabe que un nodo es una llamada a función. El motor de patrones solo sabía que había una palabra seguida de un paréntesis.
Coste proporcional al cambio
Reparsear tras una pulsación cuesta en función de lo editado, no del tamaño del archivo.
Una fuente, muchos consumidores
Resaltado, plegado, indentación y objetos de texto dejan de ser cuatro heurísticas rivales y pasan a ser cuatro vistas del mismo árbol.
La tolerancia a errores merece un párrafo aparte, porque es la propiedad que hace que un parser clásico de compilador no sirviera aquí. Un compilador puede permitirse abortar ante el primer error: su entrada es un archivo terminado. Un editor recibe entradas inválidas en la inmensa mayoría de los instantes, porque cada carácter a medio escribir deja el programa temporalmente roto. Una gramática de tree-sitter está diseñada para que un fragmento irreconocible se encapsule en un nodo ERROR acotado y el resto del árbol siga en pie, de modo que mientras escribes el cuerpo de una función el editor sigue sabiendo dónde está esa función, dónde empieza la siguiente y qué hay que colorear en el resto del archivo.
Y una consecuencia de segundo orden, la más subestimada: al existir una representación común, escribir soporte para un lenguaje nuevo deja de ser reescribir cuatro heurísticas. Con la gramática instalada, el resaltado, el plegado, la indentación y los objetos de texto se obtienen escribiendo consultas cortas contra el mismo árbol. El trabajo por lenguaje pasa de miles de líneas de patrones a unas decenas de patrones declarativos.
Los límites del modelo nuevo
Cambiar de modelo no significa cambiar de bando sin mirar. Conviene delimitar con precisión qué resuelve un parser y qué sigue fuera de su alcance, porque confundirlo genera expectativas que después se leen como fallos.
Un parser sintáctico responde preguntas de forma, no de significado. Sabe que un identificador ocupa la posición de una llamada, pero no sabe a qué función se resuelve, ni de qué tipo es, ni si existe. Todo eso es análisis semántico y vive en el servidor de lenguaje: Treesitter y LSP no compiten, se reparten el trabajo. La regla práctica es simple: si la pregunta se contesta mirando un solo archivo y sin resolver nombres, es del árbol; si exige conocer el proyecto entero, es del servidor.
Hay además costes reales. Cada lenguaje necesita su parser instalado y compilado, con la fricción que eso implica. Una gramática puede ir por detrás de la evolución del lenguaje y producir nodos de error en código perfectamente válido. Y el resaltado por árbol es más literal: donde el motor clásico disimulaba con un patrón amplio, el árbol te muestra la estructura real, que a veces no es la que esperabas.
Neovim no elimina el motor clásico. Si no hay parser para un tipo de archivo, syntax sigue haciendo su trabajo, y ambos pueden coexistir en el mismo buffer con reglas de prioridad. Lo que cambia no es que uno desaparezca, sino cuál es la fuente autorizada de estructura cuando los dos tienen opinión.
Conviene demoler una intuición muy arraigada: que Treesitter es la versión moderna del motor de sintaxis, más rápida y más precisa. No es la misma tarea hecha mejor, es otra tarea. El enfoque clásico intentaba deducir el color directamente del texto, saltándose el paso intermedio; y como el color de un token depende de su papel sintáctico, y ese papel depende de la estructura, el motor estaba obligado a reconstruir localmente, con patrones, una información que solo existe globalmente en un árbol. Ahí está el fallo de raíz: no en el número de estados ni en la calidad de los patrones, sino en pretender calcular una proyección sin calcular antes el objeto proyectado. Un archivo de sintaxis es, visto así, un parser escrito a mano por un humano, sin gramática explícita, sin pila real, sin garantía de terminación y reescrito desde cero para cada lenguaje. Treesitter invierte el orden: primero construye el objeto —el árbol— y después cualquier proyección se obtiene consultándolo. Por eso el salto no se mide en colores más exactos, sino en capacidades que antes eran inalcanzables: seleccionar la función en la que estás, plegar por bloques lógicos, saltar al siguiente parámetro, indentar según el bloque padre. Todas caen gratis del mismo árbol, y ninguna era expresable como expresión regular. Cuando entiendes eso dejas de ver Treesitter como un plugin de estética y empiezas a verlo como lo que es: el momento en que el editor deja de leer texto y empieza a leer código.
- Abre un archivo grande de TypeScript o Rust, ejecuta
:set regexpengine=1y luego:syntax on. Desplázate rápido conCtrl-fy observa la latencia. - Ejecuta
:lua vim.treesitter.stop()en un buffer con código anidado profundo y compara el resaltado con el de:lua vim.treesitter.start(). - Escribe una plantilla de JavaScript con una interpolación que contenga otra plantilla dentro. Mira qué motor colorea bien el nivel interno.
- Coloca el cursor en mitad de un comentario de bloque de más de 300 líneas y usa
:syntax sync minlines=10. Observa el color y prueba después:syntax sync fromstart. - Escribe con tus palabras por qué ese fallo no se arregla con mejores expresiones regulares, usando los términos autómata finito y pila.