Escribir tus propias queries sin tocar el plugin
Extender el resaltado de un lenguaje desde tu configuración: las modelines extends e inherits, la resolución por runtimepath, el papel del directorio after, y el orden de precedencia que decide qué patrón gana cuando dos compiten por el mismo rango.
Llega el momento en que el resaltado de un lenguaje no te basta: quieres marcar tus macros, distinguir tus tipos de dominio o apagar un color que te distrae. La tentación es editar el fichero del plugin. No lo hagas: la próxima actualización lo borrará. Treesitter tiene un mecanismo de extensión pensado exactamente para esto, y entenderlo bien significa entender cómo se compone una query a partir de varios ficheros que no se conocen entre sí.
- Añadir patrones propios sin modificar ningún fichero ajeno.
- Usar las modelines
extendseinheritscon criterio. - Predecir cómo el
runtimepathcompone la query final. - Resolver conflictos entre patrones que cubren el mismo rango.
Extender en vez de sobrescribir
Cuando Neovim necesita la query de resaltado de un lenguaje, recorre todo el runtimepath buscando la ruta queries/LENGUAJE/highlights.scm y recoge todos los ficheros que encuentra. Lo que hace después depende de una única línea: la modeline que abre cada fichero.
Sin modeline, un fichero se declara autosuficiente y reemplaza a los que vengan detrás; como tu configuración suele ir primera en el runtimepath, escribir un highlights.scm sin modeline es la forma más rápida de quedarte sin ningún resaltado, porque tus cuatro patrones sustituyen a los cuatrocientos del plugin. Con la modeline extends, en cambio, el fichero se declara aditivo: sus patrones se suman a los del resto en lugar de anularlos.
;; extends
; marca como constante cualquier identificador enteramente en mayusculas
((identifier) @constant
(#match? @constant "^[A-Z][A-Z_0-9]+$")
(#set! priority 105))
; resalta las funciones de tu dominio con un color propio
((function_call
name: (dot_index_expression
field: (identifier) @function.macro))
(#any-of? @function.macro "assert_eq" "expect" "fixture")
(#set! priority 110))
Neovim lee las primeras líneas del fichero y se detiene en cuanto encuentra una que no empiece por punto y coma. Dentro de ese bloque inicial de comentarios busca extends e inherits, y pueden aparecer varias veces. La consecuencia práctica es la contraria de lo que suele contarse: un comentario de cortesía por encima no estorba, porque también empieza por punto y coma. Lo que sí rompe la modeline es una línea en blanco delante, o cualquier patrón antes de ella: a partir de ahí Neovim deja de mirar y tu fichero pasa a comportarse como sustituto, sin ningún error, dejándote sin resaltado. Ese es el fallo número uno de quien empieza.
Segundo detalle fácil de pasar por alto: la lista de lenguajes de inherits solo admite minúsculas, guiones bajos, comas y paréntesis. Un nombre con dígitos o con guion no se reconoce y la modeline se ignora en silencio.
La segunda modeline, inherits, resuelve un problema distinto: importar en bloque las queries de otro lenguaje. Es lo que permite que un dialecto reutilice todo el trabajo hecho para su lenguaje base sin copiar una sola línea.
;; inherits: c
;; extends
; solo lo que este dialecto anade sobre C
(attribute_declaration) @attribute
Dónde colocar el fichero
El runtimepath se recorre en orden, y ese orden determina qué ficheros contribuyen y en qué secuencia se concatenan. Hay dos ubicaciones sensatas y conviene saber qué compra cada una.
queries en la raíz
~/.config/nvim/queries/LENG/highlights.scm va primero en el runtimepath. Es el lugar natural si quieres decidir tú si extiendes o sustituyes.
after/queries
~/.config/nvim/after/queries/LENG/highlights.scm se procesa al final del recorrido. Garantiza que tus patrones se añaden después de los de todo el mundo, incluidos los de las distribuciones.
flowchart TD
A[Peticion de la query highlights] --> B[Recorrer runtimepath en orden]
B --> C[Config del usuario]
B --> D[Plugins de gramaticas]
B --> E[Runtime de Neovim]
C --> F{Modeline extends}
F -->|no| G[Reemplaza al resto]
F -->|si| H[Se concatena con el resto]
D --> H
E --> H
H --> I[Query final compilada y cacheada]
G --> ILa diferencia práctica entre ambas es más sutil de lo que parece. Un fichero en la raíz te da el poder de sustituir por completo la query de un lenguaje, algo legítimo cuando la del plugin te parece sencillamente equivocada. Un fichero en after renuncia a ese poder a cambio de una garantía: nadie va a colocarse detrás de ti. Si trabajas sobre una distribución que ya trae sus propias queries, after es casi siempre la respuesta correcta, porque te sitúa fuera del terreno que la distribución gestiona y por tanto fuera de sus futuras actualizaciones.
Trata tu capa de queries como código de producción: un fichero por lenguaje, cada patrón precedido de un comentario que explique por qué existe, y ninguna copia de patrones ajenos. Dentro de seis meses no vas a recordar qué te llevó a subir una prioridad a 110, y el comentario es la diferencia entre corregirlo en un minuto o borrarlo entero por miedo.
Si prefieres no tocar el disco, la API permite fijar el texto de una query en tiempo de ejecución. Es útil para probar, para generar patrones a partir de datos o para configuraciones que se distribuyen como un único fichero Lua.
-- La modeline funciona igual dentro de una cadena: con ';; extends',
-- Neovim antepone todo lo que aporta el runtimepath y luego concatena esto.
vim.treesitter.query.set("lua", "highlights", [[
;; extends
((identifier) @constant
(#match? @constant "^[A-Z][A-Z_0-9]+$")
(#set! priority 105))
]])
-- Sin modeline, la cadena SUSTITUYE a todo lo demas, igual que un fichero sin ella
vim.treesitter.query.set("mi_dsl", "highlights", "(identifier) @variable")
-- Que ficheros componen hoy la query, y en que orden
vim.print(vim.treesitter.query.get_files("lua", "highlights"))
El orden de precedencia
Con varios ficheros aportando patrones, es inevitable que dos capturas cubran el mismo rango. La resolución tiene solo dos niveles, y conviene no imaginarse más de los que hay.
El nivel decisivo es la prioridad explícita. El resaltado se aplica con marcas extendidas, y un patrón con #set! priority gana sobre cualquier otro de prioridad inferior, sin importar dónde esté el fichero ni en qué orden se concatenó. La prioridad por defecto de un resaltado de Treesitter es 100; los diagnósticos y otras fuentes de marcas usan valores propios, por lo que subir a 105 o 110 basta para imponerse a los patrones genéricos sin pisar la señalización de errores.
El segundo nivel, y el único que queda, es el orden de aplicación: entre capturas de igual prioridad, la que se aplica después queda encima. Aquí hay una regularidad útil y una trampa. La regularidad: dentro de una misma query, el motor recorre el árbol de fuera hacia dentro, así que la captura de un nodo hijo se aplica después que la de su padre y acaba visible sobre ella. Eso es justo lo que quieres cuando pintas un token concreto dentro de una expresión entera, y explica por qué el resaltado anidado funciona sin que nadie declare nada. La trampa: entre patrones que vienen de ficheros distintos, ese orden depende de cómo se concatenaron ficheros que tú no controlas. Si el resultado te importa, dilo con #set! priority y deja de rezar.
;; extends
; generico: todo identificador es variable, prioridad por defecto 100
(identifier) @variable
; especifico: pero estos son constantes, y lo decimos alto y claro
((identifier) @constant.builtin
(#any-of? @constant.builtin "PI" "INF" "NAN")
(#set! priority 120))
; para APAGAR un color no existe ninguna captura magica: pinta con un grupo
; tuyo y con mas prioridad, y despues vacia ese grupo desde la configuracion
((comment) @comment.anotacion
(#lua-match? @comment.anotacion "^%-%-%-@")
(#set! priority 120))
-- La otra mitad de ese truco, en tu configuracion
vim.api.nvim_set_hl(0, "@comment.anotacion", {})
Hay un patrón de ingeniería que separa a quien parchea de quien compone, y acabas de aprenderlo en su forma más limpia. Modificar el comportamiento de un sistema que no controlas tiene dos caminos. El primero es abrir su código y cambiarlo: funciona hoy, se rompe en la siguiente actualización, no se puede compartir, no se puede versionar por separado y convierte cada upgrade en una negociación. El segundo es que el sistema exponga puntos de composición —aquí, un directorio en un camino de búsqueda y una modeline de tres palabras— para que tu contribución conviva con la ajena sin que ninguna de las dos sepa de la existencia de la otra. Eso es exactamente lo que hacen el runtimepath de Vim, los ficheros .d de la configuración de Unix, los overlays de Nix y los sistemas de plugins que funcionan de verdad. La propiedad valiosa no es la extensibilidad en abstracto: es que tu extensión y la actualización del upstream sean independientes, que puedan evolucionar sin coordinarse y sin conflictos de fusión. Cuando escribes ;; extends en la primera línea de un fichero que tú controlas y que nadie más va a tocar, estás ejerciendo esa independencia. Y a partir de ahora la vas a reconocer, y a echar de menos, en cada sistema que no la ofrezca.
- Crea
~/.config/nvim/after/queries/lua/highlights.scmcon la modelineextendsy un patrón propio. - Deja una línea en blanco por encima de la modeline, reinicia y observa cómo el resaltado del lenguaje se derrumba; quítala y comprueba que un comentario normal por encima no rompe nada.
- Añade dos patrones que compitan por el mismo rango y decide el ganador únicamente con
#set! priority. - Apaga el resaltado de una construcción concreta con un grupo propio de prioridad alta y
nvim_set_hl, sin tocar el fichero del plugin. - Crea un fichero con
inheritspara un dialecto y comprueba con:Inspectque hereda los colores del lenguaje base. - Ejecuta
vim.treesitter.query.get_filesantes y después de tus cambios y explica la diferencia en la lista y en su orden.