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

前回

tennashi.hatenablog.com

引き続き、Text Document Synchronization 周りの仕様について書いていく。

  • textDocument/didOpen 通知 <- done
  • textDocument/didChange 通知 <- done
  • textDocument/willSave 通知 <- done
  • textDocument/willSaveWaitUntil リクエスト <- done
  • textDocument/didSave 通知 <- 今日ここ
  • textDocument/didClose 通知

textDocument/didSave 通知

前回の willSave willSaveWaitUntil は保存前だったが、textDocument/didSave 通知は保存後にクライアントから送信される。

interface DidSaveTextDocumentParams {
    textDocument: TextDocumentIdentifier;
    text?: string;
}

textDocument はおなじみ TextDocumentIdentifier つまり TextDocument の URI である。

text は保存された TextDocument の中身であるが、これを送信するかどうかは事前にクライアントと合意が取られる。

具体的にはクライアントから送信される intialize リクエストへのレスポンスの一部として以下が送信される。

export interface SaveOptions {
    includeText?: boolean;
}

includeTexttrue な場合はサーバはクライアントが DidSaveTextDocumentParamstext フィールドを埋めることを期待することを示す。
クライアントは自身がこのフィールドを埋める機能を実装しているかどうかはサーバに通知できないので、サーバは includeTexttrue にしたところで、クライアントが対応していない場合のハンドリングはしておかなければならないと考えられる。

サーバ実装例

を読んでいく

efm-langserver

ハンドラの実装は以下

ちゃんと params.Text の中身を取り扱っていることが分かる。
ただし、initialize レスポンスに SaveOptions を渡していないため、SaveOptions を無視して保存後のテキストを送り付けてくるクライアントがあった場合にのみ発火する処理である。
// プロトコルincludeTextfalse ならクライアントは text を埋めてはならない、とは書いていないためそのようなクライアントを書いてもよい、はず。

あらためて handleTextDocumentDidSave() を追っていく。
params.Text が埋まっている場合は updateFile() へ、そうでない場合は saveFile() へ処理が移る。
updateFile() の第三引数は TextDocument のバージョンを示すが、この textDocument/didSave で送信された text ではバージョンが変化しない(例え直前の textDocument/didChange の適用後から変更されていたとしても)ので nil を渡し、バージョンを変更しないことを伝えている。

saveFile() は linter を発火するための h.request チャネルに URI を伝えている。

efm-langserver は TextDocument の更新および保存の度に linter を動作させている、言い換えると textDocument/didChange textDocument/didSave 通知が送信されるたびにクライアントに textDocument/publishDiagnostics 通知を投げていることが分かる。

sqls

ハンドラの実装は以下

efm-langserver と同様に params.Text をハンドルしていることが分かる。
params.Text の中身に応じて updateFile()saveFile() に振り分けているところまでは同様だが、sqls は textDocument/publishDiagnostics に対応していないため、saveFile() では何もせず。updateFile()params.Text の内容を反映するのみである。

gopls

今回 gopls はハンドラ部分を軽く追うだけにしておく。
// これまでも別に深く追っていたわけではないが...

gopls では didModifyFiles() というメソッドが TextDocument 関連の通知の肝になる処理だが、これは gopls のキャッシュの持ち方と強く結び付いた処理なので、このあたり含め textDocument/didClose まで紹介した後に集中して読み解いておきたい。

gopls は source.FileModification という型のフィールドの内容で didModifyFiles() の処理を制御しており、params.Text の有無も同様に対応される。

ここまでを注意深く読んでいると、以下の部分がおかしいことに気付く。

LSP 3.15 で定められた仕様では TextDocument のバージョン情報は入っていないはずである。

実際の定義も VersionedTextDocumentIdentifier 型に変更されている。

gopls は LSP で定義された各種リクエスト/構造体を microsoft/vscode-languageserver-node から自動生成している(internal/lsp/protocol/typescript)。

で、実際 vscode-languageserver-node の定義を見れば

VersionedTextDocumentIdentifier になっている。
どうやら LSP 次期バージョンの 3.16 でも TextDocumentIdentifier のまま で、vscode-languageserver-node のこの実装は 4年前からある ようなので、VSCode の独自実装かなと考えている。

クライアント実装

サーバ側を読んでいるとクライアント実装で気になるところが出てきたのでちょっとだけ追う。

  • SaveOptionsincludeText によってパラメータの text フィールドを操作しているかどうか
  • パラメータの TextDocument を VSCode に追従して VersionedTextDocumentIdentifier で扱っているかどうか

読むのは vim-lsp である。

includeText のハンドリング

こちらはちゃんと includeText に応じて text を埋めるか決めている。

textDocumentVersionedTextDocumentIdentifier として扱っているか

uri しか埋めていないことから通常の(仕様通りなのでもちろんこれが正しい) TextDocumentIdentifier として扱っている。

というわけで次回は Text Document Synchronization 最終回 textDocument/didClose 編予定。

-- 追記(2020/08/23) -- かいた

tennashi.hatenablog.com