wandres.dev
VIM.LSP · el cliente por dentro

Configurar servidores sin distribución

La configuración de un servidor de lenguaje como dato declarativo en Neovim 0.12: vim.lsp.config para describirlo, vim.lsp.enable para activarlo y la carpeta lsp del runtimepath como mecanismo de descubrimiento perezoso. Orden de fusión profunda, la carpeta after, el comando :lsp y el papel que le queda hoy a nvim-lspconfig.

⏱ 24 min

Durante casi una década, arrancar un servidor de lenguaje en Neovim exigió un plugin intermediario. No porque el editor careciera de cliente LSP —lo tiene integrado desde hace años—, sino porque faltaba la capa aburrida y decisiva: saber qué binario ejecutar, para qué tipos de fichero, desde qué directorio y con qué opciones. Esa capa vivía fuera del editor, y su ausencia dentro explicaba la sensación de que configurar el LSP era un rito de iniciación. En Neovim 0.12 esa capa forma parte del núcleo y nvim-lspconfig ha dejado de ser un requisito. Un servidor es una tabla de Lua registrada bajo un nombre, y activarlo es nombrarlo. Este capítulo desmonta ese mecanismo hasta el fondo, porque entenderlo bien es lo que te permite dejar de copiar fragmentos de configuración ajenos y empezar a escribir los tuyos con criterio.

🎯 Al terminar esta lección sabrás
  • Declarar la configuración de un servidor con vim.lsp.config y activarla con vim.lsp.enable.
  • Entender la carpeta lsp/ del runtimepath como descubrimiento perezoso y no como carga inmediata.
  • Dominar el orden de fusión entre comodín, ficheros lsp/, ficheros after/lsp/ y llamadas explícitas.
  • Inspeccionar y gobernar el resultado con :lsp, :checkhealth vim.lsp y las funciones de consulta.
  • Situar a nvim-lspconfig en su papel actual de repositorio de datos, no de motor.

Un servidor es una tabla, no un plugin

La pregunta que resuelve toda esta maquinaria es sorprendentemente concreta: para un buffer dado, ¿qué proceso hay que lanzar y cómo hay que hablarle? La respuesta cabe en cuatro campos. El primero, cmd, es el ejecutable y sus argumentos. El segundo, filetypes, la lista de tipos de fichero a los que ese servidor se ofrece. El tercero, root_markers, los nombres de fichero cuya presencia identifica la raíz de un proyecto. El cuarto, settings, la configuración específica del servidor, que Neovim no interpreta y se limita a transportar.

vim.lsp.config("luals", {
  cmd = { "lua-language-server" },
  filetypes = { "lua" },
  root_markers = { { ".luarc.json", ".luarc.jsonc" }, ".git" },
  settings = {
    Lua = {
      runtime = { version = "LuaJIT" },
      workspace = { checkThirdParty = false },
    },
  },
})

vim.lsp.enable("luals")

Fíjate en la forma de root_markers: el orden de la lista es el orden de prioridad, y una lista anidada declara “igual prioridad”. El ejemplo dice: busca hacia arriba el primer directorio que contenga cualquiera de los dos ficheros de configuración de LuaLS; si no aparece ninguno, busca el primero que contenga .git. Sin el anidamiento, el editor recorrería el árbol entero buscando el primer marcador antes de plantearse el segundo, que casi nunca es lo que quieres.

Conviene separar mentalmente las dos llamadas, porque hacen cosas de naturaleza distinta. vim.lsp.config es puramente declarativa: registra o modifica una entrada en un diccionario de configuraciones y no lanza ningún proceso. vim.lsp.enable es la que introduce comportamiento: marca ese nombre como candidato y coloca la maquinaria que, cuando se abra un buffer cuyo tipo de fichero coincida, intentará adjuntarle un cliente. Puedes declarar cuarenta servidores y habilitar tres; los otros treinta y siete no cuestan nada más que la memoria de sus tablas.

Ninguno de los cuatro campos es obligatorio en sentido estricto, pero omitir alguno tiene consecuencias predecibles que conviene anticipar. Sin cmd no hay nada que lanzar. Sin filetypes el servidor nunca encuentra un buffer al que ofrecerse, porque esa lista es el único criterio de selección. Sin root_markers la raíz cae al directorio de trabajo actual, que casi nunca es la del proyecto. Y sin settings el servidor usa sus propios valores por defecto, que suelen ser razonables y por eso es el campo que más veces se deja en blanco con toda legitimidad.

Un detalle que ahorra confusión más adelante: el nombre que pasas a ambas funciones no tiene ningún significado especial. No es el nombre del ejecutable ni un identificador del protocolo. Es una clave que tú eliges y que sirve para tres cosas: relacionar la declaración con la activación, permitir modificaciones posteriores de esa misma entrada y aparecer en los mensajes de diagnóstico del propio cliente.

La carpeta lsp del runtimepath

Declarar todos los servidores en el fichero de arranque funciona, pero degrada rápido: la configuración crece, se mezcla y se ejecuta entera aunque abras un fichero de texto plano. Por eso el mecanismo canónico es otro. Cualquier fichero lsp/nombre.lua dentro del runtimepath se interpreta como la configuración del servidor llamado nombre, y ese fichero solo se lee cuando ese servidor hace falta. La configuración deja de ser un bloque monolítico y pasa a ser un directorio de fichas independientes.

-- lsp/clangd.lua  (devuelve la tabla, sin llamar a vim.lsp.config)
return {
  cmd = { "clangd", "--background-index", "--clang-tidy" },
  filetypes = { "c", "cpp", "objc" },
  root_markers = { "compile_commands.json", ".clangd", ".git" },
}
-- init.lua  (una sola linea decide que se activa)
vim.lsp.enable({ "luals", "clangd", "rust_analyzer", "pyright" })

Sobre una misma entrada pueden confluir varias fuentes, y el orden de fusión es lo que hace posible que la personalización sea quirúrgica en lugar de destructiva. Cuando un cliente arranca, resuelve su configuración combinando cuatro capas de menor a mayor precedencia: la entrada comodín registrada bajo el nombre *; después todos los ficheros lsp/nombre.lua que aparezcan en el runtimepath; después todos los after/lsp/nombre.lua, que es el mecanismo estándar de Vim para pisar lo que aporta un plugin; y por último cualquier otra definición, es decir tus llamadas explícitas a vim.lsp.config.

La fusión es profunda, no superficial: sigue la semántica de vim.tbl_deep_extend en modo forzado. Eso significa que redefinir una rama de settings injerta tus claves dentro de la tabla existente en lugar de reemplazarla entera, que es justo lo que quieres para tocar una opción sin heredar la responsabilidad de mantener el resto. La excepción son las listas: una tabla-lista como filetypes o root_markers se sustituye completa, porque mezclar dos listas por posición no significaría nada. Si alguna vez quieres saltarte la cadena por completo y no fusionar nada, asigna directamente por índice —vim.lsp.config.clangd = { ... }— y esa tabla pasa a ser la definición, no una capa más.

Esa entrada comodín merece atención propia, porque es la vía limpia para lo transversal. Todo lo que quieras aplicar a todos los servidores —las capacidades que aporta tu motor de completado, un tiempo máximo de espera, un manejador propio— se declara una vez ahí y deja de repetirse en cada ficha.

vim.lsp.config("*", {
  capabilities = {
    textDocument = { semanticTokens = { multilineTokenSupport = true } },
  },
})
flowchart TB
a[Abres un buffer] --> b[Neovim mira el filetype]
b --> c[Busca servidores habilitados con vim.lsp.enable]
c --> d[Lee lsp de nombre punto lua en el runtimepath]
d --> e[Fusiona comodin luego lsp luego after lsp luego config explicita]
e --> f[Resuelve la raiz con root markers]
f --> g[Lanza o reutiliza el proceso y adjunta el cliente]
style e fill:#cba6f7,color:#11111b
style g fill:#a6e3a1,color:#11111b

Qué queda de nvim-lspconfig

Si el editor ya sabe declarar, fusionar y activar, cabe preguntarse qué hace todavía el plugin que durante años fue sinónimo de LSP en Neovim. La respuesta es honesta y poco glamurosa: aporta datos, no mecanismo. Ese repositorio contiene hoy, literalmente, un directorio lsp/ con centenares de ficheros como los que acabas de escribir a mano, mantenidos por gente que ha averiguado qué argumento necesita cada binario, qué marcadores identifican la raíz de cada ecosistema y qué tipos de fichero reclama cada servidor. Al instalarlo, ese directorio entra en tu runtimepath y sus fichas quedan disponibles como capa de menor precedencia que la tuya. No expone ninguna API que debas llamar: sigues escribiendo vim.lsp.config y vim.lsp.enable exactamente igual que sin él.

Si decides instalarlo, la lección 2.1 tiene el bloque; aquí interesa qué aporta, no cómo se descarga.

ℹ️
lazy.nvim y vim.pack se usan juntas

No es una disyuntiva. vim.pack es el gestor integrado de 0.12, con lockfile para fijar versiones exactas, y basta para todo lo que no necesita carga perezosa: catálogos como este, librerías, temas. lazy.nvim aporta carga perezosa fina y un DSL de specs, y sigue mereciendo la pena para plugins pesados que solo quieres bajo cierto evento o tipo de fichero. Conviven sin problema en la misma configuración.

La consecuencia práctica es un cambio de postura. Ya no dependes del plugin para que el LSP funcione, y por tanto la decisión de usarlo se vuelve un cálculo sobrio: lo instalas si trabajas con muchos lenguajes y prefieres no averiguar los detalles de cada uno, y lo omites si tu abanico es corto y quieres cero indirecciones. En ambos casos escribes el mismo código, porque la interfaz es la del núcleo. Y si lo instalas, personalizar deja de ser una operación delicada: declaras solo el campo que quieres cambiar y la fusión respeta el resto.

-- Solo cambio los marcadores de raiz; cmd, filetypes y settings
-- siguen viniendo de la ficha del repositorio de configuraciones
vim.lsp.config("pyright", {
  root_markers = { "pyproject.toml", "uv.lock", ".git" },
})

Cuando el ajuste es estable y quieres que viva junto al resto de fichas en lugar de en el fichero de arranque, el sitio idiomático es la carpeta after/. Un after/lsp/pyright.lua se fusiona sobre lo que traiga el catálogo, y lo hace por el mecanismo genérico de Vim, sin que tu configuración dependa del orden en que se instalaron los plugins.

-- after/lsp/pyright.lua
return {
  root_markers = { "pyproject.toml", "uv.lock", ".git" },
}

Queda por nombrar la otra pieza que suele acompañar a todo esto. Un gestor de herramientas como Mason resuelve un problema distinto y ortogonal: conseguir el binario. Instala servidores, formateadores y depuradores en un directorio propio y lo añade al camino de búsqueda de ejecutables. Ni declara ni activa nada; simplemente hace que el cmd de tus fichas exista en la máquina, y es igual de opcional que el catálogo: si instalas los servidores con el gestor de paquetes de tu sistema, no aporta nada. Confundir esas tres responsabilidades —conseguir el binario, describir el servidor, activarlo— es la fuente de la mayoría de las configuraciones enredadas que circulan por ahí.

Comprobar lo que de verdad se aplicó

La fusión por capas es potente y por eso mismo opaca: cuando algo no funciona, lo primero que necesitas no es una hipótesis sino la configuración efectiva, la que resultó de combinarlo todo. El propio cliente la expone, y consultarla convierte una tarde de conjeturas en dos minutos de lectura.

-- La tabla resuelta, tras comodin, ficheros lsp, after y config explicita.
-- Ojo: indexar por nombre TIENE el efecto de resolver la configuracion.
vim.print(vim.lsp.config["clangd"])

-- Comprobar si un nombre esta habilitado, sin resolverlo
vim.print(vim.lsp.is_enabled("clangd"))

-- Todas las configuraciones que atacarian a un filetype dado
vim.print(vim.lsp.get_configs({ filetype = "c", enabled = true }))

-- Que clientes tienen proceso vivo ahora mismo
vim.print(vim.tbl_map(function(c) return c.name end, vim.lsp.get_clients()))

Tres comprobaciones cubren casi todos los fallos de arranque, y conviene hacerlas en este orden. La primera es si el binario existe: un cmd que apunta a un ejecutable ausente produce un fallo que a veces no se muestra en ningún sitio visible. La segunda es si el tipo de fichero del buffer está realmente en filetypes, porque los nombres del editor no siempre coinciden con la intuición. La tercera es qué raíz se resolvió, ya que un servidor arrancado sin proyecto funciona a medias y parece configurado. Por encima de las tres, :checkhealth vim.lsp presenta un informe con las configuraciones habilitadas, la configuración resuelta de cada una, los clientes activos, a qué buffers están adjuntos, sus raíces y la ruta del registro de la sesión.

Para operar sobre todo esto sin escribir Lua, 0.12 añade un comando dedicado. :lsp enable y :lsp disable activan y desactivan una configuración por nombre para el buffer actual y los futuros; :lsp restart reinicia los clientes adjuntos al buffer, que es la maniobra correcta cuando cambias una ficha y quieres que el servidor la vuelva a leer; y :lsp stop los para sin más. Sustituyen a los comandos que antes aportaba el plugin intermediario, de modo que si un tutorial te manda ejecutar algo del estilo de LspInfo o LspRestart, está escrito para una versión anterior.

Hay además un campo que evita un mal comportamiento frecuente: workspace_required. Cuando vale verdadero, el servidor solo se activa si se encontró una raíz de proyecto de verdad. Sin él, abrir un fichero suelto arranca un proceso que no sabe dónde está y responde a medias, y ese es exactamente el caso que después se diagnostica como “el completado va raro”.

💡
Un fichero por servidor, una línea para activarlos

Trata el directorio lsp/ como tratarías un directorio de módulos: una ficha por servidor, autocontenida, sin efectos secundarios y devolviendo una tabla. Tu fichero de arranque queda con una sola llamada a vim.lsp.enable y una lista de nombres, que es exactamente la información que quieres ver de un vistazo cuando vuelvas dentro de seis meses.

🧠

Declarar no es activar

vim.lsp.config solo escribe en un registro. Sin vim.lsp.enable no se abre ningún proceso ni se adjunta nada. Separarlas permite tener declarado mucho más de lo que usas.

🧠

Perezoso por diseño

Un fichero lsp/nombre.lua no se lee al arrancar, sino cuando ese servidor entra en juego. La configuración puede crecer sin castigar el tiempo de arranque.

🧠

Fusión profunda

Las capas se combinan recursivamente, así que tocar una rama de settings no destruye las demás. Las listas, en cambio, se sustituyen enteras.

🧠

Apagar también es una operación

vim.lsp.enable acepta un segundo argumento booleano. Pasarle un valor falso desactiva la configuración y para los clientes relacionados. Equivale a :lsp disable.

Cuando una capa se absorbe, el ecosistema entero cambia de forma

Lo que acaba de ocurrir con el LSP en Neovim es un caso de libro de un fenómeno que se repite en todo sistema de software vivo, y verlo con claridad vale mucho más que memorizar dos nombres de función. Durante años existió una capa externa imprescindible que resolvía un problema que el núcleo no cubría, y esa capa acumuló, además del mecanismo, un enorme cuerpo de conocimiento empírico. Cuando el núcleo absorbe el mecanismo, no destruye la capa externa: la despoja de su parte estructural y la deja con la que es genuinamente difícil de centralizar, que es el conocimiento. Por eso nvim-lspconfig no desapareció ni pasó a ser irrelevante; pasó a ser un catálogo. Y esa distinción entre mecanismo y datos es el criterio más útil que puedes llevarte de este nivel, porque se aplica lejos del editor. Cada vez que evalúes una dependencia, pregúntate qué fracción de ella es maquinaria y qué fracción es conocimiento acumulado. La maquinaria tiende a migrar hacia abajo, hacia el sustrato, donde se estandariza y deja de ser tu problema. El conocimiento resiste, porque nace de miles de casos particulares que nadie puede derivar desde primeros principios. Un mantenedor que confunde ambos escribe un plugin que quedará obsoleto en la siguiente versión menor; uno que los distingue escribe uno que sobrevive a que el núcleo lo alcance, porque desde el principio se limitó a aportar lo que el núcleo nunca va a saber. La misma pregunta te dice, sobre tu propia configuración, cuáles de tus líneas son andamios provisionales que el editor terminará absorbiendo y cuáles son decisiones tuyas sobre cómo quieres trabajar. Las primeras las borrarás con alivio. Las segundas te acompañarán durante años.

⚔️ Tu directorio de servidores
  1. Convierte la configuración de un servidor que ya uses en un fichero lsp/nombre.lua y reduce tu arranque a una única llamada a vim.lsp.enable. Hazlo sin instalar ningún plugin.
  2. Declara una entrada comodín con una capacidad transversal y comprueba con :checkhealth vim.lsp que aparece en la configuración resuelta de todos tus servidores.
  3. Instala nvim-lspconfig, elige un servidor cuya ficha traiga y sobrescribe solo su lista de root_markers desde after/lsp/nombre.lua. Verifica con vim.print que el resto de campos sigue viniendo del catálogo.
  4. Cambia una clave anidada dentro de settings de un servidor que ya funcione y confirma, leyendo la configuración resuelta, que las claves hermanas siguen ahí. Después haz lo mismo con filetypes y observa la diferencia entre fusionar una tabla y sustituir una lista.
  5. Declara dos servidores y habilita uno solo. Comprueba, abriendo un buffer del tipo del servidor no habilitado, que su fichero de configuración no llega a leerse.
  6. Desactiva en caliente un servidor activo con :lsp disable y describe qué ocurre con el cliente ya adjunto a los buffers abiertos. Recupéralo con :lsp enable.