wandres.dev
TREESITTER: QUERIES · capturas y predicados

El lenguaje de consultas de Treesitter

Los queries como patrones declarativos sobre el árbol sintáctico: expresiones simbólicas, nodos con nombre y anónimos, campos, comodines, anclas, cuantificadores y las capturas con arroba que convierten una forma en datos utilizables desde Lua.

⏱ 16 min

Treesitter convierte tu código en un árbol, y ese árbol es un dato consultable. El lenguaje de queries es su interfaz declarativa: en lugar de escribir cómo recorrer nodos, describes qué forma buscas y el motor te devuelve todas las apariciones. Es el mismo salto conceptual que va del bucle imperativo a la expresión regular, pero aplicado a la estructura en vez de al texto plano.

🎯 Al terminar esta lección sabrás
  • Leer un patrón como una forma del árbol y no como una cadena.
  • Distinguir nodos con nombre, nodos anónimos y campos con etiqueta.
  • Combinar comodines, alternancias, agrupaciones, anclas y cuantificadores.
  • Capturar nodos con arroba y consumir el resultado desde Lua.

Un patrón describe una forma, no un texto

Un parser de Treesitter produce un árbol sintáctico concreto: cada construcción del lenguaje es un nodo con un tipo, un rango en el búfer y una lista de hijos. El lenguaje de queries se escribe en expresiones simbólicas al estilo Lisp, y su regla fundamental es de una simplicidad engañosa: un patrón coincide si su forma se puede superponer sobre una porción del árbol.

Los patrones viven en ficheros con extensión .scm porque comparten la sintaxis superficial de Scheme, no porque se evalúen como Scheme. Toda la sintaxis del lenguaje usa paréntesis y llaves, así que siempre se escribe dentro de un bloque de código.

; coincide con la declaracion de una funcion con nombre en Lua
(function_declaration
  name: (identifier) @function)

; coincide con una llamada a funcion
(function_call
  name: (identifier) @function.call)

El primer patrón no busca la palabra function en ningún sitio: busca un nodo cuyo tipo sea function_declaration y que tenga un hijo, bajo el campo name, de tipo identifier. Da igual el formato, los espacios, los saltos de línea o los comentarios intercalados. Ahí reside la diferencia categórica con una expresión regular: el patrón razona sobre la gramática, no sobre los bytes.

flowchart LR
A[Codigo fuente] --> B[Parser incremental]
B --> C[Arbol sintactico concreto]
D[Patron en fichero scm] --> E[Motor de queries]
C --> E
E --> F[Matches con capturas]
F --> G[Resaltado plegado indentacion textobjects]

Nodos con nombre, nodos anónimos y campos

La gramática distingue tres clases de referencia, y confundirlas es el origen de la mitad de los queries que nunca coinciden con nada.

🔤

Nodo con nombre

Corresponde a una regla de la gramática. Se escribe entre paréntesis con su tipo: identifier, function_call, binary_expression. Es la unidad semántica del árbol.

🔣

Nodo anónimo

Es un token literal de la gramática: una palabra clave o un signo de puntuación. Se escribe entrecomillado, como "return" o "=". No aparece en el árbol salvo que lo pidas.

🏷️

Campo

Etiqueta que la gramática pone a un hijo concreto para desambiguarlo: name:, body:, condition:, left:, right:. Es lo que evita capturar el identificador equivocado.

Los campos son la herramienta de precisión. Sin ellos, un patrón sobre binary_expression capturaría indistintamente el operando izquierdo y el derecho; con ellos, el motor solo acepta el hijo que ocupa esa posición gramatical. Existe además la negación de campo, que exige la ausencia de un hijo etiquetado.

; solo el operando izquierdo
(binary_expression
  left: (identifier) @variable
  operator: "+"
  right: (_))

; una funcion sin nombre, es decir, anonima
(function_definition !name) @funcion.anonima

; supertipos: empareja expression_statement solo cuando es subtipo de statement
(statement/expression_statement) @sentencia

Los supertipos merecen una nota, porque son la parte del lenguaje que más gente ignora. Una gramática puede declarar que expression agrupa a binary_expression, call_expression, identifier y compañía; entonces (expression) empareja cualquiera de ellos aunque expression no sea un nodo visible en el árbol, y la barra permite estrechar a un subtipo concreto. Qué supertipos existen en tu gramática te lo dice vim.treesitter.language.inspect.

Comodines, alternancias, anclas y cuantificadores

Sobre esa base mínima el lenguaje añade unos pocos combinadores que le dan potencia expresiva real. Son todos los que hay: la economía del lenguaje es deliberada.

; comodines
(_)   ; cualquier nodo CON nombre
_     ; cualquier nodo, con nombre o anonimo

; alternancia: cualquiera de estos tokens
[
  "if"
  "elseif"
  "else"
] @keyword.conditional

; agrupacion de una secuencia de hermanos
(
  (comment) @comment.documentation
  (function_declaration)
)

; cuantificadores sobre grupos o nodos
(comment)+ @comment
(parameters)? 

; ancla: restringe a hijos INMEDIATAMENTE adyacentes
(arguments . (identifier) @primer.argumento)
(block (_) @ultima.sentencia .)

El ancla, escrita como un punto, es el combinador más sutil y el más olvidado. Sin ella, dos nodos hermanos en un patrón significan aparecen en este orden en algún punto; con ella significan son vecinos inmediatos. Colocada al principio de una lista fija el primer hijo; al final, el último. Los cuantificadores +, * y ? operan sobre el elemento inmediatamente anterior, sea un nodo o un grupo entre paréntesis.

Capturas: el puente hacia Lua

Un patrón sin capturas responde sí o no. Una captura, escrita con arroba seguida de un nombre, marca un subnodo para que el motor lo devuelva. Los nombres se escriben con puntos como jerarquía —@function.call, @keyword.return— y esa jerarquía tiene consecuencias que exploraremos en la lección siguiente. Un mismo nodo puede llevar varias capturas, y una captura puede repetirse en varios puntos del patrón.

local consulta = vim.treesitter.query.parse("lua", [[
  (function_declaration
    name: (identifier) @nombre
    parameters: (parameters) @params)
]])

local parser = assert(vim.treesitter.get_parser(0, "lua"))
local raiz = parser:parse()[1]:root()

for id, nodo in consulta:iter_captures(raiz, 0) do
  local captura = consulta.captures[id]
  print(captura, vim.treesitter.get_node_text(nodo, 0))
end

Dos iteradores conviven y no son intercambiables. iter_captures recorre capturas sueltas en orden de aparición en el búfer y devuelve el identificador de la captura, el nodo, los metadatos y la coincidencia completa: es lo que quiere un resaltador, que solo necesita saber qué color pintar dónde. iter_matches devuelve coincidencias completas, con todas las capturas de un mismo patrón agrupadas: es lo que necesitas cuando la relación entre capturas importa, por ejemplo para asociar el nombre de una función con su cuerpo.

Un detalle que sorprende la primera vez: en una coincidencia, cada identificador de captura no apunta a un nodo sino a una lista de nodos. Es la consecuencia inevitable de los cuantificadores, porque (comment)+ @comment captura tantos nodos como comentarios haya seguidos. Aunque tu patrón no lleve cuantificadores, la lista sigue siendo una lista de un elemento, y olvidarlo es la causa habitual de que un predicado propio reciba una tabla donde esperaba un nodo.

Has cruzado la frontera del texto

Casi todo el tooling de editores del siglo pasado vivía preso de una idea: el código es una secuencia de caracteres y se interroga con expresiones regulares. Esa premisa es falsa, y su falsedad explica décadas de resaltado que se rompía dentro de una cadena, de plegados que confundían una llave de un comentario con una de un bloque y de refactorizaciones que destrozaban ficheros. Lo que acabas de aprender es la refutación operativa de esa premisa. Un query de Treesitter no describe caracteres: describe una forma en la gramática de un lenguaje, y esa forma es invariante frente al formato, los espacios, los comentarios y los saltos de línea. Cuando escribes name: (identifier) @function no estás diciendo busca esta cadena, estás diciendo tráeme el nodo que la gramática designa como nombre de esta declaración. Es la diferencia entre buscar y entender. A partir de aquí, cada capacidad del editor —resaltado, plegado, indentación, objetos de texto, inyección de lenguajes, saltos semánticos— deja de ser un plugin opaco y se convierte en un fichero de patrones que puedes leer, corregir y extender tú mismo. Has dejado de ser usuario del árbol para ser su interlocutor.

⚔️ Traduce tres formas a patrones
  1. Abre un fichero Lua y ejecuta :InspectTree para ver el árbol real de tu código.
  2. Escribe un patrón que capture únicamente el primer parámetro de cada función, usando un ancla.
  3. Escribe un patrón que capture solo las funciones sin nombre, usando la negación de campo.
  4. Escribe un patrón con alternancia que capture las tres palabras clave condicionales del lenguaje.
  5. Ejecuta los tres desde Lua con iter_captures e imprime el texto de cada nodo con vim.treesitter.get_node_text.
  6. Repite el ejercicio 2 con iter_matches y describe con tus palabras qué cambia en la estructura devuelta.