Configuring Language Server Protocol in Neovim

I’ve seen a lot of people with trouble trying to configure their LSP and autocompletion settings in neovim, so I decided to make this guide to try to help anyone who wants to try neovim and configure the LSP.

What is an LSP

First of all, LSP stands for Language Server Protocol, which is a language server that looks into your code and provides you with helpful diagnostics about it, as shown in the following images.

.tsx file with LSP off

The same file with LSP on

Plugins

In this guide we will use nvim-lspconfig, mason, mason-lspconfig, null-ls and nvim-cmp. That’s a lot of plugins, but what does each do?

  • Nvim-lspconfig is the built-in LSP from neovim, so we need it to configure the LSP with neovim.

  • Mason and mason-lspconfig are the managers of LSP, daps, linters, and formatters, making our task to manage the LSP’s easier.

  • With null-ls we can get formatters and diagnostics servers that we don’t have built-in neovim, like prettier and rubocop.
  • Nvim-cmp is for auto-completion and suggestions as we type.

Installation

If you don’t have a basic neovim config, you can watch this “neovim from scratch” series to get the initial experience.

To install everything, you can put this into your packer or adapt it to any other plugin manager.

use "neovim/nvim-lspconfig" -- enable LSP

use "williamboman/mason.nvim"

use "williamboman/mason-lspconfig.nvim"

use "jose-elias-alvarez/null-ls.nvim" -- for formatters and linters

use "hrsh7th/nvim-cmp"

use "hrsh7th/cmp-nvim-lsp"

and after that, run the PackerInstall or PackerSync command.

LSP

First, we will configure the LSP and create a folder to separate the logic from the rest of the code, here I’ll put the lua/user/lsp folder, and inside this folder create a init.lua file.

Also, don’t forget to write require(‘user.lsp’) inside your main init.lua file.

When talking about LSP, the main plugin is nvim-lspconfig, and in the documentation, you’ll find all the necessary instructions to set up your LSP. You’ll need to download the necessary LSP, like tsserver or pyright, and after that configure it with:require'lspconfig'.tsserver.setup{}

The server will automatically attach to your buffer if it’s the same file type and will provide you with the diagnostics. In the server’s setup, you can pass variables to get some facilities, like in the example below where we use the on_attach to remap some of the LSP features, like go to definition, show the variable specs on hover, rename the variable or format the document.

local on_attach = function(client, bufnr)
    local bufopts = { noremap=true, silent=true, buffer=bufnr }

    vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, bufopts)

    vim.keymap.set('n', 'gd', vim.lsp.buf.definition, bufopts)

    vim.keymap.set('n', 'K', vim.lsp.buf.hover, bufopts)

    vim.keymap.set('n', 'lr', vim.lsp.buf.rename, bufopts)

    vim.keymap.set('n', '<space>lf', function() vim.lsp.buf.format { async = true } end, bufopts)
end 

require’lspconfig’.tsserver.setup{ on_attach = on_attach }

With just these configs you’ll have a working LSP. If you also want to format on save, you can add on your on_attach function like this:

if client.supports_method("textDocument/formatting") then
    vim.api.nvim_clear_autocmds({ group = augroup, buffer = bufnr })
    vim.cmd("autocmd BufWritePre lua vim.lsp.buf.format()")
end

With this piece of code, you’ll create an auto command that’s called every time you save your file, executing the save command if your LSP supports it.

How I configure my LSP

In the LSP section above we saw a kind of minimal config to set up the LSP in neovim, but it’s still a bit clunky to me, so we’ll need to manually install and configure our language servers for different languages. We also don’t have a formatter like prettier. I’ll show you how to configure LSP files and maybe you’ll feel inspired to improve yours.

My LSP file tree:

lua
 |— user
     |
     |
     |– lsp
         |— handlers.lua
         |— init.lua
         |— mason.lua
         |— null-ls.lua
     |— cmp.lua

In lsp/init.lua import these files:

-- nvim/lua/user/lsp/init.lua

require("user.lsp.mason")
require("user.lsp.handlers").setup()
require("user.lsp.null-ls") 

And what does each do? Manson imports functions from the handler and sets up my required servers. In handlers.setup(), I add a few options, like icons that I want and how I want the floating window, virtual text, etc. And in null-ls, I add some extra formatters and linters which we don’t have in native LSP, like rubocop for ruby, and prettier for javascript and typescript.

-- nvim/lua/user/lsp/handlers.lua

-- First, we declare an empty object and put auto-complete features from nvim-cmp (we will set up cmp.lua later) in the LSP
local M = {}

M.capabilities = vim.lsp.protocol.make_client_capabilities()

-- protected call to get the cmp
local status_cmp_ok, cmp_nvim_lsp = pcall(require, "cmp_nvim_lsp")
if not status_cmp_ok then
  return
end

M.capabilities.textDocument.completion.completionItem.snippetSupport = true
M.capabilities = cmp_nvim_lsp.default_capabilities(M.capabilities)

-- Here we declare the setup function and add the modifications in signs and extra configs, like virtual text, false update_in_insert, rounded borders for float windows, etc.
M.setup = function()
    local signs = {
    -- change the "?" to an icon that you like  
        { name = "DiagnosticSignError", text = "?" },
        { name = "DiagnosticSignWarn", text = "?" },
        { name = "DiagnosticSignHint", text = "?" },
        { name = "DiagnosticSignInfo", text = "?" },
    }

    for _, sign in ipairs(signs) do
        vim.fn.sign_define(sign.name, { texthl = sign.name, text = sign.text, numhl = "" })
    end

    local config = {
        virtual_text = true,
        -- show signs
        signs = {
          active = signs,
        },
        update_in_insert = false,
        underline = true,
        severity_sort = true,
    }

    vim.diagnostic.config(config)
end

-- Here we set up keymaps. You can change them if you already have specifics for these functions, or just want to try another keymap.
local function lsp_keymaps(bufnr)
    local opts = { noremap = true, silent = true }
    vim.api.nvim_buf_set_keymap(bufnr, "n", "gd", "<cmd>lua vim.lsp.buf.definition()<CR>", opts)

    vim.api.nvim_buf_set_keymap(bufnr, "n", "K", "<cmd>lua vim.lsp.buf.hover()<CR>", opts)

    vim.api.nvim_buf_set_keymap(bufnr, "n", "<leader>lr", "<cmd>lua vim.lsp.buf.rename()<CR>", opts)

    vim.api.nvim_buf_set_keymap(bufnr, "n", "gl", "<cmd>lua vim.diagnostic.open_float()<CR>", opts)

    vim.cmd([[ command! Format execute 'lua vim.lsp.buf.format()' ]])
end

-- Here we let the LSP prioritize null-ls formatters. Why? Normally when we install a separate formatter or linter in null-ls we want to use just them.
-- if you don't prioritize any, neovim will ask you every time you format which one you want to use.
local lsp_formatting = function(bufnr)
    vim.lsp.buf.format({
        filter = function(client)
          return client.name == "null-ls"
        end,
        bufnr = bufnr,
    })
end

local augroup = vim.api.nvim_create_augroup("LspFormatting", {})

-- this function will attach our previously set keymaps and our lsp_formatting function to every buffer.
M.on_attach = function(client, bufnr)
    lsp_keymaps(bufnr)
    if client.supports_method("textDocument/formatting") then
        vim.api.nvim_clear_autocmds({ group = augroup, buffer = bufnr })    
        vim.api.nvim_create_autocmd("BufWritePre", {
            group = augroup,
            buffer = bufnr,
            callback = function()
                lsp_formatting(bufnr)
            end,
        })
    end
end

-- And finally, here we create a way to toggle format on save with the command "LspToggleAutoFormat" and after everything, we return the M object to use it in other files.
function M.enable_format_on_save()
    vim.cmd [[
    augroup format_on_save
        autocmd!
        autocmd BufWritePre * lua vim.lsp.buf.format({ async = false })
    augroup end
    ]]
    vim.notify "Enabled format on save"
end

function M.disable_format_on_save()
    M.remove_augroup "format_on_save"
    vim.notify "Disabled format on save"
end

function M.toggle_format_on_save()
    if vim.fn.exists "#format_on_save#BufWritePre" == 0 then
        M.enable_format_on_save()
    else
        M.disable_format_on_save()
    end
end

function M.remove_augroup(name)
    if vim.fn.exists("#" .. name) == 1 then
        vim.cmd("au! " .. name)
    end
end

vim.cmd [[ command! LspToggleAutoFormat execute 'lua ]]

-- Toggle "format on save" once, to start with the format on.
M.toggle_format_on_save()

return M

Here we call mason and declare the servers

-- nvim/lua/user/lsp/mason.lua

-- protected calls
local status_ok, mason = pcall(require, "mason")
if not status_ok then
    return
end

local status_ok_1, mason_lspconfig = pcall(require, "mason-lspconfig")
if not status_ok_1 then
    return
end

local servers = {
    "tsserver",
    "cssmodules_ls",
    "emmet_ls",
    "html",
    "sumneko_lua",
    "solargraph",
}

-- Here we declare which settings to pass to the mason, and also ensure servers are installed. If not, they will be installed automatically.
local settings = {
    ui = {
        border = "rounded",
        icons = {
        package_installed = "◍",
        package_pending = "◍",
        package_uninstalled = "◍",
        },
    },
    log_level = vim.log.levels.INFO,
    max_concurrent_installers = 4,
}

mason.setup(settings)
mason_lspconfig.setup {
    ensure_installed = servers,
    automatic_installation = true,
}

-- we'll need to call lspconfig to pass our server to the native neovim lspconfig.
local lspconfig_status_ok, lspconfig = pcall(require, "lspconfig")
if not lspconfig_status_ok then
    return
end

local opts = {}

-- loop through the servers
for _, server in pairs(servers) do
    opts = {
        -- getting "on_attach" and capabilities from handlers
        on_attach = require("user.lsp.handlers").on_attach,
        capabilities = require("user.lsp.handlers").capabilities,
    }

    -- get the server name
    server = vim.split(server, "@")[1]

    -- pass them to lspconfig
    lspconfig[server].setup(opts)
end

Now, null-ls can be called to declare our local variables, making it easier for us to use formatters and get diagnostics. We can also create a conditional variable which we’ll use to make conditional calls in projects with bundler.

Why use this conditional? In ruby, rubocop can be used in two different ways, depending on if we are in a rails project or not. In rails projects, it’s better to use bundle exec rubocop to get the project’s rubocop version instead of our computer’s, and in normal ruby files we can use just rubocop. That is what our conditional variable does.

-- nvim/lua/user/lsp/null-ls.lua

local null_ls_status_ok, null_ls = pcall(require, "null-ls")
if not null_ls_status_ok then
    return
end

local formatting = null_ls.builtins.formatting
local diagnostics = null_ls.builtins.diagnostics
local conditional = function(fn)
    local utils = require("null-ls.utils").make_conditional_utils()
    return fn(utils)
end

null_ls.setup({
    debug = false,

    -- the sources are prettier, eslint_d and rubocop
    sources = {
        formatting.prettier,

        -- setting eslint_d only if we have a ".eslintrc.js" file in the project
        diagnostics.eslint_d.with({
        condition = function(utils)
            return utils.root_has_file({ '.eslintrc.js' })
        end
    }),

    -- Here we set a conditional to call the rubocop formatter. If we have a Gemfile in the project, we call "bundle exec rubocop", if not we only call "rubocop".
    conditional(function(utils)
        return utils.root_has_file("Gemfile")
            and null_ls.builtins.formatting.rubocop.with({
            command = "bundle",
            args = vim.list_extend(
              { "exec", "rubocop" },
              null_ls.builtins.formatting.rubocop._opts.args
            ),
        })
        or null_ls.builtins.formatting.rubocop
    end),

    -- Same as above, but with diagnostics.rubocop to make sure we use the proper rubocop version for the project
    conditional(function(utils)
        return utils.root_has_file("Gemfile")
                and null_ls.builtins.diagnostics.rubocop.with({
                command = "bundle",
                args = vim.list_extend(
                  { "exec", "rubocop" },
                  null_ls.builtins.diagnostics.rubocop._opts.args
                ),
            })
            or null_ls.builtins.diagnostics.rubocop
        end),
    },
})

So far we have a complete functional LSP with formatters, linters, and diagnostics working for ruby, javascript, and lua. Now it’s time to configure the completions, so the editor can help you while you type.

The cmp plugin can help you a lot through completion sources, like snippets, buffers, LSP, etc, but each of them will have extra configuration, so for now we’ll go with just LSP and buffers, as Vim suggests.

-- nvim/lua/user/cmp.lua

local cmp_status_ok, cmp = pcall(require, "cmp")
if not cmp_status_ok then
    return
end

-- Basic mapping
cmp.setup({
    mapping = cmp.mapping.preset.insert({
        ["<C-p>"] = cmp.mapping.select_prev_item(),
        ["<C-n>"] = cmp.mapping.select_next_item(),
        ["<C-u>"] = cmp.mapping(cmp.mapping.scroll_docs(-1), { "i", "c" }),
        ["<C-d>"] = cmp.mapping(cmp.mapping.scroll_docs(1), { "i", "c" }),
        ["<C-Space>"] = cmp.mapping(cmp.mapping.complete(), { "i", "c" }),
        ["<C-c>"] = cmp.mapping({
            i = cmp.mapping.abort(),
            c = cmp.mapping.close(),
        }),
        ["<CR>"] = cmp.mapping.confirm({ select = true }),
        ["<Right>"] = cmp.mapping.confirm({ select = true }),
        ["<Tab>"] = cmp.mapping(function()
            if cmp.visible() then
                cmp.select_next_item()
            end
        end, {
            "i",
            "s",
        }),
        ["<S-Tab>"] = cmp.mapping(function()
            if cmp.visible() then
                cmp.select_prev_item()
            end
        end, {
        "i",
        "s",
    }),
}),

-- Here we choose how the completion window will appear
  formatting = {
    fields = { "kind", "abbr", "menu" },
    format = function(entry, vim_item)
      -- NOTE: order matters
      vim_item.menu = ({
        nvim_lsp = "[LSP]",
        buffer = "[Buffer]",
      })[entry.source.name]
      return vim_item
    end,

  },

-- Here is the place where we can choose our sources, if the cmp is already configured, we can just add it here.
  sources = {
    { name = "nvim_lsp" },
    { name = "buffer" },
  },
  confirm_opts = {
    behavior = cmp.ConfirmBehavior.Replace,
    select = false,
  },
  experimental = {
    ghost_text = true,
  },
})

With all of this set, you’ll have a completely working LSP and formatters for Ruby(rubocop and solargraph) and Javascript (tsserver, prettier and eslint_d), and also completions with cmp based on the LSPs, ready for development. Neovim should have the LSPs for most of programming languages by now, if you want to check which LSPs neovim can support natively you can access this link. But you can also just enter in neovim normal mode and type :Mason to check more options, the most part you’ll be able to configure with null-ls or lspconfig with mason.

We want to work with you. Check out our "What We Do" section!