koturnの日記

転職したい社会人1年生の技術系日記

Vimからfzfを利用する

はじめに

fzfとは,percolpecoと同様,絞り込みの検索を行うことのできるコマンドラインツールである. 日本では,percolやpecoが有名で,fzfはあまり有名ではないが,海外では有名であるらしい. fzfはVimから利用できるように,公式のリポジトリAPIを提供するVimプラグインが付属している. この記事では,fzfをVimから使う方法について述べる.

fzfの特徴

fzfはGoで実装されている. Goで実装されているから,マルチプラットフォームなのかと思ってしまうが,実は Windowsでは利用することができないCygwin上では利用可能).

fzfを利用したプラグインの作り方

正直,fzfのREADME.mdwikiのサンプルを見るのが早いのだが,それではこの記事の意味が無いので,ちゃんと書く. (日本語で書いておくと,日本人が読みやすいという利点もあるだろうし)

まず,fzfをVimから利用するためには,fzfが提供しているプラグインruntimepath を通す必要がある. これはfzfをクローンした場所にもよるが,

$ git clone https://github.com/junegunn/fzf.git ~/.fzf

としたならば,

set rtp+=~/.fzf

とするだけでよい. すなわち,fzfのルートディレクトリを runtimepath に追加するだけでよい. 上記の場合,fzfのVimプラグイン本体は ~/.fzf/plugin/fzf.vim にあるので,Vimからfzfの関数を利用できるようになるわけだ.

fzfをVimから利用するのは簡単で, fzf#run() に渡す適切な辞書を定義するだけだ. 後は,その辞書を第一引数とし, fzf#run() をコールするだけで,Vimからfzfを利用できる. 例えば,wikiには,

nnoremap <silent> <Leader>C :call fzf#run({
    \ 'source': map(split(globpath(&rtp, "colors/*.vim"), "\n"),
    \         "substitute(fnamemodify(v:val, ':t'), '\\..\\{-}$', '', '')"),
    \ 'sink': 'colo',
    \ 'options': '+m',
    \ 'left': 30
    \})<CR>

というfzfを用いるキーマッピングの例が紹介されている. これは,利用可能なcolorschemeを候補として表示し,選択したcolorschemeに変更するというものだ.

この fzf#run() に渡す辞書のキーと値には以下のようなものが有効である. 同じキーであっても,値の型によって挙動が異なる点に注意しよう.

  • source
    • 値の型が 文字列 のとき,その文字列を外部コマンドとして実行し,その結果をfzfに渡す
    • 値の型が リスト のとき,このリストをfzfに渡す
  • sink
    • 値の型が 文字列 のとき,選択した候補に対してその文字列をVimコマンドとして実行する
      • 候補は文字列の末尾に結合される(例:値が 'edit' ならば,実行されるコマンドは 'edit [選択したもの]'
    • 値の型が 関数参照 のとき,選択した候補を指定した関数(への参照)の第一引数として渡し,関数をコール
      • 指定する関数参照の引数は1つでなければならない
    • 複数の候補が選択されたとき,それぞれの候補に対して, sink で指定したアクションを1回1回実行する
  • sink*
    • 値の型は 関数参照 のみで,選択した候補を指定した関数(への参照)の第一引数として渡し,関数をコール
    • 複数選択された候補を リストとして扱う
      • すなわち,選択された候補はリスト化されて,関数の第一引数に渡される
  • options
    • 値の型は 文字列 のみで,fzfの起動オプションを指定する
  • dir
    • 値の型は 文字列 のみで,fzf起動時の作業ディレクトリを指定する
  • updownleftright
    • 値の型は 数値 または 文字列
      • 数値型では行数,または列数を指定できる( 20 など)
      • 文字列型では行,または列の占める割合を指定できる( '50%' など)
      • 文字列型の場合,末尾に % を付けないと,行数,または列数の指定になる( '50' では50行,または50列で50%でない )
    • tmux上でVimを起動している場合のみ
  • window
    • neovimのみ
    • 値の型は 文字列 のみで,fzfのウィンドウを開くコマンドを指定する(例: vertical aboveleft 30new
  • launcher
    • gvimのみ
    • 値の型が 文字列 のとき,fzfを起動する外部のターミナルエミュレータ名を指定する
    • 値の型が 関数参照 のとき,その関数の返り値をfzfを起動する外部のターミナルエミュレータとして用いる

キーとして必ず持つべきものは,候補に対するアクションを担当する sink または sink* である. 候補の取得を担当する source は,後述するが,ほぼ必須の項目である. なお, sinksink* の2つがある場合, sinksink* の順番に処理がなされる.

ちなみに, source を指定しない場合,デフォルトのfzfの動作(再帰的なファイル検索)になるので,単純にファイルを検索したいのであれば, source を敢えて指定しないというのも手だ. 単純なファイラとして用いるのであれば,以下のようにするとよいだろう.

call fzf#run({'sink': 'edit'})

sink も指定しなかった場合,候補を選択しても何のアクションも行われない.

" カレントディレクトリ以下のファイルが再帰的に表示されるが,
" 候補を選択しても,何のアクションも行われない
call fzf#run({})

プラグインに組み込む

僕の場合,fzfをプラグインに導入する場合,実装を別ファイルに分割している. unite.vimctrlp.vimに習い,分割したファイルは autoload/fzf/ に配置することにする.

以下は,カレントディレクトリのファイルを表示し,選択されたファイルの行数を echo するという単純なサンプルである.

  • plugin/sample.vim
if exists('g:loaded_sample')
  finish
endif
let g:loaded_sample = 1
let s:save_cpo = &cpo
set cpo&vim

command! FZFSample  call fzf#run(fzf#sample#option())

let &cpo = s:save_cpo
unlet s:save_cpo
  • autoload/fzf/sample.vim
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 filter(split(globpath('.', '*', 1), "\n"), 'filereadable(v:val)')
endfunction

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


let &cpo = s:save_cpo
unlet s:save_cpo

autoload/fzf/sample.vimfzf#sample#option() で返却する辞書の一部の項目は,いちいち新しく定義し直す必要は無いので,以下のように記述してもよいだろう.

let s:save_cpo = &cpo
set cpo&vim

let s:option = {
      \ 'down': 20
      \}
function! s:option.sink(candidate) abort
  echo len(readfile(a:candidate))
endfunction


function! fzf#sample#option() abort
  let s:option.source = s:gather_candidates()
  return s:option
endfunction


function! s:gather_candidates() abort
  return filter(split(globpath('.', '*', 1), "\n"), 'filereadable(v:val)')
endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

この例では,候補の取得をVim Script側で行っているが,カレントディレクトリのファイルを検索するのなら,外部コマンドを用いてもよいだろう. fzfがコマンドラインツールであることを考えれば,そちらの方が好ましいかもしれない.

let s:save_cpo = &cpo
set cpo&vim

let s:option = {
      \ 'down': 20,
      \ 'source': 'find -maxdepth 1 -find f'
      \}
function! s:option.sink(candidate) abort
  echo len(readfile(a:candidate))
endfunction


function! fzf#sample#option() abort
  return s:option
endfunction


let &cpo = s:save_cpo
unlet s:save_cpo

複数選択したい場合

先述の例では触れていないが,fzfの拡張は複数選択できるようにすることが可能である. 複数選択はfzf自体の機能にあるので, options にfzfのオプション -m ,または --multi を指定するだけでよい. 先述の例であれば,以下のように s:option を変更するだけだ.

let s:option = {
      \ 'down': 20,
      \ 'options': '-m'
      \}
function! s:option.sink(candidate) abort
  echo len(readfile(a:candidate))
endfunction

このとき, sink に指定したアクションが,選択した各候補に対して実行される. もし,複数選択したものを,Vim Script側ではリストとして扱いたいならば, sink* を利用するとよい.

function! s:sink(candidates) abort
  for candidate in a:candidates
    echo len(readfile(candidate))
  endfor
endfunction

let s:option = {
      \ 'down': 20,
      \ 'options': '-m',
      \ 'sink*': function('s:sink')
      \}

感想

fzfはあくまで外部ツールであって,Vimからfzfに操作を移さなければならない点が残念に思えた. 僕としては,外部ツールはあくまでシームレスに用いることができるようにしてほしいと思っている. ただ,tmux上のVimで用いた場合に,候補選択用の新しいペインを作成する点や,neovimに対応している点など,なかなか面白い試みをしていると感じた.

また,複数選択に対応している点も魅力的だと感じた. 候補選択型インターフェースで,ユーザが作成する拡張で,複数選択ができるものを作ることができるのは,実質unite.vimとfzfぐらいのものではないかと思われる(ctrlp.vimは特殊な場合に限り,複数選択可能).

参考