Bashタブ補完自作入門

ドーモ、SREチームの湯谷(@yutannihilation)です。最近気になるモジュラーシンセはIntellijelです。

上司がいい感じのコマンドをつくるという記事を書いていましたが、いい感じのコマンドにはいい感じのタブ補完を付けたくなります。この記事ではBashのタブ補完を自作する方法を紹介します。

タブ補完の仕組み

Bashのタブ補完自体はBashに組み込まれている仕組みです(参考:Bash Reference Manual - 8.6 Programmable Completion)。completeというBashの組み込み関数によって補完方法(compspec(completion specification)と言うらしいです)が規定されていて、これがタブなどによって起動されます。

タブ補完は、lsならファイル名、cdならディレクトリ名、というようにコマンドに応じたものが設定されています。最近のLinuxディストリビューションでは多くのコマンドで快適にタブ補完ができますが、それはbash-completionというパッケージのおかげです。

このパッケージが用意してくれている数々の補完関数は、/usr/share/bash-completion/completions以下に配置されていることが多いです(このパスはディストリビューションやbash-completionのバージョンによっても異なります)。たとえば、manの補完方法は/usr/share/bash-completion/completions/manに定義されています。見てみましょう。

$ cat /usr/share/bash-completion/completions/man
# man(1) completion                                        -*- shell-script -*-

[[ $OSTYPE == *@(darwin|freebsd|solaris|cygwin|openbsd)* ]] || _userland GNU \
    || return 1

_man()
{
    local cur prev words cword split
    _init_completion -s -n : || return

...略...

    __ltrim_colon_completions "$cur"
    return 0
} &&
complete -F _man man apropos whatis

# ex: ts=4 sw=4 et filetype=sh

_man()というシェル関数が定義され、それに続いてcompleteコマンドが実行されています。-Fは、補完するための関数を指定するオプションです。これによって、manコマンドとaproposコマンドとwhatisコマンドは_man()という関数によって補完が行われるように設定されています。

ユーザがタブを押すと、_man()COMP_CWORDとかCOMP_WORDSといった変数(後述)で今のコマンドラインに入力されている文字が渡されます。_man()はそこから候補を絞り込んだ結果を、COMPREPLYという変数で返します。それがタブ補完の候補として表示される、という流れです。

はじめの一歩

まず、候補としてonetwoを表示するシンプルな関数_dummy()をつくってみます。候補のリストをCOMPREPLYという変数に入れると、補完候補として表示されるようになります。

$ _dummy() { COMPREPLY=(one two); }

これをmanコマンドの補完方法に指定します。

$ complete -F _dummy man

これでmanコマンドは_dummy関数でタブ補完されるようになりました。ここで、

$ man <TAB>

と打つと、以下のように候補が表示されるはずです。

$ man
one  two

でも上の_dummy()だと、コマンドラインの状態はお構いなしに同じ候補しか表示しません。

$ man o<TAB>
one  two
$ man one<TAB>
one  two

oまで打ったら候補はoneだけになってほしいですよね。そのために_dummy()は、今のコマンドラインの状態を受け取って、候補の絞り込みをする必要があります。

補完候補の絞り込み

これには、COMP_CWORDCOMP_WORDSという変数とcompgenというコマンドを使います。

COMP_CWORDCOMP_WORDS

この変数には、それぞれ以下の値が入っています。

  • COMP_CWORD: 今カーソルがあるワードは何語目か
  • COMP_WORDS: コマンドラインのワードのリスト

たとえば、以下のようにCOMP_CWORDCOMP_WORDSechoする関数を設定してみます。

$ _dummy()
{
  echo
  echo COMP_CWORD: ${COMP_CWORD}
  echo COMP_WORDS: ${COMP_WORDS[@]}
}

これでタブを押すと、以下のような結果になります。COMP_CWORDCOMP_WORDSにどのような変数が渡っているかイメージが付くでしょうか。カーソルの直前がワードのときと空白のときで結果が異なる点には注意が必要です。

$ man <TAB>
COMP_CWORD: 1
COMP_WORDS: man
$ man test test2<TAB>
COMP_CWORD: 2
COMP_WORDS: man test test2
$ man test test2 <TAB>
COMP_CWORD: 3
COMP_WORDS: man test test2

_get_comp_words_by_ref()

現在カーソルがあるワードは${COMP_WORDS[${COMP_CWORD}]}、そのひとつ前は${COMP_WORDS[${COMP_CWORD}-1]}といったかたちでアクセスすることができます。でも、ちょっと長くて見づらいです。_get_comp_words_by_ref()という便利関数を使うと、現在カーソルがあるワードは$cur、ひとつ前のワードは$prev、ワード数は$cwordという変数に入ります。

こんな感じです。

$ _dummy()
{
  local cur prev cword
  _get_comp_words_by_ref -n : cur prev cword
  echo
  echo cur: ${cur}
  echo prev: ${prev}
  echo cword: ${cword}
}

$ man arg1 arg2<TAB>
cur: arg2
prev: arg1
cword: 2

こうした便利関数はやはりbash-completionパッケージによって提供されているもので、/usr/share/bash-completion/bash_completionに定義されています。定義が気になる場合はこのファイルを覗いてみましょう。(例えば_get_comp_words_by_refの定義はこのあたりです)

compgen

compgenは、オプションによって出てくる補完候補から、引数とマッチ(基本的に前方一致)するものだけを絞り込むコマンドです。各オプションはman 7 bash-builtinsに詳しく書かれています。ここでは主要なオプションだけ紹介します。

  • -W

-Wは、変換候補となる文字列のリストを指定するオプションです。以下のように、指定した文字列リストから、引数と前方一致するものだけに絞り込んでリストアップしてくれます。

$ compgen -W "one two once twice" --
one
two
once
twice
$ compgen -W "one two once twice" -- o
one
once
$ compgen -W "one two once twice" -- onc
once
  • -c/-f/-d

自前で文字列のリストを与えなくても、コマンドのリストとか、ファイルのリストとかを補完候補にすることができるオプションです。-cはコマンド(と実行ファイル、ディレクトリ)、-fはファイル、-dはディレクトリを補完するようになります。たとえば、以下のように-cオプションにmanという引数を与えると、manから始まるコマンドがリストアップされます。

$ compgen -c -- man
mandb
manpage-alert
man
manpath

補完してみる

compgenを使うと、dummy()は現在のコマンドラインの文字を使って候補を絞り込んだ補完ができるようになります。

$ _dummy()
{
  local cur prev opts
  _get_comp_words_by_ref -n : cur prev
  opts="one two once twice"

  COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
}

こんな感じの動きです。

$ man <TAB>
once   one    twice  two
$ man on<TAB>
once  one

ようやく補完できました。めでたしめでたし!(たったこれだけしか補完できない_dummy()に飽きたら、complete -r manでデフォルトの補完に戻すことができます)

例:go-apt-cacher の補完

具体例として、APT専用のキャッシュプロキシgo-apt-cacherのタブ補完をつくってみましょう。

go-apt-cacherには以下のオプションがあります(バージョン1.2.2時点)。オプションによって補完すべきものが違います。-f-logfileはファイル名をとり、-logformat-loglevelはそれぞれ固有の選択肢をとります。

$ go-apt-cacher -h
Usage of go-apt-cacher:
  -f string
        configuration file name (default "/etc/go-apt-cacher.toml")
  -logfile string
        Log filename
  -logformat string
        Log format [plain,logfmt,json]
  -loglevel string
        Log level [critical,error,warning,info,debug]

まず、go-apt-cacher -まで入力してタブを押すとオプションが補完されるようにしてみましょう。オプションはコマンドの直後、つまり$cwordが1の位置にきます。このときはオプションのリストをcompgen-Wに指定して補完するようにします。

$ _go_apt_cacher(){
  local cur prev cword
  _get_comp_words_by_ref -n : cur prev cword

  if [ "${cword}" -eq 1 ]; then
    COMPREPLY=( $(compgen -W "-f -logfile -logformat -loglevel" -- "${cur}") )
  fi
} &&
complete -F _go_apt_cacher go-apt-cacher

次に、オプションが-logformatの場合はplainlogfmtjsonのいずれかが、-loglevelの場合はcriticalerrorwarninginfodebugが補完されるようにしてみましょう。カーソルがオプションの次に来ているときは、$prevにオプションが入るのでこれを条件分岐に使うといいでしょう。

$ _go_apt_cacher(){
  local cur prev cword
  _get_comp_words_by_ref -n : cur prev cword
  
  if [ "${cword}" -eq 1 ]; then
    COMPREPLY=( $(compgen -W "-f -logfile -logformat -loglevel" -- "${cur}") )
  elif [ "${cword}" -eq 2 ]; then
    if [ "${prev}" = "-logformat" ]; then
      COMPREPLY=( $(compgen -W "plain logfmt json" -- "${cur}") )
    elif [ "${prev}" = "-loglevel" ]; then
      COMPREPLY=( $(compgen -W "critical error warning info debug" -- "${cur}") )
    fi
  fi
} &&
complete -F _go_apt_cacher go-apt-cacher

さらに、オプションが-logfile-fのときにはファイルへのパスが補完されるようにしてみます。ファイルの補完にはcompgen-fオプションを使います。compopt -o filenamesは、ディレクトリ名には/を付けてくれるようにするおまじないです(詳しいことが気になる方はman 7 bash-builtinsを読んでください)。

$ _go_apt_cacher(){
  local cur prev cword
  _get_comp_words_by_ref -n : cur prev cword
  
  if [ "${cword}" -eq 1 ]; then
    COMPREPLY=( $(compgen -W "-f -logfile -logformat -loglevel" -- "${cur}") )

  elif [ "${cword}" -eq 2 ]; then
    if [ "${prev}" = "-logformat" ]; then
      COMPREPLY=( $(compgen -W "plain logfmt json" -- "${cur}") )
    elif [ "${prev}" = "-loglevel" ]; then
      COMPREPLY=( $(compgen -W "critical error warning info debug" -- "${cur}") )
    elif [ "${prev}" = "-logfile" -o "${prev}" = "-f" ]; then
      compopt -o filenames
      COMPREPLY=( $(compgen -f -- "${cur}") )
    fi
  fi
} &&
complete -F _go_apt_cacher go-apt-cacher

これで完成です。

この例はcompgen-W-fしか使いませんでしたが、最終的にCOMPREPLYに候補のリストを渡せば何をしてもかまいません。やり方は様々です。/usr/share/bash-completion/completions/下に良い例がたくさんあるので、探求心の強い方は参考にしてみてください。

補完関数の置き場所

個人的に使うものであれば~/.bash_completionというファイルをつくって書いておけば、ログイン時に自動で読み込まれます。

パッケージなどに含める場合であれば、bash-completionパッケージが使っている/usr/share/bash-completion/completionsなどに置くのが良いでしょう。このパスは、pkg-config --variable=completionsdir bash-completionというコマンドで調べることができます。詳しくはbash-completionのFAQを参照してください。

まとめ

みなさまの快適なBash生活の一助となれば幸いです。

サイボウズではBashやビアバッシュが好きなエンジニアを募集しています。SREの募集要項はこちらです。