このページの目次

LSP と Language Server

IDE の補完、ジャンプ、ホバー時の型表示、リファクタリング——これらのコードインテリジェンスはすべて Language Server から提供されます。Language Server は本質的に「コンパイラフロントエンド+増分更新+エラー許容パーサ」です。LSP は、字句・構文・シンボルテーブルの能力を JSON-RPC 経由でエディタに公開します。

概要

IDE のコードインテリジェンス——補完、定義へのジャンプ、ホバー時の型表示、リファクタリング——は、従来、各 IDE が対応する言語ごとに独自の解析ロジック(M × N)を実装していました。LSP(Language Server Protocol)は、MCP と同じ戦略を用いてこの問題を解決します。⁠各言語に対して 1 つの Language Server プロセスを提供し、IDE は JSON-RPC を通じてそれと通信するのです。コンパイラ技術の観点から見ると、LSP はコンパイラフロントエンド(字句・構文・意味解析・シンボルテーブル)の直接的な消費者であり、Language Server は本質的に「コンパイラフロントエンド+増分更新エンジン」です。ここでは、LSP プロトコルの主要なリクエストタイプ、Language Server が内部で前述のコンパイラフロントエンド技術を用いてインデックスを構築し応答する方法、そして「十分に速いこと」が「十分に正確なこと」よりも難しい理由について解説します。

LSP はコンパイラではなく、ターゲットを少し変えたコンパイラフロントエンド

コンパイラが求めるもの:ソースコードから機械語を生成し、正しさを最優先とし、バッチ処理でファイル群を処理する。 Language Server が求めるもの:ユーザーが1文字入力するたびに <100ms 以内で補完・診断・ハイライトを更新し、⁠未完成またはエラーのあるコードに対しても合理的な結果を出力し、変更された部分のみを再計算する。

この後者の要件により、Language Server はコンパイラパイプライン全体をそのまま実行することはできません。⁠増分更新が必要であり、かつ構文エラーを許容する必要があります(ユーザーが途中まで記述している場合、ソースコードには必ず構文エラーが含まれるため)。

主要なリクエストタイプ:すべてシンボルテーブルと AST のクエリ

LSP におけるすべてのリクエストは、コンパイラフロントエンドのデータ構造に対するクエリです。

リクエスト対応するコンパイラフロントエンドクエリ内容
textDocument/completionシンボルテーブル + 型システム現在のスコープ内で、どのような名前が見えるか?それぞれの型とドキュメントは?
textDocument/definitionシンボルテーブルこの識別子の定義はどの行にあるか?
textDocument/referencesシンボルテーブル(逆引き)この定義はどの場所で参照されているか?
textDocument/hoverシンボルテーブル + 型システムこの識別子の型は何?ドキュメントコメントには何が書かれているか?
textDocument/signatureHelp型システム現在関数の引数リストとオーバーロードは?現在の引数は何番目か?
textDocument/renameシンボルテーブル(全参照)この定義へのすべての参照の名前を新しい名前に変更する
textDocument/publishDiagnostics意味解析(型チェック)このファイルにはどのような型エラーや未使用変数があるか?

すべての機能は シンボルテーブル に由来します。 definition はシンボルテーブルの1回のルックアップ、references はシンボルの使用リスト、completion は現在のスコープにおけるシンボルテーブルの列挙、hover はシンボルの型とドキュメント文字列です。もしシンボルテーブルが「この名前がどこで参照されているか」という逆引きインデックスを保持していなければ、referencesrename は効率的に行えません。そのため、シンボルテーブルの構築時には双方向の(定義から参照へ、参照から定義へ)データ構造を用います。

インデックス:ファイルを開く前に、そのファイルが何をエクスポートしているかを知る

大規模プロジェクトでは数千ものファイルが存在し、ユーザーが main.rs を開いた時点で、Language Server がプロジェクト全体を解析している余裕はありません。そのため、⁠永続化されたインデックスが必要です。

永続化インデックス:起動時の読み込み、増分更新、オンデマンドクエリ 起動 ① 既存インデックスの読み込み 前回の保存結果から 逆シリアライズして読み込み ② 増分更新 前回の終了以降に 変更があったファイルのみ再解析 ③ クエリ応答 新しいファイルを開く:外部シンボルを検索 メモリ上にのみそのファイルを解析 インデックスに保存されるもの エクスポートシンボル 各ファイルがエクスポート (pub) するシンボル:名前、位置、型シグネチャ、ドキュメント インポートリスト 各ファイルのインポート (use/import) リスト。「誰がそのシンボルを参照しているか」を高速に検索可能 依存関係グラフ シンボル依存関係グラフ:A が B を呼び出す → A の所在ファイルは B の定義ファイルに依存 インデックスにより、Language Server はファイルを開くたびにプロジェクト全体を解析する必要がなくなります。変更があったファイルのみ増分更新し、 影響を受けないファイルはテーブル参照のみで対応するため、これが <100ms での応答実現の鍵となります。

rust-analyzer は salsa(増分計算フレームワーク)を用いてこのインデックスを管理しており、変更が生じた場合でも影響を受けたファイルのみを再計算し、影響のないファイルはキャッシュから直接取得します。これが IDE の応答を <100ms に維持できる理由です——全量再計算ではなく、増分更新を行っているためです。

エラー許容パーサ:構文エラーのある入力に対して結果を出力する

ユーザーが入力している間(let x = some_struct. など)、ソースコードは必然的に不完全です。パーサは some_struct. の後のドットを見て、フィールド名を期待しますが EOF(ファイル終端)に遭遇します。コンパイラはこの段階でエラーを出力して処理を停止します。しかし Language Server は停止できず、エラーから回復して . の後に続く可能性のあるフィールドの補完を出力しなければなりません⁠。

これには、AST 設計とエラー回復 で述べられたエラー回復の仕組みが必要です。不完全な式を見つけたパーサは、依然としてその式の AST ノードを構築し(エラーとしてマーク)、上位の意味解析はこの「エラーを含む AST」に対して部分的な型推論を行います。some_struct の型情報からフィールドリストを取得し、補完候補として返します。

ユーザー入力: let x = some_struct.
パーサ出力: Expr::Field { object: "some_struct", field: <Error> }
意味解析: シンボルテーブルから some_struct の型を参照 → StructFoo { a: i32, b: String }
補完: [a: i32, b: String]

エラー回復がなければ補完は存在しません——これら2つは Language Server において一対の機能です。区別できるのは「現在のトークンが完全な式である場合」と「現在のトークンが構文エラーである場合」だけであり、後者の場合、パーサが出力する AST ノードに「フィールドが欠落している」ことを示すフラグを設けることで、意味解析側がユーザーがその位置での補完を期待していることを知ることができます。

Semantic Tokens: 構文ハイライトの別の実装方法

従来のハイライトは正規表現マッチ(Treesitter の highlights.scm)を用いていましたが、正規表現には意味の知識がありません。foolet foo = ... 内では変数定義、foo() 内では関数呼び出し、fn foo() 内では関数宣言です。LSP の textDocument/semanticTokens/full は、⁠各トークンに意味カテゴリ(variable、function、keyword、type、comment...)を割り当て⁠、IDE がそのカテゴリに基づいて色付けを行います。

Language Server は AST とシンボルテーブルを用いて、各トークンに意味カテゴリを割り当てます。これには、レキサが出力するトークンが十分な情報を持っていること(識別子か?シンボルテーブルの逆引きで変数か関数か型か?)か、あるいは意味解析によって追加で注釈が付けられる必要があります。

アーキテクチャ:シングルスレッド、マルチスレッド、増分スケジューリング

産業用の Language Server(rust-analyzer、clangd)の典型的なアーキテクチャは以下の通りです。

Language Server アーキテクチャ:シングルスレッド、マルチスレッド、増分スケジューリング メインスレッド (IO) JSON-RPC リクエストの受信 → スケジューラへ送信 スケジューラ リクエストキュー、同一ファイルのリクエストマージ(デバウンス) → ワーカースレッドへ割り当て ワーカースレッド ファイルの解析 → インデックスの更新 → 応答の生成 デバウンス (debounce) ユーザーが 500ms 以内に連続して10文字入力した場合、 1文字ごとに再解析せず、50ms の入力停止後に解析を実行 キャンセル (cancellation) 前回の解析が完了する前に新しい入力があった場合、 $/cancelRequest を用いて前回の作業を破棄 デバウンスは「連続した入力」を「最後の入力の後にのみ解析」にまとめ、キャンセルは古くなった中間結果を破棄します—— これらが安定して <100ms の応答を実現する鍵であり、全量再計算ではなく、必要な1回のみを実行しています。

デバウンスとキャンセルはパフォーマンスの生命線です。ユーザーが高速に入力している間、on_type の頻度は1文字あたり約100ms です。もし1文字ごとにファイル全体を全量解析すると、Language Server は確実にフリーズします。デバウンスは「連続した N 回の入力」を「最後の入力の後に解析」にまとめ、キャンセルは古くなった中間結果を破棄します。

参考

  • LSP 3.17 specification: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
  • rust-analyzer: https://github.com/rust-lang/rust-analyzer — Rust LSP サーバーのアーキテクチャドキュメントとソースコード
  • Clangd: https://clangd.llvm.org — Clang フロントエンドに基づく C/C++ 用 LSP サーバー

Keywords: LSP, Language Server Protocol, JSON-RPC, completion, go-to-definition, find-references, hover, diagnostics, semantic tokens, incremental parsing, index, debounce, cancellation, error-tolerant parsing, symbol table reverse lookup, rust-analyzer, clangd