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

tennashi.hatenablog.com

週刊 LSP 第二号。

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

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

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

textDocument/didChange 通知

textDocument/didChange 通知はクライアントでの TextDocument への変更をサーバに伝えるための通知である。

リクエストパラメータ...の前に今回使ういくつかの型について。

TextDocument の URI 関連

前回も一応 DocumentUri 型は使用していたが詳細について触れなかった。
ここで少し触れておこうと思う。

TextDocument は URI でサーバ上一意に識別される。
DocumentUri 型は URI として valid であるような文字列と定められている。
具体的には RFC3986 に定められた URI 仕様に従い、MicrosoftMicrosoft/vscode-uri で実装を公開している。

TextDocumentIdentifier はその DocumentUri を保持しており、version 情報を付加する場合はそれを継承した VersionedTextDocumentIdentifier を使う。

今回の textDocument/didChange 通知では関係ないが、versionnull を取ることができる。
これは textDocument/didOpen 通知を受信する前にサーバが特定の TextDocument に対して VersionedTextDocumentIdentifier を送信するというユースケースを想定したものである。
// サーバは textDocument/didOpen 通知で初めて TextDocument のバージョンを確定できるのであってそれ以前はバージョンは不明(ディスク内が真)なのである。

type DocumentUri = string;

interface TextDocumentIdentifier {
    uri: DocumentUri;
}

interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
    version: number | null;
}

TextDocument 本文内の位置関連

textDocument/didChange 通知ではドキュメントの編集位置を示すために使われる。
残念ながら LSP では characterUTF-16 である。
これが実際に扱われているかも実装を読む際に注目してみたい。

line/character 双方 0-based で、characterline に指定された行の文字数を越えている場合は line の文字数に切り落とされる。

また -1 で最終行を表わす、というような規定は無い。

Range は始点/終点でドキュメント内の範囲を表わす。

interface Position {
    line: number;
    character: number;
}

interface Range {
    start: Position;
    end: Position;
}

というわけであらためて、textDocument/didChange 通知のパラメータを見ていく。

interface DidChangeTextDocumentParams {
    textDocument: VersionedTextDocumentIdentifier;
    contentChanges: TextDocumentContentChangeEvent[];
}

export type TextDocumentContentChangeEvent = {
    range: Range;
    rangeLength?: number;
    text: string;
} | {
    text: string;
}

textDocument は先に見た通りである。

contentChanges

TextDocumentContentChangeEvent 型は2パターンの型が存在する。
おおまかには range が存在するかどうかで、存在しない場合は text フィールドの値がその TextDocument 全体の内容であると判断される。
そうでなければ、TextDocument 内の range 内の内容が text フィールドの内容に置き変わると判断される。

どちらがクライアントから送信されるかは事前にクライアントと合意を取っておく。
今回、詳細は触れないが、以下の定数が用意されており、サーバが扱える手段についてクライアントに伝える。

export namespace TextDocumentSyncKind {
    export const None = 0;
    export const Full = 1;
    export const Incremental = 2;
}

Full が指定されると contentChanges の長さは 1 で、常に range フィールドが空の型でクライアントから送信される。
Incremental が指定されると contentChangesrange が指定されて、今回の textDocument/didChange で反映すべき変更が全てクライアントから送信される。
このときサーバはそのリストの順番通りに反映処理をしなければならない。
また通知自体もクライアントから複数送信されるが、サーバはこの通知の順序も正しく扱わなければならない。
None は全く差分が送信されないことを示すがこれはどういうときに使うんだろうか?

なお vim-lsp の実装 を読むと None のときは本当に何も送信しないようである。

ここは今回のサーバ実装の見所その2である。

サーバ実装例

今回も

を読んでいく

efm-langserver/sqls

ハンドラは以下の部分である。

双方 TextDocumentSyncKindFull のみ対応している。

そのためクライアントからはテキストの全体がまるっと1つだけ送信されてくる前提のコードになっている。
// 余談ではあるが、プロトコルTextDocumentSyncKindFull のときに TextDocument 全体を"一つだけ"送信しなければならないとは言っていないので、変更した TextDocument 全体を複数送信するクライアントが存在するかもしれないが、まぁ大抵の場合はクライアントが想定した挙動ではないはずである。

前回触れなかったが、efm-langserver は textDocument/publishDiagnostics 通知に対応しているが、sqls は対応していないという違いがある。
これが updateFile() の実装に表れている。

ハンドラの構造体には chan DocumentURI な型を持つ request というフィールドがあって、ここにリクエストパラメータ内の uri を放り込んでいる。

この chan に値が放り込まれると、

ここで受け取られて、指定された linter での処理が走り、その結果が textDocument/publishDiagnostics 通知としてクライアントに送信される。
具体的な linter の処理は textDocument/publishDagnostics の詳細を見るときまで置いておく。

gopls

ハンドラは以下

特徴的な部分として最後の処理から注目していきたい。

そのファイルを初めて変更し、かつそれが自動生成されたファイルの場合window/showMessage 通知を利用してそのことをクライアントに送信しようとしている。

gopls の Server 構造体は watchedFiles という map[span.URI]struct{} 型のフィールドを保持しており、ここで一度でも変更を加えたファイルを識別できるようにする予定のようである。
予定のようである、と書いたのは現状このフィールドに値を放り込んでいる処理が見当らないからである。

さらにこれも変な気がするのだが、_, ok := s.changedFiles[uri]ok をそのまま return してるので、changedFiles にあれば初めての更新だということになってしまっている。
これだと現状 wasFirstChange() は常に false が返るのではなかろうか。

-- 追記(2020/08/02) --
やっぱ変だなと思ったので Issue 上げといた。
-- 追記おわり --

なお、自動生成かどうかの判定は、

で行なわれる。
細かいことはさておき、DO NOT EDIT. が含まれるコメント行の有無 で判定している。
// これ、何で定められていたんだっけな? コメントにも根拠となるリンクが貼られていたが、別に言語仕様として定められている訳ではなさそうである。

では、変更の扱いについて見ていく。

TextDocumentSyncKindIncremental 対応 なのでその辺りの処理も見れるはずである。

ここから先でリクエストパラメータから最終的な変更内容を計算している。
一応仕様上は Incremental 対応のサーバが Full もサポートしなければならないということはなさそうだが、gopls ではサポートしている。
// やはりこちらも Full で送られてくるときにはリストの長さは1である前提のようである。

Incremental なパラメータは以下で計算される。

Incremental な場合は UTF-16 で定められた Range をいい感じに扱っているかどうかが見所と言っていたが、結論としては gopls はちゃんと扱っていそうである。
Go の内部的な位置情報は span と呼ばれており、それと LSP の表現を変換する ColumnMapper という型が用意されているようである。

この辺も後日別枠で追っていこうと思う。
今回は ここ でちゃんと変換されてそうだなぁ、くらいで濁しておく。

次回 textDocument/didClose 編予定

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

tennashi.hatenablog.com