bash-completion で自作コマンドのタブ補完に挑戦

動機

以前にgetpassというコマンドを用意して、パスワード管理を行える簡単なコマンドラインツールを動かせるようにしました。

$ getpass github id ↵
Success: クリップボードにコピーされました。
$ getpass github pw ↵
Success: クリップボードにコピーされました。

上記が例。これで github のIDやパスワードが得られます。2つの引数はaddpassという別のコマンドで任意に指定しています。とても手に馴染んでお気に入りなのですが、使い込むうちに不便も感じるようになってきました。キーボードを打つのが面倒くさいのです。

個人のアカウントならば問題ないのですが、プロジェクトなどで用いると問題が浮上しました。

$ getpass project_name backlog_id
$ getpass project_name vpn
$ getpass project_name gitlab_pw

使用するサービス, ID, PW がたくさんある場合がほとんど、それらを毎度覚えないとならないのです。これは面倒です。学生時代の先輩が「ターミナルでは1文字打つごとにタブを押す!」と言っていたことを思い出し、タブ補完に挑戦することにしました。

タブ補完ことはじめ

以下のサイトが大いに参考になります。何かの間違いで先に私のサイトを訪れてしまった方は、今すぐ参考サイトへどうぞ。

中心的役割を担うのはcompleteというコマンド。 bash のビルトインコマンドのようです。

_my_command_completion(){
	# do something
}
complete -F _my_command_completion my_command

タブ補完をさせたいコマンドmy_commandに対して、任意の関数_my_command_completionを作成して作用させる仕組み。早速触っていきましょう。

Step 1 – とにかくなにか出す

my_command() {
	echo $@
}
_my_command_completion(){
	COMPREPLY=(tonikaku nanika);
}
complete -F _my_command_completion my_command

しょうもないコマンドmy_commandを用意しました。これを使って進めることにします。補完用の関数では、変数COMPREPLYへ補完したい単語を配列で渡してあげる。これが一番基本の形。

$ my_command <tabを押す>
nanika    tonikaku

もうこれだけで候補が表示されます。候補はアルファベット順に並べ替えがされるみたいですね。

Step 2 – 補完させる

ここまででは候補を表示するだけで、補完して入力まではしてくれません。そこで次の3つを使います。

compgen コマンド。入力内容から絞り込みを行なってくれる。
$COMP_WORDS 変数。補完関数の中で使える。
ターミナルに入力された文字の配列。
$COMP_CWORD 変数。補完関数の中で使える。
今カーソルをあてている単語の順番。

まずはcompgenを単体で使ってみます。completion を generate する、という意味でしょうか。

$ compgen -W "tonikaku nanika" -- t ↵
tonikaku
$ compgen -W "tonikaku nanika" -- ton ↵
tonikaku
$ compgen -W "tonikaku nanika" -- tonakai ↵
<なにも表示されない>

日本語で言い換えるならば「"tonikaku nanika"の中から t で始まるものを探して」といったところかな。タブ補完にグッと近づいた気がしてきました。これを補完関数の中に放り込み、かつ現在入力されている文字を受け取れりたいところですが、そこで$COMP_WORDS$COMP_CWORDの出番。

my_command() {
	echo $@
}
_my_command_completion(){
	COMPREPLY=( `compgen -W "tonikaku nanika" -- ${COMP_WORDS[COMP_CWORD]}` );
}
complete -F _my_command_completion my_command

${COMP_WORDS[COMP_CWORD]}で「現在入力されている文字」を受け取れます。私はここで少し戸惑い、腑に落ちるまで時間がかかりました。

Step 3 – 候補リストを動的に取得する

続けて、"tonikaku nanika"の部分を可変にします。

my_command() {
	echo $@
}
_my_command_completion(){
	local args=`ls *.html`
	COMPREPLY=( `compgen -W "$args" -- ${COMP_WORDS[COMP_CWORD]}` );
}
complete -F _my_command_completion my_command

別段難しいことはなく、変数にしてあげれば良いですね。タブ補完とは本来的には関係のない話ですらありますね。html ファイルだけを候補にする例を思い浮かびましたが、シェルで出来ることならなんでも良いと思います。

むしろここで私が学んだことは、bashの変数はデフォルトでグルーバル変数だということです。なのでビルトインコマンドのlocalを使ってあげましょう、ということ。

Step 4 – 引数の順番にあわせて候補リストを変える

今回は1つ目と2つ目の引数で補完させる内容を変えたいです。その時は$COMP_CWORDで条件分岐をさせればいけそうです。

my_command() {
	echo $@
}
_my_completion_function(){
	local args
	case $COMP_CWORD in
	1 )
		args=`ls *.html`;;
	2 )
		args=`ls *.php`;;
	esac
	COMPREPLY=( `compgen -W "$args" -- ${COMP_WORDS[COMP_CWORD]}` );
}
complete -F _my_completion_function my_command

まったく実用性のない例になってしまいましたがご容赦。

完成させる

一般的なお話はここまで。今回の動機に則したgetpass用の処理をしていきます。もう完全にタブ補完と関係がありません。json から希望のデータをどうやって得るかという話。

jq の存在は知っていましたが、使わずに済ませたい気分だったので奮闘しました。その結果は次の通り。

{
	"project_name_1": {
		"key_1_1": "value_1_1",
		"key_1_2": "value_1_2",
		"key_1_3": "value_1_3"
	},
	"project_name_2": {
		"key_2_1": "value_2_1",
		"key_2_2": "value_2_2",
		"key_3_3": "value_3_3"
	},
	"project_name_3": {
		"key_3_1": "value_3_1",
		"key_3_2": "value_3_2",
		"key_3_3": "value_3_3"
	}
}
_getpass_completion(){
	local filepath=/path/to/json.json
	local args
	case $COMP_CWORD in
	1)
		args=`grep "{" $filepath|sed -e 's/[{|:|"|]//g'`;;
	2)
		args=`sed -n "/\"${COMP_WORDS[1]}\"\:/,/}/p" $filepath|sed -e '1d' -e '$d' -e 's/:.*$//' -e 's/[ |"]//g'`;;
	esac
	COMPREPLY=( `compgen -W "$args" -- ${COMP_WORDS[COMP_CWORD]}` )
}
complete -F _getpass_completion getpass

やりたいのは次のこと。

  1. 第一引数のときは、project_name_1, project_name_2, project_name_3 が欲しい
  2. 第二引数のときは、第一引数に応じた key_* が欲しい

前者はさらりと済みました。{を含む行だけを抜き出しました。後者はズブズブと沼にはまり時間がかかりました。awkを試したりheadtailを組み合わせたり暗中模索。sedawkも、覚えた端から忘れてしまいいつまで経っても身につかないのはなぜでしょう。

最終的に、次のようにすれば、行をまたぎ最短一致で取得できることを知りました。便利。

$ sed -n "/ここから/,/ここまで/p"

終わりに

今回はまるで始めての試みでした。コピペで済ませず、ステップバイステップで進めたことで原理をしっかりと理解できました。おかげで、タブ補完の自作そのものはとても簡単シンプルだとわかりました。どんどん使っていこうと思います。

では素敵なコマンドライフを。