DEV Community

Heiker
Heiker

Posted on • Edited on • Originally published at vonheikemen.github.io

A guide on Neovim's LSP client

Pueden leer la versión en español aquí.

Maybe I should have called this "How to enable IDE-like features without third party plugins." Sounds interesting, right? That's basically what I want to show you here.

We don't need any third party plugins, but we do need this:

  1. Neovim v0.8 or greater.
  2. A language server.
  3. Patience/Energy to write some lua code for each language server.

If you want to implement any of this stuff in your own configuration, consider dedicating a little bit of time to learn the basics of lua. Here are a couple of links to help you with that:

Let's start with the language server

A language server is an external program that follows the Language Server Protocol. The LSP specification defines what type of messages a language server can receive, and also how it should respond. The idea here is that any tool that follows the LSP specification can communicate with a language server.

And so the language server is the thing that analyzes our source code and it can tell the editor what to do.

Where can we find these language servers?

The website for the LSP specification has a list.

In this particular case...

I'm going to use intelephense to show the minimal configuration needed to setup a language server in Neovim.

If you want to test intelephense you need to install NodeJS. And then you can install the server running this command in the terminal.

npm install -g intelephense
Enter fullscreen mode Exit fullscreen mode

Once you have a language server installed it's a good idea to check if Neovim "knows" where it is. You can execute this command inside Neovim.

:echo exepath('intelephense')
Enter fullscreen mode Exit fullscreen mode

This should show you the path to the language server executable. If it doesn't, it means something went wrong during the installation.

In case you didn't click on the link to intelephense, you should know that is a language server for php. If you just want to test the code I show here, you don't need the php interpreter installed, just the source code of a php project. You can use this repository: minicli, is a decent size codebase and doesn't depend on any other php libraries.

Basic Usage

Before we write any code we should learn how to use the language server. The first piece of information we need is the command that starts the server. This should be in the official documentation of said server.

If we can't find the basic usage in the documentation we can go to nvim-lspconfig's github repository. In there we look for a folder called configs, this contains configuration files for a bunch of language servers.

Right now we are interested in intelephense, so we should inspect the contents of intelephense.lua. The thing we are looking for is in a property called default_config. This piece of code right here:

default_config = {
  cmd = { 'intelephense', '--stdio' },
  filetypes = { 'php' },
  root_dir = function(pattern)
    local cwd = vim.uv.cwd()
    local root = util.root_pattern('composer.json', '.git')(pattern)

    -- prefer cwd if root is a descendant
    return util.path.is_descendant(cwd, root) and cwd or root
  end,
},
Enter fullscreen mode Exit fullscreen mode

The cmd property has the command we need to start the language server. filetypes is the list of languages the server can handle. And I'm going to talk about the root_dir in a little while.

Execute on filetype

Since we only need intelephense in php files we can use something called a filetype plugin. That's a script that gets executed after Neovim assigns a filetype to a buffer.

We create a filetype plugin in our Neovim configuration simply by adding a script in the folder ftplugin. Note that the name of the script needs to be the same as a valid filetype.

We can navigate to Neovim's configuration folder, open Neovim and then create the ftplugin folder.

:call mkdir('./ftplugin', 'p')
Enter fullscreen mode Exit fullscreen mode

We want a filetype plugin for php, so we create a new file called php.lua.

:edit ftplugin/php.lua | write
Enter fullscreen mode Exit fullscreen mode

Inside this new file we are going to execute the function that enables intelephense.

Root directory

The last piece of information we need is the root directory. We just have to tell the language server where is our project folder.

In our filetype plugin we are going to use a function called vim.fs.find(). We will give it a list of files and it will return the path of the first match it finds.

What do we look for? We search for common configuration files that projects have in their root folder. So, in php is very common to have a composer.json file. Javascript projects usually have a package.json. Rust projects have a cargo.toml. We feed this information to vim.fs.find() and it should give us a path we can use.

We can make a test already by adding this piece of code in the newly created php.lua.

-- ftplugin/php.lua

local root_files = {'composer.json'}
local paths = vim.fs.find(root_files, {stop = vim.env.HOME})

print(vim.fs.dirname(paths[1]))
Enter fullscreen mode Exit fullscreen mode

By default vim.fs.find() will look in the current folder and then the parent folders. The stop argument tells the function it should stop the search if it hits the home folder (you don't want your language server to analyze your home folder by accident).

Since vim.fs.find() returns a list we just pick the first item. And to make sure we get the path to a folder we use vim.fs.dirname().

We can navigate to a php project with a composer.json file and check that the project path is detected correctly.

Start the client

We are ready to enable the language server. Now we call the function vim.lsp.start(). The first time this is executed it will launch the language server as an external process. When called again with the same root directory it will only send information to the existing process.

Our php filetype plugin should look like this.

-- ftplugin/php.lua

local root_files = {'composer.json'}
local paths = vim.fs.find(root_files, {stop = vim.env.HOME})
local root_dir = vim.fs.dirname(paths[1])

if root_dir then
  vim.lsp.start({
    cmd = {'intelephense', '--stdio'},
    root_dir = root_dir,
  })
end
Enter fullscreen mode Exit fullscreen mode

With this setup we should get "diagnostics" out the box. If there is an error in a php file Neovim will show what line has the error, and also the message. Something like this.

Example code from slimphp framework showing an error

The E in line 8 is a diagnostic sign, it indicates there is an error. And the thing after the symbol is "virtual text" showing the error message.

Server settings

Now, some server specific configuration should be placed in a property called settings in the vim.lsp.start() function. But here's the thing, you may find the documentation of some language servers shows them in this format:

intelephense.files.maxSize: 1000000
Enter fullscreen mode Exit fullscreen mode

We would need to adapt this so it works with Neovim's LSP client. Let me show you how it should be.

vim.lsp.start({
  cmd = {'intelephense', '--stdio'},
  root_dir = root_dir,
  settings = {
    intelephense = {
      files = {
        maxSize = 1000000,
      },
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Basically, each dot is a nested "lua table" we need to add.

If there was another setting with the same namespace intelephense.files, we just add it to the existing table.

vim.lsp.start({
  cmd = {'intelephense', '--stdio'},
  root_dir = root_dir,
  settings = {
    intelephense = {
      files = {
        maxSize = 1000000,
        anotherOptionExample = false,
      },
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

Where to look if something goes wrong?

If Neovim wasn't able to start the language server, you can take a look at the log file, execute this command inside Neovim:

:lua vim.cmd.edit(vim.lsp.get_log_path())
Enter fullscreen mode Exit fullscreen mode

Look for the lines that start with [ERROR]. Maybe there is an error message with some useful information.

If you want the logs to have more details, increase the log level. Add this in your init.lua file.

vim.lsp.set_log_level('debug')
Enter fullscreen mode Exit fullscreen mode

About the diagnostics

So the diagnostics signs, the thing Neovim uses to tell us there is an error in our source code... by default the space needed to render that sign is hidden and when there is a sign the whole screen shifts to the right. That behavior can be configured in the init.lua file.

If you set the option signcolumn to the string yes, Neovim will reserve the space for the sign. You will have a whitespace reserved for any type of signs in the gutter.

vim.opt.signcolumn = 'yes'
Enter fullscreen mode Exit fullscreen mode

If you set signcolumn to the string no, Neovim will hide the column altogether. Don't do that unless you are fully aware of the consequences. There is a better way to hide the diagnostic signs.

vim.diagnostic

There is a lua module dedicated specifically to diagnostics: vim.diagnostic. This has a .config() function we can use to configure the interface of the diagnostics.

We can add the following to our init.lua.

  • Hide diagnostic signs

This is the "safe" way to disable the diagnostics sign.

vim.diagnostic.config({
  signs = false,
})
Enter fullscreen mode Exit fullscreen mode
  • Disable virtual text

Yes, there's also an option to hide the virtual text that contains the error message.

vim.diagnostic.config({
  virtual_text = false,
})
Enter fullscreen mode Exit fullscreen mode

If you have Neovim v0.10 or greater you can read the diagnostic message under the cursor with the keybinding <C-w>d (control + w then d). This will trigger the function vim.diagnostic.open_float().

When using Neovim v0.9.5 or lower you'll have to create that keybinding yourself.

vim.keymap.set('n', '<C-w>d', '<cmd>lua vim.diagnostic.open_float()<cr>')
Enter fullscreen mode Exit fullscreen mode

Now, I would love to explain all the options vim.diagnostic.config() supports but we don't have time for that. If you want to know more you can read the documentation.

What else do we get for free?

These are things Neovim does when a language server active in the buffer.

Since Neovim v0.8

  • There is an LSP powered tagfunc. That means we can jump to the definition of a function or class using the keybinding <C-]> (control + ]). And can jump back to where we were using <C-t>.

  • The formatexpr option is set to a function that uses the language server. This means the gq operator can format a piece of code. We can enter visual mode, select a piece of text, press gq and Neovim will request the language server to format the code.

  • The omnifunc option is also set. This one enables smart code completions. In insert mode the keybinding <C-x><C-o> (control + x then control + o) will trigger the completion menu with suggestions that the language server think are relevant.

Since Neovim v0.9

  • Semantic highlight is supported. Some language servers can provide information about the tokens in the source code, this allows for a more accurate syntax highlight.

Since Neovim v0.10

  • In normal mode, if we don't have a custom keybinding for K then it will display the available documentation for the symbol under the cursor.

  • In normal mode, <C-w>d opens a floating window showing the diagnostics in the line under the cursor.

  • In normal mode, [d and ]d can be used to move the cursor to the previous and next diagnostic of the current file.

Since Neovim v0.11

v0.11 is still under development. Everything in this section is subject to change.

  • In normal mode, grn renames all references of the symbol under the cursor.

  • In normal mode, gra shows a list of code actions available in the line under the cursor.

  • In normal mode, grr lists all the references of the symbol under the cursor.

  • In insert mode, <Ctrl-s> displays the function signature of the symbol under the cursor.

Let's get some easy wins

Here's a list of features we can use without too much effort, pretty much the only thing we have to do is call a lua function.

  • Jump to declaration
  • Lists implementations
  • Jump to type definition
  • List all references
  • Display a function's signature information
  • Rename all references to the symbol under the cursor
  • Format current file (or selected range)
  • List and execute a code action

All of these can be used in custom keybindings.

Custom keybindings

Is very common for people to follow the same patterns Neovim's defaults have, that is only enable features when the language server is active. So let me introduce you to the LspAttach event, this is triggered everytime Neovim enables a language server in a buffer. And with the function nvim_create_autocmd we can tell Neovim we want to execute a callback function everytime this event happens.

You can find more details about autocommands here: lua-guide-autocommands.

So this is one way to setup custom keybindings.

-- you can add this in your init.lua

vim.api.nvim_create_autocmd('LspAttach', {
  desc = 'LSP actions',
  callback = function(event)
    local bufmap = function(mode, lhs, rhs)
      local opts = {buffer = event.buf}
      vim.keymap.set(mode, lhs, rhs, opts)
    end

    -- You can find details of these function in the help page
    -- see for example, :help vim.lsp.buf.hover()

    -- Trigger code completion
    bufmap('i', '<C-Space>', '<C-x><C-o>')

    -- Display documentation of 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>')

    -- Format current file
    bufmap('n', '<F3>', '<cmd>lua vim.lsp.buf.format()<cr>')

    -- Selects a code action available at the current cursor position
    bufmap('n', '<F4>', '<cmd>lua vim.lsp.buf.code_action()<cr>')
  end
})
Enter fullscreen mode Exit fullscreen mode

Fair warning

Not every language server implements the entire LSP specification. The features of LSP servers may not be consistent between servers.

For example, intelephense can show diagnostics in real time, there is no need to save the file to get new diagnostics. But rust-analyzer, the language server for rust, can only update diagnostics after saving the file.

Here's another example: ruff-lsp, a language server for python. It describes itself as a linter and code formatter. As far as I can tell ruff-lsp does not provide code completions or semantic highlights.

What I want say is this: read the documentation of the language server so you know what it can do.

Bonus content

At this point I'd say you have all the essential knowledge needed to be productive. What follows are tips, configurations, and features you can implement by adding some boilerplate code in your Neovim configuration.

Configure a language server for multiple filetypes

Sometimes a language server can support multiple filetypes. An example of this is typescript-language-server, the language server for javascript and typescript. In this case a filetype plugin can still work but there is an easier way to go about it.

One option to consider is a "global plugin." In there we can configure the language server in the callback function of a FileType autocommand.

If you want follow along, install typescript-language-server using this command in the terminal.

npm install -g typescript typescript-language-server
Enter fullscreen mode Exit fullscreen mode

For this we need to create a plugin folder inside Neovim's configuration folder. So, we navigate to Neovim's config folder, open Neovim, then execute this command to create the plugin folder.

:call mkdir('./plugin', 'p')
Enter fullscreen mode Exit fullscreen mode

Next we create a lua script, it can have any name we want. We can call it tsserver.lua.

:edit plugin/tsserver.lua | write
Enter fullscreen mode Exit fullscreen mode

In ts_ls.lua we are going to adapt the configuration in nvim-lspconfig's source code.

-- plugin/tsserver.lua

local function start_tsserver()
  local root_files = {'package.json', 'tsconfig.json', 'jsconfig.json'}
  local paths = vim.fs.find(root_files, {stop = vim.env.HOME})
  local root_dir = vim.fs.dirname(paths[1])

  if root_dir == nil then
    -- root directory was not found
    return
  end

  vim.lsp.start({
    name = 'tsserver',
    cmd = {'typescript-language-server', '--stdio'},
    root_dir = root_dir,
    init_options = {hostInfo = 'neovim'},
  })
end

vim.api.nvim_create_autocmd('FileType', {
  pattern = {'javascript', 'javascriptreact', 'javascript.jsx', 'typescript', 'typescriptreact', 'typescript.tsx'},
  desc = 'Start typescript LSP',
  callback = start_tsserver,
})
Enter fullscreen mode Exit fullscreen mode

This will work exactly like a filetype plugin, except here we are executing one lua function and not an entire file. The advantage of the autocommand is we can define multipe filetypes in the pattern property.

By the way, this doesn't have to be a global plugin, we can setup the autocommand in the init.lua file.

Add borders to floating windows

Sadly, there is no way to add borders to all floating windows, this means we have to enable it for each feature.

We can add the following to our init.lua file.

  • Diagnostic details
vim.diagnostic.config({
  float = {
    border = 'rounded',
  },
})
Enter fullscreen mode Exit fullscreen mode
  • Documentation window

The one used by the function vim.lsp.buf.hover().

vim.lsp.handlers['textDocument/hover'] = vim.lsp.with(
  vim.lsp.handlers.hover,
  {border = 'rounded'}
)
Enter fullscreen mode Exit fullscreen mode
  • Signature help

The one used by the function vim.lsp.buf.signature_help().

vim.lsp.handlers['textDocument/signatureHelp'] = vim.lsp.with(
  vim.lsp.handlers.signature_help,
  {border = 'rounded'}
)
Enter fullscreen mode Exit fullscreen mode

Format on save

The only thing we will do here is trigger the function vim.lsp.buf.format() before Neovim saves a file. And of course, we only do it when there is an active language server.

Important note: most language servers with formatting capabilities have their own style settings. For example, we can have 2 space indent in our Neovim config but maybe the language server formats the code with 4 space indent. So it's a good idea to check the documentation of the language server to see how to configure that.

-- You can add this in your init.lua
-- or a global plugin

local fmt_group = vim.api.nvim_create_augroup('autoformat_cmds', {clear = true})

local function setup_autoformat(event)
  local id = vim.tbl_get(event, 'data', 'client_id')
  local client = id and vim.lsp.get_client_by_id(id)
  if client == nil then
    return
  end

  vim.api.nvim_clear_autocmds({group = fmt_group, buffer = event.buf})

  local buf_format = function(e)
    vim.lsp.buf.format({
      bufnr = e.buf,
      async = false,
      timeout_ms = 10000,
    })
  end

  vim.api.nvim_create_autocmd('BufWritePre', {
    buffer = event.buf,
    group = fmt_group,
    desc = 'Format current buffer',
    callback = buf_format,
  })
end

vim.api.nvim_create_autocmd('LspAttach', {
  desc = 'Setup format on save',
  callback = setup_autoformat,
})
Enter fullscreen mode Exit fullscreen mode

Change diagnostics sign text

When using Neovim v0.9.5 or lower we need to call the function vim.fn.sign_define().

-- You can add this in your init.lua
-- or a global plugin

local function sign_define(args)
  vim.fn.sign_define(args.name, {
    texthl = args.name,
    text = args.text,
    numhl = ''
  })
end

sign_define({name = 'DiagnosticSignError', text = '✘'})
sign_define({name = 'DiagnosticSignWarn', text = '▲'})
sign_define({name = 'DiagnosticSignHint', text = '⚑'})
sign_define({name = 'DiagnosticSignInfo', text = '»'})
Enter fullscreen mode Exit fullscreen mode

When using Neovim v0.10 or greater we should do this with vim.diagnostic.config().

-- You can add this in your init.lua
-- or a global plugin

vim.diagnostic.config({
  signs = {
    text = {
      [vim.diagnostic.severity.ERROR] = '✘',
      [vim.diagnostic.severity.WARN] = '▲',
      [vim.diagnostic.severity.HINT] = '⚑',
      [vim.diagnostic.severity.INFO] = '»',
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Disable diagnostics in insert mode

This is already the default behavior but there is a problem... not really a problem, just a minor detail: the diagnostics only disappear after we start typing something.

This is the code I use to disable diagnostics right after going into insert mode (or select mode).

-- You can add this in your init.lua
-- or a global plugin

vim.api.nvim_create_autocmd('ModeChanged', {
  pattern = {'n:i', 'v:s'},
  desc = 'Disable diagnostics in insert and select mode',
  callback = function(e) vim.diagnostic.disable(e.buf) end
})

vim.api.nvim_create_autocmd('ModeChanged', {
  pattern = 'i:n',
  desc = 'Enable diagnostics when leaving insert mode',
  callback = function(e) vim.diagnostic.enable(e.buf) end
})
Enter fullscreen mode Exit fullscreen mode

Disable semantic highlights

Neovim's documentation suggest that we "clear" the highlight groups of the @lsp namespace. So, we can do this.

-- You can add this in your init.lua
-- this should be executed before setting the colorscheme

vim.api.nvim_create_autocmd('ColorScheme', {
  desc = 'Clear LSP highlight groups',
  callback = function()
    for _, group in ipairs(vim.fn.getcompletion('@lsp', 'highlight')) do
      vim.api.nvim_set_hl(0, group, {})
    end
  end,
})
Enter fullscreen mode Exit fullscreen mode

The ColorScheme autocommand should be created before setting up the colorscheme. This way Neovim can clear the highlights even if we change the colorscheme in the middle of a coding session.

Highlight symbol under cursor

What we want to do here is call the function vim.lsp.buf.document_highlight() when the cursor spends some amount of time on top of a symbol. And then clear the highlight when the cursor moves.

Note, for this to work properly the colorscheme needs to support the following highlight groups:

  • LspReferenceRead
  • LspReferenceText
  • LspReferenceWrite

If the colorscheme does not support these highlight groups, we can "link" them to an existing group. Here's an example using the Search highlight group.

vim.api.nvim_set_hl(0, 'LspReferenceRead', {link = 'Search'})
vim.api.nvim_set_hl(0, 'LspReferenceText', {link = 'Search'})
vim.api.nvim_set_hl(0, 'LspReferenceWrite', {link = 'Search'})
Enter fullscreen mode Exit fullscreen mode
-- You can add this in your init.lua
-- or a global plugin

-- time it takes to trigger the `CursorHold` event
vim.opt.updatetime = 400

local function highlight_symbol(event)
  local id = vim.tbl_get(event, 'data', 'client_id')
  local client = id and vim.lsp.get_client_by_id(id)
  if client == nil or not client.supports_method('textDocument/documentHighlight') then
    return
  end

  local group = vim.api.nvim_create_augroup('highlight_symbol', {clear = false})

  vim.api.nvim_clear_autocmds({buffer = event.buf, group = group})

  vim.api.nvim_create_autocmd({'CursorHold', 'CursorHoldI'}, {
    group = group,
    buffer = event.buf,
    callback = vim.lsp.buf.document_highlight,
  })

  vim.api.nvim_create_autocmd({'CursorMoved', 'CursorMovedI'}, {
    group = group,
    buffer = event.buf,
    callback = vim.lsp.buf.clear_references,
  })
end

vim.api.nvim_create_autocmd('LspAttach', {
  desc = 'Setup highlight symbol',
  callback = highlight_symbol,
})
Enter fullscreen mode Exit fullscreen mode

Simple tab complete

In this one we will use the Tab (and shift tab) key to navigate between the items in the completion menu. When the completion menu is not visible and the cursor is in a whitespace character, it will insert a tab character. Else, it will trigger the completion menu.

When the language server can provide code completion it'll use that. Otherwise, it will try to suggest words found in the current buffer.

Note that you can use the Enter key or <C-y> to confirm the current item in the completion menu

-- You can add this in your init.lua
-- or a global plugin

vim.opt.completeopt = {'menu', 'menuone', 'noselect', 'noinsert'}
vim.opt.shortmess:append('c')

local function tab_complete()
  if vim.fn.pumvisible() == 1 then
    -- navigate to next item in completion menu
    return '<Down>'
  end

  local c = vim.fn.col('.') - 1
  local is_whitespace = c == 0 or vim.fn.getline('.'):sub(c, c):match('%s')

  if is_whitespace then
    -- insert tab
    return '<Tab>'
  end

  local lsp_completion = vim.bo.omnifunc == 'v:lua.vim.lsp.omnifunc'

  if lsp_completion then
    -- trigger lsp code completion
    return '<C-x><C-o>'
  end

  -- suggest words in current buffer
  return '<C-x><C-n>'
end

local function tab_prev()
  if vim.fn.pumvisible() == 1 then
    -- navigate to previous item in completion menu
    return '<Up>'
  end

  -- insert tab
  return '<Tab>'
end

vim.keymap.set('i', '<Tab>', tab_complete, {expr = true})
vim.keymap.set('i', '<S-Tab>', tab_prev, {expr = true})
Enter fullscreen mode Exit fullscreen mode

Expand snippets

Neovim v0.11 introduced a new module called vim.lsp.completion, this will extend the behavior of the builtin completion so it can support "additional text edits" a language server can provide. Additional edits can be things like adding missing import statements or expanding code snippets.

Right now you have to opt-in to the features vim.lsp.completion provides. So, when a language server is active you have to call the vim.lsp.completion.enable() function.

-- You can add this in your init.lua
-- or a global plugin
vim.opt.completeopt = {'menu', 'menuone', 'noinsert', 'noselect'}

vim.api.nvim_create_autocmd('LspAttach', {
  desc = 'Enable vim.lsp.completion',
  callback = function(event)
    local client_id = vim.tbl_get(event, 'data', 'client_id')
    if client_id == nil then
      return
    end

    -- warning: this api is unstable
    vim.lsp.completion.enable(true, client_id, event.buf, {autotrigger = false})

    -- warning: this api is unstable
    -- Trigger lsp completion manually using Ctrl + Space
    vim.keymap.set('i', '<C-Space>', '<cmd>lua vim.lsp.completion.trigger()<cr>')
  end
})
Enter fullscreen mode Exit fullscreen mode

Notice in the last argument to .enable() there is a property called autotrigger. false is the default value so I just leave it like that. If you set it to true Neovim will trigger the completion menu when it finds a trigger character. Trigger characters change depending on the language server. In lua for example, the completion will be triggered automatically after a . or : character.

If you are using Neovim v0.10 you don't have access to vim.lsp.completion but you do have access to the module vim.snippet. You can use that to implement your own snippet expand autocommand.

-- You can add this in your init.lua
-- or a global plugin

-- note: this doesn't support "additional text edits" like
-- adding missing import statements.
local function expand_snippet(event)
  local comp = vim.v.completed_item
  local kind = vim.lsp.protocol.CompletionItemKind
  local item = vim.tbl_get(comp, 'user_data', 'nvim', 'lsp', 'completion_item')

  -- Check that we were given a snippet
  if (
    not item
    or not item.insertTextFormat
    or item.insertTextFormat == 1
    or not (
      item.kind == kind.Snippet
      or item.kind == kind.Keyword
    )
  ) then
    return
  end

  -- Remove the inserted text
  local cursor = vim.api.nvim_win_get_cursor(0)
  local line = vim.api.nvim_get_current_line()
  local lnum = cursor[1] - 1
  local start_col = cursor[2] - #comp.word

  if start_col < 0 then
    return
  end

  local set_text = vim.api.nvim_buf_set_text
  local ok = pcall(set_text, bufnr, lnum, start_col, lnum, #line, {''})

  if not ok then
    return
  end

  -- Insert snippet
  local snip_text = vim.tbl_get(item, 'textEdit', 'newText') or item.insertText
  assert(snip_text, "Language server indicated it had a snippet, but no snippet text could be found!")
  vim.snippet.expand(snip_text)
end

vim.api.nvim_create_autocmd('CompleteDone', {
  desc = 'Expand LSP snippet',
  callback = expand_snippet
})
Enter fullscreen mode Exit fullscreen mode

vim.snippet also supports snippet placeholders. This means we can jump to different places in the current active snippet. In Neovim v0.11 the default keybindings to navigate between snippets are Tab and Shift + Tab.

If you are using Neovim v0.10, here are some keybindings that you can use.

-- You can add this in your init.lua

-- Control + f: Jump to next snippet placeholder
vim.keymap.set({'i', 's'}, '<C-f>', function()
  if vim.snippet.active({direction = 1}) then
    return '<cmd>lua vim.snippet.jump(1)<cr>'
  else
    return '<C-f>'
  end
end, {expr = true})

-- Control + b: Jump to previous snippet placeholder
vim.keymap.set({'i', 's'}, '<C-b>', function()
  if vim.snippet.active({direction = -1}) then
    return '<cmd>lua vim.snippet.jump(-1)<cr>'
  else
    return '<C-b>'
  end
end, {expr = true})

-- Control + l: Exit current snippet
vim.keymap.set({'i', 's'}, '<C-l>', function()
  if vim.snippet.active() then
    return '<cmd>lua vim.snippet.exit()<cr>'
  else
    return '<C-l>'
  end
end, {expr = true})
Enter fullscreen mode Exit fullscreen mode

Enable inlay hints

Neovim v0.10 or greater is required.

For this we can use the function vim.lsp.inlay_hint.enable().

Note that some language servers may have inlay hints disabled by default. The settings needed to enable hints should be in the documentation of the language server.

-- You can add this in your init.lua
-- or a global plugin

vim.api.nvim_create_autocmd('LspAttach', {
  desc = 'Enable inlay hints',
  callback = function(event)
    local id = vim.tbl_get(event, 'data', 'client_id')
    local client = id and vim.lsp.get_client_by_id(id)
    if client == nil or not client.supports_method('textDocument/inlayHint') then
      return
    end

    vim.lsp.inlay_hint.enable(true, {bufnr = event.buf})
  end,
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hopefully I showed is not difficult to "connect" a language server with Neovim. Think about it, 1 shell commmand and 9 lines of lua code is all it takes to get intelephense working in Neovim.

The hard part is gathering all the context inside your head. What does LSP even mean? What's a language server? Filetype plugin? hardly know her. But once you know about the moving pieces and where to find the information you need, it gets easier.

One last thing, don't ignore the basics. Take your time and learn lua, read Neovim's lua guide and learn how to navigate Neovim's documentation with the :help command.


Thank you for your time. If you find this article useful and want to support my efforts, consider leaving a tip in ko-fi.com/vonheikemen.

buy me a coffee

Top comments (0)