自作のzshスクリプトでzparseoptsを用いてコマンドラインオプションを解析したい

目標

  • その都度に設定ファイルを指定するタイプのスクリプトhomebrewで入れたmarp-cliとか)の実行をより便利にしたい
  • コマンドライン(CL)オプション(OPT)を使いたい(marpならば--pdfの有無で出力をhtmlかpdfで切り替えられる)

具体的には

その引数を忠実にパースして対応させたコマンドを呼び出せるようにする

下準備

実行環境の整理

パーミッションについて

これを読んでね

soluna-eureka.hatenablog.com

スクリプト置き場

~/zscripts/bin

にパスを通して諸々のシンボリックリンクを入れる,元のスクリプト

~/zscripts/test

のように分類することにした

シンボリックリンクを生成する際は必ずフルパスを指定して生成すること,ln -sする時は相対パス扱いで通っちゃうけど実際に走らせる時はシェル側が絶対パスとして使ってくる,つまり

pwd
# => ~/zscripts
ln -s test/testZsh1 bin

は動いてしまうのだが(これは何も間違ってはいない,この時のzshから見ればtest/testZsh1は存在するし,bin/testZshはちゃんと生成される),これを実行しようとすると

testZsh1
# => zsh「$PATHを読むぞ,~/zscripts/binを見るぞ,testZsh1があったぞ」
# => zsh「えーとtest/Zsh1に行け…?そんな場所ねぇよ!」
# => zsh「zsh: command not found」

という結果になる,ただしこの状態でも$PATH上にシンボリックリンクは存在するため,zshは可能な限りコマンド補完を提供してくれる,うーんこれはかなり紛らわしいね

現状ではlnに引数対象の絶対パスの補完機能はない,というよりもシンボリックリンクではなくハードリンクならiモード番号をまんま参照するためこんなことは起きない,シンボリックリンクリンクに特有の現象と考えて諦めて受け入れるしかなさそう

パスを通す

export PATH=~/zscripts/bin:$PATH

実行ファイル作成

以下の作業はパターン化しよう

cd ~/zscripts/test
touch testZshS1
chmod 0755 testZsh1 # <-rwxr-xr-x
ln -s ~/zscripts/test/testZsh1 ~/zscripts/bin/ # <-最後にスラッシュつけないとディレクトリと認識されないので注意
vi testZsh1
#!/bin/zsh
# this is testZsh1

echo "this is testZsh1"
% testZsh1
# => this is testZsh1

予約済み変数(特殊変数)

シェバンにzshで実行するよう指示されたシェルスクリプトは,起動時にユーザが操作しているシェルからzshのプロセスに渡される,つまりは「ログイン者と対話するシェル」と「スクリプトを実行するシェル」が必ず同じである必要がない.しかしそれでもzshで環境を統一することをオススメしたい理由として,zshが対話シェルとして渡す特殊変数と実行シェルとして受け取る特殊変数の扱いを共通化することによる利点がある.読み物が一つの同じドキュメントだけで済むために大きく労力を削減できるし,権限管理などもやりやすい上にコーディングも多少は楽になるだろうとと思われるからだ(最近になって「移行しておいてよかった」と思い始めている,それでもGoogle先生はよくzshを無視してbashの記事を出してくるけど).
シェルスクリプトはシェバンの書きようによってはshbashに実行させることも可能である,それ故にシェルを切り替えた時に迂闊なミスが起こらないよう,シェルごとにスクリプトの管理フォルダと対応するパスを切り分けるべきだと考えている.

そしてログインシェルとスクリプトシェルの間でやり取りされる情報を利用するための特殊変数がzshには用意されている,manページから以下のコマンドで抽出できるので確認しよう

# ログインシェルが渡すもの
man zshparam | col -b | expand -t 4 | pcre2grep -Moe ' {0}(PARAMETERS SET BY THE SHELL\n)( {5,}(\S* *)*\S*\n*)*'

ちなみに元から利用できる環境変数などは以下で抽出できる

# ログインシェルが使うもの
man zshparam | col -b | expand -t 4 | pcre2grep -Moe ' {0}(PARAMETERS USED BY THE SHELL\n)( {5,}(\S* *)*\S*\n*)*'

シェルスクリプトでは変数名の前に$をつけて展開できるが,これもzshも例外ではなく(以下で抽出できる)

# zshにおける変数展開について
man zshexpn | col -b | expand -t 4 | pcre2grep -Moe ' {0}(PARAMETER EXPANSION\n)( {5,}(\S* *)*\S*\n*)*'

,多くの場合は既に$がついた形式で覚えられがちなのだが,取り敢えず重要そうなものだけをドキュメント通りに並べると

  • $
  • 0
  • *
    • 呼び出し時に与えられた全ての引数
    • 配列扱い
      • bashでは文字列1個の扱いだったらしいな?
    • 引数がなければ未定義
  • @
    • Same as argv[@]らしい
    • 引数がなければ未定義
  • argv
    • Same as *らしい
    • こいつは変数として簡単に処理できる
      • *@は処理が面倒だぞ(後述)
    • 引数がなくてもargvは空の配列として定義される
  • #
    • 引数の個数
  • ?
    • 1つ前の関数の終了状態を示す
    • つまり関数が評価されるたびに値が入れ替えられる
      • シェバンに-eがあると異常終了で必ず止まるけど
      • -uすると未定義変数を参照した時に必ず止まるよ
  • status
    • Same as ?らしい

ちなみにzsh自身のオプションは以下で確認できる,-euに関してはbashと同じっぽい

man zshoptions | col -b | expand -t 4 | pcre2grep -Moe ' {0}(SINGLE LETTER OPTIONS\n)( {3,}(\S* *)*\S*\n*)*'

仕様などは以下を参考に

qiita.com

実験して確認する

#!/bin/zsh -u
# this is testZsh2
# -eu するとエラーで爆死しにくくなるらしい,みんなは-eをつけよう

true=1
false=0
# これは単なるおまじない

echo "pid: '${$}'"
echo "command: '${0}'"
echo "length: '${#}'"

cat OMMCvsCCCCM.txt
# 当然だけどそんな下品なファイルはないので異常なまま終了する(status: 1)
echo "status: '${?}'"

if ((${*:+true})); then
    echo "* is defined"
    echo "for \$* : '${*}'"
    for ((i=1;i<=${#*};i++));do
        echo "for \$*[${i}]: '${*[i]}'"
    done
fi

if ((${@:+true})); then
    echo "@ is defined"
    echo "for \$@ : '${@}'"
    for ((i=1;i<=${#@};i++));do
        echo "for \$@[${i}]: '${@[i]}'"
    done
fi

if ((${+argv})); then
    echo "argv is defined"
    echo "length of argv: '${#argv}'"
fi

unset argv
echo "after unset of 'argv', *: '${*}'"
echo "after unset of 'argv', @: '${@}'"

引数を入れたり消したりして試してみよう,この時点で半角スペースによる配列化が自動で実行されていることがわかるだろう

% testZsh2 -a b -c
# => pid: '[やる度に値が変わる]'
# => command: '[ここにフルパスが出てくる]'
# => length: '3'
# => cat: OMMCvsCCCCM.txt: No such file or directory
# => status: '1'
# => * is defined!
# => for $* : '-a b -c'
# => for $*[1]: '-a'
# => for $*[2]: 'b'
# => for $*[3]: '-c'
# => @ is defined!
# => for $@ : '-a b -c'
# => for $@[1]: '-a'
# => for $@[2]: 'b'
# => for $@[3]: '-c'
# => argv is defined!
# => length of argv: '3'
# => after unset of 'argv', *: ''
# => after unset of 'argv', @: ''

*@は定義されているか?

変数定義の確認方法として有名なものに((${+xxx}))があるものの,zsh 5.8においては((${+*}))((${+@}))zsh: bad substitutionを吐き出す…変数ではなく演算子として認識されているらしく,これに引数の有無や数は関係ないと思われる
だからtrue=1を用意して((${*:+true}))((${@:+true}))にする必要がある,これなら確実に先に*@が展開されて:で評価して定義されていれば+trueを返すことができる(((${xxx:+yyy}))については先に挙げたリファレンスにも載ってる)

他のやり方などは以下を参考に

unhexium.net

zparseoptsについて

zshをセットで導入すれば元から使えるやつらしい,他にも候補があるらしいけどzsh上の普遍性を考慮してこれにした
扱い上はzshmodulesの一部なのでmanページは以下で抽出できる

zscripts % man zshmodules | col -b | expand -t 4 | pcre2grep -Moe ' {7}(zparseopts.*\n)( {10,}(\S* *)*\S*\n*)*' 

特徴

zparseopts*を設定しなくても良い

どうやら勝手に*を覗いて変更してくるらしい,必要なのは動作の調整と扱うオプションの設定だけである

所用のハイフン数に制限なし

1つや2つに限らずいくらでも増やせる,使う機会があるかどうか?さぁ…

オプション文字数に制限なし

-x-xxも通る,-は1文字・--はそれ以外みたいな風潮はあるけど

楽に連想配列を使える

-Aを入れると-x yから勝手に-x: yみたいな連想配列を作ってくれる
-aなら-x yをくっつけて-xyを生成して配列に代入してくるだけだけど…

1オプションには1設定

-x y z -a -b -cとかを入れるとz以降が無視されz -a -b -cが余剰として扱われる,もちろん用意した連想配列は壊れる
複数の設定を1つのオプションに入れたいとなると,流石に*に対する処理を手動で実装した方が早そう,今回はやらんけど…

テンプレート

zparseopts -D -E -a optionArray -A optionPair a bc: -ddd:: --e:

-Dとは?

元の*からオプションとみなした部分を全て切り取ってくれる,複数のファイルを取り扱う場合はこれが必要になってくる
前述したようにパースが崩壊すると切り取れなかった部分が全て*に残ることになる

-Eとは?

オプション宣言の前に変数を置いたり合間に余剰な変数を置いたりしてもパースを続けてくれる,つまりはさっきの注意書きを無視できる,コマンド・ファイル・オプション・内容みたいにやる時には便利かも

オプションの指定方法について

ハイフンが1つだけ減る(連想配列から取り出すときは減らしちゃダメだよ)

  • -aならa
  • -bcならbc
  • --dddなら-ddd激安の殿堂ZOY
  • ---eなら--e

また:の数にも意味がある,オプション後に引数について

  • 必ずなければa
    • あるとエラー
  • 必ずあればa:
    • ないとエラー
  • 分岐したければa::
    • ないと空白の文字列が連想配列に代入される
    • ただし配列が完全に連結してしまう副作用がある
    • -x a[-x, a]となるところを[-xa]にしてしまう
      • ループ処理を自前でやりたい場合とか逆に辛いかも?
    • 連想配列では{-x :a}で変わらない

さらに++:+:::-::-は同じオプションを何回も呼び出した時に影響がある

  • +は倍プッシュを許可する
    • 配列に影響が出る
    • -x -x[-x]となるところを[-x, -x]にできる
  • +:は引数を連結させる
    • 連想配列に影響が出る,配列は倍プッシュされる
    • -x a -x bなら{-x: ab}[-x, a, -x ,b]になる
  • +::は引数を完全に連結させる
    • 配列に影響が出る
    • -x a -x bなら{-x: ab}[-xa, -xb]になる
  • :-::-は…ほとんど意味ない
    • まず-をつけるだけで::と同じ副作用が必ず出現する
      • +:+::みたいに有用と思われる違いがない
    • 引数の置き換えは配列でも連想配列でも重複の呼び出しで勝手に発生するので…
      • つまり:-::-も使い道がない,無視して良い

実験して確認する

#!/bin/zsh -eu
# this is testZsh3

echo "all arguments : ${*}"

zparseopts -D -E -a optionArray -A optionPair a+ bc+:: -ddd+: --e:

if [[ -n "${optionPair[(i)-a]}" ]]; then
    echo "-a option enabled"
fi

if [[ -n "${optionPair[(i)-bc]}" ]]; then
    echo "-bc option enabled: '${optionPair[-bc]}'"
fi

if [[ -n "${optionPair[(i)--ddd]}" ]]; then
    echo "--ddd option enabled: '${optionPair[--ddd]}'"
fi

if [[ -n "${optionPair[(i)---e]}" ]]; then
    echo "---e option enabled: '${optionPair[---e]}'"
fi

echo "other arguments : $*"

if ((${+optionArray})); then
    for i in $optionArray; do
        echo "component: '${i}'"
    done
fi

if ((${+optionPair})); then
    for key in ${(@k)optionPair}; do
        echo "flags: ${key} => value: ${optionPair[$key]}"
    done
fi

echo "${optionPair}"

key="---e"
echo "${#optionPair[$key]}"
% testZsh3 xyz stst -a -a -bc p -bc q --ddd daiou --ddd heika ---e rrr ---e r tsts
all arguments : xyz stst -a -a -bc p -bc q --ddd daiou --ddd heika ---e rrr ---e r tsts
-a option enabled
-bc option enabled: 'pq'
--ddd option enabled: 'daiouheika'
---e option enabled: 'r'
other arguments : xyz stst tsts
component: '-a'
component: '-a'
component: '-bcp'
component: '-bcq'
component: '--ddd'
component: 'daiou'
component: '--ddd'
component: 'heika'
component: '---e'
component: 'r'
flags: -a => value: 
flags: ---e => value: r
flags: -bc => value: pq
flags: --ddd => value: daiouheika
 r pq daiouheika
1

marpコマンドを改善

github.com

formulae.brew.sh

詳細については今は省く(また別のところでやるかも)が,CLIから叩けるmarpvscode拡張機能版とは異なり設定ファイルを-cオプションで指定できる(その設定ファイルも万能ではない)が,しかしmarp本体に記憶させることができない.そのためシェルスクリプトで事前に定めたパスを必ず指定できるように仕向けようと考えた,あとはvscodeに機能を追加するよりもzshシェルスクリプトを書く練習から始めたかった.
…とりあえず-m pdf-m html-m pptxで使い分けられるようにした.ちなみにmarpはまだ全く使いこなせていない.

#!/bin/zsh -eu
# this is marpit

template='[任意のパス]'

zparseopts -D -E -a optionArray -A optionPair i: o: m::

input='-i'
output='-o'
mode='-m'
pdf="pdf"
html="html"
pptx="pptx"

if ((${+optionPair[$mode]})); then
    type=${optionPair[$mode]}
    case $type in
    ($pdf) marp -c ${template} --pdf ${optionPair[$input]}.md -o ${optionPair[$output]}.pdf;|
    ($html) marp -c ${template} ${optionPair[$input]}.md -o ${optionPair[$output]}.html;|
    ($pptx) marp -c ${template} --pptx ${optionPair[$input]}.md -o ${optionPair[$output]}.pptx;|
    esac
else
    echo marp --template ${template}  ${optionPair[$inout]} -0 ${optionPair[$output]}
fi
% marpit -i test -o test -m html     
[  INFO ] An EXPERIMENTAL transition support for bespoke template is enabled. It is using the shared element transition API proposal and it is not yet stable. Recommend to use
          with --preview option for trying transitions. Track the latest information at https://github.com/marp-team/marp-cli/issues/382.
[  INFO ] Converting 1 markdown...
[  INFO ] test.md => test.html
% marpit -i test -o test -m pdf 
[  INFO ] An EXPERIMENTAL transition support for bespoke template is enabled. It is using the shared element transition API proposal and it is not yet stable. Recommend to use
          with --preview option for trying transitions. Track the latest information at https://github.com/marp-team/marp-cli/issues/382.
[  INFO ] Converting 1 markdown...
[  WARN ] Insecure local file accessing is enabled for conversion from test.md.
[  INFO ] test.md => test.pdf
% marpit -i test -o test -m pptx
[  INFO ] An EXPERIMENTAL transition support for bespoke template is enabled. It is using the shared element transition API proposal and it is not yet stable. Recommend to use
          with --preview option for trying transitions. Track the latest information at https://github.com/marp-team/marp-cli/issues/382.
[  INFO ] Converting 1 markdown...
[  WARN ] Insecure local file accessing is enabled for conversion from test.md.
[  INFO ] test.md => test.pptx

zshの制御構文については以下で抽出できる

man zshmisc | col -b | expand -t 4 | pcre2grep -Moe ' {0}(COMPLEX COMMANDS\n)( {7,}(\S* *)*\S*\n*)*'

感想

少しだけ仲良くなれたんじゃないかなと思います,以上で終わります

おまけ

man zshmoduleszparseoptsの部分を抜き出した

       zparseopts [ -D -E -F -K -M ] [ -a array ] [ -A assoc ] [ - ] spec ...
          This builtin simplifies the parsing of options in positional
          parameters, i.e. the set of arguments given by $*.  Each spec
          describes one option and must be of the form `opt[=array]'.  If an
          option described by opt is found in the positional parameters it
          is copied into the array specified with the -a option; if the
          optional `=array' is given, it is instead copied into that array,
          which should be declared as a normal array and never as an
          associative array.

          Note that it is an error to give any spec without an `=array'
          unless one of the -a or -A options is used.

          Unless the -E option is given, parsing stops at the first string
          that isn't described by one of the specs.  Even with -E, parsing
          always stops at a positional parameter equal to `-' or `--'. See
          also -F.

          The opt description must be one of the following.  Any of the
          special characters can appear in the option name provided it is
          preceded by a backslash.

          name
          name+  The name is the name of the option without the leading `-'.
             To specify a GNU-style long option, one of the usual two
             leading `-' must be included in name; for example, a
             `--file' option is represented by a name of `-file'.

             If a `+' appears after name, the option is appended to
             array each time it is found in the positional parameters;
             without the `+' only the last occurrence of the option is
             preserved.

             If one of these forms is used, the option takes no
             argument, so parsing stops if the next positional parameter
             does not also begin with `-' (unless the -E option is
             used).

          name:
          name:-
          name:: If one or two colons are given, the option takes an
             argument; with one colon, the argument is mandatory and
             with two colons it is optional.  The argument is appended
             to the array after the option itself.

             An optional argument is put into the same array element as
             the option name (note that this makes empty strings as
             arguments indistinguishable).  A mandatory argument is
             added as a separate element unless the `:-' form is used,
             in which case the argument is put into the same element.

             A `+' as described above may appear between the name and
             the first colon.

          In all cases, option-arguments must appear either immediately
          following the option in the same positional parameter or in the
          next one. Even an optional argument may appear in the next
          parameter, unless it begins with a `-'.  There is no special
          handling of `=' as with GNU-style argument parsers; given the spec
          `-foo:', the positional parameter `--foo=bar' is parsed as `--foo'
          with an argument of `=bar'.

          When the names of two options that take no arguments overlap, the
          longest one wins, so that parsing for the specs `-foo -foobar'
          (for example) is unambiguous. However, due to the aforementioned
          handling of option-arguments, ambiguities may arise when at least
          one overlapping spec takes an argument, as in `-foo: -foobar'. In
          that case, the last matching spec wins.

          The options of zparseopts itself cannot be stacked because, for
          example, the stack `-DEK' is indistinguishable from a spec for the
          GNU-style long option `--DEK'.  The options of zparseopts itself
          are:

          -a array
             As described above, this names the default array in which
             to store the recognised options.

          -A assoc
             If this is given, the options and their values are also put
             into an associative array with the option names as keys and
             the arguments (if any) as the values.

          -D     If this option is given, all options found are removed from
             the positional parameters of the calling shell or shell
             function, up to but not including any not described by the
             specs.  If the first such parameter is `-' or `--', it is
             removed as well.  This is similar to using the shift
             builtin.

          -E     This changes the parsing rules to not stop at the first
             string that isn't described by one of the specs.  It can be
             used to test for or (if used together with -D) extract
             options and their arguments, ignoring all other options and
             arguments that may be in the positional parameters.  As
             indicated above, parsing still stops at the first `-' or
             `--' not described by a spec, but it is not removed when
             used with -D.

          -F     If this option is given, zparseopts immediately stops at
             the first option-like parameter not described by one of the
             specs, prints an error message, and returns status 1.
             Removal (-D) and extraction (-E) are not performed, and
             option arrays are not updated.  This provides basic
             validation for the given options.

             Note that the appearance in the positional parameters of an
             option without its required argument always aborts parsing
             and returns an error as described above regardless of
             whether this option is used.

          -K     With this option, the arrays specified with the -a option
             and with the `=array' forms are kept unchanged when none of
             the specs for them is used.  Otherwise the entire array is
             replaced when any of the specs is used.  Individual
             elements of associative arrays specified with the -A option
             are preserved by -K.  This allows assignment of default
             values to arrays before calling zparseopts.

          -M     This changes the assignment rules to implement a map among
             equivalent option names.  If any spec uses the `=array'
             form, the string array is interpreted as the name of
             another spec, which is used to choose where to store the
             values.  If no other spec is found, the values are stored
             as usual.  This changes only the way the values are stored,
             not the way $* is parsed, so results may be unpredictable
             if the `name+' specifier is used inconsistently.

          For example,

             set -- -a -bx -c y -cz baz -cend
             zparseopts a=foo b:=bar c+:=bar

          will have the effect of

             foo=(-a)
             bar=(-b x -c y -c z)

          The arguments from `baz' on will not be used.

          As an example for the -E option, consider:

             set -- -a x -b y -c z arg1 arg2
             zparseopts -E -D b:=bar

          will have the effect of

             bar=(-b y)
             set -- -a x -c z arg1 arg2

          I.e., the option -b and its arguments are taken from the
          positional parameters and put into the array bar.

          The -M option can be used like this:

             set -- -a -bx -c y -cz baz -cend
             zparseopts -A bar -M a=foo b+: c:=b

          to have the effect of

             foo=(-a)
             bar=(-a '' -b xyz)