Quantcast
Channel: Bashタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 2722

高機能で短いシェルスクリプト用のオプション解析コード(POSIXシェル準拠・独自実装)

$
0
0

はじめに

シェルスクリプト用のオプション解析コードのサンプルは検索するといくつも見つかるのですが、機能が不足していたり雛形とするには長くメンテナンスしづらいものばかりだったので作成しました。以下のような特徴があります。

  • POSIXシェル準拠なので bash 以外でも動作可能(dash, zsh, ksh, mksh, yash 等で動作確認)
  • ロングオプションにも対応(getopt, getopts 未使用)
  • オプションとオプション以外の引数の順番を混在させることが可能
  • 単一の - をオプションではなく引数として扱う
  • オプション解析を打ち切る -- に対応
  • 結合されたショートオプションに対応(例 -abc
  • 省略可能なオプション引数に対応(例 -ovalue, --option=value
  • フラグを反転させるオプションに対応(例 +o, --no-option
  • 必要な機能のみ使えるように段階的に実装
  • コードが短くメンテナンスしやすい
  • shellcheck によるチェックと shellspec を使ったテストコードあり

ちなみに独自実装しているのは getopt がロングオプション対応してるのが GNU 版だけで macOS では動かないからです。getopt(と getopts)を使用せずに、十分に置き換え可能な機能を実装しています。また本質的なコードよりもオプション解析コードの方が長いなんて事にならないよう短く仕上げています。そのため少し変わったコーディングスタイルになってますので気に入らない人は好きに書き換えてください。雛形(サンプルコード)として作成してるので自由に変更して使ってもらって構いません。

実際に動作するサンプルコードは こちら です。参考ですが getopt, getopts の詳細な解説は「シェルスクリプト オプション解析 徹底解説 (getopt / getopts)」で記事にしています。

※ これまで自分が書いていたコードをベースに機能追加して新たに作成したものなのでバグがあるかもしれません。使用する場合は十分確認をお願いします。

サンプルコード一覧

  1. オプション引数が必要ない場合
  2. オプション引数に対応する
  3. オプションとオプション以外の引数の順番の混在に対応する
  4. -- でオプション解析処理を打ち切る
  5. 結合されたショートオプションに対応する
  6. 結合されたオプション引数に対応する
  7. 省略可能なオプション引数に対応する
  8. +, --no- でフラグを反転させる

上から解説を加えつつ機能を実装しています。使用する際は全てを実装する必要はなく必要だと思う所までで十分です。殆どの場合 4. まで実装すれば必要十分だと思います。5. 以降を実装すると GNU 版の getopt と比べても遜色がない機能になりますがコードの量が増えます。解説の都合上、一度定義した関数は省略しています。例えば unknown 関数は 1. で定義しています。

1. オプション引数が必要ない場合

全ての引数がオプションまたはオプション以外の引数で、オプション引数が必要なければ for を使って簡潔に書くことができます。

abort() { echo "$*" >&2; exit 1; }
unknown() { abort "unrecognized option '$1'"; }

FLAG_A='' FLAG_B=''

for arg; do
  case $arg in
    -a | --flag-a) FLAG_A=1 ;;
    -b | --flag-b) FLAG_B=1 ;;
    -?*) unknown "$@" ;;
    *) set -- "$@" "$arg"
  esac
  shift
done

このコードはオプションを環境変数に設定し、オプション以外の引数を位置パラメータ $@ に残します。多少分かりづらいと思うので説明します。

  1. まず for arg in "$@" により $@ がループの要素リストとしてメモリに保持されます。
  2. この状態で for ループの中で shift を行っても、メモリに保持された方の $@ には影響がありません。
    言い換えると、引数が5個ある場合、for ループの中で何度 shift を行っても5回ループします。
  3. ループの中にある shift により、ループする前に $@ にあった引数はすべてなくなります。
  4. しかし、ループの中でオプション以外の引数を set -- "$@" "$arg"$@ に追加しています。
  5. つまり、最初あった引数はなくなりますが、新たに追加した引数が $@ に残ります。

for を使うのはこのパターンだけです。なぜなら例えば --flag1 --file data.txt --flag2 という引数の場合、data.txt の部分はオプション引数なので位置パラメータに残したくありません。ですが for ループは引数を一つずつ繰り返すため data.txt を飛ばすことができません。また -abc--option=value のような一つの引数が複数の意味を持つ場合の対応も難しくなります。while を使用すればそのような処理をシンプルに書くことができます。

2. オプション引数に対応する

オプション引数に対応させます。

required() { [ $# -gt 1 ] || abort "option '$1' requires an argument"; }

FLAG_A='' FLAG_B='' ARG_I='' ARG_J=''

while [ $# -gt 0 ]; do
  case $1 in
    -a | --flag-a) FLAG_A=1 ;;
    -b | --flag-b) FLAG_B=1 ;;
    -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
    -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
    -?*) unknown "$@" ;;
    *) break
  esac
  shift
done

1. との差分(関数と変数定義以外)
-for arg; do
-  case $arg in
+while [ $# -gt 0 ]; do
+  case $1 in
     -a | --flag-a) FLAG_A=1 ;;
     -b | --flag-b) FLAG_B=1 ;;
+    -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
+    -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
     -?*) unknown "$@" ;;
-    *) set -- "$@" "$arg"
+    *) break
   esac
   shift
 done

3. オプションとオプション以外の引数の順番の混在に対応する

オプションとオプション以外の引数の順番を混在できるようにします。

param() { eval "$1=\$$1\ \\\"\"\\\${$2}\"\\\""; }

FLAG_A='' FLAG_B='' ARG_I='' ARG_J='' PARAMS=''

parse_options() {
  OPTIND=$(($# + 1))
  while [ $# -gt 0 ]; do
    case $1 in
      -a | --flag-a) FLAG_A=1 ;;
      -b | --flag-b) FLAG_B=1 ;;
      -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
      -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
      -?*) unknown "$@" ;;
      *) param PARAMS $((OPTIND - $#))
    esac
    shift
  done
}

parse_options "$@"
eval "set -- $PARAMS"

2. との差分(関数と変数定義以外)
+parse_options() {
+  OPTIND=$(($# + 1))
 while [ $# -gt 0 ]; do
   case $1 in
     -a | --flag-a) FLAG_A=1 ;;
@@ -12,10 +14,14 @@
     -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
     -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
     -?*) unknown "$@" ;;
-    *) break
+      *) param PARAMS $((OPTIND - $#))
   esac
   shift
 done
+}
+
+parse_options "$@"
+eval "set -- $PARAMS"

新しく増えたのは param 関数です。オプション以外の引数を取り出すための PARAMS 変数を組み立てています。変数名を変更できるようにするため eval を使った読みづらいコードとなっていますが、最終的に PARAMS="$PARAMS \"\${$((OPTIND - $#))}\"" というコードを実行します。例えばコマンドライン引数が -a foo -b bar の場合 PARAMS 変数には "${2}" "${4}" という文字列が入ります。そしてこの文字列を eval "set -- $PARAMS" で位置パラメータ $@ に取り出しています。bash 等であれば配列に引数を入れていくだけで良いのですが、POSIX 準拠の範囲では配列が使えないのでこのような方法を使っています。

また OPTIND 変数を本来とは違う用途で使用しているので注意してください。本来は getopts コマンドによって使用されるシェル変数で、これを違う目的に使い回すのは一般に良くないことですが、シェルスクリプトには(POSIX準拠の範囲では)グローバル変数しかなく衝突を避けるためにこのようにしています。オプション解析を自前で行うので OPTIND 変数が本来の用途で使われることはまずないので無害でしょう。気になる方は別の名前に変更してください。

4. -- でオプション解析処理を打ち切る

引数の途中で -- が出てきたときに、そこでオプション解析処理を打ち切るための対応です。

params() { [ "$2" -ge "$3" ] || params_ "$@"; }
params_() { param "$1" "$2"; params "$1" $(($2 + 1)) "$3"; }

FLAG_A='' FLAG_B='' ARG_I='' ARG_J='' PARAMS=''

parse_options() {
  OPTIND=$(($# + 1))
  while [ $# -gt 0 ]; do
    case $1 in
      -a | --flag-a) FLAG_A=1 ;;
      -b | --flag-b) FLAG_B=1 ;;
      -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
      -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
      --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
      -?*) unknown "$@" ;;
      *) param PARAMS $((OPTIND - $#))
    esac
    shift
  done
}

parse_options "$@"
eval "set -- $PARAMS"

3. との差分(関数と変数定義以外)
 parse_options() {
   OPTIND=$(($# + 1))
   while [ $# -gt 0 ]; do
     case $1 in
       -a | --flag-a) FLAG_A=1 ;;
       -b | --flag-b) FLAG_B=1 ;;
       -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
       -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
+      --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
       -?*) unknown "$@" ;;
       *) param PARAMS $((OPTIND - $#))
     esac
     shift
   done
 }

 parse_options "$@"
 eval "set -- $PARAMS"

新しく増えたのは params 関数です。-- が現れたらそれ以降の引数をすべて PARAMS 変数に追加しているだけです。

5. 結合されたショートオプションに対応する

ls -al のようにショートオプションをつなげたものへの対応です。

FLAG_A='' FLAG_B='' ARG_I='' ARG_J='' PARAMS=''

parse_options() {
  OPTIND=$(($# + 1))
  while [ $# -gt 0 ]; do
    case $1 in
      -[!-]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
    esac

    case $1 in
      -a | --flag-a) FLAG_A=1 ;;
      -b | --flag-b) FLAG_B=1 ;;
      -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
      -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
      --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
      -?*) unknown "$@" ;;
      *) param PARAMS $((OPTIND - $#))
    esac
    shift
  done
}

parse_options "$@"
eval "set -- $PARAMS"

4. との差分(関数と変数定義以外)
 parse_options() {
   OPTIND=$(($# + 1))
   while [ $# -gt 0 ]; do
+    case $1 in
+      -[!-]?*) OPTARG=$1; shift
+        set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
+    esac
+
     case $1 in
       -a | --flag-a) FLAG_A=1 ;;
       -b | --flag-b) FLAG_B=1 ;;
       -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
       -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
       --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
       -?*) unknown "$@" ;;
       *) param PARAMS $((OPTIND - $#))
     esac
     shift
   done
 }

 parse_options "$@"
 eval "set -- $PARAMS"

例えば -abcd のようになってる場合-a -bcd と最初の一文字と残りに分解しています。この処理をループのたびに行うことで、その他のコードを修正すること無く結合されたショートオプションに対応しています。

OPTIND 変数と同様に OPTARG 変数も本来の目的とは違った用途で使用しているので注意してください。OPTARG は結合されたショートオプションを分解するためのワーク変数として使用しています。

6. 結合されたオプション引数に対応する

-i value-ivalue--arg-i value--arg-i=value という指定の仕方もできるようにするための対応です。

noarg() { [ ! "$OPTARG" ] || abort "option '$1' doesn't allow an argument"; }

FLAG_A='' FLAG_B='' ARG_I='' ARG_J='' PARAMS=''

parse_options() {
  OPTIND=$(($# + 1))
  while [ $# -gt 0 ] && OPTARG=; do
    case $1 in
      --?*=*) OPTARG=$1; shift
        set -- "${OPTARG%%\=*}" "${OPTARG#*\=}" "$@" ;;
      -[ij]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}" "$@" ;;
      -[!-]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
    esac

    case $1 in
      -a | --flag-a) noarg "$@"; FLAG_A=1 ;;
      -b | --flag-b) noarg "$@"; FLAG_B=1 ;;
      -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
      -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
      --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
      -?*) unknown "$@" ;;
      *) param PARAMS $((OPTIND - $#))
    esac
    shift
  done
}

parse_options "$@"
eval "set -- $PARAMS"

5. との差分(関数と変数定義以外)
 parse_options() {
   OPTIND=$(($# + 1))
-  while [ $# -gt 0 ]; do
+  while [ $# -gt 0 ] && OPTARG=; do
     case $1 in
+      --?*=*) OPTARG=$1; shift
+        set -- "${OPTARG%%\=*}" "${OPTARG#*\=}" "$@" ;;
+      -[ij]?*) OPTARG=$1; shift
+        set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}" "$@" ;;
       -[!-]?*) OPTARG=$1; shift
         set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
     esac

     case $1 in
-      -a | --flag-a) FLAG_A=1 ;;
-      -b | --flag-b) FLAG_B=1 ;;
+      -a | --flag-a) noarg "$@"; FLAG_A=1 ;;
+      -b | --flag-b) noarg "$@"; FLAG_B=1 ;;
       -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
       -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
       --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
       -?*) unknown "$@" ;;
       *) param PARAMS $((OPTIND - $#))
     esac
     shift
   done
 }

 parse_options "$@"
 eval "set -- $PARAMS"

「結合されたオプション引数」は 5. の「結合されたショートオプション」と区別する必要があるので、どれが「結合されたオプション引数」であるかを判定するコードが必要になります。上記コードの -[ij]?*) が該当のコードです。(case の部分と二重管理に定義する必要があるので少し嫌ですね。)

また OPTARG は「結合されたオプション引数だったか?」を判定するフラグ変数としても再利用しています。

7. 省略可能なオプション引数に対応する

省略可能なオプション引数とは、「-i または -ivalue」「--arg-i または --arg-i=value」という指定ができるオプションです。-i value--arg-i value という指定の仕方はできないので注意してください。(GNU 版の getopt も同じ仕様です。)

optional() { [ "$OPTARG" ] && OPTARG=$2; }

FLAG_A='' FLAG_B='' ARG_I='' ARG_J='' OPT_O='' OPT_P='' PARAMS=''

parse_options() {
  OPTIND=$(($# + 1))
  while [ $# -gt 0 ] && OPTARG=; do
    case $1 in
      --?*=*) OPTARG=$1; shift
        set -- "${OPTARG%%\=*}" "${OPTARG#*\=}" "$@" ;;
      -[ijop]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}" "$@" ;;
      -[!-]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
    esac

    case $1 in
      -a | --flag-a) noarg "$@"; FLAG_A=1 ;;
      -b | --flag-b) noarg "$@"; FLAG_B=1 ;;
      -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
      -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
      -o | --opt-o ) optional "$@" && shift; OPT_O=$OPTARG ;;
      -p | --opt-p ) optional "$@" && shift; OPT_P=$OPTARG ;;
      --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
      -?*) unknown "$@" ;;
      *) param PARAMS $((OPTIND - $#))
    esac
    shift
  done
}

parse_options "$@"
eval "set -- $PARAMS"

6. との差分(関数と変数定義以外)
 parse_options() {
   OPTIND=$(($# + 1))
   while [ $# -gt 0 ] && OPTARG=; do
     case $1 in
       --?*=*) OPTARG=$1; shift
         set -- "${OPTARG%%\=*}" "${OPTARG#*\=}" "$@" ;;
-      -[ij]?*) OPTARG=$1; shift
+      -[ijop]?*) OPTARG=$1; shift
         set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}" "$@" ;;
       -[!-]?*) OPTARG=$1; shift
         set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
     esac

     case $1 in
       -a | --flag-a) noarg "$@"; FLAG_A=1 ;;
       -b | --flag-b) noarg "$@"; FLAG_B=1 ;;
       -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
       -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
+      -o | --opt-o ) optional "$@" && shift; OPT_O=$OPTARG ;;
+      -p | --opt-p ) optional "$@" && shift; OPT_P=$OPTARG ;;
       --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
       -?*) unknown "$@" ;;
       *) param PARAMS $((OPTIND - $#))
     esac
     shift
   done
 }

 parse_options "$@"
 eval "set -- $PARAMS"

OPTARG はさらに省略可能なオプション引数の値を入れた変数としても使用しています。

8. +, --no- でフラグを反転させる

GNU 版の getopt でも対応してない機能ですが、比較的よく使われている機能なので対応しています。

FLAG_A='' FLAG_B='' ARG_I='' ARG_J='' OPT_O='' OPT_P='' PARAMS=''

parse_options() {
  OPTIND=$(($# + 1))
  while [ $# -gt 0 ] && OPTARG=; do
    case $1 in
      --?*=*) OPTARG=$1; shift
        set -- "${OPTARG%%\=*}" "${OPTARG#*\=}" "$@" ;;
      --no-*) unset OPTARG ;;
      -[ijop]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}" "$@" ;;
      -[!-]?*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
      +??*) OPTARG=$1; shift
        set -- "${OPTARG%"${OPTARG#??}"}" "+${OPTARG#??}" "$@"; unset OPTARG ;;
      +*) unset OPTARG ;;
    esac

    case $1 in
      [-+]a | --flag-a | --no-flag-a) noarg "$@"; FLAG_A=${OPTARG+1} ;;
      [-+]b | --flag-b | --no-flag-b) noarg "$@"; FLAG_B=${OPTARG+1} ;;
      -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
      -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
      -o | --opt-o ) optional "$@" && shift; OPT_O=$OPTARG ;;
      -p | --opt-p ) optional "$@" && shift; OPT_P=$OPTARG ;;
      --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
      [-+]?*) unknown "$@" ;;
      *) param PARAMS $((OPTIND - $#))
    esac
    shift
  done
}

parse_options "$@"
eval "set -- $PARAMS"

7. との差分(関数と変数定義以外)
 parse_options() {
   OPTIND=$(($# + 1))
   while [ $# -gt 0 ] && OPTARG=; do
     case $1 in
       --?*=*) OPTARG=$1; shift
         set -- "${OPTARG%%\=*}" "${OPTARG#*\=}" "$@" ;;
+      --no-*) unset OPTARG ;;
       -[ijop]?*) OPTARG=$1; shift
         set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}" "$@" ;;
       -[!-]?*) OPTARG=$1; shift
         set -- "${OPTARG%"${OPTARG#??}"}" "-${OPTARG#??}" "$@"; OPTARG= ;;
+      +??*) OPTARG=$1; shift
+        set -- "${OPTARG%"${OPTARG#??}"}" "+${OPTARG#??}" "$@"; unset OPTARG ;;
+      +*) unset OPTARG ;;
     esac

     case $1 in
-      -a | --flag-a) noarg "$@"; FLAG_A=1 ;;
-      -b | --flag-b) noarg "$@"; FLAG_B=1 ;;
+      [-+]a | --flag-a | --no-flag-a) noarg "$@"; FLAG_A=${OPTARG+1} ;;
+      [-+]b | --flag-b | --no-flag-b) noarg "$@"; FLAG_B=${OPTARG+1} ;;
       -i | --arg-i ) required "$@" && shift; ARG_I=$1 ;;
       -j | --arg-j ) required "$@" && shift; ARG_J=$1 ;;
       -o | --opt-o ) optional "$@" && shift; OPT_O=$OPTARG ;;
       -p | --opt-p ) optional "$@" && shift; OPT_P=$OPTARG ;;
       --) shift; params PARAMS $((OPTIND - $#)) $OPTIND; break ;;
-      -?*) unknown "$@" ;;
+      [-+]?*) unknown "$@" ;;
       *) param PARAMS $((OPTIND - $#))
     esac
     shift
   done
 }

 parse_options "$@"
 eval "set -- $PARAMS"


FLAG_A 変数は、フラグが立っているときは 1 立っていないときは空文字になります。0 でないのは [ "$FLAG_A" ] という短い書き方でフラグを判定することができるようにするためです。

OPTARG をさらに反転させたフラグであるかを判断するために使用しています。

対応してない機能について

  • 単一の - で始まるロングオプション (例 -exec)
    • GNU 版の getopt で使用可能だが、find コマンドと Java や PowerShell 関連でしか見たことがなく、シェルスクリプトで実装するものとしては需要は少ないと思われるため
  • + で始まる結合されたオプション引数 (例 +ovalue)
    • zsh, ksh, mksh で使用可能だが、使われてる例を知らないため
  • オプション名を短縮名で指定できるようにする
    • コードが長くなるし、個人的にあまり好きな機能ではないので

さいごに

このコードは必要な部分をコピーして手直して組み込むことを想定したものですが、ライブラリの形にすると更に使いやすくなりそうです。気が向いたら作るかもしれません。


Viewing all articles
Browse latest Browse all 2722

Trending Articles