⚙️

Neovim の LSP を設定するための基本知識

2023/12/11に公開
1

概要

LSP は定義ジャンプやメソッド名の補完など言語ごとの編集機能をサポートするための仕組みであり、現代的なテキストエディタにとって非常に重要な機能のひとつになっています。Neovim も組み込みで LSP のサポートを備えていますが、思い通りに設定を行えるようにするためにはそれなりの知識が必要になります。

一方、LSP の設定例などについては比較的多く見つかるものの、それ以前の基本知識についてまとまった資料は意外と少ないように感じられます。
また、設定の紹介においてもプラグインの使用を前提とした設定方法の紹介が多いため

  • プラグインがないと LSP は使えないのではないかのように思いこんでしまう
  • どのプラグインをなんのために入れているのかわからなくなる

といった問題を感じることもあります。

そこで、この記事では前半でそもそも LSP とは何であるかという部分を説明し、後半では Neovim の builtin LSP を用いてプラグインなしで最低限の設定を行ってみます。
この記事のサンプルは Neovim の組み込みの機能しか用いていないのでインストールしたばかりの素の Neovim でも動かせるはずです。

検証に用いた Neovim のバージョンは 0.9.4 です。

想定する読者層

  • テキストエディタ一般について LSP がどのように動いているのか知識をつけたい (記事前半)
  • Neovim に LSP をこれから導入したい
  • Neovim の builtin LSP の機能を知りたい
  • 人の設定を参考にして LSP を利用しているが、自分に合わせた設定に変更できるよう知識をつけたい

LSP 自体の事前知識は要求しません。
Vim/Neovim についてもそれほど詳しい知識は要求しません。Lua で少し設定を書いたことがある程度の想定です。

この記事の目標

  • LSP におけるサーバー・クライアントの役割や通信内容をおおまかに理解できる
  • Neovim の builtin LSP を利用した LSP の利用方法を理解できる
  • 巷にある Neovim LSP の設定をおおよそ解読できるようになる

扱わないこと

プラグイン類については最低限の紹介はしますが、設定まで書くと内容が膨れあがるので本記事では扱わず参考記事を紹介するに留めます。
実用上は built-in の機能だけですべて設定するのは煩雑になるので上手くプラグインを利用することを検討するとよいでしょう。
プラグインを利用する場合であっても、LSP 自体の基本的な知識と builtin LSP の理解は助けになるはずです。

LSP とは

背景

エディタや IDE による自動補完や定義ジャンプといった編集支援機能はコーディングの効率や体験を向上させる上で重要な機能になっています。
しかしこのような機能を実装するのはそれなりに大変で、しかも従来は特定のエディタ専用にそれぞれ機能がつくられてきたので、あるエディタ向けに機能をつくっても他のエディタ向けにはまた一からつくりなおしという状態になってしまっていました (e.g. Vim 用のプラグインを Emacs や VSCode 用に転用できない)。
この状況だと、下図のようにエディタ × 言語の組み合せごとに拡張機能をつくることになるので作成や管理が大変です。

Language Server Protocol (LSP)

上のような問題を解決するために生みだされたのが LSP という仕組みです。
LSP を用いる場合には補完候補の生成や変数の定義箇所の検索といったエディタ実装と独立して提供できる機能を Language Server に切り出します。
一方、エディタ側は Language Server に適切なリクエストを送ったり、送られてきた情報を適切に解釈するためのクライアントを実装し、受け取った情報をもとに実際の編集機能を提供します。
このときにエディタと Language Server の間でやりとりする通信の内容を定めたものが LSP (Language Server Protocol) です。

この方式であれば特定の言語向けの機能はどのエディタで使われるのかを意識することなく Language Server として実装すればよいですし、エディタ側としても LSP の規格にのっとったクライアントをひとつ実装してしまえばあらゆる言語の Language Server が利用できることになります (下図)。

LSP の規格

LSP は Microsoft が中心となって作成したオープンな規格です。

https://microsoft.github.io/language-server-protocol/

仕様 (specification) は、GitHub 上で markdown ドキュメントとして管理されていて誰でも見ることができます。
一から全部順に読んでいくのはつらいので、気になったメソッドについてやりとりされている内容を確認したりするところから始めると良いかなと思います。

JSON RPC

LSP の規格は JSON RPC という JSON データのやりとりで request/response を行うための規格の上に構成されています。
ここでは JSON RPC についても簡単に確認しておきましょう。

まず、RPC (Remote Procedure Call) というのは外部 (remote) に実装された手続きを呼びだすことです。外部というのは物理的に離れたホストでも、同じマシン内の別のプロセスでも構いません。
JSON RPC は RPC を実現するのに必要なやりとりの記述を JSON で行うための軽量な規格です。

https://www.jsonrpc.org/specification

Specification も小さいので興味があれば全部読んでしまっても良いと思います。ここでは、specification 内に記載されている例を用いて簡易的に説明します。
以下の例は、リモートに実装された引き算 (subtract) の手続きを JSON RPC で呼び出したいときに送る JSON の例です。

# Request
{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
  • jsonrpc: JSON RPC specification の番号を指定
  • method: 呼び出したい手続き(メソッド)の名前を指定
  • params: method に渡す引数を指定
  • id: リクエストの id (送信順など)

id は response がどの request に対応しているか判断するために必要になります。

# Response
{"jsonrpc": "2.0", "result": 19, "id": 1}
  • jsonrpc: JSON RPC specification の番号を指定 (request と同じ)
  • result: メソッドの実行結果
  • id: 対応するリクエストの id

これらの例から JSON RPC がどんなものかだいたいイメージがついたのではないかと思います。
paramsresult はもっと複雑な object 等を扱うこともできますが、例としてはとりあえず以上としておきます。

上は request/response のやりとりが発生する例でしたが、一方的に通知を送る notification と呼ばれるものもあります。
notification の場合は response が不要なので id も付与されません。

LSP の動作

それではいよいよ LSP で実際にどのような通信が行われるのか見てみましょう。
おおまかにいえば LSP の機能を用いた編集は次のようなライフサイクルで行われます。

  1. Language server およびクライアントを(典型的にはエディタが)起動
  2. 初期化 (initialize) (クライアントおよび server がサポートする操作に関する情報 (capability) の交換など)
  3. ファイル編集開始の通知 (textDocument/didOpen)
  4. ファイル変更の同期 (textDocument/didChange)
  5. 言語サービスの提供 (textDocument/definition, textDocument/publishDiagnostics など, 4,5 は相互に繰り返し)
  6. ファイル編集終了の通知 (textDocument/didClose)
  7. クライアント、サーバーの停止

initializetextDocument/* はすべて LSP で用いられる RPC のメソッド名です。
以下の図はクライアントとサーバーの間の通信を図示したものです[1]

コードの状態は都度編集されてディスク上に保存された状態からは離れたものになっていくので、サーバーは適切な解析情報を提供するため編集中の状態をエディタと同期する必要があります。
そのため、クライアントはユーザーがコードに対して行った変更を都度通知し、サーバーはその情報をもとにコードの現在の状態を再現します。

そして、サーバーは現在のコードに対するエラーなどの診断情報 (diagnostics) を通知したり、ユーザーの操作に従ってクライアントから送信された定義ジャンプ (textDocument/definition) などの request に答えたりします。

LSP 自体は言語非依存なプロトコルである必要があるため、テキストの変更内容の通知や現在のカーソル位置、ジャンプ先の位置などは基本的に何行何列目 (行,列とも 0-indexed) という数字の情報でやりとりされます。

具体例

LSP の動作をより具体的にイメージするため簡単なコードで実際の通信内容を見てみましょう。次の Lua コードを例として使います。

local foo = 1

print(foo)

3行目での "f" の位置で textDocument/definition のリクエストを送った場合の実際に送受信される JSON の内容の一例を示します。

request/response の JSON の中身 (やや長いので折り畳み)
  • Request
{
  "jsonrpc": "2.0",
  "id": 30,
  "method": "textDocument/definition",
  "params": {
    "position": {
      "line": 2
      "character": 6,
    },
    "textDocument": {
      "uri": "file:///Users/sankantsu/code/nvim/builtin-lsp/minimum-sample.lua"
    }
  }
}
  • Response
{
  "jsonrpc": "2.0",
  "id": 30,
  "result": [
    {
      "targetUri": "file:///Users/sankantsu/code/nvim/builtin-lsp/minimum-sample.lua",
      "originSelectionRange": {
        "start": {
          "line": 2,
          "character": 6
        },
        "end": {
          "line": 2,
          "character": 9
        }
      },
      "targetSelectionRange": {
        "start": {
          "line": 0,
          "character": 6
        },
        "end": {
          "line": 0,
          "character": 9
        }
      },
      "targetRange": {
        "start": {
          "line": 0,
          "character": 6
        },
        "end": {
          "line": 0,
          "character": 9
        }
      }
    }
  ]
}

Request で注目してほしいのは "method": "textDocument/definition""position": { "line": 2, "character": 6 } の部分です。
これでクライアントが (1-indexed で) 3行7列目の場所で textDocument/definition のリクエストを送ったということがわかります。

Response で注目してほしいのは resulttargetRange です。

"targetRange": {
  "start": {
    "line": 0,
    "character": 6
  },
  "end": {
    "line": 0,
    "character": 9
  }
}

これは foo の定義箇所が (1-indexed で) 1行目の7-9文字目の範囲であることを示しています (end は exclusive)。
このようにクライアントが送るのはカーソルの "点" のみですが、サーバーは変数のようなまとまりを認識して範囲で返しているのがわかると思います。

Neovim で LSP を使う

以上で LSP のおおまかな仕組みについて説明しました。
ここからは、Neovim における LSP 事情と builtin LSP の基本的な設定方法について見ていきます。
より詳しい説明については :h lsp を読んでみてください。

LSP クライアントの種類

LSP クライアントは単に JSON RPC でサーバーとテキスト情報をやりとりするものでしたから、外部プロセスと通信できる仕組みや、サーバーから受け取った情報をエディタ上の操作として反映できる仕組みがあれば実装することができます。
そのため、Neovim においても builtin の実装の他にもプラグインとして複数 LSP クライアントの実装が存在します。
例えば、有名どころとして

があります。
特に coc.nvim は単なる LSP クライアントではなく nodeJS を利用した独自のエコシステムを構成しており、VSCode like な使用感を手軽に実現できるものであるようです (自身は試したことがありません)。
また、Neovim の builtin LSP についてもエディタのコア部分に実装されているというわけではなく runtime の lua ライブラリとして実装されているものであり、性質としてはプラグインとそれほど大きな違いがあるわけではありません。

以降では Neovim の builtin LSP を使って説明していきます。
他の LSP クライアントでも実装しているプロトコルは同じなわけなので、多かれ少なかれ似たような機能を提供しているのではないかと思います。

サーバーとクライアントの起動

まずはサーバーとクライアントが起動できないと何も始まらないですね。
ここでは起動用のお手軽な API として vim.lsp.start() を使ってみましょう。
例として lua-language-server を起動してみます[2]

-- launch-test.lua
vim.lsp.start({
  name = "lua_ls", -- 管理上の名前
  cmd = { "lua-language-server" }, -- Language server を起動するためのコマンド
  root_dir = vim.fs.dirname(vim.fs.find({ ".luarc.json" }, { upward = true })[1]), -- プロジェクトのルートディレクトリを検索する
})

nvim -u launch-test.lua launch-test.lua などとして Neovim を起動すると lua-language-server が起動されて diagnostics の表示が出るのが確認できると思います。

Language server の機能を利用する

Language server に接続するだけでもデフォルトで diagnostics が出るようになるので有用ではありますが、せっかく LSP を利用するのであれば定義ジャンプ等の便利な機能を利用したくなります。
これらの機能の多くは vim.lsp.buf というモジュールで提供されています。例えば、

API メソッド名 説明
vim.lsp.buf.definition() textDocument/definition カーソル下の symbol の定義箇所にジャンプ
vim.lsp.buf.references() textDocument/references カーソル下の symbol の参照箇所の一覧表示・ジャンプ
vim.lsp.buf.rename() textDocument/rename カーソル下の symbol の一括リネーム
vim.lsp.buf.hover() textDocument/hover カーソル下の symbol のドキュメント等の表示

といった具合です。
対象としたい変数にカーソルを合わせてから :lua vim.lsp.buf.definition() などとして実行すれば各機能を利用できます。毎回これを打ちこむのは面倒なのでキーマップを作成したほうが良いでしょう。例えば

vim.keymap.set('n', 'gd', '<cmd>:lua vim.lsp.buf.definition()<CR>')

のようにして設定できます。

autocmd

Vim/Neovim では特定の event をトリガにして事前に登録しておいた関数を実行できる autocmd という機能があります。autocmd を利用することで LSP の設定も適切なタイミングで行うことができます。

先程は vim.lsp.start() を設定ファイルのトップレベルで使って language server を起動しました。しかし、このままだと lua 以外の言語のときも lua-language-server が立ち上がってしまいます。関係ない言語で勝手に language server が起動されてしまうのは避けたいですが、一方 lua のファイルを開いたときは自動的に lua-language-server が起動してほしいです。
このようなときは FileType イベントによって language server の起動が発火するように設定すると便利です。以下が設定例になります。

vim.api.nvim_create_autocmd("FileType", {
  desc = "launch lua-language-server",
  pattern = "lua",
  callback = function()
    vim.lsp.start({
      name = "lua_ls",
      cmd = { "lua-language-server" },
      root_dir = vim.fs.dirname(vim.fs.find({ ".luarc.json" }, { upward = true })[1]),
    })
  end
})

LSP 関連のキーマッピングも language server に接続したときだけ設定されると良いでしょう。こちらは言語やサーバーの種類は関係なく language server に接続されたときに設定が行われると良いです。このユースケースで便利なのは LspAttach という event です。以下は LspAttach を用いた設定例です。

vim.api.nvim_create_autocmd("LspAttach", {
  desc = "Attach key mappings for LSP functionalities",
  callback = function ()
    vim.keymap.set('n', 'gd', '<cmd>:lua vim.lsp.buf.definition()<CR>')
    vim.keymap.set('n', 'gr', '<cmd>:lua vim.lsp.buf.references()<CR>')
    -- more mappings ...
  end
})

lsp-handlers

おおよそここまでで LSP の機能を使うための最低限の設定はできていますが、各メソッドに関するふるまいを変更する手段も少し確認しておきましょう。

各メソッドについて response がサーバーから返ってきたとき Neovim がその結果をどのようにハンドルするかは、vim.lsp.handlers という global なテーブルで管理されています。例えば textDocument/definition の handler は vim.lsp.handlers["textDocument/definition"] に登録されています。デフォルトの handler は $VIMRUNTIME/lua/vim/lsp/handlers.lua から確認できます。

vim.lsp.handlers を設定することで各メソッドに対するふるまいを変更することができます。比較的変更されることが多いのは diagnostics の表示オプションでしょう。diagnostics の表示は textDocument/publishDiagnostics に対する handler として実現されているのでこれを変更します。変更する際もデフォルトの handler である vim.lsp.diagnostic.on_publish_diagnostics をベースに組みたてます。以下は diagnostics について virtual text の表示 (行の右側に出てくるエラー表示) を出さなくする例です。vim.lsp.with() は単に config を拡張して渡すようにした handler をつくるためのユーティリティです(実装)。

vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with(
  vim.lsp.diagnostic.on_publish_diagnostics, { virtual_text = false }
)

diagnostics に関しては、:h vim.diagnostic.config() から設定可能な項目を確認できます。

置き換え先の handler は vim.lsp.with() でつくったものに限らず完全にカスタムにすることもできます。あまり実用的な例ではないですが、次のようにすると textDocument/definition で定義ジャンプをするかわりにサーバーから返ってきたカーソル位置情報を含む生データをダンプします。デバッグ等には役立つこともあるかもしれません。

vim.lsp.handlers["textDocument/definition"] = function (_, result)
  print(vim.inspect(result))
end

また、サーバーごとに異なる handler 設定をしたい場合には vim.lsp.start()vim.lsp.start_client() の引数で handler を設定することで実現できます。nvim-lspconfig のようなプラグインによる設定インターフェースを利用する場合も同様のことが可能です。

低水準の機能

vim.lsp.buf_request() という API を使うと、じかに

  • メソッド名
  • サーバーに送る引数
  • response に対する handler

を指定して RPC request を送ることができます。
これを用いることで LSP の機能を活用した独自のコマンドなどを作成することができます。

たとえば telescope.nvim の LSP 系の builtin picker などはこの機能を使ってつくられているようです。
LSP から得られる情報を使って自作の機能を作ったりしてみたい場合は使ってみるとよいでしょう。

ログ機能

LSP に関するエラーや通信内容はログが残されています。
ログファイルの場所は :lua print(vim.lsp.get_log_path()) などで取得できます。
どのレベルまでログを記録するかは :lua vim.lsp.set_log_level(vim.log.levels.DEBUG) のように設定することができます。
DEBUG レベルまで記録すると生の JSON RPC のやりとりなどをログとして見れるようになるのでデバッグや学習用に便利です。

プラグインの最低限の紹介

最後に LSP の設定によく使われるプラグインをごく簡単に紹介しておきます。
詳しい設定方法や使用方法は README, ヘルプや他記事をご参照ください。

nvim-lspconfig

LSP の設定をするならだいたい使われるプラグインです。
以下のような機能を提供します。

  • vim.lsp API 呼出のラッピング
  • utility command の提供 (e.g. LspInfo, LspStart, LspLog)
  • サーバーの種類ごとのデフォルト設定の提供

基本サーバーの起動などは nvim-lspconfig がラップしてくれるので nvim-lspconfig を用いる場合にはあまり書かなくなります。
また、使う言語が多くなるとどうしても LSP 関連の設定量が多くなるのでサーバーごとのデフォルト設定はうまく活用すると良いでしょう。気にいらない部分は上書きすることも可能です。

mason.nvim

Language server, formatter, linter 等のインストーラです。
Language server もひとつひとつ自前でインストールしてくるのは大変なので使う場合が多いです。どんな language server が存在しているのか眺めるギャラリーとして使うのもアリかもしれません。

mason-lspconfig.nvim

mason.nvim でインストールした language server の設定を行うための補助プラグインです。
以下のような機能を提供します。

  • Language server の自動インストール
  • インストール済みの全てのサーバーの自動 setup

ひとつひとつの language server の setup を列挙しなくてもまとめて setup できるのは便利です。

おわりに

今回は LSP 自体の基本的な知識と、Neovim の builtin LSP クライアントの設定方法を紹介しました。今回紹介した設定を直接使うことは多くないかもしれまんが、設定する際の知識のベースとして助けになれば幸いに思います。

LSP を利用した機能にはほかにも自動補完やパンくずリスト (どの関数の中にいるかなどを表示する) など様々な強力なものがあります。筆者もまだいろいろ試行錯誤中ではありますが、ぜひ LSP を使いこなして快適な Neovim ライフを手に入れてください!

参考記事・ドキュメントの紹介

LSP の公式ドキュメントです。
Overview はあまり長くないのでこれだけでも読んでおくのがおすすめです。

https://microsoft.github.io/language-server-protocol/

Neovim の LSP の設定に使われる各プラグインの役割の違いがまとまった記事です。
どのプラグインをなんのために入れなければいけないかわからなくなった人におすすめです。

https://zenn.dev/futsuuu/articles/3b74a8acec166e

Neovim の builtin LSP に 0.8 以降で追加された機能の解説です。
この記事で用いた設定例のベースにもなっています。

https://zenn.dev/ryoppippi/articles/8aeedded34c914

本記事より充実した LSP の設定例です。
補完機能 nvim-cmp の設定なども行っています。

https://zenn.dev/nazo6/articles/c2f16b07798bab

https://zenn.dev/botamotch/articles/21073d78bc68bf

脚注
  1. https://microsoft.github.io/language-server-protocol/overviews/lsp/overview/ より引用 ↩︎

  2. lua-language-server は事前にインストールしてPATHを通しておいてください。mason.nvim でインストールした場合などはシステムのPATHからは外れるので手動で通す必要があります。mason.nvim でインストールした場合は ~/.local/share/nvim/mason/bin/ などに置かれていることが多いと思います。 ↩︎

GitHubで編集を提案

Discussion

鷹勇鷹勇

大変有益な記事でした。
LSPについての概観や具体的なRPCの内容を通して、理解が深まりました。
臆せず、LSP周りの設定をしたり、APIを弄ったりすることができるようになりました。
ありがとうございました。

ただ、現在だと、当記事にあるlsp.start(...)のみの設定ではうまく動かないようです。
具体的には、definition jump や、renameが期待通りに動かないです。
vim.lsp.buf 系のAPIを実行しても、なにもリアクションがないといった具合でした。
vim.lsp クライアント側のログを見たところRPCリクエストをvim.lsp.buf.definition()APIで送れていないようでした。

# 検証したnvimのバージョン
nvim -v
NVIM v0.11.0-dev-684+g53af02adb
Build type: RelWithDebInfo
LuaJIT 2.1.1724512491
Run "nvim -V1 -v" for more info

試しに、nvim-lspconfig拡張をインストールしてみたところ、vim.lsp.buf.definition()APIなどが期待通りに動作するようになりました。(lspクライアントの送受信のログも期待通りのものが確認できました。)
バージョンによるギャップがneovim側にあるのかなぁ...といった所感です。
共有をさせていただきました。

ありがとうございました。