Help us understand the problem. What is going on with this article?

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 に引数を追加しています。


大分長くなってしまいましたが、おおよそ欲しい機能を満たした解析処理が書けたので満足。(自己満)
ソースについて「もっとシンプルに書き換えられる!」「ここがおかしい!」等ありましたらコメントでお知らせください。

以上です。

hidekuro
雑食。私がQiitaで公開する独自コードは、特に記載がない限り CC0 https://creativecommons.org/publicdomain/zero/1.0/deed.ja とします。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away