wandres.dev
TREESITTER: QUERIES · capturas y predicados

Predicados y directivas: filtrar y modificar

Los dos operadores que el motor de queries añade sobre el patrón puro: predicados que descartan coincidencias por el contenido del texto y directivas que adjuntan metadatos a la coincidencia superviviente, más cómo registrar los tuyos desde Lua.

⏱ 17 min

El patrón puro solo sabe de forma. Pero muchas decisiones reales dependen del contenido: una constante y una variable local son el mismo nodo identifier y solo las distingue su ortografía. Los predicados devuelven al motor la capacidad de mirar el texto, y las directivas le permiten anotar la coincidencia con información que el consumidor final usará. Interrogación y anotación: dos verbos, una sintaxis mínima.

🎯 Al terminar esta lección sabrás
  • Distinguir un predicado, terminado en interrogación, de una directiva, terminada en admiración.
  • Filtrar coincidencias con #eq?, #match?, #any-of? y sus negaciones.
  • Anotar coincidencias con #set!, #offset!, #gsub! y #trim!.
  • Registrar predicados y directivas propios desde Lua.

Predicados: filtrar por contenido

Un predicado es una condición que se evalúa después de que el patrón haya encontrado una coincidencia candidata. Si devuelve falso, la coincidencia se descarta entera. Sintácticamente, el patrón se envuelve en un par extra de paréntesis y el predicado se escribe dentro, empezando por almohadilla y terminando en interrogación.

; un identificador en mayusculas es una constante
((identifier) @constant
  (#match? @constant "^[A-Z][A-Z_0-9]*$"))

; comparar una captura con una cadena literal
((function_call
  name: (identifier) @_fn
  arguments: (arguments (string) @string.special.path))
  (#eq? @_fn "require"))

; comparar dos capturas entre si
((tag_name) @_apertura
  (end_tag_name) @_cierre
  (#eq? @_apertura @_cierre))

El orden importa conceptualmente: primero la forma, después el contenido. Es una separación de responsabilidades con consecuencias prácticas, porque un patrón demasiado laxo obliga al motor a evaluar predicados sobre miles de candidatos. Un query eficiente es siempre uno cuyo patrón discrimina lo máximo posible antes de que el predicado tenga que abrir la boca.

flowchart LR
A[Patron estructural] --> B[Coincidencias candidatas]
B --> C{Predicados}
C -->|falso| D[Descartada]
C -->|verdadero| E[Directivas]
E --> F[Coincidencia con metadatos]
F --> G[Resaltador o consumidor Lua]

El catálogo de predicados

🟰

Igualdad

#eq? compara el texto de una captura con una cadena o con otra captura. #not-eq? invierte el resultado.

🔍

Coincidencia de patrón

#match? evalúa una expresión regular de Vim sobre el texto capturado; #lua-match? hace lo propio con un patrón de Lua. Ambos tienen su variante not-.

📋

Pertenencia

#any-of? acepta una lista de cadenas y comprueba si el texto es alguna de ellas. Es el sustituto legible y rápido de una alternancia larga de #eq?.

🔤

Subcadena

#contains? acepta una o varias cadenas y acierta si alguna aparece dentro del texto capturado, sin anclarse a los extremos.

🌳

Contexto estructural

#has-ancestor? y #has-parent? reciben tipos de nodo y comprueban los ancestros de la captura, algo que el patrón por sí solo no puede expresar hacia arriba.

Dos prefijos modulan su comportamiento, y conviene no confundir su alcance. El prefijo not- está implementado de forma genérica: cualquier predicado, incluidos los tuyos, admite su versión negada sin que tengas que escribirla. El prefijo any-, en cambio, solo existe para cuatro: eq?, match? con su alias vim-match?, lua-match? y contains?. Cambia el cuantificador implícito: cuando una captura recoge varios nodos porque lleva un cuantificador, la semántica por defecto exige que todos cumplan la condición, mientras que la variante any- se conforma con que la cumpla uno.

; palabras reservadas por pertenencia a una lista
((identifier) @keyword.builtin
  (#any-of? @keyword.builtin "self" "super" "nil"))

; excluir explicitamente lo que ya pinta otro patron
((identifier) @variable
  (#not-any-of? @variable "self" "super" "nil"))

; solo dentro de un bloque de comentario de documentacion
((comment) @comment.documentation
  (#has-ancestor? @comment.documentation chunk))

Directivas: anotar la coincidencia

Una directiva no filtra: se ejecuta sobre las coincidencias que sobrevivieron a los predicados y adjunta metadatos a la coincidencia o a una captura concreta. Termina en signo de admiración, y esos metadatos son precisamente el tercer valor que devuelven los iteradores de la API de Lua.

; prioridad explicita: gana sobre el patron generico que pinte el mismo rango
((identifier) @variable.builtin
  (#eq? @variable.builtin "self")
  (#set! priority 105))

; injections.scm: reparsea el contenido como otro lenguaje
((function_call
  name: (identifier) @_fn
  arguments: (arguments (string_content) @injection.content))
  (#eq? @_fn "sql")
  (#set! injection.language "sql"))

; recortar el rango capturado por los extremos: la cadena sin sus comillas
((string) @string
  (#offset! @string 0 1 0 -1))

; normalizar el texto antes de que lo lea el consumidor
((comment) @_texto
  (#gsub! @_texto "^%-%-%s*" ""))

; recortar lineas y columnas en blanco por los cuatro lados
((block) @fold
  (#trim! @fold 1 1 1 1))

Las cuatro directivas del núcleo cubren necesidades muy distintas. #set! escribe un par clave-valor arbitrario, en la coincidencia entera o en una captura concreta si le pasas la captura como primer argumento; sus claves con significado propio —priority, conceal, conceal_lines, url, injection.language, injection.combined, injection.include-children— son el mecanismo por el que las queries configuran al motor sin una línea de Lua.

#offset! desplaza los cuatro extremos del rango capturado. Ojo con un cambio importante: ya no escribe un rango final en los metadatos, sino un desplazamiento que se aplica después; por eso el rango bueno de un nodo se pide siempre con vim.treesitter.get_range, pasándole los metadatos, y no leyendo los metadatos a mano.

#gsub! sustituye sobre el texto de la captura y lo deja en los metadatos, sin tocar el búfer; solo admite capturas de un único nodo. Y #trim! recorta espacio en blanco del rango con cuatro banderas —líneas por delante, columnas por delante, líneas por detrás, columnas por detrás—, imprescindible para que un plegado no arrastre líneas vacías.

-- El rango correcto de una captura, con offset y trim ya aplicados.
-- get_range devuelve seis valores: fila, columna y byte de inicio, y los tres del final.
for id, nodo, metadata in consulta:iter_captures(raiz, 0) do
  local r = vim.treesitter.get_range(nodo, 0, metadata[id])
  print(consulta.captures[id], r[1], r[2], r[4], r[5])
end

Extender el motor desde Lua

Ni predicados ni directivas son un conjunto cerrado. La API permite registrar los tuyos, y a partir de ese momento cualquier fichero .scm de tu configuración puede invocarlos como si fueran nativos.

-- match asocia cada identificador de captura con una LISTA de nodos.
-- pred es la directiva entera: pred[1] es el nombre, pred[2] la captura,
-- y del tercero en adelante los argumentos literales.
vim.treesitter.query.add_predicate("mas-largo-que?", function(match, _, source, pred)
  local nodos = match[pred[2]]
  if not nodos or #nodos == 0 then return true end
  local limite = tonumber(pred[3])
  for _, nodo in ipairs(nodos) do
    if #vim.treesitter.get_node_text(nodo, source) <= limite then
      return false
    end
  end
  return true
end, { force = true })

vim.treesitter.query.add_directive("marcar-dudoso!", function(_, _, _, _, metadata)
  metadata.priority = 130
end, { force = true })

La única opción es force, que evita el error si el nombre ya estaba registrado, algo habitual al recargar la configuración. Todo lo demás son convenciones: el nombre de un predicado propio termina en interrogación y el de una directiva en admiración, no es obligatorio pero romperlo confunde a quien lea tus queries.

⚠️
Ya no hay opción para recibir un solo nodo

Durante un tiempo add_predicate aceptó una opción all que decidía si el manejador recibía la lista completa de nodos de cada captura o solo el primero. Ese parámetro ya no existe: el manejador recibe siempre la lista. Si copias un predicado escrito para configuraciones antiguas, lo verás fallar en cuanto le pases el resultado a get_node_text, porque le estarás dando una tabla donde espera un nodo. Y si quieres la semántica de “basta con que uno cumpla”, implémentala tú dentro del bucle: el prefijo any- es privilegio de cuatro predicados del núcleo y no se extiende a los tuyos. La negación con not-, en cambio, la obtienes gratis.

Para saber en cualquier momento qué tienes disponible, incluido lo que hayan registrado tus plugins, están vim.treesitter.query.list_predicates y vim.treesitter.query.list_directives.

Has entendido por qué el lenguaje se detiene donde se detiene

Fíjate en lo que el diseño de Treesitter decidió no hacer, porque ahí está la lección. El lenguaje de patrones podría haber crecido hasta ser Turing completo: condicionales, variables, aritmética, funciones. No lo hizo. Se quedó en una gramática de formas puramente declarativa, sin poder de cómputo, y abrió exactamente dos ventanas hacia el exterior: una que solo puede decir sí o no —el predicado—, y otra que solo puede escribir metadatos —la directiva—. Esa frugalidad es lo que permite compilar una query una sola vez, evaluarla sobre árboles gigantescos, ejecutarla de forma incremental mientras escribes y garantizar que nunca entrará en un bucle infinito ni bloqueará tu editor a mitad de una pulsación. Es la misma disciplina que hace que las expresiones regulares de estado finito sean rápidas y que las de retroceso sean bombas de relojería. Y cuando la restricción se queda corta, el sistema no rompe su propio modelo: te deja registrar una función en el lenguaje anfitrión, en el punto exacto donde el modelo declarativo se agota. Diseñar así —un núcleo pequeño, total y analizable, con puntos de extensión estrictamente delimitados— es una de las decisiones de arquitectura más difíciles de tomar y de las que más rendimiento dan a largo plazo. Reconocerla en el código ajeno es el paso previo a saber aplicarla en el propio.

⚔️ Filtra, anota y extiende
  1. Escribe una query que resalte como constante todo identificador en mayúsculas y verifica el resultado con :Inspect.
  2. Reescribe con #any-of? una alternancia de cinco #eq? y compara la legibilidad de ambas versiones.
  3. Usa #offset! para que una cadena se resalte sin sus comillas delimitadoras y recupera el rango final con vim.treesitter.get_range.
  4. Escribe dos patrones que cubran el mismo rango y resuelve el conflicto con #set! priority.
  5. Registra un predicado propio con add_predicate, úsalo desde un fichero .scm y comprueba que su versión con not- funciona sin haberla escrito.
  6. Recupera los metadatos con iter_matches desde Lua e imprímelos para confirmar qué escribió cada directiva.
  7. Ejecuta vim.treesitter.query.list_predicates y localiza cuáles ha añadido algún plugin y no vienen del núcleo.