wandres.dev
NINJA · IDE completo

LSP nativo: el cerebro del IDE

Configura el Language Server Protocol con la API nativa de Neovim 0.12: vim.lsp.config, vim.lsp.enable y la carpeta lsp/. Diagnósticos, ir a definición, code actions, inlay hints y el comando :lsp, sin plugins obligatorios.

⏱ 22 min

El LSP es lo que convierte a Neovim en un IDE: autocompletado semántico, ir a definición, renombrado seguro, diagnósticos en tiempo real y refactors. En Neovim 0.12 todo eso vive en el núcleo: describes cada servidor en un fichero, lo activas con una línea, y no necesitas ningún plugin para que funcione.

🎯 Al terminar esta lección sabrás
  • Entender qué es un language server y cómo habla con Neovim.
  • Configurar LSP con la API nativa: vim.lsp.config, vim.lsp.enable y la carpeta lsp/.
  • Saber qué aportan hoy nvim-lspconfig y Mason, y por qué ambos son opcionales.
  • Añadir solo los atajos que Neovim 0.12 no trae ya, sin pisar teclas ocupadas.

Qué es el LSP

El Language Server Protocol separa la inteligencia del lenguaje del editor. Un “language server” (p. ej. rust-analyzer, clangd, lua_ls) es un proceso independiente que entiende tu código y responde preguntas: “¿dónde se define esto?”, “¿qué autocompleta aquí?”, “¿hay errores?”. Neovim es el cliente que le pregunta.

flowchart LR
N[Neovim cliente LSP] -->|peticiones| S[Servidor de lenguaje]
S -->|diagnosticos completado definiciones| N
M[Gestor de binarios opcional] -.instala.-> S
style N fill:#89b4fa,color:#11111b
style S fill:#cba6f7,color:#11111b
style M fill:#a6e3a1,color:#11111b

La forma nativa: describir y activar

En 0.12 configurar un servidor son dos gestos separados. vim.lsp.config describe un servidor: qué binario lanzar (cmd), para qué tipos de fichero (filetypes), qué marca la raíz del proyecto (root_markers) y qué opciones enviarle (settings). vim.lsp.enable lo activa: a partir de ahí, cuando abras un buffer del tipo adecuado, Neovim resuelve la raíz, arranca el proceso y adjunta el cliente.

Puedes llamar a vim.lsp.config desde tu init.lua, pero la forma canónica es otra: cualquier fichero lsp/nombre.lua dentro del runtimepath se interpreta como la configuración del servidor nombre, y solo se lee cuando ese servidor hace falta. Un fichero por servidor, y el arranque se queda con una sola línea.

-- Devuelve la tabla; no llames a vim.lsp.config desde aquí.
return {
  cmd = { "lua-language-server" },
  filetypes = { "lua" },
  -- La lista nested indica "igual prioridad": cualquiera de los dos vale.
  root_markers = { { ".luarc.json", ".luarc.jsonc" }, ".git" },
  settings = {
    Lua = {
      runtime = { version = "LuaJIT" },
      workspace = { checkThirdParty = false },
      telemetry = { enable = false },
    },
  },
}
return {
  cmd = { "clangd", "--background-index", "--clang-tidy" },
  filetypes = { "c", "cpp", "objc", "objcpp" },
  root_markers = { "compile_commands.json", ".clangd", ".git" },
}
-- Una sola línea decide qué se activa.
vim.lsp.enable({
  "lua_ls",       -- Lua (tu config)
  "clangd",       -- C / C++
  "ts_ls",        -- TypeScript / JavaScript
  "tailwindcss",
  "cssls", "html", "jsonls", "yamlls",
})

Lo transversal —lo que quieres para todos los servidores— se declara una vez bajo el nombre comodín *:

vim.lsp.config("*", {
  root_markers = { ".git" },
  capabilities = {
    textDocument = { semanticTokens = { multilineTokenSupport = true } },
  },
})
ℹ️
Rust y Swift van aparte

No habilites rust_analyzer aquí si usas rustaceanvim (lección 2.6): ese plugin arranca y gestiona el servidor por su cuenta y tendrías dos clientes peleándose. Y sourcekit-lsp viene con Xcode, no hay nada que descargar (lección 2.7).

nvim-lspconfig y Mason: qué aportan hoy

Ninguno de los dos es necesario. Conviene saber exactamente qué hace cada uno para decidir con criterio.

nvim-lspconfig ya no es una capa, es un catálogo. El repositorio contiene hoy 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 con menor precedencia que las tuyas. No aporta mecanismo: sigues escribiendo vim.lsp.config y vim.lsp.enable. Lo instalas si trabajas con muchos lenguajes y no quieres averiguar los detalles de cada uno; lo omites si tu abanico es corto.

neovim/nvim-lspconfig
return {
  "neovim/nvim-lspconfig",
  lazy = false,  -- son datos en el runtimepath: nada que cargar en diferido
}
vim.pack.add({ { src = "https://github.com/neovim/nvim-lspconfig" } })

Opcional. Solo añade su carpeta lsp/ al runtimepath: no arranca, no activa y no configura nada por su cuenta.

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; solo hace que el cmd de tus fichas exista en la máquina. Si instalas tus servidores con el gestor de paquetes del sistema (brew, apt, npm, cargo), no lo necesitas.

mason-org/mason.nvim
return {
  "mason-org/mason.nvim",
  lazy = false,
  priority = 100,  -- el PATH debe estar listo antes del primer LspAttach
  opts = {},
}
vim.pack.add({ { src = "https://github.com/mason-org/mason.nvim" } })

require("mason").setup({})

Opcional. Tiene que cargarse antes de que arranque ningún servidor, porque lo que hace es poner su directorio de binarios en el PATH.

💡
lazy.nvim y vim.pack no compiten

No tienes que elegir. vim.pack es el gestor nativo de 0.12, con lockfile para reproducir versiones exactas, y es perfecto para todo lo que no necesita carga perezosa —que es mucho: temas, catálogos como nvim-lspconfig, librerías. lazy.nvim sigue valiendo la pena cuando quieres lazy loading fino o su DSL de specs. Muchas configuraciones de 2026 usan las dos a la vez. Por eso cada instalación de esta guía se muestra en ambas vías: elige la que encaje con ese plugin concreto.

Novedades de 0.12 que vas a notar

🎛️

El comando :lsp

:lsp enable, :lsp disable, :lsp restart y :lsp stop gestionan clientes de forma interactiva. Sustituyen a los viejos comandos de plugin del estilo LspInfo o LspRestart.

Semantic tokens por viewport

El cliente pide textDocument/semanticTokens/range: solo procesa lo visible en pantalla. En ficheros grandes la diferencia de fluidez es evidente.

🔎

Code lens en líneas virtuales

El code lens se reimplementó y ahora se dibuja como líneas virtuales sobre el código, no como texto virtual al final de la línea. grx ejecuta el que tengas bajo el cursor.

🧩

Capacidades nuevas

selectionRange (selección incremental), inlineCompletion, linkedEditingRange, documentLink (gx abre el enlace bajo el cursor) y diagnósticos de workspace.

Keymaps: mapea solo lo que falta

Este es el cambio de hábito más grande. Neovim 0.12 crea atajos globales de LSP y de diagnósticos al arrancar, sin que configures nada:

Tecla Hace
grn renombrar símbolo
gra code action (normal y visual)
grr referencias
gri implementaciones
grt ir a la definición del tipo
grx ejecutar el code lens del cursor
gO símbolos del documento
K documentación flotante
CTRL-] ir a definición (vía tagfunc)
CTRL-S en Insert signature help
]d [d ]D [D saltar entre diagnósticos
CTRL-W d ver el diagnóstico del cursor en flotante
an in en Visual ampliar o reducir la selección
⚠️
Teclas que NO puedes robar

Antes de inventarte atajos, tres reglas que valen para todo el track. Ctrl-Espacio está reservado al autocompletado: es lo que dispara el completado nativo de 0.12 (vim.o.autocomplete) y también el atajo por defecto de blink.cmp y nvim-cmp. Si lo mapeas a otra cosa, tu completado deja de responder en Insert. Lo mismo con Tab: parece libre y no lo está. Y **Y s y S son de flash.nvim, con mini.surround en el prefijo gs (lección 2.3).

Añade a la lista un cuarto caso propio del LSP: no mapees gr a secas. Si lo haces, tapas la familia entera grn, gra, grr, gri, grt y grx. Tampoco gi, que en Vim vuelve al último punto de inserción, ni gh, que arranca el modo Select.

Con eso claro, lo que queda por mapear es poco. Los atajos que dependen del LSP viven en el evento LspAttach, que es el único momento en que existen buffer y cliente:

vim.api.nvim_create_autocmd("LspAttach", {
  group = vim.api.nvim_create_augroup("lsp_attach", { clear = true }),
  callback = function(ev)
    local client = vim.lsp.get_client_by_id(ev.data.client_id)
    if not client then return end

    local map = function(keys, fn, desc)
      vim.keymap.set("n", keys, fn, { buffer = ev.buf, desc = "LSP: " .. desc })
    end

    -- Solo lo que Neovim no trae ya mapeado
    map("<leader>lf", function() vim.lsp.buf.format({ timeout_ms = 2000 }) end, "Formatear")
    map("<leader>lw", vim.lsp.buf.workspace_symbol, "Buscar simbolo en el proyecto")
    map("<leader>ld", vim.diagnostic.setqflist, "Diagnosticos al quickfix")

    -- Inlay hints (tipos en linea): estan apagados por defecto
    if client:supports_method("textDocument/inlayHint") then
      vim.lsp.inlay_hint.enable(true, { bufnr = ev.buf })
      map("<leader>lh", function()
        local on = vim.lsp.inlay_hint.is_enabled({ bufnr = ev.buf })
        vim.lsp.inlay_hint.enable(not on, { bufnr = ev.buf })
      end, "Alternar inlay hints")
    end

    -- Code lens: tambien apagado por defecto; se dibuja como lineas virtuales
    if client:supports_method("textDocument/codeLens") then
      vim.lsp.codelens.enable(true, { bufnr = ev.buf })
    end
  end,
})
💡
Condiciona siempre a lo negociado

client:supports_method(...) pregunta qué acordaron cliente y servidor durante el arranque. Invocar una capacidad que el servidor no ofrece produce errores silenciosos que luego cuesta rastrear. Y recuerda que LspAttach se dispara una vez por cada pareja de cliente y buffer: con dos servidores en el mismo fichero, tu función corre dos veces.

Diagnósticos con estilo

Configura cómo se ven los errores y avisos. Esto no depende del LSP: vim.diagnostic es un subsistema propio y el cliente es solo uno de sus productores.

vim.diagnostic.config({
  virtual_text = { severity = { min = vim.diagnostic.severity.WARN }, prefix = "●" },
  virtual_lines = { current_line = true },   -- el mensaje completo, solo en la linea del cursor
  underline = true,
  update_in_insert = false,
  severity_sort = true,
  float = { border = "rounded", source = true },
  signs = {
    text = {
      [vim.diagnostic.severity.ERROR] = " ",
      [vim.diagnostic.severity.WARN]  = " ",
      [vim.diagnostic.severity.HINT]  = " ",
      [vim.diagnostic.severity.INFO]  = " ",
    },
  },
})

Plugins que hacen el LSP aún mejor

Ninguno hace falta para que el LSP funcione. Estos tres se ganan el sitio por otras razones.

folke/lazydev.nvim
return {
  "folke/lazydev.nvim",
  ft = "lua",
  opts = {
    library = { { path = "${3rd}/luv/library", words = { "vim%.uv" } } },
  },
}
vim.pack.add({ { src = "https://github.com/folke/lazydev.nvim" } })

require("lazydev").setup({
  library = { { path = "${3rd}/luv/library", words = { "vim%.uv" } } },
})

Completado y tipos correctos para tu propia config de Neovim en Lua. Con esto no necesitas el truco de declarar vim como global en los settings de lua_ls.

fidget.nvimj-hui/fidget.nvim

Muestra el progreso del LSP (indexando, cargando) en una esquina. Ojo: 0.12 ya expone vim.ui.progress_status() en la línea de estado por defecto, así que esto es cosmética, no necesidad.

opt
🚑
trouble.nvimfolke/trouble.nvim

Lista navegable de diagnósticos, referencias y símbolos del proyecto. Un buen sustituto del quickfix si te resulta árido.

opt
El comando que resuelve el 90% de los problemas

Si un servidor no arranca o algo va raro, ejecuta :checkhealth vim.lsp. Verás las configuraciones habilitadas, la configuración resuelta de cada una tras la fusión, qué clientes están vivos, a qué buffers están adjuntos, la raíz detectada y la ruta del registro de la sesión. Antes de buscar en internet, mira ahí: casi siempre es un binario que falta en el PATH, un filetype que no coincide con el que creías o una raíz de proyecto mal resuelta. Para reintentar sin salir del editor, :lsp restart.

⚔️ Enciende el cerebro
  1. Crea lsp/lua_ls.lua con la ficha del servidor y añade una única llamada a vim.lsp.enable en tu init.lua. Sin instalar ningún plugin.
  2. Abre un archivo .lua de tu propia config. Deberías ver diagnósticos si hay errores, y K debería mostrar documentación sobre vim.opt.
  3. Ejecuta :checkhealth vim.lsp y localiza la raíz resuelta y la configuración efectiva de lua_ls.
  4. Prueba los atajos que ya vienen de fábrica: grr, grn, gra, gO, ]d. Comprueba que no necesitas mapear nada para tenerlos.
  5. Desactiva el servidor en caliente con :lsp disable lua_ls y vuelve a activarlo con :lsp enable lua_ls. Observa qué pasa con los diagnósticos del buffer abierto.

Con el LSP vivo, en la siguiente lección le damos un buen autocompletado.