DEV Community

Heiker
Heiker

Posted on • Updated on

Make lsp-zero.nvim coexists with other plugins instead of controlling them

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({})
Enter fullscreen mode Exit fullscreen mode

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'})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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({})
Enter fullscreen mode Exit fullscreen mode

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({})
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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({})
Enter fullscreen mode Exit fullscreen mode

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({})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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(),
  }),
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode
  • 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,
})
Enter fullscreen mode Exit fullscreen mode
  • 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,
})
Enter fullscreen mode Exit fullscreen mode

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'},
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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 ☕.

buy me a coffee

Latest comments (0)