My setup for Neovim's builtin LSP client

Suggest An Edit

Table of Content

What is LSP and Why?

If you don’t already know what LSP is, well, LSP is a Language Server Protocol and it was created by Microsoft. It’s a better implementation of language support for a text editor. Instead of having to implement it for every language on every text editor, we only need a server for a specific language and a client for a text editor that can speak to the server.

Imagine the editor as X and language feature as Y, the first solution would take X*Y to implement because it needs to implements every language features for every editor. The second solution which is the LSP way would only take X+Y because it would only take a server for the language and a client that can speak to that server. The server can be used for any text editor that has a client and the client can speak to any LSP server. No more reinventing the wheel, great!

Here are some resources that explain LSP way better and in more detail.

Neovim builtin LSP client

I use Neovim’s built-in LSP client which only available on the master branch of Neovim at the time of writing this. I was using coc.nvim but it was slow on my machine because it uses node and it’s a remote plugin which adds some overhead. It still works great nonetheless, it’s just slow on my machine.

The new neovim’s built-in LSP client is written in Lua and Neovim now ships with LuaJIT which makes it super fast. I couldn’t recommend you enough to watch TJ’s talk about Neovim LSP client on Vimconf 2020, he explained it really well.



Neovim has a repo with LSP configuration for a various language called nvim-lspconfig, this is NOT where the LSP client lives, the client already ships with Neovim. It’s just a repo that holds the configuration for the client.

I have this piece of code on my config to install it. I use packer.nvim

use {'neovim/nvim-lspconfig', opt = true} -- builtin lsp config


I have a directory filled with LSP related config. Here’s some snippet that sets up the LSP.

local custom_on_attach = function(client)

  if client.config.flags then
    client.config.flags.allow_incremental_sync = true

local custom_on_init = function()
  print('Language Server Protocol started!')

  on_attach = custom_on_attach,
  on_init = custom_on_init,

I made a custom_on_attach function to attach LSP specific mappings and enable incremental_sync if the server supports it. I also made a custom on_init function to notify me when the LSP is running. Though, I’m not sure if on_init is the correct thing that I’m looking for. Sometimes it notifies me when the LSP server hasn’t even started yet.

You can find the full content of this file here


Here are some of my LSP related mappings which you can find in the file here

local remap = vim.api.nvim_set_keymap
local M = {}

M.lsp_mappings = function()
  remap('n', 'K', '<cmd>lua vim.lsp.buf.hover()<CR>', { noremap = true, silent = true })
  remap('n', 'ga', '<cmd>lua vim.lsp.buf.code_action()<CR>', { noremap = true, silent = true })
  remap('n', 'gd', '<cmd>lua vim.lsp.buf.definition()<CR>', { noremap = true, silent = true })
  remap('i', '<C-s>', '<cmd>lua vim.lsp.buf.signature_help()<CR>', { noremap = true, silent = true })
  remap('n', 'gD', '<cmd>lua vim.lsp.diagnostic.show_line_diagnostics()<CR>', { noremap = true, silent = true })
  remap('n', 'gr', '<cmd>lua require"telescope.builtin".lsp_references()<CR>', { noremap = true, silent = true })
  remap('n', 'gR', '<cmd>lua vim.lsp.buf.rename()<CR>', { noremap = true, silent = true })
  remap('n', 'gf', '<cmd>lua vim.lsp.buf.formatting()<CR>', { noremap = true, silent = true })

return M

Almost all mappings I use are bound to the default function except the one that shows LSP references. I use telescope.nvim and it has a built-in that shows references from LSP. It’s nicer in my opinion because I can then fuzzy find the reference.

Language-specific config

I have most of my LSP config to be default but I gave several LSP an option like tsserver, svelteserver, or sumneko_lua.


I have my tsserver to be started on every JS/TS file regardless of its directory. The default config will only start when it found package.json or .git.

  filetypes = { 'javascript', 'typescript', 'typescriptreact' },
  on_attach = custom_on_attach,
  on_init = custom_on_init,
  root_dir = function() return vim.loop.cwd() end


I disabled its HTML emmet suggestion and removed > and < from triggerCharacters. They’re so annoying to me.

  on_attach = function(client)

    if client.config.flags then
      client.config.flags.allow_incremental_sync = true
    client.server_capabilities.completionProvider.triggerCharacters = {
      ".", '"', "'", "`", "/", "@", "*",
      "#", "$", "+", "^", "(", "[", "-", ":"
  on_init = custom_on_init,
  filetypes = { 'html', 'svelte' },
  settings = {
    svelte =  {
      plugin = {
        html = {
          completions = {
            enable = true,
            emmet = false


I took this from the example on TJ’s talk and added some global variables for AwesomeWM.

  on_attach = custom_on_attach,
  on_init = custom_on_init,
  settings = {
    Lua = {
      runtime = { version = "LuaJIT", path = vim.split(package.path, ';'), },
      completion = { keywordSnippet = "Disable" },
      diagnostics = {
        enable = true,
        globals = {
          "vim", "describe", "it", "before_each", "after_each",
          "awesome", "theme", "client"
UPDATE Thu, February 4, 2021
Because `:LSPInstall` has been removed, we need to install the LSP manually and override the `cmd` field. I have the LSP repo at `~/repos/lua-language-server`. So what I need to add is something like this
local sumneko_root = os.getenv("HOME") .. "/repos/lua-language-server"
cmd = {
  .. "/bin/Linux/lua-language-server", "-E",
  sumneko_root .. "/main.lua"


I was using diagnostic-nvim before this big PR got merged which makes diagnostic-nvim redundant. Here’s some of my diagnostic config.

vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with(
  vim.lsp.diagnostic.on_publish_diagnostics, {
    virtual_text = {
      prefix = "»",
      spacing = 4,
    signs = true,
    update_in_insert = false,

vim.fn.sign_define('LspDiagnosticsSignError', { text = "", texthl = "LspDiagnosticsDefaultError" })
vim.fn.sign_define('LspDiagnosticsSignWarning', { text = "", texthl = "LspDiagnosticsDefaultWarning" })
vim.fn.sign_define('LspDiagnosticsSignInformation', { text = "", texthl = "LspDiagnosticsDefaultInformation" })
vim.fn.sign_define('LspDiagnosticsSignHint', { text = "", texthl = "LspDiagnosticsDefaultHint" })

I set the prefix for virtual_text to be » because I don’t really like the default one and enabled signs for the diagnostic hint. I also made it to only update the diagnostic when I switch between insert mode and normal mode because it’s quite annoying when I haven’t finished typing and get yelled at by LSP because it expects me to put = after a variable name that I haven’t even finished typing yet.

Fancy Stuff

UPDATE Mon, March 1, 2021

I now use lspsaga.nvim to enhance the builtin LSP experience like bordered window, popup menu for code actions, etc. Just go to its Github page, there’s no point of me explaining it here. It’s really good :)

Linting and Formatting

I recently started using efm-langserver to run eslint and formatting like prettier, gofmt , LuaFormatter, and rustfmt.

UPDATE Tue, January 26, 2021

I now use gofumpt for stricter formatting.

It was kinda hard to setup but it was well worth it. The formatter is fast and I got nice linting from external linters like ESLint. Here’s some of my config for efm-langserver.

  cmd = {"efm-langserver"},
  on_attach = function(client)
    client.resolved_capabilities.rename = false
    client.resolved_capabilities.hover = false
  on_init = custom_on_init,
  settings = {
    rootMarkers = {vim.loop.cwd()},
    languages = {
      javascript = { eslint, prettier },

I disabled the capability for rename and hover on efm-langserver because the server doesn’t support that and I don’t want to have any conflict with the other server that’s running.

My prettier is a table with a key of formatCommand with its value as a function to check if .prettierrc is present on current directory and fallback to my global .prettierrc if it doesn’t have a local .prettierrc.

local prettier = {
  formatCommand = (
      if not vim.fn.empty(vim.fn.glob(vim.loop.cwd() .. '/.prettierrc')) then
        return "prettier --config ./.prettierrc"
        return "prettier --config ~/.config/nvim/.prettierrc"

The ESlint config is pretty simple, it looks like this.

local eslint = {
  lintCommand = "./node_modules/.bin/eslint -f unix --stdin --stdin-filename ${INPUT}",
  lintIgnoreExitCode = true,
  lintStdin = true,
  lintFormats = {"%f:%l:%c: %m"},
  rootMarkers = {

I need to explicitly specify the lintFormats, otherwise it shows the wrong message and it’s really annoying.

You can get my full config for efm-langserver here

UPDATE Fri, January 22, 2021

I no longer use efm-langserver for formatting because I have an issue with prettier not working correctly when using it to format svelte file. So I use formatter.nvim as my formatter.

Diagnostic Conflict

UPDATE Fri, January 22, 2021

When I use efm-langserver, the diagnostic that comes from the LSP (like tsserver) and external linter that efm-langserver uses are conflicting. So, I made a custom function for it to check if there’s a file like .eslintrc.js, it will turn off the diagnostic that comes from LSP and use ESlint instead.

That being said, I think this is kinda a hacky way? Because when I use this method, the global config for diagnostic gets overridden. I have to manually pass the config table like this.

I think you supposed to use `vim.with` but I couldn't get it work.

UPDATE Mon, February 1, 2021

I’ve found a better way from one of TJ’s stream to do this which looks like this.

local is_using_eslint = function(_, _, result, client_id)
  if is_cfg_present("/.eslintrc.json") or is_cfg_present("/.eslintrc.js") then

  return vim.lsp.handlers["textDocument/publishDiagnostics"](_, _, result, client_id)

I’ve overridden the vim.lsp.handlers["textDocument/publishDiagnostics"] anyway so reusing it would also works and it looks way cleaner.

Completion and Snippets

I use a completion and snippet plugin to make my life easier. For completion, I use nvim-compe, previously I was using completion-nvim but I had some issues with it such as path completion sometimes not showing up and flickering. You may use whatever works for you.

Snippet wise, I use vim-vsnip. I love to use snippets.nvim but it doesn’t integrate well with LSP’s snippet.

Here’s some of my nvim-compe config

local remap = vim.api.nvim_set_keymap

require'compe'.setup {
  enabled = true;
  debug = false;
  min_length = 2;
  preselect = "disable";
  source_timeout = 200;
  incomplete_delay = 400;
  allow_prefix_unmatch = false;
  source = {
    path = true;
    buffer = true;
    vsnip = true;
    nvim_lsp = true;

vim.g.vsnip_snippet_dir = vim.fn.stdpath("config").."/snippets"

-- check prev character, depending on previous char
-- it will do special stuff or just `<CR>`
-- i.e: accept completion item, indent html, autoindent braces/etc, just enter
  'i', '<CR>',
  '? complete_info()["selected"] != "-1"',
  '? compe#confirm(lexima#expand("<LT>CR>", "i"))',
  ': "<C-g>u".lexima#expand("<LT>CR>", "i")',
  ': v:lua.Util.check_html_char() ? lexima#expand("<LT>CR>", "i")."<ESC>O"',
  ': lexima#expand("<LT>CR>", "i")'
  { silent = true, expr = true }

-- cycle tab or insert tab depending on prev char
  'i', '<Tab>',
  'pumvisible() ? "<C-n>" : v:lua.Util.check_backspace() ? "<Tab>" : compe#confirm(lexima#expand("<LT>CR>", "i"))',
  { silent = true, noremap = true, expr = true }
remap('i', '<S-Tab>', 'pumvisible() ? "<C-p>" : "<S-Tab>"', { noremap = true, expr = true })

You can get the full config for my completion setup here

Closing Note

I’m pretty pleased with my current setup. Kudos to Neovim’s developer that brings LSP client to be a built-in feature! These are of course some other great LSP client alternatives for (Neo)vim, definitely check them out!

Here’s my whole LSP config if you want them. If you’ve read this far then thank you and have a wonderful day :)