I created lsp-zero to help people reduce the boilerplate code needed to use nvim-lspconfig and nvim-cmp. At first it was a pre-made config wrapped in a plugin, but now is more like a collection of functions. In its current state lsp-zero can help you enhance your config instead of controlling it. Let's see what we can do with it.
Configuring LSP servers
lsp-zero will integrate nvim-cmp and nvim-lspconfig when you require the module for the first time. This means the call to require("lsp-zero")
will take care of a few things for you. And after that you are free to use lspconfig
to setup each language server. This a valid usage.
require('lsp-zero')
-- Call the language servers you have installed
require('lspconfig').tsserver.setup({})
require('lspconfig').eslint.setup({})
require('lspconfig').rust_analyzer.setup({})
In this particular example we don't customize any of these servers, we are just using the defaults lspconfig provides. And if this is what you actually want, you can use the function .setup_servers() with a list of language servers.
require('lsp-zero').setup_servers({'tsserver', 'eslint', 'rust_analyzer'})
Disable automatic integration
If you want to prevent lsp-zero from doing things automatically, you can set the following global variables before using lsp-zero:
vim.g.lsp_zero_extend_cmp = 0
vim.g.lsp_zero_extend_lspconfig = 0
Alternatively, you can require the module lsp-zero.api
. This will return all the currently supported functions and avoid the side effects.
And then you can control when the integration happens using the functions .extend_lspconfig() and .extend_cmp().
Adding keybindings
At this point you would want to create the keybindings that use the wonderful features the language servers provide. In nvim-lspconfig's documentation recommend you use the LspAttach
autocommand, so lsp-zero has a convenience method to help you do that.
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
local opts = {buffer = bufnr}
vim.keymap.set('n', 'K', '<cmd>lua vim.lsp.buf.hover()<cr>', opts)
vim.keymap.set('n', 'gd', '<cmd>lua vim.lsp.buf.definition()<cr>', opts)
vim.keymap.set('n', 'gD', '<cmd>lua vim.lsp.buf.declaration()<cr>', opts)
vim.keymap.set('n', 'gi', '<cmd>lua vim.lsp.buf.implementation()<cr>', opts)
vim.keymap.set('n', 'go', '<cmd>lua vim.lsp.buf.type_definition()<cr>', opts)
vim.keymap.set('n', 'gr', '<cmd>lua vim.lsp.buf.references()<cr>', opts)
vim.keymap.set('n', 'gs', '<cmd>lua vim.lsp.buf.signature_help()<cr>', opts)
vim.keymap.set('n', '<F2>', '<cmd>lua vim.lsp.buf.rename()<cr>', opts)
vim.keymap.set({'n', 'x'}, '<F3>', '<cmd>lua vim.lsp.buf.format({async = true})<cr>', opts)
vim.keymap.set('n', '<F4>', '<cmd>lua vim.lsp.buf.code_action()<cr>', opts)
vim.keymap.set('n', 'gl', '<cmd>lua vim.diagnostic.open_float()<cr>', opts)
vim.keymap.set('n', '[d', '<cmd>lua vim.diagnostic.goto_prev()<cr>', opts)
vim.keymap.set('n', ']d', '<cmd>lua vim.diagnostic.goto_next()<cr>', opts)
end)
require('lspconfig').tsserver.setup({})
require('lspconfig').eslint.setup({})
require('lspconfig').rust_analyzer.setup({})
But what if I told you all those keybindings are already in lsp-zero? You can just create them using the function .default_keymaps().
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
lsp_zero.default_keymaps({buffer = bufnr})
local opts = {buffer = bufnr}
vim.keymap.set('n', 'gd', '<cmd>Telescope lsp_definitions<cr>', opts)
vim.keymap.set('n', 'gi', '<cmd>Telescope lsp_implementations<cr>', opts)
vim.keymap.set('n', 'gr', '<cmd>Telescope lsp_references<cr>', opts)
end)
require('lspconfig').tsserver.setup({})
require('lspconfig').eslint.setup({})
require('lspconfig').rust_analyzer.setup({})
Not only I can use the keybindings in lsp-zero, I can override them if I want. In this example I replaced a few Neovim functions with Telescope commands that do something similar.
About on_attach
The on_attach
function acts like a "global hook". It'll run every time a language server gets attached to a buffer, even if you add a custom on_attach
function to a particular server.
You can try it.
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
lsp_zero.default_keymaps({buffer = bufnr})
print(client.name .. ': Hello there.')
end)
require('lspconfig').tsserver.setup({
on_attach = function(client, bufnr)
print('tsserver: How are you today?')
end
})
When tsserver gets attached to a buffer Neovim will print both messages. First the one on .on_attach()
then the one on tsserver.
Creating your own default config
Sometimes you need to add arguments to each language server. Maybe you want to enable a feature, or add arguments required by a plugin. I know that process is a little annoying so lsp-zero provides a function called .set_server_config(), with it you can add a configuration to each server you configure with lspconfig.
Here is an example that adds the options nvim-ufo needs to pass to the each language server. It will also disable semantic highlights. And finally disable single file support.
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
lsp_zero.default_keymaps({buffer = bufnr})
end)
lsp_zero.set_server_config({
single_file_support = false,
on_init = function(client)
client.server_capabilities.semanticTokensProvider = nil
end,
capabilities = {
textDocument = {
foldingRange = {
dynamicRegistration = false,
lineFoldingOnly = true
}
}
}
})
require('lspconfig').tsserver.setup({})
require('lspconfig').eslint.setup({})
require('lspconfig').rust_analyzer.setup({})
Diagnostic icons
You can change the default text Neovim shows in the sign column. The default behaviour will show letters like E
or W
, but we can change it to whatever we want using .set_sign_icons().
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
lsp_zero.default_keymaps({buffer = bufnr})
end)
lsp_zero.set_sign_icons({
error = '✘',
warn = '▲',
hint = '⚑',
info = '»',
})
require('lspconfig').tsserver.setup({})
Enable format on save
We can do this in multiple ways but let me just show the one that looks easier.
We can call the function .async_autoformat() inside the on_attach
function and lsp-zero will setup format on save in every file the language server is active.
local lsp_zero = require('lsp-zero')
require('lspconfig').tsserver.setup({
on_attach = function(client, bufnr)
lsp_zero.async_autoformat(client, bufnr)
end,
})
Note: if you want synchronous autoformatting you can use .buffer_autoformat().
Now you might be thinking "oh, great I can use it in the global on_attach and enable it for every language server." That would work fine... until you have multiple language servers attached to a single file. Then you would get into the game "which language server is doing this to my code?" and you don't want that.
Autocompletion
When you call require('lsp-zero')
then lsp-zero will try to add the "essential" options to have a working autocompletion menu. You will be able to use Neovim's default keybindings to control the completion menu. And you'll also get suggestions from your active language servers.
What if you wanted to customize nvim-cmp? Easy, just call the cmp
module.
local cmp = require('cmp')
local cmp_action = require('lsp-zero').cmp_action()
cmp.setup({
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
mapping = cmp.mapping.preset.insert({
-- `Enter` key to confirm completion
['<CR>'] = cmp.mapping.confirm({select = false}),
-- Ctrl+Space to trigger completion menu
['<C-Space>'] = cmp.mapping.complete(),
-- Scroll up and down the documentation window
['<C-u>'] = cmp.mapping.scroll_docs(-4),
['<C-d>'] = cmp.mapping.scroll_docs(4),
-- Navigate between snippet placeholders
['<C-f>'] = cmp_action.luasnip_jump_forward(),
['<C-b>'] = cmp_action.luasnip_jump_backward(),
}),
})
In lsp-zero's documentation you can find a whole section about how to customize nvim-cmp: Autocomplete.md.
Lesser known features
Setup a server without lspconfig
If for some reason you don't have access to lspconfig
or just don't want to install it, you can setup a language server manually using the function .new_client().
To setup a language server you will need to know the command that start the server, the filetypes where it should be active, and a function that detects the "root directory" of your project.
Here is an example using the lua language server.
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
lsp_zero.default_keymaps({buffer = bufnr})
end)
lsp_zero.new_client({
name = 'lua_ls',
cmd = {'lua-language-server'},
filetypes = {'lua'},
root_dir = function()
return lsp_zero.dir.find_first({'.luarc.json'})
end,
})
The function in root_dir
will be executed everytime Neovim opens a lua file. If it finds a file called .luarc.json
in the current folder or any parent folder then it will attach the language server to that buffer.
Do note that .new_client()
is a thin wrapper over a built-in Neovim function called vim.lsp.start(), so it takes the same arguments (plus filetypes
and root_dir
, I added those).
Get some basic completions
If for some reason you don't have access to nvim-cmp or don't want to install it, you can use the function .omnifunc.setup() to configure Neovim's built-in completion mechanism.
So omnifunc
is a vim term, is a function that you can create and then put into a variable. This is how vim lets users create their own custom completions. Neovim has an implementation that lets you get completions from the active language servers. The default keybinding for this is ctrl-x + ctrl-o
.
Since Neovim's default are not intuitive for a lot of people lsp-zero has a way to modify it. Here are some examples.
- Setup tab completion
You can use the Tab
key to trigger the completions when the cursor is in the middle of a word. And when the completion menu is visible, you can navigate between the items using Tab
and Shift + tab
.
local lsp_zero = require('lsp-zero')
lsp_zero.omnifunc.setup({
tabcomplete = true,
use_fallback = true,
update_on_delete = true,
})
- Toggle completion with a custom key
You can specify a keybinding to toggle the completion menu.
local lsp_zero = require('lsp-zero')
lsp_zero.omnifunc.setup({
trigger = '<C-Space>',
use_fallback = true,
update_on_delete = true,
})
- Autocomplete
There is an option to get autocomplete but I must warn you the implementation is very naive. Is basically like pressing ctrl-x + ctrl-o
after every single letter in a word.
local lsp_zero = require('lsp-zero')
lsp_zero.omnifunc.setup({
autocomplete = true,
use_fallback = true,
update_on_delete = true,
})
All of this means that you can have a basic setup just using lsp-zero. No extra plugins are needed.
An example without dependencies
local lsp_zero = require('lsp-zero')
lsp_zero.on_attach(function(client, bufnr)
lsp_zero.default_keymaps({buffer = bufnr})
end)
lsp_zero.omnifunc.setup({
tabcomplete = true,
use_fallback = true,
update_on_delete = true,
})
lsp_zero.new_client({
name = 'lua_ls',
cmd = {'lua-language-server'},
filetypes = {'lua'},
root_dir = function()
return lsp.dir.find_first({'.luarc.json'})
end,
settings = {
Lua = {
-- Neovim's omnifunc doesn't support snippets
completion = {keywordSnippet = 'Disable'},
}
}
})
Conclusion
Hopefully I've demonstrated lsp-zero can coexists peacefully with other plugins. It doesn't have to be this "super plugin" that needs 11 dependencies to work. You can use what you need and just ignore the rest.
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 ☕.
Oldest comments (0)