wandres.dev
VIM.LSP · el cliente por dentro

Handlers y peticiones a mano

Bajar por debajo de vim.lsp.buf: enviar peticiones crudas con vim.lsp.buf_request y sus variantes, entender la firma de un manejador, sustituir uno por defecto para filtrar o enriquecer respuestas, y construir comportamiento propio a partir de datos del protocolo que ninguna función de conveniencia expone.

⏱ 26 min

Las funciones de vim.lsp.buf son cómodas precisamente porque esconden todo: envían una petición, esperan, interpretan la respuesta y actúan. Esa comodidad tiene un techo, y lo alcanzas el día que quieres algo que ninguna de ellas hace —ordenar los resultados a tu manera, descartar los que vienen de código generado, extraer un dato que el servidor devuelve y nadie muestra, o alimentar con la respuesta una interfaz propia. Debajo de esa capa hay otra, perfectamente pública y sorprendentemente pequeña: peticiones que envías tú y funciones que reciben la respuesta tal como llegó. Este capítulo se instala en ese nivel, donde el protocolo deja de ser una abstracción y pasa a ser una estructura de datos que puedes leer.

🎯 Al terminar esta lección sabrás
  • Enviar peticiones al servidor con vim.lsp.buf_request y sus variantes, y construir sus parámetros.
  • Leer la firma de un manejador y entender qué significa cada uno de sus cuatro argumentos.
  • Sustituir un manejador por defecto para filtrar, ordenar o enriquecer sin perder el comportamiento base.
  • Consumir una respuesta cruda del protocolo para construir comportamiento que nadie ofrece.

Una petición es un método, unos parámetros y una continuación

Todo lo que el cliente hace se reduce a enviar un método con nombre, acompañado de una tabla de parámetros, y registrar qué hacer cuando llegue la respuesta. La función que hace eso a nivel de buffer envía la petición a todos los clientes adjuntos que declaren soportar el método, y llama a tu continuación una vez por cada respuesta.

local params = vim.lsp.util.make_position_params(0, "utf-8")

vim.lsp.buf_request(0, "textDocument/definition", params, function(err, result, ctx)
  if err or not result then return end
  local lista = vim.islist(result) and result or { result }
  vim.notify(("%d definicion(es) desde %s")
    :format(#lista, vim.lsp.get_client_by_id(ctx.client_id).name))
end)

Los parámetros no se escriben a mano casi nunca, porque el protocolo exige detalles tediosos y fáciles de equivocar: el identificador del documento como URI, la posición en coordenadas del protocolo y la codificación de posiciones negociada con ese servidor concreto. Las utilidades del cliente los construyen correctamente, y usarlas evita la clase entera de errores en que todo funciona hasta que aparece un carácter fuera del alfabeto latino.

Hay tres formas de enviar y conviene elegir con criterio. La que acabas de ver invoca tu función una vez por cliente, lo que sirve cuando cada respuesta se procesa aislada. La variante que espera a todas y las entrega juntas en un solo mapa indexado por identificador de cliente es la correcta cuando necesitas comparar o fusionar, por ejemplo para deduplicar definiciones que dos servidores reportan. Y existe una forma síncrona con tiempo máximo de espera que bloquea el editor hasta obtener resultado; su único uso legítimo es un flujo que no puede continuar sin la respuesta, como formatear justo antes de escribir el fichero, y fuera de ahí es una fuente garantizada de tirones perceptibles.

Anatomía de un manejador

Un manejador es la función que recibe la respuesta, y su firma tiene cuatro argumentos con papeles muy distintos. El primero es el error, presente solo si el servidor respondió con fallo. El segundo es el resultado, cuya forma depende enteramente del método y puede ser una tabla, una lista de tablas o un valor nulo cuando no hay nada que devolver. El tercero es el contexto: el identificador del cliente que respondió, el buffer, el nombre del método y los parámetros que enviaste. El cuarto es la configuración que acompañaba a la petición.

---@param err table|nil
---@param result any
---@param ctx table   -- client_id, bufnr, method, params
---@param config table|nil
local function mi_manejador(err, result, ctx, config) end

La existencia del contexto es lo que hace utilizable este nivel. Sin él, una respuesta sería un fragmento de datos sin procedencia; con él sabes qué servidor la produjo, sobre qué buffer y a raíz de qué pregunta, y puedes por tanto tratar de forma distinta lo que viene de distintos orígenes. Cada método tiene un manejador por defecto registrado en una tabla del cliente, y esa tabla es escribible: si colocas ahí tu función, será la tuya la que se ejecute para todas las respuestas de ese método.

El patrón sano no es reemplazar el comportamiento sino envolverlo. Guardas el manejador original, escribes uno que transforma lo que recibe y termina delegando en el guardado. Así conservas toda la lógica de presentación, salto, ventanas y listas que ya funciona, y te limitas a intervenir en los datos.

local original = vim.lsp.handlers["textDocument/definition"]

vim.lsp.handlers["textDocument/definition"] = function(err, result, ctx, config)
  if type(result) == "table" and vim.islist(result) then
    result = vim.tbl_filter(function(loc)
      local uri = loc.uri or loc.targetUri or ""
      return not uri:match("/generated/") and not uri:match("%.min%.js$")
    end, result)
  end
  return original(err, result, ctx, config)
end
sequenceDiagram
participant T as Tu codigo
participant C as Cliente de Neovim
participant S as Servidor
T->>C: buf request con metodo y parametros
C->>S: peticion JSON RPC con identificador
S-->>C: respuesta con el mismo identificador
C->>C: busca el manejador del metodo
C->>T: manejador con err, result, ctx y config
T->>T: filtra, ordena o enriquece
T->>C: delega en el manejador original

Filtrar diagnósticos donde nacen

El caso más rentable de sustitución es el de los diagnósticos publicados por el servidor, porque es una notificación —el servidor la envía sin que nadie la pida— y por tanto no hay ningún punto posterior donde interponerse. Un proyecto con una regla que rechaza deliberadamente produce ruido constante, y silenciar el servidor entero es una cura peor que la enfermedad.

local publicar = vim.lsp.handlers["textDocument/publishDiagnostics"]
local silenciados = { ["unused-local"] = true, ["missing-fields"] = true }

vim.lsp.handlers["textDocument/publishDiagnostics"] = function(err, result, ctx, config)
  if result and result.diagnostics then
    result.diagnostics = vim.tbl_filter(function(d)
      local codigo = type(d.code) == "table" and d.code.value or d.code
      return not silenciados[codigo]
    end, result.diagnostics)
  end
  return publicar(err, result, ctx, config)
end

Antes de escribir un filtro así conviene mirar los datos reales, porque la forma exacta del resultado varía entre servidores más de lo que la especificación sugiere. La vía honesta es registrar temporalmente lo que llega, inspeccionarlo y escribir el filtro sobre lo observado en lugar de sobre lo supuesto. Activar el registro detallado del cliente y leer el fichero de la sesión muestra la conversación completa, petición y respuesta, tal como viaja por el canal.

Preguntar cosas que nadie muestra

La consecuencia interesante de este nivel no es filtrar, sino preguntar. El servidor sabe muchas más cosas de las que la interfaz por defecto expone, y cualquiera de ellas es una petición y un poco de Lua. La jerarquía de llamadas, los símbolos del documento, la jerarquía de tipos, los enlaces del documento, los rangos de selección semántica: todos son métodos del protocolo con respuestas estructuradas.

-- Todos los simbolos del buffer, ordenados por profundidad, a una lista propia
vim.lsp.buf_request(0, "textDocument/documentSymbol",
  { textDocument = vim.lsp.util.make_text_document_params(0) },
  function(err, result)
    if err or not result then return end
    local plano = {}
    local function aplanar(nodos, nivel)
      for _, s in ipairs(nodos or {}) do
        local r = s.range or s.location.range
        plano[#plano + 1] = {
          bufnr = 0, lnum = r.start.line + 1, col = r.start.character + 1,
          text = ("%s%s"):format(("  "):rep(nivel), s.name),
        }
        aplanar(s.children, nivel + 1)
      end
    end
    aplanar(result, 0)
    vim.fn.setqflist(plano, "r")
    vim.cmd.copen()
  end)

Dos advertencias evitan que este poder se vuelva contra ti. La primera es que sustituir un manejador es un cambio global: afecta a todos los servidores y a todos los buffers, así que si tu intención es más estrecha, discrimina dentro de la función usando el contexto. La segunda es que existe una alternativa más limpia cuando la intervención solo concierne a un servidor: pasar la tabla de manejadores en su propia configuración, de modo que el cambio quede acotado por construcción y documentado junto al resto de su ficha.

vim.lsp.config("rust_analyzer", {
  handlers = {
    ["textDocument/hover"] = function(err, result, ctx, config)
      config = vim.tbl_deep_extend("force", config or {}, { border = "rounded" })
      return vim.lsp.handlers.hover(err, result, ctx, config)
    end,
  },
})
🧠

Cuatro argumentos

Error, resultado, contexto y configuración. El contexto es lo que convierte una respuesta anónima en un dato con procedencia comprobable.

🧠

Envolver, no reemplazar

Guarda el manejador original y delega en él al final. Conservas presentación, saltos y listas, e intervienes solo en los datos.

🧠

Notificar no es pedir

Los diagnósticos llegan sin que nadie los solicite. Por eso el único punto de intervención posible es su manejador.

🧠

Síncrono solo si toca

La variante que bloquea con tiempo máximo se justifica antes de escribir un fichero. Fuera de ahí, produce tirones garantizados.

La capa de conveniencia es una opinión, y las opiniones se pueden discutir

Hay un momento en la vida técnica de cualquiera en que se descubre que las funciones cómodas de una biblioteca no son la biblioteca, sino una opinión sobre cómo suele usarse. Este capítulo es ese momento aplicado al cliente LSP. Cuando llamas a la función que va a la definición de un símbolo, alguien decidió por ti que quieres saltar al primer resultado, o abrir una lista si hay varios, o mostrar una ventana con cierto borde. Son decisiones razonables, tomadas por gente que pensó en el caso común, y funcionan bien la inmensa mayoría del tiempo. Pero son decisiones, no propiedades del protocolo. Debajo hay algo mucho más simple y mucho más neutral: un canal por el que viajan mensajes con nombre y estructura conocidos, y unas funciones que devuelven esos mensajes sin interpretarlos. Darse cuenta de que puedes bajar hasta ahí cambia la relación con la herramienta de una manera que va bastante más allá del editor. Dejas de clasificar el software en “lo que la herramienta hace” y “lo que no hace”, una taxonomía que te deja siempre en posición de solicitante, y pasas a clasificarlo en “los datos que existen” y “lo que alguien decidió hacer con ellos”, que te deja en posición de autor. Casi todo lo que percibes como una limitación de tu editor no es una limitación del protocolo ni del servidor: es una función de conveniencia que resolvió un caso que no es el tuyo. Y la asimetría es enorme, porque escribir tu propia interpretación de una respuesta cuesta veinte líneas mientras que esperar a que alguien la implemente cuesta meses o no ocurre nunca. Esta es la diferencia real entre configurar un editor y programarlo, y no está en la cantidad de código sino en dónde decides que termina lo que puedes tocar.

⚔️ Baja al protocolo
  1. Activa el registro detallado del cliente, ejecuta una petición de definición y localiza en el fichero de la sesión la petición y su respuesta completas.
  2. Envía a mano una petición de referencias y presenta los resultados agrupados por fichero en una ventana propia.
  3. Envuelve el manejador de definiciones para que descarte resultados de ficheros generados y verifique que el comportamiento por defecto sigue intacto.
  4. Filtra por código una regla concreta de un servidor que uses y compara el resultado con silenciar la severidad entera desde vim.diagnostic.
  5. Usa la variante que agrupa las respuestas de todos los clientes y deduplica definiciones que dos servidores reportan sobre el mismo símbolo.