Saltar al contenido

Linters y formateadores a mano: lo que los plugins hacen por dentro

Las dos tuberías que convierten una herramienta externa en parte del editor: el formateador síncrono con stdin y stdout en BufWritePre, comprobando code antes de tocar el buffer y restaurando el cursor, y el linter asíncrono que parsea la salida y la publica como diagnósticos en un namespace propio o en la quickfix vía errorformat.

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. En la lección 3.4 instalaste conform.nvim y nvim-lint y funcionaron solos; aquí construyes su núcleo con las piezas de las lecciones 13.1 y 13.2, para que dejen de ser magia.

  • Formatear un buffer con el patrón stdin → stdout de vim.system, sin pasar por el disco.
  • Comprobar code antes de tocar el buffer y restaurar el cursor tras el reemplazo.
  • Publicar la salida de un linter como diagnósticos en un namespace propio, con los campos e índices reales de la API.
  • Usar la vía alternativa: setqflist con lines y efm, y el puente fromqflist.
  • Enganchar cada tubería al evento correcto: BufWritePre síncrono, BufWritePost con debounce.

Formatear: la tubería stdin → stdout

Un formateador moderno es un filtro: lee código por la entrada estándar, escribe código por la salida estándar. stylua -, black -, prettier --stdin-filepath — la bandera cambia, el contrato no. Eso encaja como un guante con lo que ya sabes de la 13.1: la opción stdin de vim.system acepta una lista de líneas, las escribe con su salto final y cierra el canal sola.

LUA
local function formatear(bufnr)  local lineas = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)  local obj = vim.system(    { 'stylua', '-' },    { stdin = lineas, text = true, timeout = 2000 }  ):wait()  if obj.code ~= 0 then    return vim.notify(obj.stderr or 'el formateador falló', vim.log.levels.WARN)  end  local nuevas = vim.split(obj.stdout, '\n')  if nuevas[#nuevas] == '' then table.remove(nuevas) end   -- el \n final no es una línea más  local cursor = vim.api.nvim_win_get_cursor(0)  vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, nuevas)  pcall(vim.api.nvim_win_set_cursor, 0, cursor)end

Cada línea de esa función tapa un agujero real. La comprobación de code va antes de tocar el buffer, y no es negociable: cuando un formateador no puede parsear tu archivo, sale con código distinto de cero y un stdout vacío o a medias, y aplicarlo significaría sustituir tu código por la nada. El recorte de la última cadena vacía existe porque la salida termina en \n, y vim.split convierte ese salto final en un elemento vacío que, escrito tal cual, añade al archivo una línea en blanco por guardado. Y el cursor se guarda antes y se restaura con pcall, porque el buffer formateado puede tener menos líneas que el original y nvim_win_set_cursor lanza error si la posición ya no existe; si prefieres conservar también el desplazamiento de la ventana, vim.fn.winsaveview y winrestview hacen el mismo trabajo con más memoria.

Queda una pérdida que este patrón no evita: el reemplazo total. nvim_buf_set_lines de cero a menos uno sustituye todas las líneas, incluso las que no cambiaron, y con ellas mueren las marcas, los pliegues manuales y las extmarks ancladas a ese texto. Los plugins serios no lo hacen así: conform.nvim calcula el diff entre el buffer y la salida —vim.text.diff te lo da en el propio Neovim— y aplica solo los rangos que cambiaron con nvim_buf_set_text, de modo que todo lo anclado al texto intacto sobrevive. Ese refinamiento exige entender qué es una extmark, que es literalmente el tema del Nivel 14; hoy te basta saber que la versión honesta funciona y cuál es su precio.

Guardar sin sustos: BufWritePre síncrono

¿Dónde se engancha? En BufWritePre, y en modo síncrono, que parece contradecir todo lo que la 13.1 te enseñó sobre el veneno de :wait(). No lo contradice: lo matiza. El guardado es una operación donde el usuario ya aceptó esperar —pulsó :w y está mirando—, y el orden importa: si formatearas en asíncrono, el archivo se escribiría en disco antes de que llegara el resultado, y el formato aterrizaría sobre un buffer ya guardado, dejándolo modificado otra vez. Síncrono aquí significa «el formato entra en esta misma escritura».

LUA
vim.api.nvim_create_autocmd('BufWritePre', {  pattern = '*.lua',  callback = function(ev) formatear(ev.buf) end,})

La condición para que esto sea aceptable es el timeout corto que ya llevaba la función: dos segundos de tope. Un formateador sano tarda decenas de milisegundos; si el tuyo se cuelga, el timeout lo mata con SIGTERM, code llega valiendo 124, la comprobación lo descarta y el guardado sigue adelante sin formato. Es la misma decisión que toma conform.nvim con su format_on_save = { timeout_ms = 1000 } de la lección 3.4: bloquear está bien cuando el bloqueo tiene un tope conocido y corto.

Fuera del guardado —un comando :Formatear que lanzas en mitad de la edición— la elección se invierte: asíncrono, con una salvaguarda extra. Entre que capturas las líneas y llega el resultado, el usuario puede haber seguido tecleando, y aplicar un formato calculado sobre una versión vieja destruiría esas ediciones. El detector es changedtick, el contador de cambios del buffer:

LUA
local tick = vim.api.nvim_buf_get_changedtick(bufnr)vim.system({ 'stylua', '-' }, { stdin = lineas, text = true }, function(obj)  vim.schedule(function()    if obj.code ~= 0 then return end    if vim.api.nvim_buf_get_changedtick(bufnr) ~= tick then return end  -- ya cambió    -- aplicar igual que antes  end)end)

Guárdate ese patrón: en la lección 13.5 se generaliza a cualquier resultado que pueda llegar obsoleto.

Lintar: de la salida a los diagnósticos

El linter es la tubería opuesta. No sustituye nada: anota. Y como analizar puede tardar segundos, aquí el modo asíncrono no es una opción sino la única respuesta decente; su callback corre en fast context y cruza con vim.schedule, exactamente como fijaste en la 12.3 y practicaste en la 13.1.

Lo nuevo es el destino. Neovim no reserva su sistema de diagnósticos para el LSP: cualquiera puede publicar en él, siempre que se identifique con un namespace propio creado con nvim_create_namespace. Un diagnóstico es una tabla con campos concretos, y conviene tenerlos delante porque los índices son la trampa de esta lección:

Campo Qué es
lnum, col Inicio del subrayado, contando desde cero
end_lnum, end_col Final; si los omites, valen lo mismo que el inicio
message El texto del aviso; junto a lnum, lo único obligatorio
severity vim.diagnostic.severity.ERROR, WARN, INFO o HINT; por defecto, ERROR
source Quién habla: el nombre de tu herramienta
code El identificador del aviso: F401, no-unused-vars
LUA
local ns = vim.api.nvim_create_namespace('mi_linter')local function lintar(bufnr)  local ruta = vim.api.nvim_buf_get_name(bufnr)  vim.system({ 'ruff', 'check', '--output-format', 'concise', ruta }, { text = true },    function(obj)      vim.schedule(function()        if not vim.api.nvim_buf_is_valid(bufnr) then return end        local diags = {}        for linea in vim.gsplit(obj.stdout or '', '\n', { trimempty = true }) do          local lnum, col, codigo, msg = linea:match(':(%d+):(%d+): (%S+) (.+)$')          if lnum then            diags[#diags + 1] = {              lnum = tonumber(lnum) - 1,          -- la herramienta cuenta desde 1              col = tonumber(col) - 1,            -- la API, desde 0              message = msg,              code = codigo,              severity = vim.diagnostic.severity.WARN,              source = 'ruff',            }          end        end        vim.diagnostic.set(ns, bufnr, diags)      end)    end)end

El namespace es lo que hace esto seguro. vim.diagnostic.set reemplaza 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; limpiar es igual de aislado, con vim.diagnostic.reset(ns, bufnr). Y a cambio heredas gratis el aparato completo de presentación —signos, texto virtual, subrayado, la flotante al posarte, la navegación con vim.diagnostic.jump— sin escribir una línea. Ese sistema tiene mucho más dentro —severidades como filtro, configuración por namespace, desactivación selectiva— y la lección 20.3 lo recorre entero; aquí te basta el papel de productor.

Cuando la herramienta ofrece salida JSON, prefiérela sin dudarlo: un patrón sobre texto pensado para humanos se rompe el día que la herramienta cambia una coma, mientras que el JSON es un contrato. pcall(vim.json.decode, obj.stdout) y un vim.tbl_map sustituyen al bucle de patrones, y el resto de la tubería no se entera.

El buffer pudo morir mientras el linter pensaba

La comprobación de nvim_buf_is_valid dentro del schedule no es paranoia. Entre lanzar el proceso y recibir su resultado pasan segundos, y en ese hueco el usuario pudo cerrar el buffer: publicar diagnósticos en un buffer inexistente lanza error. Es la versión mínima de un problema más general —resultados que llegan tarde a un mundo que cambió— que la lección 13.5 ataca con un patrón completo.

La vía quickfix

Los diagnósticos contestan qué pasa aquí, con el contexto delante. La quickfix contesta qué pasa en el proyecto, y se recorre con ]q. Para un linter que analiza el repositorio entero —no el archivo actual— es el destino natural, y Neovim te ahorra el parseo: setqflist sabe aplicar un errorformat a las líneas crudas, el mismo adaptador que conociste a fondo en la lección 1.6.

LUA
local salida = vim.split(obj.stdout or '', '\n', { trimempty = true })vim.fn.setqflist({}, ' ', {  title = 'ruff (proyecto)',  lines = salida,  efm = [[%f:%l:%c: %m]],})

El segundo argumento es fuente de confusión eterna: un espacio crea una lista nueva en la pila —conservando las anteriores para :colder—, r reemplaza la actual y a añade a ella. Para un linter que se relanza en cada guardado, r da una única lista que se refresca; el espacio te deja comparar con la ejecución anterior. La decisión hermana es quickfix contra location list: la quickfix es global a la sesión y la location list vive en una ventana, así que el análisis de un solo archivo va a la location list —setloclist, misma firma con la ventana delante— para no pisarle al usuario el grep que estaba siguiendo.

¿Y si quieres las entradas parseadas sin tocar ninguna lista? getqflist con lines y efm te las devuelve estructuradas, cada una con su campo valid que separa lo que casó del ruido. Eso abre un puente elegante entre los dos mundos: parsear con errorformat y publicar como diagnósticos, con vim.diagnostic.fromqflist haciendo la conversión de índices por ti.

LUA
local items = vim.fn.getqflist({ lines = salida, efm = [[%f:%l:%c: %m]] }).itemsitems = vim.tbl_filter(function(it) return it.valid == 1 end, items)vim.diagnostic.set(ns, bufnr, vim.diagnostic.fromqflist(items))

Un solo parser, dos destinos. El puente inverso también existe y es aún más útil: vim.diagnostic.setqflist recoge los diagnósticos de todas las fuentes —el LSP, tu linter, cualquier otra—, admite filtro por severidad y los vuelca en la quickfix ya ordenados. Publicar en tu namespace y dejar que el usuario materialice la lista cuando quiera es mejor diseño que mantener tú dos copias sincronizadas.

Engancharlo al editor

Queda decidir cuándo corre cada tubería. El formateador ya quedó en BufWritePre, síncrono y con tope. El linter va a BufWritePost —después de escribir, porque muchas herramientas leen el archivo del disco— y en asíncrono, claro. Para herramientas rápidas que quieras en cada pausa del tecleo, TextChanged más el debounce que construiste en la 12.4, que colapsa la ráfaga de eventos en una sola ejecución.

LUA
vim.api.nvim_create_autocmd('BufWritePost', {  pattern = '*.py',  callback = function(ev) lintar(ev.buf) end,})

Falta un cabo suelto que esta lección deja deliberadamente sin atar: si guardas dos veces rápido, dos procesos compiten por publicar en el mismo namespace, y gana el último en terminar, que no es necesariamente el de la versión más reciente. Cancelar el anterior, descartar resultados obsoletos y limpiar al cerrar es exactamente el programa de la lección 13.5.

El envoltorio es un traductor de modelos, no un lanzador de comandos

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 extmarks ancladas a posiciones, y cuyas filas la API de diagnósticos cuenta desde cero. 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 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. El diff por rangos del Nivel 14 existe porque el reemplazo total castiga a todo lo anclado a un texto que no cambió. Y pcall al restaurar el cursor existe porque en un buffer, a diferencia de un archivo, hay alguien mirando.

Lo esencial

Formatear es nvim_buf_get_lines → vim.system con stdin → comprobar code → set_lines y cursor restaurado con pcall; síncrono en BufWritePre con timeout corto, con changedtick si es asíncrono. Lintar es vim.system asíncrono → vim.schedule → traducir índices restando uno → vim.diagnostic.set en tu namespace. La vía quickfix parsea con lines y efm (1.6), y fromqflist y setqflist tienden los puentes entre ambos mundos.

La tubería completa en una línea: buffer a stdin, veredicto en code, recorte del elemento vacío final y reemplazo. Cambia sort por tu formateador real y su bandera de stdin, y ya tienes el núcleo de la lección.

Aparece el signo, el subrayado y la flotante al posarte: todo el aparato de presentación, gratis. Fíjate en el - 1: line('.') cuenta desde uno y lnum desde cero. Limpia con :lua vim.diagnostic.reset(Ns) y comprueba que los diagnósticos del LSP siguen intactos.

Dos entradas: la primera con valid en 1 y sus campos rellenos, la segunda marcada como ruido. Es el parser de la lección 1.6 convertido en función pura: entra texto, salen entidades, y tú decides el destino.

todo junto

El puente oficial entre los dos mundos: recoge lo del LSP, lo de tu namespace y lo de cualquier otro productor, y lo vuelca ordenado en una lista navegable con ]q. Admite filtro por severidad para quedarte solo con los errores.

Envuelve una herramienta de principio a fin

  1. Escribe formatear para tu formateador real con las tres salvaguardas: code, recorte del salto final y cursor con pcall. Rompe la sintaxis del archivo y comprueba que el buffer queda intacto y llega el aviso.
  2. Engánchala a BufWritePre con timeout de dos segundos, y verifica con un guardado normal que el formato entra en la misma escritura, sin dejar el buffer modificado.
  3. Escribe lintar para un linter de tu lenguaje: asíncrono, con vim.schedule, la resta de índices y tu propio namespace. Comprueba que el LSP sigue mostrando los suyos.
  4. Provoca el error por uno: quita la resta de lnum y observa el subrayado desplazado una línea. Devuélvela.
  5. Repite el parseo con getqflist más efm y publica el resultado con vim.diagnostic.fromqflist. Compara con tu parser manual.
  6. Engancha el linter a BufWritePost, guarda dos veces muy rápido y observa el problema de la carrera: dos procesos, un namespace. Lo resolverás en la 13.5.