世界を動かす技術を、日本語で。

BashとZshのためのシンプルなタブ補完の作成

概要

  • BashとZshでの タブ補完 の違いと統一的な実装方法を解説
  • Mill build tool での実践例を紹介
  • ZshだけでなくBashでも 補完候補の説明文 を表示する手法
  • コマンドが1つだけの時にも説明を見せる 工夫
  • CLIツールにおける ユーザー体験向上 のヒント

BashとZshでのタブ補完の基本

  • BashZsh ではタブ補完APIが異なるため、両対応の実装が必要

  • タブ補完は、ユーザーが<TAB>を押した時に ハンドラ関数 を呼び出し、入力中の単語やカーソル位置を受け取る仕組み

  • 補完候補は 配列 で管理し、入力のプレフィックスに一致するものをリストアップ

  • Bashでは COMPREPLY、Zshでは compadd を使って補完候補を返却

  • 設定は ~/.bashrc~/.zshrc などに記述し、シェル起動時に読み込ませる

    • 例: Mill build toolの./mill mill.tabcomplete/installコマンドは自動で設定ファイルを更新

補完候補の説明文表示

  • Zshは 補完候補の説明文 を標準サポート、Bashは未対応
  • 候補生成関数で「単語:説明文」の形式で配列を作成
  • Zshではcompadd -d説明文付き候補 を表示
  • Bashでは説明文を カットして COMPREPLYに入れることで、単語のみ補完

Bashで説明文を見せるハック

  • Bashで説明文を表示するには、 複数候補 がある時だけ説明文付きで候補を返す

  • 候補が1つだけなら説明文をカットし、複数ならそのまま表示

  • Bashは共通プレフィックスしか自動補完しないため、説明文がコマンドラインに挿入されることはない

    • 例:
      • foo <TAB> → 候補+説明文一覧を表示
      • foo a<TAB> → プレフィックス一致の候補+説明文のみ表示

完全一致時にも説明文を表示する工夫

  • コマンドが 1つだけ一致 した場合でも説明文を表示したい場合は、 ダミー候補 を追加

  • 1つの候補+説明文と、説明文なしの同じ単語を2つ返すことで、シェルが候補一覧を表示

  • 実際に補完されるのは説明文なしの単語のみ

    • Bash・Zshともにこの方法で 完全一致時の説明文表示 が可能

実装例のまとめ

  • 補完ロジックは _generate_foo_completions 関数に集約

  • BashとZshで 補完API の違いを吸収しつつ、UXを向上

  • 補完候補の 説明文 はCLIツールの学習コストを下げる重要な機能

    • Mill, Maven, Gradleなど ラッパースクリプト 型のツールにも有効

CLIツール開発者へのアドバイス

  • Bash/Zsh両対応 の補完スクリプトを用意することで、幅広いユーザーに快適な体験を提供
  • 補完候補の 説明文 を積極的に活用し、ユーザーが迷わずコマンドを選択できるようにする
  • シェルの仕様差異を吸収しつつ、 一貫した補完体験 を目指す設計が重要

Hackerたちの意見

これを書いたんだけど、みんなが読んで面白いと思ってくれたらいいな!初めてこれを理解したとき、めっちゃ楽しかったから。

面白い記事をありがとう!

zshの補完についての良い初めの一歩だね。全体的にかなり大きなシステムで、まだちょっと苦労してるけど。仕事では、ansibleのラッパースクリプトに自動補完を少しずつ追加してるんだ。どのプレイブックをいつ使うかの説明とか、選択されたプレイブックに基づいたスマートな-l補完(例えば、プレイブックがpostgres.ymlのときはmariadbグループを提案しない)とか、タグの自動補完(ちょっとハードコーディングされた説明があるけど、これらのタグの使い方)とかね。金曜日の午後の苦労プロジェクトみたいなもんだけど、大きなansibleプロジェクトを使いやすくしてるよ。

共有してくれてありがとう!君のbash補完のアイデアを自分のCLIに取り入れたいな(もうzshの補完はあるけど)。zshの補完スクリプトを毎回起動時に読み込む代わりに、$fpathのどこかにインストールすれば、zshが補完を「コンパイル」してキャッシュしてくれるんだ。これでシェルの起動時間がかなり速くなるけど、もちろん設定はちょっと難しい。ユーザーは補完をそこに置くために$fpathを理解する必要があるからね。自分のCLIはHomebrew経由で配布してて、補完を自動でインストールできるよ。

プログラムがこのbashスクリプトを書くのを避けるための標準的なフラグってないの?理想的には、argparseみたいなライブラリの一部になってるべきだよね。

zshでは、--helpフラグを使ったコマンドの簡単な補完に、_gnu_generic関数を使えるよ。スタートアップファイルのどこかに、こんな感じの行を入れておけばOK:compdef _gnu_generic

これについても考えたことがある。標準の--completionみたいなものがあれば、共通の引数解析ライブラリが自動的に実装してくれるといいのに(自動的なヘルプテキストを実装するのと同じように)。

Rustには最も人気のある引数解析ライブラリのためのclap_completeパッケージがあるよ:https://crates.io/crates/clap_complete。ripgrepは独自のシェル補完とmanページ生成を--generateオプションを通じて公開してる:rg --generate=man、rg --generate=complete-bash、などなど。xh(clapベース)でも同じことを提供してるけど、AFAIK、あのインターフェースをコピーしてるのはうちだけだと思う。Symfony(PHP用)は何らかのランタイム補完生成を提供してるけど、詳細は知らないな。

こちらは、組み込み関数を使ってzshの補完を作成するための別のチュートリアルだよ: https://github.com/vapniks/zsh-completions/blob/master/zsh-c...

fishの場合、興味のあるプログラムが古くからのmanページを提供してくれてるなら、fish_update_completionsを実行するだけで簡単にできるよ。システム上のすべてのmanページを解析して、補完ファイルを生成してくれる。デフォルトでは、~/.cache/fish/generated_completions/*に入るよ。もしmanページがうまく書かれてなかったり、欠けてたりしたら、自分で補完を書くこともできるし(できれば上流に送ってね)。fishはすごくシンプルなフォーマットを使ってるから、公式ドキュメント以外のチュートリアルは必要ないと思うよ: https://fishshell.com/docs/current/completions.html 例えば、curlの補完の一部を紹介すると、complete --command curl --short-option 'L' --long-option 'location' --description 'リダイレクトを追う' complete --command curl --short-option 'O' --long-option 'remote-name' --description 'リモートファイル名で出力をファイルに書き込む'

スクリーンシェアしてると、みんながzshやたくさんのプラグインを使ってないことに気づかないんだよね。実際にはfishだけで、初めからめっちゃ美しいんだ。

コメントありがとう。https://github.com/umlx5h/zsh-manpage-completion-generatorはこれをZSHに適応させるみたいだね。まだ試してないけど。

マニュアルページを出さないプログラムがあるけど、--helpだけに頼るのはどうなの?fishシェルにZshの_gnu_genericやBashのcomplete -F _longoptに相当するものがあるか知ってる?もしないなら、なんでそうなってるのか、何が必要か教えてくれる?

おお、すごい!うちのArchインストールで9461のマニュアルページを解析してるよ、合計で13MBだって。ありがとう!

OpenSUSEでzypper search fish-completionを実行すると200以上のパッケージが返ってくるのは驚きだね。なんか怪しいことが起きてる。

マニュアルページってすごく過小評価されてるよね。今のプロジェクトはみんなREADME.mdを持ってるから、LLMの助けがあってもなくても自動生成できると思うんだけど。それに、プログラムがヘルプ、設定ファイル、バージョン、タスクの背景、PIDファイル、ログファイル、ログレベルのために標準化された一般的な引数を使ってくれたらいいな。

cargoがパスにないときにcar TABblkdiscardに展開されるのをやめたらfishに切り替えるつもり。コマンドの非プレフィックス補完は本当に悪だよ。

bashの補完機能が「賢く」なって、ファイル名やディレクトリ名の補完をブロックするようになったせいで、使い勝手が悪くなった気がする。カーソル位置にファイル名が不適切だと思ったら、補完を止めるんじゃなくて、デフォルトはファイル名の補完に戻るべきだよね。イライラして、補完スクリプトを全部無効にしたりアンインストールしたくなることもある。何十年も培った筋肉の記憶がこの挙動で台無しにされてる感じ。テキストフィールドの悪いUXみたいで、UIが常にこっちと戦ってるみたい。「不正な中間入力」を防ぐためにさ。例えば、ここにクリップボードをペーストさせてくれよ、マジで。自分が何をしてるか分かってるから、後で修正するから。

フロントエンドのメールバリデーターが壊れてるウェブフォームの入力が本当に嫌いになった。メールを入力するためのヒントが表示されるから、入力を始めると、最初の文字を打った瞬間に赤くなって「エラー!!! 無効なメールアドレス!」って言われる。信じられないくらいイライラする。

complete -rを実行した後は期待通りに動くけど、bash-completionスクリプトには何か壊れてるところがあるみたい。

確かにそれはイライラするよね。ファイル名を補完してから実際のプログラムからエラーが出る方が、何も起こらなくて混乱するよりずっとマシだよ。lsでファイルが実際に存在するか確認しなきゃいけなくなるし、存在するのにタブ補完が何かの理由で壊れてると思っちゃう。ファイル名をコピー&ペーストして、やっと状況を説明するエラーが出る。せめて「ファイル foo.exe は存在するけど実行可能ではありません」みたいなメッセージを表示すべきだよ。

時々使う特定のコマンドがファイルの補完が完全に壊れてるから、正しい補完動作を得るために'ls X Y Z'を使って、最後に'ls'を正しいコマンドに変えてるよ。

bashにはファイル名だけを補完するcomplete-filename関数があって、デフォルトではM-/にバインドされてるよ。これを使えば、"complete"(通常はタブにバインドされてる関数)がやりたくないことをする場所でファイル名を補完できる。デフォルトでバインドされている他の文脈を考慮しない補完関数もいくつかあって、例えばforループでホスト名を補完したいときに便利だよ。zleにはこれのかなり大きなスーパーセットがあって、ドキュメントはzshzleとzshcomp*のmanページに分散してる。

自分のシンプルな関数用に考えたzshのスニペットを紹介するね。他の補完のベースに使ってる。この例では、set-java-home zulu-21という関数がJAVA_HOMEを~/apps/java/zulu-21に設定する。これが_set-java-homeだよ:#compdef set-java-home local -a versions=(~/apps/java/*(:t)) _describe 'version' versions ほぼワンライナーなんだけど、残念ながら本当にワンライナーにはできなかった。

bash/zshでJSONフィールドの補完ができるよ: https://fx.wtf/install#autocomplete

これリンクしてくれてありがとう!ijq(インタラクティブjq)に比べて軽量なソリューションだけど、役に立つかもしれないね。 https://github.com/gpanders/ijq

_gnu_genericには詳しくないけど、フルスクリプトを書かずに基本的な補完ができる便利なショートカットみたいだね。--helpしかないコマンドでも使えるのかな?

kshでの基本的な補完は、配列を定義するだけで簡単だよ。https://man.openbsd.org/ksh からの引用: 「カスタム補完は、‘complete_command’という名前の配列を作成することで設定できる。オプションで引数番号を付けて、特定の引数だけに補完を行うことも可能。」例えば、‘complete_kill’という配列を定義すると、kill(1)コマンドの引数に対する補完ができるけど、‘complete_kill_1’は最初の引数だけを補完する。例えば、以下のコマンドでkshがkill(1)の最初の引数に対して信号名の選択肢を提供するようになる: set -A complete_kill_1 -- -9 -HUP -INFO -KILL -TERM

これはKorn & Bolskyのksh88の本に載ってるの?それともksh93の構文がokshにバックポートされたもの?

自分のCLIにはjdxのusage[1]を使い始めたよ。clapにうまく統合できて、スクリプトの中でも単独で使えるんだ。補完やargparse、manページなどを生成できる。fishスクリプトのargparseブロックを置き換えるのが面倒かどうかまだ迷ってるけど、古いoptparseと比べるとずっと良いよ。[1]: https://usage.jdx.dev/