wandres.dev
TREESITTER: EL ÁRBOL · parseo incremental

El árbol sintáctico concreto: nodos, nombres y campos

Qué distingue un árbol concreto de uno abstracto, por qué un editor necesita el concreto, cómo se organizan nodos con nombre, nodos anónimos, campos y nodos extra, y cómo recorrer todo eso desde Lua con vim.treesitter.

⏱ 20 min

El árbol que produce tree-sitter no es el que dibujan los libros de compiladores. Un compilador se queda con la esencia y tira la puntuación, los espacios y los comentarios, porque va a generar código y esas cosas le estorban. Un editor no puede tirar nada: necesita saber exactamente qué bytes ocupa cada cosa para colorearla, plegarla o seleccionarla. De esa exigencia nace el árbol sintáctico concreto, y entenderlo es la diferencia entre usar Treesitter y programarlo.

🎯 Al terminar esta lección sabrás
  • Distinguir árbol concreto de árbol abstracto y justificar por qué el editor necesita el primero.
  • Diferenciar nodos con nombre, nodos anónimos y nodos extra dentro del mismo árbol.
  • Usar campos para acceder a hijos por su papel y no por su posición.
  • Recorrer el árbol desde Lua con vim.treesitter leyendo tipos, rangos y texto.

Concreto frente a abstracto

Un árbol abstracto representa el significado de la construcción: una llamada tiene una función y unos argumentos, y da igual dónde estaban los paréntesis. Un árbol concreto representa además el texto tal cual: cada paréntesis, cada coma y cada palabra reservada es un nodo con su rango de bytes. Nada del buffer queda fuera del árbol.

Esa diferencia no es académica. Si quieres colorear la palabra reservada function de otro color que el nombre de la función, necesitas un nodo que sea exactamente esa palabra y sepa dónde empieza y acaba. Si quieres plegar un bloque, necesitas saber en qué byte está la llave que lo cierra. Si quieres que dif borre el cuerpo de una función sin tocar su firma, necesitas los límites reales del cuerpo, no una idea platónica de él. Un árbol abstracto no sirve para ninguna de las tres.

Escrito en la notación de expresiones simbólicas que usa tree-sitter, una función de Lua trivial se ve así:

(function_declaration
  name: (identifier)
  parameters: (parameters
    (identifier))
  body: (block
    (return_statement
      (binary_expression
        left: (identifier)
        right: (number)))))

Los prefijos con dos puntos —name:, body:, left:— son campos. Lo que no aparece en esa vista resumida, pero sí está en el árbol, son los nodos anónimos: la palabra function, los paréntesis, la coma, el end. La notación los oculta por legibilidad; el árbol los contiene.

ℹ️
Concreto no significa literal

El árbol conserva todos los tokens, pero no conserva los espacios en blanco como nodos. La disposición se deduce de los rangos: si un nodo acaba en el byte 42 y el siguiente empieza en el 45, entre medias hay tres bytes de relleno. Por eso siempre puedes reconstruir el texto original a partir del árbol y el buffer, propiedad que los refactorizadores basados en Treesitter aprovechan para no destrozar el formato.

Nodos con nombre, anónimos, campos y extra

Dentro del árbol conviven cuatro categorías que hay que saber separar, porque toda la interfaz de Lua está construida sobre esa distinción.

Los nodos con nombre corresponden a reglas de la gramática: function_declaration, binary_expression, identifier. Son los que te interesan casi siempre. Los nodos anónimos son los literales que la gramática menciona entre comillas: function, +, ,, end. Existen, ocupan bytes y se pueden consultar, pero no llevan nombre de regla, sino su propio texto como tipo.

Los campos son etiquetas que la gramática pone a ciertos hijos para indicar su papel: en una asignación, left y right; en una declaración de función, name, parameters y body. Sin campos tendrías que acceder por índice y depender del orden, que cambia entre lenguajes y entre versiones de la gramática. Con campos preguntas por significado.

Los nodos extra son los que la gramática permite en cualquier punto sin que aparezcan en las reglas: típicamente los comentarios. Por eso un comentario en mitad de una lista de argumentos no rompe el análisis.

🏷️

Con nombre

node:named_child(0) recorre solo reglas de la gramática e ignora la puntuación.

🔣

Anónimo

node:child(0) incluye llaves, comas y palabras reservadas, útil para colorearlas.

🧭

Campo

node:field("body") devuelve el hijo por su papel, no por su posición.

💬

Extra

Los comentarios flotan donde sea sin invalidar la estructura que los rodea.

flowchart TD
F[function declaration] -->|name| I[identifier]
F -->|parameters| P[parameters]
F -->|body| B[block]
F --> K1[palabra function anonima]
F --> K2[palabra end anonima]
P --> A1[identifier]
B --> R[return statement]
R --> X[binary expression]
X -->|left| L[identifier]
X -->|right| N[number]
style F fill:#89b4fa,color:#11111b
style K1 fill:#45475a,color:#cdd6f4
style K2 fill:#45475a,color:#cdd6f4
style X fill:#a6e3a1,color:#11111b

Recorrerlo desde Lua

La interfaz vive en vim.treesitter y en el tipo TSNode. El punto de entrada más directo es pedir el nodo bajo el cursor y subir por sus padres hasta la raíz: eso ya te dice en qué construcciones estás anidado.

-- Cadena de ancestros desde el cursor hasta la raiz
local node = vim.treesitter.get_node()
while node do
  local r1, c1, r2, c2 = node:range()
  print(string.format("%-24s nombre=%s  %d:%d -> %d:%d",
    node:type(), tostring(node:named()), r1, c1, r2, c2))
  node = node:parent()
end

Para trabajar sobre el árbol completo, pide el parser del buffer, obtén la raíz y recorre los hijos. iter_children devuelve el nodo y, si lo tiene, el nombre de su campo:

local parser = vim.treesitter.get_parser(0)
if not parser then return end          -- el buffer no tiene parser: no hay arbol
local raiz = parser:parse()[1]:root()

for hijo, campo in raiz:iter_children() do
  if hijo:named() then
    local texto = vim.treesitter.get_node_text(hijo, 0)
    print(campo or "-", hijo:type(), vim.split(texto, "\n")[1])
  end
end

-- Acceso por papel, no por posicion
local primera = raiz:named_child(0)
local cuerpo = primera and primera:field("body")[1]
if cuerpo then
  local fila_ini = cuerpo:start()
  local fila_fin = cuerpo:end_()
  print("el cuerpo ocupa de la linea", fila_ini, "a la", fila_fin)
end

Cuatro detalles que ahorran horas de depuración. Los rangos son de base cero y con final exclusivo en columna, mientras que las líneas del buffer en la interfaz clásica son de base uno: mezclar convenios es el error más frecuente. field devuelve una lista, porque una gramática puede etiquetar varios hijos con el mismo campo. vim.treesitter.get_node_text necesita el buffer, porque el nodo guarda posiciones, no texto: el árbol jamás copia el contenido. Y vim.treesitter.get_parser devuelve nil cuando no puede crear un parser —junto a un mensaje de error como segundo valor— en lugar de lanzar una excepción, así que comprobarlo con if not parser es correcto y envolverlo en pcall no sirve de nada.

Además de subir y bajar, puedes entrar directamente por coordenadas, que es lo que hacen internamente los objetos de texto y el plegado:

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

-- El nodo con nombre mas pequenio que cubre un rango dado
local n = raiz:named_descendant_for_range(10, 4, 10, 12)
print(n:type(), n:has_error())

-- Subir hasta el primer ancestro de un tipo concreto
local function ancestro(nodo, tipo)
  while nodo and nodo:type() ~= tipo do
    nodo = nodo:parent()
  end
  return nodo
end
⚠️
Un nodo no sobrevive a una edición

Un TSNode es válido mientras lo sea el árbol del que salió. Si guardas un nodo en una variable, editas el buffer y vuelves a usarlo, estarás preguntando por posiciones que ya no significan lo mismo. La regla de oro es no almacenar nodos entre eventos: pide el árbol, resuelve lo que necesites y quédate con rangos o con extmarks, que sí se actualizan solos con el texto.

El árbol no describe el código: el árbol es una segunda representación del buffer, y ahí está toda su potencia

La forma habitual de entender el árbol es verlo como metadatos —una anotación sobre el texto, que sigue siendo la realidad—. Invierte la relación y todo encaja mejor: el buffer y el árbol son dos representaciones del mismo objeto, una lineal y otra jerárquica, sincronizadas byte a byte y sin duplicar contenido, porque el árbol solo guarda posiciones. Un editor de texto trabaja sobre la representación lineal: líneas, columnas, rangos. Un editor de código puede trabajar sobre la jerárquica: nodos, padres, campos. Y como ambas comparten el mismo sistema de coordenadas, puedes traducir libremente entre ellas en mitad de una operación: partes de una posición del cursor, subes al nodo que la contiene, obtienes su rango y vuelves a operaciones de texto sobre ese rango. Ese viaje de ida y vuelta es literalmente el mecanismo de todo lo que viene después: un objeto de texto sintáctico es subir al nodo y bajar a su rango; un plegado es un rango de nodo; un resaltado es un rango de nodo con un grupo asociado; una selección incremental es subir un nivel y volver a bajar. Por eso el árbol concreto tiene que ser concreto: si perdiera un solo token, habría posiciones del buffer sin nodo que las cubra y la traducción entre las dos representaciones dejaría de ser total. La exhaustividad no es celo de diseño; es la condición que hace que las dos vistas sean la misma cosa.

⚔️ Explorar el árbol a mano
  1. Con el cursor dentro de una función, ejecuta el bucle de ancestros y anota la cadena completa de tipos hasta la raíz.
  2. Compara raiz:child_count() con raiz:named_child_count() y explica de dónde sale la diferencia.
  3. Localiza un nodo con campos y pide field("body"), field("name") y un campo inexistente. Observa qué devuelve cada uno.
  4. Escribe un comentario en mitad de una lista de argumentos y comprueba que el árbol sigue siendo válido; identifica el nodo extra.
  5. Toma un nodo cualquiera, recupera su texto con vim.treesitter.get_node_text y verifica que sus rangos coinciden con lo que ves en pantalla.