wandres.dev
TREESITTER APLICADO · text objects y más

Plegado e indentación: el árbol como fuente de la verdad

Plegar por estructura real en lugar de por marcas o sangría, con foldexpr nativo y queries de folds. Indentar con las capturas de indents en vez de con reglas escritas a mano, y por qué la indentación por árbol no sustituye a un formateador.

⏱ 19 min

Plegar e indentar parecen problemas menores de presentación y son, en realidad, dos preguntas idénticas formuladas sobre ejes distintos: ¿qué región del buffer constituye una unidad, y a qué profundidad jerárquica está? Vim las respondió históricamente con tres estrategias, todas indirectas: marcas escritas a mano en comentarios, sangría existente en el texto, o expresiones regulares por lenguaje mantenidas a pulso en ficheros de cientos de líneas. Las tres comparten el mismo defecto de fondo: infieren la estructura a partir de su propia manifestación tipográfica. Con un árbol de sintaxis disponible la inferencia sobra, porque la estructura ya está calculada, es exacta, y se actualiza sola tras cada pulsación.

🎯 Al terminar esta lección sabrás
  • Activar el foldexpr nativo de Neovim y controlar foldlevel, foldtext y la persistencia de pliegues.
  • Leer una query de folds y añadir capturas propias para plegar lo que tu lenguaje no pliega por defecto.
  • Configurar el indentexpr que aporta nvim-treesitter y leer las capturas de indents una a una.
  • Distinguir qué pone Neovim y qué pone el plugin, que en este tema es la mitad de la confusión.
  • Situar el límite exacto entre indentar mientras escribes y formatear con una herramienta canónica.

Plegar por estructura real

Neovim trae el cálculo de pliegues por árbol integrado en el núcleo, sin plugins. Como los pliegues son opciones locales a la ventana, la forma correcta de activarlos es por tipo de fichero y comprobando antes que existe un parser, para no dejar buffers de texto plano con una expresión que no sabrá evaluar.

vim.api.nvim_create_autocmd("FileType", {
  callback = function(args)
    -- get_parser devuelve nil si no hay parser: no lanza excepcion,
    -- asi que envolverlo en pcall no detecta nada.
    if not vim.treesitter.get_parser(args.buf) then return end
    vim.wo[0][0].foldmethod = "expr"
    vim.wo[0][0].foldexpr = "v:lua.vim.treesitter.foldexpr()"
    vim.wo[0][0].foldlevel = 99  -- abre todo al entrar
  end,
})

vim.o.foldlevelstart = 99        -- ficheros nuevos tambien abiertos
vim.o.foldtext = ""              -- conserva el resaltado en la linea plegada
vim.o.foldnestmax = 4            -- ignora anidamientos absurdamente profundos

La forma vim.wo[0][0] no es capricho: fija la opción como local a la ventana pero atada al buffer actual, que es exactamente lo que quieres en un autocomando de tipo de archivo. Con vim.wo a secas, la expresión de plegado se queda pegada a la ventana y viaja con ella al siguiente buffer que abras ahí, aunque ese buffer no tenga parser.

Tres decisiones merecen justificación. La primera, foldlevel alto: un fichero que se abre completamente plegado obliga a un trabajo de apertura antes de poder leer nada, y el plegado es útil como acto deliberado —zc sobre lo que ya has entendido—, no como estado inicial. La segunda, dejar foldtext vacío: en versiones recientes eso hace que la línea plegada conserve el resaltado sintáctico en lugar de convertirse en un texto gris, lo cual mantiene legible lo que el pliegue resume. La tercera, foldnestmax: el árbol tiene muchísima más profundidad que la que a un humano le resulta útil plegar, y sin tope acabas con pliegues dentro de pliegues dentro de expresiones.

La persistencia es el otro asunto práctico. Los pliegues no sobreviven a cerrar el buffer salvo que los guardes con vistas, y las vistas guardan también otras cosas que quizá no quieras restaurar. Conviene decidir explícitamente: o los pliegues son efímeros —posición del cursor y estructura recalculadas cada vez— o guardas vistas limitadas a folds mediante la opción correspondiente. La ambigüedad entre ambos modelos es la causa habitual de que un fichero se abra con pliegues que no recuerdas haber creado.

Con la estructura resuelta, el vocabulario de teclas de siempre cobra un valor nuevo, porque ahora cada pliegue coincide con una unidad real del programa.

🪗

Abrir y cerrar

za alterna el pliegue bajo el cursor, zc lo cierra, zo lo abre y zv abre lo justo para ver la línea actual.

🗺️

Panorámica

zM cierra todo y zR lo abre todo. Con pliegues por árbol, zM produce un índice del fichero: solo firmas.

🎚️

Por niveles

zm y zr suben y bajan un nivel de golpe, que es la forma de leer un fichero desconocido de fuera hacia dentro.

La query que decide qué se pliega

El comportamiento por defecto pliega los nodos que la gramática marca en queries/<lenguaje>/folds.scm con una única captura. Es una de las queries más simples que existen y por eso la más fácil de ampliar.

-- Consulta desde Lua que nodos pliega tu lenguaje actualmente.
-- Ojo: el lenguaje del parser no siempre coincide con el tipo de archivo.
local lang = vim.treesitter.language.get_lang(vim.bo.filetype)
local q = lang and vim.treesitter.query.get(lang, "folds")
print(q and table.concat(q.captures, ", ") or "sin query de folds")

-- Y de que ficheros sale
vim.print(lang and vim.treesitter.query.get_files(lang, "folds") or {})
; ~/.config/nvim/after/queries/lua/folds.scm
;; extends

; plegar tambien las tablas literales, que en configuracion son enormes
(table_constructor) @fold

; y recortar las lineas en blanco del final para que el pliegue no las arrastre
((function_declaration) @fold
  (#trim! @fold))

Un aviso sobre #trim!: solo admite capturas de un único nodo. Si lo aplicas a una captura con cuantificador, no es que se ignore, es que aborta con un error. Es el fallo más habitual al ampliar un folds.scm.

Merece la pena entender que el nivel de plegado de una línea no es una propiedad del nodo sino una función de la profundidad de anidamiento de los nodos plegables que la contienen. Por eso los pliegues se comportan de forma coherente al anidar: una función dentro de una clase queda automáticamente un nivel por debajo, sin que nadie lo declare.

Si tu lenguaje no pliega algo que a ti te estorba —un bloque de importaciones larguísimo, una lista literal de doscientos elementos, un comentario de documentación— basta con crear tu propio folds.scm con la directiva de extensión al principio y añadir el patrón. El rango del pliegue es el rango del nodo capturado, con la convención habitual de que la última línea queda visible; por eso plegar una función deja a la vista su firma, que es exactamente lo que quieres.

⚠️
Los pliegues son locales a la ventana, no al buffer

Si abres el mismo fichero en dos ventanas, cada una tiene su propio estado de pliegues y su propia foldexpr. Configurar el plegado con opciones globales parece funcionar hasta que abres una ventana nueva sobre un buffer ya cargado y aparece sin pliegues, o hasta que un buffer sin parser hereda una expresión que no puede evaluar. La activación por tipo de fichero con comprobación previa evita ambas patologías.

flowchart TB
a[Edicion en el buffer] --> b[Reparse incremental del arbol]
b --> c[Query de folds sobre los nodos]
c --> d[Nivel de plegado por linea]
d --> e[foldexpr nativo]
e --> f[Pliegues coherentes con la estructura]
b --> g[Query de indents sobre los nodos]
g --> h[indentexpr calcula la sangria de la linea]
style f fill:#a6e3a1,color:#11111b
style h fill:#89b4fa,color:#11111b
style b fill:#cba6f7,color:#11111b

Indentar con el árbol

La indentación automática de Vim vive en indentexpr, una opción local al buffer que recibe una línea y devuelve su columna de sangría. Los ficheros tradicionales de indentación son programas en VimScript llenos de casos especiales; la versión por árbol sustituye ese código por una query declarativa.

A diferencia del plegado, la indentación por árbol no la da Neovim: la da el plugin, y sigue marcada como experimental. Fíjate en las comillas de la expresión, porque son parte de la sintaxis y cambiarlas la rompe.

El plugin ya está instalado desde la lección 1.2; lo único que aporta aquí es la función de indentación.

vim.api.nvim_create_autocmd("FileType", {
  callback = function(args)
    if not vim.treesitter.get_parser(args.buf) then return end
    -- plegado: de Neovim
    vim.wo[0][0].foldmethod = "expr"
    vim.wo[0][0].foldexpr = "v:lua.vim.treesitter.foldexpr()"
    -- indentacion: del plugin, y experimental
    vim.bo[args.buf].indentexpr = "v:lua.require'nvim-treesitter'.indentexpr()"
  end,
})
; Un indents.scm real, recortado del que trae el plugin para C
[
  (compound_statement)
  (field_declaration_list)
  (initializer_list)
] @indent.begin

(compound_statement
  "}" @indent.end)

[
  ")"
  "}"
] @indent.branch

[
  "#define"
  "#ifdef"
  "#endif"
] @indent.zero

[
  (preproc_arg)
  (string_literal)
] @indent.ignore

((argument_list) @indent.align
  (#set! indent.open_delimiter "(")
  (#set! indent.close_delimiter ")"))

(comment) @indent.auto

El fichero indents.scm no captura “lo que se indenta” sino los eventos que modifican la sangría, y ahí está la elegancia del modelo. @indent.begin abre un nivel a partir de ese nodo; @indent.end lo cierra; @indent.dedent baja la línea actual sin afectar a las siguientes, que es justo lo que necesita un else o un case; @indent.branch marca los puntos donde una estructura se bifurca sin cambiar de profundidad; @indent.align, acompañado de las dos directivas que declaran los delimitadores, pide que los elementos se alineen con el paréntesis de apertura en lugar de con un múltiplo del ancho de tabulación; @indent.zero fuerza la columna uno, como en las directivas de preprocesador; @indent.ignore excluye regiones que no deben tocarse, como el interior de una cadena multilínea; y @indent.auto deja la línea como estaba, que es lo sensato dentro de un comentario.

Una pista de lectura que se aprende mirando estos ficheros: verás patrones que capturan explícitamente nodos ERROR. No son un descuido, son el corazón del asunto. Mientras escribes, la línea está incompleta y el parser produce un nodo de error, de modo que la regla que debía dispararse todavía no puede emparejar; capturar la forma rota es la única manera de indentar código a medio nacer. Por eso la indentación al teclear a veces difiere de la que obtienes al reindentar después con =. No es un fallo: son dos entradas distintas.

Indentar no es formatear

Aquí conviene ser preciso, porque la confusión cuesta horas. indentexpr responde a una pregunta estrictamente local: dada esta línea, ¿en qué columna empieza? No parte líneas largas, no reordena argumentos, no normaliza comillas ni espacios alrededor de operadores, no decide si una llamada cabe en una línea o debe explotar en varias. Un formateador canónico responde a una pregunta global sobre el documento entero y produce una salida determinista que todo el equipo comparte.

Hay además dos detalles de configuración que provocan desconciertos evitables. El primero: indentexpr es una opción local al buffer, así que un ftplugin que se cargue después de tu autocomando la sobrescribe sin avisar; si la sangría por árbol “deja de funcionar” en un lenguaje concreto, lo primero es mirar el valor real con :verbose set indentexpr?, que además te dice quién lo puso. El segundo: mientras escribes, indentexpr solo se dispara con las teclas que enumera indentkeys; con = se dispara siempre. Esa asimetría explica buena parte de los “a veces indenta y a veces no”.

📝
Reindentar código pegado

Pegar desde el portapapeles del sistema y reindentar después con = sobre el rango pegado es más fiable que confiar en la sangría de origen, porque el árbol recalcula la profundidad real en el destino. Con el registro de la última pegada, =`] reindenta exactamente lo insertado sin tener que seleccionarlo a mano.

La consecuencia operativa es una división de trabajo limpia: la indentación por árbol es para el flujo de escritura —o, O, Enter, =, gg=G, pegar y reindentar— y el formateador es para el guardado y para el control de versiones. Si intentas que la primera haga el trabajo de la segunda, obtendrás resultados que se parecen al estilo del proyecto sin coincidir con él, y cada guardado producirá un diferencial de ruido. Si en cambio dejas que el formateador mande al guardar, la indentación por árbol solo tiene que ser buena mientras escribes, que es un requisito mucho más fácil de cumplir y que cumple sobradamente.

Plegar e indentar son la misma pregunta, y el árbol la responde una sola vez

Merece la pena reconocer lo que ocurre aquí, porque es un caso de libro de eliminación de duplicidad conceptual. Durante décadas, el editor mantuvo tres respuestas independientes a la pregunta de cuál es la estructura del código: una para colorear, otra para plegar y otra para indentar. Cada una tenía su propio motor —expresiones regulares para el resaltado, marcas o sangría para los pliegues, VimScript imperativo para la indentación—, su propio conjunto de casos límite y su propio mantenedor. Tres modelos aproximados del mismo objeto, discrepando entre sí de formas visibles: bloques que se coloreaban bien pero se plegaban mal, código que se indentaba correctamente hasta que aparecía una cadena con una llave dentro. Treesitter no mejora las tres respuestas: sustituye las tres fuentes por una sola. El árbol se calcula una vez, incrementalmente, y sobre él se declaran queries distintas para propósitos distintos: highlights, folds, indents, textobjects, locals, injections. Es exactamente la separación entre modelo y vistas, aplicada a un editor de texto, y su efecto es que la coherencia deja de ser algo que hay que mantener y pasa a ser una propiedad estructural: lo que se colorea como una función se pliega como una función y se indenta como una función, necesariamente, porque las tres cosas leen el mismo nodo. Añade a eso que las queries son datos y no código —un fichero declarativo que puedes leer, extender con una directiva y depurar con el inspector, sin escribir una línea de VimScript— y tienes la razón por la que un lenguaje nuevo obtiene resaltado, plegado, indentación y objetos de texto el mismo día en que alguien publica su gramática. Antes eso requería cuatro contribuciones distintas a cuatro subsistemas distintos, mantenidas por personas distintas durante años. Ahora requiere una gramática y unas pocas queries, y todo lo demás se deriva. Esa es la clase de simplificación que no se percibe como una funcionalidad, sino como la desaparición silenciosa de un problema que dabas por inevitable.

⚔️ Estructura visible
  1. Activa el foldexpr nativo por tipo de fichero con comprobación de parser y abre un buffer sin gramática; explica qué habría ocurrido con una opción global.
  2. Compara la misma función plegada con foldtext vacío y con el valor por defecto, y argumenta cuál conserva más información útil.
  3. Consulta desde Lua las capturas de la query de pliegues de tu lenguaje y añade un folds.scm propio que pliegue el bloque de importaciones.
  4. Escribe una estructura condicional pulsando Enter línea a línea y luego reindéntala con =; identifica en qué línea difieren ambos resultados y por qué.
  5. Configura un formateador al guardar y comprueba que la indentación por árbol y el formateador no compiten: describe qué corrige cada uno sobre el mismo fragmento mal escrito.