LSPと言語サーバー

このページの目次

Language Server Protocol (LSP) は、補完、ジャンプ、診断などの言語機能を、エディタと言語サービス間のプロトコルとして定義します。これにより、各エディタが個別に言語ごとの実装を行うための重複作業を削減しますが、言語サービスが特定のコンパイラ、スレッドモデル、またはデータベースを採用することを義務付けるものではありません。

言語サーバーはコンパイラフロントエンドを再利用することも、対話的な分析エンジン専用に維持することもできます。それは絶えず変化し、しばしば不完全なソースコードを扱います。応答速度、結果の完全性、およびバージョンの正確性の間で作業を整理する必要があります。IDEの言語機能はエディタ内部で実装することも可能であり、LSPが唯一のソースであるわけではありません。

能力の合意を先に定め、リクエストと通知を交換する

LSPはJSON-RPCメッセージモデルを使用します。リクエストにはIDがあり、結果またはエラーに対応します。通知には対応するレスポンスがありません。初期化フェーズで双方が能力を交換し、合意された結果に基づいて機能を使用します。例えば、textDocument/publishDiagnostics はサーバーから送信される通知であり、エディタから開始される通常のクエリとしてリストすべきではありません。

ドキュメント同期には通常、didOpen、didChange、didCloseなどの通知が含まれます。全文本文を使用するか増分編集を使用するかは、能力の合意によって決まります。開いているドキュメントに対して、サーバーはエディタが同期した未保存の内容を分析する必要があり、毎回ディスク上の古いファイルを読み直すことはできません。LSP 3.17 仕様

1回の補完:ドキュメントバージョンを中心に取消可能なクエリを構築する エディタがドキュメント変更を同期 didChange: バージョン42に更新 1 サーバーが分析スナップショットを構築 構文、型、プロジェクト設定は整合性のあるビューを使用 2 エディタがカーソル位置での補完を要求 コンテキスト、可視シンボル、型から候補を生成 3 新しい編集でバージョン43に到達 古いタスクは取消可能、古い結果は依然として適用可能か確認が必要 4 現在のコンテキストと一致する結果を返す 編集位置とテキストエンコーディングの一貫した解釈が必要 5 ドキュメントバージョンは同期と結果管理の根拠です。すべてのLSPリクエストが直接バージョンフィールドを含むわけではありません。

1回のフィールド補完から分析依存関係を理解する

ユーザーが account. と入力したとき、式はまだ不完全です。分析エンジンはレシーバー account、ドット、および欠落しているメンバーノードを保持したまま、account の束縛と型を解析し続け、その後候補メンバーを列挙できます。候補は、可視性、ジェネリクス置換、トレイト、拡張メソッド、および期待される型によって制約される可能性があります。

したがって、補完は単に「シンボルテーブルのすべての名前をリストする」ことではありません。構文位置がどの種の候補を期待するかを決定し、名前解決が具体的な宣言を特定し、型分析が利用可能なメンバーを決定し、ソートと自動インポートが対話体験を改善します。

コンパイラもエラー回復を行うことができます。つまり、不完全な式に出会った瞬間に停止する必要はありません。主な違いは目標にあります。対話システムは、エラーを含むソースコードであっても局所的に有用な結果を頻繁に提供することを求めています。

一般的な機能クイックリファレンス

メッセージまたはリクエスト核心的な作業無視できない境界条件
completionコンテキスト、可視シンボル、メンバー、ソート不完全な構文、自動インポート、置換範囲
definition使用箇所から具体的な定義へ解決オーバーロード、マクロ展開、生成コードの位置
referencesシンボルとして参照を照会同名だが異なるシンボル、プロジェクト横断、インデックスの完全性
hover型、ドキュメント、関連情報型のインスタンス化、エラー回復状態
signatureHelp現在の呼び出しの候補シグネチャとパラメータ位置ネストされた呼び出しとオーバーロード
rename関連する位置の WorkspaceEdit を生成名前キャプチャ、競合、ファイルバージョン、書き込み可能性
publishDiagnosticsサーバーが診断をプッシュ古いバージョンの結果の廃棄;プル型診断インターフェースも存在
semanticTokens分類されたテキスト範囲を返す位置エンコーディング、能力、レジェンド

Rename(名前変更)はグローバルな文字列置換ではありません。2つのスコープにある同名の変数は同じシンボルではありません。マクロ展開中の名前にも、直接編集可能なソース位置があるとは限りません。サーバーが編集集合を生成した後、クライアントはプロトコルとバージョン状態に従って適用する必要があります。

増分計算:キャッシュするのはクエリの依存関係であり、ファイルだけではない

クエリチェーンを「ファイルテキスト→構文木→宣言シグネチャ→関数本体の型→診断」とします。関数本体内のローカル式を変更すると、その関数の型結果が無効になる可能性がありますが、エクスポートされたシグネチャには影響しない場合があります。公共型を変更すると、複数の依存ファイルに影響を与える可能性があります。

増分エンジンは依存関係と入力バージョンを記録し、影響を受けたクエリのみを再計算します。また、結果が変化していない場合は、無効化の伝播を防ぐことができます。「変更されたファイルのみを再計算する」のは狭すぎ、「変更があればプロジェクト全体を再計算する」のは広すぎます。プロジェクト設定、コンパイルオプション、依存バージョン、および生成ファイルも入力です。

rust-analyzer のアーキテクチャは、構文、セマンティクス、増分クエリなどの責任を分離しており、対話型分析向けのこの組織化を体現しています。これは、すべての結果をディスクインデックスとして永続化する必要があることを意味するものではありません。rust-analyzer アーキテクチャ

プロジェクトインデックス:シンボルIDで宣言と使用を接続する

プロジェクトレベルのインデックスは、開いていないファイル内のシンボル、参照、および関係を検索するのに役立ちます。これは、現在開いているファイルの動的結果、バックグラウンドで構築されたインデックス、およびオプションの外部インデックスと組み合わせることができます。キャッシュやディスクへの永続化はパフォーマンスのための手段であり、LSPの必須要件ではありません。

clangd は、異なるソースのインデックスを組み合わせ、現在の編集状態とプロジェクト全体の情報の両方を兼顾します。clangd インデックス設計

インデックスは、実際のコンパイルコンテキストに基づいてソースコードを解釈する必要があります。C/C++の場合、同じファイルでも異なるマクロ定義やincludeパス下では異なるプログラムを表す可能性があります。ファイルの更新時刻のみをキャッシュキーとして使用すると、誤った結果が再利用される可能性があります。インデックスが未完成の場合でも、どのクエリが局所的な結果を返すことができ、どの操作がより多くの情報を待つ必要があるかを明確にする必要があります。

位置情報:文字数が常にバイト数と一致するわけではありません

LSPの位置は通常、0から始まる行番号と行内のオフセットで表されます。3.17では位置エンコーディングの交渉がサポートされており、他のエンコーディングが交渉されていない場合はUTF-16の互換デフォルトが保持されます。UTF-8バイト、Unicodeコードポイント、およびUTF-16コードユニットの数は異なる場合があります。

例えば、非BMP文字はUTF-16では2つのコードユニットを占め、UTF-8では4バイトを占める可能性があります。サーバーがバイトオフセットを直接LSPの文字位置として使用すると、中国語や絵文字付近での補完置換や診断範囲がずれます。編集の適用順序、改行の表現、および範囲の端点はプロトコルに従う必要があります。具体的なデータ構造はLSPメタモデルを参照してください。

連続する編集:2番目の範囲は1回目の編集後のテキストに属する

バージョン7で1行 a😀b だけあり、位置エンコーディングがUTF-16であるとします。aは1ユニット、😀は2ユニットを占め、bの開始位置は3です。内部文字列がUnicodeコードポイントでインデックス付けされている場合、bの添字は2になります。まずオフセット1でXを挿入すると、テキストは aX😀b になり、bのUTF-16開始位置は4になります。次に範囲 [4,5) を使用してbをBに置換すると、aX😀B になります。

これら2つの変更が同じ didChange メッセージに含まれている場合、配列の順序に従って適用する必要があります。2番目の範囲は、最初バージョン7ではなく、中間テキストに基づきます。メッセージ内のバージョン番号は、すべての変更が適用された後のバージョンを示します。ドキュメント同期の定義

以下は、単一行で有効なUnicodeテキストのみを処理する変換モデルです。行長を超えるオフセットはプロトコルに従って行末にクリップされます。絵文字のサロゲートペアの途中にあるオフセットは、文字を分割しないようにこのモデルでは拒否されます。この拒否戦略は、プロトコルがすべてのサーバーに同じ修復方法を規定していることを意味するものではありません。

def index16(text, offset):
    if offset < 0:
        raise ValueError("negative position")
    units = 0
    for index, char in enumerate(text):
        if units == offset:
            return index
        units += len(char.encode("utf-16-le")) // 2
        if units > offset:
            raise ValueError("inside surrogate pair")
    return len(text)  # 行末または行末を越える


def apply(text, changes):
    for start, end, replacement in changes:
        if start > end:
            raise ValueError("reversed range")
        lo, hi = index16(text, start), index16(text, end)
        text = text[:lo] + replacement + text[hi:]
    return text


assert index16("a😀b", 3) == 2
assert index16("a😀b", 99) == 3
assert apply("a😀b", [(1, 1, "X"), (4, 5, "B")]) == "aX😀B"
assert apply("a😀b", [(1, 3, "")]) == "ab"
for changes in [[(2, 3, "")], [(3, 1, "")], [(-1, 0, "")]]:
    try:
        apply("a😀b", changes)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid edit accepted")

完全な実装には、行インデックス、CRLF、跨行置換、および全文置換イベントの処理が必要です。ここでの単一行関数をそのまま実際のドキュメントに直接使用することはできません。各編集後、テキストと行インデックスを同期して更新する必要があります。古い行の開始位置をキャッシュしてテキストのみを変更すると、後続のすべての位置変換が誤った基礎の上に構築されることになります。Position 定義

同じカーソルなのに、なぜ3つの異なるオフセットがあるのか?

3つのカウンターは、同じ文字の境界を指しているだけであり、3つの異なるカーソルを指しているわけではありません。境界を移動するとき、絵文字によりUTF-8は4、UTF-16は2、コードポイントは1ずつ増加します。その後、連続する編集の中間テキストを比較します。Xの挿入によりbの位置が移動しているため、次の置換では新しいUTF-16範囲を使用する必要があります。以下のボタンで完全な編集スナップショットを選択し、境界スライダーは観察のみを行い、テキストを密かに変更することはありません。

図を準備しています
同じカーソルなのに、なぜ3つの異なるオフセットがあるのか?

UTF-8バイト、UTF-16ユニット、およびコードポイントは同じ単位ではありません。連続する change は順序に基づいて中間テキストに適用されます。2回目で古い範囲を使用し続けると、誤った位置や文字内部を指すことになります。

セマンティックハイライトと構文ハイライト

正規表現ルールは局所的な字句パターンを識別できます。Tree-sitter の highlights.scm は主に構文木ノードを照会するものであり、ファイル全体を純粋な正規表現マッチとして扱うものではありません。構文構造を利用できますが、通常、完全な跨ファイル型解析の責任は負いません。Tree-sitter ハイライトドキュメント

LSP の semantic tokens は、サーバーがより豊富な分類と修飾子を返すことを許可します。例えば、ある識別子が型、パラメータ、または読み取り専用変数であるかどうかです。これは「ソースコードの各トークンに必ずラベルを付ける必要がある」と規定するものではなく、エディタが高速な基本層として構文ハイライトを同時に保持することを禁止するものでもありません。

スケジューリング:高速な応答と正しいバージョンの両方が重要

補完、ホバーなどの対話型クエリは、プロジェクト全体のインデックスよりも優先されるべきです。バックグラウンド分析では、近接する編集をマージし、スナップショットを再利用し、入力の変化後に期限切れのタスクを取消すことができます。しかし、古い診断が到着して新しい結果を上書きしてはいけません。

$/cancelRequest は、特定のプロトコルリクエストの取消を表現するものであり、任意の内部バックグラウンドタスクの統一停止メカニズムではありません。取消は完了と競合する可能性があり、サーバーはリクエストを適切に終了させる必要があります。内部分析にも、独自の協調的取消とリソース管理が必要です。

デバウンス待機とクエリレイテンシにはトレードオフがあります。一部の高速な局所分析は、各編集で実行できます。高コストなバックグラウンド診断のみをマージするのが適しています。「キーを押すたびに解析すると必ず遅延する」ことや、「統一されたデバウンス 50ms ですべてのリクエストが 100ms 未満になる」という結論は存在しません。

同じリクエストのIDと、それが分析するドキュメントバージョンは別物です リクエスト 81 / スナップショット v7 バックグラウンド分析は固定テキストを使用 v8 に編集 現在の状態が進行 リクエスト 82 / スナップショット v8 新しい結果が先に完了 リクエスト 81 が遅れて完了:依然として v7 に属する プロトコルリクエストを終了;表示または適用前に適用性を確認 取消は不要な作業を減らすために使用されます。バージョンと依存関係のチェックは、期限切れの結果が現在の状態を上書きするのを防ぐ役割を果たします。

なぜ取消後も結果のチェックを行う必要があるのか

リクエスト 81 が v7 を分析していると仮定します。その後、ドキュメントが v8 に変更され、リクエスト 82 が先に返却されます。このとき、クライアントが 81 の取消通知を送信したとしても、81 は計算を完了しているか、レスポンスを送信中である可能性があります。取消は「このIDからは今後一切メッセージを受信しない」という約束ではありません。リクエストはプロトコルに従って終了レスポンスを持つ必要があります。取消メカニズム

内部タスクに (URI, ドキュメントバージョン, プロジェクト設定世代) を保存できます。以下に、完全なスナップショットキーがまだ一致している場合にのみ結果を受け入れるという保守的な戦略の例を示します。LSP の補完リクエストには、ここで示すすべてのフィールドが直接含まれているわけではありません。これらはクライアント/サーバー側の独自のスケジューリング層によって関連付けられます。プロジェクト設定の変化により、テキストが変更されていなくても結果が無効になる可能性があります。

current = ("file:///demo.lang", 8, 3)
pending = {81: ("file:///demo.lang", 7, 3), 82: current}
shown = []


def finish(request_id, result):
    snapshot = pending.pop(request_id)  # まずこのリクエスト管理を終了
    if snapshot == current:
        shown.append(result)


finish(82, "new completion")
finish(81, "old completion")
assert shown == ["new completion"] and not pending
pending[83] = current
current = ("file:///demo.lang", 8, 4)  # テキストは不変だが、コンパイル設定が変更
finish(83, "stale configuration")
assert shown == ["new completion"]

これは、すべての作業を破棄するようエディタに要求するものではありません。より洗練されたシステムでは、クエリの依存関係が変化していないことを証明したり、古い位置を新しいバージョンにマッピングして結果を再利用したりできます。しかし、「リクエストIDが大きいからといって」結果が有効であるとは限りません。ID はリクエストとレスポンスを対応付けるためのものであり、ドキュメントバージョンのソートフィールドではありません。

結果の種類期限切れによる実際の問題注目すべき状態
補完置換範囲が古いテキストを指し、新しい入力を上書きリクエストコンテキスト、カーソル、テキストバージョン
診断修正済みのエラーが再発診断が属するバージョン;バージョンがない場合のスケジューリング戦略
名前変更編集変更後のファイルの誤った位置を編集各ドキュメントのバージョン、競合、編集能力
バックグラウンドインデックス同名シンボルが別の条件コンパイル定義に接続プロジェクト設定、依存関係、インデックス世代

名前変更の場合、「サーバーがすべての参照を計算し終えたから」といって、ファイルが変更されていないと見なしてはいけません。サポートされている場合、バージョン付きの TextDocumentEdit を使用して編集の根拠を表現し、クライアントはバージョンと能力に基づいて処理します。跨ファイル編集では、失敗時の処理能力も考慮する必要があり、すべてのクライアントが完全に同じアトミックコミットセマンティクスを提供すると仮定してはいけません。

リクエスト番号が大きいからといって、結果が必ずしも新しいわけではない?

ここでは意図的に2番目のタスクのリクエストIDを小さくし、数値順序をバージョン順序と誤解しないようにしています。タスクは起動時のテキストバージョンと設定世代を保存し、納品時に現在のスナップショットと比較します。期限切れの結果は、すでに受け入れられた新しい結果を上書きできません。取消は意図のみを表現し、図の遅延納品は、完了したレスポンスとの競合をシミュレートしています。設定の変更により、テキストバージョンが変更されていなくても結果が無効になります。

図を準備しています
リクエスト番号が大きいからといって、結果が必ずしも新しいわけではない?

リクエストIDはリクエストとレスポンスをペアリングするだけです。テキストバージョンとプロジェクト設定の両方が、結果の適用性を決定します。取消は完了と競合する可能性があり、遅延した結果でも依然として根拠をチェックする必要があります。

最小限の実装のための学習順序

まず、初期化、ドキュメント同期、および位置変換を実装し、サーバーが正しいテキストを保持していることを確認します。次に、局所的な構文診断と単純な hover を提供します。その後、名前解決、プロジェクトコンテキスト、および増分クエリを導入します。最後に、跨ファイル参照、名前変更、およびより複雑な補完を実装します。各ステップでは、一度限りの完全なファイルだけでなく、エラーを含むソースコードと連続する編集も確認する必要があります。

関連する基礎知識:AST とエラー回復、シンボルテーブルとスコープ。

参考文献

Keywords: LSP, language server, JSON-RPC, incremental analysis, document version, UTF-16, semantic tokens, cancellation