My setup for Neovim's builtin LSP client

Suggest An Edit

Table of Content

What is LSP and Why?

20-08-2021: This post is no longer maintained because I’ve changed my config quite a bit since I wrote this and I don’t feel like updating it :p

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 ships with LuaJIT which makes it super fast.



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()


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

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

  on_attach = custom_on_attach,
  on_init = custom_on_init,

I made a custom_on_attach function to attach LSP specific mappings. I also made a custom on_init function to notify me when the LSP is started and enable incremental_sync. 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 :p

UPDATE Thu, February 4, 2021

I’ve updated my config to use a better way to set them up. Basically, I have a key-value pair table, each item is a table with the server name as its key. This way, I wouldn’t need to copy and paste nvim_lsp.lsp_name.setup{...}.

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 = {}

local signature = require("lspsaga.signaturehelp")
-- other LSP saga modules

M.lsp_mappings = function()
  if type == "jdtls" then
    nnoremap({ "ga", require("jdtls").code_action, { silent = true } })
    nnoremap({ "ga", require("plugins._telescope").lsp_code_actions, { silent = true } })

  inoremap({ "<C-s>", signature.signature_help, { silent = true } })
  -- some other mappings here

return M

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.

I have my `tsserver` to be started on every JS/TS file regardless of its directory. With the default config, it will only start when it found `package.json` or `.git` which marks the root directory for the LSP.

-- inside the `servers` table
tsserver = {
  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.

-- inside the `servers` table
svelteserver = {
  on_attach = function(client)

    client.server_capabilities.completionProvider.triggerCharacters = {
      ".", '"', "'", "`", "/", "@", "*",
      "#", "$", "+", "^", "(", "[", "-", ":"
  on_init = custom_on_init,
  handlers = {
    ["textDocument/publishDiagnostics"] = is_using_eslint,
  filetypes = { 'html', 'svelte' },
  settings = {
    svelte = {
      plugin = {
        -- some settings


[lua-language-server][lua-ls] is a bit different because I compiled it from source so it needs some extra setup.

local sumneko_root = os.getenv("HOME") .. "/repos/lua-language-server"

-- inside the `servers` table
sumneko_lua = {
  cmd = {
    sumneko_root .. "/bin/Linux/lua-language-server",
    sumneko_root .. "/main.lua",
  on_attach = custom_on_attach,
  on_init = custom_on_init,
  settings = {
    Lua = {
      runtime = { version = "LuaJIT", path = vim.split(package.path, ";") },
      diagnostics = {
        enable = true,
        globals = {
          "vim", "describe", "it", "before_each", "after_each",
          "awesome", "theme", "client", "P",
      workspace = {
        preloadFileSize = 400,


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.

Linting and Formatting

I recently started using [null-ls][efm-ls] to run eslint and formatters like prettier and stylua.

You can get my full config for null-ls here

Diagnostic Conflict

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.

UPDATE Fri, January 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.

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

Here’s some of my nvim-compe config

local remap = vim.api.nvim_set_keymap

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

  enabled              = true,
  debug                = false,
  min_length           = 2,
  preselect            = "disable",
  source_timeout       = 200,
  incomplete_delay     = 400,
  allow_prefix_unmatch = false,

  source = {
    path     = true,
    calc     = true,
    buffer   = true,
    vsnip    = true,
    nvim_lsp = true,
    nvim_lua = true,

Util.trigger_completion = function()
  if vim.fn.pumvisible() ~= 0 then
    if vim.fn.complete_info()["selected"] ~= -1 then
      return vim.fn["compe#confirm"]()

  local prev_col, next_col = vim.fn.col(".") - 1, vim.fn.col(".")
  local prev_char = vim.fn.getline("."):sub(prev_col, prev_col)
  local next_char = vim.fn.getline("."):sub(next_col, next_col)

  -- minimal autopairs-like behaviour
  if prev_char == "{" and next_char == "" then return Util.t("<CR>}<C-o>O") end
  if prev_char == "[" and next_char == "" then return Util.t("<CR>]<C-o>O") end
  if prev_char == "(" and next_char == "" then return Util.t("<CR>)<C-o>O") end
  if prev_char == ">" and next_char == "<" then return Util.t("<CR><C-o>O") end -- html indents

  return Util.t("<CR>")

  { expr = true, silent = true }
    "pumvisible() ? "<C-n>" : v:lua.Util.check_backspace()",
    "? "<Tab>" : compe#confirm()",
  { silent = true, noremap = true, expr = true }

  "pumvisible() ? "<C-p>" : "<S-Tab>"",
  { noremap = true, expr = true }
  { noremap = true, expr = true, silent = 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 :)