LSP 実装メモ (Text Document Synchronization `textDocument/didOpen` 編)

Language Server Protocol に従ったサーバを実装するためのメモ

まずは Text Document Synchronization の実装から調査する。

Text Document Synchronization とは

Text Document Synchronization は LSP サーバ/クライアント間で Text Document、つまり補完などの処理をする対象のテキストファイル内のデータを同期するための仕様群である。

LSP で定義されたこれに関連するメソッドは以下である。
// 今となってはどうでもいいが、ちょっと前まで仕様書には textDocument/open textDocument/change textDocument/close という未定義なメソッドについての記述があったが、さっき読みなおしたところ textDocument/didOpen textDocument/didChange textDocument/didClose に修正されていた。

  • textDocument/didOpen 通知
  • textDocument/didChange 通知
  • textDocument/willSave 通知
  • textDocument/willSaveWaitUntil リクエス
  • textDocument/didSave 通知
  • textDocument/didClose 通知

これらがクライアントから送信されてくる。

では各々のリクエストパラメータなどの詳細を見ていく。
短かく分けることで記事を継続して書くモチベーションになるかもしれないので、今回は textDocument/didOpen 通知のみを見ていく。

textDocument/didOpen 通知

textDocument/didOpen はドキュメントをクライアントが開いた際、そのことをサーバに教えるための通知である。

パラメータは以下である。
なお、仕様書が TypeScript で記述されているため、ここでもそのまま記述する。

interface DidOpenTextDocumentParams {
    textDocument: TextDocumentItem;
}

interface TextDocumentItem {
    uri: DocumentUri;
    languageId: string;
    version: number;
    text: string;
}

uri

LSP ではドキュメントの位置は URI で指定される。
クライアント側の実装例として prabirshrestha/vim-lsp を見ると、以下のように基本的には files://スキーマとして指定されてくるようである。
URI 生成処理部分

languageId

複数の言語に対応したサーバなど(例えば Go 言語の Language Server 実装である gopls は Go で書かれたコードだけでなく、go.mod の解釈もする)ではどの言語で書かれた TextDocument であるかは重要である。 このとき、URI をパースして拡張子を解釈したりしないですむように languageId を指定できる。

推奨される Language ID 一覧は TextDocumentItem の定義 に記述されている。
vim-lsp では Vimfiletype 値が送信される。

version

version はそのテキストのバージョンを示し、編集操作が行なわれる度増加(undo/redo などでも増加)する。
vim-lsp では 1 が送られる。

text

text にはファイルの中身のテキストが格納される。
uri が送信されるので、サーバ側で読めばいいのでは、と思われるかもしれないが、LSP ではテキストはクライアント側 (エディタ側) で管理されるものと定められており、プロトコルとしてサーバ側で開いてはならない (MUST NOT) と規定されている。
DidOpenTextDocument Notification

その他動作に関する仕様として、

  • textDocument/didOpen 通知は同一の TextDocument について連続で送信してはならない。
    • 必ず textDocument/didClose 通知を送信してから送信しなければならない。
    • 実際サーバ側が didOpen 通知を連続して受け取った場合にどのように処理すべきかについては触れられていない。
      • Note that a server’s ability to fulfill requests is independent of whether a text document is open or closed. という記述から、クライアントが送信してはならないがサーバは処理してもいい、と解釈できるかもしれない
      • このあたりはサーバ実装を調査する
  • languageId を変更する場合は TextDocument を開きなおさなければならない
    • つまり didClose 通知で閉じてから再度 didOpen 通知で開く
    • vim-lsp ではぱっと読んだところ FileType autocmd を処理してないので、途中で languageId が変更されても何も起きない、はず
    • 例えば JavaScript から TypeScript に Language ID を変更するときに、TypeScript に対応したサーバを起動すべきかどうかについては言及がない
      • 同様に JavaScript の TextDocument がなくなったときにサーバを終了すべきかどうかについても言及はない

サーバ側実装例

サーバ側でどのように実装しているかをいくつか観察してみる。
私は Go が一番手に馴染んでいるので、以下の Go で書かれたサーバ実装を見る。

efm-langserver/sqls

この二つの実装はだいたい同じ実装である。
シンプルにサーバ/ハンドラの構造体に Map でファイルの中身をそのまま持つ。

openFile() で map に登録して、updateFile() でパラメータのテキスト内容に更新する、という流れである。

またすでに開いている TextDocument のチェックは記述されていないため、didOpen が連続した場合は上書き登録される。

gopls

gopls は implementation.md にあるとおり、多重 cache 機構があるので、その兼ね合いで一見複雑そうに見えるが、基本的には同じでパラメータの URI にデータを登録して、テキスト内容に更新する。

まず Session.ViewOf() で登録された cache を探して、存在しなければ cache を初期化する。
その後、中身をパラメータで指定された値に更新する。

こちらもぱっと読んだ限り、すでに存在しているかのチェックは無いのでそのまま cache の内容が新しいパラメータで更新されるだけと思われる。

cache 周りの詳細な実装は追えておらず、また後日。

次は textDocument/didChange 編予定。

-- 追記(2020/08/01) --
書いた tennashi.hatenablog.com