Capturas: nombrar nodos y dónde viven las queries
La captura como contrato: el catálogo estándar de nombres de resaltado, la cadena de caídas jerárquica, las capturas privadas con guion bajo, y la anatomía del directorio queries dentro del runtimepath.
Capturar un nodo es fácil; nombrarlo bien es lo que hace que el resto del ecosistema te entienda. Los nombres de captura no son etiquetas arbitrarias: son un contrato público entre quien escribe la query, el motor que la consume y el tema de colores que la pinta. Aprender ese contrato es lo que convierte un patrón que funciona en tu máquina en uno que funciona en cualquier configuración.
- Entender la captura como contrato entre query, motor y tema de colores.
- Manejar el catálogo estándar de nombres de resaltado y su jerarquía.
- Usar capturas privadas y la desactivación explícita del resaltado.
- Localizar y leer los ficheros de query dentro del
runtimepath.
La captura como contrato de nombres
Cuando el resaltador de Neovim ejecuta la query highlights de un lenguaje, no interpreta lo que significa cada captura: se limita a aplicar un grupo de resaltado cuyo nombre es literalmente el de la captura. Una captura llamada @keyword.return provoca la aplicación del grupo @keyword.return. Si ese grupo no está definido por el tema, Neovim recorre hacia arriba la jerarquía separada por puntos hasta encontrar uno que sí lo esté.
flowchart TD A[Captura keyword.return.lua] --> B[Grupo keyword.return.lua] B -->|no definido| C[Grupo keyword.return] C -->|no definido| D[Grupo keyword] D -->|definido por el tema| E[Se aplica el color] B -->|definido| E C -->|definido| E
Esa cadena de caídas explica dos cosas de golpe. Primera: por qué puedes inventar capturas muy específicas sin romper nada, porque siempre degradan a un ancestro razonable. Segunda: por qué el sufijo de lenguaje existe. Neovim añade automáticamente una variante con el nombre del lenguaje al final, de modo que definir el grupo @variable.lua te permite dar un color distinto a las variables de Lua sin tocar las de ningún otro lenguaje.
El resaltado se aplica con marcas extendidas de prioridad 100 por defecto. Cuando dos capturas cubren el mismo rango con la misma prioridad, gana la que se aplica después; para no depender de ese orden implícito, la directiva #set! permite fijar una prioridad explícita, y la veremos en la lección siguiente.
Conviene ser exacto con el sufijo de lenguaje, porque explica un detalle desconcertante: el resaltador no busca el grupo @captura, busca directamente @captura seguida de un punto y el nombre del lenguaje. Es el mecanismo general de caídas de los grupos de resaltado el que después recorta por los puntos hasta encontrar uno definido. Por eso una captura inventada nunca rompe nada y por eso definir @variable.lua funciona sin haber declarado nada más.
Que el grupo de resaltado sea un grupo normal de Vim, sin nada especial, tiene una consecuencia liberadora: puedes definirlo con las mismas herramientas de siempre, enlazarlo a otro o darle atributos propios, sin que el motor de Treesitter intervenga en absoluto.
-- enlazar una captura poco frecuente a un grupo ya existente
vim.api.nvim_set_hl(0, "@constant.macro", { link = "@constant" })
-- o darle atributos propios solo en un lenguaje
vim.api.nvim_set_hl(0, "@variable.parameter.lua", { italic = true })
El catálogo estándar de resaltado
Neovim documenta un conjunto cerrado de nombres canónicos en :help treesitter-highlight-groups. Respetarlo no es burocracia: es la razón de que un tema de colores escrito por un desconocido pinte correctamente un lenguaje que ese desconocido nunca vio.
Identificadores
@variable, @variable.builtin, @variable.parameter, @variable.member, @constant, @constant.builtin, @module, @label, @property.
Literales
@string, @string.escape, @string.regexp, @string.special, @character, @boolean, @number, @number.float.
Tipos y funciones
@type, @type.builtin, @type.definition, @attribute, @constructor, @function, @function.call, @function.method, @function.builtin, @function.macro.
Sintaxis y prosa
@keyword y sus variantes, @operator, @punctuation.bracket, @punctuation.delimiter, @comment con sus notas, y toda la familia @markup para documentos.
Tres reglas de nombrado tienen semántica real y conviene conocerlas desde el principio. La primera: toda captura cuyo nombre empiece por guion bajo —por convención @_algo— es privada. El resaltador comprueba literalmente si el nombre empieza por _ y, si es así, no busca ningún grupo: la captura existe para el motor de queries pero no pinta nada, de modo que puedes usarla como variable auxiliar dentro de un predicado sin ensuciar el aspecto del búfer.
La segunda y la tercera son @spell y @nospell, que no tienen que ver con el color sino con el corrector: marcan qué regiones revisa el corrector ortográfico integrado, y @nospell gana siempre sobre @spell porque se aplica con una prioridad un punto mayor. Es lo que permite corregir la prosa de los comentarios sin que el corrector se queje de cada identificador.
; la captura privada solo sirve para el predicado
((function_call
name: (identifier) @_nombre
arguments: (arguments (string) @string.special))
(#eq? @_nombre "require"))
; un mismo nodo admite varias capturas: color y correccion ortografica
(comment) @comment @spell
Circula por ahí la idea de que capturar algo como @none desactiva su color. En Neovim no es así: el resaltador no trata ese nombre de forma especial, se limita a buscar el grupo @none como buscaría cualquier otro, y un grupo sin atributos no borra lo que hayan pintado otros patrones. Si quieres apagar un resaltado, tienes dos vías de verdad: vaciar el grupo desde tu configuración con vim.api.nvim_set_hl(0, "@grupo", {}), o escribir un patrón propio con una prioridad mayor que capture a un grupo que tú controlas.
Dónde viven los ficheros de query
Las queries no son código: son datos que Neovim busca en disco siguiendo una convención estricta. Cualquier directorio del runtimepath puede aportar ficheros con la ruta queries/LENGUAJE/TIPO.scm, donde el tipo determina qué motor los consumirá.
~/.config/nvim/
└── queries/
├── lua/
│ ├── highlights.scm ; resaltado sintactico
│ ├── injections.scm ; lenguajes anidados dentro del codigo
│ ├── locals.scm ; ambitos, definiciones y referencias
│ └── folds.scm ; rangos plegables
└── rust/
└── highlights.scm
Tres orígenes aportan queries a la vez, y los tres se resuelven por el mismo mecanismo. Neovim empaqueta las suyas en $VIMRUNTIME/queries para un puñado de lenguajes esenciales —los que necesita para funcionar y documentarse a sí mismo—; los plugins de gramáticas aportan las de todos los demás lenguajes; y tu configuración, que suele ir primera en el runtimepath, tiene la última palabra.
Conviene interiorizar la separación que hay detrás de esa estructura de directorios: el parser de un lenguaje es una biblioteca binaria compilada a partir de la gramática, mientras que las queries son texto plano interpretado en tiempo de ejecución. Son artefactos independientes, con ciclos de vida independientes, y esa independencia es justo lo que te permite reescribir el resaltado de un lenguaje sin recompilar absolutamente nada. También explica el fallo más desconcertante del ecosistema: si actualizas la gramática y no las queries, tus patrones pueden referirse a nodos que han dejado de existir.
Para inspeccionar qué está viendo el motor en un momento dado, la API te lo dice sin ambigüedad.
-- que ficheros aportan la query de resaltado de Lua
vim.print(vim.treesitter.query.get_files("lua", "highlights"))
-- la query ya combinada y compilada que se esta usando
local q = vim.treesitter.query.get("lua", "highlights")
vim.print(q.captures)
Otros tipos de query, otros contratos
El fichero highlights.scm es solo el más visible. Cada tipo de query tiene su propio vocabulario de capturas obligatorias, y ese vocabulario es tan contractual como el del resaltado. En locals.scm, las capturas @local.scope, @local.definition y @local.reference construyen el grafo de ámbitos que permite a Neovim distinguir dos variables homónimas en funciones distintas. En injections.scm, @injection.content marca la región y @injection.language indica en qué lenguaje debe reparsearse, lo que hace posible que una consulta SQL dentro de una cadena de Python se resalte como SQL. En folds.scm, la única captura relevante es @fold.
; locals.scm: cada funcion abre un ambito
(function_definition) @local.scope
(parameters (identifier) @local.definition.parameter)
(identifier) @local.reference
; injections.scm: una cadena que en realidad es codigo de otro lenguaje
((function_call
name: (identifier) @_fn
arguments: (arguments (string_content) @injection.content))
(#eq? @_fn "vim.cmd")
(#set! injection.language "vim"))
La lección estructural es que ninguno de estos motores comparte vocabulario con los otros: el resaltador no sabe qué es @local.scope y el motor de ámbitos no sabe qué es @keyword. Cada tipo de fichero define su propio conjunto de nombres significativos y descarta los demás. Por eso una captura mal nombrada no produce un error, sino algo mucho peor de diagnosticar: silencio absoluto. El motor recibe una captura que no reconoce, se encoge de hombros y sigue.
Hay una lección de ingeniería enterrada en algo tan mundano como una convención de nombres, y merece la pena desenterrarla. El ecosistema de Treesitter en Neovim funciona porque nadie coordina nada: quien escribe la gramática de un lenguaje nuevo, quien escribe sus queries, quien escribe el tema de colores y quien escribe el motor no se conocen entre sí, no comparten calendario y probablemente ni siquiera comparten idioma. Lo único que comparten es un vocabulario de nombres de captura con una jerarquía de caídas bien definida. Esa es toda la coordinación que existe, y es suficiente. Un tema publicado hace tres años pinta correctamente un lenguaje que se inventó ayer, porque el autor del lenguaje llamó @function.call a lo que era una llamada a función. Cuando comprendes esto dejas de ver el catálogo estándar como una lista que hay que memorizar y empiezas a verlo como lo que es: un protocolo, tan real como HTTP, negociado en el espacio de los nombres en vez de en el de los bytes. Y a partir de ahí, cada vez que inventes una captura tomas una decisión de diseño de interfaz pública: o encajas en el protocolo, o te quedas fuera y nadie te entiende.
- Ejecuta
vim.treesitter.query.get_filespara tres lenguajes que uses a diario y anota cuántos orígenes aportan queries a cada uno. - Con el cursor sobre distintos elementos, ejecuta
:Inspecty anota qué captura y qué grupo final se están aplicando. - Define en tu configuración un grupo con sufijo de lenguaje, por ejemplo
@variable.lua, y comprueba que solo afecta a Lua. - Escribe una query que use una captura privada
@_algoy verifica con:Inspectque no produce ningún color. - Vacía un grupo con
nvim_set_hlpasándole una tabla sin atributos y comprueba que eso sí apaga el resaltado de esa captura. - Localiza el
folds.scmde algún lenguaje y explica qué construcciones decide plegar y cuáles deja fuera.