LSPのテキスト編集と同期についてもメモ

自作のLSP(Language Server Protocol)クライアントを作って遊んでいる。

github.com

補完とか整形とか幾らか動いていて、それなりに使えているのだが、 普段使っている言語以外のLSPサーバを動かすと、 途端に怪しい挙動に遭遇する。

今回はテキストの編集(textEdit)と同期(textDocumentSync)に悩んだ。 まだ、解決しきった訳ではないけど、ここまでのところをメモとして残しておく。

同期をどう実装するか?

エディタやIDEでの編集状況は、LSPクライアントからサーバに送られ、お互いテキストの同期をとる。 同期の方法(TextDocumentSyncKind)として、None,Full,Incrementalの3つが定義されている。

多くのサーバはIncrementalを指定しているようだが、面倒なのでFullでやってみたら、 それで動くLSPサーバがあったので、ラッキーと思って、済ませていた。

Fullだと変更の通知(didChange)のたびに全テキストを送りつけることになる(のだろう)。 サイズが大きいと大変そうだが、同期のズレはなさそう。

ただ、Incrementalしか対応しないLSPサーバもあるようで(なんだったか忘れた)、 しょうがないのでIncrementalも実装しようとした。

最初思いついたのは、Incrementalとはいえ、全部送るという雑な方法。 Incrementalでは、変更のあった箇所(range)と新しいテキスト(newText)を送る。 同時に複数送ることもできる。 ファイル全体をrangeで指定して、newTextで全部送れば良いじゃん、と。 細かな問題は、変更前の状態を覚えておく必要があること。

ただ、流石に格好悪い気がしたので、地道に差分だけ送る方法を検討した。 LSPクライアントを実装している自作テキストエディタは、テキストエディタフレームワークScintillaを使っている。

Scintilla and SciTE

Scintillaの持っている機能で編集があったら通知(SCN_MODIFIED)してくれて、 通知には変更のあった場所、変更文字列と長さが含まれているので、それをそのままdidChangeで送ればよさそうだった。 一箇所の変更であれば、これで良かったのだが、複数同時変更で困ることになる。 テキストエディタの機能としては、一括置換など。

複数の変更通知(例えばC1とC2、変更箇所はC1<C2)を順次didChangeで送ると、変更C1の通知でテキストの状態が変り、 C2で送るrangeと最新の位置にズレが生じてしまう。 これは、C1とC2を個別のdidChangeで送っても、1回のdidChangeにC1とC2を含めても同じ。 ズレなくするためには、単純にはファイルの後方の変更から通知すれば良い。 C2を先に送ってからC1を送るということ。

didChangeの通知タイミングも問題で、複数箇所の同時編集後に、didChangeで通知しようとすると、 ScintillaのSCN_MODIFIEDの通知に含まれる場所は、イベント発生時の先頭からの位置(position)なので、 複数変更があると、イベントが発生した時点での位置と、最新の状態の位置にズレが生じている可能性がある。

なので、複数箇所同時変更であっても、個々の編集の単位でdidChangeを送るのが安全そう(に思われる)。 なんか良い方法ないものか?

サーバからのテキスト編集

サーバからもtextEditという形で変更が送られてくるので、クライアント側でも編集の対応を行うことになる。 サーバからの変更は、クライアントからの要求への応答という形になる。 整形(formatting)、名称変更(rename)、補完(completion)など。

いずれも複数箇所同時変更があるので、変更順に注意が必要。 これも、ファイルの後ろから変更すれば基本的にはOKなハズ。

そして、この変更に対してもdidChangeで同期する必要があるのだが、これも後ろから個々に対応していれば、 問題なく同期できるだろう(と思われる)。

ただ、ここで補完(completion)が曲者であった。

completionのadditionalTextEditsの扱い

まず、completionの場合、サーバから送られてくるCompletionItemの中身がLSPサーバによってバラバラ。 クライアントの選択によって挿入されるテキストの指定方法に複数あるし、 良くあるパターンとして途中まで入力して、続きを補完するということなので、そこも対処が必要。

例えば、prまで入力して、補完候補からprintfを選択した場合、実際に挿入するのはintfである、とか。

その次に悩ましいのが、additionalTextEditsの扱い。 これは例えば、補完する関数名や定数名が定義されているヘッダをincludeするとか、そういうパターン。

便利だけど、やっかい。あったりなかったり(あんまりない)だし。

あと、完全にテキストエディタとしての実装の問題だが、編集後のカーソル位置はどこが良いか?とか面倒。

今後にむけて

やってみると、以外と面倒なテキストの同期。 サーバ側で認識しているファイルの状態を知る方法とか、 なにかのタイミングで完全同期しなおす方法がないかなぁ・・・。