はじめに
こちらの記事に触発され、私もシェルスクリプトの循環的複雑度(サイクロマティック複雑度)を測定するツールを作ってみました。(まだアルファ版です。)この方も書かれていますが、シェルスクリプト用の循環的複雑度を測定するツールってなかったんですよね。シェルスクリプトでそんなものが必要になるほど複雑なものなんて作らないってことなんでしょうが、まったく無いというわけでもなく、例えば有名なものでいえば rbenvとか git-secretとか、私が作ってる BDD テスティングフレームワークの shellspecとか。シェルスクリプトで実装すると多くの Linux / Unix / macOS / WSL で環境設定やパッケージインストールすることなく動くので物によっては便利だったりします。
循環的複雑度を測定するツールは前から欲しいとは思っていたのですが計算方法がこんなに簡単であることを知らなかったので手を出していなかったのですが、先の方のソースコードを見てあれ?たったこんだけ?と気づいたので、これなら(シェルスクリプトで)作れると思ったので作ってみました。(正味3日ほど)
使い方
ツールとしてはまだ機能不十分でディレクトリ対応とかないです。引数のファイル(複数対応)の循環的複雑度を表示します。
Usage: shellmetrics [options] files...
-d, --debug
-h, --help
-v, --version
サンプルとして、shellmetrics 自身の循環的複雑度です。CCNが循環的複雑度の値です。SLOC は実際のソースコードの行数、LLOC はカッコだけの行を省いたり、セミコロンで一行に複数のコマンドを詰め込んだのを分解した本質的な行数です。
$shellmetrics ./shellmetrics
======================================================================
LLOC CCN Location
----------------------------------------------------------------------
1 1 usage:7 ./shellmetrics
5 2 count:22 ./shellmetrics
3 1 repeat_string:31 ./shellmetrics
3 2 array:37 ./shellmetrics
2 1 array_is_empty:44 ./shellmetrics
9 4 push_array:49 ./shellmetrics
12 3 pop_array:63 ./shellmetrics
12 3 shift_array:80 ./shellmetrics
8 3 peel:97 ./shellmetrics
7 3 pretty:110 ./shellmetrics
1 1 lex:123 ./shellmetrics
39 20 parse:127 ./shellmetrics
20 8 analyze:192 ./shellmetrics
42 6 report:226 ./shellmetrics
2 1 abort:296 ./shellmetrics
1 1 unknown:297 ./shellmetrics
1 1 required:298 ./shellmetrics
1 1 param:299 ./shellmetrics
1 1 params:300 ./shellmetrics
2 1 params_:301 ./shellmetrics
14 8 parse_options:305 ./shellmetrics
7 2 <main>./shellmetrics
----------------------------------------------------------------------
1 file analyzed.
======================================================================
SLOC LLOC LLOC CCN Func File
total total avg avg cnt
----------------------------------------------------------------------
337 193 8.77 3.36 22 ./shellmetrics
----------------------------------------------------------------------
======================================================================
SLOC LLOC LLOC CCN Func File
total total avg avg cnt cnt
----------------------------------------------------------------------
337 193 8.77 3.36 22 1
----------------------------------------------------------------------
なぜシェルスクリプトで作ったのか?
意外かと思われるかもしれませんが、シェルスクリプト (bash、zshでも動作可能) で作るのが簡単だったからです。上記の shellmetrics 自身のメトリクスから分かる通り、コード行数わずか337行(論理行数LLOC 193行)しかありません。循環的複雑度を計算するのに一番大変なのはソースコードをパースする部分だと思うのですが、実はその部分を思いっきり手抜きしています。普通のやり方で文字列をパースしていくのはシェルスクリプトでは面倒で遅くなってしまうでしょう。その一番大変な部分を bash 自身にやらせています。
構文解析
実は bash と zsh には登録した関数を、declare -fでソースコードの形で出力することができます。
$echo'foo() { echo foo; for i; do echo "$i"; done }; declare -f foo' | bash
foo ()
{
echo foo; for i in "$@";
do
echo "$i";
done
}
見てわかるとおり関数を登録して出力するとコードが整形されます。関数定義やループの前後には必ず改行やインデントが入るのでこれを利用することで解析すべき構文のパターンを大幅に減らすことができます。文字単位でのパースが必要がなくシェルスクリプトで十分実装可能な行レベルの文字列処理だけで実装することができます。とは言え改行が入った文字列やヒアドキュメント内の文字列との誤爆をどう回避するかとか、元のソースコードの関数の行番号との対応とかスタック関数の実装とか、細々とした面倒はありましたが。
--debugオプションで(整形された)ソースコードをどのように解釈したのかを見れるようにしています。(というか自分向けデバッグ用)
$ shellmetrics -d shellmetrics
略
0| |};
0|*f |function analyze:192 ()
0| |{
4|* | ccn=1 lloc=0 func_array_last=0;
4|* | array indent func ccn lloc;
4|* | echo 0 "$2" "<begin>" "$1";
4|* l| while IFS="|" read -r indent mark line; do
8|* | case $mark in
12| c| *"~"*)
16|* | continue
12| | ;;
8| | esac;
8|* | case $line in
12| c| "}" | "};" | "} "*)
16|* c| if [ "$indent" = "${indent_array_last:-none}" ]; then
20|* | echo "$ccn" "$lloc" "$func_array_last" "$1";
20|* | pop_array indent func ccn lloc;
16| | fi
12| | ;;
8| | esac;
略
文字列・ヒアドキュメント処理
構文を解析するときに単純に正規表現マッチングだけで対処すると、文字列やヒアドキュメントの中にマッチする文字が入っていた場合に困ります。例えば以下のようなコードだとヒアドキュメントの中に含まれるbar()を関数とみなしてしまうでしょう。
foo(){cat<<HERE
bar() {
echo
}
HERE
}どの部分がヒアドキュメント(継続行)か判断する必要がありますが、シェルスクリプトはコマンド置換を使って文字列の中にコードを埋め込むことができます。例えば次のようなコードです。
echo"$(
foo(){echo"ok"}
foo
)"他の言語であればダブルクォートから次のダブルクォート(エスケープ文字除く)までが文字列とみなされますがシェルスクリプトではそうは行きません。これに関しては次のように対処しました。
まず前項の通りコードを関数として登録し declare -fで整形済みのコードを出力します。このとき関数になってないコードもあるので、周りをダミーの関数でくくります。そうするとこのように整形されます。
dummy(){echo"$(
foo(){echo"ok"}
foo
)"}関数でくくって整形したのでコードはインデントされます。ただし文字列はインデントされません。「ダミーの関数でくくる」を複数回くリ返します。そうするとこのようになります。
dummy (){function dummy (){function dummy (){function dummy (){function dummy (){echo"$(
foo(){echo"ok"}
foo
)"}}}}}echoのようなコードであれば、当然インデントされますが、改行が入った文字列やヒアドキュメントはインデントされません。(インデントしてしまうと意味が変わります。)これを利用して想定されるインデント位置より前にあるかどうかで継続行か否かを判断しています。(いくつインデントするかはコードをみて計算しています)この方法により複雑なパース処理を行うこと無く問題を解決しています。
さいごに
ということでシェルスクリプトでそれなりの規模のツールを作りやすくするという私の計画がさらに一歩進みました。
- shellspec BDDテスティングフレームワーク
- shellmetrics循環的複雑度測定ツール
- shellcheck有名なlintツール(※もちろん私が作ったのではない)
名前もいい感じに揃えています(笑)