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!