Configuring Neovim’s LSP client for TypeScript development
By: Jose Alvarez
Tags: Vim, Neovim, Typescript, Vscode, Terminal

Installing Neovim + LSP dependencies

The version of Neovim from your package manager probably doesn’t have LSP support yet. As of now, to use the latest LSP features, you need to build Neovim from its master branch. On macOS, this is as simple as running the following command

brew install --HEAD neovim

Note that formulas installed from --HEAD will not update when you run brew update . You'll have to run brew uninstall neovim and re-run the install command to update, and since you're building from source, it'll take longer than a normal install.

For Linux, your package manager may have a package that tracks Neovim’s master branch on GitHub. You’re using Linux, so I’m sure you can figure it out.

You’ll also need to install the following NPM packages globally:

npm install -g typescript typescript-language-server diagnostic-languageserver eslint_d

nvim-lspconfig

Neovim’s LSP client is built into the program, but configuring it directly can be tricky. That’s where nvim-lspconfig comes in.

This repo handles automatically launching, initializing, and configuring language servers that are installed on your system.

If you’re using vim-plug, install it normally:

Plug 'neovim/nvim-lspconfig'

If you’re using packer.nvim (which I recommend, since we’ll need to write a lot of Lua and we might as well go all the way):

use "neovim/nvim-lspconfig"

You’ll then need to configure the plugin and your LSP clients.

Lua from Vimscript

The rest of the code in this post will be Lua, not Vimscript. To use Lua in your init.vim , you have two options:

Place the code into a Lua block:

lua << EOF (code goes here) EOF

2. Place the code into a Lua file and import that Lua file in your init.vim .

I highly recommend the second method. You can place your LSP configuration into a file, e.g. ~/.config/nvim/lua/lsp-config.lua , and import it by calling:

lua require("lsp-config")

-- or whatever you named your file, relative to your nvim/lua directory

(You can also go crazy and rewrite your init.vim in Lua, but that's outside the scope of this guide.)

LSP configuration

We’re going to set up two LSP servers: typescript-language-server to wrap around tsserver and analyze your TypeScript code, and diagnostic-languageserver to handle ESLint diagnostics and formatting via Prettier.

At the top of your configuration, add this line to import the nvim-lspconfig plugin:

local nvim_lsp = require("lspconfig")

Now we’ll configure the language servers.

typescript-language-server is simple. Add the following code to your configuration:

nvim_lsp.tsserver.setup {

on_attach = function(client)

client.resolved_capabilities.document_formatting = false

on_attach(client)

end

}

We’ve disabled formatting to avoid conflicts with Prettier. You might also note that on_attach is not yet defined, but hang on and we'll get there.

diagnostic-languageserver is a little more complicated. It's essentially a wrapper around command-line tools like eslint , so we have to define the commands as well as the format of the diagnostics we'll receive from the tools:

local filetypes = {

typescript = "eslint",

typescriptreact = "eslint",

} local linters = {

eslint = {

sourceName = "eslint",

command = "eslint_d",

rootPatterns = {".eslintrc.js", "package.json"},

debounce = 100,

args = {"--stdin", "--stdin-filename", "%filepath", "--format", "json"},

parseJson = {

errorsRoot = "[0].messages",

line = "line",

column = "column",

endLine = "endLine",

endColumn = "endColumn",

message = "${message} [${ruleId}]",

security = "severity"

},

securities = {[2] = "error", [1] = "warning"}

}

} local formatters = {

prettier = {command = "prettier", args = {"--stdin-filepath", "%filepath"}}

} local formatFiletypes = {

typescript = "prettier",

typescriptreact = "prettier"

} nvim_lsp.diagnosticls.setup {

on_attach = on_attach,

filetypes = vim.tbl_keys(filetypes),

init_options = {

filetypes = filetypes,

linters = linters,

formatters = formatters,

formatFiletypes = formatFiletypes

}

}

I’m using eslint_d, which is essentially a faster version of eslint , but if you want to stick with the original, replace the command in the linters table with eslint .

We’ll now need to define an on_attach function to run whenever a language server starts. This is where we'll define commands and keybindings.

This is my configuration, but feel free to change it to suit your needs. I defined easy-to-remember commands to make my life easier and assigned my most used ones to keys:

local on_attach = function(client, bufnr)

local buf_map = vim.api.nvim_buf_set_keymap

vim.cmd("command! LspDef lua vim.lsp.buf.definition()")

vim.cmd("command! LspFormatting lua vim.lsp.buf.formatting()")

vim.cmd("command! LspCodeAction lua vim.lsp.buf.code_action()")

vim.cmd("command! LspHover lua vim.lsp.buf.hover()")

vim.cmd("command! LspRename lua vim.lsp.buf.rename()")

vim.cmd("command! LspOrganize lua lsp_organize_imports()")

vim.cmd("command! LspRefs lua vim.lsp.buf.references()")

vim.cmd("command! LspTypeDef lua vim.lsp.buf.type_definition()")

vim.cmd("command! LspImplementation lua vim.lsp.buf.implementation()")

vim.cmd("command! LspDiagPrev lua vim.lsp.diagnostic.goto_prev()")

vim.cmd("command! LspDiagNext lua vim.lsp.diagnostic.goto_next()")

vim.cmd(

"command! LspDiagLine lua vim.lsp.diagnostic.show_line_diagnostics()")

vim.cmd("command! LspSignatureHelp lua vim.lsp.buf.signature_help()") buf_map(bufnr, "n", "gd", ":LspDef<CR>", {silent = true})

buf_map(bufnr, "n", "gr", ":LspRename<CR>", {silent = true})

buf_map(bufnr, "n", "gR", ":LspRefs<CR>", {silent = true})

buf_map(bufnr, "n", "gy", ":LspTypeDef<CR>", {silent = true})

buf_map(bufnr, "n", "K", ":LspHover<CR>", {silent = true})

buf_map(bufnr, "n", "gs", ":LspOrganize<CR>", {silent = true})

buf_map(bufnr, "n", "[a", ":LspDiagPrev<CR>", {silent = true})

buf_map(bufnr, "n", "]a", ":LspDiagNext<CR>", {silent = true})

buf_map(bufnr, "n", "ga", ":LspCodeAction<CR>", {silent = true})

buf_map(bufnr, "n", "<Leader>a", ":LspDiagLine<CR>", {silent = true})

buf_map(bufnr, "i", "<C-x><C-x>", "<cmd> LspSignatureHelp<CR>",

{silent = true}) if client.resolved_capabilities.document_formatting then

vim.api.nvim_exec([[

augroup LspAutocommands

autocmd! * <buffer>

autocmd BufWritePost <buffer> LspFormatting

augroup END

]], true)

end

end

If you’re curious about these Lua commands, this repo is a fantastic introduction.

We’re not done yet! In the last block above, we enabled formatting on save through a Vim autocommand. The built-in LSP client provides a formatting command, which runs asynchronously, and a synchronous version, but neither is ideal if you want to format on save.

We’ll define our own function and override Neovim’s default command with ours (magical, right?):

local format_async = function(err, _, result, _, bufnr)

if err ~= nil or result == nil then return end

if not vim.api.nvim_buf_get_option(bufnr, "modified") then

local view = vim.fn.winsaveview()

vim.lsp.util.apply_text_edits(result, bufnr)

vim.fn.winrestview(view)

if bufnr == vim.api.nvim_get_current_buf() then

vim.api.nvim_command("noautocmd :update")

end

end

end vim.lsp.handlers["textDocument/formatting"] = format_async

We’ll also define a simple command to organize imports, just like VS Code. We’ll attach it to the global _G object to make it available from Vimscript Lua calls.

_G.lsp_organize_imports = function()

local params = {

command = "_typescript.organizeImports",

arguments = {vim.api.nvim_buf_get_name(0)},

title = ""

}

vim.lsp.buf.execute_command(params)

end

All together now, for easy copy-pasting:

local nvim_lsp = require("lspconfig") local format_async = function(err, _, result, _, bufnr)

if err ~= nil or result == nil then return end

if not vim.api.nvim_buf_get_option(bufnr, "modified") then

local view = vim.fn.winsaveview()

vim.lsp.util.apply_text_edits(result, bufnr)

vim.fn.winrestview(view)

if bufnr == vim.api.nvim_get_current_buf() then

vim.api.nvim_command("noautocmd :update")

end

end

end vim.lsp.handlers["textDocument/formatting"] = format_async _G.lsp_organize_imports = function()

local params = {

command = "_typescript.organizeImports",

arguments = {vim.api.nvim_buf_get_name(0)},

title = ""

}

vim.lsp.buf.execute_command(params)

end local on_attach = function(client, bufnr)

local buf_map = vim.api.nvim_buf_set_keymap

vim.cmd("command! LspDef lua vim.lsp.buf.definition()")

vim.cmd("command! LspFormatting lua vim.lsp.buf.formatting()")

vim.cmd("command! LspCodeAction lua vim.lsp.buf.code_action()")

vim.cmd("command! LspHover lua vim.lsp.buf.hover()")

vim.cmd("command! LspRename lua vim.lsp.buf.rename()")

vim.cmd("command! LspOrganize lua lsp_organize_imports()")

vim.cmd("command! LspRefs lua vim.lsp.buf.references()")

vim.cmd("command! LspTypeDef lua vim.lsp.buf.type_definition()")

vim.cmd("command! LspImplementation lua vim.lsp.buf.implementation()")

vim.cmd("command! LspDiagPrev lua vim.lsp.diagnostic.goto_prev()")

vim.cmd("command! LspDiagNext lua vim.lsp.diagnostic.goto_next()")

vim.cmd(

"command! LspDiagLine lua vim.lsp.diagnostic.show_line_diagnostics()")

vim.cmd("command! LspSignatureHelp lua vim.lsp.buf.signature_help()") buf_map(bufnr, "n", "gd", ":LspDef<CR>", {silent = true})

buf_map(bufnr, "n", "gr", ":LspRename<CR>", {silent = true})

buf_map(bufnr, "n", "gR", ":LspRefs<CR>", {silent = true})

buf_map(bufnr, "n", "gy", ":LspTypeDef<CR>", {silent = true})

buf_map(bufnr, "n", "K", ":LspHover<CR>", {silent = true})

buf_map(bufnr, "n", "gs", ":LspOrganize<CR>", {silent = true})

buf_map(bufnr, "n", "[a", ":LspDiagPrev<CR>", {silent = true})

buf_map(bufnr, "n", "]a", ":LspDiagNext<CR>", {silent = true})

buf_map(bufnr, "n", "ga", ":LspCodeAction<CR>", {silent = true})

buf_map(bufnr, "n", "<Leader>a", ":LspDiagLine<CR>", {silent = true})

buf_map(bufnr, "i", "<C-x><C-x>", "<cmd> LspSignatureHelp<CR>",

{silent = true}) if client.resolved_capabilities.document_formatting then

vim.api.nvim_exec([[

augroup LspAutocommands

autocmd! * <buffer>

autocmd BufWritePost <buffer> LspFormatting

augroup END

]], true)

end

end nvim_lsp.tsserver.setup {

on_attach = function(client)

client.resolved_capabilities.document_formatting = false

on_attach(client)

end

} local filetypes = {

typescript = "eslint",

typescriptreact = "eslint",

} local linters = {

eslint = {

sourceName = "eslint",

command = "eslint_d",

rootPatterns = {".eslintrc.js", "package.json"},

debounce = 100,

args = {"--stdin", "--stdin-filename", "%filepath", "--format", "json"},

parseJson = {

errorsRoot = "[0].messages",

line = "line",

column = "column",

endLine = "endLine",

endColumn = "endColumn",

message = "${message} [${ruleId}]",

security = "severity"

},

securities = {[2] = "error", [1] = "warning"}

}

} local formatters = {

prettier = {command = "prettier", args = {"--stdin-filepath", "%filepath"}}

} local formatFiletypes = {

typescript = "prettier",

typescriptreact = "prettier"

} nvim_lsp.diagnosticls.setup {

on_attach = on_attach,

filetypes = vim.tbl_keys(filetypes),

init_options = {

filetypes = filetypes,

linters = linters,

formatters = formatters,

formatFiletypes = formatFiletypes

}

}

Open up a TypeScript file, try out a command, and you should now have proper LSP functionality, including ESLint diagnostics and formatting on save. You did it, and I’m proud of you.

Ahh, yes, that’s it.

Completion

There’s one more piece to put in place: completion. The built-in LSP provides completion through Vim’s omni completion, but it’s not exactly comfortable if you’re used to the way a modern editor handles completion.

The best completion plugin right now (and I’ll fight you if you disagree) is nvim-compe. It’s fast, easy to configure, and, crucially, supports automatic imports in TypeScript.

It pairs perfectly with vim-vsnip, a snippet plugin that uses the same snippet format as VS Code (meaning you can install the VS Code snippet extensions as Neovim plugins, though I prefer to write my own).

The following configuration for these two plugins (again, written in Lua) will get you set up with VS Code-like tab completion.

-- use .ts snippets in .tsx files

vim.g.vsnip_filetypes = {

typescriptreact = {"typescript"}

} require"compe".setup {

preselect = "always",

source = {

path = true,

buffer = true,

vsnip = true,

nvim_lsp = true,

nvim_lua = true

}

} local t = function(str)

return vim.api.nvim_replace_termcodes(str, true, true, true)

end _G.tab_complete = function()

if vim.fn.pumvisible() == 1 then

return vim.fn["compe#confirm"]()

elseif vim.fn.call("vsnip#available", {1}) == 1 then

return t("<Plug>(vsnip-expand-or-jump)")

else

return t("<Tab>")

end

end vim.api.nvim_set_keymap("i", "<Tab>", "v:lua.tab_complete()", {expr = true})

vim.api.nvim_set_keymap("s", "<Tab>", "v:lua.tab_complete()", {expr = true}) vim.api.nvim_set_keymap("i", "<C-Space>", "compe#complete()",

{expr = true, silent = true})

vim.api.nvim_set_keymap("i", "<CR>", [[compe#confirm("<CR>")]],

{expr = true, silent = true})

vim.api.nvim_set_keymap("i", "<C-e>", [[compe#close("<C-e>")]],

{expr = true, silent = true})

You’ll now see suggestions pop up automatically as you type, and pressing <Tab> or <CR> on an item will automatically import it into your file.