Setting up LSPs for Modern JavaScript Tooling in Neovim

It's no secret that I love Vim. I've been using it since I started in the industry, but rather than talking about WHY I love it so much (that feels like a separate post) let's talk about adding some key modern features of a code editor for writing JavaScript.

Note: These features are going to require us to run with Neovim, a relatively recent retooling of Vim, the modal editor that's been in active development since the 90's! (Such sleek, much wow, very powerful... πŸ•)

We're going to nab:

What you'll need:

LSP's, you magical beasties...

A lot of the features we're interested in come from the Language Server Protocol or LSP developed by Microsoft. They act as language-specific 'brains' that an editor (like Visual Studio Code) can communicate with for all sorts of handy language features.

This is the tech behind Intellisense completion, and it's also supported in Neovim. We're going to be nabbing these brains for ourselves with the help of NPM.

There are 4 LSP's that we're interested in today:

We're going to install everything globally with this command:

npm install -g typescript-language-server typescript vscode-langservers-extracted @tailwindcss/language-server

(I'm using npm to install modules in this example, but you can use whatever package manager you prefer. The point is you just need these globally installed so you can access them from Neovim. These LSP's are just a small sampling of what's available, see the docs at nvim-lspconfig for ways to install and configure more LSPs)

Now that we have these magical language 🧠's available globally we need to connect them to Neovim.

Connecting Neovim

We'll be installing Neovim plugins to help attach everything. The plugin manager I'm using here is Packer, but these will work with any other plugin manager as well.

We're also going to be writing the setup and configurations of these plugins in Lua because most of them are written in Lua themselves. However, there's no need to swap out your init.vim (vimscript-based config equivalent of .vimrc for Neovim) for init.lua just yet. Neovim gives you the option to write Lua blocks inside of Vimscript like:

lua << EOF
  -- all your lua config here
end

So if you're rocking Vimscript, make sure to wrap the following setup in the Lua markers.

Install the plugins

require('packer').startup({function(use)
  -- ...other plugins before and after...
  --
  -- LSP integration and autocomplete
  use 'neovim/nvim-lspconfig'
  use 'hrsh7th/nvim-cmp'
  use 'hrsh7th/cmp-nvim-lsp'
  use 'hrsh7th/cmp-nvim-lsp-signature-help'
  use 'hrsh7th/cmp-buffer'
  use 'hrsh7th/cmp-path'
  -- Prettier
  use {
    'prettier/vim-prettier',
    run = 'yarn install --frozen-lockfile --production',
    ft = {'javascript', 'typescript', 'css', 'scss', 'json', 'graphql', 'markdown', 'vue', 'yaml', 'html'}
  }
end})
Plugin Description
nvim-lspconfig Officially supported LSP quickstart configs
nvim-cmp The completion engine we'll be using for everything
cmp-nvim-lsp This is a 'source' plugin for our complete engine, nvim-cmp. This allows it to use the LSPs (required)
cmp-nvim-lsp-signature-help A helper source that will auto-hint at function arguments for us (optional)
cmp-buffer While we're at it, another source plugin that allows autocompletion from the buffer itself (optional)
cmp-path Another source plugin to help complete file system paths (optional)
Prettier Officially maintained auto-formatting utility for a variety of languages, what we're doing here is actually only activating it for certain filetypes

One note about Prettier: You may have noticed we did not install Prettier globally. TYPICALLY you'll be running Prettier from a local project installation, and this setup will hook into a project that has Prettier installed. But if you want to use Prettier outside of a project config, you can also install a version globally that you can use without modifying project dependencies. We'll come back to Prettier a little later on.

Activate the plugins

If you haven't worked with Lua plugins before they differ from traditional Vim plugins in the fact that they're not active by default, you have to call them (and optionally pass configuration options) before they'll attach themselves to your session. This allows for you to selectively load plugins per filetype or any other fancy options you might need.

Inside of your init.vim or init.lua add the following:

LSP Config

-- LSP Mappings + Settings -----------------------------------------------------
-- modified from: https://github.com/neovim/nvim-lspconfig#suggested-configuration
local opts = { noremap=true, silent=true }
-- Basic diagnostic mappings, these will navigate to or display diagnostics
vim.keymap.set('n', '<space>d', vim.diagnostic.open_float, opts)
vim.keymap.set('n', '[d', vim.diagnostic.goto_prev, opts)
vim.keymap.set('n', ']d', vim.diagnostic.goto_next, opts)
vim.keymap.set('n', '<space>q', vim.diagnostic.setloclist, opts)

-- Use an on_attach function to only map the following keys
-- after the language server attaches to the current buffer
local on_attach = function(client, bufnr)
  -- Enable completion triggered by <c-x><c-o>
  vim.api.nvim_buf_set_option(bufnr, 'omnifunc', 'v:lua.vim.lsp.omnifunc')

  -- Mappings to magical LSP functions!
  local bufopts = { noremap=true, silent=true, buffer=bufnr }
  vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, bufopts)
  vim.keymap.set('n', 'gd', vim.lsp.buf.definition, bufopts)
  vim.keymap.set('n', 'gk', vim.lsp.buf.hover, bufopts)
  vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, bufopts)
  vim.keymap.set('n', 'gK', vim.lsp.buf.signature_help, bufopts)
  vim.keymap.set('n', '<space>D', vim.lsp.buf.type_definition, bufopts)
  vim.keymap.set('n', '<space>rn', vim.lsp.buf.rename, bufopts)
  vim.keymap.set('n', '<space>ca', vim.lsp.buf.code_action, bufopts)
  vim.keymap.set('n', 'gr', vim.lsp.buf.references, bufopts)
  vim.keymap.set('n', '<space>f', function() vim.lsp.buf.format { async = true } end, bufopts)
end

-- The nvim-cmp almost supports LSP's capabilities so You should advertise it to LSP servers..
local capabilities = require('cmp_nvim_lsp').default_capabilities()

-- Capabilities required for the visualstudio lsps (css, html, etc)
capabilities.textDocument.completion.completionItem.snippetSupport = true

-- Activate LSPs
-- All LSPs in this list need to be manually installed via NPM/PNPM/whatevs
local lspconfig = require('lspconfig')
local servers = { 'tailwindcss', 'tsserver', 'jsonls', 'eslint' }
for _, lsp in pairs(servers) do
  lspconfig[lsp].setup {
    on_attach = on_attach,
    capabilites = capabilities,
  }
end

-- This is an interesting one, for some reason these two LSPs (CSS/HTML) need to
-- be activated separately outside of the above loop. If someone can tell me why,
-- send me a note...
lspconfig.cssls.setup {
  on_attach = on_attach,
  capabilities = capabilities
}

lspconfig.html.setup {
  on_attach = on_attach,
  capabilities = capabilities
}

Awesome, that's the LSP setup. We now have LSP's activated and attached -- but they're not going to autocompleting yet. We need to set up nvim-cmp. But to do that we also need to specify a snippet engine.

Snippet Setup

Now, you don't NEED LuaSnip specifically for nvim-cmp, but you do need A snippet engine (nvim-cmp requires one 🀷). I've chosen LuaSnip for it's ease of use and power, which I may cover in a future article. For now let's set it up:

-- Luasnip ---------------------------------------------------------------------
-- Load as needed by filetype by the luasnippets folder in the config dir
local luasnip = require("luasnip")
require("luasnip.loaders.from_lua").lazy_load()
-- set keybinds for both INSERT and VISUAL.
vim.api.nvim_set_keymap("i", "<C-n>", "<Plug>luasnip-next-choice", {})
vim.api.nvim_set_keymap("s", "<C-n>", "<Plug>luasnip-next-choice", {})
vim.api.nvim_set_keymap("i", "<C-p>", "<Plug>luasnip-prev-choice", {})
vim.api.nvim_set_keymap("s", "<C-p>", "<Plug>luasnip-prev-choice", {})
-- Set this check up for nvim-cmp tab mapping
local has_words_before = function()
  local line, col = unpack(vim.api.nvim_win_get_cursor(0))
  return col ~= 0 and vim.api.nvim_buf_get_lines(0, line - 1, line, true)[1]:sub(col, col):match("%s") == nil
end

Autocomplete Setup

Almost done! Now we just need to activate nvim-cmp and feed it both our LSP and other sources, and create some keymappings:

-- CMP - Autocompletion --------------------------------------------------------
local cmp = require 'cmp'
cmp.setup {
  snippet = {
    expand = function(args)
       require('luasnip').lsp_expand(args.body) -- For `luasnip` users.
    end,
  },
  mapping = {
    ['<C-p>'] = cmp.mapping.select_prev_item(),
    ['<C-n>'] = cmp.mapping.select_next_item(),
    ['<C-d>'] = cmp.mapping.scroll_docs(-4),
    ['<C-f>'] = cmp.mapping.scroll_docs(4),
    ['<C-Space>'] = cmp.mapping.complete(),
    ['<C-e>'] = cmp.mapping.close(),
    ['<CR>'] = cmp.mapping.confirm {
      behavior = cmp.ConfirmBehavior.Replace,
      select = true,
    },
    ["<Tab>"] = cmp.mapping(function(fallback)
      if cmp.visible() then
        cmp.select_next_item()
      elseif luasnip.expand_or_jumpable() then
        luasnip.expand_or_jump()
      elseif has_words_before() then
        cmp.complete()
      else
        fallback()
      end
    end, { "i", "s" }),

    ["<S-Tab>"] = cmp.mapping(function(fallback)
      if cmp.visible() then
        cmp.select_prev_item()
      elseif luasnip.jumpable(-1) then
        luasnip.jump(-1)
      else
        fallback()
      end
    end, { "i", "s" }),
  },
  sources = {
    { name = 'nvim_lsp' },
    { name = 'nvim_lsp_signature_help' },
    { name = 'luasnip' },
    { name = 'buffer' },
    { name = 'path' }
  },
}

Aaaaay, now we have LSP-assisted autocomplete working! 😎 We've set up some smart mappings specifically around <Tab> and <S-Tab> to allow nvim-cmp to quickly cycle through options, and we've fed all of our sources into the completion engine.

Prettier Setup

And lastly, lets activate and make some keybindings for Prettier:

-- Prettier
keymap("n", "<leader>re", "<Plug>(Prettier)", opts)
keymap("v", "<leader>re", ":PrettierFragment<cr>", opts)

If it's more helpful to see everything all together, you can take a peek at my personal Neovim config, just know that it may deviate slightly from these instructions as I'm always tinkering.

That's great, but what can I DO with these things?

Each one of these things we just installed and connected will allow to some truly magical actions.

Language-Powered Extensible autocompletion (nvim-cmp + LSP)

If you've seen someone use VSCode, you have seen this in action. Language keywords are automatically provided for you as you type, and more than that: it KNOWS your code base. If you've correctly formatted your code, the autocomplete popups will be able to tell you what types of arguments the function requires, grab methods and functions from imported code, and just overall better autocomplete.

But YOU get to pick what goes in there. If you need something like Elixir and Erlang support, there are LSP's for that. This system will allow you to grab whatever completion brains you want and plug them into your new system.

Super Language Powers

There are a lot of powerful features hidden in the LSPs that are not obvious at first. We bound a lot of these commands to key mappings in our config files, here are a few of them explained:

Linting In Context

We didn't address it directly, but part of the vscode-langservers-extracted package included eslint. Through the LSP integration, diagnostics will output directly alongside your JavaScript so you can see when you've made a terrible mistake when your code could be improved. This setup also gives you an easy way to navigate through diagnostic errors (We bound them to [d and ]d in the config files) and even have some quick LSP-assisted resolution through Code Actions.

Auto-formatting (Prettier)

Alright, this is more subjective than the other benefits, but stick with me here for a πŸ”₯ hot take πŸ”₯.

While Prettier isn't perfect, it does have a valuable function: it completely removes arbitrary and subjective formatting differences when working on a team.

Tabs/Spaces? (Foh-get a-bout eet) Spaces around argument options? (Naht to woo-ray, hun). Entire PR's littered with whitespace adjustments? (Ne-vah agaihns). Write the code how you want to while you're hacking away, then, when you're done, have the formatter clean it up and snap it into alignment with team standards (collected in the .pretterrc). It's like a robot butler for your code, and while it may not put everything back where YOU would put it you can also know that it's doing the same thing for everyone else. And that's a lot harder to get stuck on.

Conclusion

The support of LSP's is one of the main reasons I hopped onboard the Neovim train. After seeing how nice the JavaScript experience is in Visual Studio Code and the power of what LSP's allow you to do with your code I was sold. When you're styling a user interface in TailwindCSS or working in a Next.js app, having the lightning fast autocomplete along with quick hopping, output previews, and contextual linting... It turns Neovim into a rocket ship for writing modern JavaScript!

Newsletter

Want to receive these thoughts and others in your inbox?

Discussion

Want to discuss this? Email Me