はじめに
こちらの記事に触発され、私もシェルスクリプトの循環的複雑度(サイクロマティック複雑度)を測定するツールを作ってみました。名前は ShellMetrics です。この方も書かれていますが、シェルスクリプト用の循環的複雑度を測定するツールってなかったんですよね。シェルスクリプトでそんなものが必要になるほど複雑なものなんて作らないってことなんでしょうが、まったく無いというわけでもなく、例えば有名なものでいえば rbenv とか git-secret とか、私が作ってる BDD テスティングフレームワークの ShellSpec とか。シェルスクリプトで実装すると多くの Linux / Unix / macOS / WSL で環境設定やパッケージインストールすることなく動くので物によっては便利だったりします。
循環的複雑度を測定するツールは前から欲しいとは思っていたのですが計算方法がこんなに簡単であることを知らなかったので手を出していなかったのですが、先の方のソースコードを見てあれ?たったこんだけ?と気づいたので、これなら(シェルスクリプトで)作れると思ったので作ってみました。(アルファ版は正味3日ほどで作りました。)
使い方
ツールとしてはまだ機能不十分でディレクトリ対応とかないです。引数のファイル(複数対応)の循環的複雑度を表示します。しきい値でエラーにする機能とかもありませんが CSV 出力機能をつけているので独自で対応したり CI に組み込んだりすることは難しくないと思います。
Usage: shellmetrics [options] files...
-s, --shell The path of shell to use as parser [default: bash]
Supported shells: bash, mksh, yash, zsh
--[no-]color Enable / Disable color [default: enabled]
--csv Generate CSV output
-p, --pretty Format pretty with wrapper function(s)
-d, --debug Display parsed data for debug instead of report
-v, --version Display the version
-h, --help You're looking at it
サンプルとして、ShellMetrics 自身の循環的複雑度です。CCN が循環的複雑度の値です。NLOC はコメントや空白を除いたソースコードの行数、LLOC はカッコだけの行を省いたり、セミコロンで一行に複数のコマンドを詰め込んだのを分解したりした実行可能なステートメントの行数です。ファイル全体、コメント、空白の行数も表示しています。
$ shellmetrics ./shellmetrics
==============================================================================
LLOC CCN Location
------------------------------------------------------------------------------
1 1 usage:9 shellmetrics
1 1 proxy:35 shellmetrics
1 1 putsn:40 shellmetrics
1 1 putsn:44 shellmetrics
4 2 putsn:39 shellmetrics
9 2 count:51 shellmetrics
2 1 is_comment_line:64 shellmetrics
2 2 is_blank_line:69 shellmetrics
3 1 repeat_string:73 shellmetrics
3 2 array:79 shellmetrics
2 1 array_is_empty:86 shellmetrics
7 2 push_array:91 shellmetrics
11 3 pop_array:102 shellmetrics
11 3 shift_array:119 shellmetrics
8 3 peel:136 shellmetrics
7 3 pretty:149 shellmetrics
2 1 process:162 shellmetrics
65 27 parse:167 shellmetrics
21 8 analyze:238 shellmetrics
56 6 default_report:274 shellmetrics
10 4 csv_report:372 shellmetrics
3 1 title:389 shellmetrics
9 5 init_mode:395 shellmetrics
9 2 main:412 shellmetrics
4 2 error:426 shellmetrics
2 1 abort:435 shellmetrics
1 1 unknown:440 shellmetrics
1 1 required:441 shellmetrics
1 1 param:442 shellmetrics
1 1 params:443 shellmetrics
2 1 params_:444 shellmetrics
20 12 parse_options:446 shellmetrics
52 2 <main> shellmetrics
------------------------------------------------------------------------------
1 file(s), 33 function(s) analyzed. [bash 4.4.20(1)-release]
==============================================================================
NLOC NLOC LLOC LLOC CCN Func File (lines:comment:blank)
total avg total avg avg cnt
------------------------------------------------------------------------------
412 12.48 332 10.06 3.18 33 shellmetrics (479:5:62)
------------------------------------------------------------------------------
==============================================================================
NLOC NLOC LLOC LLOC CCN Func File lines comment blank
total avg total avg avg cnt cnt total total total
------------------------------------------------------------------------------
412 12.48 332 10.06 3.18 33 1 479 5 62
------------------------------------------------------------------------------
なぜシェルスクリプトで作ったのか?
意外かと思われるかもしれませんが、シェルスクリプトで作るのが簡単だったからです。上記の ShellMetrics 自身のメトリクスから分かる通り、コード行数わずか412行(論理行数 LLOC 332行)しかありません。循環的複雑度を計算するのに一番大変なのはソースコードをパースする部分だと思うのですが、実はその部分を思いっきり手抜きしています。普通のやり方で文字列をパースしていくのはシェルスクリプトでは面倒で遅くなってしまうでしょう。その一番大変な部分を bash 自身にやらせています。
構文解析
実は bash (や zsh、mksh、yash) では定義したシェル関数を typeset -fp
でソースコードの形で出力することができます。この時に対応しているシェルではソースコードが整形されます。
$ echo 'foo() { echo foo; for i; do echo "$i"; done }; typeset -fp foo' | bash
foo ()
{
echo foo;
for i in "$@";
do
echo "$i";
done
}
関数定義やループの前後には必ず改行やインデントが入るのでこれを利用することで解析すべき構文のパターンを大幅に減らすことができます。文字単位でのパースが必要がなくシェルスクリプトで十分実装可能な行レベルの文字列処理だけで実装することができます。とは言え改行が入った文字列やヒアドキュメント内の文字列との誤爆をどう回避するかとか、元のソースコードの関数の行番号との対応とかスタック関数の実装とか、細々とした面倒はありましたが。
--debug
オプションで(整形された)ソースコードをどのように解釈したのかを見れるようにしています。(というか自分向けデバッグ用)
$ shellmetrics -d shellmetrics
略
0| |};
0|*f |function analyze_L238 ()
0| |{
4|* | ccn=1 lloc=0 func_array_last=0;
4|* | array indent func ccn lloc;
4|* | echo 0 0 "<begin>" "$1|${2:-0}:${3:-0}:${4:-0}";
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 "$lloc" "$ccn" "$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
)"
他の言語であればダブルクォートから次のダブルクォート(エスケープ文字除く)までが文字列とみなされますがシェルスクリプトではそうは行きません。これに関しては次のように対処しました。
まず前項の通りコードを関数として登録し typeset -fp
で整形済みのコードを出力します。このとき関数になってないコードもあるので、周りをダミーの関数でくくります。そうするとこのように整形されます。
dummy()
{
echo "$(
foo() {
echo "ok"
}
foo
)"
}
関数でくくって整形したのでコードはインデントされます。ただし文字列はインデントされません。「ダミーの関数でくくる」を複数回くリ返します。そうするとこのようになります。
dummy ()
{
function dummy ()
{
function dummy ()
{
function dummy ()
{
function dummy ()
{
echo "$(
foo() {
echo "ok"
}
foo
)"
}
}
}
}
}
echo
のようなコードであれば、当然インデントされますが、改行が入った文字列やヒアドキュメントはインデントされません。(インデントしてしまうと意味が変わります。)これを利用して想定されるインデント位置より前にあるかどうかで継続行か否かを判断しています。(いくつインデントするかはコードをみて計算しています)この方法により複雑なパース処理を行うこと無く問題を解決しています。
テスト
テストはもちろん ShellSpec を使って行っています。まさにこのためのツールですから。我ながらテストが書きやすいです(笑)対応シェル(bash、zsh、mksh、yash)の全てでテストを行って TravisCI、Coveralls と連携させています。シェルスクリプトで実装されてるツールでここまでしてるのはなかなかないと思います。
さいごに
ということでシェルスクリプトでそれなりの規模のツールを作りやすくするという私の計画がさらに一歩進みました。
- ShellSpec BDDテスティングフレームワーク
- ShellMetrics 循環的複雑度測定ツール
- ShellCheck 有名なlintツール(※もちろん私が作ったのではない)
名前もいい感じに揃えています(笑)