はじめに
最近Neovimを使い始めまして、その際にLSPの設定を行いました。VSCodeでは標準で実装されているLSPですが、自分で1から設定するとなるとなかなかに大変だったので、苦労を忘れないために記事に残しておこうと思います。LSPは一度設定するとしばらく触ることのないので、アウトプットしておかないと忘れそうですし。
LSPについて
LSPとは
LSP=Language Server Protocolの略称です。
Languageはプログラミング言語のことを指しています。各種プログラミング言語に対応したサーバーをLanguage Serverと呼び、そのサーバーと通信する際のプロトコルをLSPといいます。LSPに従って通信するクライアントはエディタ・IDEです(Vim, VSCode, IntelliJなど)。
例えば、VSCodeでindex.tsファイルを編集するとTypeScriptの文法に応じた自動補完やシンタックスエラーの表示や関数の定義ジャンプなどがデフォルトの機能として動作しますが、これらの機能はVSCodeとTypeScript用のLanguage ServerとのLSPに基づく通信がバックグラウンドで実行されていることによって実現しています。
また、LSPはMicrosoftが開発しておりJSON-RPCベースで動作します。
余談:JSON-RPCとは
- RPC
- Remote Procedure Call
- リモートサーバーで定義された手続き(procedure)をクライアントから実行するプロトコル
- JSON-RPC
- その名の通り、JSONを用いたRPC
- procedureの実行指示をJSON形式で記述してリモートサーバーに送信する。実行結果もJSONで返却される
- 例:足し算を実行するaddというprocedureを実行するサンプルは以下の通り
{
"jsonrpc": "2.0",
"method": "add",
"params": [5, 3],
"id": 1
}
{
"jsonrpc": "2.0",
"result": 8,
"id": 1
}
あわせて読みたい↓
Neovim built-in LSP
ではNeovimにおけるLSPはどうなっているのかというと、NeovimはデフォルトでLSPクライアントとしての機能を持っています。つまりプラグイン無しでもLSPの機能を利用することができます。
https://neovim.io/doc/user/lsp.html
Nvim supports the Language Server Protocol (LSP), which means it acts as a client to LSP servers and includes a Lua framework vim.lsp for building enhanced LSP tools.
自分はNeovim v0.11から使い始めた民なので以前のバージョンのことは想像でしか話せませんが、過去のNeovimバージョンではプラグインを用いてLSPを利用する方法がデファクトのようでした。ただ、v0.11の前後でLSPに関する設定ファイルの書き方や設定ファイルの配置の仕方が変化しており、ネットや生成AIで調べる際はどのバージョンに則った説明をしているかを注意深く確認する必要があります。(ググってHITする記事は大体0.11以前の書き方を紹介していることが多い印象です)
NeovimにおけるLSPの設定
私のNeovimの設定ファイルはこちらのリポジトリで管理しています。(LSPとは関係ないファイルも混ざっていますが)
LSPに関連するファイルはこの辺りです↓
※LSPに関係のあるファイルだけ抜粋
├── init.lua
└── lua
└── lsp
├── init.lua
└── ts_ls.lua
設定ファイルの全体像
.
├── init.lua
├── lazy-lock.json
└── lua
├── config
│ └── lazy.lua
├── lsp
│ ├── init.lua
│ ├── lua_ls.lua
│ ├── rust-analyzer.lua
│ ├── tailwindcss.lua
│ └── ts_ls.lua
└── plugins
├── autopairs.lua
├── bufferline.lua
├── coc.lua
├── comment.lua
├── gitsigns.lua
├── lualine.lua
├── neoscroll.lua
├── nvim-tree.lua
├── nvim-web-devicons.lua
├── telescope.lua
├── treesitter.lua
└── vim-illuminate.lua
- init.lua・・・Neovim自体の初期設定
- lsp/init.lua・・・LSPの初期設定
- lsp/ts_ls.lua・・・各言語のLanguage Serverの設定
LSPの導入手順
LSPの導入手順をおおまかに説明します。以降の手順ではTypeScriptのLSPを設定する例を書きます。
1. lsp/init.luaの作成
まずは各言語のLSPで共通する設定を記述するためのlsp/init.luaというファイルを作成します。
後述するLspAttach
の設定をここに書きます。
https://github.com/tttol/dotfiles/blob/main/nvim/lua/lsp/init.lua
2. lsp/ts_ls.luaの作成
TypeScript向けの設定を書くためにlsp/ts_ls.luaというファイルを作成します。
このファイルは以下のnvim-lspconfigというリポジトリから拝借したものです。
このリポジトリにはTypeScript以外にもさまざまな言語向けのファイルがPUSHされています。特にこだわりがない場合はここにあるファイルをそのまま利用するのが良さそうです。
nvim-lspconfigのlsp/配下のファイルを動的に取得する方法もあるようです。が、私はまだ試せてません。
3. init.luaでlsp/init.luaを読み込む
init.luaに以下の記述を追記します。これにより、lsp/init.luaの内容をNeovimが読み込んでくれるようになります。
require('lsp')
4. TypeScript向けのLanguage Serverのインストール
Neovimの設定ファイルの準備ができたら、最後にLanguage Serverをインストールします。TypeScriptの場合は以下のコマンドでインストールが可能です。
npm install -g typescript typescript-language-server
Language Serverのインストール方法はnvim-lspconfigのlsp/配下のファイルのコメント部分に記載されていることが多いです。ts_ls.luaのコメントにも以下の通り記述があります。
--- `typescript-language-server` depends on `typescript`. Both packages can be installed via `npm`:
--- ```sh
--- npm install -g typescript typescript-language-server
--- ```
手順4まで実施したのち、Neovimを再起動して任意の.tsファイルをNeovimで開くと定義ジャンプや自動補完が動作するはずです。
各種記載の解説
先ほどの導入手順の中で作成したlsp/init.luaの中身を詳しく見ていきます。
LspAttach
NeovimとLanguage Serverの接続が確立されたタイミングで、LspAttachが実行されます。LspAttachでは各言語で共通する設定を記述しています。(言語ごとの個別の設定もLspAttachで記述可能です)
具体的には以下のように記述します。
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
-- (略)
end
})
autocmdとは
特定のイベントが発生した時に自動で実行されるコマンドのことです。autocmdはLSP以外の文脈でも使われているVimの機能です。
on_attach vs LspAttach
LspAttachとは別にon_attachというトリガーも存在します。両者はLSPクライアントがLanguage Serverに接続された時に実行されるという点では同じですが、実装方法が異なります。on_attachは各言語ごとに設定を記述する必要がありますが、LspAttachは一箇所に記述するだけでOKです。
-- 各LSPサーバーの設定時に個別に指定
local my_on_attach = function(client, bufnr)
-- 設定
end
-- サーバーごとに指定が必要
require('lspconfig').tsserver.setup({
on_attach = my_on_attach, -- ← ここで指定
})
require('lspconfig').pyright.setup({
on_attach = my_on_attach, -- ← ここでも指定
})
require('lspconfig').rust_analyzer.setup({
on_attach = my_on_attach, -- ← ここでも指定
})
-- 一度定義すれば、全てのLSP接続に自動適用
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
-- この設定が全LSPサーバーに自動適用される
end,
})
他にも違いはあります。詳細は以下のリンクが参考になります。
https://zenn.dev/ryoppippi/articles/8aeedded34c914
※自分はLspAttachをメインで使っているのでon_attachの上手い使い方をわかっておらず…
自動補完
自動補完とは文字を打った後に自動で入力候補を表示する機能のことです。
公式リファレンスに記載があるので、この通りに設定していけばOKです。
https://neovim.io/doc/user/lsp.html#_lua-module:-vim.lsp.completion
ただし、-- Optional: trigger autocompletion on EVERY keypress. May be slow!
の部分のコメントアウトは外す必要があります。
lsp/init.lua
vim.api.nvim_create_autocmd('LspAttach', {
group = vim.api.nvim_create_augroup('my.lsp', {}),
callback = function(args)
local client = assert(vim.lsp.get_client_by_id(args.data.client_id))
if client:supports_method('textDocument/completion') then
if client.server_capabilities.completionProvider then
-- Optional: trigger autocompletion on EVERY keypress. May be slow!
local chars = {}; for i = 32, 126 do table.insert(chars, string.char(i)) end
client.server_capabilities.completionProvider.triggerCharacters = chars
-- Extend existing trigger characters
local existing_chars = client.server_capabilities.completionProvider.triggerCharacters or {}
local additional_chars = { '.', ':', '->', '::', '(', '[', '{', ' ' }
for _, char in ipairs(additional_chars) do
table.insert(existing_chars, char)
end
client.server_capabilities.completionProvider.triggerCharacters = existing_chars
end
vim.lsp.completion.enable(true, client.id, args.buf, {
autotrigger = true,
convert = function(item)
return { abbr = item.label:gsub('%b()', '') }
end,
})
end
end,
})
キー配置の変更
コーディングをする上でよく利用する機能に対してキーマップを設定します。
例えば、
- Go Definition(定義ジャンプ)=gd
- Go Reference(参照)=gr
などです。
lsp/init.lua
-- LSP attach autocmd for common configuration
vim.api.nvim_create_autocmd('LspAttach', {
group = vim.api.nvim_create_augroup('my.lsp', {}),
callback = function(args)
local client = assert(vim.lsp.get_client_by_id(args.data.client_id))
-- Common keymaps
local opts = { buffer = args.buf, noremap = true, silent = true }
vim.keymap.set("n", "<leader>e", vim.diagnostic.open_float,
vim.tbl_extend("force", opts, { desc = "Show diagnostics" }))
vim.keymap.set("n", "gd", vim.lsp.buf.definition, vim.tbl_extend("force", opts, { desc = "Jump to definition" }))
vim.keymap.set("n", "gD", vim.lsp.buf.declaration,
vim.tbl_extend("force", opts, { desc = "Jump to declaration" }))
vim.keymap.set("n", "gi", vim.lsp.buf.implementation,
vim.tbl_extend("force", opts, { desc = "Jump to implementation" }))
vim.keymap.set("n", "gr", vim.lsp.buf.references, vim.tbl_extend("force", opts, { desc = "Show references" }))
vim.keymap.set("n", "gt", vim.lsp.buf.type_definition,
vim.tbl_extend("force", opts, { desc = "Jump to type definition" }))
vim.keymap.set("n", "<leader>rn", vim.lsp.buf.rename, vim.tbl_extend("force", opts, { desc = "Rename symbol" }))
vim.keymap.set({ "n", "v" }, "<leader>ca", vim.lsp.buf.code_action,
vim.tbl_extend("force", opts, { desc = "Code action" }))
-- Tab completion keymaps
vim.keymap.set("i", "<Tab>", function()
if vim.fn.pumvisible() == 1 then
return "<C-n>"
else
return "<Tab>"
end
end, vim.tbl_extend("force", opts, { expr = true, desc = "Tab completion" }))
vim.keymap.set("i", "<S-Tab>", function()
if vim.fn.pumvisible() == 1 then
return "<C-p>"
else
return "<S-Tab>"
end
end, vim.tbl_extend("force", opts, { expr = true, desc = "Shift-Tab completion" }))
vim.keymap.set("i", "<CR>", function()
if vim.fn.pumvisible() == 1 then
return "<C-y>"
else
return "<CR>"
end
end, vim.tbl_extend("force", opts, { expr = true, desc = "Enter completion confirm" }))
vim.opt_local.pumheight = 10 -- Limit popup menu height
end,
})
-- Force override Tab completion for all filetypes after all plugins load
vim.api.nvim_create_autocmd('FileType', {
pattern = '*',
callback = function(args)
vim.schedule(function()
local opts = { buffer = args.buf, noremap = true, silent = true }
vim.keymap.set("i", "<Tab>", function()
if vim.fn.pumvisible() == 1 then
return "<C-n>"
else
return "<Tab>"
end
end, vim.tbl_extend("force", opts, { expr = true, desc = "LSP Tab completion override" }))
vim.keymap.set("i", "<S-Tab>", function()
if vim.fn.pumvisible() == 1 then
return "<C-p>"
else
return "<S-Tab>"
end
end, vim.tbl_extend("force", opts, { expr = true, desc = "LSP Shift-Tab completion override" }))
-- vim.keymap.set("i", "<CR>", function()
-- if vim.fn.pumvisible() == 1 then
-- return "<C-y>"
-- else
-- return "<CR>"
-- end
-- end, vim.tbl_extend("force", opts, { expr = true, desc = "LSP Enter completion override" }))
end)
end,
})
最後の-- Force override Tab completion for all filetypes after all plugins load
以降のところはちょっと力技だと思っていて、もっとスマートな書き方があるかなと思ってます…。自動補完で表示された候補を決定する際のキーマップを変更したくて、をTabに、をShift+Tabに、をEnterに上書きしています。ただ、設定の読み込み順序の問題なのかLspAttachに記載しても上書きされなかったので、lsp/init.luaの末尾にFileTypeのautocmd記載することで無理やり上書きを行なっています。
さいごに
自分のNeovimの設定ファイルはまだまだ発展途上で不完全なものなので、今後も改修していく予定です。この記事がこれからNeovimやLSPに入門する人の参考になれば幸いです。
参考