Setup nvim-lspconfig + nvim-cmp
Neovim includes a lua framework that allows the editor to communicate with a language server. What does that mean for us? Means we can have some IDE-like features such as rename variables, smart jump to definition, list references, etc. But of course we need to configure all of this first.
Wait, why do we need all these plugins?
Out of the box neovim offers the tools to start and query a language server but it doesn't have any opinion on how we should use them. Enter nvim-lspconfig, with it neovim can scan the "root directory" of a project and choose which language server you configured should be initialiazed.
And then we have nvim-cmp, the autocompletion plugin. I think the completions provided by neovim are good enough but the thing is it requires a fair amount of manual intervention. Modern code editors have all this automated in a way that feels more intuitive, this is what nvim-cmp offers. We can have that smart autocompletion in neovim.
Now you know the why, let's move on to the how.
Requirements
- Neovim v0.7 or greater.
- An LSP server.
- Install nvim-lspconfig.
- Install plugins for autocompletion:
In nvim-lspconfig
documentation you'll find instructions to install the language servers it supports: server_configurations.md
LSP Config
First thing you would want to do is declare a global configuration, options that you can share between all the servers.
local lsp_defaults = {
flags = {
debounce_text_changes = 150,
},
capabilities = require('cmp_nvim_lsp').default_capabilities(),
on_attach = function(client, bufnr)
vim.api.nvim_exec_autocmds('User', {pattern = 'LspAttached'})
end
}
What do we have here?
flags.debounce_text_changes
: Amount of miliseconds neovim will wait to send the next document update notification.capabilities
: The data on this option is send to the server, to announce what features the editor can support. We usevim.lsp.protocol.make_client_capabilities()
build the default capabilities. Sincenvim-cmp
can add features to neovim we need to send an updated version of these capabilities.on_attach
: Callback function that will be executed when a language server is attached to a buffer. It is recommended that we set our keybindings and commands in this function. Personally, I like to usenvim_exec_autocmds
to trigger an "event", that way I can declare my keybindings anywhere I want.
Now, lsp_defaults
it's just a variable, there is nothing special about it. We need to merge this with lspconfig
's global config. We can find it in util.default_config
. We will be using vim.tbl_deep_extend
to merge those two variables in a safe way.
local lspconfig = require('lspconfig')
lspconfig.util.default_config = vim.tbl_deep_extend(
'force',
lspconfig.util.default_config,
lsp_defaults
)
Next step is to call the language servers we have installed. They way we do this with lspconfig
is by calling the .setup()
of the language server we want to configure.
How do we know which one we have available? Again, lspconfig's documentation has the answer. You can find the list of valid names using :help lspconfig-server-configurations
.
For the language lua
we can use a server called sumneko_lua
. Install it and then call it in your config like this.
lspconfig.sumneko_lua.setup({})
If you need to customize its behavior you need to add some keys to .setup()
's argument.
lspconfig.sumneko_lua.setup({
single_file_support = true,
on_attach = function(client, bufnr)
print('hello')
lspconfig.util.default_config.on_attach(client, bufnr)
end
})
Notice here that the new on_attach
calls the on_attach
on default_config
. We must do this because the options we set on .setup()
will override the ones on our global configuration. We could also use lsp_defaults.on_attach
if its in the scope of the function.
At this point to take advantage of some "LSP features" we need to create some keybindings. This particular setup I'm showing allows us to do it with an autocommand. We can declare our keybindings anywhere in our config.
vim.api.nvim_create_autocmd('User', {
pattern = 'LspAttached',
desc = 'LSP actions',
callback = function()
local bufmap = function(mode, lhs, rhs)
local opts = {buffer = true}
vim.keymap.set(mode, lhs, rhs, opts)
end
-- Displays hover information about the symbol under the cursor
bufmap('n', 'K', '<cmd>lua vim.lsp.buf.hover()<cr>')
-- Jump to the definition
bufmap('n', 'gd', '<cmd>lua vim.lsp.buf.definition()<cr>')
-- Jump to declaration
bufmap('n', 'gD', '<cmd>lua vim.lsp.buf.declaration()<cr>')
-- Lists all the implementations for the symbol under the cursor
bufmap('n', 'gi', '<cmd>lua vim.lsp.buf.implementation()<cr>')
-- Jumps to the definition of the type symbol
bufmap('n', 'go', '<cmd>lua vim.lsp.buf.type_definition()<cr>')
-- Lists all the references
bufmap('n', 'gr', '<cmd>lua vim.lsp.buf.references()<cr>')
-- Displays a function's signature information
bufmap('n', '<C-k>', '<cmd>lua vim.lsp.buf.signature_help()<cr>')
-- Renames all references to the symbol under the cursor
bufmap('n', '<F2>', '<cmd>lua vim.lsp.buf.rename()<cr>')
-- Selects a code action available at the current cursor position
bufmap('n', '<F4>', '<cmd>lua vim.lsp.buf.code_action()<cr>')
bufmap('x', '<F4>', '<cmd>lua vim.lsp.buf.range_code_action()<cr>')
-- Show diagnostics in a floating window
bufmap('n', 'gl', '<cmd>lua vim.diagnostic.open_float()<cr>')
-- Move to the previous diagnostic
bufmap('n', '[d', '<cmd>lua vim.diagnostic.goto_prev()<cr>')
-- Move to the next diagnostic
bufmap('n', ']d', '<cmd>lua vim.diagnostic.goto_next()<cr>')
end
})
Here is the complete code to configure lspconfig
.
---
-- Global Config
---
local lsp_defaults = {
flags = {
debounce_text_changes = 150,
},
capabilities = require('cmp_nvim_lsp').default_capabilities(),
on_attach = function(client, bufnr)
vim.api.nvim_exec_autocmds('User', {pattern = 'LspAttached'})
end
}
local lspconfig = require('lspconfig')
lspconfig.util.default_config = vim.tbl_deep_extend(
'force',
lspconfig.util.default_config,
lsp_defaults
)
---
-- LSP Servers
---
lspconfig.sumneko_lua.setup({})
Snippets
This is a good time to configure our snippet engine. The next bit is not related to language servers, LSP or whatever. We are just going to load the snippets we have installed.
require('luasnip.loaders.from_vscode').lazy_load()
The .lazy_load()
function will load any snippet we have in our runtimepath
. And by that I mean the ones available in friendly-snippets
.
Autocompletion
Before we start, nvim-cmp
's documentation says we should set completeopt
with the following values:
vim.opt.completeopt = {'menu', 'menuone', 'noselect'}
To configure nvim-cmp we will use two modules cmp
and luasnip
.
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp
is the one we will use to configure nvim-cmp
. And this luansnip
? Well, nvim-cmp doesn't "know" how to expand a snippet, that's why we need it.
We are going to spend some time exploring nvim-cmp's options. I want to explain all the things I do in my personal configuration. For now let's just add the setup function.
local select_opts = {behavior = cmp.SelectBehavior.Select}
cmp.setup({
})
snippet.expand
Callback function, it receives data from a snippet. This is where nvim-cmp
expect us to provide a thing that can expand snippets, and we do that with luasnip.lsp_expand
.
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end
},
sources
Here we can list all the data sources nvim-cmp will use to populate the completion list.
Each "source" is a lua table that must have a name
property. That name
is not the name of the plugin, it's the "id" the plugin used when creating the source. Each source should tell what name they have in their documentation.
Other properties you should be aware of are priority
and keyword_length
. priority
allows nvim-cmp to sort the completion list. If you do not set a priority
then the order of the sources will determine the priority. Now with keyword_length
you can control how many characters are necesary to begin querying the source.
In my personal configuration I have the sources setup this way.
sources = {
{name = 'path'},
{name = 'nvim_lsp', keyword_length = 3},
{name = 'buffer', keyword_length = 3},
{name = 'luasnip', keyword_length = 2},
},
path
: Autocomplete file paths.nvim_lsp
: Shows suggestions based on the response of a language server.buffer
: Suggests words found in the current buffer.luasnip
: Shows available snippets and expands them if they are chosen.
window.documentation
Controls the appearance and settings for the documentation window. To configure this quickly nvim-cmp offers a preset we can use to add some borders.
window = {
documentation = cmp.config.window.bordered()
},
formatting.fields
List of strings that determines the order of the elements in an item.
formatting = {
fields = {'menu', 'abbr', 'kind'}
},
abbr
is the content of the suggestion. kind
is the type of data, this can be text, class, function, etc. Finally, menu
which apparently is empty by default.
formatting.format
Callback function that allows us to customize the appearance of the completion menu. A simple example I can show: assign an icon to a field based on the source name.
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
List of keybindings. For this we need to declare a list of key/value pairs. Where the value is a function of the cmp
module. Like this.
mapping = {
['<CR>'] = cmp.mapping.confirm({select = true}),
}
In this example ['<CR>']
is the key/shortcut we want to bind. The function on the other side of the assignment is the action we want to execute.
Here is a list of common keybindings:
- Move between completion items.
['<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),
- Scroll text in the documentation window.
['<C-u>'] = cmp.mapping.scroll_docs(-4),
['<C-f>'] = cmp.mapping.scroll_docs(4),
- Cancel completion.
['<C-e>'] = cmp.mapping.abort(),
- Confirm selection.
['<CR>'] = cmp.mapping.confirm({select = true}),
- Jump to the next placeholder in the snippet.
['<C-d>'] = cmp.mapping(function(fallback)
if luasnip.jumpable(1) then
luasnip.jump(1)
else
fallback()
end
end, {'i', 's'}),
- Jump to the previous placeholder in the snippet.
['<C-b>'] = cmp.mapping(function(fallback)
if luasnip.jumpable(-1) then
luasnip.jump(-1)
else
fallback()
end
end, {'i', 's'}),
- Autocomplete with tab.
If the completion menu is visible, move to the next item. If the line is "empty", insert a Tab
character. If the cursor is inside a word, trigger the completion menu.
['<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'}),
- If the completion menu is visible, move to the previous item.
['<S-Tab>'] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_prev_item(select_opts)
else
fallback()
end
end, {'i', 's'}),
Complete cmp config
Here is the entire configuration for nvim-cmp
and luansnip
.
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 = 3},
{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-f>'] = cmp.mapping.scroll_docs(4),
['<C-e>'] = cmp.mapping.abort(),
['<CR>'] = cmp.mapping.confirm({select = true}),
['<C-d>'] = 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'}),
},
})
Bonus content
Technically we are ready. All the code shown so far should get us a functional setup. We can now use our language servers and autocompletion. But there are still a couple of customizations we can make.
Change diagnostic icons
We do this using a function called sign_define
. Keep in mind this is not a lua function, it's vimscript. We access it using the prefix vim.fn
. To know the details of this function check the documentation: :help sign_define
.
Anyway, this is the code you'll need to change the sign icons.
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 = ''})
Diagnostics config
This time we must use the vim.diagnostic
module, specifically the .config()
function. It takes a lua table as an argument, and these are the defaults.
{
virtual_text = true,
signs = true,
update_in_insert = false,
underline = true,
severity_sort = false,
float = true,
}
virtual_text
: Show diagnostic message using virtual text.signs
: Show a sign next to the line with a diagnostic.update_in_insert
: Update diagnostics while editing in insert mode.underline
: Use an underline to show a diagnostic location.severity_sort
: Order diagnostics by severity.float
: Show diagnostic messages in floating windows.
Each one of these option can be either a boolean or a lua table. You can find more details about them in the documentation: :help vim.diagnostic.config()
.
I prefer less distracting diagnostics. This is the setup I use.
vim.diagnostic.config({
virtual_text = false,
severity_sort = true,
float = {
border = 'rounded',
source = 'always',
header = '',
prefix = '',
},
})
Help windows with borders
There are two lsp methods that use floating windows: vim.lsp.buf.hover()
and vim.lsp.buf.signature_help()
. By default these windows don't have any style, but we can change that by modifying the associated "handler" of each method. To achieve this we need to use vim.lsp.with()
.
I don't want to bore you with the details so let me just show you the code.
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'}
)
Install language servers "locally"
Sooner or later you are going to find out about this plugin: nvim-lsp-installer. With it you can manage your language servers using neovim. You'll be able to install, update and remove language server.
But the servers you install using this method will not be available globally. They are installed in the "data folder" of neovim. This means you'll have to setup nvim-lsp-installer
before using nvim-lspconfig
. You'll use this function.
require('nvim-lsp-installer').setup({})
After you configure nvim-lsp-installer
you can continue using lspconfig
like always. Pretend like nvim-lsp-installer
doesn't even exists.
Here is an example usage.
require('nvim-lsp-installer').setup({})
local lsp_defaults = {
flags = {
debounce_text_changes = 150,
},
capabilities = require('cmp_nvim_lsp').default_capabilities(),
on_attach = function(client, bufnr)
vim.api.nvim_exec_autocmds('User', {pattern = 'LspAttached'})
end
}
local lspconfig = require('lspconfig')
lspconfig.util.default_config = vim.tbl_deep_extend(
'force',
lspconfig.util.default_config,
lsp_defaults
)
lspconfig.sumneko_lua.setup({})
Right now this is all I have to say about nvim-lsp-installer
. If you want to know more about it you'll have read their documentation.
Conclusion
We've learned the things nvim-lspconfig
can do for us. We figure out how to make a global config for our servers. We setup a handful of lsp functions in our keybindings. Basically, we know everything we need to take advantage of the cool features a language server can provide.
We had the chance to assemble a configuration for nvim-cmp
from scratch. We explored some common options step by step.
Finally, we made some additional customizations to diagnostics. Learned a little something about configuring lsp handlers, specifically the ones using floating windows. And we took a brief look at a method to install language servers locally.
You can find all the configuration code here: nvim-lspconfig + nvim-cmp
Sources
- :help lsp.txt
- :help lspconfig-global-defaults
- :help lspconfig-keybindings
- :help cmp-config
- :help cmp-mapping
- :help vim.diagnostic.config()
Thank you for your time. If you find this article useful and want to support my efforts, consider leaving a tip in buy me a coffee ☕.