Por qué existe LSP: N editores por M lenguajes
El problema combinatorio que dio origen al Language Server Protocol, la idea de desacoplar el análisis semántico del editor que lo muestra, y las consecuencias arquitectónicas de convertir la inteligencia de código en un proceso separado que habla un protocolo público.
Durante dos décadas, cada editor que quiso ofrecer autocompletado semántico, salto a definición o renombrado seguro tuvo que implementar, dentro de sí mismo, un analizador para cada lenguaje que pretendiera soportar. Eso no era pereza ni mala arquitectura: era la única opción disponible, porque el conocimiento sobre un lenguaje vivía en el compilador y el compilador no exponía nada utilizable en tiempo de edición. El resultado fue un desperdicio industrial de proporciones asombrosas: el mismo análisis de resolución de nombres de Java, escrito una vez para Eclipse, otra para IntelliJ, otra para NetBeans, otra para Emacs, otra para Vim, cada una con sus propios fallos, su propia latencia y su propia noción incompleta de qué es un símbolo. El Language Server Protocol no inventó ningún algoritmo nuevo. Lo que hizo fue observar que ese problema tenía forma de producto cartesiano, y que un producto cartesiano se rompe introduciendo una interfaz en el medio.
- Formular el problema de integración editor-lenguaje como un producto
N x My entender por qué escala mal. - Identificar la frontera exacta donde LSP corta: qué queda del lado del editor y qué del lado del servidor.
- Reconocer las consecuencias de que el analizador sea un proceso separado y no una biblioteca enlazada.
- Explicar por qué el protocolo es intencionadamente pobre en semántica y rico en posiciones de texto.
El producto cartesiano
Sea N el número de editores que quieren ofrecer inteligencia de código y M el número de lenguajes que merecen soporte. Sin protocolo común, el trabajo total de integración es N x M: cada pareja necesita una implementación específica, porque el editor debe conocer la estructura interna del analizador y el analizador debe conocer las estructuras de datos del editor. Con veinte editores relevantes y cuarenta lenguajes vivos, son ochocientas integraciones. En la práctica nunca se construyeron ochocientas: se construyeron unas pocas decenas, y el resto del espacio quedó vacío. Esa es la forma real en que el problema se manifestaba —no como coste insoportable, sino como ausencia silenciosa de funcionalidad para casi todas las combinaciones—.
La observación central de LSP es que si se define una interfaz en el medio, el trabajo pasa de N x M a N + M. Cada editor la implementa una vez, del lado del cliente. Cada lenguaje la implementa una vez, del lado del servidor. Veinte más cuarenta son sesenta piezas en lugar de ochocientas, y —lo más importante— cada pieza nueva que aparece se conecta automáticamente con todas las del otro lado. Cuando alguien publica un servidor para un lenguaje nuevo, ese lenguaje adquiere el mismo día soporte en todos los editores que hablan el protocolo. Esa propiedad, la de que el valor marginal de cada implementación sea proporcional al tamaño del otro conjunto, es lo que convierte el diseño en un efecto de red.
flowchart LR subgraph antes e1[editor 1] --- l1[lenguaje 1] e1 --- l2[lenguaje 2] e2[editor 2] --- l1 e2 --- l2 end subgraph despues e3[editor 1] --> p[protocolo] e4[editor 2] --> p p --> s1[servidor 1] p --> s2[servidor 2] end style p fill:#a6e3a1,color:#11111b
Conviene precisar que la reducción no es exactamente a N + M, porque cada cliente sigue necesitando saber cómo arrancar cada servidor: qué ejecutable invocar, con qué argumentos, qué ficheros marcan la raíz del proyecto y qué opciones específicas admite. Ese residuo de conocimiento por pareja es real, pero es de una naturaleza completamente distinta —configuración declarativa, no lógica de análisis— y por eso se deja externalizar a un catálogo compartido. Neovim expone ese catálogo como configuración de datos pura.
-- El residuo de conocimiento por pareja: puro dato declarativo.
vim.lsp.config["luals"] = {
cmd = { "lua-language-server" },
filetypes = { "lua" },
root_markers = { ".luarc.json", ".git" },
settings = { Lua = { runtime = { version = "LuaJIT" } } },
}
vim.lsp.enable("luals")
Dónde corta la frontera
Una interfaz solo funciona si el corte está en el sitio correcto, y el sitio correcto no era obvio. La decisión de diseño más importante de LSP fue colocar la frontera de manera que el servidor no sepa absolutamente nada sobre interfaz de usuario. El servidor no dibuja un menú de completado, no sabe qué es una ventana flotante, no decide si los diagnósticos aparecen como subrayado ondulado o como signos en la columna lateral. Su universo se reduce a documentos identificados por una URI, posiciones expresadas como pares de línea y carácter, y respuestas estructuradas en un formato serializable.
Simétricamente, el cliente no sabe nada sobre el lenguaje. Neovim no conoce la regla de resolución de sobrecargas de C++ ni el sistema de tipos de Rust: recibe una lista de candidatos con etiquetas y fragmentos de texto, y su única responsabilidad es presentarlos y aplicar la edición que el usuario elija. Esta ignorancia mutua es deliberada y es la fuente de toda la robustez del sistema. Un cliente escrito hoy funciona con un servidor publicado dentro de cinco años para un lenguaje que aún no existe, porque ninguna de las dos partes hizo suposiciones sobre la otra más allá del contrato.
El vocabulario que cruza la frontera es sorprendentemente reducido. La unidad atómica es la posición, un par de enteros; el rango es un par de posiciones; la localización es un rango con una URI. Casi todo lo demás del protocolo se construye componiendo esas tres formas.
{
"uri": "file:///home/dev/proyecto/src/main.rs",
"range": {
"start": { "line": 41, "character": 8 },
"end": { "line": 41, "character": 19 }
}
}
Lado cliente
Gestiona el ciclo de vida del proceso servidor, notifica cambios del buffer, envía peticiones y presenta resultados. No analiza código.
Lado servidor
Mantiene su propio modelo del proyecto, indexa, resuelve tipos y responde consultas. No conoce ninguna interfaz gráfica.
Vocabulario común
URIs de documento, rangos de línea y carácter, y un puñado de estructuras compuestas. Nada más cruza la frontera.
El coste de la separación
Convertir el analizador en un proceso separado no es gratis, y conviene tener presente la factura. Primero, hay serialización: cada consulta implica convertir estructuras en memoria a texto, transmitirlas por una tubería y reconstruirlas al otro lado. Segundo, hay asincronía obligatoria: el editor no puede bloquearse esperando, así que toda la funcionalidad se vuelve una promesa que puede tardar, fallar o llegar cuando el usuario ya movió el cursor. Tercero, hay duplicación de estado: el servidor mantiene su propia copia del contenido de cada fichero abierto, y esa copia puede desincronizarse de la del editor —de hecho, la mayoría de los fallos desconcertantes de LSP en producción son exactamente eso—.
A cambio, se obtiene aislamiento. Un servidor que consume memoria sin control o que entra en bucle infinito no arrastra al editor consigo: se reinicia el proceso y la sesión de edición sobrevive. Un servidor escrito en el lenguaje que analiza —el caso habitual, porque quien mejor conoce un lenguaje es su propio compilador— puede reutilizar la infraestructura real del compilador en lugar de reimplementarla. Y la frontera de proceso permite que el servidor lo escriba el equipo del lenguaje mientras el cliente lo escribe el equipo del editor, sin coordinación entre ambos más allá de la especificación.
-- El aislamiento es observable: el proceso se puede inspeccionar y reiniciar
-- sin que la sesion de edicion se entere de nada.
for _, cliente in ipairs(vim.lsp.get_clients()) do
print(cliente.name, cliente.id, cliente.root_dir)
end
vim.api.nvim_create_user_command("LspReiniciar", function()
for _, c in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do
c:stop()
end
vim.defer_fn(function() vim.cmd("edit") end, 200)
end, {})
Hay una última consecuencia que suele pasar desapercibida y que explica muchas frustraciones. Como el protocolo es el mínimo común denominador de todos los lenguajes posibles, sus estructuras de datos son deliberadamente pobres: un símbolo tiene un nombre, un tipo tomado de una enumeración fija y un rango, pero no tiene genéricos, ni visibilidad, ni pertenencia a un módulo, porque no todos los lenguajes tienen esos conceptos. Cuando echas de menos una función que tu entorno anterior hacía, muchas veces no es que el servidor no sepa la respuesta: es que el protocolo no tiene una casilla donde ponerla.
El ecosistema que la interfaz hizo posible
La prueba de que el corte estaba bien situado no es teórica sino histórica: apareció una clase entera de componentes que nadie había planeado. Como el cliente solo sabe hablar el protocolo y no le importa quién esté al otro lado, cualquier programa que hable el protocolo puede ocupar ese puesto aunque no sea un analizador de lenguaje. De ahí nacieron los servidores puente, que envuelven herramientas antiguas de línea de órdenes —linters, formateadores, correctores ortográficos— y traducen su salida a diagnósticos y acciones de código. Ninguna de esas herramientas fue diseñada para editores, y sin embargo hoy se integran igual de bien que un compilador moderno.
La misma indiferencia funciona en la otra dirección. Como el servidor no sabe quién le pregunta, un proceso intermedio puede sentarse entre ambos, reenviar los mensajes y aprovechar el paso para registrarlos, transformarlos o repartirlos entre varios servidores. Esa arquitectura de intermediario transparente —imposible si el analizador fuese una biblioteca enlazada dentro del editor— es la que permite depurar el tráfico, multiplexar un mismo servidor entre varias ventanas o parchear un comportamiento defectuoso sin tocar ninguno de los dos extremos.
-- Nada en la configuracion revela si al otro lado hay un compilador,
-- un envoltorio sobre una herramienta antigua o un intermediario.
vim.lsp.config["puente"] = {
cmd = { "efm-langserver" },
filetypes = { "markdown", "sh", "yaml" },
root_markers = { ".git" },
}
-- Varios clientes adjuntos al mismo buffer: la pregunta correcta nunca es
-- que soporta el servidor, sino que soporta alguno de los adjuntos.
local function alguno_soporta(metodo)
for _, c in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do
if c:supports_method(metodo) then return c end
end
end
Ese es el rendimiento compuesto de una interfaz bien colocada: no solo reduce el trabajo previsto, sino que habilita usos que su autor no imaginó. Ninguna de estas piezas requirió permiso, coordinación ni cambios en la especificación, porque todas se limitan a cumplir el mismo contrato que ya existía.
Un servidor de lenguaje es, por definición, cualquier proceso que hable el protocolo. Esa definición deliberadamente vacía es lo que permite envolver herramientas antiguas, encadenar intermediarios y sustituir un extremo por otro sin que el editor lo note.
Merece la pena separar el artefacto de la idea, porque el artefacto es un protocolo concreto con verrugas y la idea es una de las más limpias de la ingeniería de software. La idea dice: cuando dos poblaciones de componentes tienen que interoperar y el trabajo crece como el producto de sus tamaños, la solución no es hacer cada integración más barata, es introducir una interfaz que convierta el producto en suma. Esa transformación es la misma que hicieron los controladores de dispositivo con los sistemas operativos, los formatos de fichero con las aplicaciones, la máquina virtual con los procesadores, el juego de instrucciones con los compiladores. En todos esos casos la ganancia no vino de resolver mejor el problema original, sino de reformularlo. Ahora fíjate en la parte difícil, que es dónde exactamente se coloca el corte, porque una interfaz mal situada es peor que ninguna. LSP acertó al poner la frontera en el punto donde la información deja de depender del lenguaje y todavía no depende de la presentación: rangos de texto y datos estructurados. Un corte un poco más alto —si el servidor devolviera fragmentos de interfaz ya renderizados— habría atado el protocolo a una generación de editores y hoy estaría muerto. Un corte un poco más bajo —si el servidor expusiera su árbol de tipos interno— habría obligado a cada cliente a entender la semántica de cuarenta lenguajes, que es exactamente el problema que se quería evitar. Ese equilibrio, y no la elección de JSON-RPC o el formato de las cabeceras, es lo que hay que estudiar. Y hay un corolario incómodo: toda interfaz que convierte producto en suma cobra su precio en expresividad, porque el vocabulario común solo puede contener lo que todos los participantes comparten. Por eso ningún servidor LSP iguala nunca a un entorno monolítico dedicado a un solo lenguaje. La pregunta de ingeniería no es cuál de los dos es mejor, sino si el noventa por ciento de la funcionalidad disponible para todos los lenguajes vale más que el cien por cien disponible para uno. Para casi todo el mundo, la respuesta es que sí, y esa aritmética es la que explica por qué hoy escribes código con esta arquitectura y no con la anterior.
- Cuenta cuántos servidores distintos tienes instalados y cuántos editores usas; calcula el trabajo de integración que habría hecho falta sin protocolo común.
- Elige una funcionalidad que echas de menos y determina si el problema está en el servidor, en el cliente o en la ausencia de una estructura de datos en el protocolo.
- Mata el proceso de un servidor mientras editas y observa qué sobrevive; argumenta qué habría pasado si el analizador fuese una biblioteca enlazada.
- Busca dos servidores distintos para el mismo lenguaje y contrasta qué ofrece cada uno; explica por qué el cliente no necesitó cambiar para usarlos.
- Describe una funcionalidad de un entorno monolítico que el protocolo no podría expresar y propón qué estructura de datos habría que añadir.