Markdownのテーブルの列に色付けするVSCode拡張機能を作ったよ

# はじめに



Markdown を書いているときに、テーブルの各カラムにそれぞれの色を付けたらわかりやすくなるのではないかと思って、テーブルの列に色を付ける VSCode 拡張機能を作りました。
いわゆるレインボー系の拡張機能ですね（indent-rainbow とか Rainbow CSV みたいな）。そんな呼び名があるかどうかは知りませんが。

ちなみに、しばらく前の [Qiita の 記事](https://qiita.com/tawara_/items/5d59cf725e7c7aa72f0f) で、マークダウンのテーブルを整形してくれるクールな拡張機能が紹介されていたあたりがきっかけで思いついたものです。
私の拡張機能はあまりクールではなく、かなりニッチな感じですが。

![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97350/1b7b4ca2-03ec-1be2-3c4d-64bb2b62093a.png)

（上の画像は古いバージョンのもので、現在は色の順番がちょっと変わっています。誰も気にしないと思いますが。インデントしたテーブルを書くと、有名な拡張機能の indent-rainbow の最初の色と似ているせいで溶け合っちゃうので、色の順番を変えたのです）

軽く調べてみた限りでは、似たような拡張機能が見つからなかったので、思い切って作ってみることにしました。
多分、しっかり探したら似たようなものはあったんじゃないかと思うのですが、せっかく作る気になっていたので、軽く調べて見つからなかった後は、気にせず作成に入りました。
そのまま勢い余って、マーケットプレイスで公開もしています。

【マーケットプレイス】

https://marketplace.visualstudio.com/items?itemName=yoshi389111.markdown-table-rainbow

【リポジトリ】

https://github.com/yoshi389111/vscode-md-table-rainbow

# 使い方

マーケットプレイスでダウンロードするか、VSCode で `markdown-table-rainbow` で検索してインストールしてください。基本はそれで使えると思います。

ちなみに、対象のファイルタイプは `markdown` だけにしています。
他にも使いたいファイルタイプはあるんだろうか？
（前述の[クールなテーブル整形の拡張機能](https://github.com/takumisoft68/vscode-markdown-table)だと、他のファイルもいくつか対象にしているみたい。 `mdx` とか（JSXのMarkdown版みたいな感じのやつ？）。今再度見てみたら `quarto` というのにも対応しているみたい（pandoc を使ってきれいな本を作るやつ？））

設定項目は 2 つあります。設定画面を開いて `拡張機能` → `Markdown Table Rainbow` で設定項目が表示されると思います。

## 更新のディレイタイム (markdownTableRainbow.updateDelay)

* `markdownTableRainbow.updateDelay` : 更新する前のディレイタイムをミリ秒単位で指定します。未指定時は 100ms として動作します。

ディレイタイムの概念は、前述のサンプルコードにもあったものです。

文字の装飾を変えるという動作は、VSCode で見ているアクティブエディタが切り替わったタイミング（`vscode.window.onDidChangeActiveTextEditor()` でリッスンできる）や、編集中のファイルの中身が書き換わったタイミング（`vscode.workspace.onDidChangeTextDocument()` でリッスンできる）で、編集中のドキュメントの内容を全部見て、色付けが必要な個所を洗い出して設定する、という流れになります。

もし、1 文字入力するたびに毎回この処理をしてしまうと、処理コストが増えて UI のパフォーマンスが落ちることになります。

そのため、ファイルの中身が書き換わった場合には、ディレイタイム後に処理が実行されるように JavaScritp の `setTimeout()` で処理を登録しておくだけにします。

https://developer.mozilla.org/ja/docs/Web/API/setTimeout

もし、ディレイ中に別の編集が発生した場合には、先ほどの登録済の処理を `clearTimeout()` でキャンセルして、再度ディレイタイム後に処理するように `setTimeout()` で処理を登録しなおします。

https://developer.mozilla.org/ja/docs/Web/API/clearTimeout

つまり、文字を連続して入力している最中は、文字装飾の書き換えを行わずに、入力が終わった後にまとめて 1 回だけ装飾を書き換える、ということをやっています（公式サンプルのまま）。

このディレイタイムは、入力してから対象の色が変わるまでのタイムラグになるので、小さい方がすぐに色が変わってわかりやすいですが、小さすぎると処理コストが増える、大きいとその逆というトレードオフになっています。

サンプルコードでは 500ms になっていますが、ちょっとのんびり目な設定値ではないかと思っています。
多くの実際の拡張機能では、大体デフォルトは 100ms ぐらいなイメージでした。10ms だとちょっと小さすぎる感じ？

このあたりは、好みや環境（マシンスペック等）に合わせて変更できるようにしています。

## 列の背景色のリスト (markdownTableRainbow.colors)

* `markdownTableRainbow.colors` : テーブルの各列の背景色のリスト（配列）を指定します。指定しない場合にはデフォルトの色を使います。テーブルの列数が配列長よりも大きい場合には、指定されたリストを循環して使用します。

通常、VSCode の設定は、設定画面で入力が可能になっていると思いますが、この項目は配列になっているため、直接 `settings.json` を書き換える必要があります（よくわかっていないけど、専用の設定機能を作り込めば GUI っぽく入れられるのかな？）。

設定画面では `settings.json で編集` みたいなリンクがあると思います。
そこをクリックすると、デフォルト値が入った状態で `settings.json` が開くので、お好みの色に変えてください。

配列の各項目は `#RRGGBB` 形式か `rgb(R, G, B)` 形式か、`rgba(R, G, B, A)` 形式でしか指定できないので注意してください。

（ `#RGB` とか `hsl()` とかで入れたい人もいるかな？）

# 技術的な話（というか、取り留めのないメモ）

VSCode の拡張機能の開発を作る話などは、Qiita 上にも他の箇所にも多数あるので、割愛します。

最初にこの拡張機能を思いついた際には、Markdown の構造を解析するようなライブラリを使う必要があるかと思っていたのですが、他の Markdown のテーブルを操作するような拡張機能のソースをいくつか確認したところ、そこまでやっている物はあまりなかったみたいなので、単に正規表現でテーブルっぽい表現、つまり行頭に `|` があり、同じ行に複数の `|` あるような行を対象に処理することにしました。

ちなみに、引用ブロックの中にテーブルがあるケースも考慮して、行頭が `>` の行も対象にしています。

なので、例えばコードブロック内に、Markdown のテーブルっぽい記載があったら、そこもテーブルだと誤判断してしまいます。が、仕様です。

さて今回は、文字の背景を変えればよいだけのシンプルな拡張機能なので、まずは必要そうな API とサンプルコードを参考にしました。

【オフィシャルな API ドキュメント】

https://code.visualstudio.com/api/references/vscode-api

【サンプルコード(decorator-sample)】

https://github.com/microsoft/vscode-extension-samples/tree/main/decorator-sample

【その時の学習結果をアウトプットした物】

https://qiita.com/yoshi389111/items/8dca20680eb79f01cb2c

上記のサンプルコードをベースに色を付けるところと、色の付け方を変えたらほぼ最初のバージョンは出来上がりでした。

## 色を付ける範囲

今回、色を付けるカラムの範囲を、前の `|` を含み、後ろの `|` を含まないようにしました。

当初は両方とも含めない方が良いかとも思ったのですが、マークダウンの種類によっては colspan 的なことが `| ここは２カラム結合 ||` という風に書けるらしいという記事を見かけました。
そのため、カラムの色が全く表示されないとわかりにくいかと思い、片方の `|` を入れるようにしました。

前後のどちらを入れてもよかったのですが、とりあえず前の `|` を含むようにしました。

後から思えば、indent-rainbow とかの色と干渉しないように、前の `|` を入れずに、後ろの `|` を入れた方が良かったのかな？ 1 文字くらいだと見分けにくいから一緒だったかな。

## リソースの dispose について

前述の公式サンプルコードでは、装飾をするための定義である `vscode.TextEditorDecorationType` のインスタンスを、作っているだけで、特に後処理的なものはやっていません。

でも、`vscode.TextEditorDecorationType` のソースを見ると、`dispose()` メソッドが存在します。
別に呼び出さなくてもよいのかもしれないですが、なんとなく気になるので、最後に呼び出すようにしました。

といっても、`activate()` が呼び出されるときに渡される `vscode.ExtensionContext` のインスタンスに登録すれば、あとは不要になったら処理してくれるみたいなのですが。

```typescript
  const decorationTypes: vscode.TextEditorDecorationType[] = [ /* 略 */ ];
  // ...
  context.subscriptions.push(...decorationTypes);
```

## 装飾範囲の自動拡張

上記の、お勉強結果のアウトプットにも書いたのですが、デフォルトでは装飾範囲の直前、直後に文字を追加すると、追加した文字も装飾範囲に入ってしまう、という仕様です。

具体的には、`vscode.TextEditorDecorationType` に指定する `rangeBehavior` が未指定（デフォルト）では `OpenOpen` となっているせいです。 

今回はカラムの前の `|` を装飾範囲に含めていますが、その前に文字を追加されたときに追加された文字に後ろのカラムの色がついてしまうのはおかしいので、直前は `Closed` にしました。

逆に、後ろの `|` は装飾範囲に含めていないので、装飾範囲の直後に（つまり `|` の前に）入力するのはカラムの色になってもらった方が良い場合が多いので、直後は `Open` のままにしました（例外は新しく `|` を入力する場合だけど、その場合にはディレイ後の書き換えで問題ないと判断）。

つまり `ClosedOpen` を指定するようにしました。

## コンフィグの取得タイミング

最初は、公式サンプルのように、`activate()` が呼びされたタイミングでコンフィグを見て、`vscode.TextEditorDecorationType` の定義を生成していました。

しかし、これだとユーザーが `settings.json` を書き換えても、再度 `activate()` が呼び出されなければ古い定義で動作し続けます。そして普通は呼び出されないので、再起動しないと反映されないことになります。

そこで、`vscode.workspace.onDidChangeConfiguration()` でコンフィグの変更を検出して、再度 `vscode.TextEditorDecorationType` のオブジェクトを作成しなおすことをやるようにしました。

ちなみに、前述の通り、`vscode.TextEditorDecorationType` は処理終了時に `dispose()` するために、`context.subscriptions` に登録しています。
それほど問題ないのかもしれないですが、不要なリソースをずっと抱えておくのは気になるのと、何度もコンフィグを変更すると何個も登録されてしまって気になるので、作り直すタイミングで、以前のオブジェクトを `context.subscriptions` から削除するような処理を入れました。

なんとなくお行儀が悪い気がしているのだけど、`context.subscriptions` の配列を再代入するのはさらにお行儀が悪そうな気がしていて……。どうするのが正しいんだろう？

## コンフィグの配列値のチェック

設定画面から定義する項目の場合には、例えば `string` 型だとしても、正規表現でチェックができます（`package.json` のプロパティ定義の `pattern` に正規表現を指定すればよい）。

しかし今回、背景色は配列型で定義してあり、直接 `settings.json` に記載してもらう必要があるので、そのままでは形式チェックを掛けられません。

そこで前述の `vscode.workspace.onDidChangeConfiguration()` でコンフィグの変更を検出して、リソースを作り直す際に、形式チェックを掛けるようにしました。

その際、今は長めの正規表現を用意してそれとマッチするかというチェックをしています。
本当は、いくつかの短めの正規表現に分割したり、すべてを正規表現でやらずにロジックでチェックでやったほうが良い気がしているのですが（例えば、R/G/Bの値が 0～255 の範囲であることのチェックは正規表現でやるより、ロジックでやったほうが簡単）、なぜ長めの１つの正規表現でチェックしているのかというと、[他のVSCode拡張機能](https://qiita.com/yoshi389111/items/0ae999c73acb2887424b) でも色を指定してもらうものを作っており、そこでは `package.json` の定義（`pattern`）で、形式チェックをしていて、両方正規表現でチェックしておくと、その正規表現は共通に使えると思ったためです。
将来、他のパターンを増やす場合には、両方同じ値をコピーすればよいので。

そのため、JavaScript の正規表現なのですが `/RE/` のような形ではなく `new RegExp("RE")` のように定義しています（もう片方では `package.json` の文字列として定義しているので、同じ形にしたかった）。

## コンフィグの取得方法

ネットで見かけたコードでは、以下のようにコンフィグを取得していました。

```typescript
const config = vscode.workspace.getConfiguration('myExtension');
const updateDelay: number = config['updateDelay'] || 500;
```

これでも動くのですが、この場合 `config[]` が返す値は `any` 形になってしまうようです。

API の仕様書を見ると `get()` というメソッドがあり、これだとジェネリックで型が指定できるのと、デフォルト値もメソッドに指定ができるようでした。もちろん定義とコードとで整合性をとって記載する必要はあるわけですが。

```typescript
const config = vscode.workspace.getConfiguration('myExtension');
const updateDelay: number = config.get<number>('updateDelay', 500);
```

ちなみに、デフォルト値そのものは、`package.json` に定義しておけば、ここには書かなくてもよいのかもしれません。
今は、一応両方の同じ値を書いていますが（当初は、`package.json` の定義を書かずに動かしていた名残）。

## 対象のドキュメントの絞り込み

最初、勘違いをして、`package.json` の `activationEvents` に、`onLanguage:markdown` と登録しておけば、Markdown でのみイベントが通知されると思ったのですが、どうやら Markdown のファイルを開いた場合にアクティベートされるという意味で、それ以降のイベントはドキュメントの種類を問わず通知されるようです。

なので、対象の `editor` を処理する前に、以下のようにドキュメントが対象かをチェックする必要があります。

```typescript
  if (editor.document.languageId !== 'markdown') {
    return;
  }
```

これでもよいかと思ったのですが、将来 `markdown` 以外のファイルも処理対象にした場合、`package.json` とこのロジックの両方を修正しないといけないのは面倒だな、と思いました。

どうやら `context.extension.packageJSON` で自拡張機能の `package.json` の中身が取得できるようです。
（他の拡張機能も `vscode.extensions.getExtension()` で取得できるようですが。キーは `"<publisher名>.<extension名>"` みたいに指定するようです）

なので、以下のようにしてみました。現状はオーバースペックですが。

```typescript
    const onLanguages = (context.extension.packageJSON["activationEvents"] as string[])
        .filter(it => it.startsWith("onLanguage:"))
        .map(it => it.substring("onLanguage:".length));

    // ...

        if (!onLanguages.includes(editor.document.languageId)) {
            return;
        }
```

## VSCode の最低バージョン

`package.json` の `engines` に、動作する VSCode の最低バージョンを記載する箇所があります。

```js
    "engines": {
        "vscode": "^1.76.0"
    },
```

しかし、どうやってテストしたらよいかわからないので、その時点の最新のバージョンにしてしまいました。
そんなに新しい機能は使っていないと思うので、本当は結構古いバージョンでも動くと記載したかったのだけど、どうしたら最低バージョンを調べられるんだろう？

VSCode はすぐにバージョン上げてくれるから（？）、最新で問題ないのかな？