nikkie-ftnextの日記

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

VS Codeで「Transform to Title Case」を担当するTitleCaseActionの実装の中の正規表現を読み解く

はじめに

エミリーちゃん、小文字にしたいな〜。nikkieです。

4/20(土)のVS Code Conference Japan 2024登壇1準備からアウトプットです。

目次

Transform to Title Case

VS Codeで文字列を選択して、コマンドパレットから「Transform to Title Case」でタイトルケースに変えられます。
タイトルケースとは、Pythonのドキュメントがわかりやすいと思います。
https://docs.python.org/ja/3/library/stdtypes.html#str.title

文字列を、単語ごとに大文字から始まり、残りの文字のうち大小文字の区別があるものは全て小文字にする、タイトルケースにして返します。

例:emily stewart -> Emily Stewart

過去の記事でも取り上げました。

この実装を見ていきます。

TitleCaseActionの持つ正規表現

https://github.com/microsoft/vscode/blob/1.88.1/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts#L1135

export class TitleCaseAction extends AbstractCaseAction {

    public static titleBoundary = new BackwardsCompatibleRegExp('(^|[^\\p{L}\\p{N}\']|((^|\\P{L})\'))\\p{L}', 'gmu');

    // 省略
}

BackwardsCompatibleRegExpクラスはgetメソッド呼び出しで、RegExpまたはnullを返します。
https://github.com/microsoft/vscode/blob/1.88.1/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts#L1116
backwards compatibleのこころは、後方互換性がないときはnullが返るってことなのかな?

この記事で見ていく正規表現RegExp('(^|[^\\p{L}\\p{N}\']|((^|\\P{L})\'))\\p{L}', 'gmu')です2

Unicode文字クラスエスケープを使った正規表現

この正規表現、ウッとなりますね。
呪文詠唱です。

'\\p{L}''\\p{N}'は文字列中だからバックスラッシュがついていて、\p{L}\p{N}と同じです。
これはUnicode文字クラスエスケープというものでした。
Unicode文字の集合を指定しています。

https://unicode.org/reports/tr18/#General_Category_Property より

  • LはLetterの短縮
  • NはNumberの短縮

この正規表現どんな文字列と一致するかを理解するのが今回のゴールです。
ここでテストデータをガイドにします。

RegExp('(^|[^\\p{L}\\p{N}\']|((^|\\P{L})\'))\\p{L}', 'gmu')をにらむと、第1引数の正規表現にはキャプチャグループのカッコ()3があることが分かります。
'(キャプチャグループ)\\p{L}'
※後述しますが、replaceメソッドにこの正規表現と関数を渡します。関数シグネチャは第1引数match(一致した部分文字列)4のみなので、キャプチャグループに対応する文字列は使われていないという理解です

キャプチャグループのカッコの内側は|で、いずれか(=または)となっていますね。

^|[^\\p{L}\\p{N}\']|((^|\\P{L})\')
  • ^(行頭)
  • 文字集合 [^\\p{L}\\p{N}\']
    • \p{L} \p{N} シングルクォート)でない(^)
    • =文字でも数字でもシングルクォートでもない
    • 半角スペースはマッチしそうです
    • シングルクォートはrock'n'rollのように単語の中で使うから指定していそうですね!
  • キャプチャグループ ((^|\\P{L})\')
    • 行頭または\P{L}(のグループ)にシングルクォートが続く(というグループ)
    • \P{L}は、Letter(=\p{L}でない(例えば半角スペースが該当)

ここまでの読み解きを元に、開発ツール(Firefox)のコンソールで手を動かします。
※gフラグを指定した正規表現のmatchでは、正規表現全体に一致した結果を返し、キャプチャグループは返しません5

'hello world'.match(new RegExp('(^)\\p{L}', 'gmu'));  // 行頭の1文字が取れる
// Array [ "h" ]

'hello world'.match(new RegExp('([^\\p{L}\\p{N}\'])\\p{L}', 'gmu'));  // 2語目以降のスペース+1文字目
// Array [ " w" ]

'hello \'world'.match(new RegExp('(((^|\\P{L})\'))\\p{L}', 'gmu'));  // 2語目以降のスペース+シングルクォート+1文字目。new RegExp('(((\\P{L})\'))\\p{L}', 'gmu') がマッチ
// Array [ " 'w" ]

'\'physician\'s assistant\''.match(new RegExp('(((^|\\P{L})\'))\\p{L}', 'gmu'));  // 行頭のシングルクォートと1文字目。new RegExp('(((^)\'))\\p{L}', 'gmu') がマッチ
// Array [ "'p" ]

どうやら単語の先頭の部分を取り出す正規表現みたいですね!

正規表現で先頭部分を取り出してreplace

この正規表現を指す変数名がtitleBoundaryというのはうまい名前だなと思います(タイトルケースの境界

// https://github.com/microsoft/vscode/blob/1.88.1/src/vs/editor/contrib/linesOperations/browser/linesOperations.ts#L1152-L1154
return text
    .toLocaleLowerCase()
    .replace(titleBoundary, (b) => b.toLocaleUpperCase());

文字列(text)を一度小文字にしてから(toLocaleLowerCase)、取り出した先頭部分を大文字に変える(toLocaleUpperCase)ことでタイトルケースになります!

regex = new RegExp('(^|[^\\p{L}\\p{N}\']|((^|\\P{L})\'))\\p{L}', 'gmu');
// -> /(^|[^\p{L}\p{N}']|((^|\P{L})'))\p{L}/gmu

'hello world \'world'.match(regex);
// -> Array(3) [ "h", " w", " 'w" ]
'hello world \'world'.replace(regex, (b) => b.toLocaleUpperCase());
// -> "Hello World 'World"

'\'physician\'s assistant\''.match(regex);
// -> Array [ "'p", " a" ]
'\'physician\'s assistant\''.replace(regex, (b) => b.toLocaleUpperCase());
// -> "'Physician's Assistant'" 

終わりに

VS Codeの「Transform to Title Case」を担うクラスTitleCaseActionの実装で使われている正規表現を読み解きました。

  • 正規表現 RegExp('(^|[^\\p{L}\\p{N}\']|((^|\\P{L})\'))\\p{L}', 'gmu') は、タイトルケースの境界にマッチする
    • 行頭
    • (行頭ではない)単語の始まり(半角スペース + 1文字)
    • 英単語が含むシングルクォートも考慮
  • 境界には単語の先頭の1文字だけでなく、その前にある半角スペースやシングルクォートも含む
  • 一度小文字にしてから、タイトルケースの境界を大文字に変える
    • 半角スペースやシングルクォートはそのままで、含む1文字だけ大文字になる=タイトルケース

\p{L}は全角文字も含むので、全角アルファベットのタイトルケースもできますね!
EMILY stewart -> Emily Stewart

P.S. 読みやすくしたいな〜

こちらを思い出していました。


  1. 文字列のちょっとした変換について話します
  2. 第2引数のフラグは、グローバル検索(g)・複数行の検索(m)・Unicode文字クラスエスケープのためのunicode(u)です。gとmはこちらをどうぞ
  3. 一致した内容を記憶してくれます。グループと後方参照 - JavaScript | MDN
  4. String.prototype.replace() - JavaScript | MDN
  5. String.prototype.match() - JavaScript | MDN をどうぞ