Neovim journey: using LSP without any plugins!

While reading about setting up Neovim, I kept coming across guidance to install the nvim-lspconfig plugin. The gist of the advice was that installing this plugin was essential to using LSP in Neovim. It made it “easier” somehow.

As I wanted to avoid plugins where I could, and I only needed to configure three or four language servers (out of the dozens nvim-lspconfig supports), I wondered what the plugin actually did and whether it was worth pulling into my configuration.

In this post, we’ll look at getting a language server working with an open file without using any Neovim plugins at all. We’ll get to the point where we can open .go files in a Go module, and automatically be able to use LSP functionality like go-to-definition, auto-complete and rename.

Then we’ll look at nvim-lspconfig with fresh eyes, understanding what it’s doing and better able to judge whether it’s worthwhile for my, and your, configuration. Regardless of the conclusion we come to, for me it’s always super-valuable to understand what’s going on under the hood of my tools, and we’ll certainly come out of this article knowing a lot more about Neovim’s LSP API and capabilities than I did before I started writing it 🌟

Before starting

For the article, I have used Go’s language server, gopls. I’ve installed and set it up manually with other editors, so I was confident I’d just need to learn the “Neovim bits” rather than futzing with the language server itself. This seemed wise as an approach, given I knew little about Neovim’s LSP Lua API before starting.

If you want to follow along, let’s get gopls installed, and learn how to start Neovim with a clean configuration so we really can do this from first principles.

  1. Start Neovim with a temporary, clean configuration. You can do this for a given instance of Neovim using:

    nvim --clean -u <path to fresh init.lua>
    

    I chose to use nvim --clean -u ~/.config/nvim.go/init.lua.

    If you miss out --clean, the configuration specified with -u will be appended to your default configuration; not what we want at all.

  2. Ensure gopls is installed.

    On macOS, homebrew can be used, or go install per the gopls docs. If you don’t have gopls installed, Neovim will give you an error when you run the vim.lsp.start() commands later:

    Spawning language server with cmd: `gopls` failed. The language 
    server is either not installed, missing from PATH, or not executable.
    

    Once in Neovim, check that the Neovim PATH contains gopls by using which:

    :!which gopls
    

    That is, use : to enter command mode, then type ! to execute a command in the shell, and then which gopls, and finally <enter> to execute that command. If all is well, a path to gopls should be printed.

  3. Check out an example project. We can use one of mine, go-clis-with-flag:

    cd /Users/mike/code/gh/mikerhodes
    git clone https://github.com/mikerhodes/go-clis-with-flag.git
    cd go-clis-with-flag
    

    This is a super-simple demo Go project. Again, I wanted to focus on issues within Neovim rather than issues with gopls or my project, so keeping the project simple made sense.

Now we are ready to connect to the language server from Neovim, and start using smart functionality. All without using any plugins!

LSP in Neovim

Having spent a few weeks configuring and working in Neovim, I was confident enough to head straight to the Neovim LSP documentation. What we find there is that Neovim’s support for LSP is reasonably extensive. Without any additional plugins, it can:

  • Start a language server process and create a client for that language server.
  • “Attach” the client to a buffer. Once this has been done, many features are available:
    • Display method documentation in a floating window.
    • Show errors from the language server in line with your code, or in a separate window.
    • Jump to definitions, show references.
    • Show LSP-driven smart autocomplete using Neovim’s omni-completion.
    • Format the buffer using the language server.
    • Show available “code actions” — generally refactorings.

This told me that Neovim can do a lot on its own. It also told me that it would be easy to tell whether I’d got things working — I should be able to carry out these functions on my own Go code.

After spending ten productive — and fifteen head-scratching — minutes with the Neovim documentation, I found out that to start using a language server’s functionality within Neovim, we need to do two things:

  1. Create a client for a language server using vim.lsp.start(). If needed, when creating a client Neovim will start the language server process (in our case, gopls) that the client will connect to. This client needs to be appropriately set up for the file in question, so it knows where to find files like go.mod.
  2. Once we have a client, we need to “attach” the client to each buffer (loaded file) where we wish to use the language server’s features. In our case, that’ll be .go files.

Starting a language server client

The Quick Start section of the LSP docs gives a basic example of calling vim.lsp.start():

vim.lsp.start({
  name = 'my-server-name',
  cmd = {'name-of-language-server-executable'},
  root_dir = vim.fs.dirname(
    vim.fs.find({'setup.py', 'pyproject.toml'}, { upward = true })[1]
  ),
})

Looking further into the documentation for start(), we can see what name, cmd and root_dir are:

  • name arbitrary name for the LSP client. Should be unique per language server.
  • cmd command (in list form) used to start the language server. Must be absolute, or found on $PATH. Shell constructs like ~ are not expanded.
  • root_dir path to the project root. By default this is used to decide if an existing client should be re-used. The example above uses vim.fs.find() and vim.fs.dirname() to detect the root by traversing the file system upwards starting from the current directory until either a pyproject.toml or setup.py file is found.

While there are a boatload of other options available, I figured that the example above must show all the required values. Let’s alter it to use gopls. To start with, let’s also drop the fancy vim.fs.find() and hard-code our folder:

vim.lsp.start({
  name = 'gopls',   -- No idea whether this should be "more unique" 🤷
  cmd = {'gopls'},
  root_dir = '/Users/mike/code/gh/mikerhodes/go-clis-with-flag/'
})

This all needs to be entered as a single Neovim command, on one line. Use the :lua Vim command, followed by the Lua code to execute. Prefix the Lua code with = to get Neovim to print the Lua code’s return value:

:lua =vim.lsp.start({name='gopls',cmd={'gopls'},root_dir='YOUR_ROOT_DIR'})

In my case, this looks like this, all written out:

start() returns the newly created client’s ID. Using the = form of the command, shown in the screenshot, shows that return value in the command area after pressing enter. In my case this was 1. We’ll need this ID to attach the client to a buffer.

As noted earlier, the main error that might appear in response to this command is:

Spawning language server with cmd: `gopls` failed. The language
server is either not installed, missing from PATH, or not executable.

If you see this, check that :!which gopls shows your installed gopls. If it doesn’t, for some reason the path Neovim is starting with can’t see the language server binary. I’m not sure how to solve this, presuming the binary is installed. Sorry to have to leave you on your own if you hit this.

Attaching the client to the buffer

Assuming we’ve been successful getting a gopls server started and a client created for it, we’re ready to attach the client to a buffer.

To attach a client to a buffer, we use vim.lsp.buf_attach_client(). The method takes the ID of a buffer and the ID of a language server client. We’ll use the language server client we just created, which on my machine got id 1. Conveniently, Neovim’s Lua buffer methods tend to allow passing 0 as the buffer ID as a shorthand for “the current buffer”. So we can attach the client to our current buffer by typing:

:lua =vim.lsp.buf_attach_client(0, 1)

And hitting enter. As we used =, Neovim will print the result to the command area. It should be true for a successful attachment.

Warning! When you are attaching the LSP client to a buffer using Lua like this, you are responsible for making sure that the client is appropriate for the file open in the buffer. For example, that it’s the right language and that the root_dir is appropriate for the file. Without this, the language server will likely respond with errors or garbage.

We can confirm the client was attached using buf_is_attached:

:lua =vim.lsp.buf_is_attached(0, 1)

Again, this should return true.

At this point the buffer will start to receive information from the LSP. For example, it will show errors:

When a client is attached to a buffer, it updates Neovim’s omni-completion to use completions from the LSP server. Call up omni-completion using Ctrl-X Ctrl-O.

Pardon the weird colours, this is just how Neovim looked without a theme set in my terminal 🤷.

We can also start to call LSP functions. As Neovim doesn’t map any of this functionality to hot keys by default, we have to call the LSP functions via :lua commands. After putting the cursor on a method, we can use :lua vim.lsp.buf.hover() to show the documentation:

Again, colours improve once you set a theme in Neovim itself 🧑‍🎨.

Other examples:

  • :lua vim.lsp.buf.definition() will jump to the definition of the symbol under the cursor.
  • :lua vim.lsp.buf.format() will format the document.
  • :lua vim.lsp.buf.rename('NEWNAME') will rename a symbol, if the LSP server supports the refactoring.

The Neovim LSP buffer functions documentation has several others that can be called.

Automating creating and attaching clients

It’s actually not that hard to take what we’ve learned, and automate getting an LSP client attached to a buffer correctly. We do this by writing an autocmd that runs when Go files are opened. It creates an LSP client and attaches it to the new buffer. There are some helper functions and smart behaviour built into Neovim that we can take advantage of:

  1. First, we go back to that first example from the LSP documentation for vim.lsp.start(). It contained code that will traverse up the file tree until it finds a language-appropriate file indicating we’ve hit what looks like a “project” the language server will understand. In Go’s case, that’s go.mod or go.work files. We fallback to .git.
  2. We can repeatedly call vim.lsp.start() using the same name. If we pass the same root_dir, Neovim will return us an existing client for that “project”. Otherwise it will start a new one. Because this is smart, we never have to worry about checking for existing clients ourselves (I confirmed this by checking that new LSP clients were not spawned as I opened more Go files in the example project).
  3. We then just attach the client to the new buffer.

The code looks like this:

local autocmd = vim.api.nvim_create_autocmd
autocmd("FileType", {
    pattern = "go",
    callback = function()
        local root_dir = vim.fs.dirname(
            vim.fs.find({ 'go.mod', 'go.work', '.git' }, { upward = true })[1]
        )
        local client = vim.lsp.start({
            name = 'gopls',
            cmd = { 'gopls' },
            root_dir = root_dir,
        })
        vim.lsp.buf_attach_client(0, client)
    end
})

With just this code in your init.lua, Go files will have all the LSP functionality automatically made available. Presuming you have gopls available to Neovim 😉

Getting the state of the language server clients

It’s sometimes useful to see what language server clients are started, to either debug them or to grab things like their IDs. This can be done using vim.lsp.get_active_clients().

For example, to return a count of active clients, we can combine the = operator with Lua’s # operator:

:lua =#(vim.lsp.get_active_clients())

To get the ID of the first client (eg, to use in attaching it to a buffer):

:lua =vim.lsp.get_active_clients()[1].id

We can dump the full state of all clients to a temporary buffer using:

:lua =vim.lsp.get_active_clients()

It prints a lot! Here we can see the state for the client we created earlier, as we can see from the cmd and root_dir:

nvim-lspconfig

Now we know how to use an LSP server with an open file “by hand”, we can better understand what nvim-lspconfig does and how it makes things easier. While we saw that it was actually really easy to get the LSP client attached to all your Go buffers using a single autocmd, my feeling is that nvim-lspconfig gives enough convenience to make it worth using:

  • nvim-lspconfig contains best-practice configuration for lots of language servers. This is really helpful for languages where one is less familiar with the ecosystem than I am with Go’s. For example, it made getting the Lua language server running for use when writing my Neovim configuration easy.
    • Now we’ve learned how to set up the gopls server “by hand”, it’s easier to see how the nvim-lspconfig gopls configuration maps to the Neovim Lua code. This makes it easier to customise, if we need to.
  • It handles creating the autocmds for creating LSP clients and doing the buffer attachments. It’s likely more complete than I’d work out, and probably more robust than my autocmd above.
  • The :LspInfo command provides the key state information required to check on the LSP without digging through the output of vim.lsp.get_active_clients().
  • There are other :Lsp* commands that automate operations like restarting language servers to pick up configuration changes and so on.

Overall, my feeling is that :LspInfo is almost worth the price of entry on its own. There’s no contest between its concise display and the epic splurge of Lua tables we saw earlier:

Verdict

I’m glad I probed under the hood of Neovim’s LSP support and learned how to make it work by calling Neovim’s Lua APIs. I’ll be able to pick up and use new LSP features without waiting for them to appear in plugins. Even saying this, however, working through Neovim’s LSP functionality from first-principles has allowed me to see better the value that nvim-lspconfig provides. I’ll definitely still keep using nvim-lspconfig. But now with the benefit that I can understand the code better, and likely be much better placed to debug any issues I come across in future.

Putting nvim-lspconfig together with mason gives us a great LSP experience. mason complements nvim-lspconfig by automatically downloading LSP server binaries into a path private to Neovim. I’d like to understand mason better too. Therefore I’m hoping to write a little more about that plugin soon as well, to dig into what it’s doing and where it puts things.

As noted at the start of the article, it’s always worth knowing what tools are doing on your behalf. It’s been quite a long journey getting down into these particular weeds and nettles, but I’m glad I did. I hope you enjoyed the read, and managed to get everything working if you followed along.

Addendum

After writing this, I found New LSP features in Neovim 0.8. It turns out that vim.lsp.start() was new in Neovim 0.8 and was built around making using LSP servers in Neovim without plugins easier. I only started using Neovim after 0.8 was released, so I’d never seen a Neovim without vim.lsp.start().

Previously one needed to use vim.lsp.start_client() which didn’t have any of the smarts around reusing existing LSP clients. That was one of the major selling points of nvim-lspconfig, that it handled that client reuse for you. So you wouldn’t end up starting a language server process every time you opened a file, if you naively used and autocmd and start_client() together.

I admit that I was confused about why nvim-lspconfig had code dedicated to client reuse when Neovim seemed to do that for you — clearly, before 0.8, this would’ve been a problem. So perhaps my autocmd isn’t as awful as I thought: start() allows you to be a lot more slapdash in your own code.

I still think the :LspInfo command is rather better than the mass of Lua code that vim.lsp.get_active_clients() spits out.

← Older
Scheduling future publication with Hugo, GitHub Actions and Pages
→ Newer
Cut flowers