Integrar herramientas: del texto plano a la quickfix y a los diagnósticos
Cómo envolver un linter o un formateador externo: parsear su salida con errorformat o con JSON, volcarla en la lista quickfix, publicarla como diagnósticos en un namespace propio y reemplazar el buffer sin destruir la posición del cursor.
Un linter que escribe en tu terminal es una herramienta. El mismo linter cuyos avisos aparecen subrayados en la columna exacta, navegables con una tecla y sincronizados con cada guardado, es parte de tu editor. La distancia entre ambas cosas son unas cuarenta líneas de Lua, y consiste enteramente en traducir: de bytes sin estructura a entidades con posición.
- Convertir la salida de una herramienta en entradas estructuradas con
errorformato JSON. - Poblar la lista quickfix y su variante local con
setqflistysetloclist. - Publicar diagnósticos propios en un
namespaceaislado del LSP. - Aplicar el resultado de un formateador sin perder cursor, marcas ni historial.
Traducir la salida
Toda herramienta de línea de comandos emite posiciones en algún dialecto de archivo dos puntos línea dos puntos columna. Neovim lleva décadas parseando eso y su motor se llama errorformat: una lista de patrones donde %f es el archivo, %l la línea, %c la columna, %t el tipo y %m el mensaje.
No hace falta tocar la opción global. getqflist sabe aplicar un errorformat a una lista de líneas y devolverte las entradas ya estructuradas:
local function parsear(lineas, efm)
return vim.fn.getqflist({ lines = lineas, efm = efm }).items
end
local items = parsear(salida, [[%f:%l:%c: %t%*[^:]: %m]])
Cada entrada resultante trae bufnr, lnum, col, type, text y valid. Ese último campo es el que separa las líneas que encajaron del ruido; filtrar por valid distinto de cero es siempre el primer paso.
Cuando la herramienta ofrece salida JSON, prefiérela sin dudarlo. Un errorformat es una heurística sobre texto pensado para humanos, y se rompe el día que la herramienta cambia una coma. El JSON es un contrato:
local ok, informe = pcall(vim.json.decode, obj.stdout)
if not ok then return end
local items = vim.tbl_map(function(d)
return { filename = d.path, lnum = d.line, col = d.column, text = d.message, type = 'W' }
end, informe.diagnostics)
Las herramientas externas casi siempre cuentan líneas y columnas desde 1. La lista quickfix también. Pero la API de diagnósticos de Neovim usa líneas desde 0, y las columnas del LSP se miden en unidades UTF-16, no en bytes. Cada frontera entre estos tres sistemas es un error por uno esperando a ocurrir, y el síntoma es un subrayado desplazado una línea o un carácter.
Volcarlo a la quickfix
Con las entradas en la mano, la quickfix es una sola llamada. Usa siempre la forma con diccionario, que permite darle un título y evita el comportamiento posicional heredado:
vim.fn.setqflist({}, ' ', {
title = 'ruff',
items = items,
})
vim.cmd.copen()
El segundo argumento merece explicación porque es la fuente de mucha confusión: un espacio crea una lista nueva en la pila, r reemplaza la actual y a añade a ella. Para un linter que se relanza al guardar, la elección correcta es r si quieres una única lista que se refresca, o el espacio si quieres poder volver con :colder a los resultados anteriores.
La decisión de fondo no es quickfix contra nada, sino quickfix contra location list. La quickfix es global: hay una por ventana de resultados y su contenido es del proyecto entero. La location list es local a una ventana, y por eso es el destino natural de un linter que solo analiza el archivo actual. Confundirlas produce el efecto irritante de que analizar un archivo borre los resultados de la búsqueda que estabas siguiendo.
flowchart LR h[Herramienta externa] --> s[stdout con texto o JSON] s --> p[Parseo con errorformat o vim.json.decode] p --> i[Lista de entradas con archivo linea columna y mensaje] i --> q[Quickfix o location list] i --> d[vim.diagnostic.set en un namespace propio] q --> nav[Navegacion con cnext y cprev] d --> vis[Signos virtuales y subrayado en el buffer]
Diagnósticos propios
La quickfix es una lista que consultas; los diagnósticos son anotaciones que viven en el buffer. Neovim no reserva su sistema de diagnósticos para el LSP: cualquiera puede publicar en él, siempre que se identifique con un namespace propio.
local ns = vim.api.nvim_create_namespace('mi_linter')
local function publicar(bufnr, items)
local diags = vim.tbl_map(function(it)
return {
lnum = it.lnum - 1, -- la API cuenta desde cero
col = math.max(it.col - 1, 0),
end_lnum = it.lnum - 1,
message = it.text,
severity = it.type == 'E' and vim.diagnostic.severity.ERROR
or vim.diagnostic.severity.WARN,
source = 'mi_linter',
}
end, items)
vim.diagnostic.set(ns, bufnr, diags)
end
El namespace es lo que hace esto seguro. vim.diagnostic.set reemplaza todos los diagnósticos de ese namespace en ese buffer y no toca los de nadie más, así que tu linter puede relanzarse cien veces sin borrar lo que publicó el servidor de lenguaje. Y limpiar es igual de aislado: vim.diagnostic.reset con tu namespace y nada más desaparece.
A cambio heredas gratis todo el aparato de presentación: signos en la columna, texto virtual, subrayado, la ventana flotante al posarte encima, la navegación con vim.diagnostic.jump y el filtrado por severidad. Nada de eso hay que escribirlo.
Los dos destinos no son excluyentes y conviene tenerlos a la vez, porque responden a preguntas distintas. Los diagnósticos contestan qué pasa aquí, con el contexto delante; la quickfix contesta qué pasa en el proyecto, y permite recorrerlo. Neovim ofrece el puente en una línea:
vim.diagnostic.setqflist({ severity = vim.diagnostic.severity.ERROR })
Esa función recoge los diagnósticos de todas las fuentes activas —el LSP, tu linter, cualquier otra— los filtra por severidad y los vuelca en la quickfix ya ordenados. Publicar en el namespace y dejar que el usuario decida cuándo materializar la lista es mejor diseño que mantener tú dos copias sincronizadas a mano.
Engancha el análisis a BufWritePost para herramientas lentas y a TextChanged con un debounce de unos cientos de milisegundos para las rápidas. Cancela siempre la ejecución anterior antes de lanzar la nueva: sin eso, teclear rápido acumula procesos que compiten por publicar en el mismo namespace y el último en terminar gana, que no es necesariamente el de la versión más reciente del buffer.
El caso del formateador
Formatear parece más simple que analizar y es más peligroso, porque no anota: sustituye. Un formateador mal integrado te teletransporta al principio del archivo, pierde tus marcas y convierte una operación en un salto de historial que no puedes deshacer con precisión.
El patrón correcto envía el buffer por stdin, comprueba el código de salida y solo entonces escribe:
local lineas = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local tick = vim.api.nvim_buf_get_changedtick(0)
vim.system({ 'stylua', '-' }, { stdin = lineas, text = true }, function(obj)
vim.schedule(function()
if obj.code ~= 0 then
return vim.notify(obj.stderr, vim.log.levels.ERROR)
end
if vim.api.nvim_buf_get_changedtick(0) ~= tick then return end -- ya cambio
local vista = vim.fn.winsaveview()
vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(obj.stdout, '\n'))
vim.fn.winrestview(vista)
end)
end)
Tres salvaguardas y ninguna sobra. La primera es de correctitud del formateador; la segunda, de correctitud temporal; la tercera, de respeto al usuario. El code distinto de cero significa que el formateador no pudo parsear el archivo, y aplicar su salida en ese caso vaciaría el buffer. El changedtick detecta que el usuario siguió escribiendo durante los milisegundos que tardó el proceso, y aplicar un formato calculado sobre una versión vieja destruiría esas ediciones. Y winsaveview con winrestview conserva cursor y desplazamiento, que es la diferencia entre una herramienta invisible y una que te expulsa de donde estabas.
La lección que casi nadie extrae de integrar su primera herramienta es que el código difícil no está en ejecutar el proceso —eso son tres líneas— sino en reconciliar dos modelos del mundo que no coinciden. Una herramienta externa razona sobre un archivo: una secuencia inmutable de bytes en disco, identificada por una ruta, con líneas numeradas desde uno y columnas contadas en bytes. Neovim razona sobre un buffer: una estructura viva en memoria, identificada por un número, que puede no tener ruta, que puede diferir del disco, que tiene marcas y pliegues y ediciones extendidas ancladas a posiciones, y cuyas columnas la API de diagnósticos mide desde cero mientras el LSP las mide en unidades UTF-16. Cada campo que copias de un lado al otro cruza esa frontera, y cada cruce admite un error por uno. Pero hay una asimetría más profunda que la aritmética: el archivo es estático y el buffer es temporal. La herramienta analiza un instante congelado; para cuando responde, ese instante ya pasó. Toda la robustez de una integración seria se reduce a tomarse en serio esa desincronización, y por eso el changedtick no es una optimización sino la condición de corrección: sin él estás aplicando la respuesta a una pregunta que ya no es la que se hizo. Interioriza esto y las tres decisiones de diseño de la lección dejan de ser recetas y se vuelven consecuencias. El namespace existe porque varias fuentes anotan el mismo buffer y necesitan poder equivocarse por separado. La location list existe porque el ámbito del análisis debe reflejarse en el ámbito de la lista. Y winsaveview existe porque en un buffer, a diferencia de un archivo, hay alguien mirando.
Parsea con getqflist y un errorformat local, o mejor con JSON si la herramienta lo ofrece. Vuelca al quickfix para resultados de proyecto y a la location list para los del archivo. Publica diagnósticos en tu propio namespace recordando que la API cuenta líneas desde cero. Y al formatear, comprueba code y changedtick antes de escribir, y restaura la vista después.
- Ejecuta un linter de tu lenguaje con
vim.system, parsea su salida congetqflisty unerrorformatpropio, y ábrela concopen. - Cambia el destino a la location list y explica en qué caso cada una es la elección correcta.
- Publica los mismos resultados como diagnósticos en un
namespacellamado como tu plugin. Comprueba que el LSP sigue mostrando los suyos intactos. - Engancha el análisis a
BufWritePosty añade la cancelación de la ejecución anterior antes de lanzar una nueva. - Escribe el formateador con las tres salvaguardas y demuestra que funcionan: rompe la sintaxis del archivo, y en otra prueba teclea durante la ejecución.