nikkie-ftnextの日記

イベントレポートや読書メモを発信

VS Code拡張ハンズオン基礎編のテキストの中の、正規表現を使ってCodeLensを設定する実装を理解する

はじめに

「そうなんだ〜1、nikkieです。

以前、Markdownの「歩夢」を「歩夢🎀」に置き換えるという、どうかしているVS Code拡張を自作しました。

この実装では、VSCode Conference Japan 2021 ハンズオンの基礎編のテキストを大変参考にしています。
このたび、基礎編のテキストの実装の理解が一歩進んだのでアウトプットします。

目次

CodeLensを設定する箇所を正規表現で探す(「ドキュメントを編集しよう」より)

本節では、ドキュメント上に表示されるコードレンズのボタンを押して、ドキュメントを編集する拡張機能を作ります。

ドキュメント上にCodeLens(コードレンズ)のボタンを表示するために正規表現を使っています。
該当するコードは以下です:

この記事では、「正規表現で、見出しを探す」コメント以降のコードを深堀っていきます。

document.getText()でファイルの全行を取得する

以下ではファイルの内容を全部取得します。

const text = document.getText();

Get the text of this document.

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

ファイルの全行が取得されていて、そこには改行文字も含みます。
textが指す文字列は、以下のテンプレートリテラル2のようなイメージです。

const text = `# s

ほげほげ

## t

ふがふが`;

疑問:CodeLensは何個表示されるんだろう?

Python大好きnikkieは、JavaScript(TypeScript)のコードがパッとは読めません。
ハンズオンのコードは正規表現にマッチする数と同じ数のCodeLensができると理解しています。
ただ、上で出したMarkdownのように、見出しが2行ある(###がある)とき、「正規表現にN個マッチして、CodeLensはN個表示される」のNはいくつになるんでしょう?

正規表現は、正規表現リテラル3/^#+\s/gで表されています。
この正規表現リテラルからRegExpインスタンスが初期化され、execメソッドを使ったwhile文があります。

考え込むより手を動かしてみようと、ブラウザの開発者モードを使いました。
以下の関数を定義し、いくつかのtextを渡してみます。

function matchTitleRegex(text) {
  const titleRegex = /^#+\s/g;
  const regex = new RegExp(titleRegex);
  let matches;
  while ((matches = regex.exec(text)) !== null) {
    console.log(`Found ${matches[0]}. Index of matches: ${matches.index}. Next starts at ${regex.lastIndex}`)
  }
  console.log("End");
}

回答:文字列先頭が1つ以上の#で始まればCodeLensは1個、始まっていなければ0個

「こうじゃないかな」という仮説を持って読んでいた方は、一致しましたか?
動きを確認し、理由を考えていきましょう。

文字列先頭に#の並びで始まる行がある場合

先ほどのtextを渡してみます。

const text = `# s

ほげほげ

## t

ふがふが`;

console.logによる出力は以下です。

matchTitleRegex(text);

Found # . Index of matches: 0. Next starts at 2
End
  • 1箇所マッチした
  • 0-1文字目がマッチした(文字列先頭
  • 2文字目以降はマッチしない

見出しのレベルを入れ替えても、1箇所マッチは変わりません

const text2 = `## t

ほげほげ

# s

ふがふが`;
matchTitleRegex(text2);

Found ## . Index of matches: 0. Next starts at 3
End

#で始まるタイトルがいくつあったとしても、文字列先頭のタイトルだけにマッチするように実装しているのですね。

文字列先頭に#の並びで始まる行がない場合

const text3 = `ほげほげ

# s

ふがふが`;
matchTitleRegex(text3);

End

マッチしませんでした!
正規表現にマッチしないので、text3の内容のMarkdownファイルにはCodeLensは1つも表示されません。

読み解き/^#+\s/g

JavaScript正規表現のドキュメントを確認しながら読み解きます。

^

^アサーションの1つです。
アサーション - JavaScript | MDN

^ 入力の先頭に一致します。(略) 例えば /^A/ は "an A" の 'A' には一致しませんが、"An E" の 'A' には一致します。

#+

+数量詞の1つ。
数量詞 - JavaScript | MDN

x+ 直前のアイテム "x" の 1 回以上の繰り返しに一致します。

#+#の1回以上の繰り返しですね。

小まとめ:^#+

ここまでをまとめると、^#+は「文字列の先頭にある#の1回以上の繰り返し」です。

\s

\s文字クラスの1つ。

\s ホワイトスペース 1 文字に一致します。例えば空白、タブ、改ページ、改行、その他の Unicode 空白文字などです。

小まとめ:^#+\s

「文字列の先頭にある#の1回以上の繰り返しとそれに続く空白文字1つ」

g

末尾のgはフラグです。

フラグを用いた高度な検索」より

正規表現には、グローバル検索や大文字小文字を区別しない検索などの機能を実現する 6 種類のオプションフラグがあります。

g グローバル検索を行います。

つまり/^#+\s/gとは

「文字列の先頭にある#の1回以上の繰り返しとそれに続く空白文字1つ」のグローバル検索です。
グローバル検索してもアサーション^によって文字列の先頭としかマッチしないので、上で見たようにマッチは1回か0回です。

  • マッチ1回:文字列先頭でマッチした
  • マッチ0回:先頭でマッチしなかった

mフラグを追加すると、#で始まる各行にマッチ!

gフラグの他にmフラグがあります。(フラグを用いた高度な検索

m 複数行の検索です。

アサーション^について、引用で(略)とした部分、以下のように書かれています!

複数行フラグが true にセットされている場合は、改行文字の直後にも一致します。

つまり、各行の先頭とマッチするかを見ていくことになります。
動きが変わることを確認しましょう。

mフラグ追加の動作確認

function matchTitleRegexV2(text) {
  const titleRegex = /^#+\s/gm;  // g -> gm に変えました
  const regex = new RegExp(titleRegex);
  let matches;
  while ((matches = regex.exec(text)) !== null) {
    console.log(`Found ${matches[0]}. Index of matches: ${matches.index}. Next starts at ${regex.lastIndex}`)
  }
  console.log("End");
}
const text4 = `ほげほげ

# s

ふがふが

## t`;
matchTitleRegexV2(text4);

Found # . Index of matches: 6. Next starts at 8
Found ## . Index of matches: 17. Next starts at 20
End

インデックス6が3行目の#、インデックス17が7行目の##(の左側)です。
各行にマッチしていますね!(CodeLensをタイトルの各行に表示できます🙌)

終わりに

ファイルの中で正規表現にマッチする箇所全てにCodeLensを表示する拡張の実装を読み解きました。
実装自体は文字列先頭とのマッチで実装しているので、グローバル検索の結果、マッチは1または0回です。
正規表現を読み解いていき、mフラグと^アサーションで各行の先頭とマッチさせられることまで見ました。

どうかしている拡張実装時はそこまで精読せずに雰囲気で使いましたが、1つ1つ読み解いていると気付きがありますね!

今回お世話になったのはMDNのドキュメントですが、ドキュメントに当たる前にざっくり理解するため、以下の書籍にも当たりました。


  1. 前回のVS Code拡張ネタエントリから引き続き…
  2. 変数の値も埋め込めます
  3. スラッシュで囲まれたパターンからなる正規表現リテラルを使用します。」 ref: 正規表現 - JavaScript | MDN