Configurando nvim-lspconfig + nvim-cmp

Última actualización: 2023-11-01

Dentro de Neovim se encuentra un "framework" que le permite al editor comunicarse con un servidor LSP. ¿Qué significa eso? Quiere decir que, con la configuración correcta, tendremos acceso a funcionalidades como renombrar una variable, saltar a una definición, listar referencias, etc. Características que podrían encontrar en un IDE.

¿Por qué necesitamos plugins?

Por defecto Neovim nos ofrece las herramientas pero no tiene ninguna opinión de cómo debemos usarlas. Es aquí donde entra nvim-lspconfig, este plugin escanea la "carpeta raíz" de nuestro proyecto e intenta determinar qué servidor LSP necesitamos. Pero claro, primero debemos tener instalados los servidores en nuestro sistema y luego debemos configurarlos dentro de Neovim.

Y luego tenemos nvim-cmp, un plugin de autocompletado. ¿De verdad lo necesitamos? En realidad no. En mi opinión el autocompletado de Neovim es bastante capaz. ¿Cuál es el problema? Digamos que requiere de mucha intervención manual. Los editores de código modernos nos tienen acostumbrados a un flujo donde todo ocurre de manera automática. nvim-cmp nos permite tener ese autocompletado cómodo e inteligente.

Ahora que sabemos el porqué de las cosas pongamos manos a la obra.

Requisitos

En la documentación de nvim-lspconfig pueden encontrar instrucciones de cómo instalar algunos servidores: server_configurations.md

Configuración LSP

Lo que necesitamos hacer es llamar los servidores que tenemos instalados en nuestro sistema. Usamos lspconfig para llamar la función .setup() del servidor que queremos configurar.

¿Pero cómo sabemos qué servidores tenemos disponibles? En la documentación de lspconfig pueden encontrar la lista completa, pueden navegar a ella usando el comando :help lspconfig-server-configurations.

Por ejemplo, para el lenguaje lua tenemos disponible el servidor lua_ls. Luego de instalarlo en nuestro sistema podemos configurarlo de la siguiente manera.

local lspconfig = require('lspconfig')

lspconfig.lua_ls.setup({})

Ahora bien, al momento de configurar un servidor deben tener en cuenta que vamos a usar nvim-cmp para manejar el autocompletado. Debemos modificar una opción llamada capabilities en cada servidor que queremos usar. Esta opción le dice al servidor qué capacidades del protocolo LSP soporta Neovim.

Tenemos que utilizar el módulo cmp_nvim_lsp para obtener los valores apropiados para la opción capabilities.

local lsp_capabilities = require('cmp_nvim_lsp').default_capabilities()

Una vez que tenemos la variable lsp_capabilities podemos utilizarla en la función .setup() del servidor.

local lspconfig = require('lspconfig')
local lsp_capabilities = require('cmp_nvim_lsp').default_capabilities()

lspconfig.lua_ls.setup({
  capabilities = lsp_capabilities,
})

Para conocer las opciones disponibles de .setup() revisen la documentación con el comando :help lspconfig-setup.

Ahora lo más importante, los atajos de teclado. Para esto utilizaremos un autocomando, esto nos dará la libertad de colocar nuestros atajos en cualquier lugar de nuestra configuración.

vim.api.nvim_create_autocmd('LspAttach', {
  desc = 'Acciones LSP',
  callback = function()
    local bufmap = function(mode, lhs, rhs)
      local opts = {buffer = true}
      vim.keymap.set(mode, lhs, rhs, opts)
    end

    -- Muestra información sobre símbolo debajo del cursor
    bufmap('n', 'K', '<cmd>lua vim.lsp.buf.hover()<cr>')

    -- Saltar a definición
    bufmap('n', 'gd', '<cmd>lua vim.lsp.buf.definition()<cr>')

    -- Saltar a declaración
    bufmap('n', 'gD', '<cmd>lua vim.lsp.buf.declaration()<cr>')

    -- Mostrar implementaciones
    bufmap('n', 'gi', '<cmd>lua vim.lsp.buf.implementation()<cr>')

    -- Saltar a definición de tipo
    bufmap('n', 'go', '<cmd>lua vim.lsp.buf.type_definition()<cr>')

    -- Listar referencias
    bufmap('n', 'gr', '<cmd>lua vim.lsp.buf.references()<cr>')

    -- Mostrar argumentos de función
    bufmap('n', 'gs', '<cmd>lua vim.lsp.buf.signature_help()<cr>')

    -- Renombrar símbolo
    bufmap('n', '<F2>', '<cmd>lua vim.lsp.buf.rename()<cr>')

    -- Listar "code actions" disponibles en la posición del cursor
    bufmap('n', '<F4>', '<cmd>lua vim.lsp.buf.code_action()<cr>')

    -- Mostrar diagnósticos de la línea actual
    bufmap('n', 'gl', '<cmd>lua vim.diagnostic.open_float()<cr>')

    -- Saltar al diagnóstico anterior
    bufmap('n', '[d', '<cmd>lua vim.diagnostic.goto_prev()<cr>')

    -- Saltar al siguiente diagnóstico
    bufmap('n', ']d', '<cmd>lua vim.diagnostic.goto_next()<cr>')
  end
})

Con esto nuestros atajos serán creado cada vez que Neovim vincule un servidor LSP a un archivo.

Nota: si están usando Neovim v0.7.2 o menor, tienen que crear una función y asignarla a la opción on_attach de cada servidor LSP. En la documentación de lspconfig pueden encontrar un ejemplo, ejecuten el comando :help lspconfig-keybindings.

Para terminar con esta sección aquí tienen el código necesario para configurar un servidor usando lspconfig.

local lspconfig = require('lspconfig')
local lsp_capabilities = require('cmp_nvim_lsp').default_capabilities()

lspconfig.lua_ls.setup({
  capabilities = lsp_capabilities,
})

Snippets

Interrumpimos la programación habitual para configurar nuestro motor de snippets. Esta parte no está relacionada con el cliente LSP.

require('luasnip.loaders.from_vscode').lazy_load()

La función .lazy_load() carga las colecciones de snippets que tengamos en el runtimepath. Es aquí cuando se cargan los snippets de friendly-snippets.

Autocompletado

Para empezar, la documentación de nvim-cmp recomienda que configuremos la completeopt de la siguiente manera.

vim.opt.completeopt = {'menu', 'menuone', 'noselect'}

Para configurar nvim-cmp necesitamos dos módulos cmp y luasnip.

local cmp = require('cmp')
local luasnip = require('luasnip')

cmp es el módulo de nvim-cmp. ¿Qué hay de luasnip? Lo que pasa es que nvim-cmp no "sabe" como expandir un snippet, por eso necesitamos luasnip.

Vamos a pasar un buen rato configurando nvim-cmp. Quiero explicarles las opciones que yo uso en mi configuración, pero primero vamos a agregar la función cmp.setup.

local select_opts = {behavior = cmp.SelectBehavior.Select}

cmp.setup({

})

snippet.expand

Es una función que recibe la información de un snippet. Aquí es donde nvim-cmp nos da la oportunidad de expandir los snippets con el plugin que queramos. En nuestro caso tenemos luasnip.

snippet = {
  expand = function(args)
    luasnip.lsp_expand(args.body)
  end
},

sources

Aquí podemos listar los plugins que nvim-cmp usará para obtener datos.

Cada "fuente" es una tabla que debe tener la propiedad name, este nombre no se refiere al nombre del plugin sino al nombre con el que se registró la fuente en nvim-cmp. La documentación de cada fuente debería mostrarles este nombre.

Otras propiedades que deben tener en cuenta son priority y keyword_length. Con priority podemos elegir el orden en el que aparecen las sugerencias en la lista de autocompletado. Si no se especifica la propiedad priority entonces el orden de las fuentes dicta la prioridad. Con keyword_length se controla la cantidad de caracteres que se necesitan para empezar a ver datos de la fuente.

En mi configuración personal tengo las fuentes configuradas de esta manera.

sources = {
  {name = 'path'},
  {name = 'nvim_lsp', keyword_length = 1},
  {name = 'buffer', keyword_length = 3},
  {name = 'luasnip', keyword_length = 2},
},

window.documentation

Controla la apariencia de la ventana donde se muestra la documentación de un item. nvim-cmp ofrece una configuración que podemos usar para obtener una ventana con bordes.

window = {
  documentation = cmp.config.window.bordered()
},

formatting.fields

Lista que controla el orden en el que aparecen los elementos de un item.

formatting = {
  fields = {'menu', 'abbr', 'kind'}
},

abbr es el contenido de la sugerencia. kind es el tipo de dato, puede ser texto, una clase, una función, etc. menu está vacío por defecto.

formatting.format

Función que determina el formato del item. Un ejemplo simple que puedo mostrarles es cómo asignarle un ícono al "field" menu basado en el nombre de la fuente.

formatting = {
  fields = {'menu', 'abbr', 'kind'},
  format = function(entry, item)
    local menu_icon = {
      nvim_lsp = 'λ',
      luasnip = '',
      buffer = 'Ω',
      path = '🖫',
    }

    item.menu = menu_icon[entry.source.name]
    return item
  end,
},

mapping

Lista de atajos de teclado. Aquí necesitamos declarar una lista de pares propiedad/valor. Donde el valor será una función del módulo cmp.

mapping = {
  ['<CR>'] = cmp.mapping.confirm({select = false}),
}

En este ejemplo ['<CR>'] es el atajo que queremos crear. La función del otro lado de la asignación es la acción que queremos ejecutar.

Estas son algunas acciones comunes:

['<Up>'] = cmp.mapping.select_prev_item(select_opts),
['<Down>'] = cmp.mapping.select_next_item(select_opts),

['<C-p>'] = cmp.mapping.select_prev_item(select_opts),
['<C-n>'] = cmp.mapping.select_next_item(select_opts),
['<C-u>'] = cmp.mapping.scroll_docs(-4),
['<C-d>'] = cmp.mapping.scroll_docs(4),
['<C-e>'] = cmp.mapping.abort(),
['<C-y>'] = cmp.mapping.confirm({select = true}),
['<CR>'] = cmp.mapping.confirm({select = false}),
['<C-f>'] = cmp.mapping(function(fallback)
  if luasnip.jumpable(1) then
    luasnip.jump(1)
  else
    fallback()
  end
end, {'i', 's'}),
['<C-b>'] = cmp.mapping(function(fallback)
  if luasnip.jumpable(-1) then
    luasnip.jump(-1)
  else
    fallback()
  end
end, {'i', 's'}),

Si la lista de sugerencias es visible, navega al siguiente item. Si la línea está "vacía", inserta el caracter Tab. Si el cursor está en medio de una palabra, activa el autocompletado.

['<Tab>'] = cmp.mapping(function(fallback)
  local col = vim.fn.col('.') - 1

  if cmp.visible() then
    cmp.select_next_item(select_opts)
  elseif col == 0 or vim.fn.getline('.'):sub(col, col):match('%s') then
    fallback()
  else
    cmp.complete()
  end
end, {'i', 's'}),
['<S-Tab>'] = cmp.mapping(function(fallback)
  if cmp.visible() then
    cmp.select_prev_item(select_opts)
  else
    fallback()
  end
end, {'i', 's'}),

Configuración cmp

He aquí el código completo de la configuración de nvim-cmp y luasnip.

vim.opt.completeopt = {'menu', 'menuone', 'noselect'}

require('luasnip.loaders.from_vscode').lazy_load()

local cmp = require('cmp')
local luasnip = require('luasnip')

local select_opts = {behavior = cmp.SelectBehavior.Select}

cmp.setup({
  snippet = {
    expand = function(args)
      luasnip.lsp_expand(args.body)
    end
  },
  sources = {
    {name = 'path'},
    {name = 'nvim_lsp', keyword_length = 1},
    {name = 'buffer', keyword_length = 3},
    {name = 'luasnip', keyword_length = 2},
  },
  window = {
    documentation = cmp.config.window.bordered()
  },
  formatting = {
    fields = {'menu', 'abbr', 'kind'},
    format = function(entry, item)
      local menu_icon = {
        nvim_lsp = 'λ',
        luasnip = '',
        buffer = 'Ω',
        path = '🖫',
      }

      item.menu = menu_icon[entry.source.name]
      return item
    end,
  },
  mapping = {
    ['<Up>'] = cmp.mapping.select_prev_item(select_opts),
    ['<Down>'] = cmp.mapping.select_next_item(select_opts),

    ['<C-p>'] = cmp.mapping.select_prev_item(select_opts),
    ['<C-n>'] = cmp.mapping.select_next_item(select_opts),

    ['<C-u>'] = cmp.mapping.scroll_docs(-4),
    ['<C-d>'] = cmp.mapping.scroll_docs(4),

    ['<C-e>'] = cmp.mapping.abort(),
    ['<C-y>'] = cmp.mapping.confirm({select = true}),
    ['<CR>'] = cmp.mapping.confirm({select = false}),

    ['<C-f>'] = cmp.mapping(function(fallback)
      if luasnip.jumpable(1) then
        luasnip.jump(1)
      else
        fallback()
      end
    end, {'i', 's'}),

    ['<C-b>'] = cmp.mapping(function(fallback)
      if luasnip.jumpable(-1) then
        luasnip.jump(-1)
      else
        fallback()
      end
    end, {'i', 's'}),

    ['<Tab>'] = cmp.mapping(function(fallback)
      local col = vim.fn.col('.') - 1

      if cmp.visible() then
        cmp.select_next_item(select_opts)
      elseif col == 0 or vim.fn.getline('.'):sub(col, col):match('%s') then
        fallback()
      else
        cmp.complete()
      end
    end, {'i', 's'}),

    ['<S-Tab>'] = cmp.mapping(function(fallback)
      if cmp.visible() then
        cmp.select_prev_item(select_opts)
      else
        fallback()
      end
    end, {'i', 's'}),
  },
})

Contenido extra

Técnicamente ya están listos. Si agregaron todo el código que les mostré en su configuración ya deben tener el servidor LSP y el autocompletado funcionando. Pero aún cosas que podemos hacer para mejorar nuestra experiencia.

Personalizando los íconos de diagnóstico

Esto lo logramos con la función sign_define. Esta no es una función de lua, es de vimscript, quiere decir que accedemos a ella usando el prefijo vim.fn. Para saber los detalles de esta función usen el comando :help sign_define.

En fin, con esto podremos cambiar los "signos de diagnóstico".

local sign = function(opts)
  vim.fn.sign_define(opts.name, {
    texthl = opts.name,
    text = opts.text,
    numhl = ''
  })
end

sign({name = 'DiagnosticSignError', text = ''})
sign({name = 'DiagnosticSignWarn', text = ''})
sign({name = 'DiagnosticSignHint', text = ''})
sign({name = 'DiagnosticSignInfo', text = '»'})

Configuración global de diagnósticos

Para esto debemos usar el módulo vim.diagnostic, específicamente la función .config(). Esta función acepta una tabla como argumento, estas son sus propiedades.

{
  virtual_text = true,
  signs = true,
  update_in_insert = false,
  underline = true,
  severity_sort = false,
  float = true,
}

Cada una estas opciones aceptar una tabla de opciones para personalizar aún más su comportamiento. Pueden encontrar todos esos detalles en la documentación: :help vim.diagnostic.config().

Personalmente prefiero diagnósticos menos llamativos. Uso esta configuración.

vim.diagnostic.config({
  virtual_text = false,
  severity_sort = true,
  float = {
    border = 'rounded',
    source = 'always',
  },
})

Bordes en ventanas de ayuda

Hay dos métodos que muestran ventanas flotantes con información: vim.lsp.buf.hover() y vim.lsp.buf.signature_help(). Por defecto estas ventanas no tienen ningún estilo, pero podemos cambiar eso si modificamos la configuración del "handler" asociado a estas funciones. Para estos casos tenemos que usar vim.lsp.with().

No quiero aburrirlos con los detalles así que aquí les dejo el código.

vim.lsp.handlers['textDocument/hover'] = vim.lsp.with(
  vim.lsp.handlers.hover,
  {border = 'rounded'}
)

vim.lsp.handlers['textDocument/signatureHelp'] = vim.lsp.with(
  vim.lsp.handlers.signature_help,
  {border = 'rounded'}
)

Instalar servidores LSP de manera "local"

Tarde o temprano se van a encontrar con este plugin: mason.nvim. Esto les permite manejar completamente sus servidores LSP dentro de Neovim. Podrán instalar, actualizar y eliminar servidores usando Neovim.

Los servidores que instalen con este método no estarán disponibles de manera global, se instalarán en la carpeta "data" de Neovim. Ahora bien, algunos servidores al ser instalados con este método requieren de configuraciones extra, es por eso que también necesitamos usar mason-lspconfig.

Una vez que tengan todos los plugins necesarios deben configurar mason.nvim de esta manera.

require('mason').setup()
require('mason-lspconfig').setup()

Después de invocar estas funciones pueden usar lspconfig como si nada. Pueden fingir que mason.nvim no existe.

Aquí les dejo un ejemplo de uso.

require('mason').setup()
require('mason-lspconfig').setup()

local lspconfig = require('lspconfig')
local lsp_capabilities = require('cmp_nvim_lsp').default_capabilities()

lspconfig.lua_ls.setup({
  capabilities = lsp_capabilities,
})

En está ocasión es todo lo que voy a decir sobre mason.nvim. Si quieren saber más detalles deben revisar su documentación.

Conclusión

Han aprendido porque necesitamos nvim-lspconfig. Saben cómo crear una configuración global para todos sus servidores. Conocen las funciones más comúnes para sus atajos de teclado. Todo lo necesario para aprovechar las bondades que puede darles un servidor LSP.

Tuvimos la oportunidad de armar una configuración de nvim-cmp desde cero. Explorando paso a paso las opciones más utilizadas.

Finalmente vimos algunas opciones que nos permiten modificar la presentación de los diagnósticos (mensajes de error, advertencia, etc). Aprendimos a modificar los handlers que muestran ventanas flotantes. Y, dimos un vistazo a un método para instalar servidores de manera local.

Pueden encontrar todo el código de configuración aquí: nvim-lspconfig + nvim-cmp.

Y aquí les dejo una configuración de Neovim completamente funcional: nvim-starter - branch: 03-lsp.

Fuentes


¿Tienen alguna pregunta? Pueden dejar un comentario en cualquiera de estas plataformas:

Pueden contactarme por las redes sociales:

Gracias por su tiempo. Si este artículo les pareció útil y quieren apoyar mis esfuerzos para crear más contenido, pueden dejar una propina en buy me a coffee ☕.

Buy Me A Coffee