Navegación estructural: moverse por el árbol, no por el texto
Saltar a la siguiente función, subir al nodo padre y hacer crecer la selección nodo a nodo. El módulo de movimiento del plugin de textobjects, la lista de saltos, la selección incremental nativa de Neovim y una implementación propia con una pila por buffer.
Los movimientos clásicos de Vim recorren una geometría plana: caracteres, palabras, líneas, párrafos, pantallas. Incluso ]], que promete llevarte a la siguiente sección, no es más que una búsqueda de una llave en la primera columna —una convención tipográfica de C que se rompe en cuanto el lenguaje indenta sus definiciones—. Con un árbol de sintaxis disponible, el buffer deja de ser una tira de líneas y pasa a ser un grafo dirigido con jerarquía: cada posición del cursor está simultáneamente dentro de un identificador, de una llamada, de una sentencia, de un bloque y de una función. Navegar estructuralmente es explotar esas dos dimensiones nuevas —el eje de los hermanos y el eje de los ancestros— que el texto plano no tenía.
- Configurar saltos al inicio y al final de funciones, clases y parámetros, y encadenarlos con
;y,. - Preservar la lista de saltos para que
Ctrl-odeshaga cualquier movimiento estructural. - Consultar el nodo bajo el cursor y subir por sus ancestros distinguiendo nodo con nombre de nodo anónimo.
- Usar la selección incremental que Neovim ya trae, y saber cuándo escribir la tuya con una pila por buffer.
- Repartir el teclado sin pisar las teclas que ya tienen dueño.
Saltar entre nodos hermanos
El módulo de movimiento reutiliza exactamente las mismas capturas que los objetos de texto: si @function.outer existe para tu lenguaje, existen los cuatro saltos que se derivan de ella —al inicio y al final del siguiente nodo, al inicio y al final del anterior—. La simetría no es adorno: pulsar ]f para ir al inicio de la siguiente función y ]F para ir a su final te da dos anclas distintas, y la segunda es la que quieres cuando vas a insertar código justo detrás de una definición.
Todo lo que sigue usa el plugin de objetos de texto que ya montaste en la lección 2.3.
local mv = require("nvim-treesitter-textobjects.move")
local function salto(fn, captura)
return function() mv[fn](captura, "textobjects") end
end
vim.keymap.set({ "n", "x", "o" }, "]f", salto("goto_next_start", "@function.outer"))
vim.keymap.set({ "n", "x", "o" }, "]F", salto("goto_next_end", "@function.outer"))
vim.keymap.set({ "n", "x", "o" }, "[f", salto("goto_previous_start", "@function.outer"))
vim.keymap.set({ "n", "x", "o" }, "[F", salto("goto_previous_end", "@function.outer"))
vim.keymap.set({ "n", "x", "o" }, "]a", salto("goto_next_start", "@parameter.inner"))
vim.keymap.set({ "n", "x", "o" }, "]c", salto("goto_next_start", "@class.outer"))
Las seis funciones del módulo son goto_next_start, goto_next_end, goto_previous_start, goto_previous_end y, para movimientos de grano más fino, goto_next y goto_previous, que van al extremo más cercano sea cual sea. Todas aceptan una captura o una lista de capturas, y todas admiten el grupo de query como segundo argumento, así que puedes saltar por ámbitos con "locals" o por pliegues con "folds" sin salir del mismo mecanismo.
-- Una tecla para varias capturas a la vez
vim.keymap.set({ "n", "x", "o" }, "]o", function()
mv.goto_next_start({ "@loop.inner", "@loop.outer" }, "textobjects")
end)
-- Y saltos que no salen de textobjects.scm
vim.keymap.set({ "n", "x", "o" }, "]s", function()
mv.goto_next_start("@local.scope", "locals")
end)
vim.keymap.set({ "n", "x", "o" }, "]z", function()
mv.goto_next_start("@fold", "folds")
end)
Dos detalles elevan esto de truco a herramienta. El primero es que los saltos también viven en modo operator-pending, de modo que d]f borra desde el cursor hasta el principio de la siguiente función: un rango que ninguna combinación de movimientos clásicos podía expresar con esa precisión. El segundo es la lista de saltos. Un movimiento que atraviesa medio fichero debe ser reversible, y para ello el plugin marca la posición previa antes de mover el cursor; con set_jumps activado, Ctrl-o te devuelve exactamente donde estabas y Ctrl-i te trae de vuelta.
-- Reutiliza ; y , para repetir el ultimo movimiento estructural,
-- sin perder su comportamiento con f, t, F y T.
local rep = require("nvim-treesitter-textobjects.repeatable_move")
vim.keymap.set({ "n", "x", "o" }, ";", rep.repeat_last_move_next)
vim.keymap.set({ "n", "x", "o" }, ",", rep.repeat_last_move_previous)
vim.keymap.set({ "n", "x", "o" }, "f", rep.builtin_f_expr, { expr = true })
vim.keymap.set({ "n", "x", "o" }, "F", rep.builtin_F_expr, { expr = true })
vim.keymap.set({ "n", "x", "o" }, "t", rep.builtin_t_expr, { expr = true })
vim.keymap.set({ "n", "x", "o" }, "T", rep.builtin_T_expr, { expr = true })
Ese bloque merece leerse dos veces. repeat_last_move_next y repeat_last_move_previous fuerzan que ; vaya siempre hacia delante y , siempre hacia atrás, sin importar en qué dirección fue el último salto; si prefieres el comportamiento clásico de Vim —que ; repita en la dirección que llevabas— existen repeat_last_move y repeat_last_move_opposite. Y las cuatro expresiones que envuelven f, F, t y T son lo que impide que reasignar ; te rompa la búsqueda dentro de la línea: sin ellas, ganarías el salto estructural y perderías uno de los movimientos más usados de Vim.
Subir al padre: el eje vertical
El movimiento entre hermanos es útil, pero la dimensión verdaderamente nueva es la ascendente. Neovim expone el árbol directamente, sin plugins, y con tres llamadas tienes todo lo necesario para escribir tus propios movimientos.
local nodo = vim.treesitter.get_node() -- nodo con nombre mas pequeno bajo el cursor
if nodo then
print(nodo:type()) -- por ejemplo function_call
local padre = nodo:parent() -- ascender un nivel
local sr, sc, er, ec = nodo:range() -- filas y columnas en base cero
print(vim.treesitter.get_node_text(nodo, 0))
end
Hay que entender qué devuelve exactamente esa consulta, porque de ello depende toda la ergonomía posterior. El árbol contiene nodos con nombre, que son los que la gramática considera categorías gramaticales —una llamada, un identificador, un bloque—, y nodos anónimos, que son los literales: la coma, el paréntesis, la palabra clave if. La consulta por defecto devuelve el nodo con nombre más pequeño que cubre la posición, porque los anónimos casi nunca son unidades sobre las que quieras operar; con node:parent() asciendes por la cadena completa de ancestros, y ahí sí puede haber nodos intermedios que la gramática usa como andamiaje y que, al recorrerlos uno a uno, producen crecimientos de selección que al usuario le parecen imperceptibles.
Un segundo matiz técnico con consecuencias reales: las columnas que devuelve el rango son desplazamientos en bytes, no en caracteres. Si tu código contiene identificadores acentuados, cadenas con emoji o comentarios en cualquier alfabeto no latino, colocar el cursor con esa columna sin convertirla producirá desalineaciones sutiles. Neovim acepta columnas en bytes al fijar el cursor en una ventana, así que la ruta directa es segura; el problema aparece cuando mezclas esos valores con funciones que cuentan caracteres.
Eje de hermanos
]f y [f recorren nodos del mismo tipo al mismo nivel. Es la navegación que sustituye a ]] y funciona en cualquier lenguaje con gramática.
Eje de ancestros
node:parent() sube de expresión a sentencia, de sentencia a bloque, de bloque a función. Es la dimensión que el texto plano no tenía.
Inspector
El visor de árbol integrado muestra el nodo bajo el cursor en vivo. Es la herramienta de diagnóstico obligatoria antes de escribir cualquier query.
Selección incremental: la que ya tienes
La selección incremental es la interfaz más natural para el eje ascendente: empiezas en el nodo más pequeño y creces hacia sus ancestros con una tecla, decreces con otra. Durante años esto exigió un módulo de plugin, y la mayoría de las configuraciones que circulan por internet siguen configurando uno que ya no existe. No hace falta: Neovim la trae de serie, mapeada en modo Visual y sin instalar nada.
an
En modo Visual, selecciona el nodo padre. Con un contador, sube tantos niveles como le digas.
in
Baja al nodo hijo, deshaciendo el crecimiento. Es la pareja de an.
]n y [n
Mueven la selección al nodo siguiente o al anterior, en lugar de agrandarla.
]N y [N
Extienden la selección hasta abarcar también el nodo siguiente o el anterior.
Si el buffer no tiene parser, an e in no fallan: recurren al rango de selección que ofrezca el servidor de lenguaje. Detrás de las cuatro parejas hay una única función, vim.treesitter.select, que recibe hacia dónde ir —parent, child, next, prev, extend_next o extend_prev— y un contador opcional, y que arranca la selección si no había ninguna. Con ella puedes ponerle las teclas que quieras.
-- Enter crece, Retroceso encoge. Funcionan desde Normal, porque select
-- inicia la seleccion visual si aun no hay ninguna.
vim.keymap.set({ "n", "x" }, "<CR>", function()
vim.treesitter.select("parent")
end, { desc = "crecer seleccion" })
vim.keymap.set("x", "<BS>", function()
vim.treesitter.select("child")
end, { desc = "reducir seleccion" })
Media internet mapea la selección incremental a Ctrl-Espacio, y es de las peores decisiones de reparto de teclado que puedes copiar. Esa combinación es la que dispara el autocompletado: la del completado nativo de Neovim 0.12 y también la que traen por defecto blink.cmp y nvim-cmp. Si se la quitas, el autocompletado deja de responder en modo Inserción y el síntoma no se parece en nada a la causa, así que perderás una tarde buscando en el sitio equivocado. Con Tab pasa lo mismo: parece libre porque no ves nada mapeado, y en realidad es la tecla con la que se navega el menú de completado.
La regla del track es sencilla y la vas a agradecer: Ctrl-Espacio y Tab son intocables, los saltos estructurales van en los prefijos ] y [ que Vim reserva precisamente para eso, y las acciones van detrás de <leader>. Y s y S son de flash.nvim, con mini.surround en el prefijo gs (lección 2.3).
Y si prefieres escribirla tú
Merece la pena implementarla a mano una vez, aunque después uses la nativa, porque aclara todo lo anterior. La única sutileza de diseño es que hace falta memoria: para decrecer necesitas recordar por dónde subiste, porque desde un nodo grande no hay forma de saber cuál de sus descendientes era el original.
local pilas = {} -- una pila de nodos por buffer
local function mismo_rango(a, b)
local a1, a2, a3, a4 = a:range()
local b1, b2, b3, b4 = b:range()
return a1 == b1 and a2 == b2 and a3 == b3 and a4 == b4
end
local function seleccionar(nodo)
local sr, sc, er, ec = nodo:range()
vim.fn.setpos("'<", { 0, sr + 1, sc + 1, 0 })
vim.fn.setpos("'>", { 0, er + 1, ec, 0 })
vim.cmd("normal! gv")
end
local function crecer()
local buf = vim.api.nvim_get_current_buf()
local pila = pilas[buf] or {}
local actual = pila[#pila]
if not actual then
actual = vim.treesitter.get_node()
if not actual then return end
pila[#pila + 1] = actual
else
local padre = actual:parent()
-- ignora ancestros que no amplian el rango: no aportan nada visible
while padre and mismo_rango(padre, actual) do padre = padre:parent() end
if not padre then return end
pila[#pila + 1] = padre
end
pilas[buf] = pila
seleccionar(pila[#pila])
end
local function decrecer()
local buf = vim.api.nvim_get_current_buf()
local pila = pilas[buf]
if not pila or #pila < 2 then return end
pila[#pila] = nil
seleccionar(pila[#pila])
end
-- Teclas propias, para no pisar las nativas an / in / ]n / [n.
-- Y nunca Ctrl-Espacio ni Tab: esas son del completado.
vim.keymap.set({ "n", "x" }, "<CR>", crecer, { desc = "crecer seleccion" })
vim.keymap.set("x", "<BS>", decrecer, { desc = "reducir seleccion" })
Hay un detalle en mismo_rango que parece pedantería y no lo es. En Lua, comparar padre:range() == actual:range() no compara los cuatro valores: una llamada a función en el lado de una comparación se recorta a su primer valor de retorno, así que estarías comparando solo la fila inicial y descartando ancestros que sí ampliaban el rango. Es un fallo silencioso, produce saltos de selección aparentemente aleatorios y aparece copiado en decenas de configuraciones.
La pila hay que invalidarla cuando el buffer cambia o cuando sales de modo Visual; en caso contrario, la próxima llamada crecerá desde un nodo obsoleto cuyo rango ya no corresponde al texto. Un autocomando sobre el evento de salida del modo Visual que ponga la pila a cero es suficiente y evita el fallo más desconcertante de esta clase de herramientas.
Si encuentras una configuración con require("nvim-treesitter.configs").setup({ incremental_selection = { ... } }), está escrita para la rama master de nvim-treesitter, congelada y limitada a Neovim 0.11. Ese módulo desapareció en la reescritura y no tiene sustituto en ningún plugin: o usas las teclas nativas, o escribes las treinta líneas de arriba.
flowchart BT n1[identificador] --> n2[expresion de llamada] n2 --> n3[sentencia] n3 --> n4[bloque] n4 --> n5[definicion de funcion] n5 --> n6[modulo] style n1 fill:#89b4fa,color:#11111b style n5 fill:#a6e3a1,color:#11111b style n6 fill:#cba6f7,color:#11111b
Vale la pena examinar qué se gana realmente, porque no es velocidad de desplazamiento sino algo más profundo: la desaparición de un tipo entero de trabajo cognitivo. Cuando navegas por líneas, tu cerebro sostiene continuamente un modelo aproximado de dónde estás —“la función empezaba por ahí arriba, la clase debe de acabar hacia el final”— y ese modelo se reconstruye leyendo. Es un coste que pagas cada pocos segundos y que ni siquiera percibes, del mismo modo que no percibes el esfuerzo de mantener el equilibrio al andar. La navegación por árbol externaliza ese modelo: el editor ya sabe la jerarquía, con exactitud, y te la ofrece como movimiento. Pulsar ]f no es “ir más rápido a la siguiente función”: es dejar de tener que localizarla. Y la incrementalidad de la selección lleva la idea a su forma más pura, porque invierte la dirección habitual del trabajo. Con objetos de texto tú declaras qué quieres —una función, un parámetro— y el editor lo encuentra; con selección incremental no declaras nada: partes del punto donde está el cursor y exploras hacia arriba, viendo cómo la selección se expande por unidades sintácticas reales hasta que abarca justo lo que querías. Eso resuelve el caso que ningún objeto nombrado cubre: las estructuras intermedias que no tienen nombre en tu vocabulario de teclas, la condición de un if con dos operandos, el segundo argumento de una llamada anidada dentro de otra, la rama de un match. Ahí no hay una captura preparada, pero sí hay un nodo, y un nodo siempre se puede alcanzar subiendo. Fíjate además en la disciplina de ingeniería que ha hecho falta para que esto se sienta trivial: la pila por buffer, la invalidación al editar, el salto de ancestros que no amplían el rango, el respeto por la lista de saltos, las columnas en bytes. Ninguno de esos detalles se nota cuando están bien resueltos, y todos se notan cuando no lo están. La navegación estructural es, en el fondo, una demostración de que la calidad de una herramienta de edición se mide por la cantidad de razonamiento que te ahorra sin que llegues a enterarte.
- Mapea los cuatro saltos de función y usa
d]fpara borrar desde una posición arbitraria hasta la siguiente definición; explica por qué ese rango no era expresable con movimientos clásicos. - Desactiva
set_jumps, cruza tres funciones y comprueba qué haceCtrl-o; vuelve a activarlo y describe la diferencia en términos de la lista de saltos. - Con el inspector abierto, imprime el tipo del nodo bajo el cursor y el de sus cinco ancestros dentro de una llamada anidada; identifica cuáles no amplían el rango.
- Selecciona una expresión en modo Visual y crece con
anusando un contador; después compárala con]N, que extiende en lugar de subir. - Implementa la selección incremental del capítulo y añade un autocomando que vacíe la pila al salir de modo Visual; provoca el fallo con y sin él.
- Sustituye
mismo_rangopor la comparación directa de los dosrange()y observa qué ancestros se saltan por culpa del recorte de valores de retorno de Lua. - Ejecuta
:map <C-Space>y:map <Tab>en tu configuración real y comprueba tú mismo que nadie se los ha llevado.