fzf+pmyでzshの補完をカスタマイズする

最近 relastle/pmy というツールにハマっています。

Zshの補完機能の欠点

zshの補完機能は強力ですが、カスタマイズ性や拡張の容易さに難があります。

また、やろうとおもえば補完関数内でfzfなどのFuzzy Finderを用いてインタラクティブな補完を実現できますが、fzfの--multiオプションなどを用いた複数結果の展開などはできません。

デフォルトの補完機能 (ZLEのexpand-or-complete)を上書きするような形でそれらも実現はできますが、やはり記述の複雑さや保守性の面からつらいものがあります。

そこで、pmyというCLIツールとfzfを組み合わせてインタラクティブな補完を実現しました。 利用時の様子は上のツイートのとおりです。

fzf

fzfはGo製のFuzzy Finderです。

github.com

日本では同じくGo製のFuzzy Finderであるpecoの方が知名度が高い印象がありますが、preview windowはpecoにはない魅力です。

f:id:Ryooooooga:20201231130448p:plain
fzfのpreview window

preview windowは、任意のコマンドなどを用いて検索候補のプレビューを表示できる機能です。 上の図ではbatを用いてシンタックスハイライト付きでファイルの中身を表示しています。他にもlstreeのようなコマンドを使ってディレクトリの構造を表示する、あるいはgit showでコミットの情報を表示するなど、用途の幅が広い便利な機能です。

私の場合は以下のような用途にfzfを用いていました。

  1. historyを使ったコマンドヒストリーの検索
  2. chpwd_recent_dirsを使ったディレクトリ移動履歴の検索
  3. ghqを使ったリポジトリ間の移動
  4. テキストエディタを開く際のファイルの選択
  5. docker rmdocker rmiの際に削除するイメージの選択

1 ~ 3 はFuzzy Finderの利用例で頻繁に挙げられる用途なので特筆すべきことはありません。

4 に関しては以下のような$EDITOR (私の場合はnvim) のラッパー関数 e を用意していました。

e() {
    if [ $# -eq 0 ]; then
        local selected="$(fd --hidden --color=always --exclude='.git' --type=f  | fzf --exit-0 --multi --preview="fzf-preview-file '{}'" --preview-window="right:60%")"
        [ -n "$selected" ] && "$EDITOR" -- ${(f)selected}
    else
        command "$EDITOR" "$@"
    fi
}

これは、eに引数が指定されていた場合は通常通りに $EDITOR を起動し、引数なしで呼び出された場合にはカレントディレクトリ以下のファイルから fzf で選択したものを $EDITOR の引数に与えるものです (かなり便利)。

これまでの不満点

上のような fzf の活用は、実際に便利で重宝していたのですが、若干の物足りなさを感じていました。 具体的には以下のような点です。

  • e で選択したファイルがコマンドヒストリーに記録されない

    上の関数ではどのようなファイルを選択してもコマンドヒストリーに e しか残りません。 そのため、以前に開いたファイルを再び開こうと思った場合にも再度検索を行わなければならず、煩わしさを感じていました。

  • git switch などの既存のコマンドの補完にfzfを用いたい

    Gitなどの既存のコマンドやそのサブコマンドの補完にfzfを用いたい場合、逐一ラッパースクリプトを用意しなければならず面倒でした。

余談

fzfには補完用のスクリプトが用意されており、これを使用することでfzfを使った補完が可能になっていました。

参考: https://github.com/junegunn/fzf#fuzzy-completion-for-bash-and-zsh

また、この機構を利用してGitなどの補完を実現したプロジェクトに chitoku-k/fzf-zsh-completions があります。

github.com

今回、これらのような既存のものを利用しなかったのは以下の理由のためです。

  • 自分用の環境に合わせてカスタマイズするのが面倒
  • 補完のトリガーである**<TAB>というシーケンスに慣れない

pmy

というわけで本題です。

pmyはFuzzy Finderを用いたzshの補完を実現するためのCLIツールです。

github.com qiita.com

YAML (もしくはJSON) で設定ファイルを書くことにより、冒頭で示したようなfuzzyな検索と補完が可能になります。

インストール方法

  • go get を用いたビルド方法
$ go get -u github.com/relastle/pmy
$ eval "$(pmy init)"
  • zinit を用いたプリビルドバイナリのダウンロード
zinit ice lucid wait"0" as"program" from"gh-r" \
    pick"pmy*/pmy" \
    atload'eval "$(pmy init)"'
zinit light 'relastle/pmy'

設定ファイルの編集

pmyは、デフォルトでは ~/.pmy/rules 以下に配置された設定ファイルを参照します。 例えば、冒頭のツイートのような補完は下の設定で再現できます。

# ~/.pmy/rules/_.yaml
- description: editor
  regexp-left: ^\s*(?P<cmd>(nvim|vim|vi))\s+(?P<args>\S+\s+)*)(?P<query>\S*)$
  cmd-groups:
    - stmt: fd --color=always --hidden --type=f
      after: paste -s -d ' ' -
  fuzzy-finder-cmd: fzf --multi --preview="bat {}" --query=<query>
  buffer-left: '<cmd> <args>'
  buffer-right: '[]'

f:id:Ryooooooga:20201231173125p:plain
pmyによるファイル名補完の例

上の場合、ファイルパスやその一部を雑に入力しただけで候補に出現するため、通常のzsh補完よりも効率的に目的のファイルにたどり着けます。

他にも以下のような設定でpmyを運用しています。

https://github.com/Ryooooooga/dotfiles/tree/main/config/pmy/rules

f:id:Ryooooooga:20201231173324p:plain
pmyによるGitサブコマンド補完の例

設定ファイルの参照ディレクトリなどの変更

pmy はデフォルトでは ~/.pmy 以下を作業ディレクトリとして用います。 しかし、私はホームディレクトリが雑多なdotfilesで汚れることをあまり好ましく思っていません。

幸いにも、pmyは環境変数によって作業ディレクトリを変えられるため、XDG風のファイルの配置に変更しました。 また、補完のトリガーになるキー(デフォルトではCtrl+Space)も変えられます。

# 補完のトリガーを変更 (Ctrl+P)
export PMY_TRIGGER_KEY="^P"

# ルールファイルの参照先を変更
export PMY_RULE_PATH="$XDG_CONFIG_HOME/pmy/rules"
export PMY_SNIPPET_PATH="$XDG_CONFIG_HOME/pmy/snippets"
# ログファイルの出力先を変更
export PMY_LOG_PATH="$XDG_CACHE_HOME/pmy/log.txt"

eval "$(pmy init)"

pmyの利点

pmyを利用することには以下のような利点があります。

  • コマンドやサブコマンドなどのコンテキストごとに補完の候補を柔軟に変えられる
  • YAMLなのでShellスクリプトに比べカスタマイズが容易
  • 補完結果がコマンドヒストリーに残る

pmyの欠点

一方で、pmyもまた万能ではありません。 しばらく利用してみて感じた欠点を以下に挙げます。

  • ルールのマッチングのための正規表現を書くのがつらい
    • 空白や引用符などを考慮した正規表現を書こうとすると大変
    • オプションやフラグごとの補完候補の変更など、あまり複雑なことをしようとすると正規表現が爆発する
  • ルールのマッチングは上から順に判定されるため、設定の記述順序が重要になる
  • エスケープシーケンス周りの動作が怪しい
    • PRを投げて幾分かはマシになった

pmyはかなり独特でおもしろいツールなので、fzfがすきなzshユーザの方は一回試してみるといいんじゃないでしょうか。