PlantUML/Mermaid 形式の状態遷移図から Markdown 形式の状態遷移表・N-スイッチカバレッジ表を作る Visual Studio Code 用拡張機能を作ってみました！

# はじめに

PlantUML/Mermaid 形式の状態遷移図から Markdown 形式の状態遷移表・N-スイッチカバレッジ表を作る Visual Studio Code 用拡張機能を作りました。
そこで

- 拡張機能の紹介
- 狙い
- 拡張機能作りで工夫した点

について記事をかいてみました。

# 拡張機能の紹介

## できること

PlantUML/Mermaid 形式で書いた状態遷移図から、Markdown 形式の状態遷移表と N-スイッチカバレッジ表を作成してクリップボードにコピーする拡張機能です。

具体的には次のような PlantUML/Mermaid 形式での状態遷移図のテキストから（例は PlantUMLのもの）

```
[*] -right-> 開始 : 開く
開始 -right-> 終了 : 了解
終了 -left-> 開始 : 戻る
終了 -right-> [*] : 閉じる
```

次のような Markdown の表形式での状態遷移表と N-スイッチカバレッジ表を作成することができます。

```markdown
## Transition Table

| |開く|了解|戻る|閉じる|
| :----: | :----: | :----: | :----: | :----: |
|[*]|開始|N/A|N/A|N/A|
|開始|N/A|終了|N/A|N/A|
|終了|N/A|N/A|開始|[*]|

## 0 Switch Coverage

|state|event|state|
| :----: | :----: | :----: |
|[*]|開く|開始|
|開始|了解|終了|
|終了|戻る|開始|
|終了|閉じる|[*]|

## 1 Switch Coverage

|state|event|state|event|state|
| :----: | :----: | :----: | :----: | :----: |
|[*]|開く|開始|了解|終了|
|開始|了解|終了|戻る|開始|
|開始|了解|終了|閉じる|[*]|
|終了|戻る|開始|了解|終了|
```

仕上がりでいうと

```plantuml
@startuml
[*] -right-> 開始 : 開く
開始 -right-> 終了 : 了解
終了 -left-> 開始 : 戻る
終了 -right-> [*] : 閉じる
@enduml
```

という状態遷移図から、次のような表を作ることができます。

## Transition Table

| |開く|了解|戻る|閉じる|
| :----: | :----: | :----: | :----: | :----: |
|[*]|開始|N/A|N/A|N/A|
|開始|N/A|終了|N/A|N/A|
|終了|N/A|N/A|開始|[*]|

## 0 Switch Coverage

|state|event|state|
| :----: | :----: | :----: |
|[*]|開く|開始|
|開始|了解|終了|
|終了|戻る|開始|
|終了|閉じる|[*]|

## 1 Switch Coverage

|state|event|state|event|state|
| :----: | :----: | :----: | :----: | :----: |
|[*]|開く|開始|了解|終了|
|開始|了解|終了|戻る|開始|
|開始|了解|終了|閉じる|[*]|
|終了|戻る|開始|了解|終了|

表での並び順は遷移図での並び順をなるべく意識しています。
（ただし先端・終端の [*] に関わるものについては、なるべく最初・最後に並べるようにしています）

## インストール

https://marketplace.visualstudio.com/items?itemName=prop-tech-plus.state-diagram-2-markdown-tables

からインストールできます。

## 使い方

使い方は次の通りです。

1. 変換したい PlantUML/Mermaid の状態遷移図のテキストを選択します
1. コマンドパレットから `State Diagram 2 Markdown Tables And Copy` コマンドを実行します
1. 変換されたテキストがクリップボードにコピーされますので、使いたいところで貼り付けてください

PlantUML/Mermaid の状態遷移は

```
状態１ --> 状態２ : イベント
```

という形式のものを変換対象とします。

```
状態１ --> 状態２
```

というイベントの記述がないものは変換対象となりません。  
（逆に [*] を使った開始・終了を表から除きたい場合などに利用できます）

また

```
state "別名" as 状態名
```

で状態の別名を指定している場合には、変換後の名称でも別名の方が使われます。

また、N-スイッチカバレッジを作る深さは設定で変えることができます（デフォルトは 1 まで）。

## 制限

上記以上の記法、例えば合成状態、履歴、フォーク、条件分岐といったものには対応していません。

## ソース

https://github.com/yoichi-ihara/StateDiagram2MarkdownTables 

にあります（MITライセンス）。

# 狙い

状態遷移図は設計の視覚化で役に立つ手段の一つです。
その一方、状態遷移図はテストにも役立ちます。

同じように設計やテストを作るうえで役立つ状態遷移表と N-スイッチカバレッジは、状態遷移図から機械的に生成できます。
その生成を手伝おうというのがこのツールになります。

PlantUML/Mermaid で状態遷移図をお絵かきをすれば、Markdown で状態遷移表と N-スイッチカバレッジが手に入り、設計やテストに役立てられるというわけです。

この辺りの詳しい解説は

https://www.jasst.jp/symposium/jasst19tohoku/pdf/S5-2.pdf

https://www.kzsuzuki.com/entry/2020/05/06/225904

などを参照してください。

:::note info
もちろん [astah*](https://astah.change-vision.com/ja/) といったツールは、そういうったことをもっとリッチにサポートしています！
残念ながら astah* community 版は提供終了となっているので、有償になりますが…
:::

# 拡張機能作りで工夫した点

## 開発環境について

### Dev Container を使って構築

Dev Container について詳しくは

https://qiita.com/yoshii0110/items/c480e98cfe981e36dd56

https://blog.kinto-technologies.com/posts/2022-12-10-VSCodeDevContainer/

を参照して欲しいのですが、簡単に言えば VSCode ＋ Docker で開発環境全体を仮想化してくれるツールです。

- 自分の実環境を汚さなくてすむ
- 他の人でも手軽に開発環境を構築できる

というメリットがあります。

https://github.com/yoichi-ihara/StateDiagram2MarkdownTables/tree/main/.devcontainer

にあるファイルがそのための定義になっていて、あとは VSCode + それ用の拡張がよしなにやってくれます。

Dockerfile では

```Dockerfile
RUN apt install -y default-jre && apt install -y graphviz

RUN sudo npm install -g @vscode/vsce
```

という形で

- PlantUML 利用のために Java や Graphviz のインストール
- 拡張開発用の vsce コマンドのインストール

といったことをしています。

あわせて開発で使う拡張機能の推奨設定を extension.json で

```javascript
{
	// See https://go.microsoft.com/fwlink/?LinkId=733558
	// for the documentation about the extensions.json format
	"recommendations": [
		"dbaeumer.vscode-eslint",
		"shd101wyy.markdown-preview-enhanced"
	]
}
```

という形で定義することで、Dev Container 環境での必要な拡張機能インストールをやりやすくしています。

:::note info
拡張のひな形をつくるための環境も node のイメージ＋ RUN npm install -g yo generator-code して Dev Container で構築していました！
:::

## ソースについて

### 全体概要

全体的な流れとしては次のような形になっています

1. テキストのパース＋ソート順の取得＋ソート
1. 状態遷移表の作成（２次元配列に展開して、それをテキストに起こす）
1. N-スイッチカバレッジ表の作成（再帰的に n レベルまで作成する）
1. 出来上がったものをクリップボードにコピー

### パース部分

ここは簡易的な正規表現によるパースをしています。
これにより

- 遷移の行は開始状態、終了状態、イベント
- alias 指定は alias名、元文字列

を抽出しています。

```javascript
  // state1 -> state2 形式の遷移定義のパース（色や形の指定は対応）
  let matches = umlText.split(/\r\n|\n/).map(line => line.match(/([^ ]+) +-+[^ ]*-*> +([^ ]+)( +: +| +:|: +)(.*[^ ])/)).filter(match => match);
  // state による alias 指定のパース
  let states = umlText.split(/\r\n|\n/).map(line => line.match(/state +"([^"]+)" +as +([^ ]+)/)).filter(match => match);
```

###  ソート順決定・ソート部分

ソート順は基本登場順。
そのために並べたい要素を key としてソート順を 0 から順に割り振っていきます。
後でそれを参照して要素を並べ替えます。

ただ、記法上、先頭・最後を表す '[*]' 関連だけは優先順を特殊に扱っています。
具体的には、先頭 or 末尾に寄せるために、他より必ず小さくなる or 大きくなる値をソート順として割り振っています。

<details><summary>ソート関連部分のコード</summary>

```javascript
  // ソート順を決める（状態とイベントそれぞれに）
  let orderMapState = {};
  let orderCountState = 0;
  let orderMapEvents = {};
  let orderCountEvents = 0;

  matches.forEach(match => {
    if (match[START_STATE_POS] === STATE_POINT_MARK) {
      // 開始の [*] の要素は先頭に寄せる
      orderMapState[match[END_STATE_POS]] = -1;
      orderMapEvents[match[EVENT_POS]] = -1;
      orderMapState[STATE_POINT_MARK] = -2;
    }
    else if (match[END_STATE_POS] === STATE_POINT_MARK) {
      // 終了の [*] の要素は末尾に寄せる
      orderMapState[match[START_STATE_POS]] = matches.length;
      orderMapEvents[match[EVENT_POS]] = matches.length;
    }
    // 後は最初に出てきた順番
    if (orderMapState[match[START_STATE_POS]] == null) {
      orderMapState[match[START_STATE_POS]] = orderCountState++;
    }
    if (orderMapState[match[END_STATE_POS]] == null) {
      orderMapState[match[END_STATE_POS]] = orderCountState++;
    }
    if (orderMapEvents[match[EVENT_POS]] == null) {
      orderMapEvents[match[EVENT_POS]] = orderCountEvents++;
    }
  });

  // パースした結果を↑に従ってソート
  matches.sort((a, b) => {
    let orderA, orderB;
    for (const pos of [START_STATE_POS, EVENT_POS, END_STATE_POS]) {
      if (pos === EVENT_POS) {
        orderA = orderMapEvents[a[pos]];
        orderB = orderMapEvents[b[pos]];
      }
      else {
        orderA = orderMapState[a[pos]];
        orderB = orderMapState[b[pos]];
      }
  
      if (orderA !== orderB) {
        // 違っているところが出たら実際の比較を行う（最後まで来たらそれで比較する）
        break;
      }
    }

    // 順番判定
    return orderA - orderB;
  });


  // 開始のステートを全部ピックアップ（ソート済みなのでその順番に従う）
  const startStates = Array.from(new Set(matches.map(match => match[START_STATE_POS])));

  // イベントを全部ピックアップ＋ソート
  const events = Array.from(new Set(matches.map(match => match[EVENT_POS]))).sort((a, b) => {
    return orderMapEvents[a] - orderMapEvents[b];
  });
```
</details>

### 状態遷移表を作る部分

もともとの遷移図が

```plantuml
[*] -right-> 状態1 : 開始
状態1 -right-> 状態2 : 進む
状態2 -right-> 状態1 : 戻る
状態2 -right-> [*] : 終了
```
```plaintext
[*] -right-> 状態1 : 開始
状態1 -right-> 状態2 : 進む
状態2 -right-> 状態1 : 戻る
状態2 -right-> [*] : 終了
```

に対する状態遷移表を作るには、最初に行分の配列をつくり

| index=用途 | |
|:-:|:-:|
|0=[*] | |
|1=状態1 |   |
|2=状態2 |   |

次に各行に対して、遷移前状態＋発生するイベント（空＝'N/A' で埋めた）用の列データを生成、

| index=用途 | 0=遷移前状態 |0=開始 | 1=進む | 2=戻る | 3=終了 |
|:-:|:-:|:-:|:-:|:-:|:-:|
|0=[*] | N/A | N/A | N/A | N/A | N/A |
|1=状態1 | N/A | N/A | N/A | N/A | N/A |
|2=状態2 | N/A | N/A | N/A | N/A | N/A |

それに対してイベント発生に対する遷移後状態を埋めて、

| index=用途 | 0=遷移前状態 |0=開始 | 1=進む | 2=戻る | 3=終了 |
|:-:|:-:|:-:|:-:|:-:|:-:|
|0=[*] | [*] | 状態1 | N/A | N/A | N/A |
|1=状態1 | 状態1 | N/A | 状態2 | N/A | N/A |
|2=状態2 | 状態2 | N/A | N/A | 状態1 | [*] |

それを元ににヘッダ文字列を加味して

| |開始|進む|戻る|終了|
| :----: | :----: | :----: | :----: | :----: |
|[*]|状態1|N/A|N/A|N/A|
|状態1|N/A|状態2|N/A|N/A|
|状態2|N/A|N/A|状態1|[*]|

という Markdown のテーブルを生成しています。

<details><summary>状態遷移表を作る部分のコード</summary>

```javascript
  // N/A で埋まった表を作って、適切な位置に状態を適用
  let statesTable = new Array(startStates.length);

  for (let i = 0; i < statesTable.length; i++) {
    // 縦方向に状態、横方向にイベント分の N/A を埋める
    statesTable[i] = new Array(events.length + 1).fill("N/A");
    // 横位置の先頭位置は状態のラベル
    statesTable[i][0] = startStates[i];
  }
  matches.forEach(match => {
    // 縦方向で状態、横方向でイベントに対応する位置に遷移状態を埋める
    const posY = startStates.indexOf(match[START_STATE_POS]);
    const posX = events.indexOf(match[EVENT_POS]) + 1;
    statesTable[posY][posX] = match[END_STATE_POS];
  });
  console.log(statesTable);

  // 表テキストの作成
  let statesTableText = `| |${events.join('|')}|\n`;
  statesTableText += `|${' :----: |'.repeat(events.length + 1)}\n`;
  statesTableText += `${statesTable.map(line => `|${line.join('|')}|`).join('\n')}`;
```
</details>

### N スイッチカバレッジ表を作る部分

PlantUML/Mermaid の状態遷移図を表すテキストは、それ自体が 0 スイッチカバレッジとなっているので、それをそのまま２次元配列に起こします。

|state|event|state|
| :----: | :----: | :----: |
|[*]|開始|状態1|  
|状態1|進む|状態2|
|状態2|戻る|状態1|
|状態2|終了|[*]|

後は最後の状態に累積的に 0 スイッチカバレッジの状態をかけていけばいいので、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/397251/df94275b-2192-1501-19a3-e3085e769561.png)

それを必要な N 階層分再帰呼び出しで行い、例えば 1階層分であれば

|state|event|state|event|state|
| :----: | :----: | :----: | :----: | :----: |
|[*]|開始|状態1|進む|状態2|
|状態1|進む|状態2|戻る|状態1|
|状態1|進む|状態2|終了|[*]|
|状態2|戻る|状態1|進む|状態2|

という表を生成しています。
再帰呼び出しの仕組み上、N スイッチの N は任意の深さで作れるようになっています。

<details><summary>N スイッチカバレッジ表を作るコード</summary>

```javascript
// N-スイッチカバレッジ表テキストの作成
// 再帰呼び出しで N スイッチを作れるようになっている
function makeNSwitchCoverage(diaglamInfo, depth, coverageTable) {
  const { matches } = diaglamInfo;

  let table = [];
  if (!coverageTable) {
    // １階層目は 0 スイッチ分を素直に作る
    matches.forEach(match => {
      table.push([match[START_STATE_POS], match[EVENT_POS], match[END_STATE_POS]]);
    });
  }
  else {
    // 2階層目は +1 スイッチ分作る（0スイッチ分を掛け算する）
    coverageTable.forEach(line => {
      const last = line[line.length - 1];
      if (last === STATE_POINT_MARK) {
        return;
      }
      matches.filter(match => match[START_STATE_POS] === last).forEach(match => {
        table.push([...line, match[EVENT_POS], match[END_STATE_POS]]);
      });
    });
  }

  console.log(table);

  if (depth <= 0) {
    // 最下層まで来たら戻す
    return table;
  }

  // ＋１分を再帰呼び出しで作る
  return makeNSwitchCoverage(diaglamInfo, depth - 1, table);
}

//（途中のコードは省略）

  // 作ったテーブルデータを元に Markdown の表テキストを生成して返す
  const length = coverageTable[0].length;
  let tableText = `|state${'|event|state'.repeat((length - 1) / 2)}|\n`;
  tableText += `|${' :----: |'.repeat(length)}\n`;
```
</details>

### N スイッチカバレッジの深さの設定部分

N スイッチカバレッジの深さは VSCode の標準の設定画面から変えられるようになっています。

[package.json](https://github.com/yoichi-ihara/StateDiagram2MarkdownTables/blob/main/package.json) に [contributes.configuration](https://code.visualstudio.com/api/references/contribution-points#contributes.configuration) の設定を定義すれば、 VSCode が対応する設定画面を用意してくれます。

```json
  "contributes": {
    "configuration": [
      {
        "title": "State Diagram 2 MarkdownTables configuration",
        "properties": {
          "stateDiagram2MarkdownTables.nSwitchCoveragesDepth": {
            "type": "number",
            "default": 1,
            "markdownDescription": "Specifies the depth of N-switch Converages Tables(the number of depth is the number of tables)"
          }
        }
      }
    ]
```

後は拡張機能側で

```javascript
  const config = vscode.workspace.getConfiguration('stateDiagram2MarkdownTables');
  const maxDepth = config.get('nSwitchCoveragesDepth', 1);
```

というように対応する key 値を指定することで設定値の取得ができます。

一つ注意する点としては vscode.workspace.getConfiguration() は config.get() する直前に毎回取得すること。
そうしないと最新の値が取得できないようです。
（config.get() の際に現在値を取りに行くのではなく、getConfiguration() の際に値をキャッシュしてしまう様子）

## テストについて

### VSCode を実際に使ったテストコード

テストの呼び出し部分は自動生成されるひな形をそのまま利用して、[extension.test.js](https://github.com/yoichi-ihara/StateDiagram2MarkdownTables/blob/main/test/suite/extension.test.js) にテスト呼び出しを追加しています。
ひな形でできるテストを実行した際にはテスト用の VSCode のインスタンスも生成されるので、実際に VSCode 上での動作テストを次のように行っています。

テストとしてはテスト用の作成元テキストが入ったファイル、想定結果が格納されたファイル（一部、空の場合は対応がないですが）を用意していて

1. 作成元テキストファイルを VSCode で新しく開く
1. ↑ のテキストを選択する（テストによっては選択しない、というのもテストしています）
1. ↑ の状態で拡張機能を実行する
1. ↑ の結果、クリップボードに変換結果がコピーされているハズなので、それを取得
1. ↑の結果と期待値を比較してテスト結果とする

ということをしています。
拡張機能での変換結果はテストが fail した際に確認しやすいように、ファイルへの保存もしています。

<details><summary>テストの実行部分のコード</summary>

```javascript
async function testCopyResultIsEqualToFile(testFile, resultFile, isSelectionAll, detpth=1){

  // N スイッチカバレッジの深さの設定
  const config = vscode.workspace.getConfiguration('stateDiagram2MarkdownTables');
  await config.update('nSwitchCoveragesDepth', detpth, true);

  // テスト用ディレクトリの定義
  const testDataDir = path.resolve(__dirname, './data/');
  const resultDataDir = path.resolve(__dirname, './result/');

  // 想定結果データの取得
  const resultData = resultFile == null ? '' : fs.readFileSync(testDataDir + '/' + resultFile, 'utf-8');

  // テスト用データを VSCode で開く
  const document = await vscode.workspace.openTextDocument(testDataDir + '/' + testFile);
  const editor = await vscode.window.showTextDocument(document);

  if (isSelectionAll) {
    // 全体選択
    editor.selection = new vscode.Selection(new vscode.Position(0,0), document.lineAt(document.lineCount-1).range.end);
  }
  else {
    // 選択しない
    editor.selection = new vscode.Selection(new vscode.Position(0,0), new vscode.Position(0,0));
  }

  // テスト結果判定用にクリップボードの初期化
  await vscode.env.clipboard.writeText('');

  // 拡張呼び出し
  await myExtension.main();

  // 結果＝クリップボードの読みだし
  const copyResult = await vscode.env.clipboard.readText();

  // テスト用に開いたドキュメント＝タブを閉じる
  await vscode.window.tabGroups.close(vscode.window.tabGroups.activeTabGroup.activeTab);

  // console.log(`result=[${resultData}], copy=[${copyResult}]`);

  // テスト結果の書き込み
  if (!fs.existsSync(resultDataDir)) {
    fs.mkdirSync(resultDataDir);
  }
  fs.writeFileSync(`${resultDataDir}/${testFile}`, copyResult,);

  // Nスイッチカバレッジの深さを初期値に戻す
  await config.update('nSwitchCoveragesDepth', 1, true);

  // テスト結果を判定して返す
  return resultData === copyResult;
}
```
</details>

# 参考にした情報

## 拡張機能作成で参考にした情報

なんといっても公式

https://code.visualstudio.com/api

ただ、やっぱりとっつきにくいので

https://qiita.com/HelloRusk/items/073b58c1605de224e67e

https://zenn.dev/tomi/articles/2021-03-26-vscode-extension

といったところで全体像を把握すると分かりやすいです。

具体的にどんなことができるかは

https://qiita.com/RAWSEQ/items/7c53596754d2a102499f

といったところが参考になりました。

あとは `yo code` して生成されるひな形をまずは作ってみるといいです。
そこで生成されるドキュメントやソースのコメントが色々と参考になります。

## 拡張機能のテスト作りで参考にした情報

テスト作成の全体像把握は

https://lowpolysnow.com/wp/20200202/396/

https://qiita.com/OrukRed/items/4dcfa55013c05bb027c4

といったところが参考になりました。

基本 test/suite/extension.test.ts に自分がやりたいテストを追加していけばいいです。
ただ、概要としては[公式の解説](https://code.visualstudio.com/api/working-with-extensions/testing-extension)で分かりますが、具体的にテストを作る段になるとなかなかまとまって参考になるところがなく、実際の [VSCode API](https://code.visualstudio.com/api/references/vscode-api) から探したり、それらしいワードで検索したり（やはり https://stackoverflow.com/ が参考になることが多かったです）、他の拡張のソースなどを見たりしていました。

## 拡張機能を Marketplace で公開する際に参考にした情報

https://qiita.com/tkts_knr/items/92a15a9fe7475418b333

が分かりやすかったです。
特に CLI とファイルアップロード両方でのやり方が書いてあるのが良かったです。