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.
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.
- 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.treesitterleyendo 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.
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 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.
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.
- Con el cursor dentro de una función, ejecuta el bucle de ancestros y anota la cadena completa de tipos hasta la raíz.
- Compara
raiz:child_count()conraiz:named_child_count()y explica de dónde sale la diferencia. - Localiza un nodo con campos y pide
field("body"),field("name")y un campo inexistente. Observa qué devuelve cada uno. - Escribe un comentario en mitad de una lista de argumentos y comprueba que el árbol sigue siendo válido; identifica el nodo extra.
- Toma un nodo cualquiera, recupera su texto con
vim.treesitter.get_node_texty verifica que sus rangos coinciden con lo que ves en pantalla.