Text objects sintácticos: la gramática de Vim sobre el árbol
Un objeto de texto deja de ser un patrón de caracteres y pasa a ser un nodo del árbol de sintaxis. Cómo se definen los objetos de función, clase y parámetro en las queries de textobjects, qué algoritmo elige el nodo bajo el cursor, y por qué el verbo de Vim no necesita cambiar para operar sobre conceptos del lenguaje.
La gramática de Vim —verbo más objeto— es un lenguaje, y durante treinta años su vocabulario de complementos fue estrictamente léxico. daw sabe qué es una palabra porque existe una tabla de caracteres llamada iskeyword; dap sabe qué es un párrafo porque cuenta líneas vacías; di( encuentra su pareja recorriendo el buffer hacia atrás con una expresión regular equilibrada. Ninguno de los tres sabe qué es una función. Treesitter no añade comandos: cambia el sustrato. Mantiene, de forma incremental y dentro del propio editor, un árbol de sintaxis concreto del buffer, y sobre ese árbol es posible definir objetos que nombran conceptos del lenguaje. daf no borra caracteres entre llaves equilibradas; borra el nodo que la gramática llama definición de función. El verbo es el mismo. Lo que ha cambiado es la ontología del complemento.
- Distinguir el objeto léxico del objeto sintáctico y saber en qué casos exactos el primero miente.
- Leer una query de
textobjectsy comprender la diferencia entre una capturaoutery unainner. - Instalar
nvim-treesitter-textobjectsy declarar tú las teclas, porque el plugin ya no mapea nada. - Configurar la selección con
lookahead,selection_modesyinclude_surrounding_whitespace. - Componer los objetos nuevos con verbos, registros y
.sin perder ninguna propiedad de la gramática.
Dos ontologías del objeto de texto
Un objeto de texto, en su forma más pura, es una función que va del estado del editor a un rango del buffer. Vim implementa esa función con reglas locales y baratísimas: clases de caracteres, líneas en blanco, conteo de delimitadores. Son heurísticas excelentes para texto plano y mediocres para código, porque desconocen el contexto. Un paréntesis dentro de una cadena o de un comentario engaña a di(; una llave dentro de una regex desalinea el emparejamiento; una palabra con guion se parte o no según cómo tengas iskeyword ese día. El defecto no es de implementación: es que el modelo del buffer es una secuencia de caracteres, y en ese modelo la noción de función sencillamente no existe.
Treesitter sustituye ese modelo. El buffer se analiza con un parser incremental que produce un árbol de sintaxis concreto —no un AST depurado: cada token literal, cada coma y cada llave tienen su nodo—, y ese árbol se reparse tras cada edición reutilizando los subárboles intactos. Un objeto de texto pasa entonces a ser un nodo: un intervalo con un tipo, un padre y unos hijos con nombre. La consecuencia práctica es que la pregunta cambia de “¿dónde está el paréntesis que cierra?” a “¿en qué nodo estoy?”, y la segunda pregunta tiene siempre una respuesta exacta.
Conviene subrayar que el árbol es concreto y no abstracto: conserva cada coma, cada paréntesis y cada palabra clave como nodos propios. Para compilar eso sería ruido, pero para editar texto es imprescindible, porque un objeto tiene que empezar y terminar en un carácter exacto del buffer.
Lo elegante del diseño es que el operador no se entera. d, y, c, gU o > solo necesitan que un mapeo en modo operator-pending deje una selección; de dónde salga esa selección les es indiferente. Por eso los objetos de Treesitter se integran sin fricción en la gramática existente en lugar de constituir un subsistema aparte.
La query que nombra el objeto
Los objetos no están codificados en el plugin: se declaran en ficheros de query, un dialecto de S-expresiones que se busca en el runtimepath bajo queries/<lenguaje>/textobjects.scm. Una query empareja patrones de nodos y les asigna capturas, y son los nombres de captura los que el plugin conoce.
-- Una query es dato: puedes leerla, parsearla e inspeccionarla desde Lua.
local q = vim.treesitter.query.parse("lua", [[
; el nodo entero: nombre, parametros, cuerpo y el end final
(function_declaration) @function.outer
; solo el bloque interior, sin la cabecera ni el cierre
(function_declaration body: (block) @function.inner)
; cada parametro por separado
(parameters (identifier) @parameter.inner)
]])
La distinción entre outer e inner reproduce la que ya conoces de a frente a i, pero elevada: af cubre la definición completa y if solo su cuerpo, del mismo modo que a" incluye las comillas y i" no. La convención de nombres —@function.outer, @function.inner, @class.outer, @parameter.inner, @conditional.outer, @loop.inner, @call.outer, @assignment.lhs— es precisamente la capa de indirección que hace portable el sistema: la gramática de Rust llama a sus nodos de una manera y la de Python de otra, pero ambas publican una captura @function.outer, de modo que tu mapeo af significa lo mismo en los dos lenguajes.
Puedes ampliar o corregir cualquier query desde tu configuración. Si creas queries/lua/textobjects.scm en tu directorio de config y su primera línea es la directiva de extensión, tus patrones se suman a los del plugin en lugar de reemplazarlos; sin esa directiva, el fichero de mayor prioridad en el runtimepath gana y silencia al resto. Es el error más común al personalizar objetos: de pronto af deja de funcionar en un lenguaje y la causa es una query propia que sombreó la original.
Elegir el nodo: cursor, anidamiento y lookahead
Con la query resuelta, el algoritmo de selección recoge todas las capturas del nombre pedido en el árbol visible y aplica tres criterios en orden. Primero conserva las que contienen la posición del cursor. Si hay varias —el caso del anidamiento: una función interna dentro de otra— elige la más pequeña, porque el criterio de intimidad es el que mejor predice la intención. Y si ninguna contiene el cursor, entra en juego lookahead: se busca hacia adelante la siguiente captura que empiece después del cursor y se salta a ella. Esa tercera regla es la que permite escribir daf desde la línea en blanco anterior a una función y que la operación acierte.
Los objetos los aporta nvim-treesitter-textobjects, y su rama main no crea ni un solo mapeo: la política de teclas es tuya de principio a fin. Eso, que parece una molestia, es lo único que evita que un plugin te robe atajos que ya usabas.
El plugin lo instalaste en la lección 2.3 y su rama main no crea ni un solo mapeo: todo lo que sigue existe porque lo declaras tú.
Las tres opciones de select son exactamente las que hay. lookahead activa la tercera regla del algoritmo. selection_modes fija, por captura, si la selección será por caracteres, por líneas o por bloque —seleccionar una función entera en modo línea evita el resto de basura de la primera y la última línea—. Y include_surrounding_whitespace extiende el objeto al espacio en blanco que lo rodea, dando prioridad al posterior para imitar el ap nativo. Las dos últimas admiten también una función, por si quieres decidirlo en tiempo de ejecución en vez de con una tabla fija.
local sel = require("nvim-treesitter-textobjects.select")
local function objeto(captura)
return function() sel.select_textobject(captura, "textobjects") end
end
for tecla, captura in pairs({
af = "@function.outer", ["if"] = "@function.inner",
ac = "@class.outer", ic = "@class.inner",
aa = "@parameter.outer", ia = "@parameter.inner",
}) do
vim.keymap.set({ "x", "o" }, tecla, objeto(captura), { desc = "objeto " .. captura })
end
El segundo argumento de select_textobject no es decorativo: es el grupo de query del que sale la captura. Con "textobjects" lees textobjects.scm, pero puedes leer igual de bien "locals" —para seleccionar un ámbito entero con @local.scope— o "folds". Admite además un tercer argumento con el modo, útil cuando construyes los mapeos en un bucle sobre x y o.
-- El ambito lexico completo como objeto de texto, desde locals.scm
vim.keymap.set({ "x", "o" }, "as", function()
sel.select_textobject("@local.scope", "locals")
end, { desc = "ambito" })
flowchart TB
a[Pulsas d a f] --> b[Mapeo operator pending]
b --> c[Query textobjects sobre el arbol]
c --> d{Alguna captura contiene el cursor}
d -- si --> e[Elige la captura mas interna]
d -- no --> f{lookahead activo}
f -- si --> g[Salta a la siguiente captura]
f -- no --> h[No hay objeto y el operador se cancela]
e --> i[Selecciona el rango y devuelve el control al operador]
g --> i
style i fill:#a6e3a1,color:#11111b
style h fill:#f38ba8,color:#11111b
style c fill:#cba6f7,color:#11111bComponer sin perder nada de la gramática
Los objetos nuevos entran en el álgebra completa de Vim. yaf copia una función entera; cif vacía su cuerpo y te deja en Inserción; "ayac guarda una clase en el registro a; gUia pasa un parámetro a mayúsculas; >if indenta el cuerpo. En modo Visual, vaf selecciona y vif reduce, y con include_surrounding_whitespace decides si daf se lleva también la línea en blanco que separaba la función de la siguiente, evitando el hueco doble que casi siempre hay que limpiar a mano.
Hay una propiedad sutil que conviene entender bien: el . sigue funcionando, y funciona mejor de lo que esperarías. . no repite el rango calculado, sino las pulsaciones; al reejecutarlas, el mapeo vuelve a consultar el árbol desde la nueva posición del cursor. Por eso daf seguido de . borra la siguiente función aunque tenga otro tamaño, otra forma y otros parámetros. El objeto es una función del contexto, y el . la reevalúa en cada contexto.
La convención de teclas también merece pensarse, porque el espacio de dos letras que empieza por a o por i es finito y ya está medio ocupado. Conviene reservar las letras memorables para los objetos que usas cada minuto —función, clase, parámetro, condicional, bucle, llamada— y aceptar que el resto se alcanzará por selección incremental en lugar de por una tecla dedicada. Un mapeo que no recuerdas es peor que no tenerlo: ocupa espacio mental y no se llega a usar.
Outer
af, ac, aa: el nodo completo con su cabecera y su cierre. Es lo que quieres para mover, copiar o borrar una unidad entera.
Inner
if, ic, ia: solo el contenido. Es lo que quieres para reescribir el cuerpo sin tocar la firma.
Lookahead
Si no estás dentro de ningún objeto, el algoritmo salta al siguiente. Convierte daf en una operación que casi nunca falla.
Nada te obliga a limitarte al catálogo estándar. Cualquier captura que definas en una query propia se convierte en objeto con una línea de configuración, y ahí es donde el sistema deja de ser genérico y empieza a hablar de tu proyecto.
; ~/.config/nvim/after/queries/lua/textobjects.scm
;; extends
((function_call
name: (identifier) @_n
(#eq? @_n "describe")) @test.outer)
vim.keymap.set({ "x", "o" }, "at", function()
require("nvim-treesitter-textobjects.select").select_textobject("@test.outer", "textobjects")
end, { desc = "bloque de prueba completo" })
La primera no es estilo, es una restricción real: nada de guiones ni caracteres especiales en el nombre. El módulo de movimiento interpreta los nombres de captura como patrones de Lua, donde el guion es un cuantificador, así que @custom_capture.inner funciona y @custom-capture.inner produce comportamientos imposibles de diagnosticar. La segunda es una consecuencia del algoritmo: en la selección, una captura se busca dentro del rango de su @*.outer si existe. Por eso puedes definir @call.name o @assignment.lhs con la confianza de que se resolverán dentro de su @call.outer o su @assignment.outer, incluso mirando hacia atrás desde el cursor; y por eso, si defines una captura suelta sin su outer, a veces seleccionará algo distinto de lo que esperabas.
Si af no selecciona nada, el orden de diagnóstico es siempre el mismo: comprueba que el parser del lenguaje está instalado, mira si el buffer tiene nodos de error con el inspector de árbol, y verifica que la query define la captura para ese lenguaje. Un objeto ausente casi nunca es un fallo del plugin: es una gramática sin instalar o una captura que ese lenguaje no publica.
Merece la pena medir la magnitud del salto, porque se disfraza de simple mejora ergonómica. Durante décadas el editor de texto operó bajo una limitación epistémica dura: solo conocía caracteres. Todo lo demás —qué es una función, dónde acaba un bloque, qué palabra es un identificador y cuál una cadena— se aproximaba con expresiones regulares, y esa aproximación fallaba exactamente allí donde el código se vuelve interesante: en el anidamiento, en las cadenas con delimitadores dentro, en los comentarios que contienen código. Las heurísticas eran buenas al noventa por ciento, y el diez por ciento restante era ruido que aprendiste a esquivar sin darte cuenta. Treesitter no mejora la heurística: elimina la necesidad de heurística, sustituyéndola por un análisis sintáctico real, incremental y tolerante a errores. Ese último adjetivo es la clave y explica por qué esto funciona en un editor y no funcionó en veinte intentos anteriores: un compilador se detiene ante el primer error, pero un buffer abierto está casi siempre mal formado, porque estás escribiendo en él. El parser de Treesitter recupera de los errores localmente, aísla el fragmento roto en un nodo de error y mantiene válido todo el resto del árbol; por eso daf sigue acertando en la función de arriba mientras la de abajo está a medio teclear. A eso súmale que el árbol es concreto y no abstracto: conserva cada coma, cada paréntesis y cada palabra clave, lo cual es imprescindible cuando el objetivo no es compilar sino manipular texto con precisión de carácter. Y por encima de todo está la decisión de diseño que hace que nada de esto rompa tus reflejos: los objetos se exponen como mapeos en modo operator-pending, es decir, en el punto de extensión que Vim lleva ofreciendo desde siempre. No hay un modo nuevo, ni un comando nuevo, ni una segunda gramática que aprender. Hay un vocabulario ampliado dentro de la gramática que ya dominas, y esa continuidad es justamente lo que convierte una tecnología de análisis sintáctico en un reflejo motor.
- Escribe una cadena que contenga un paréntesis sin cerrar y comprueba cómo
di(se equivoca desde fuera de ella; repite la operación con el objeto sintáctico equivalente y explica la diferencia. - Abre el inspector de árbol sobre una función anidada dentro de otra y predice, antes de pulsar, qué seleccionará
vafcon el cursor en la interna; verifícalo. - Coloca el cursor en una línea en blanco anterior a una función y ejecuta
dafconlookaheadactivo y desactivado; describe con precisión qué regla del algoritmo explica cada resultado. - Crea un fichero de query propio para tu lenguaje principal con la directiva de extensión y añade una captura nueva; luego bórrala y observa qué ocurre al omitir la directiva.
- Aplica
dafy repite con.sobre tres funciones de tamaños distintos; argumenta por qué el rango borrado cambia en cada repetición. - Define una captura propia con un guion en el nombre, úsala desde
selecty desdemove, y explica por qué solo falla en el segundo. - Cambia
selection_modespara que@function.outerse seleccione por líneas y compara el resultado dedafcon el modo por caracteres.