Edited at

bash 引数解析のテンプレート(改良版、解説付き)

More than 3 years have passed since last update.

前回の記事「シェルスクリプト 引数解析のテンプレート」が適当すぎたので再考してみました。

基本的な構想は前回に引き続き bash によるオプション解析@b4b4r07 を参考にさせていただいております。


要件


  • BSD / GNU どちらでもいけるように、なるべく Bash 単体で書く

  • オプションの終端( -, -- 以降をオプションではなく引数として扱う指定)が使える。

  • ショートオプションが使える。



    • -a -b のような個別のフラグ指定


    • -ab のような複合フラグ指定


    • -b B_VAL, -ab B_VAL のような値指定



  • ロングオプションが使える。



    • --longopt のようなフラグ指定


    • --longopt=VAL のような等号区切りの値指定


    • --longopt VAL のような空白区切りの値指定




ソース

コメントや空白行を詰めると、解析部分だけで60行くらいです。長いですね。

これがスクリプト本体より長いようなら、起動方法をルール付けて限定するなどして、もっと簡略化したほうが良さそうです。


sample.sh

#!/bin/bash

set -e
PROGNAME=$(basename $0)

function usage_exit {
cat <<_EOS_ 1>&2

Usage: $PROGNAME [OPTIONS...] arg1 arg2

OPTIONS:
-h, --help このヘルプを表示
-a, --enable-a スイッチAをオンにするフラグ的なオプション
-t, --tag=TAG 何かのタグ的な値をとるオプション

Example: $PROGNAME --tag=hoge

_EOS_
exit 1
}

declare -i argc=0
declare -a argv=()

while (( $# > 0 )); do
case
"$1" in
# オプション終端
- | -- )
shift
argc+=$#
argv+=("$@")
break
;;

# ロングオプション
--* )
opt_name="${1#--}"
opt_name="${opt_name%%=*}"

delim_by_space=true
opt_value=""
if [[ "$1" =~ = ]]; then
delim_by_space=false
opt_value="${1#*=}"
fi

case "$opt_name" in
'help' )
usage_exit
;;

'tag' )
OPT_TAGS+=("${opt_value:-$2}")
($delim_by_space) && shift
;;

# 'hoge' )
# OPT_HOGE="${opt_value:-$2}"
# ($delim_by_space) && shift
# ;;

'enable-a' )
OPT_A=1
;;
esac
;;

# ショートオプション
-* )
for (( i=1; i < ${#1}; i++ )); do
opt_name="${1:$i:1}"
case "$opt_name" in
'h' )
usage_exit
;;

'a' )
OPT_A=1
;;

't' )
OPT_TAGS+=("$2")
shift
;;
esac
done
;;

# 実引数
* )
(( ++argc ))
argv+=("$1")
;;
esac
shift
done

# 確認用。区切り文字を "/" に一時変更しつつ出力
(IFS="/"; cat <<_EOS_
argc :
$argc
argv :
${argv[*]}
OPT_A :
$OPT_A
OPT_TAGS :
${OPT_TAGS[*]}
_EOS_
)


確認してみる

$ sample.sh -at T1 arg1 arg2 --tag=T2 --tag T3 -- --tag=T4 --tag T5 "-a arg3"

上の実行例では、次のような指定がされているはずです。


  • AフラグがON

  • タグが3個


    • T1

    • T2

    • T3



  • 引数が6個


    • arg1

    • arg2

    • --tag=T4

    • --tag

    • T5

    • -a arg3



実行結果

argc     : 6

argv : arg1/arg2/--tag=T4/--tag/T5/-a arg3
OPT_A : 1
OPT_TAGS : T1/T2/T3

うまくいっているようです。


解説

上から読み解いていきましょう。 Usage とかその辺はまぁ飛ばします。


概要

はじめの declare は「オプションではない実引数」の数と値リストを入れるためのものです。

declare -i argc=0

declare -a argv=()

その後につづく引数解析処理の全容は、次のようなものです。

while (( $# > 0 )); do

case
"$1" in
# 解析...
esac
shift
done

ループの中では、現在のステップにおける先頭の引数 $1 を解析し、

ループの最後に shift で引数を先頭方向に1つズラします。

これが、引数を消化しきるまで繰り返されます。

解析部分は次のようになっています。

case "$1" in

- | -- )
# (1) オプション終端の処理
;;

--* )
# (2) ロングオプションの解析
;;

-* )
# (3) ショートオプションの解析
;;

* )
# (4) 実引数の処理
;;
esac

shift を使った引数解析では case 文のマッチング順序は重要です。

より早いタイミングで完全一致 or 前方一致させたいものが上側 に来るように置いていきます。


オプション終端

- | -- )

shift
argc+=$#
argv+=("$@")
break
;;

「オプション終端が来たら、以降の引数をすべて引数扱いにして解析終了」となります。

オプションの解析よりも優先するために先頭に置きます。

このケースにマッチしたときの $1"-", "--" なので、まずは shift で押し出します。

-- arg1 -b と来ていたら arg1 -b だけが残ります。

その状態で引数の数 $# と値リスト $@ をそのまま argc, argv に追加し、

break で解析ループ自体を抜けて終わりです。


ロングオプション

--* )

opt_name="${1#--}"
opt_name="${opt_name%%=*}"

delim_by_space=true
opt_value=""
if [[ "$1" =~ = ]]; then
delim_by_space=false
opt_value="${1#*=}"
fi

case "$opt_name" in
'help' )
usage_exit
;;
'tag' )
OPT_TAGS+=("${opt_value:-$2}")
($delim_by_space) && shift
;;
'enable-a' )
OPT_A=1
;;
esac
;;

オプション終端にマッチせず、 "--" で始まる何かが来たらロングオプションとして解析します。

まず

opt_name="${1#--}"

opt_name="${opt_name%%=*}"

これですが、変数の部分除去を利用して「最初に出現した "--"「最初に出現した "=" 以降全て」を削除し、オプション名の部分だけを取り出しています。

例えば --longopt='foo=bar' が与えられたら opt_name"longopt" になります。

${var#word} は var から前方最短マッチで word を削除します。

${var%%word} は var から後方最長マッチで word を削除します。

 → パラメータの展開 | Man page of Bash

次の部分

delim_by_space=true

opt_value=""
if [[ "$1" =~ = ]]; then
delim_by_space=false
opt_value="${1#*=}"
fi

これは



  • delim_by_space


    • 空白区切りだったかどうか。

    • 空白区切りなら true コマンド、それ以外は false コマンドとなる。




  • opt_value


    • オプションの値。

    • 等号区切りだった場合のみ "=" の右辺が設定され、それ以外の場合は空文字となる。



を定義している部分です。

パターンマッチ if [[ "$1" =~ = ]] で、現在の引数 $1"=" が含まれるかチェックします。



  • "=" を含む場合


    • 等号区切りで値をとるロングオプション




  • "=" を含まない場合


    • 空白区切りで値をとるロングオプション

    • 値のないフラグ型のロングオプション



…となります。

"=" を含む場合は、さきほども出てきた前方最短マッチを使って

「最初に出現した "=" 以前を削除」、つまり"=" の右辺」を取り出して opt_value にセットしています。

また、空白区切りではないので delim_by_spacefalse コマンドにしています。

続く解析部分の case 文を見てみます。

case "$opt_name" in

'help' )
usage_exit
;;
'tag' )
OPT_TAGS+=("${opt_value:-$2}")
($delim_by_space) && shift
;;
'enable-a' )
OPT_A=1
;;
esac

helpenable-a はシンプルなのでわかりやすいと思います。

tag のところをもう少し詳しく。

OPT_TAGS+=("${opt_value:-$2}")

これは OPT_TAGS に値を追加しています。

配列に += で配列を与えると、要素が追加されます(高級言語のコレクション操作で言う add)。

追加している値ですが、変数のデフォルト値を展開する機能によって

opt_value が定義されていて空文字でもなければそのまま」

「未定義または空文字の場合は $2

…を与えています。 → パラメータの展開 | Man page of Bash

その下の行を見てみます。

($delim_by_space) && shift

簡単にいえば $delim_by_space の実行結果が正常終了の場合のみ shift するようになっています。

$delim_by_space$1 が等号区切り形式ではなかった場合には true コマンドにしているので、

($delim_by_space) && shift(true) && shift となり、引数が正しく shift されます。

ややこしくなりましたが、この記述方法によってロングオプションの値の渡し方が

空白区切りでも等号区切りでも動くようになっています。


ショートオプション

-* )

for (( i=1; i < ${#1}; i++ )); do
opt_name="${1:$i:1}";
case "$opt_name" in
'h' )
usage_exit
;;
'a' )
OPT_A=1
;;
't' )
OPT_TAGS+=("$2")
shift
;;
esac
done
;;

若干ゴチャゴチャしていますが、ロングオプションよりは単純です。

for (( i=1; i < ${#1}; i++ )) は、開始値1で $1 文字数までループしています。

要するにショートオプションの先頭 "-" を飛ばして、1文字ずつ case 文に投げるようなループです。

opt_name="${1:$i:1}" は部分文字列展開を利用したもので、

ここでは $1$i 文字目を opt_name に設定という意味になります。

${var:offset:length}var の offset 文字目から length 文字だけ切り出します。

仮想言語で書くと var.substring(offset, length) のような感じです。

残りはただの case 文なので説明省略します。


実引数の処理

* )

(( ++argc ))
argv+=("$1")
;;

オプション終端、ロングオプション、ショートオプションのいずれにもマッチしなかった引数は、実引数として処理します。

argc をインクリメント(1加算)し、値リスト argv に引数を追加しています。


大分長くなってしまいましたが、おおよそ欲しい機能を満たした解析処理が書けたので満足。(自己満)

ソースについて「もっとシンプルに書き換えられる!」「ここがおかしい!」等ありましたらコメントでお知らせください。

以上です。