Bashのreadで好きなものを補完する


はじめに

これを作っているときにいろいろ試行錯誤した過程の備忘録です.
Bashのread-eオプションを指定してあげるとGNU Readlineが有効になります.いわゆるTab補完も有効になりますがそのままでは補完対象はファイル名(カレントディレクトリの)のみです.この記事ではそれ以外の好きなものをTabで補完する方法をいろいろ模索したのでまとめておきます.なおコマンドのオプションを補完するアレの話ではありませんのでご注意ください.
ちなみに記事中のスクリーンキャストはすべてttyrecで記録したものをGIFに変換したものです.

標準でできるファイル名以外の補完

bind -l | grep completeとかするといろいろ出てきます.

コマンド名の補完

bind "TAB:complete-command"

ファイル名の補完

bind "TAB:complete-filename"

などなどいろいろあります.ただし補完対象を好きなものに設定する(例えば候補をリストで与える)ことはできません.(もしできるなら教えてください.zshだとできるらしい?)
ただbind -x keyseq:shell-commandで好きな関数を任意のキーに割り当てられるのでこれを使ってTab補完を自作することは可能です.次節に続きます.

好きなものを補完する

備忘録も兼ねていますので切り分けつつ説明します.結果だけ見たい人は適当に読み飛ばしてください.

任意の関数をキーに割り当てる

先程述べたようにbind -x keyseq:shell-commandで好きな関数を任意のキーに割り当てられます.例えばTabを押したときにHello, world!を表示するには以下のようにします.

tab-say-hello.bash
#!/bin/bash

function say-hello () {
  echo "Hello, world!"
}

set -o emacs
bind -x '"\t":"say-hello"'

read -ep ">>> " line

READLINE_LINEREADLINE_POINT

bashには特殊な変数としてREADLINE_LINEREADLINE_POINTがあります.これらはそれぞれGNU Readlineにおけるrl_line_bufferrl_pointに相当するもので現在の編集行の内容と行内のカーソル位置を表します.したがってこれらの値を書き換えればキーを押したときにラインバッファを書き換えたりできます.例えばTabを押したときにカーソル位置にHello, world!を挿入するには以下のようにします.

tab-insert-hello.bash
#!/bin/bash

function insert-hello () {
  local hello="Hello, world!"
  local left="$(echo "$READLINE_LINE" | cut -b -$((READLINE_POINT+1)))"
  local right="$(echo "$READLINE_LINE" | cut -b $((READLINE_POINT+2))-)"
  READLINE_LINE="$left$hello$right"
  let READLINE_POINT+=${#hello}
}

set -o emacs
bind -x '"\t":"insert-hello"'

read -ep ">>> " line

任意の候補からTabで補完する

いよいよ本題です.ここまで来ればやり方はわかっていただけると思いますが処理の流れは以下のようになります.

  1. READLINE_LINEREADLINE_POINTから対象の単語を取得する
  2. 候補のリストから補完候補を絞り込む(compgenを使用)
  3. 候補が一つなら補完する.複数なら途中まで補完.候補がなければ何もしない.

なお,入力がなかった,あるいはすでに途中まで補完されているなら候補を表示して次の入力を待ちます.

単語の区切りが常にスペース一つだと仮定すると雑に以下のような感じで実現できます.

tab-completion.bash
#!/bin/bash

# 以下のリストから補完.適当に野菜の名前
candidates=(
  "cabbage"
  "carrot"
  "cucumber"
  "lettuce"
  "potate"
  "radish"
)

prompt=">>> "

function tab-complete () {
  local words=($READLINE_LINE)

  # 入力がなければ候補を表示して終わり
  if [ ! $words ]; then
    echo "$prompt$READLINE_LINE"
    echo ${candidates[@]}
    return
  fi

  # カーソル位置の単語を取得 
  local slice=(${READLINE_LINE:0:$(($READLINE_POINT + 1))})
  local n=$((${#slice[@]} - 1))

  # compgenで絞り込み
  local comp_words=($(compgen -W "$(echo ${candidates[@]})" -- ${words[$n]}))

  # 一つに決まったら挿入
  if [ ${#comp_words[@]} -eq 1 ]; then
    words[$n]=${comp_words[0]}

  # 一つに決まらなかったら候補の共通部分を取得
  elif [ ${#comp_words[@]} -gt 1 ]; then
    local prefix=$(printf "%s\n" "${comp_words[@]}" |\
      sed -e '$!{N;s/^\(.*\).*\n\1.*$/\1\n\1/;D;}')

    # カーソル位置の単語が共通部分と等しければ候補を表示
    if [ $prefix = ${words[$n]} ]; then
      echo "$prompt$READLINE_LINE"
      echo ${comp_words[@]}

    # 等しくなければ共通部分を挿入
    else
      words[$n]=$prefix
    fi
  fi

  # ラインバッファを更新
  READLINE_LINE="${words[@]}"
  slice="${words[@]:0:$(($n + 1))}"
  READLINE_POINT=${#slice}
}

set -o emacs
bind -x '"\t":"tab-complete"'

read -ep "$prompt" line

なお単語の区切りはスペース一つと仮定しているので複数のスペースがあった場合勝手に切り詰められます.

おわりに

記事は以上です.補完候補のリストを動的に変えたり単語の見つけ方・挿入の仕方を工夫すればもっとエレガントな補完も作れそうです.ただReadlineの本家のbashで自在にReadlineが扱えないのは少し残念に思います.
お読みいただきありがとうございました.