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

Refactorizaciones: renombrar, intercambiar y reescribir sobre el árbol

Renombrar dentro del ámbito correcto con las queries de locals, intercambiar parámetros con rangos exactos, y la disciplina que exige toda edición basada en el árbol: aplicar los cambios en orden inverso, mantenerlos en un solo paso de deshacer y verificar el resultado buscando nodos de error.

⏱ 21 min

Refactorizar es reescribir preservando el significado, y esa definición contiene ya el problema entero: preservar el significado exige conocerlo. Una sustitución con :%s reescribe con eficacia absoluta y sin la menor idea de qué está tocando; por eso renombrar una variable con expresiones regulares acierta casi siempre y falla exactamente donde duele —dentro de una cadena, en un comentario, en otra función que reutiliza el mismo nombre para otra cosa—. El árbol de sintaxis no da significado completo, pero da algo intermedio y sorprendentemente potente: la estructura de ámbitos, definiciones y referencias. Con ella, un conjunto de refactorizaciones locales pasa de heurística arriesgada a operación exacta, y el resto queda claramente delimitado como territorio del servidor de lenguaje.

🎯 Al terminar esta lección sabrás
  • Leer las capturas de locals y distinguir ámbito, definición y referencia en el árbol.
  • Saber en qué estado está hoy ese grupo de queries y qué garantías te da realmente.
  • Renombrar dentro del ámbito correcto y saber exactamente cuándo esa técnica es insuficiente frente al renombrado semántico.
  • Intercambiar parámetros y hermanos sintácticos con las dos funciones del módulo swap.
  • Aplicar ediciones múltiples en orden inverso, en un único paso de deshacer, y verificar el resultado.

Ámbitos, definiciones y referencias

Además de resaltado y objetos, cada gramática publica una query llamada locals cuyo propósito es describir la estructura de nombres del programa. Sus capturas son pocas y muy expresivas: una marca los nodos que abren un ámbito —una función, un bloque, un módulo—, otra marca las definiciones de nombres, con variantes según se defina una variable, un parámetro, una función o un tipo, y otra marca las referencias, es decir, cada uso de un nombre. Con esas tres piezas se resuelve el problema de resolución de nombres puramente sintáctico: dada una referencia, se asciende por la cadena de ámbitos hasta encontrar el ámbito más cercano que contenga una definición con el mismo texto.

local buf = 0
local parser = assert(vim.treesitter.get_parser(buf))
local lang = parser:lang()
local root = parser:parse()[1]:root()
local q = assert(vim.treesitter.query.get(lang, "locals"), "este lenguaje no trae locals")

for id, nodo in q:iter_captures(root, buf) do
  local captura = q.captures[id]        -- local.scope, local.definition, local.reference
  if captura:find("definition") then
    print("define", vim.treesitter.get_node_text(nodo, buf))
  end
end
⚠️
Las queries de locals son terreno en retirada

Conviene saber en qué te apoyas. nvim-treesitter sigue distribuyendo ficheros locals.scm, pero ya no los usa para nada: se mantienen por compatibilidad, y esa compatibilidad es explícitamente limitada. Traducido a decisiones: son una base perfectamente utilizable para una herramienta tuya, porque el motor de queries de Neovim los lee igual que cualquier otro grupo, pero no son un contrato tan firme como el de highlights o el de textobjects, pueden faltar en lenguajes recién añadidos y su calidad varía mucho de uno a otro. Comprueba siempre que existen antes de construir sobre ellos, que es justo lo que hace el assert de arriba.

Ese recorrido resuelve el sombreado correctamente, que es su mérito principal: si una función interna redefine n, sus referencias apuntan a la definición interna y no a la externa, porque el ámbito más cercano gana. Y resuelve también el problema que arruina la sustitución textual, porque las cadenas y los comentarios simplemente no producen nodos de referencia: no aparecen en el recorrido y por tanto no se tocan.

Renombrar: el límite exacto de lo sintáctico

Con lo anterior, un renombrado local es directo: localiza la definición asociada a la referencia bajo el cursor, recoge todas las referencias que se resuelven a esa misma definición y sustituye sus rangos.

local function renombrar_local(nuevo)
  local buf = vim.api.nvim_get_current_buf()
  local nodo = vim.treesitter.get_node()
  if not nodo then return end
  local nombre = vim.treesitter.get_node_text(nodo, buf)

  local parser = vim.treesitter.get_parser(buf)
  if not parser then return end
  local root = parser:parse()[1]:root()
  local q = vim.treesitter.query.get(parser:lang(), "locals")
  if not q then return end

  local rangos = {}
  for id, n in q:iter_captures(root, buf) do
    local cap = q.captures[id]
    if (cap:find("definition") or cap:find("reference"))
      and vim.treesitter.get_node_text(n, buf) == nombre then
      rangos[#rangos + 1] = { n:range() }
    end
  end

  -- clave: de abajo arriba, para que ningun rango se invalide al editar
  table.sort(rangos, function(a, b)
    if a[1] ~= b[1] then return a[1] > b[1] end
    return a[2] > b[2]
  end)
  for _, r in ipairs(rangos) do
    vim.api.nvim_buf_set_text(buf, r[1], r[2], r[3], r[4], { nuevo })
  end
end

Ahora la parte incómoda y necesaria: esto no es un renombrado correcto en general, y hay que saber por qué. El análisis es sintáctico, no semántico. No conoce el sistema de tipos, así que no distingue un método abrir de una clase del método abrir de otra. No cruza ficheros, así que un símbolo exportado se renombra aquí y queda roto en los otros cuarenta módulos que lo importan. No entiende alias de importación, ni herencia, ni sobrecarga, ni macros que generen nombres. El renombrado del servidor de lenguaje sí conoce todo eso, porque lo respalda un compilador o un analizador completo, y devuelve una edición de espacio de trabajo que abarca todos los ficheros afectados. La regla operativa es nítida: cuando haya servidor de lenguaje, renombra con él; el renombrado por árbol es el recurso para variables estrictamente locales, para lenguajes sin servidor, y para ficheros de configuración o datos donde no hay más semántica que la estructura.

ℹ️
Dos herramientas, dos alcances

El árbol es local, instantáneo y siempre disponible, incluso con el fichero a medio escribir. El servidor de lenguaje es global, semántico y correcto, pero necesita un proyecto que compile y un índice al día. No compiten: cubren regímenes distintos. La destreza consiste en saber en cuál de los dos estás.

Intercambiar hermanos sintácticos

Cambiar de sitio dos parámetros es la refactorización que mejor ilustra la diferencia entre editar texto y editar estructura. Textualmente exige recortar dos fragmentos de longitud distinta, respetar los separadores, preservar los espacios y no confundirse si uno de ellos ocupa varias líneas o contiene una llamada anidada con sus propias comas. Estructuralmente es una permutación de dos nodos hermanos, y el plugin de objetos la ofrece resuelta.

El módulo swap de nvim-treesitter-textobjects expone exactamente dos funciones, swap_next y swap_previous, y como todo en ese plugin no trae mapeos: los pones tú, y los pones detrás de <leader> porque son acciones, no movimientos.

Sigue siendo el mismo plugin de la lección 2.3: aquí solo cambia el módulo que le pides.

local swap = require("nvim-treesitter-textobjects.swap")

vim.keymap.set("n", "<leader>cp", function() swap.swap_next("@parameter.inner") end,
  { desc = "parametro hacia la derecha" })
vim.keymap.set("n", "<leader>cA", function() swap.swap_previous("@parameter.inner") end,
  { desc = "parametro hacia la izquierda" })
vim.keymap.set("n", "<leader>cf", function() swap.swap_next("@function.outer") end,
  { desc = "reordenar funciones" })

El mismo mecanismo sirve para envolver: seleccionar un nodo con un objeto sintáctico, guardarlo y reinsertarlo dentro de una estructura nueva es una refactorización de dos pasos que ya sabes ejecutar.

-- Envolver el nodo bajo el cursor en un bloque protegido
vim.keymap.set("n", "<leader>rp", function()
  local n = vim.treesitter.get_node()
  if not n then return end
  local sr, sc, er, ec = n:range()
  local txt = vim.treesitter.get_node_text(n, 0)
  vim.api.nvim_buf_set_text(0, sr, sc, er, ec, { "pcall(function() return " .. txt .. " end)" })
end, { desc = "envolver expresion" })

La operación se generaliza a cualquier captura con hermanos: parámetros, argumentos, elementos de una lista, campos de un registro, funciones completas dentro de un fichero. Y como el intercambio se define sobre nodos, un parámetro de tres líneas con comentario propio viaja entero, con su comentario, sin que tengas que seleccionarlo.

flowchart LR
a[Nodo bajo el cursor] --> b[Query locals o textobjects]
b --> c[Conjunto de rangos exactos]
c --> d[Ordenar de abajo hacia arriba]
d --> e[Aplicar ediciones en el buffer]
e --> f[Reparse incremental]
f --> g{Hay nodos de error}
g -- no --> h[Refactor valido en un solo undo]
g -- si --> i[Deshacer y revisar]
style h fill:#a6e3a1,color:#11111b
style i fill:#f38ba8,color:#11111b
style c fill:#cba6f7,color:#11111b

La disciplina de toda edición por árbol

Tres reglas convierten un script frágil en una refactorización fiable, y las tres se derivan de la misma observación: los rangos que la query devolvió describen el buffer anterior a tu primera edición.

La primera es el orden inverso. En cuanto insertas texto en la línea diez, todos los rangos posteriores de esa línea quedan desplazados y todos los de líneas siguientes también si tu edición cambió el número de líneas. Aplicar de abajo hacia arriba y de derecha a izquierda garantiza que cada edición ocurre en una región cuyos rangos aún no se han tocado. La alternativa —recalcular el árbol tras cada cambio— es correcta pero mucho más costosa y, sobre todo, mucho más difícil de razonar.

La segunda es la atomicidad para deshacer. Una refactorización que toca veinte sitios debe deshacerse con un solo u; si el usuario tiene que pulsar veinte veces, la operación es inutilizable en la práctica y peligrosa en el peor momento. Las ediciones aplicadas dentro de una misma llamada síncrona quedan en el mismo bloque de deshacer, así que la regla es no partir el trabajo en varias vueltas del bucle de eventos ni intercalar cambios de modo.

La tercera es la verificación posterior. Tienes un parser: úsalo como postcondición. Tras aplicar los cambios, vuelve a analizar y pregunta al nodo raíz si contiene errores. Si aparecieron y antes no había, tu refactorización rompió la sintaxis y lo correcto es deshacer y avisar en lugar de dejar al usuario con un fichero inválido.

local function valido(buf)
  local parser = vim.treesitter.get_parser(buf)
  if not parser then return true end     -- sin arbol no hay postcondicion que comprobar
  local root = parser:parse(true)[1]:root()   -- true: reparsea el buffer entero
  return not root:has_error()
end
Refactorizar por árbol es aceptar un contrato de corrección que la expresión regular nunca ofreció

Conviene detenerse en lo que realmente separa estas operaciones de una sustitución bien escrita, porque la diferencia no es de potencia sino de garantías, y las garantías son lo que permite automatizar sin miedo. Una expresión regular tiene una tasa de acierto, no una demostración: aciertas el noventa y ocho por ciento de las veces y el dos por ciento restante lo descubres en producción, o no lo descubres. El árbol cambia la naturaleza del enunciado: no se sustituyen las apariciones de un texto, se sustituyen los nodos que la gramática clasificó como referencias que se resuelven a una definición concreta. Ese es un enunciado verificable, y su alcance está perfectamente delimitado —termina donde termina la sintaxis—, lo cual es una virtud y no un defecto, porque una herramienta que sabe dónde acaba su competencia es infinitamente más útil que una que se equivoca en silencio. De ahí que la frontera con el servidor de lenguaje sea sana: el árbol garantiza cosas locales instantáneamente y sobre buffers rotos; el analizador semántico garantiza cosas globales a cambio de un proyecto que compile. Ahora bien, hay una lección más honda en la disciplina de las tres reglas. Ordenar las ediciones de abajo arriba, encerrarlas en un bloque de deshacer y verificar la postcondición con el propio parser no son detalles de implementación: son la traducción, a escala de editor, de lo que en sistemas se llama una transacción. Atomicidad, porque o se aplican todas o ninguna. Consistencia, porque el estado final debe seguir siendo un programa sintácticamente válido. Reversibilidad, porque un solo u restaura el estado previo. Y es precisamente esa propiedad transaccional la que hace que una refactorización automática deje de ser un truco arriesgado y se convierta en algo que ejecutas sin pensarlo sobre un fichero de mil líneas. La confianza no viene de que la herramienta sea lista, sino de que su fallo sea detectable y su deshacer sea barato. Diseña siempre así lo que escribas sobre el árbol: no aspires a no equivocarte nunca, aspira a que equivocarse cueste una tecla.

⚔️ Reescribir con garantías
  1. Recorre la query de locals de tu lenguaje e imprime cada definición con su ámbito contenedor; comprueba el comportamiento con una variable sombreada.
  2. Implementa el renombrado local del capítulo y ejecútalo sobre un identificador cuyo nombre aparezca también dentro de una cadena y de un comentario; explica por qué esos usos no se tocan.
  3. Renombra un símbolo exportado con tu implementación y luego con el servidor de lenguaje; enumera los ficheros que cada uno modifica.
  4. Mapea el intercambio de parámetros y pruébalo con un argumento multilínea que lleve un comentario propio; describe qué habría hecho falta hacer a mano.
  5. Elimina la ordenación inversa de rangos y observa el resultado sobre un identificador que aparezca tres veces en la misma línea; después añade la verificación de nodos de error y provoca un fallo deliberado.