🥿

Vimのビルトイン補完をフォールバックして順番に検索する

2024/07/28に公開

さいきん、Vimのキーワード補完を自動でトリガーする方法と、ファイルパス補完を連続で呼び出す方法についての記事を書きました。

https://zenn.dev/kawarimidoll/articles/c14c8bc0d7d73d

https://zenn.dev/kawarimidoll/articles/54e38aa7f55aff

これらを使っていて、自動トリガーで出る候補にファイルパス補完も含めたいと感じました。しかし、'complete'オプションにはファイルパスの設定はありません。

したがって、「とりあえずファイルパス補完を実行し、それがヒットしなければキーワード補完を実行する」というアイデアを思いつきました。ついでに、途中にファイルタイプごとの補完を挟んでも良さそうです。

ということで、作りたい処理は以下のようになります。

  1. ファイル補完(<c-x><c-f>)を実行する
    a. ここで候補が表示されていたら終了
  2. ファイルタイプごとの補完を実行する
    a. vimにはVim補完(<c-x><c-v>)がある
    b. 他のファイルはomni補完(<c-x><c-o>)を使う
    c. ここで候補が表示されていたら終了
  3. キーワード補完(<c-n>)を実行する

一つが失敗したら次の手を実行する、いわゆるフォールバックというやつです。

各補完は実行するための関数などは用意されていないので、キー入力を使う必要があります。つまりfeedkeys()が必要です。また、候補があるかの確認にはpumvisible()を使えそうです。
以下が素朴な実装です。

fallback_cmp.vim 動かないバージョン
" 候補存在確認のためメニューを表示させる必要がある
set completeopt=menuone,noselect

function! s:fallback_cmp() abort
  " ファイルパス補完を実行
  call feedkeys("\<c-x>\<c-f>", 'n')
  " 候補が表示されていたら終了
  if pumvisible()
    return
  endif

  " vimファイルならVim補完を、omnifuncがあるならOmni補完を実行
  if &filetype == 'vim' || &filetype == 'help'
    call feedkeys("\<c-x>\<c-v>", 'n')
  elseif !empty(&omnifunc)
    call feedkeys("\<c-x>\<c-o>", 'n')
  endif
  " 候補が表示されていたら終了
  if pumvisible()
    return
  endif

  " キーワード補完を実行
  call feedkeys("\<c-n>", 'n')
endfunction

" 起動キーの設定
inoremap <c-x><c-b> <cmd>call <sid>fallback_cmp()<cr>

残念ながら、これは適切に動きません。なぜかというと…

そう、feedkeys()の仕様のためですね。

feedkeys({string} [, {mode}])        *feedkeys()*
    {string}中の各文字を、あたかもマッピングまたはユーザーによって
    タイプされたかのように、処理キューに入れる。

    デフォルトではこれらの文字は先行入力バッファの末尾に付け足され
    る。そのためマッピングを展開している途中であれば、これらの文字
    はマッピングを展開した後に来ることになる。他の文字の前に挿入す
    るには、'i' フラグを使用する。それらはマッピングからの任意の文
    字の前の挿入の次に実行される。

あくまで「処理キューに入れる」処理なので、記述の順に実行されるとは限らないのでした。
call feedkeys("\<c-x>\<c-f>\<cmd>echomsg 'file cmp'\<cr>", 'n')みたいにプリントデバッグを挟むとわかりますが、関数が最後まで行ってからfeedkeysが実行されます。'i'フラグを足すと逆順になります。

幸い、わたしたちはこの問題を解決する黒魔術を知っています。

https://zenn.dev/kawarimidoll/articles/bd962ed91a6984

feedkeys()内でキーの入力と関数の再帰を行うことで、順番問題を解消できます。可読性は死にます。黒魔術ゆえ致し方ありません。
エントリーポイントの関数ひとつで対応することもできるのですが、「入力するキーのリストを作る関数」と「リストを消費しながら再帰する関数」の二つを用意するとわかりやすいでしょう。以下に示します。

" 候補存在確認のためメニューを表示させる必要がある
set completeopt=menuone,noselect

" feedkeys再帰を使って補完をトリガーする関数
function! s:rec_cmp(list) abort
  " 補完が発動した場合、またはリストが尽きた場合は終了
  if pumvisible() || empty(a:list)
    return
  endif

  " スクリプトローカル関数の再帰のためにsidを取りだす
  let sid = expand("\<SID>")
  " リストの先頭をfeedkeysで入力し、残りを引数として渡して再帰
  let [head; rest] = a:list
  " 直前の補完モードを終了させるため<c-x><c-z>を挟む
  call feedkeys($"\<c-x>\<c-z>{head}\<cmd>call {sid}rec_cmp({rest})\<cr>", 'ni')
endfunction

" フォールバック補完の順番リストを生成する関数 最初に呼び出される
function! s:fallback_cmp() abort
  " 補完キーのリスト
  let list = []

  " ファイルパス補完をリストに追加
  call add(list, "\<c-x>\<c-f>")

  " Vim補完 / Omni補完をリストに追加
  if &filetype == 'vim' || &filetype == 'help'
    call add(list, "\<c-x>\<c-v>")
  elseif !empty(&omnifunc)
    call add(list, "\<c-x>\<c-o>")
  endif

  " キーワード補完をリストに追加
  call add(list, "\<c-n>")

  " 再帰補完関数を呼び出す
  call s:rec_cmp(list)
endfunction

" 起動キーの設定
inoremap <c-x><c-b> <cmd>call <sid>fallback_cmp()<cr>

これで、いい感じに文脈に応じて補完が選択されているかのように見える動きができます。たまに思わぬ候補にヒットして誤爆することがありますが。

過去記事で紹介した補完トリガー関数で呼び出すと自動で実行されて便利です。

 function! s:auto_cmp_start() abort
   " 省略

-  call feedkeys("\<c-n>", 'ni')
+  call s:fallback_cmp()
 endfunction

フォールバックの仕組みが参考になれば幸いです。

Discussion