Open4

LSP 対応の補完プラグインの機能たち

hrsh7thhrsh7th

Vim における補完処理の概要

Vim における補完の処理って何をやらないといけないの?というのを書いていきます。

かんたんにいうと、

  1. 補完処理を適切なタイミングで起動する
  2. 補完開始位置を検出する
  3. 補完候補を集めてくる
  4. 集めてきた補完候補をフィルタして表示する

こういうことをやります。

補完処理を適切なタイミングで起動する

Vim においてはいくつかの方法でこれを検出することができます。

  1. TextChangedI,TextChangedP イベントを利用する
  2. InsertCharPre イベントを利用する
  3. タイマーを使う

まず、最も一般的である「TextChangedI,TextChangedP」を使う方法についてですが、これは純粋にテキストが変更されたことをイベントで知る。という方法です。
この方法が最も扱いやすいですが、注意点もあります。それは「仮にカーソル周辺のテキストが変化していなくてもこのイベントは飛んでくる」という点です。

そのため、この方式を採用する場合は「最後にカーソルがあった位置」を保持しておいて、「カーソルも移動しているかどうか?」で処理を中止したりする必要があります。(やったほうが軽いよね。という程度の話)

次に、「InsertCharPre」を使う方法についてです。
これは「ユーザが何かを入力したら補完する」という捉え方になるのでなんとなく「いいじゃん!」と思うかもしれません。
しかし、この方法はかなり面倒です。なぜなら、このイベントが発火したタイミングでは、入力された文字はまだ挿入されていなためです。
そのため、この方式を採用する場合は「このイベントが発火したタイミングで、そのイベントが終わり、文字が入力されるまで待ってから補完処理を開始する」という工夫が必要になります。

されにいえば、このイベントはエンターやバックスペースでは発火しません。(むしろこっちのほうが根本的に問題では?という感じはする)

最後に、タイマーを利用するという方式です。ほとんど採用されていないように思いますが、実は nvim-lua/completion-nvim はこの方式を採用しています。
この方式は 「InsertEnter」が発火したらタイマーを起動し、「InsertLeave」が発火したらタイマーを停止するという感じで処理が行われます。

この方式に利点はあんまりないのですが、例えばターミナルバッファで補完処理を出したい!といったケースではこういう方法しかありません。

まあ、最初の方式を使えばいいと思います。

補完開始位置を検出する

ここが一番 Vim において面倒な部分です。
下記の例を見てください。(カーソル位置を # で表現しています。)

if v:true
  call getbuf#
endif

さて、このケースではフィルタ文字列は「getbuf になるだろう」というのには同意が得られると思います。
しかし、なぜ getbuf になるのでしょうか?
それは脳内で keyword_pattern\w+ だと認識しているからです。

例えば、数式の結果を補完したい場合、

1 + 2#

この場合のフィルタテキストは 1 + 2 になってもおかしくない気がします。
他にも PHP でいう下記のケースでは、$this がフィルタテキストになりそうな気がします。

$this#

何が言いたいかというと「キーワードのパターンがあり、それは補完する対象(ソース)によって変わりそうだ」ということです。

この事実はかなりヘビーです。

例えば、スニペットを補完するケースを考えてみると、スニペットのトリガーキーワードはユーザが定義するわけなので、一意なキーワードパターンが定まらないということが普通に発生します。
もし tex のシンボルを補完しようと思ったら \lambda とか \pi とかが補完対象になりうるわけですが、バックスラッシュが邪魔で補完のフィルタがうまくいかないみたいなことが発生したりするわけです。

これを解決するためのアイデアはいくつかあります。例えばカーソル位置から 1 文字ずつ前方にスキャンして集めてきたキーワードとマッチする位置を自動決定するなどです。(nvim-cmp という補完プラグインではそういうことをやっています。)

例えば、nvim-cmp は下記のようなケースで適切な補完位置を自動的に検出します。

\date#

この状態で { word = '\date' } が返却されたことを考えます。
通常、keywod_patten\w+ なので、このままでは \#date のように \ が含まれない補完位置となってしまいます。nvim-cmp ではこのようなケースでは補完テキスト側の \date を含めて解析し、#\date を補完開始位置として検出します。

補完候補を集めてくる

補完処理を実施するタイミングを検出し、補完開始位置もわかりました。
次は補完候補を集めてくる必要があります。

ただし、文字が入力されるたびに愚直に補完候補を集めていたらかなり重くなりそうです。

call get# " 1
↓
call getb# " 2
↓
call getbuf# " 3

最初の call get# の段階で集めてきた補完候補を 2, 3 のタイミングではフィルタするだけに留めたいです。
そのため、「補完開始位置が変化していなければ前回の結果を使う」といったことが補完プラグインではほとんどのケースで行われています。

もう一つ、例えば call # の段階で補完が行われるとフィルタ文字列が空なのですべてのキーワードが全部列挙されてしまいます。
つまり、「キーワードに合致するテキストが 1 文字以上ある場合にだけ補完をする」必要があります。

実際に補完候補を集めるのは本当に単純な話、下記を叩くのと意味的には全く同じです。単純ですね。

function s:get_completion_items() abort
  return [{ 'word': '月' }, { 'word': '火' }, { 'word': '水' }, ...]
endfunction

集めてきた補完候補をフィルタして表示する

これはかんたんですね。Vim のビルトイン関数である complete(col, items) を叩くだけです。

ただし、注意点として complete(col, items) を実行して表示した補完候補はデフォルトで vim 側にフィルタされてしまいます。しかし、補完プラグインは基本的に自前のフィルタ処理を持っているのでこれは邪魔になります。

vim 側のフィルタを無効化するためには、各アイテムに equal = 1 を付与する必要があります。

まとめ

大雑把に補完プラグインがやっていること、注意点を書きました。
何が言いたかったかというと

  • 補完には、ソース毎に固有のキーワードパターンが必要
  • 補完は新しいキーワードが 1 文字以上ある場合にだけ行われる
  • 補完は新しいキーワードが開始されたときに一度だけ行われ、その後はキャッシュが使われる

という点でした。

hrsh7thhrsh7th

LSP で定義された補完機能の概要

LSP で定義された補完処理について書いていきます。

LSP には下記のような仕組みが備わっています。

triggerCharacters

Vim における補完処理の概要 で説明したように、一般的にテキストの補完処理というのは「キーワードのパターンが合致している間は新たなリクエストを行わない」という判定があります。
また、一般的に 1 文字以上は入力がないと補完を行いません。

if #

この状態でバッファの全キーワードが勝手に列挙されたらめっちゃ嫌ですよね。そういうことです。

しかし、キーワードが 0 文字でも補完してほしい場合があります。例えば、Go を書いていて、

fmt.#

おそらく Go の場合のキーワードパターンは \w+ あたりだと思いますが、このケースでは Println/Fprintln とかが出てきてほしい感じがあります。

こういうときのために TriggerCharacter があります。
もし、LSP サーバが triggerCharacters = ['.'] を指定してきた場合、補完プラグインは fmt.# で補完をキックし、候補を列挙する必要があるということです。

isIncomplete

Vim における補完処理の概要 で説明したように、基本的に自動補完というのは「キーワードが続いている間は補完候補をキャッシュして再利用する」ということを行います。

しかし、場合によっては何度もリクエストを投げてほしいケースもありそうです。
例えば、サーバが裏で補完候補を収集するが、比較的カンタンな候補を出したあとに、複雑なロジックで精度の高い補完候補を計算し直す場合などです。

こういう場合、LSP サーバは補完レスポンスに isIncomplete = true を返却することができます。
補完プラグインはキーワードが続いていたとしても isIncomplete = true が返ってきているならば、新しくリクエストをし直して候補を更新する必要があるということです。

context

LSP では、サーバに補完リクエストを送信する際に「その補完が行われた理由を送ることができる」と定義されています。

https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#completionContext

さて、ここまでが LSP における「補完を開始するまで」のざっくりとした登場人物です。

気づきましたでしょうか?ここに keyword_pattern は出てきません。

Vim における補完処理の概要 で説明したように、補完処理にはキーワードパターンがほぼ必須です。
補完候補のキャッシュや、補完開始位置の確定など、様々な箇所で利用されます。

LSP に定義されてないなら VSCode はどうやってるんだ?と思われるかもしれませんが、VSCode は拡張機能がキーワードパターンを LSP の定義の外で定義できる仕組みになっています。

https://github.com/bmewburn/vscode-intelephense/blob/master/src/extension.ts#L42

なぜこうなっているのか?というと、LSP はなるべく正規表現を仕様から除外する努力をしているからです。
一部、スニペットの定義に正規表現がでてきますが、それも JavaScript Regular Expression Grammer となっており、JavaScript を使っていないエディタでは対応するのが現実的ではないからです。

hrsh7thhrsh7th

VSCode と Vim の違い

ここまでで、Vim/LSP の補完処理の概要がわかりました。

ところで、LSP といえば VSCode ですね。
VSCode も大雑把に言えば上記のような仕組みで補完をしているわけですが、いくつかの特徴があります。

Vim

Vim は補完が行われたあと、ユーザが補完候補を選択した段階でバッファを変更します。
例えば

aiueo
ai|

この状態で補完を行うと aiueo という候補が表示されると思いますが、その候補を選択しただけで

aiueo
aiueo|

この状態になります。(自分はこの「選択したら挿入されるテキスト」を「一時的に挿入されるテキスト」と呼んでいます。)
ちなみに、Vim における素朴な補完はここで終了です。選んだ段階で目的は達成されます。

VSCode

VSCode には上記の「一時的に挿入されるテキスト」は存在しません。

どういうことかというと、VSCode は「候補が選択された段階」では何もせず、その後「候補を確定」すると、はじめてテキストが変更されます。

まとめ

  • Vim は「補完の確定」という処理が存在しない(候補を選択した時点でテキストを変化させる)
  • VSCode は「補完の選択」時点ではテキストを変化させない、「補完を確定」するとテキストを変化させる

これが Vim と VSCode の補完の大きな大きな違いです。
これがどう「大きな違い」となってくるのか、については後述のセクションで述べていきたいと思います。

hrsh7thhrsh7th

LSP の詳細な機能

ここまでで、Vim/LSP/VSCode における「補完の概要」と「補完処理の特徴」について理解できたかと思います。
ここでは主に LSP で定義された機能(これはほぼ = VSCode の実装)について見ていこうと思います。

補完の開始条件

ここまでの情報をまとめると、LSP における補完の開始条件は以下のようになるということがわかっているはずです。

  • keywordPattern
  • triggerCharacters
  • isIncomplete