LSP 実装メモ (Text Document Synchronization `textDocument/didChange` 編)
週刊 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 仕様に従い、Microsoft は Microsoft/vscode-uri で実装を公開している。
TextDocumentIdentifier
はその DocumentUri
を保持しており、version 情報を付加する場合はそれを継承した VersionedTextDocumentIdentifier
を使う。
今回の textDocument/didChange
通知では関係ないが、version
が null
を取ることができる。
これは 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 では character
は UTF-16 である。
これが実際に扱われているかも実装を読む際に注目してみたい。
line
/character
双方 0-based で、character
が line
に指定された行の文字数を越えている場合は 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
が指定されると contentChanges
は range
が指定されて、今回の textDocument/didChange
で反映すべき変更が全てクライアントから送信される。
このときサーバはそのリストの順番通りに反映処理をしなければならない。
また通知自体もクライアントから複数送信されるが、サーバはこの通知の順序も正しく扱わなければならない。
None
は全く差分が送信されないことを示すがこれはどういうときに使うんだろうか?
なお vim-lsp の実装 を読むと None
のときは本当に何も送信しないようである。
ここは今回のサーバ実装の見所その2である。
サーバ実装例
今回も
を読んでいく
efm-langserver/sqls
ハンドラは以下の部分である。
双方 TextDocumentSyncKind
は Full
のみ対応している。
そのためクライアントからはテキストの全体がまるっと1つだけ送信されてくる前提のコードになっている。
// 余談ではあるが、プロトコル上 TextDocumentSyncKind
が Full
のときに 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.
が含まれるコメント行の有無 で判定している。
// これ、何で定められていたんだっけな? コメントにも根拠となるリンクが貼られていたが、別に言語仕様として定められている訳ではなさそうである。
では、変更の扱いについて見ていく。
TextDocumentSyncKind
は Incremental
対応 なのでその辺りの処理も見れるはずである。
ここから先でリクエストパラメータから最終的な変更内容を計算している。
一応仕様上は Incremental
対応のサーバが Full
もサポートしなければならないということはなさそうだが、gopls ではサポートしている。
// やはりこちらも Full
で送られてくるときにはリストの長さは1である前提のようである。
Incremental
なパラメータは以下で計算される。
Incremental
な場合は UTF-16 で定められた Range
をいい感じに扱っているかどうかが見所と言っていたが、結論としては gopls はちゃんと扱っていそうである。
Go の内部的な位置情報は span と呼ばれており、それと LSP の表現を変換する ColumnMapper
という型が用意されているようである。
この辺も後日別枠で追っていこうと思う。
今回は ここ でちゃんと変換されてそうだなぁ、くらいで濁しておく。
次回 textDocument/didClose
編予定
-- 追記(2020/08/09) -- かいた