はじめに
エミリーちゃん、小文字にしたいな〜。nikkieです。
4/20(土)のVS Code Conference Japan 2024登壇1準備からアウトプットです。
目次
- はじめに
- 目次
- Transform to Title Case
- TitleCaseActionの持つ正規表現
- Unicode文字クラスエスケープを使った正規表現
- 正規表現で先頭部分を取り出してreplace
- 終わりに
- P.S. 読みやすくしたいな〜
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の持つ正規表現
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の短縮
この正規表現がどんな文字列と一致するかを理解するのが今回のゴールです。
ここでテストデータをガイドにします。
- https://github.com/microsoft/vscode/blob/1.88.1/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts#L692
'hello world'
->'Hello World'
- https://github.com/microsoft/vscode/blob/1.88.1/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts#L803-L809
'foO baR BaZ'
->'Foo Bar Baz'
'foO\'baR\'BaZ'
->'Foo\'bar\'baz'
(シングルクォートを含む文字列は一語扱い)
- https://github.com/microsoft/vscode/blob/1.88.1/src/vs/editor/contrib/linesOperations/test/browser/linesOperations.test.ts#L827-L829
'\'physician\'s assistant\''
->'\'Physician\'s Assistant\''
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. 読みやすくしたいな〜
こちらを思い出していました。
「Readable 正規表現」見事な洞察でした👏 #phperkaigi
— nikkie / にっきー (@ftnext) 2024年3月10日
正規表現の10文字足らずに、関心を詰め込みすぎてしまえるのか〜
電話番号を例に分解していくのはなるほどーhttps://t.co/D1fNmWUhKG
(リポジトリのREADMEからcanvaのスライドに飛べます)
- 文字列のちょっとした変換について話します ↩
- 第2引数のフラグは、グローバル検索(g)・複数行の検索(m)・Unicode文字クラスエスケープのためのunicode(u)です。gとmはこちらをどうぞ ↩
- 一致した内容を記憶してくれます。グループと後方参照 - JavaScript | MDN↩
- String.prototype.replace() - JavaScript | MDN↩
- String.prototype.match() - JavaScript | MDN をどうぞ↩