シェルスクリプトの循環的複雑度を測定するツールをシェルスクリプトで実装してみた

# はじめに

[こちらの記事](https://qiita.com/summergirlna/items/3396a2cd4226dec34e28)に触発され、私もシェルスクリプトの循環的複雑度（サイクロマティック複雑度）を測定するツールを作ってみました。名前は  [ShellMetrics](https://github.com/shellspec/shellmetrics) です。この方も書かれていますが、シェルスクリプト用の循環的複雑度を測定するツールってなかったんですよね。シェルスクリプトでそんなものが必要になるほど複雑なものなんて作らないってことなんでしょうが、まったく無いというわけでもなく、例えば有名なものでいえば [rbenv](https://github.com/rbenv/rbenv) とか [git-secret](https://github.com/sobolevn/git-secret) とか、私が作ってる BDD テスティングフレームワークの [ShellSpec](https://github.com/shellspec/shellspec) とか。シェルスクリプトで実装すると多くの Linux / Unix / macOS / WSL で環境設定やパッケージインストールすることなく動くので物によっては便利だったりします。

循環的複雑度を測定するツールは前から欲しいとは思っていたのですが計算方法が[こんなに簡単](https://qiita.com/uhooi/items/c77a53a4c7ac232a1ba1)であることを知らなかったので手を出していなかったのですが、先の方のソースコードを見てあれ？たったこんだけ？と気づいたので、これなら（シェルスクリプトで）作れると思ったので作ってみました。（アルファ版は正味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 はカッコだけの行を省いたり、セミコロンで一行に複数のコマンドを詰め込んだのを分解したりした実行可能なステートメントの行数です。ファイル全体、コメント、空白の行数も表示しています。

```console
$ 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` でソースコードの形で出力することができます。この時に対応しているシェルではソースコードが整形されます。

```console
$ 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`オプションで（整形された）ソースコードをどのように解釈したのかを見れるようにしています。（というか自分向けデバッグ用）

```cosole
$ 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()`を関数とみなしてしまうでしょう。

```sh
foo() {
  cat<<HERE
    bar() {
      echo 
    }
HERE
}
```

どの部分がヒアドキュメント（継続行）か判断する必要がありますが、シェルスクリプトはコマンド置換を使って文字列の中にコードを埋め込むことができます。例えば次のようなコードです。

```sh
echo "$(
  foo() {
    echo "ok"
  }
  foo
)"
```

他の言語であればダブルクォートから次のダブルクォート（エスケープ文字除く）までが文字列とみなされますがシェルスクリプトではそうは行きません。これに関しては次のように対処しました。

まず前項の通りコードを関数として登録し `typeset -fp` で整形済みのコードを出力します。このとき関数になってないコードもあるので、周りをダミーの関数でくくります。そうするとこのように整形されます。

```sh
dummy()
{
    echo "$(
  foo() {
    echo "ok"
  }
foo
)"
}
```

関数でくくって整形したので**コード**はインデントされます。ただし文字列はインデントされません。「ダミーの関数でくくる」を複数回くリ返します。そうするとこのようになります。

```sh
dummy ()
{
    function dummy ()
    {
        function dummy ()
        {
            function dummy ()
            {
                function dummy ()
                {
                    echo "$(
  foo() {
    echo "ok"
  }
foo
)"
                }
            }
        }
    }
}
```

`echo` のようなコードであれば、当然インデントされますが、改行が入った文字列やヒアドキュメントはインデントされません。（インデントしてしまうと意味が変わります。）これを利用して想定されるインデント位置より前にあるかどうかで継続行か否かを判断しています。（いくつインデントするかはコードをみて計算しています）この方法により複雑なパース処理を行うこと無く問題を解決しています。

# テスト

テストはもちろん ShellSpec を使って行っています。まさにこのためのツールですから。我ながら[テスト](https://github.com/shellspec/shellmetrics/blob/master/spec/shellmetrics_spec.sh)が書きやすいです（笑）対応シェル（bash、zsh、mksh、yash）の全てでテストを行って TravisCI、Coveralls と連携させています。シェルスクリプトで実装されてるツールでここまでしてるのはなかなかないと思います。
[![Travis CI](https://img.shields.io/travis/com/shellspec/shellmetrics/master.svg?label=TravisCI&style=for-the-badge)](https://travis-ci.com/shellspec/shellmetrics) [![Coveralls](https://img.shields.io/coveralls/github/shellspec/shellmetrics.svg?label=Coveralls&style=for-the-badge)](https://coveralls.io/github/shellspec/shellmetrics?branch=master)

# さいごに

ということでシェルスクリプトでそれなりの規模のツールを作りやすくするという私の計画がさらに一歩進みました。

* [ShellSpec](https://github.com/shellspec/shellspec) BDDテスティングフレームワーク
* [ShellMetrics](https://github.com/shellspec/shellmetrics) 循環的複雑度測定ツール
* [ShellCheck](https://github.com/koalaman/shellcheck) 有名なlintツール（※もちろん私が作ったのではない）

名前もいい感じに揃えています（笑）