Saturday, October 1, 2022 » Neovim

New LSP features in Neovim 0.8

Neovim 0.8 got released the other day, time to write about some of the LSP changes.


To make it easier to use the LSP functionality without relying on nvim-lspconfig, Neovim 0.8 adds a vim.lsp.start function.

What does it do? The simplified answer is that it enables LSP functionality on a buffer. For example, to use the python-language-server pylsp, you could call it like this within each buffer containing a python file:

   name = 'pylsp',
   cmd = {'pylsp'},
   root_dir = vim.fs.dirname(vim.fs.find({'', 'pyproject.toml'}, { upward = true })[1]),

To add some context: So far most users using the LSP functionality of Neovim did so using the nvim-lspconfig project. The project initially had two purposes:

  • Serve as a database of language server configurations.
  • Act as a testing ground for some LSP configuration related functionality.

Some people got confused, thinking that nvim-lspconfig contained all the LSP functionality. It was always possible to use the LSP functionality without nvim-lspconfig. Except that it was a little tedious.

Up until 0.8, Neovim provided two core functions to enable LSP functionality:

  • vim.lsp.start_client
  • vim.lsp.buf_attach_client

vim.lsp.start_client starts a language server process and returns a client_id. This alone doesn’t accomplish anything useful to a user. You also need to attach it to buffer to make use of it. That’s what vim.lsp.buf_attach_client is for.

You don’t want to run start_client every time you open up a buffer, because you’d start a new language server process each time. That would consume lots of memory and CPU time. Instead, you want to re-use a client if appropriate. This is more or less what nvim-lspconfig does for you. If you call lspconfig.<language-server>.setup() it registers a FileType autocommand for the filetypes defined for the server. This autocommand calls vim.lsp.start_client if there isn’t a language server already running for the current project, otherwise it re-uses a client and calls vim.lsp.buf_attach_client.

vim.lsp.start() does the same, minus the FileType part. That’s still up to a user. You can either put the logic into a ftplugin/<language>.lua file, or add it to your init.lua, creating a custom FileType autocommand:

vim.api.nvim_create_autocmd('FileType', {
  pattern = 'python',
  callback = function()
      name = 'pylsp',
      cmd = {'pylsp'},
      root_dir = vim.fs.dirname(vim.fs.find({'', 'pyproject.toml'}, { upward = true })[1]),

One advantage of not doing that implicitly is that users gain more direct control of when to start a language server.

Before I mentioned “[…] language server already running for the current project”. What’s the current project? Neovim doesn’t have the concept of a workspace or project folder. But language servers often operate with the notation of projects. They need a way learn about your project’s dependencies.

The root_dir in the vim.lsp.start options tells the language server about the project root. vim.fs.dirname and vim.fs.find are functions that can be used to find the project root based on some marker files. (They are new in Neovim 0.8 too!). This is another piece of the puzzle that nvim-lspconfig takes care of. Each language server part of nvim-lspconfig has a custom definition for how to find the project root.

If you always spawn one Neovim instance within the root of a project, you could also set the root dir to the current working directory: root_dir = vim.fn.getcwd(). (But be careful with that, you may end up indexing your $HOME)

vim.lsp.start also allows you to control when to re-use a client. The function takes a second opts table as argument which allows you to define a reuse_client function. For example, for Python you may want to re-use an already running client for system libraries located in /usr/lib/python:

local config = {
   name = 'pylsp',
   cmd = {'pylsp'},
   root_dir = vim.fs.dirname(vim.fs.find({'', 'pyproject.toml'}, { upward = true })[1]),
vim.lsp.start(config, {
  reuse_client = function(client, conf)
    return ( ==
      and (
        client.config.root_dir == conf.root_dir
        or (conf.root_dir == nil and vim.startswith(api.nvim_buf_get_name(0), "/usr/lib/python"))

LspAttach and LspDetach

Neovim 0.8 added two new autocommands, LspAttach and LspDetach. LspAttach is triggered if a LSP client attaches to a buffer. This is useful because it allows you to run custom logic de-coupled from a concrete language-server vim.lsp.start or vim.lsp.start_client call.

The most common use-case for this is to setup keymaps. For example, you could define something like this in your init.lua:

vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(args)
    vim.keymap.set({'n', 'v'}, '<a-CR>', vim.lsp.buf.code_action, { buffer = args.buf, silent = true })

tagfunc, formatexpr and omnifunc defaults

Neovim so far doesn’t set any keymaps for LSP by default. Part of the reason is that given how long Vim and with extension Neovim have been around, people had plenty of time to come up with their customized keymaps and plugins define all sorts of additional keymaps too. Finding a set of agreeable keys that users have not already utilized is difficult. But there was a way to partially address the problem by setting formatexpr, tagfunc and omnifunc by default.

What’s a tagfunc?

Vim and Neovim have the ability to use tags for lots of operations. tags are kind of a database of symbols in a project. For example, you can use CTRL-] to jump to the definition of the keyword under the cursor. tags can be generated with a tool like ctags.

tagfunc is a function that allows you to plug-in a different source for tags and Neovim provides one that utilizes LSP: vim.lsp.tagfunc. As soon as a LSP client attaches to a buffer, the tagfunc is set to vim.lsp.tagfunc if the buffer local tagfunc value was empty and if the language server offers the required capabilities. The rationale for that choice is that LSP is likely a better source for tags if you haven’t customized it. If you have customized it, it’s better not to mess with your customization.

Why is this useful? Because it removes the need to define custom keymaps to jump to the definition, and you gain some extras:

  • Use CTRL-w ] to split the current window and jump to the definition of the symbol under the cursor in the upper window.
  • Use CTRL-w } to open a preview window with the definition of the symbol under the cursor.
  • Use :tselect <name> to list all tags matching the name
  • Use :tjump <name>, like :tselect, but jump to it if there is only one match.

And many more.

What’s a formatexpr?

This may come as a surprise for some, but Neovim has the built-in ability to format text. Yes, without any additional external plugin. You can trigger formatting using gq - see :help gq. The problem is, for some languages it doesn’t work all that well. The solution to that problem is formatexpr. It lets users plug-in a function that takes care of the formatting and with Neovim 0.8 this is now set by default to vim.lsp.formatexpr as soon as a LSP client attaches to a buffer, and if the buffer local formatexpr value was empty. One less keymap to setup.

What’s a omnifunc?

Vim and Neovim have various built-in ways to complete text. For example you can use keyword completion in insert mode using CTRL-X CTRL-N. Another option for completion is the omnifunc, triggered with CTRL-X CTRL-O. omnifunc is kind of the Vim/Neovim version for “intelligent code completion”. For it to work you need a omnifunc function definition, and there is one for LSP too: vim.lsp.omnifunc. Similar to formatexpr and tagfunc this is now set by default as soon as a LSP client attaches to a buffer.

See also: Neovim completion plugin building blocks

TCP support

So far LSP support in Neovim was limited to language servers which can be spawn as executable and communicate via stdio. With Neovim 0.8 there is now built-in support for TCP as well. One example for a language server that uses TCP is Godot. If you start Godot it launches a language server and you can connect to it from within Neovim using:

  name = 'godot',
  cmd = vim.lsp.rpc.connect('', 6008), -- Port must match your Language server settings within Godot
  root_dir = vim.fs.dirname(vim.fs.find({'project.godot'}, { upward = true })[1]),

And more

These were only some of the highlights. There have been many more changes. If you’re curious you could take a look at the git log:

git log --grep "feat(lsp)" v0.7.2..v0.8.0