koturnの日記

普通の人です.ブログ上のコードはコピペ自由です.

候補絞り込み型インターフェースを提供するプラグインについて

この記事は Vim Advent calendar 2015 の16日目の記事です.

僕は普段「だ・である」調でブログを書いていますが,今回の記事は多数の人に見ていただくことを考慮して,多くの人がブログで採用している「です・ます」調,語り口調で書いていくことにしましょう.

さて本題です. 候補を絞り込むインターフェースというのは人気で,Vimではunite.vimctrlp.vimなどが有名ですね. そして,コマンドラインツールfzfVimからも利用できるように,本体にVimプラグインが付属しています. また,ctrlp.vimにインスパイアされて開発されたプラグインとして,LeafCageさんによるalti.vimやkamichiduさんによるvim-milqiがあります. この記事では,前述の5つの候補絞り込み型インタフェースを提供するVimプラグイン

について,簡単な解説と比較をしたいと思います. FuzzyFindervim-kuについては取り上げません.

基本操作の比較

unite.vim

unite.vimでは,本体にたくさんの拡張が付属しており,第三者(特に日本人に多い)によって開発された拡張も多数公開されているので,自分で拡張を書く必要は無いですね. 基本的に, :Unite [unite source名] とすることで,unite.vimを起動できます.

unite.vim上でのキーマッピングは以下のようになっています. unite.vimは他の候補絞り込み型プラグインと異なり,ノーマルモードとインサートモードがあり,それぞれのモードで操作が異なります.

キーマッピング

ノーマルモード
キー 動作
i / I インサートモードに
a 候補選択時はアクションを選択,そうでなければ A と同じ
A 入力欄の末尾にカーソル移動をしてインサートモード
q or <C-g> unite.vim を終了し,1つ前のuntieバッファメニューを復元する
Q or g<C-g> unite.vim を終了する
<C-r> uniteを再起動する
<Space> / <S-Space> 候補の選択状態をトグル(複数選択できるsourceのみ)
* 全ての候補選択をトグルする(選択状態が反転する)
M 表示候補数を制限する
<Tab> アクションを選択する
<C-p> / <C-n> sourceを切り替える
<C-a> / <C-k> ログ / 候補を echo する
<C-l> 再描画する
<C-h> 1つ前のパスを削除する
gg or <C-Home> カーソルを1番上に
G or <C-End> カーソルを1番下に
j or <Down> カーソルを1つ下に
k or <Up> カーソルを1つ上に
J / K マッチしない候補をスキップしてカーソルを上 / 下に移動
g? 簡単なuniteのキーマッピングのヘルプを表示
N 候補を追加する(action_tableunite__new_candidate が定義されている場合のみ)
. ドットを絞り込み欄に入力し,インサートモードに
<CR> デフォルトアクションを行う
b ブックマークのアクションを行う
d 削除アクションを行う
e narrowアクションを行う
t 別のタブで開く
yy ヤンクアクションを行う
o オープンアクションを行う
x クイックマッチを用いて,デフォルトのアクションを行う(マークされた候補があるとダメ)
インサートモード
キー 動作
<Esc> ノーマルモード
<Tab> アクションを選択する
<C-n> or <Down> 次の行を選択
<C-p> or <Up> 前の行を選択
<C-f> / <C-b> 前 / 次の行を選択
<C-h> or <BS> カーソルの1つ前の文字を削除
<C-u> カーソルの前の入力を全て削除
<C-w> カーソルの前の単語を削除
<C-a> or <Home> カーソルを先頭に
<Left> / <Right> カーソルを右 / 左に移動
<C-l> 再描画する
<C-g> uniteを終了する
ビジュアルモード
キー 動作
<Space> 選択された範囲の候補全ての選択状態をトグル

本体付属のunite source

以下の表は,unite.vim本体から提供されているunite sourceの一部です. デフォルトのアクションについても併記しています.

他にもたくさんのunite sourceが付属しているので,調べてみると面白いと思います.

unite source名 機能
bookmark UniteBookmarkAdd でブックマークされたファイルを表示し,選択されたファイルを開く
buffer リストされているバッファ一覧を表示し,選択されたバッファを開く
change ファイルの変更履歴(:changeの結果)を表示し,選択された位置に移動
command 利用できるコマンドを表示し,選択されたコマンドをコマンドウィンドウに入力(引数のヒントも表示)
directory カレントディレクトリ以下のディレクトリを表示し,ファイラのように利用できる
file カレントディレクトリ以下のファイルとディレクトリを表示し,ファイラのように利用できる
file_rec カレントディレクトリ以下のファイルを再帰的に検索して表示し,ファイラのように利用できる
find 対象ディレクトリと名前を入力させて,findコマンドを実行した結果を表示
function 利用できる関数を表示し,選択された関数をコマンドウィンドウに入力(引数のヒントも提示)
grep 外部コマンド grep を行うように促し,grepを行った結果を一覧として表示.選択された位置に移動する
history/unite 過去に利用したunite sourceの履歴を表示
jump ジャンプリスト(:jumpsの結果)を表示し,選択された位置に移動
launcher パスが通っているところから,実行可能ファイルを候補として表示する
line 現在のバッファの全ての行を候補として表示し,選択された行に移動
mapping 全てのキーマッピングを表示し,選択されたものを実行する
output Vimコマンドを入力を促し,入力されたコマンドの出力を候補とする
process 起動中のプロセス一覧を表示し,選択した候補にTERMシグナルを送る(確認あり)
register Vimレジスタを表示し,選択されたレジスタの内容を現在位置に挿入
runtimepath runtimepath に追加されているディレクトリ一覧を表示し,選択されたディレクトリに移動
source unite sourceの一覧を表示し,選択されたsourceをもとにUniteを起動
tab 現在利用中のVimのタブ一覧(とタブ内のバッファをヒントとして)を表示し,選択されたタブに移動
vimgrep :vimgrep を行うように促し,vimgrepを行った結果を一覧として表示.選択された位置に移動する
window 現在のタブのウィンドウ一覧を表示し,選択されたウィンドウに移動

ctrlp.vim

プラグイン名になっているように,デフォルトでは <C-p> でCtrlPを起動します. あるいは, :CtrlP というコマンドで起動してもよいでしょう.

unite.vimと同様,ctrlp.vimの本体からも多数の拡張が提供されており,それぞれの拡張は :CtrlPXXXX のようなコマンドを実行することで利用可能です.

CtrlPバッファでのキーマッピングは以下のようになっています.

キー 動作
<C-c> or <Esc> CtrlPを終了する
<C-f> / <C-b> エクステンションを切り替える
<C-a> / <C-e> 入力ウィンドウのカーソルを先頭 / 最後に
<C-j> / <C-k> 候補ウィンドウのカーソルの上下移動
<C-d> フルパス検索モードとファイル名のみの検索モードを切り替え
<C-r> 正規表現検索モードと通常の検索モードのトグル
<CR> 同じタブに開く
<C-t> 新しいタブに開く
<C-v> 垂直分割で開く
<C-x> or <C-s> or <C-CR> 水平分割で開く
<C-p> 1つ前の入力履歴を入力欄に入れる
<C-n> 1つ後の入力履歴を入力欄に入れる
<F5> キャッシュを更新する
<F7> 選択した(マークを付けた)候補を候補から除外する
<C-z> 対象ファイルにマークを付ける(選択状態にする)

また,ctrlp.vimでは,:CtrlP 以外にもコマンドが提供されています. これらは, autoload/ctrlp.vim で定義されている組み込みの拡張とも言えるでしょう.

コマンド 機能
:CtrlPMRUFiles [dir] 最近利用したファイルを表示し,選択されたファイルを開く
:CtrlPBuffer リストされているバッファを表示し,選択されたバッファに移動する
:CtrlPLastMode [args] 最後に利用したCtrlP拡張を実行する,[args]--dir が指定されたとき,最後のワーキングディレクトリも用いる
:CtrlPClearCache or :ClearCtrlPCache CtrlPのキャッシュを削除する
:CtrlPAllClearCache or :ClearAllCtrlPCaches CtrlPのキャッシュを全て削除する
:CtrlPCurWD カレントディレクトリ以下のファイルを再帰的に表示
:CtrlPCurFile 現在編集中のファイルのディレクトリ以下のファイルを再帰的に表示
:CtrlPRoot わからない....

先に述べたように,ctrlp.vimの本体からは多数の拡張が提供されています. 先に述べた組み込みの拡張とは違い,多数の拡張は autoload/ctrlp/xxx.vim で定義されており,本体から分離されております.

拡張名 対応するコマンド 機能
tag :CtrlPTag 見つかったtagsファイル(オプション tags に依存)の項目を表示し,選択された項目の位置へジャンプする
buffertag :CtrlPBugTag [buffer] カレントバッファ,または指定されたバッファからtagsファイルの項目を表示し,選択された項目の位置へジャンプする
buffertag :CtrlPBugTagAll リストされているバッファ
quickfix :CtrlPQuickfix quickfixにある項目を表示し,選択されたエラー位置に移動する
dir :CtrlPDir [dir] カレントディレクトリ,または指定されたディレクトリ以下のディレクトリを表示し,選択されたディレクトリをワーキングディレクトリにする
rtscript :CtrlPRTS runtimepath 以下にあるファイルを表示し,選択されたファイルを開く
undo :CtrlPUndo Undo履歴を表示し,選択されたところまでundoする
list :CtrlPLine リストされている全てのバッファ,または指定されたバッファの行を表示し.選択された位置にジャンプする
changes :CtrlPChange [buffer] カレントバッファ,または指定されたバッファの変更箇所を表示し,選択された位置にジャンプする
changes :CtrlPChangeAll リストされているバッファ全ての変更履歴を表示し,選択された位置にジャンプする
mixed :CtrlPMixed カレントディレクトリ以下のファイル,バッファ,最近使用したファイルを表示し,選択されたものを開く
bookmarkdir :CtrlPBookmarkDir ブックマークされているディレクトリを表示し,
bookmarkdir :CtrlPBookmarkDirAdd [dir] カレントディレクトリ,または指定されたディレクトリをブックマークする(選択後,ブックマークのタイトルの入力が求められる)

fzf

fzfはコマンドラインツールとして呼び出されるので,Vimとして操作することはできません. 用意されているマッピングは以下のように単純なものです.

キー 動作
<C-c> or <C-g> or <Esc> fzfを終了する
<C-f> / <C-b> カーソルを移動する
<CR> 候補を選択する
<S-Tab> 複数選択可能なとき(オプション -m or --multi が指定されているとき),候補にマークを付ける

本体から拡張は提供されていないので,自分で書くことになるでしょう. 公式のwikiにサンプルが多数ありますので,それらを ~/.vimrc にコピペなどすればよいかと思います.

alti.vim

alti.vimグローバル変数 g:alti_default_mappings_base'standard''ctrlplike' を代入することで,操作のプリセットを変更することができます. このグローバル変数のデフォルト値は 'standard' なので,何もしなかったり,無効な値を入力すると,standardのキーマッピングになるでしょう.

standard

キー 動作
<BS> or <C-h> 入力欄のカーソルの前1文字を削除
<Del> or <C-d> 入力欄のカーソルの後ろ1文字を削除
<C-w> 入力欄のカーソルの前1単語を削除
<C-u> 入力欄をクリアする
<C-r> レジスタ挿入モードに
<C-x><C-n> or <C-_> 1つ後の入力履歴を入力欄に入れる
<C-x><C-p> or <C-s> 1つ前の入力履歴を入力欄に入れる
<C-a> / <C-e> 入力欄の先頭 / 末尾にカーソルを移動
<C-b> or <Left> 入力欄のカーソルを左に
<C-f> or <Right> 入力欄のカーソルを右に
<C-j> or <PageDown> or <kPageDown> 候補欄で次のページを表示する
<C-j> or <PageDown> or <kPageDown> 候補欄で前のページを表示する
<C-n> or <Down> 候補欄のカーソルを1つ下に
<C-p> or <Up> 候補欄のカーソルを1つ上に
<C-g>g or <C-g><C-g> or <Home> or <kHome> 候補窓で先頭にカーソルを移動
<C-g>G or <End> or <kEnd> 候補窓で末尾にカーソルを移動
<Tab> 候補窓のカーソル下の候補を,入力欄に入力する
<C-o> 選択候補についてのアクションメニューを出す
<Esc> or <C-c> alti.vimを終了する
<CR> 入力(あるいはカーソル下の候補)に基づいて,アクションを行う
<C-y> 拡張の定義辞書のキー default_actions に指定されたリストの0番目に指定されたアクションを実行
<C-v> 拡張の定義辞書のキー default_actions に指定されたリストの1番目に指定されたアクションを実行

ctrlplike

キー 動作
<BS> or <C-]> 入力欄のカーソルの前1文字を削除
<Del> or <C-d> 入力欄のカーソルの後ろ1文字を削除
<C-w> 入力欄のカーソルの前1単語を削除
<C-u> 入力欄をクリアする
<C-r> or <C-\> レジスタ挿入モードに
<C-n> 1つ後の入力履歴を入力欄に入れる
<C-p> 1つ前の入力履歴を入力欄に入れる
<C-a> / <C-e> 入力欄の先頭 / 末尾にカーソルを移動
<C-h> or <Left> 入力欄のカーソルを左に
<C-l> or <Right> 入力欄のカーソルを右に
<C-f> or <PageDown> or <kPageDown> 候補欄で次のページを表示する
<C-b> or <PageDown> or <kPageDown> 候補欄で前のページを表示する
<C-j> or <Down> 候補欄のカーソルを1つ下に
<C-k> or <Up> 候補欄のカーソルを1つ上に
<C-g>g or <C-g><C-g> or <Home> or <kHome> 候補窓で先頭にカーソルを移動
<C-g>G or <End> or <kEnd> 候補窓で末尾にカーソルを移動
<Tab> 候補窓のカーソル下の候補を,入力欄に入力する
<C-o> 選択候補についてのアクションメニューを出す
<Esc> or <C-c> alti.vimを終了する
<CR> 入力(あるいはカーソル下の候補)に基づいて,アクションを行う
<C-y> 拡張の定義辞書のキー default_actions に指定されたリストの0番目に指定されたアクションを実行
<C-v> 拡張の定義辞書のキー default_actions に指定されたリストの1番目に指定されたアクションを実行

このプラグインも本体に拡張は付属していませんが,使い勝手はかなり良いので,拡張を自分で作って利用するとよいでしょう.

vim-milqi

vim-milqiはドキュメントが無いので,ソースコードを読んだ結果を書きます. このあたりを見る限り,ctrlp.vimから持ってきてると推測できるので,ほとんどctrlp.vimと同じ操作であると言ってよいでしょう.

vim-milqiの本体にも拡張は付属していませんが, :MilqiFromUnite というコマンドが提供されており,引数にunite source名を指定することで,unite sourceから候補を取得し,アクションを実行することができます. 他のプラグインでは提供されていない機能なので,なかなか面白いと思いました. しかし,unite sourceの候補にkindが指定されていない場合,エラーとなるので注意が必要です.

拡張の作りやすさの比較

各絞り込み検索プラグインの拡張の作りやすさを比較してみましょう. まず,拡張において実現できる特徴的なことを比較すると,以下のようになります.

プラグイン 複数選択 複数のアクションの提供 非同期の候補取得 プロンプトの変更
unite.vim ○(起動オプション)
ctrlp.vim △(候補選択時のキー) × ×
fzf × ×
alti.vim × ◎(動的に変更可)
vim-milqi × × ×

ここでいう「非同期の候補取得」とは,定期的に候補を取得処理を呼び出し,選択可能な候補数を増やす(あるいは減らすこともできるでしょう)ことです. Vimでは非同期処理をする手段がちゃんとした形で提供されていないので,非同期の候補の取得は,Shougo/vimproc.vimと, updatetimefeedkeys() , そして, CursorHoldCursorMoved という autocmd を組み合わせたポーリングによって行われるのが主流です. +clientserver の機能と RemoteReply を組み合わせても非同期処理は実現できますが,+clientserver になっているVimは多くないので,こちらが採用されることはありません.

非同期の候補取得でアニメーションを実現するという面白い使い方もあり,例えばsupermomonga/jazzradio.vimでは音量レベルのようなアニメーションを行うunite sourceが提供されています.

さて,同じ目的を達成するそれぞれの拡張を作ってみましょう. 今回は「apple, banana, cakeという3つの候補を表示し,それぞれ選択されたものを echomsg する」という非常に単純なものを作ることにします.

とにかく,コードを書かないと始まりませんね. 以下,それぞれのコードと解説です. なお,このサンプルの拡張は koturn/vim-exts にあります.

plugin/exts.vim : インタフェース

if exists('g:loaded_exts')
  finish
endif
let g:loaded_exts = 1
let s:save_cpo = &cpo
set cpo&vim

command! CtrlPExts  call ctrlp#init(ctrlp#exts#id())
command! FZFExts  call fzf#run(fzf#exts#option())
command! AltiExts  call alti#init(alti#exts#define())
command! MilqiExts  call milqi#candidate_first(milqi#exts#define())
" command! MilqiExts  call milqi#query_first(milqi#exts#define())

let &cpo = s:save_cpo
unlet s:save_cpo

インターフェースを定義しているだけなので,特に言及することはありません. uniteだけはコマンド定義しなくて良いので楽ですね. vim-milqiを用いるコマンドについて,コメントアウトしているものがありますが,これについてはvim-milqiの項目で解説します.

autoload/unite/sources/exts.vim : unite.vimの拡張

let s:save_cpo = &cpo
set cpo&vim


let s:source = {
      \ 'name': 'exts',
      \ 'description': 'descriptions',
      \ 'action_table': {},
      \ 'default_action': 'my_action'
      \}

let s:source.action_table.my_action = {
      \ 'description': 'my action'
      \}
function! s:source.action_table.my_action.func(candidate) abort
  echomsg a:candidate.word
endfunction

function! s:source.gather_candidates(args, context) abort
  return map(['apple', 'banana', 'cake'], '{
        \ "word": v:val
        \}')
endfunction


function! unite#sources#exts#define() abort
  return s:source
endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

unite#sources#exts#define() でunite sourceに関する辞書を返却するようにします. 今回は word のみ指定していますが, abbrkind など,他にも重要な項目もありますので, :h unite-notation-{candidate} で調べてみるとよいでしょう. この関数はunite.vim本体から呼び出されます.

上記の例では,myaction というアクションのみを source に対して定義し,<CR> で選択したときのアクションである default_action に指定しています.

また,今回はやっていませんが,s:source.action_table.my_actionis_selectable: 1 を追加すると複数選択できるようになり,s:source.action_table.myaction.func() の引数が,選択した候補の辞書ではなく,複数の辞書を含むリストになります.

なお,作成したunite sourceに引数を渡して起動したい場合は, Unite exts:arg1:arg2 のように,コロン区切りで引数を指定する形になります. この引数は, s:source.gather_candidates の第一引数 a:args というリストで受け取ることができます. この引数を補完する関数は, s:source のキー complete に,補完関数の関数参照を指定します.

function! s:source.complete(args, context, arglead, cmdline, cursorpos) abort
  return filter(['foo', 'bar', 'piyo'], 'v:val =~? ^"' . a:arglead . '"')
endfunction

のような形になり, <Tab> によって選択できる候補のリストを返却すればよいだけです. ユーザー定義コマンドの補完関数と似たようなもので,引数が2つほど増えているだけですね.

ちなみに,アクションはunite kindとして分離可能であり,丁寧なunite拡張を書くのであれば,候補の取得はsourceで,アクションの担当はkindにした方がよいでしょう(前述の通り,vim-milqiはKindを利用します). kindを指定しなかった場合,untie.vimではkindにcommonが指定されたものとします.

autoload/ctrlp/exts.vim : ctrlp.vimの拡張

if get(g:, 'loaded_ctrlp_exts', 0)
  finish
endif
let g:loaded_ctrlp_exts = 1
let s:save_cpo = &cpo
set cpo&vim

let s:ctrlp_builtins = ctrlp#getvar('g:ctrlp_builtins')

function! s:get_sid_prefix() abort
  return matchstr(expand('<sfile>'), 'function \zs<SNR>\d\+_\zeget_sid_prefix$')
endfunction
let s:sid_prefix = s:get_sid_prefix()
delfunction s:get_sid_prefix

let g:ctrlp_ext_vars = add(get(g:, 'ctrlp_ext_vars', []), {
      \ 'init': s:sid_prefix . 'init()',
      \ 'accept': s:sid_prefix . 'accept',
      \ 'lname': 'sample extension',
      \ 'sname': 'sample',
      \ 'type': 'line',
      \ 'nolim': 1
      \})
let s:id = s:ctrlp_builtins + len(g:ctrlp_ext_vars)
unlet s:ctrlp_builtins s:sid_prefix


function! ctrlp#exts#id() abort
  return s:id
endfunction


function! s:init() abort
  let candidates = ['apple', 'banana', 'cake']
  return candidates
endfunction

function! s:accept(mode, str) abort
  call ctrlp#exit()
  echomsg a:str
endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

ctrlp.vimの拡張の作り方に関しては,以下の記事に詳しく書いてあるので,そちらを見ていただいた方がよいでしょう.

ざっと説明すると, s:init() は候補の取得を担当する関数で, s:accept() がアクションを担当する関数になります. ctrlp#exts#id() は,この拡張のIDを返却する関数で,ctrlp.vimの本体の関数 ctrlp#init() に渡すことで,ctrlpが起動します. 他の候補選択型インターフェースと比較して,作成した拡張の起動に辞書をそのまま渡せる形にしてほしかったと感じます.

autoload/fzf/exts.vim : fzfの拡張

let s:save_cpo = &cpo
set cpo&vim


function! fzf#sampleoption() abort
  return {
        \ 'down': 20,
        \ 'sink': function('s:sink'),
        \ 'source': s:gather_candidates()
        \}
endfunction

function! s:gather_candidates() abort
  return ['apple', 'banana', 'cake']
endfunction

function! s:sink(candidate) abort
  echo len(readfile(a:candidate))
endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

fzfはファイルを分離する必要は無いのですが,他のプラグインに習って分離してみました. 拡張の作り方については,以前書いた

を参考にするとよいでしょう.

autoload/alti/exts.vim : alti.vimの拡張

let s:save_cpo = &cpo
set cpo&vim

function! s:get_sid_prefix() abort
  return matchstr(expand('<sfile>'), 'function \zs<SNR>\d\+_\zeget_sid_prefix$')
endfunction
let s:sid_prefix = s:get_sid_prefix()
delfunction s:get_sid_prefix

let s:define = {
      \ 'name': 'exts',
      \ 'cmpl': s:sid_prefix . 'cmpl',
      \ 'prompt': s:sid_prefix . 'prompt',
      \ 'submitted': s:sid_prefix . 'submitted'
      \}
unlet s:sid_prefix

function! alti#exts#define() abort
  return s:define
endfunction


function! s:cmpl(context) abort
  return a:context.filtered(self.candidates)
endfunction

function! s:prompt(context) abort
  return 'exts> '
endfunction

function! s:submitted(context, line) abort
  if len(a:context.inputs) == 0
    echomsg a:context.selection
  else
    for input in a:context.inputs
      echomsg input
    endfor
  endif
endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

alti.vimはctrlp.vimに不満があって作成されたプラグインだけあって,拡張の作成はctrlp.vimと似た雰囲気になります. しかし,ctrlp.vimよりパワフルな拡張を作ることが可能になっています. alti.vimは入力することに重きを置いたプラグインという雰囲気です.

上記コードにおいて,候補の取得とフィルタリングは s:cmpl() が,アクションは s:submitted() が担当しています. 他の候補絞り込み型プラグインと異なり,s:cmpl() で入力に対する候補の絞り込みを実装する必要があります. alti.vim本体には4つのフィルタリング関数が(引数 context の辞書関数として)用意されており,それぞれ

  • 前方一致:a:context.filtered()
  • 後方一致:a:context.backward_filtered()
  • 部分一致:a:context.partial_filtered()
  • 曖昧一致:a:context.fuzzy_filtered()

となっています. もちろん,自前でフィルタリングを実装してもよいですが,基本的には上記4つのいずれかを用いることで解決できるでしょう.

s:submitted() において,カーソル下にあった候補は, a:context.selection に格納され,入力された候補は a:context.inputs にリストとして格納されています. alti.vimでは,スペース区切りで候補の入力が認識されるようになっていて,複数の候補を選択する場合は,スペース区切りで入力することになります. <Tab> を入力することで,候補窓のカーソル下の候補が入力欄に挿入されるので,ユーザとしては,

  1. 絞り込みクエリを入力し,候補を絞り込む
  2. 候補窓でカーソル移動をして,候補を <Tab> で完全に入力欄に入力する
  3. <CR> で選択を終え,アクションを実行する

という流れになると思います. alti.vim的には,ユーザが <CR> でカーソル下の単語を選択してアクションを実行するのではなく, <CR> によって,入力された候補を送信することが想定されていると思われます. したがって, a:context.selection は基本的にアクション側で見る必要はないでしょう(ただし,受理できる入力が無かった場合は,カーソル下の候補に対してアクションを行うというのもアリかもしれません). なお,入力された候補は,候補窓からは除外されるようになっているので,重複選択の恐れはありません. 手動で重複した入力を与えたとしても,候補から除外されます.

入力欄に入力されたもののうち,元々の候補群になかったものは除外されて a:context.inputs に格納されます. つまり,ユーザがでたらめな入力をしたとしても,それらは全て除外されるということです.

なお,上記コード中の辞書 s:define の中で,値が関数名の文字列となっているものは,関数参照を取ることも可能です. 関数名を渡すより,関数参照を渡した方がコードとしてはシンプルになるのではないかと思います.

autoload/milqi/exts.vim : vim-milqiの拡張

let s:save_cpo = &cpo
set cpo&vim


let s:define = {'name': 'exts'}

function! milqi#exts#define() abort
  return s:define
endfunction

function! s:define.init(context) abort
  " let context.i = 0
  return ['apple', 'banana', 'cake']
endfunction

function! s:define.accept(context, candidate) abort
  call milqi#exit()
  echomsg a:candidate
endfunction

" function! s:define.lazy_init(context, query) abort
"   let a:context.i += 1
"   if query ==# ''
"     return {
"           \ 'reset': 0,
"           \ 'candidates': map(['lazy_apple', 'lazy_banana', 'lazy_cake'],
"           \   'v:val . " - " . a:context.i')
"           \}
"   else
"     return {
"           \ 'reset': 1,
"           \ 'candidates': ['apple_query', 'banana_query', 'cake_query']
"           \}
"   endif
" endfunction

" function! s:define.async_init(context) abort
"   let a:context.i += 1
"   return {
"         \ 'done': a:context.i < 6 ? 0 : 1,
"         \ 'candidates': map(['async_apple', 'async_banana', 'async_cake'],
"         \   'v:val . " - " . a:context.i')
"         \}
" endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

辞書関数ばかりとなっていますが,コードの雰囲気は,ctrlp.vimと似ていますね. 今回の例では活用していませんが(例が悪いのは申し訳ありません),vim-milqiの一番の特徴は, query-firstcandidate-first という2つのインタラクションモードです. 上記コード中では s:define.lazy_inits:define.async_init が,それらのインタラクションモードでのアクションに関係しています この2つのインタラクションモードは,vim-milqiのセールスポイントを語る上では欠かせない要素です. 以下,2015年3月29日のLingrの過去ログからの引用です.

query-firstは入力に応じて動的に内容が変化する系,candidate-firstは単なる非同期ctrlp系ですね candidate-firstだと,例えば時間のかかる処理で候補の取得に時間のかかるもの向けで考えていて query-firstだと,例えば過去の選択状態に応じて最初に一部の候補をキャッシュから出し,入力があれば入力に対応した候補を動的に生成して出すということができます candidate-firstはuniteやctrlpを考えていただければイメージは掴みやすいと思います query-firstは,こっちが私のやりたいメインですが,例えば初期表示でa, bを表示,hogeと入力したときにc, d, eを表示,hogeを消してfugaと入力したときにf, g, h, iを表示,ということができます

candidate-first

candidate-first は,unite.vimにもある「非同期の候補の取得ができるもの」といえるでしょう. candidate-firstモードとして利用する場合, milqi#candidate_first() に拡張の定義辞書を渡します. この辞書のキー async_init に関数参照が指定されていれば, updatetime ハックにより,定期的にその関数がコールされます. async_init に指定された関数は,donecandidates をキーに持つ辞書を返却する必要があります. キーの名前の通り, done は非同期処理が終了したかどうかを, candidates は非同期で取得した候補を表します. candidate に指定したリストは既にある候補群に追加されるので,拡張を作成する側が候補リストの要素に追加するといった処理は不要です. done1 を指定したとき,非同期処理は完了したものとして,関数を定期的なコールが終了します.

query-first

query-first は,入力に応じて,候補を動的に生成する,といったものらしいです. query-firstモードとして利用する場合, milqi#candidate_first() に拡張の定義辞書を渡します. この辞書のキー lazy_init に関数参照が指定されていれば, updatetime ハックにより,定期的にその関数がコールされます. ここまでは,candidate-firstモードと変わらないですが, lazy_init に指定した関数は第二引数に入力クエリを受け取ることができます. そして,返り値は resetcandidates をキーに持つ辞書を返却しなくてはなりません. candidates に指定したリストが候補群に追加されていくのは,candidate-firstモードの async_init と同様ですが, reset1 を指定することで,候補群を(ただの init で返却した候補リストも含めて)全て除去することができます. 入力クエリと reset を活用して,候補を動的に変更するのが,query-firstモードの使い方といえるでしょう.

単純な例の場合

今回のような単純な例(拡張辞書に lazy_initasync_init のどちらも持たない例)では,どちらのモードでも(すなわち, milqi#query_first()milqi#candidate_first のどちらに拡張の定義辞書を渡しても),動作に違いは無いでしょう.

なお,上記コード中のコメントアウト部分を解除し, plugin/exts.vimvim-milqiに関する部分を適切に変更することで,candidate-first , query-firstのそれぞれを体験することができるようにしています. candidate-firstの場合は,関数が5回コールされるまで,候補が増えるようにしています. query-first の場合,何か入力すると,候補が apple_querybanana_querycake_query の3つだけになるようにしています.

感想

この記事では5つの候補絞り込み型インターフェースを提供するプラグインについて,簡単に説明しました. どれもざっと見た限りでは「候補絞り込み型」という風にまとめられますが,それぞれが実現したいことや,目指しているものが少しずつ違っていて面白いですね.

unite.vimは初めから拡張を見越して作られた分,第三者にとってはかなり自由かつパワフルに拡張を作ることができるようになっています. また,作成したsourceの呼び出しは, :Unite のサブコマンド,すなわち第一引数にsource名を与える形になっているので,source作成者が plugin/ 下のファイルにコマンドを定義しなくてよいのも魅力です. :Unite のオプション引数である -auto-preview などは,uniteにしかできないことでしょう.

ctrlp.vimは元々がファイラであり,拡張が後付けのようなものであるため,作ることのできる拡張にやや不自由があるのは否めません. 例えば,複数選択がファイルを開くという操作に限定されるので,第三者にとっては利用しにくいものでしょう. しかし,コンパクトな実装である分,unite.vimより初回の起動時間が短い点は評価できるでしょう.

fzfは外部コマンドとして利用するので,一時的にVimから出る形になります. その点が他の絞り込み検索型のプラグインと比べると異質なところではあります. しかし,tmux上であれば,画面分割を利用し,候補を表示することができたり,neovimであればneovimのターミナルエミュレーター上で起動するようにしていたり,とても面白い機能が実装されています.

alti.vimはctrlp.vimにインスパイアされて開発されたプラグインだけあって,細かい部分での配慮が行きとどいていますね. 本体から前方一致,後方一致,部分一致,ファジーマッチという4つの絞り込み関数が提供されているのも,なかなかよいと感じました. ctrlp.vimはファジーマッチしかできないようになっており,僕としては多数の候補のフィルタリングに苦労することがありました.

vim-milqiは,unite souceから候補を取得できるようにしているのは画期的な機能だと思いました. また,非同期処理を主眼に置いており,他の絞り込み検索プラグインには無い特徴を感じ取ることができました.

おわりに

絞り込み検索型プラグインの拡張を作ることは難しくありません. この記事で紹介したような,たった十数行程度のコード(ほとんどが定型句のようなもので,本質的な部分は数行程度)を書くだけで,自作のプラグインから絞り込み検索型プラグインのUIを利用することができます. もし,これを読んでいるあなたがプラグイン作成の初心者であり,unite sourceやctrlp.vimの拡張を書いたことが無いのであれば,この機会に挑戦してみるのはいかがでしょうか?

なお,unite.vimは日本での知名度がとても高く,ググれば多数の情報が出てくると思います. unite.vimは高機能なため,この記事では紹介しきれていないので,unite.vimの本当の力を知るためにもググることをオススメします.

参考