wandres.dev
VIM.LSP · el cliente por dentro

Diagnósticos: un sistema independiente

vim.diagnostic no pertenece al LSP: es un registro general de hallazgos por buffer con productores identificados por namespace, severidades ordenadas y una capa de presentación desacoplada. Manejadores, configuración por severidad, filtrado, consulta programática y publicación de diagnósticos propios.

⏱ 24 min

Casi todo el mundo conoce los diagnósticos como “los subrayados rojos del LSP”, y esa asociación, además de incompleta, impide usarlos bien. vim.diagnostic es un subsistema autónomo del editor que existiría igual sin ningún servidor de lenguaje: un registro de hallazgos posicionados en un buffer, con productores identificados, severidades ordenadas y una capa de presentación completamente desacoplada del origen. El cliente LSP es apenas uno de sus proveedores, y probablemente no debería ser el único en tu configuración. Entender esa separación es lo que convierte los diagnósticos de decoración pasiva en una superficie sobre la que puedes construir.

🎯 Al terminar esta lección sabrás
  • Distinguir las tres capas del sistema: productores, almacén por namespace y presentación.
  • Publicar y retirar diagnósticos propios sin implicar a ningún servidor de lenguaje.
  • Configurar los manejadores de presentación y ajustar el comportamiento por severidad.
  • Consultar y filtrar el almacén de forma programática para construir flujos propios.
  • Aprovechar lo que Neovim 0.12 añadió: resumen de estado, filtros por activación y diagnósticos de proyecto.

Tres capas que conviene no mezclar

El sistema se entiende de golpe en cuanto se ve como una tubería de tres tramos independientes. En el primero están los productores: cualquier código que descubra algo digno de señalarse. El cliente LSP lo es, pero también un linter externo cuya salida analizas, un compilador invocado al guardar, una regla propia escrita sobre el árbol sintáctico o una comprobación de estilo de tres líneas. En el segundo está el almacén, que guarda listas de diagnósticos indexadas por buffer y por espacio de nombres, y que es la única fuente de verdad. En el tercero está la presentación, que lee el almacén y decide cómo se ve: subrayado, texto virtual, líneas virtuales, signos en la columna lateral, ventana flotante.

Que el sistema sea autónomo tiene una consecuencia visible desde el primer arranque: los saltos entre hallazgos existen sin que configures nada. ]d y [d van al diagnóstico siguiente y anterior, ]D y [D al último y al primero del buffer, y CTRL-W d abre en flotante el que tienes bajo el cursor. Son atajos globales que Neovim crea al iniciarse, y conviene no volver a definirlos: lo que merece la pena mapear es lo que ellos no cubren, como saltar filtrando por severidad.

La pieza que articula todo es el namespace. Es un identificador numérico que representa a un productor, y su función es hacer que cada uno pueda escribir y borrar solo lo suyo. Sin él, publicar los hallazgos de tu comprobación propia borraría los del servidor de lenguaje, y viceversa.

local ns = vim.api.nvim_create_namespace("estilo_propio")

local function revisar(bufnr)
  local diags = {}
  for i, linea in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do
    local col = linea:find("TODO")
    if col then
      diags[#diags + 1] = {
        lnum = i - 1, col = col - 1, end_col = col + 3,
        severity = vim.diagnostic.severity.HINT,
        source = "estilo", code = "todo-pendiente",
        message = "tarea pendiente sin ticket asociado",
      }
    end
  end
  vim.diagnostic.set(ns, bufnr, diags)   -- reemplaza TODO lo de este namespace
end

Conviene fijarse en la semántica de la publicación, porque es de reemplazo y no de adición: cada llamada sustituye por completo lo que ese productor tenía en ese buffer. Eso simplifica enormemente el código —no hay que llevar la cuenta de lo publicado antes para borrarlo— pero obliga a calcular siempre el conjunto completo de hallazgos en cada pasada, no solo los nuevos.

Un diagnóstico es una tabla plana con posición y contenido. Los índices de línea empiezan en cero, como en toda la API moderna del editor, y las columnas se cuentan en bytes; olvidarlo produce subrayados desplazados en cuanto aparece un carácter no ASCII. Los campos source y code son opcionales pero valiosos: identifican al emisor y a la regla concreta, y son la base de cualquier filtrado fino posterior.

Escribir productores propios es la mejor forma de entender el sistema, pero para los linters de siempre no hace falta: ya existe un plugin que hace exactamente esto —ejecutar el binario, analizar su salida y publicarla en su propio espacio de nombres— para un centenar de herramientas.

nvim-lint lo instalaste en la lección 3.1, donde se configura a fondo. Aquí solo nos interesa que es un productor más de diagnósticos.

Severidad como orden, no como color

Las cuatro severidades —error, aviso, información y pista— no son etiquetas intercambiables sino un orden total, y el sistema lo aprovecha constantemente. Numéricamente, el error es la más baja, de modo que comparar severidades es comparar números y “lo más grave” es el mínimo. Ese orden gobierna cuál se muestra cuando varias coinciden en la misma línea, qué se resalta con qué grupo y qué acepta cada filtro.

vim.diagnostic.config({
  severity_sort = true,        -- lo mas grave manda en cada linea
  underline = { severity = { min = vim.diagnostic.severity.WARN } },
  signs = {
    text = {
      [vim.diagnostic.severity.ERROR] = "E",
      [vim.diagnostic.severity.WARN]  = "W",
      [vim.diagnostic.severity.INFO]  = "I",
      [vim.diagnostic.severity.HINT]  = "H",
    },
  },
  virtual_text = { severity = { min = vim.diagnostic.severity.WARN }, prefix = "" },
  virtual_lines = { current_line = true },
  float = { border = "rounded", source = true, header = "" },
  update_in_insert = false,    -- no reescribir mientras se teclea
})

Casi todas las claves aceptan la misma gramática: un valor booleano para activar o desactivar, o una tabla de opciones que puede incluir un filtro de severidad con mínimo, máximo o una lista explícita. Y todas admiten además una función que recibe el número de espacio de nombres y el buffer y devuelve la configuración a aplicar, lo que permite decidir la presentación caso por caso —por tipo de fichero, por tamaño del buffer o por productor— sin escribir condicionales dispersos.

⚠️
Formas de configurar signos que ya no existen

En Neovim 0.12 los signos de diagnóstico solo se configuran por la clave signs que acabas de ver. Definirlos con los comandos y funciones clásicos de signos dejó de funcionar, igual que desaparecieron vim.diagnostic.disable y vim.diagnostic.is_disabled y la firma antigua de vim.diagnostic.enable. Todo eso llevaba obsoleto desde 0.10 y en esta versión se retiró de verdad, así que cualquier fragmento que encuentres con esas llamadas no fallará de forma elegante: fallará y punto.

Las dos claves que más cambian la experiencia diaria son las de texto virtual y líneas virtuales. La primera escribe el mensaje al final de la línea afectada, es compacta y se vuelve ilegible con mensajes largos. La segunda abre líneas dedicadas bajo el diagnóstico, cabe cualquier mensaje y desplaza el código. La combinación equilibrada —líneas virtuales solo en la línea del cursor, texto virtual apagado o reservado a errores— da lo mejor de ambas sin el coste de ninguna.

flowchart LR
p1[Cliente LSP] --> alm[Almacen por buffer y namespace]
p2[Linter externo] --> alm
p3[Regla propia sobre el arbol] --> alm
alm --> h1[Subrayado]
alm --> h2[Texto virtual]
alm --> h3[Lineas virtuales]
alm --> h4[Signos y flotante]
alm --> q[Consulta y filtrado programatico]
style alm fill:#cba6f7,color:#11111b
style q fill:#a6e3a1,color:#11111b

Consultar, filtrar y navegar

El almacén es consultable, y ahí está la mayor parte del valor que la gente no aprovecha. vim.diagnostic.get devuelve las tablas crudas y acepta un filtro por espacio de nombres, por severidad y —desde 0.12— por si están activados o no; vim.diagnostic.count devuelve un recuento por severidad sin materializar las listas, que es lo que quieres en una línea de estado que se redibuja constantemente. Con ambos se construyen indicadores, condiciones y flujos propios en muy poco código.

-- Cuantos errores hay en TODO el proyecto abierto
local total = 0
for _, b in ipairs(vim.api.nvim_list_bufs()) do
  local c = vim.diagnostic.count(b, { severity = vim.diagnostic.severity.ERROR })
  total = total + (c[vim.diagnostic.severity.ERROR] or 0)
end

-- Saltar solo entre errores, ignorando avisos y pistas.
-- Los corchetes son el territorio de los saltos; ]d y [d ya existen de fabrica.
vim.keymap.set("n", "]e", function()
  vim.diagnostic.jump({ count = 1, severity = vim.diagnostic.severity.ERROR })
end, { desc = "siguiente error" })

vim.keymap.set("n", "[e", function()
  vim.diagnostic.jump({ count = -1, severity = vim.diagnostic.severity.ERROR })
end, { desc = "error anterior" })

-- Volcar los hallazgos a la lista quickfix como agenda de trabajo.
-- Las acciones viven bajo el prefijo de lider, nunca en teclas nativas.
vim.keymap.set("n", "<leader>dq", function()
  vim.diagnostic.setqflist({
    severity = { min = vim.diagnostic.severity.WARN },
    -- 0.12: transforma o descarta cada entrada antes de que llegue a la lista
    format = function(d)
      if d.source == "estilo" then return nil end
      return ("%s [%s]"):format(d.message, d.source or "?")
    end,
  })
end, { desc = "diagnosticos al quickfix" })

Esa clave format es una de las incorporaciones de 0.12 y resuelve limpiamente algo que antes exigía construir la lista a mano: la función recibe cada diagnóstico y devuelve el texto que quieres ver, o un valor nulo para que ni siquiera aparezca. Lo mismo vale para la lista de ubicaciones.

Para la línea de estado hay ahora un atajo mejor que contar a mano. vim.diagnostic.status() devuelve un resumen ya formateado del buffer actual, del estilo de E:2 W:3, omitiendo las severidades sin hallazgos y usando los mismos nombres que hayas definido en signs. De hecho la línea de estado por defecto de 0.12 ya lo incluye, así que quizá lo que tengas que hacer no sea añadirlo sino dejar de duplicarlo.

Merece la pena nombrar también de dónde vienen más diagnósticos que antes. El cliente LSP de 0.12 soporta diagnósticos de proyecto además de los del fichero abierto: vim.lsp.buf.workspace_diagnostics() los pide al servidor, y estos entran en el mismo almacén por espacio de nombres que todo lo demás. Es la diferencia entre ver los errores del fichero que tienes delante y ver los que tu último cambio ha provocado en los tres módulos que dependen de él.

Queda un filtrado de otra naturaleza, más quirúrgico y muy útil en proyectos reales: descartar diagnósticos concretos antes de que lleguen al almacén. Un servidor que insiste en una regla que tu proyecto rechaza deliberadamente genera ruido permanente, y silenciarlo entero es peor que el problema. La vía limpia es interponerse en el manejador que el cliente usa para publicar, quedarse con lo que interesa y delegar el resto en el comportamiento por defecto. Ese mecanismo de sustitución de manejadores es exactamente lo que se estudia en la lección siguiente, y los diagnósticos son su caso de uso más inmediato.

Desactivar sin desconectar

Hay una operación que se pide constantemente y que casi todo el mundo implementa mal: dejar de ver diagnósticos un rato. La forma torpe es parar el servidor, que además de los subrayados se lleva el completado, los saltos y el renombrado. La forma correcta actúa sobre la capa de presentación, que es justo lo que molesta, y deja intacto todo lo demás. El sistema ofrece un conmutador con el mismo ámbito que el resto de la API moderna: global, por buffer o por espacio de nombres.

-- Apagar y encender solo la presentacion, sin tocar clientes
vim.keymap.set("n", "<leader>dd", function()
  vim.diagnostic.enable(not vim.diagnostic.is_enabled({ bufnr = 0 }), { bufnr = 0 })
end, { desc = "alternar diagnosticos del buffer" })

-- Silenciar un unico productor y dejar el resto visible
vim.diagnostic.enable(false, { ns_id = ns })

-- Retirar lo publicado por un productor sin desactivarlo
vim.diagnostic.reset(ns, 0)

Distinguir reset de enable evita bastantes sorpresas. El primero borra los datos de un espacio de nombres, de modo que si el productor no vuelve a publicar, no vuelven a aparecer. El segundo deja los datos intactos y solo apaga la presentación, así que al reactivarla reaparece todo sin recalcular nada. Para un conmutador de comodidad quieres el segundo; para limpiar tras un productor que ha terminado su trabajo, el primero.

💡
Empieza por el ruido, no por la configuración

Antes de retocar colores, prefijos y bordes, dedica una semana a observar qué diagnósticos ignoras sistemáticamente. Todo hallazgo que descartas sin leerlo es ruido, y el ruido no se arregla con estética: se arregla filtrando por severidad, por código o por productor. Una configuración con la mitad de diagnósticos que sí lees vale mucho más que una preciosa que has aprendido a no mirar.

Todo sistema que perdura separa el hecho de su representación

Fíjate en la decisión de diseño que sostiene este capítulo entero, porque es la misma que sostiene los sistemas de software que envejecen bien. En ningún momento un productor de diagnósticos decide cómo se ven sus hallazgos. El servidor de lenguaje afirma un hecho —en esta posición, con esta gravedad, hay esto— y ahí termina su responsabilidad. Que ese hecho acabe siendo un subrayado ondulado, una letra en el margen, una línea virtual, una entrada en una lista navegable o un número en la barra de estado es una decisión que se toma en otra capa, con otra información y por otro motivo. Esa separación entre afirmar y representar parece una sutileza de arquitectura y en realidad es lo que hace que el sistema admita productores que sus autores nunca imaginaron. Cuando escribiste veinte líneas para señalar tareas pendientes, no tuviste que implementar subrayados, ni signos, ni ventanas flotantes, ni navegación, ni integración con el quickfix: heredaste todo eso gratis por el mero hecho de expresar tu hallazgo en el vocabulario común. Y a la inversa, el día que cambies cómo quieres ver los diagnósticos, no tocarás ni un solo productor. El acoplamiento que evitas es el multiplicativo, el que hace que un sistema con diez productores y seis formas de presentación necesite sesenta integraciones en vez de dieciséis piezas. Reconocer esta forma es más valioso que aprender la API concreta, porque una vez que la ves empiezas a ver dónde falta. Cada vez que en tu propio código un componente que descubre algo decide también cómo mostrarlo, has fundido dos capas que se separan solas, y lo notarás la primera vez que quieras mostrarlo de otra manera o descubrirlo de otra forma. Los diagnósticos de Neovim son una lección de diseño disfrazada de subrayado rojo.

⚔️ Tu propio productor
  1. Escribe una comprobación propia sobre el buffer actual, publícala en un espacio de nombres exclusivo y comprueba que no interfiere con los diagnósticos del servidor.
  2. Engánchala a un evento de escritura y asegúrate de que borra sus hallazgos previos en cada pasada.
  3. Configura líneas virtuales solo en la línea del cursor y texto virtual reservado a errores. Convive una semana con ello y anota qué corriges.
  4. Compara vim.diagnostic.status, vim.diagnostic.count y vim.diagnostic.get para construir un indicador de línea de estado. Mide el coste de cada uno y decide si tu línea de estado necesita algo más que lo que 0.12 ya muestra por defecto.
  5. Define saltos separados para errores y para avisos en ] y [, y añade bajo <leader> un comando que vuelque al quickfix solo los hallazgos de tu productor, usando format para descartar el resto.