🐚

bashシェルスクリプトで引数とオプションを解析する

2021/09/01に公開約5,400字2件のコメント

先日、ghコマンドのエクステンションとして、gh graphというものを作りました。

https://zenn.dev/kawarimidoll/articles/75430b40622e7c

こちらにオプションで表示を出し分ける機能を追加したのですが、シェルスクリプトでの引数解析に手こずったので考え方を残しておきます。

以下の記事がたいへん参考になりました。感謝!

https://qiita.com/b4b4r07/items/dcd6be0bb9c9185475bb#いっそ自前で解析しちゃう

サンプルスクリプト

本記事の説明をざっくりとまとめた基本的なスクリプトを作成しました。以下のGistに載せています。
コピーして実行してみてください。

https://gist.github.com/kawarimidoll/371ee1741897608b945c8338b7a75ac3

基本戦略

whileループの中でcaseを使い、スクリプトの引数(optionもargumentも含む)を一つずつ処理します。
ループするごとにshiftで引数を取り除いていき、0個になるまで続けます。

while (( $# > 0 ))
do
  case $1 in
    -o)
      echo "option o"
      ;;
    -*)
      echo "invalid option"
      exit 1
      ;;
    *)
      echo "argument $1"
      ;;
  esac
  shift
done

上記の-oの箇所を-o | --optionのようにパイプで分割することで、ショート・ロングオプションの表記ゆれに対応できます。
マッチ範囲の関係上、引数(argument)の処理はオプションより後に書く必要があります。

なお、参考になるものを探していた際、for OPT in "$@"でループする例も見られましたが、オプションが引数を持つ場合など、一度に複数個の引数を扱いたい場合にうまくいきませんでした。

オプションの扱い

先頭に-がついている引数をオプションとします。

即終了するオプション

指定されていたら即終了するオプションの場合、case内部でexitします。
helpversionなどで使われるかと思います。

while (( $# > 0 ))
do
  case $1 in
    # ...
    -o | --option)
      echo "option"
      exit 0
      ;;
    # ...
  esac
  shift
done

引数を持たないオプション

引数を持たず、指定されているかどうかを確認したいオプションの場合です。
指定されていた場合にその旨を変数に代入します。

while (( $# > 0 ))
do
  case $1 in
    # ...
    -o | --option)
      OPT=1
      ;;
    # ...
  esac
  shift
done

複数回の指定を禁止する場合

変数の値が存在しているかを代入前に確認します。
無視する場合もあると思います。

while (( $# > 0 ))
do
  case $1 in
    # ...
    -o | --option)
      if [[ -n "$OPT" ]]; then
        echo "Duplicated 'option'." 1>&2
        exit 1
      fi
      OPT=1
      ;;
    # ...
  esac
  shift
done

引数を持つオプション

引数を持つオプションの場合です。httpポートを指定したり、出力先を指定したりする例が考えられます。
以下の3通りの指定方法を想定しています[1]

  • -o arg
  • --option arg
  • --option=arg
while (( $# > 0 ))
do
  case $1 in
    # ...
    -o | --option | --option=*)
      if [[ "$1" =~ ^--option= ]]; then
        OPT=$(echo $1 | sed -e 's/^--option=//')
      elif [[ -z "$2" ]] || [[ "$2" =~ ^-+ ]]; then
        echo "'option' requires an argument." 1>&2
        exit 1
      else
        OPT="$2"
        shift
      fi
      ;;
    # ...
  esac
  shift
done

複数回の指定を禁止する場合、先頭にもう一つif判定を追加します。

if [[ "$1" =~ ^--option= ]]

イコールがついた形で指定された場合です。このとき$1--option=argという値が入っています。
オプションに渡された引数はbbbなので、sed--option=の部分を除去して変数に代入します。

elif [[ -z "$2" ]] || [[ "$2" =~ ^-+ ]]

着目している引数($1)の次の引数($2)が存在しないか、先頭が-(つまりオプション)の場合です。
つまり引数が渡されていないため、エラーとします。

引数が任意の場合、ここでデフォルト値を代入します。

else

$2が存在する場合、それがオプションに渡された引数なので、変数に代入します。
オプションとその引数とで、スクリプトの引数を2つ消費するため、追加でshiftが必要です。

ショートオプションの複数指定

command -a -bcommand -abのように指定する場合です。

while (( $# > 0 ))
do
  case $1 in
    # ...
    -*)
      if [[ "$1" =~ 'a' ]]; then
        OPT_A=1
      elif [[ "$1" =~ 'b' ]]; then
        OPT_B=1
      fi
      ;;
    # ...
  esac
  shift
done

「複数回の指定を禁止」したい場合はその処理を追加してください。
この形式でさらに引数を受け入れる例もあるかもしれませんが、かなり作りづらいと思います。今回は考えていません。

想定していないオプション

先頭に-がついているのにここまでで捕獲されなかった場合、想定されていないオプションです。
エラーを出して終了します。

while (( $# > 0 ))
do
  case $1 in
    # ...
    -*)
      echo "Illegal option -- '$(echo $1 | sed 's/^-*//')'." 1>&2
      exit 1
      ;;
    # ...
  esac
done

エラーを出さず無視する実装もあると思います。

前述の「ショートオプションの複数指定」がある場合、分岐の最後につなげます。

while (( $# > 0 ))
do
  case $1 in
    # ...
    -*)
      if [[ "$1" =~ 'a' ]]; then
        OPT_A=1
      elif [[ "$1" =~ 'b' ]]; then
        OPT_B=1
      else
        echo "Illegal option -- '$(echo $1 | sed 's/^-*//')'." 1>&2
        exit 1
      fi
      ;;
    # ...
  esac
  shift
done

その他の特殊な条件

以下の特殊な条件を設定する場合、ループ後にifで判定するのが扱いやすいと思います。
ループ中で判定を挟もうとすると記述が複雑になるためですが、パフォーマンス的にはループ中で確認したほうが勝ることもあるかもしれません。

  • 指定必須のオプション
  • 特定のオプションと一緒に設定する(または、一緒に設定してはいけない)オプション

引数の扱い

先頭に-がない引数を引数とします(変な日本語だ)。
*で捕獲し、順番に配列に代入していきます。

ARGS=()
while (( $# > 0 ))
do
  case $1 in
    # ...
    *)
      ARGS=("${ARGS[@]}" "$1")
      ;;
  esac
  shift
done

if [[ "${#ARGS[@]}" -lt 1 ]]; then
  echo "Too few arguments." 1>&2
  exit 1
elif [[ "${#ARGS[@]}" -gt 2 ]]; then
  echo "Too many arguments." 1>&2
  exit 1
fi

get_nth () {
  local n=$1
  shift
  eval echo \$${n}
}

ARG1=$(get_nth 1 "${ARGS[@]}")
ARG2=$(get_nth 2 "${ARGS[@]}")
# ...

ループを抜けた後、引数の個数を検証します。上記では1から2個の引数を期待しています。状況によって調整してください。

その後、扱いやすくするため配列を分割しています。

https://qiita.com/b4b4r07/items/e56a8e3471fb45df2f59
この際、 シェルによって配列のインデックスが異なる ため、それを吸収するためにget_nth関数を作成しています。
https://rcmdnk.com/blog/2018/08/19/computer-bash-zsh/

配列を使わない場合

シェルスクリプトの引数は高々数個程度…と考えれば、配列を使わない実装もできます。
もし想定する引数が一つであれば、配列を使わず直接代入するのが楽でしょう。
ただ、順番に変数に代入していく処理を作る必要があるため、3つ以上になると厳しい気がします。

以下はARG1が必須、ARG2が任意の例です。

while (( $# > 0 ))
do
  case $1 in
    # ...
    *)
      if [[ -n "$ARG1" ]] && [[ -n "$ARG2" ]]; then
        echo "Too many arguments." 1>&2
        exit 1
      elif [[ -n "$ARG1" ]]; then
        ARG2="$1"
      else
        ARG1="$1"
      fi
      ;;
  esac
  shift
done

if [[ -z "$ARG1" ]]; then
  echo "'arg1' is required." 1>&2
  exit 1
fi

if [[ -z "$ARG2" ]]; then
  # default value
  ARG2=arg
fi

おわりに

個人的に、シェルスクリプトは(シェルスクリプトで実行できる範囲のことなら)作りやすい・使いやすいのですが、引数の扱いをちゃんとわかっていなかったのでまとめておきました。

この記事でできないような引数処理は作りたくないかな…。
言語ごとに得意不得意もありますし、複雑なものにしすぎない設計が大切という話ですかね。

脚注
  1. ショートオプションだとイコールを使わないイメージなのですが、どうなのでしょう ↩︎

Discussion

とても参考になる記事だと思います。シェルスクリプトを書く際にたびたび参考にさせてもらっています。

一点、確認させてください。

記事内のスクリプトで、1&>2 とありますが、これは 1>&2 の誤植でしょうか?

読んでいただけて嬉しいです 🥰
ご指摘の通りtypoでしたので修正しました 😅 ありがとうございます!

ログインするとコメントできます