wandres.dev
HIGHLIGHTS · el sistema de color

Treesitter frente a syntax: dos motores, un lienzo

Los grupos clásicos del motor de expresiones regulares conviven con las capturas del árbol sintáctico y con los semantic tokens del LSP. Cómo se nombran, cómo heredan y qué tabla de prioridades decide quién pinta encima.

⏱ 16 min

En un Neovim moderno hay al menos tres sistemas coloreando el mismo carácter a la vez, y ninguno sabe de la existencia de los otros. El motor syntax clásico reconoce patrones con expresiones regulares, Treesitter recorre un árbol sintáctico real y el servidor de lenguaje aporta información que solo se puede saber tras resolver símbolos. No compiten por exclusión: se apilan, y una tabla de prioridades decide qué se ve.

🎯 Al terminar esta lección sabrás
  • Contrastar el modelo de syntax con el de las capturas de Treesitter.
  • Leer un nombre de captura y deducir su cadena de herencia.
  • Entender la tabla de prioridades y por qué existe.
  • Situar los semantic tokens del LSP como tercera capa.

Dos modelos de reconocimiento

El motor clásico se declara en archivos de syntax y funciona por coincidencia léxica: define regiones y palabras clave con expresiones regulares y les asigna un grupo con nombre prefijado por el lenguaje, del estilo luaFunction o cType. Ese grupo casi nunca lleva color propio; el archivo termina enlazándolo a una raíz canónica con hi link, que es el patrón que estudiaste en la lección anterior.

Su límite es estructural, no de calidad: una expresión regular no sabe anidar. No puede distinguir una variable de un campo, ni saber si un identificador está siendo declarado o usado, porque esas preguntas requieren una gramática, y una gramática requiere un árbol.

Treesitter construye ese árbol de verdad, incremental y actualizado con cada pulsación. Sobre él se ejecutan queries de resaltado, escritas en un dialecto de S-expressions, que seleccionan nodos y les asignan una captura:

; queries/lua/highlights.scm, fragmento ilustrativo
(function_declaration
  name: (identifier) @function)

((identifier) @constant
  (#match? @constant "^[A-Z][A-Z_0-9]*$"))

El nombre de la captura empieza siempre por arroba y describe el papel sintáctico del nodo, no su apariencia. Esa es la diferencia conceptual con el modelo clásico: luaFunction nombra un patrón textual de un lenguaje concreto, mientras que @function nombra un rol universal que existe en cualquier gramática.

De esa diferencia se siguen tres consecuencias prácticas. La primera es que las capturas son compartidas: definir @function afecta a todos los lenguajes a la vez, mientras que el modelo clásico obligaba a enumerar un grupo por lenguaje. La segunda es que Treesitter puede resaltar correctamente construcciones anidadas y recursivas, porque el árbol conoce la profundidad; una expresión regular, por definición, no. La tercera es que Treesitter distingue posiciones dentro de una construcción, y por eso puede colorear distinto el nombre de un campo cuando se declara y cuando se lee, algo que léxicamente son el mismo texto.

Conviene resistir una conclusión precipitada: el motor clásico no ha quedado obsoleto. Sigue siendo el único resaltador para los formatos sin parser, arranca sin coste de compilación de gramáticas y funciona en archivos que Treesitter descarta por tamaño. Ambos motores coexisten activos, y lo que decide qué ves no es cuál está encendido, sino la tabla de la que trata el apartado siguiente.

Capturas jerárquicas por puntos

Las capturas usan el punto como separador de especificidad creciente: @keyword, @keyword.return, @keyword.import. Neovim aprovecha esa forma para construir una cadena de respaldo automática: si el grupo más específico no tiene definición, se prueba con el prefijo inmediatamente anterior, y así hasta la raíz.

El resultado es que un tema que solo defina @keyword colorea correctamente las diez variantes que existan, y un tema que quiera afinar puede darle a @keyword.return un matiz propio sin tocar nada más. La granularidad es opcional en lugar de obligatoria, que es justo lo contrario de lo que ocurría con los grupos por lenguaje.

Hay una segunda dimensión, la del lenguaje. Puedes sufijar una captura con el nombre del parser para afectar solo a ese lenguaje, y Neovim consulta primero la versión específica:

-- solo los comentarios de Markdown, sin tocar los del resto
vim.api.nvim_set_hl(0, "@comment.markdown", { fg = "#7f849c", italic = false })

Y, cerrando el círculo con el sistema antiguo, Neovim declara enlaces por defecto que conectan las capturas estándar con las raíces canónicas: @function acaba en Function, @variable en Identifier, @comment en Comment. Por eso un tema anterior a Treesitter sigue funcionando: nunca dejó de pintar los nodos donde el grafo desemboca.

flowchart TD
A[Captura mas especifica: keyword punto return] --> B[Existe version por lenguaje]
B -->|si| C[Se usa la variante del parser]
B -->|no| D[Se prueba el prefijo: keyword]
D --> E[Enlace por defecto hacia la raiz canonica]
E --> F[Atributos definidos por el tema]
C --> F
style D fill:#f9e2af,color:#11111b
style F fill:#a6e3a1,color:#11111b

La consecuencia de diseño es que existen tres niveles de compromiso para quien escribe un tema. Puede quedarse en las raíces canónicas y obtener un resultado correcto con catorce definiciones; puede bajar a las capturas genéricas y ganar distinciones que el motor clásico jamás ofreció; o puede descender a las variantes por lenguaje para casos muy concretos. Cada escalón multiplica el trabajo de mantenimiento, y solo el primero está garantizado contra la aparición de parsers nuevos.

Prioridades: quién pinta encima

Cuando varias capas cubren el mismo byte, no gana la última en ejecutarse sino la de mayor prioridad. Neovim publica esos valores en una tabla consultable:

vim.print(vim.hl.priorities)
-- syntax = 50, treesitter = 100, semantic_tokens = 125,
-- diagnostics = 150, user = 200

La lectura de esa tabla es una declaración de principios sobre la fiabilidad de cada fuente. El motor de expresiones regulares es el más barato y el menos informado, así que queda debajo. Treesitter conoce la estructura y lo desplaza. El servidor de lenguaje conoce además los tipos y los símbolos resueltos, y se impone a Treesitter. Los diagnósticos van por encima de todo lo semántico porque comunican un problema, y el problema debe verse aunque tape el color bonito. Y por encima queda un escalón reservado a lo que tú marques deliberadamente.

🔤

Motor syntax

Prioridad 50. Coincidencia léxica por expresiones regulares, sin noción de estructura. Sigue siendo el único resaltador disponible para formatos sin parser, y por eso no ha desaparecido.

🌳

Treesitter

Prioridad 100. Árbol sintáctico incremental y queries declarativas. Reconoce anidamiento, lenguajes inyectados y roles gramaticales que ninguna expresión regular puede expresar.

🧠

Semantic tokens

Prioridad 125. Los envía el servidor de lenguaje tras resolver símbolos, así que distingue lo que solo se sabe con tabla de símbolos: si un nombre es un tipo, un parámetro o algo obsoleto.

🚨

Diagnósticos

Prioridad 150. Subrayados de error y aviso. Se colocan por encima a propósito, porque un fallo debe ser visible aunque compita con el color semántico del token que lo contiene.

Dentro de una misma capa hay una regla adicional: en un archivo de queries, el patrón que coincide más tarde gana, y una directiva puede fijar una prioridad explícita para un patrón concreto. Es el mecanismo con el que un parser corrige casos particulares sin reescribir sus reglas generales.

; forzar que este patron se imponga sobre otros de la misma capa
((identifier) @variable.builtin
  (#eq? @variable.builtin "self")
  (#set! priority 105))
💡
Sube de capa antes de pelear con la tuya

Si un color tuyo no se ve, el reflejo habitual es retocar el grupo una y otra vez. Casi siempre es más rápido comprobar primero qué capa lo está tapando: si tu ajuste vive en la 100 y encima hay algo en la 125, ninguna cantidad de retoques en la 100 lo hará visible. Diagnostica la pila antes de tocar el color.

La tercera capa: semantic tokens

Los semantic tokens llegan por el protocolo del servidor de lenguaje y se traducen a grupos con el prefijo @lsp. Los hay de tipo, como @lsp.type.parameter, de modificador, como @lsp.mod.readonly, y combinados, como @lsp.typemod.variable.readonly, que solo aplica cuando ambas condiciones coinciden.

Su valor está en lo que Treesitter no puede saber. El árbol ve un identificador; el servidor sabe si ese identificador designa un tipo importado, un parámetro, un campo heredado o algo marcado como obsoleto. La contrapartida es que llegan tarde, de forma asíncrona, y por eso a veces se percibe un cambio de color unos milisegundos después de abrir el archivo: no es un fallo, es la capa 125 aterrizando sobre la 100.

Si el efecto te molesta, la solución no es desactivar la capa entera sino neutralizar los grupos que la producen, dejándolos sin atributos para que el color de Treesitter vuelva a asomar por debajo:

-- silenciar un token semántico concreto sin apagar el resto
vim.api.nvim_set_hl(0, "@lsp.type.variable", {})

La operación inversa también es útil. Hay distinciones que solo el servidor puede aportar y que merecen atención visual precisamente porque son caras de calcular: marcar en cursiva lo declarado como obsoleto, o atenuar lo que es de solo lectura, aprovecha información que ningún árbol sintáctico contiene.

Conviene además saber que las tres capas no cubren siempre el mismo territorio. En un archivo sin parser instalado solo actúa la capa de prioridad 50; en un archivo con parser pero sin servidor de lenguaje, las capas 50 y 100; y dentro de bloques de código incrustados en Markdown o en plantillas, Treesitter puede estar aplicando varios parsers anidados mediante inyecciones, cada uno con sus propias capturas. Cuando el color parece incoherente entre dos zonas de un mismo archivo, la explicación casi siempre es que no las está resaltando el mismo conjunto de capas.

El color como consenso entre analizadores desiguales

Lo que tienes delante no es un resaltador de sintaxis, es un sistema de fusión de evidencia. Tres analizadores con capacidades incomparables opinan sobre el mismo byte, y en lugar de elegir uno, Neovim los ordena por cuánto contexto necesitaron para emitir su juicio: cuanto más caro es saber algo, más arriba se pinta. Esa ordenación no es una convención arbitraria sino una jerarquía epistémica, y por eso resuelve conflictos que nunca podrán enumerarse por adelantado, incluidos los que introduzca un servidor de lenguaje que aún no existe. La consecuencia práctica es incómoda para el purista: perseguir que todo se vea siempre igual es luchar contra el diseño. Un archivo recién abierto se colorea con evidencia barata y se refina cuando llega la cara, exactamente igual que un compilador refina un análisis en pasadas sucesivas. Aceptar esa inestabilidad transitoria, en vez de apagar capas para eliminarla, es lo que separa configurar el color de entenderlo.

⚔️ Desmontar la pila de capas
  1. Sobre un parámetro de función, ejecuta Inspect y anota las tres capas: captura de Treesitter, semantic token y grupo clásico si aparece.
  2. Imprime vim.hl.priorities y comprueba en qué orden explican lo que estás viendo.
  3. Da un color muy visible a la captura de Treesitter y observa si se aprecia o queda tapada por la capa superior.
  4. Neutraliza el grupo con prefijo @lsp correspondiente y comprueba que ahora sí asoma el color de Treesitter.
  5. Repite el ejercicio en un formato sin parser instalado y explica por qué solo ves ahí la capa de prioridad 50.