結論

  • --select-1 は候補が 1 件なら、--exit-0 は 0 件なら、一覧を出さずに終わる。「fzf で人が選ぶ」前提の導線でも、1 件に絞れた候補はそのまま確定する
  • --query の既定はあいまい一致で、行全体を照合する。api は apps-migration に当たり、--exact でも main は domain-model に当たる。行と完全に一致させるのは ^main$ だけ
  • fzf は --filter 以外では起動時に /dev/tty を開く。制御端末の無いプロセスでは、--select-1 で 1 件に絞れても failed to open /dev/tty で exit 2 になる
  • 終了コードは 0 (選んだ) / 1 (一致なし) / 2 (エラー) / 130 (中断)。--expect= (キー無し) は 2。producer | fzf は、fzf が先に終わると producer が SIGPIPE の 141 で終わり、pipefail で全体が 141 になる
  • {} {1} はシングルクォートで囲んだ 1 引数に展開される。自分で "{}" と囲むと、そのクォートが値に混ざる
  • preview は行ごとに別の $SHELL -c で走り、呼び出し元の関数と export していない変数は見えない
  • --multi の出力は選んだ順。--tac は入力の並びを反転するだけで、カーソルは常に並びの先頭に乗る

以下は fzf 0.74.3 / bash 5.3.15 / linux/amd64 で実測した。対話の操作は script コマンドが作る疑似端末 (pty) の中で fzf を動かし、キーを送って再現した。

前提: fzf は 3 つの経路を使い分ける

fzf は候補を標準入力から読み、画面とキーは /dev/tty でやり取りし、結果を標準出力と終了コードで返す

fzf は 1 回の実行で、次の 3 つの経路を別々に使う。

  1. 候補: 標準入力から 1 行 1 件で読む
  2. 画面とキー: /dev/tty (プロセスの制御端末) を開いて描画し、キーを読む
  3. 結果: 選んだ行を標準出力へ 1 行 1 件で書き、終了コードで結果の種類を返す

$(fzf) で結果を受け取っても画面が出るのは、画面が標準出力ではなく /dev/tty に出るため。逆に、制御端末が無いと 2 の経路を開けない。

終了コードは man fzf の EXIT STATUS のとおり。

EXIT STATUS
     0      Normal exit
     1      No match
     2      Error
     126    Permission denied error from become action
     127    Invalid shell command for become action
     130    Interrupted with CTRL-C or ESC

以下の節は、3 つの経路と終了コードのどこで、呼び出し側の想定と違う値が返るかを順に扱う。

--select-1 と --exit-0 は一覧を出さずに終わる

候補が 1 件なら –select-1 が、0 件なら –exit-0 が、一覧を出す前に fzf を終わらせる

man fzf は 2 つのオプションを次のとおり書いている。

-1, --select-1
       If there is only one match for the initial query (--query), do not start interactive finder and automatically select the only match
-0, --exit-0
       If there is no match for the initial query (--query), do not start interactive finder and exit immediately
$ printf 'deploy-prod\n' | fzf --select-1; echo "exit=$?"
deploy-prod
exit=0
$ printf 'deploy-prod\ndeploy-stg\n' | fzf --query prod --select-1; echo "exit=$?"
deploy-prod
exit=0
$ printf 'deploy-prod\ndeploy-stg\n' | fzf --query zzz --exit-0; echo "exit=$?"
exit=1

候補が 2 件以上残ったときだけ一覧を出し、キーを待つ。誰も押さなければ待ち続け、ESC で 130 になる。

$ printf 'deploy-prod\ndeploy-stg\n' | fzf --query deploy --select-1; echo "exit=$?"
exit=130    # 一覧が出て、ESC を押した
  • PITFALL: 「fzf に並べて人に選ばせる」と説明したランチャでも、候補が 1 件なら確認の画面を出さずに確定する。共有の環境へ書き込む候補が 1 件だけ当たると、そのまま走る
  • PITFALL: --exit-0 で 0 件のときは、何も出力せず exit 1 で終わる。呼び出し側が exit 1 を見てメッセージを出さないと、失敗したことも、その理由もログに残らない

--query のあいまい一致で、別の候補が確定する

api という query は、a・p・i が順に現れる apps-migration にあいまい一致し、–select-1 でそのまま確定する

--query の既定はあいまい一致 (query の文字が、その順で行のどこかに現れれば当たる)。存在しない名前を渡しても、文字が散らばって含まれる別の候補に当たる。--select-1 と組むと、その候補で確定する。

$ names=$'apps-migration\nfeature-auth\nfix-login'
$ fzf --query api --select-1 <<<"$names"; echo "exit=$?"
apps-migration
exit=0
$ fzf --filter api --exact <<<"$names"; echo "exit=$?"    # --exact は部分文字列一致
exit=1

--exact (と query 先頭の ') は部分文字列一致なので、名前の一部に当たれば確定する。行全体と一致させるのは、先頭の ^ と末尾の $ を両方付けた query だけ。

$ names2=$'domain-model\nfeature-auth'
$ fzf --query main --select-1 --exact <<<"$names2"; echo "exit=$?"
domain-model
exit=0
$ fzf --filter "'main" <<<"$names2"; echo "exit=$?"
domain-model
exit=0
$ fzf --filter '^main$' <<<"$names2"; echo "exit=$?"
exit=1
$ fzf --filter '^main$' <<<$'domain-model\nmain'; echo "exit=$?"
main
exit=0

照合の対象は行全体。名前の前に状態の列があると、その列の文字にも当たる。--nth=-1 で最後の列だけに絞る。

$ rows=$'[merged] feature-auth\n[open] apps-migration'
$ fzf --query merged --select-1 --exact <<<"$rows"; echo "exit=$?"
[merged] feature-auth
exit=0
$ fzf --filter merged --exact --nth=-1 <<<"$rows"; echo "exit=$?"
exit=1

既定では query の ^ $ ! ' を検索の構文として扱う (extended-search mode)。--no-extended を付けると、どれも文字として探す。

$ fzf --filter '!open' <<<"$rows"; echo "exit=$?"     # !open は「open を含まない行」
[merged] feature-auth
exit=0
$ fzf --filter '!open' --exact --no-extended <<<"$rows"; echo "exit=$?"
exit=1

「引数があれば絞り込んで 1 件なら確定、無ければ一覧」のラッパーは、絞り込みを部分文字列・最後の列・構文なしにする。

args=(--prompt 'select> ')
if [[ -n $query ]]; then
  args+=(--query "$query" --select-1 --exact --no-extended --nth=-1)
fi
fzf "${args[@]}"
  • --exit-0 は付けない。0 件のときに一覧が出て、query を打ち直せる
  • 部分文字列一致は残るので、main を渡すと domain-model で確定する。行全体と一致させるなら ^main$ を使う。^ と $ は extended-search の構文なので、--no-extended とは両立しない
  • PITFALL: query の表記が候補と揃っていないと当たらない。feature/foo のような入力を feature-foo 形式のディレクトリ名の一覧に当てるなら、query を先に揃える

端末が無いと --select-1 でも exit 2 で終わる

制御端末が無いと、–filter 以外の fzf は候補を数える前に /dev/tty を開けず exit 2 で終わる

fzf 0.74.3 は、--filter 以外では候補を読む前に /dev/tty を開く (src/core.go の Run が --filter でないときに NewTerminal を呼び、NewTerminal が tty を開けなければ exit 2 で返す)。--select-1 で一覧を出さずに終わる場合も、先に端末が要る。

setsid で制御端末を外したプロセスから呼ぶ。

$ setsid -w bash -c 'printf "a\n" | fzf --select-1; echo "exit=$?"'
failed to open /dev/tty
exit=2
$ setsid -w bash -c 'printf "a\nb\n" | fzf --query a --select-1 --exact; echo "exit=$?"'
failed to open /dev/tty
exit=2
$ setsid -w bash -c 'printf "a\nb\n" | fzf --query zzz --exit-0; echo "exit=$?"'
failed to open /dev/tty
exit=2
$ setsid -w bash -c 'printf "a\nb\n" | fzf --filter a --exact; echo "exit=$?"'
a
exit=0

端末が無くても動くのは --filter だけ。--filter は一覧を出さず、当たった行をすべて出力する (0 件なら exit 1)。1 件かどうかは呼び出し側で数える。

制御端末はあるが、キーを押す人がいない (端末の中で自動実行が走る) 場合は、一覧を出してキーを待ち続ける。

  • PITFALL: 非 0 で終わったときに、引数を落として素の一覧へ戻るリトライを書くと、端末が無ければもう一度 exit 2、端末があって人がいなければ待ち続ける。非 0 は再試行せず中断する
  • PITFALL: printf 'a\nb\n' | fzf ... < /dev/null のように < /dev/null を足すと、パイプの代わりに /dev/null が標準入力になり、候補が 0 件になる

--expect= (キー無し) は exit 2 で終わる

fzf 同梱の Vim プラグインの fzf#wrap は g:fzf_action のキーを –expect に並べるので、空の dict だと –expect= になり fzf が exit 2 で終わる

--expect は、Enter 以外で確定させるキーを並べるオプション。キーを 1 つも書かないとエラーになる。

$ printf 'a\n' | fzf --expect= --select-1; echo "exit=$?"
key names required
exit=2
$ printf 'a\n' | fzf --expect=ctrl-v --select-1; echo "exit=$?"

a
exit=0

--expect を付けると、出力の 1 行目は押したキーの名前になる。Enter (と --select-1 の自動確定) では空行になる。

fzf 本体に同梱の Vim プラグイン (plugin/fzf.vim) は、fzf#wrap で g:fzf_action のキーを --expect に並べる。g:fzf_action が未設定なら既定のキーが入り、空の dict なら --expect= になる。

$ vim -Nu NONE -es ... -c 'unlet! g:fzf_action' -c 'let o = fzf#wrap({})' -c 'put =string(o.options)' ...
'  --expect=ctrl-v,ctrl-x,ctrl-t'
$ vim -Nu NONE -es ... -c 'let g:fzf_action = {}' -c 'let o = fzf#wrap({})' -c 'put =string(o.options)' ...
'  --expect='

g:fzf_action を一時的に上書きして戻す処理では、元が未設定だったかを記録し、未設定なら unlet! で未設定に戻す。

let s:had_action = exists('g:fzf_action')
let s:saved_action = get(g:, 'fzf_action', {})
let g:fzf_action = {'ctrl-o': 'edit'}
put ='during: ' . fzf#wrap({}).options
if s:had_action
  let g:fzf_action = s:saved_action
else
  unlet! g:fzf_action
endif
put ='after: exists=' . exists('g:fzf_action') . fzf#wrap({}).options
during:   --expect=ctrl-o
after: exists=0  --expect=ctrl-v,ctrl-x,ctrl-t
  • PITFALL: get(g:, 'fzf_action', {}) の既定値 {} をそのまま let g:fzf_action = s:saved_action で書き戻すと、未設定だった変数が空の dict として残る。同じ手順で、戻した後は after: exists=1 --expect= になり、以後の fzf#wrap を通る fzf はすべて exit 2 で終わる

producer | fzf は、fzf が先に終わると producer が SIGPIPE で終わる

fzf が Enter で先に終わると、まだ書いている producer は閉じたパイプへ書いて 141 で終わり、pipefail で全体が 141 になる

パイプの読み手が先に終わると、書き手は閉じたパイプへ書いた時点で SIGPIPE を受け、終了コード 141 (128 + 13) で終わる。set -o pipefail の下では、パイプ全体の終了コードが 141 になる。

1 行目を出した後、2 秒遅れて残りを書く producer で試す。1 行目が出た時点で Enter を押す。

set -o pipefail
produce() { echo first; sleep 2; seq 100000; }
sel=$(produce | fzf) || echo "fallback: status=$?"
echo "sel=$sel"
produce | fzf > /dev/null; echo "PIPESTATUS=${PIPESTATUS[*]}"
fallback: status=141
sel=first
PIPESTATUS=141 0

fzf は exit 0 で first を返し、sel にも入っている。それでも || の側へ進む。

出力を先に変数へ確定させてから渡すと、producer は fzf を起動する前に書き終わっているので、SIGPIPE を受けない。

rows=$(produce)               # 先に出力を確定させる
sel=$(fzf <<<"$rows") || :    # パイプにしない
rows: 100001 lines
sel=first
  • PITFALL: producer の出力が小さいと、fzf が読み終える前に書き終わるので再現しない。テストでは通り、出力が大きいときや、すぐ Enter を押したときにだけ起きる
  • PITFALL: || return 0 のような fallback と組むと、選んだのに何も起きない。pipefail と終了コードの関係は bash の set -e が効かない場所と効きすぎる場所 にもまとめた

{} と {1} はクォート済みの 1 引数になる

{} は fzf がシングルクォートで囲んだ 1 引数になる。自分で "" で囲むと、そのクォートが値に混ざる

--bind の execute(...) become(...) や --preview に書いた {} (行全体) と {1} (区切った 1 列目) は、シングルクォートで囲んだ文字列に置き換わる。man fzf は次のとおり書いている。

Each expression expands to a quoted string, so that it's safe to pass it as an argument to an external command. So you should not manually add quotes around the curly braces.

置き換えた後のコマンド文字列は、--with-shell に printf を渡すと見られる (fzf は --with-shell の後ろに、置き換えた後のコマンド文字列を 1 引数で付けて実行する)。

$ line="it's a file.txt"
$ fzf --with-shell 'printf cmd=%s\n' --bind 'enter:become(printf "[%s]\n" {})' <<<"$line"
cmd=printf "[%s]\n" 'it'\''s a file.txt'
$ fzf --bind 'enter:become(printf "[%s]\n" {})' <<<"$line"
[it's a file.txt]

空白とシングルクォートを含む行が、1 つの引数でそのまま届く。自分で "{}" と囲むと、fzf が付けたクォートが値の一部になる。

$ fzf --with-shell 'printf cmd=%s\n' --bind 'enter:become(printf "[%s]\n" "{}")' <<<"$line"
cmd=printf "[%s]\n" "'it'\''s a file.txt'"
$ fzf --bind 'enter:become(printf "[%s]\n" "{}")' <<<"$line"
['it'\''s a file.txt']

r フラグ ({r}) はクォートを付けず、空白で別の引数に分かれる。

$ fzf --bind 'enter:become(printf "[%s]\n" {r})' <<<"a file.txt"
[a]
[file.txt]

--delimiter と --with-nth で 1 列目を表示から隠すと、表示を短くしたまま、子プロセスへは 1 列目の値を 1 引数で渡せる。

$ printf '%s\t%s\n' "/srv/data/it's a file.txt" "it's a file" |
    fzf --delimiter '\t' --with-nth 2 --bind 'enter:become(printf "[%s]\n" {1})'
[/srv/data/it's a file.txt]
  • PITFALL: クォートされるのは {} を置き換えた部分だけ。cmd= の出力のとおり、周りに書いたコマンドの文字列は書いたまま実行されるので、そちらのクォートは自分で書く

preview は行ごとに別のシェルで走る

preview はカーソルが乗った行ごとに別の $SHELL -c で走り、呼び出し元の関数と export していない変数は見えない

--preview のコマンドは、カーソルが乗った行ごとに、別プロセスの $SHELL -c で走る。呼び出し元のスクリプトで定義した関数と、export していない変数は見えない。

show() { echo "show: $1"; }
VAR=local-only
export EXPORTED=exported
fzf --preview 'echo "pid=$$ line={}" >> preview.out; show {} >> preview.out 2>&1; echo "VAR=$VAR EXPORTED=$EXPORTED" >> preview.out' \
  <<<$'a.txt\nb.txt'

a.txt の preview が出た後、b.txt へ移って Enter を押した。

pid=495 line='a.txt'
/bin/bash: line 1: show: command not found
VAR= EXPORTED=exported
pid=498 line='b.txt'
/bin/bash: line 1: show: command not found
VAR= EXPORTED=exported

行ごとに pid が違い、関数 show は未定義で、VAR は空になる。export した EXPORTED だけが届く。

一覧と preview で同じ処理を使うなら、スクリプト自身を、preview 用の引数を付けて絶対パスで呼び直す。

#!/bin/bash
show() { echo "show: $1"; }
if [[ $1 == --preview-line ]]; then
  show "$2"
  exit
fi
self=$(readlink -f "$0")
cd /tmp || exit 1 # 一覧を作る前に別の場所へ移るスクリプトを想定する
fzf --preview "$self --preview-line {}" <<<$'a.txt\nb.txt'

preview に出た内容 (確かめるために、preview の出力をファイルへも書いて読んだ):

show: a.txt
  • PITFALL: self=$0 のまま相対パスで呼び直すと、スクリプトが fzf の前に cd していれば preview から見つからない。sub/ から ../self.sh として起動すると、preview は /bin/bash: line 1: ../self.sh: No such file or directory になる

--multi の出力は選んだ順に 1 行 1 件

–multi は選んだ順に 1 行 1 件で出力する。c、a の順に選ぶと c、a の順に出る

--multi (-m) で複数を選ぶと、入力の順ではなく選んだ順に出力する。入力の順で出すオプションは無い。

a b c の一覧で、c を Tab で選び、続けて a を選んで Enter を押す。

$ printf 'a\nb\nc\n' | fzf --multi
c
a

入力の順に戻すには、行番号を付けて表示から隠し、選んだ後に番号で並べ直す。同じ操作で a c の順になる。

$ printf 'a\nb\nc\n' | nl -ba | fzf --multi --with-nth=2.. | sort -k1,1n | cut -f2-
a
c

出力は 1 行 1 件なので、空白で繋いでから展開すると、空白を含む名前が分かれる。

list=$'my file.txt\nb.txt'
files=$(fzf --multi --bind 'load:select-all+accept' <<<"$list" | tr '\n' ' ')
printf '[%s]\n' $files
[my]
[file.txt]
[b.txt]

1 行ずつ配列に入れ、"${files[@]}" で渡す。

files=()
while IFS= read -r f; do
  [[ -n $f ]] && files+=("$f")
done < <(fzf --multi --bind 'load:select-all+accept' <<<"$list")
printf '[%s]\n' "${files[@]}"
[my file.txt]
[b.txt]
  • PITFALL: # shellcheck disable=SC2086 でクォートの警告を消すと、空白を含む名前が来るまで動いて見える
  • PITFALL: read -r だけで IFS= を付けないと、行の先頭と末尾の空白が削られる

--tac は入力の並びを反転し、カーソルは並びの先頭に乗る

既定のレイアウトは下から上へ並べ、–reverse は上から下へ並べる。–tac は入力の順を反転し、カーソルは常に並びの先頭に乗る

並びに効くオプションは 2 つあり、別のものを変える。

  • --tac: 入力の順を反転する (man fzf: “Reverse the order of the input”)
  • --reverse (--layout=reverse): 画面のレイアウトを反転する。既定 (default) は画面の下から上へ並べ、reverse は上から下へ並べる

カーソルの初期位置は、どちらの組み合わせでも並びの先頭 (既定のレイアウトでは最下段、--reverse では最上段)。起動してすぐ Enter を押すと、その行が出る。

$ printf '1\n2\n3\n' | fzf                  # => 1
$ printf '1\n2\n3\n' | fzf --tac            # => 3
$ printf '1\n2\n3\n' | fzf --reverse        # => 1
$ printf '1\n2\n3\n' | fzf --tac --reverse  # => 3

画面は次のとおり (--height=6 --pointer='>' --gutter=' ' で描き、端末エミュレータ pyte で読み取った。2 つずつ横に並べ、区切り線を縮めた)。

fzf                   fzf --tac
  3                     1
  2                     2
> 1                   > 3
  3/3 ──────            3/3 ──────
>                     >

fzf --reverse         fzf --tac --reverse
>                     >
  3/3 ──────            3/3 ──────
> 1                   > 3
  2                     2
  3                     1

打鍵なしで選ばせたい候補があるなら、既定のレイアウトのまま、その候補を入力の 1 行目に置き、--tac は付けない。降順で作った入力を昇順で見せたいときだけ --tac を付ける。

  • PITFALL: 別のスクリプトが --tac を使っているからといって写すと、入力を並べる向きが違えば、画面の上下とカーソルの位置が逆になる

まとめ

場面起きること対策
--select-1 / --exit-01 件なら一覧を出さずに確定、0 件なら出力なしで exit 1人に選ばせたい導線では付けない。exit 1 は呼び出し側でメッセージを出す
--query既定はあいまい一致で行全体を照合。--exact も部分文字列--exact --no-extended --nth=-1。行全体の一致は ^...$
制御端末が無い--filter 以外は failed to open /dev/tty で exit 2端末の無い経路では --filter を使い、件数は自分で数える
--expect=key names required で exit 2g:fzf_action は exists() で記録し、未設定なら unlet! で戻す
producer | fzffzf が先に終わると producer が 141、pipefail で全体が 141出力を変数に確定させてから <<< で渡す
{} / {1}シングルクォートで囲んだ 1 引数になる自分で "{}" と囲まない。表示と値は --with-nth で分ける
--preview行ごとに別の $SHELL -c。関数と export していない変数は見えないスクリプト自身を絶対パスで呼び直す
--multi選んだ順に 1 行 1 件入力の順は nl で番号を付けて並べ直す。配列で受ける
--tac入力の順を反転する。カーソルは並びの先頭選ばせたい候補を入力の 1 行目に置く

参考