Los métodos que importan: sincronización, consultas y diagnósticos
El repertorio real de mensajes que sostiene el noventa por ciento del trabajo diario: cómo se mantiene sincronizado el documento, qué devuelven exactamente hover, definición y referencias, cómo funcionan el completado en dos fases y las acciones de código, y por qué los diagnósticos viajan en sentido contrario.
La especificación del protocolo define más de cien métodos, y esa cifra intimida hasta que se observa la distribución real del tráfico en una sesión de trabajo. Nueve de cada diez mensajes pertenecen a un puñado muy pequeño: las notificaciones que mantienen al servidor al día del contenido del buffer, tres o cuatro consultas que parten de una posición del cursor, el ciclo de completado y el flujo inverso de diagnósticos. Todo lo demás —jerarquías de llamadas, resaltado semántico, lentes de código, enlaces de documento— es superestructura valiosa pero periférica. Entender ese núcleo con precisión sirve para mucho más que la erudición: es lo que permite predecir por qué el editor se comporta como se comporta, dónde se está gastando el tiempo y qué está viendo el servidor cuando su respuesta no coincide con lo que tú tienes delante.
- Comparar sincronización completa e incremental y calcular el coste de cada una en tráfico y en riesgo de desfase.
- Describir la forma exacta de las respuestas de
hover, definición y referencias, incluidas sus variantes. - Explicar el completado en dos fases y qué justifica que exista una petición de resolución separada.
- Situar las acciones de código y los diagnósticos como el par que convierte un análisis en una edición aplicable.
Mantener el documento sincronizado
Todo lo demás depende de esto. El servidor no lee el fichero del disco cuando quiere: mantiene su propia copia en memoria, alimentada exclusivamente por notificaciones del cliente, y esa copia es la única realidad sobre la que responde. La secuencia es invariable: textDocument/didOpen entrega el contenido íntegro y el identificador de lenguaje; textDocument/didChange entrega cada modificación posterior con un número de versión monótonamente creciente; textDocument/didClose retira el documento y devuelve la autoridad al disco.
El modo de sincronización se negocia en el arranque y solo hay dos que importan. En modo completo, cada pulsación de tecla envía el buffer entero; es trivial de implementar y correcto por construcción, pero en un fichero de diez mil líneas significa enviar cientos de kilobytes por carácter escrito. En modo incremental, cada notificación describe únicamente el rango sustituido y su texto nuevo, lo que reduce el tráfico en órdenes de magnitud a cambio de exigir que ambos extremos apliquen exactamente la misma transformación.
{
"textDocument": { "uri": "file:///p/src/lib.rs", "version": 87 },
"contentChanges": [
{
"range": {
"start": { "line": 12, "character": 4 },
"end": { "line": 12, "character": 4 }
},
"rangeLength": 0,
"text": "let x = 1;"
}
]
}
Aquí vive la clase de fallo más desconcertante de todo LSP. Si el cliente calcula mal un rango —por un error de una unidad, por una conversión de codificación, por una edición aplicada fuera del flujo normal—, la copia del servidor diverge de la del editor y no hay ningún mecanismo que lo detecte. A partir de ese instante, los diagnósticos señalan líneas equivocadas, el salto a definición aterriza dos caracteres a la izquierda y el completado propone símbolos de un contexto que ya no existe. Todo parece funcionar, pero todo está desplazado. La codificación de posiciones es la causa habitual: el protocolo mide columnas en unidades de código de dieciséis bits por defecto, mientras que el editor las mide en bytes, y esa discrepancia solo se manifiesta cuando hay caracteres fuera del alfabeto latino básico.
didOpen
Entrega el texto completo. A partir de aquí manda la copia del servidor, no el fichero del disco.
didChange
Rangos y texto nuevo, con versión creciente. Es el mensaje más frecuente de toda la sesión.
didSave
Opcional y negociada. Muchos servidores reindexan aquí, no en cada pulsación.
Consultas que parten de una posición
Tres métodos comparten exactamente la misma forma de parámetros —un documento y una posición— y difieren solo en lo que devuelven. textDocument/hover devuelve contenido descriptivo y opcionalmente el rango al que se aplica; el contenido llega marcado como texto plano o como formato ligero, y el cliente decide cómo renderizarlo. textDocument/definition devuelve una localización, una lista de localizaciones o una lista de enlaces con rango de origen; esa última variante es la que permite resaltar la palabra exacta desde la que se saltó. textDocument/references devuelve una lista de localizaciones y acepta un parámetro que decide si la declaración misma se incluye.
local params = vim.lsp.util.make_position_params(0, "utf-8")
-- Definicion: la respuesta puede venir en tres formas distintas.
vim.lsp.buf_request(0, "textDocument/definition", params, function(err, res)
if err or not res then return end
local destinos = vim.islist(res) and res or { res }
vim.print(#destinos .. " destinos")
end)
-- Referencias: el contexto decide si la declaracion cuenta como referencia.
local p2 = vim.tbl_extend("force", params, { context = { includeDeclaration = false } })
vim.lsp.buf_request(0, "textDocument/references", p2, function(_, res)
vim.fn.setqflist(vim.lsp.util.locations_to_items(res or {}, "utf-8"))
end)
La familia se amplía con parientes que se comportan igual y que conviene conocer porque resuelven preguntas distintas: la declaración separa el sitio donde algo se declara del sitio donde se define, distinción que solo tiene sentido en lenguajes con cabeceras; la implementación busca los cuerpos concretos de una interfaz; la definición de tipo salta al tipo de una expresión en lugar de al símbolo. Un servidor puede ofrecer las cuatro o solo una, y esa es precisamente la información que quedó registrada en el inventario de capacidades.
El ciclo de completado y las acciones de código
El completado es el método con la ergonomía más elaborada del protocolo, y la razón es puramente económica. Una lista de candidatos puede tener miles de elementos, y calcular la documentación completa de cada uno sería absurdo cuando el usuario va a mirar uno solo. De ahí el diseño en dos fases: textDocument/completion devuelve elementos con lo mínimo para mostrarlos —etiqueta, tipo, texto a insertar—, y completionItem/resolve completa un elemento individual con documentación, detalle y ediciones adicionales cuando el usuario lo selecciona. Ese segundo paso es opcional y el servidor declara si lo soporta.
El campo de ediciones adicionales es el detalle que separa un completado decente de uno excelente: es la lista de cambios en otras partes del fichero que hay que aplicar junto con la inserción, típicamente la importación del símbolo elegido. Un cliente que ignora ese campo insertará el nombre y dejará el fichero sin compilar.
{
"label": "HashMap",
"kind": 22,
"detail": "std::collections::HashMap",
"insertTextFormat": 2,
"textEdit": {
"range": { "start": { "line": 8, "character": 12 },
"end": { "line": 8, "character": 16 } },
"newText": "HashMap"
},
"additionalTextEdits": [
{ "range": { "start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 } },
"newText": "use std::collections::HashMap;\n" }
]
}
Las acciones de código funcionan con la misma lógica perezosa. textDocument/codeAction recibe un rango y un contexto que incluye los diagnósticos activos en él, y devuelve acciones que pueden venir ya resueltas —con su edición dentro— o solo con título y una marca para resolverlas después mediante codeAction/resolve. Cada acción lleva además una clasificación jerárquica que permite filtrar: pedir solo correcciones rápidas, solo extracciones o solo organización de importaciones. Esa clasificación es lo que hace posible que una acción se aplique automáticamente al guardar sin preguntar nada al usuario.
Diagnósticos: el canal en sentido inverso
Los diagnósticos son la única funcionalidad central que no se pide: se recibe. El servidor decide cuándo ha terminado de analizar y envía textDocument/publishDiagnostics con la lista completa de problemas de un documento. La palabra completa es esencial: cada publicación sustituye íntegramente a la anterior para esa URI, de modo que una lista vacía es la forma de decir que ya no hay problemas. Un cliente que acumulara en lugar de sustituir mostraría errores fantasma para siempre.
Ese modelo empujado tiene una limitación que el protocolo terminó reconociendo: el cliente no sabe cuándo esperar la publicación ni puede pedirla. Por eso existe también una variante tirada, en la que el cliente pregunta por los diagnósticos de un documento y el servidor puede responder que nada cambió desde la última vez mediante un identificador de resultado. Los servidores modernos suelen ofrecer ambas, y merece la pena saber cuál está en uso porque determina si los errores aparecen al escribir, al guardar o cuando el editor decide preguntar.
sequenceDiagram participant U as usuario participant C as cliente participant S as servidor U->>C: escribe una linea C->>S: didChange version 88 S-->>C: publishDiagnostics U->>C: pide una accion de codigo C->>S: codeAction con rango y diagnosticos S-->>C: lista de acciones sin resolver C->>S: codeAction resolve de la elegida S-->>C: accion con su edicion completa
El emparejamiento entre diagnósticos y acciones de código merece subrayarse porque es donde el protocolo cierra su circuito más útil. Un diagnóstico no es solo un mensaje: lleva un código, una fuente y datos opcionales que el servidor puede reconocer más tarde. Cuando el cliente pide acciones sobre un rango, adjunta los diagnósticos activos en él, y el servidor usa precisamente esos datos para proponer la corrección concreta en lugar de una lista genérica. Ese es el motivo de que pedir acciones sin adjuntar el contexto devuelva mucho menos de lo que el servidor sabría ofrecer.
-- Los diagnosticos ya normalizados por espacio de nombres de cada cliente.
vim.diagnostic.config({
virtual_text = { severity = { min = vim.diagnostic.severity.WARN } },
severity_sort = true,
update_in_insert = false,
})
-- Accion de codigo automatica al guardar, filtrada por su clasificacion.
vim.api.nvim_create_autocmd("BufWritePre", {
callback = function()
vim.lsp.buf.code_action({
context = { only = { "source.organizeImports" }, diagnostics = {} },
apply = true,
})
end,
})
Repasa el núcleo que acabas de ver y busca el patrón, porque está en todos los métodos y una vez lo detectas la especificación deja de parecer arbitraria. La sincronización incremental existe porque enviar el buffer completo en cada pulsación es caro; el completado en dos fases existe porque calcular documentación para mil candidatos es caro; las acciones de código sin resolver existen porque calcular la edición de cada refactorización ofrecida es caro; el identificador de resultado en los diagnósticos existe porque reenviar una lista idéntica es caro. Ninguna de esas decisiones tiene que ver con la semántica de los lenguajes: todas son negociaciones sobre latencia percibida y ancho de banda entre dos procesos. Esto revela algo importante sobre qué clase de artefacto es LSP. No es un modelo de programas ni una teoría del análisis estático: es un protocolo de caché distribuida donde la información cara vive en el servidor, la información que se mira vive en el cliente, y todo el diseño consiste en decidir qué se copia, cuándo y a costa de qué riesgo de desfase. Verlo así reordena de golpe tu manera de depurar. Cuando el editor va lento, la pregunta no es si el servidor es lento sino qué se está copiando de más. Cuando algo aparece desplazado, la pregunta no es qué está mal calculado sino en qué momento divergieron las dos copias. Cuando una función no reacciona, la pregunta no es si está rota sino qué la dispara y si ese disparador llegó. Y hay una consecuencia de diseño para cualquiera que construya sobre esto: la funcionalidad que añadas debe respetar la misma disciplina económica, porque un cliente que pide completado en cada carácter sin retardo, o que resuelve los mil elementos por adelantado, o que solicita acciones de código en cada movimiento del cursor, no está usando mal una función sino violando el contrato implícito que hace que todo el sistema se sienta instantáneo. La velocidad de un entorno LSP bien montado casi nunca viene del servidor: viene de que el cliente pregunta poco, tarde y solo cuando alguien va a mirar la respuesta.
- Determina el modo de sincronización negociado en dos servidores y estima el tráfico de escribir una línea en un fichero grande.
- Compara las tres formas posibles de respuesta de definición y escribe un manejador que las trate todas correctamente.
- Desactiva la resolución de elementos de completado y documenta qué información desaparece del menú.
- Registra las ediciones adicionales de un candidato que requiera importación y comprueba qué pasa si no se aplican.
- Captura dos publicaciones consecutivas de diagnósticos del mismo documento y verifica que la segunda sustituye a la primera.