Switching to NeoVim Native LSP

I do all of my day to day work in vim, specifically NeoVim . One of my favorite plugins as of late has been the CoC plugin. CoC provides language server integrations, autocompletion, linting, and formatting all in one easy use package. With the success of LSP in VScode and plugins like CoC, the NeoVim team has provided a native implementation of a language server client in 0.5.0. In addition to language-server support, the 0.5.0 release contains a host of new features including lua scripting, and a new lanaguage syntax compiler (treesitter). I was interested in finding out how the native LSP support compared to CoC and wanted to share the resulting configuration I came up with.

Language Server?

I’ve used this term and its abbreviation LSP a few times already. The language-server protocol is a client/server model that editors can implement to connect to compilers and interpreters running in a special mode. LSP clients make RPC requests to attached server processes. These commands get symbol information, diagnostic results, or queries for context on what the user is doing. Language servers can be implemented in any language, and run in their own process. This client/server relationship is similar to the relationship that web-browser and a web-servers have. Instead of HTTP, LSP uses more limited and specific set of operations with pre-defined structures. The split between client and server allow languages like PHP, Python and Typescript provide platform specific features and integrate with any client. Clients like NeoVim, CoC, or VSCode benefit from only having to implement one way to collect autocomplete and diagnostic errors from the outside world.

Converting to the new language server

Now that you’re done drinking the kool-aid lets get going. First we need to install a few global npm packages. For me that means using volta for you that might be npm install -g. Either way you’ll need the following npm packages:

Show Plain Text
  1. # For python, php and linter bridge
  2. volta install pyright intelephense diagnostic-languageserver
  3.  
  4. # For typescript
  5. volta install typescript typescript-language-server

Next we have to install a few new vim plugins. Again there are many ways to do this but I’m using vim-plug:

Show Plain Text
  1. " Add to vimrc, restart vim and run `:PlugInstall`
  2. " Improved syntax highlighting
  3. Plug 'nvim-treesitter/nvim-treesitter', {'do': ':TSUpdate'}
  4.  
  5. " LSP configuration helpers
  6. Plug 'neovim/nvim-lspconfig'
  7. " Improved LSP interface
  8. Plug 'glepnir/lspsaga.nvim', { 'branch': 'main' }
  9. " Autocomplete menus
  10. Plug 'hrsh7th/nvim-compe'
  11. " Formatter integration
  12. Plug 'mhartington/formatter.nvim'

Next, update vimrc to include a new lua module as LSP configuration can be a bit verbose.

Show Plain Text
  1. " Load Lua LSP config
  2. lua require('lsp-config')
  3. lua require('formatting')

Next we have to define which language servers to use, and define keymappings to interact with the language servers.

Show Plain Text
  1. -- Put in ~/.vim/lua/lsp-config.lua
  2. --- Configuration for LSP, formatters, and linters.
  3. local nvim_lsp = require("lspconfig")
  4. local saga = require("lspsaga")
  5.  
  6. -- Completion setup
  7. local compe = require("compe")
  8.  
  9. vim.o.completeopt = "menuone,noselect"
  10.  
  11. compe.setup {
  12.   enabled = true;
  13.   autocomplete = true;
  14.   throttle_time = 200;
  15.   source_timeout = 150;
  16.   source = {
  17.     -- only read from lsp and lua as I find buffer, path and others noisy.
  18.     nvim_lsp = true;
  19.     nvim_lua = true;
  20.   }
  21. }
  22.  
  23. -- short cut methods.
  24. local t = function(str)
  25.   return vim.api.nvim_replace_termcodes(str, true, true, true)
  26. end
  27.  
  28. local check_back_space = function ()
  29.   local col = vim.fn.col('.') - 1
  30.   return col == 0 or vim.fn.getline('.'):sub(col, col):match('%s') ~= nil
  31. end
  32.  
  33. -- Global function used to send <C-n> to compe
  34. _G.tab_complete = function()
  35.   if vim.fn.pumvisible() == 1 then
  36.     return t "<C-n>"
  37.   elseif check_back_space() then
  38.     return t "<Tab>"
  39.   else
  40.     return vim.fn['compe#complete']()
  41.   end
  42. end
  43.  
  44. -- Handler to attach LSP keymappings to buffers using LSP.
  45. local on_attach = function(client, bufnr)
  46.   -- helper methods for setting keymaps
  47.   local function buf_set_keymap(...) vim.api.nvim_buf_set_keymap(bufnr, ...) end
  48.   local function buf_set_option(...) vim.api.nvim_buf_set_option(bufnr, ...) end
  49.  
  50.   vim.opt_local.omnifunc = "v:lua.vim.lsp.omnifunc"
  51.  
  52.   --- Mappings
  53.   local opts = { noremap=true, silent=true }
  54.   buf_set_keymap('n', 'gh', "<cmd>lua require('lspsaga.provider').lsp_finder()<CR>", opts)
  55.   buf_set_keymap('n', 'K', "<cmd>lua require('lspsaga.hover').render_hover_doc()<CR>", opts)
  56.  
  57.   -- Scroll down in popups
  58.   buf_set_keymap('n', '<C-b>', "<cmd>lua require('lspsaga.action').smart_scroll_with_saga(1)<CR>", opts)
  59.  
  60.   -- Navigate and preview
  61.   buf_set_keymap('n', 'gs', "<cmd>lua require('lspsaga.signaturehelp').signature_help()<CR>", opts)
  62.   buf_set_keymap('n', 'gd', "<cmd>lua require('lspsaga.provider').preview_definition()<CR>", opts)
  63.   buf_set_keymap('n', 'gD', '<cmd>lua vim.lsp.buf.definition()<CR>', opts)
  64.   buf_set_keymap('n', 'gr', "<cmd>lua require('lspsaga.rename').rename()<CR>", opts)
  65.  
  66.   -- View diagnostics
  67.   buf_set_keymap('n', '<space>e', "<cmd>lua require('lspsaga.diagnostic').show_line_diagnostics()<CR>", opts)
  68.   buf_set_keymap('n', '[d', "<cmd>lua require('lspsaga.diagnostic').lsp_jump_diagnostic_prev()<CR>", opts)
  69.   buf_set_keymap('n', ']d', "<cmd>lua require('lspsaga.diagnostic').lsp_jump_diagnostic_next()<CR>", opts)
  70.  
  71.   -- Autocomplete
  72.   buf_set_keymap("i", "<C-Space>", 'compe#complete()', {noremap = true, silent = true, expr = true})
  73.   buf_set_keymap("i", "<CR>", "compe#confirm('<CR>')", {noremap = true, silent = true, expr = true})
  74.   buf_set_keymap("i", "<Esc>", "compe#close('<Esc>')", {noremap = true, silent = true, expr = true})
  75.   buf_set_keymap("i", "<Tab>", "v:lua.tab_complete()", {expr = true})
  76. end
  77.  
  78. -- Typescript
  79. nvim_lsp.tsserver.setup {
  80.   on_attach = function(client, bufnr)
  81.     -- Disable tsserver formatting as prettier/eslint does that.
  82.     client.resolved_capabilities.document_formatting = false
  83.     on_attach(client, bufnr)
  84.   end
  85. }
  86. -- Python
  87. nvim_lsp.pyright.setup {
  88.   on_attach = on_attach,
  89. }
  90. -- PHP
  91. nvim_lsp.intelephense.setup {
  92.   on_attach = on_attach,
  93. }
  94.  
  95. saga.init_lsp_saga {
  96.   error_sign = '\u{F658}',
  97.   warn_sign = '\u{F071}',
  98.   hint_sign = '\u{F835}',
  99. }

This configuration is what I’m running at time of writing and I’m quite happy with it. I feel that the native LSP client is a bit faster than what I was using before, but I’ve not actually measured response times. The UX from LSP is lovely and I apprecate the file previews that it renders.

Linters

Not all the linters I use have direct LSP implementations. Thankfully for these we can use diagnosticls to make tools like eslint and flake8 quack like an LSP server.

Show Plain Text
  1. -- Put in ~/.vim/lua/lsp-config.lua
  2. --- Linter setup
  3. local filetypes = {
  4.   typescript = "eslint",
  5.   typescriptreact = "eslint",
  6.   python = "flake8",
  7.   php = {"phpcs", "psalm"},
  8. }
  9.  
  10. local linters = {
  11.   eslint = {
  12.     sourceName = "eslint",
  13.     command = "./node_modules/.bin/eslint",
  14.     rootPatterns = {".eslintrc.js", "package.json"},
  15.     debouce = 100,
  16.     args = {"--stdin", "--stdin-filename", "%filepath", "--format", "json"},
  17.     parseJson = {
  18.       errorsRoot = "[0].messages",
  19.       line = "line",
  20.       column = "column",
  21.       endLine = "endLine",
  22.       endColumn = "endColumn",
  23.       message = "${message} [${ruleId}]",
  24.       security = "severity"
  25.     },
  26.     securities = {[2] = "error", [1] = "warning"}
  27.   },
  28.   flake8 = {
  29.     command = "flake8",
  30.     sourceName = "flake8",
  31.     args = {"--format", "%(row)d:%(col)d:%(code)s: %(text)s", "%file"},
  32.     formatPattern = {
  33.       "^(\\d+):(\\d+):(\\w+):(\\w).+: (.*)$",
  34.       {
  35.           line = 1,
  36.           column = 2,
  37.           message = {"[", 3, "] ", 5},
  38.           security = 4
  39.       }
  40.     },
  41.     securities = {
  42.       E = "error",
  43.       W = "warning",
  44.       F = "info",
  45.       B = "hint",
  46.     },
  47.   },
  48.   phpcs = {
  49.     command = "vendor/bin/phpcs",
  50.     sourceName = "phpcs",
  51.     debounce = 300,
  52.     rootPatterns = {"composer.lock", "vendor", ".git"},
  53.     args = {"--report=emacs", "-s", "-"},
  54.     offsetLine = 0,
  55.     offsetColumn = 0,
  56.     sourceName = "phpcs",
  57.     formatLines = 1,
  58.     formatPattern = {
  59.       "^.*:(\\d+):(\\d+):\\s+(.*)\\s+-\\s+(.*)(\\r|\\n)*$",
  60.       {
  61.         line = 1,
  62.         column = 2,
  63.         message = 4,
  64.         security = 3
  65.       }
  66.     },
  67.     securities = {
  68.       error = "error",
  69.       warning = "warning",
  70.     },
  71.     requiredFiles = {"vendor/bin/phpcs"}
  72.   },
  73.   psalm = {
  74.     command = "./vendor/bin/psalm",
  75.     sourceName = "psalm",
  76.     debounce = 100,
  77.     rootPatterns = {"composer.lock", "vendor", ".git"},
  78.     args = {"--output-format=emacs", "--no-progress"},
  79.     offsetLine = 0,
  80.     offsetColumn = 0,
  81.     sourceName = "psalm",
  82.     formatLines = 1,
  83.     formatPattern = {
  84.       "^[^ =]+ =(\\d+) =(\\d+) =(.*)\\s-\\s(.*)(\\r|\\n)*$",
  85.       {
  86.         line = 1,
  87.         column = 2,
  88.         message = 4,
  89.         security = 3
  90.       }
  91.     },
  92.     securities = {
  93.       error = "error",
  94.       warning = "warning"
  95.     },
  96.     requiredFiles = {"vendor/bin/psalm"}
  97.   }
  98. }
  99.  
  100. nvim_lsp.diagnosticls.setup {
  101.   on_attach = on_attach,
  102.   filetypes = vim.tbl_keys(filetypes),
  103.   init_options = {
  104.     filetypes = filetypes,
  105.     linters = linters,
  106.   },
  107. }

Formatters

Because linters are also often used to do formatting, more bridge code is necessary. While diagnosticls seems like it should support formatters, I wasn’t able to get it working. Instead I found a small plugin that does just what I need. The formatter.nvim package lets you define a list of formatters by filetype. I did need to apply the following pull request to get phpcbf working as it returns a non-zero exit code even when it ‘works’.

Show Plain Text
  1. -- Put in ~/.vim/lua/formatting.lua
  2. -- Formatters
  3. -- Formatting can be run via :Format
  4. local formatter = require('formatter')
  5.  
  6. local eslint_fmt = {
  7.   function()
  8.     return {
  9.       exe = "./node_modules/.bin/eslint",
  10.       args = {"--fix", "--stdin-filename", vim.api.nvim_buf_get_name(0)},
  11.       stdin = false,
  12.     }
  13.   end
  14. }
  15.  
  16. formatter.setup {
  17.   logging = true,
  18.   filetype = {
  19.     typescript = eslint_fmt,
  20.     typescriptreact = eslint_fmt,
  21.     javascript = eslint_fmt,
  22.     javascriptreact = eslint_fmt,
  23.     python = {
  24.       function ()
  25.         return {
  26.           exe = '~/.pyenv/shims/black',
  27.           args = {"-"},
  28.           stdin = true,
  29.         }
  30.       end,
  31.       function ()
  32.         return {
  33.           exe = '~/.pyenv/shims/isort',
  34.           args = {"-"},
  35.           stdin = true,
  36.         }
  37.       end
  38.     },
  39.     php = {
  40.       function ()
  41.         return {
  42.           exe = './vendor/bin/phpcbf',
  43.           args = {'--stdin-path=' .. vim.api.nvim_buf_get_name(0), '-'},
  44.           stdin = true,
  45.           ignore_exitcode = true,
  46.         }
  47.       end
  48.     }
  49.   }
  50. }

And that’s it. While it is significantly more code than getting CoC working, I’m happy with the results as it forced me to learn some lua, and do some housekeeping in my overall vim configuration. I’m also pleased with the output from lspsaga as it makes the whole experience lovely. If you’re looking for the finished results you can find them in my vim-files repository

Comments

There are no comments, be the first!

Have your say: