このページの目次

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]

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

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

従来のハイライトは正規表現マッチ(Tree-sitter の 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 文字を入力した場合、 文字ごとに再解析せず、50ms の入力停止後に解析を実行 キャンセル (Cancellation) 前回の解析が完了する前に新しい入力が到着した場合、 $/cancelRequest を用いて前回の作業を破棄 デバウンスは「連続する入力」を「最後の1回のみ解析」にまとめ、キャンセルは時代遅れになった中間結果を破棄します—— これらが応答を安定して <100ms に保つ鍵であり、全量再計算ではなく、必要な1回のみを実行しています。

デバウンスとキャンセルはパフォーマンスの生命線です。ユーザーが高速に入力している間、on_type の頻度は約 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