前回まで
前回は基礎ということで、タグ付きテンプレートといいながら、テンプレートリテラルでできるようなことしかしてませんでしたが、今回は応用ということでちょっとは役に立ちそうなものを作ってみます。
regexp
タグ付きテンプレート
今回は正規表現を生成するタグ付きテンプレートregexp
を作ります。
ユーザー入力などの任意の値を正規表現に埋め込むとき、いちいち正規表現の特殊文字をエスケープするのめんどくせー、と思ったことはないですか?私にはあります。
また、正規表現を書いていて、こことここ、同じパターンだから使い回ししてー、と思ったことはないですか?私にはあります。
ということでタグ付きテンプレートとして挿入される値は
- エスケープされるもの
- 正規表現のまま
の2種類を受け付けることにしましょう。
それに追加して、正規表現のフラグも挿入する値で指定できるようにしたいところです。
一部の正規表現を引数にとるメソッドでは、指定された正規表現に必要なフラグが設定されていないとエラーになるものがあります(String.prototype.replaceAllとか)。それらのメソッドでも利用できるようにフラグを設定できる必要があります。
また正規表現を書いているとき、
- 括弧の対応がわからん、改行とかインデントとか入れてぇ
- ここの正規表現わけわからん、コメント入れてぇ
とか思ったことはないですか?私には日常茶飯事です。
ということで、テンプレート文字には
- 1行コメント(
//
から改行の前まで) - ブロックコメント(
/*
から*/
まで) - 空白、タブ、改行
を書いても大丈夫なように、パターンを生成する際には除去するようにしたいと思います。
値の挿入、フラグの指定
- エスケープされるもの
- 正規表現のまま
この二つを区別するためには、型やクラスを変えるとか、値以外のプロパティに種類を持たせるとかいろいろ方法はあります。
ありますが、JavaScriptには元々正規表現を扱う仕組みがあるのでそれを利用させてもらいましょう。
- 文字列値はエスケープして挿入する。
- 正規表現のインスタンスはそのパターンをそのまま挿入する。
また、正規表現のコンストラクタでは、正規表現のパターンだけでなくフラグも指定できます。
指定した正規表現についているフラグをそのまま継承する、というのでもいいのですが、そのために不要な正規表現を追加する必要があっては本末転倒です。
なので
-
flags
プロパティを持つオブジェクトは、そのflags
プロパティの値を正規表現のフラグとする。
としておけば、正規表現に指定されたフラグも回収できますし、フラグだけを指定することも可能になります。
であれば先ほどの
- 正規表現のインスタンスはそのパターンをそのまま挿入する。
も同様に
-
source
プロパティを持つオブジェクトは、そのsource
プロパティの値をパターンとして挿入する。
という仕様の方が柔軟性が高そうです。
コメント、空白文字の除去
テンプレート文字列からは
- 1行コメント(
//
から改行の前まで) - ブロックコメント(
/*
から*/
まで) - 空白、タブ、改行
を除去するのですが、regexp`abc def ghi`
と書いておいて'abc def ghi'
にはマッチせず'abcdefghi'
にはマッチする、というのはちょっと釈然としません。
なので、
- ただし連続した空白文字の前後に英数字がある場合は、1文字以上の空白文字(
\s+
)に置き換える。
というルールを追加したいと思います。
どんなパターンでも指定できるようにしておきたいので、エスケープされていた場合は(`\/*`
や`\ `
など)対象外としましょう。
コメントが入るか入らないかでパターンが変化するのもうれしくないので、空白文字と英数字の間にコメントが存在していた場合はコメントを除去してから判定するものとします。
仕様
regexp
関数の仕様はこんな形になります。
type RegExpFlags = AllCombinations<'dgimsuy'>;
function regexp(
template: TemplateStringsArray,
...values: Array<
| string
| { source: string }
| { flags: RegExpFlags }
| {
source: string;
flags: RegExpFlags;
}
>,
): RegExp;
AllCombinations
は文字の組み合わせ型関数の作り方で作った型関数で、指定した文字を組み合わせた文字列のUnion型になります。
正規表現のフラグとして指定できるのはd
、g
、i
、m
、s
、u
、y
の7文字なので、これを組み合わせた文字列のUnion型を生成してRegExpFlags
にエイリアスしています。
これで${~}
の中に対応しないオブジェクトが指定されてもTypeScriptがエラーにしてくれるようになったので、よけいなことを考えなくてもよくなりました。
実装
基本的なところ、テンプレート文字列の間に値を挿入していく、というのは変わらないのでbasic
と同じように、reduce
を使って繋げるというのは変わりません。
正規表現では\
を多用するのでTemplateStringsArray
のraw
プロパティを使うことになります。
args[0].raw.reduce((r, e, i) => r.concat(String(args[i]), e));
今回はこの文字列を正規表現のパターンとして使用するので、こうなります。
const source = args[0].raw.reduce((r, e, i) => r.concat(String(args[i]), e));
return new RegExp(source);
reduce
のコールバック関数内
挿入する値によって、挿入され方が変わってくるのでString(args[i])
の部分がもうちょっと複雑になります。
- 文字列値はエスケープして挿入する。
-
source
プロパティを持つオブジェクトは、そのsource
プロパティの値をパターンとして挿入する。
なのでこうなります。
(r, e, i) => {
const value = args[i];
const pattern =
typeof value === 'string'
? value.replace(/[[\](){}.?+*|^$\\]/g, '\\$&')
: 'source' in value
? `(?:${value.source})`
: '';
return r.concat(pattern, e);
}
正規表現のパターンを追加するときには前後からの影響を受けないように、影響を与えないように、(?:
~)
で囲っておきます。
ついでにフラグの対応もここに入れておきましょう。
let flags: RegExpFlags = '';
とflags
という変数を外側に用意しておいて
const pattern =
typeof value === 'string'
? value.replace(/[[\](){}.?+*|^$\\]/g, '\\$&')
: /*ここから追加*/
('flags' in value &&
(flags += [...value.flags]
.filter(flag => !flags.includes(flag))
.join('')),
/*ここまで追加*/
'source' in value
? `(?:${value.source})`
: '');
としておけばreduce
が終わったときflags
には、すべての値から回収したflags
プロパティをマージしたものが入っていますので、RegExp
のコンストラクタにも渡します。
return new RegExp(source, flags);
テンプレート文字列の改変
コメントや改行、インデントが入っても大丈夫なようにテンプレート文字列の改変しておきます。
配列の内容すべてに変更を加えるので、map
とreplace
を使います。
args[0].raw.map(s => s.replace(
/(\\)[\s\S]|(?<=(\w)?)(?:\/\/.*|\/\*[\s\S]*?\*\/|\s+)+(?=(\w)?)/g,
(
match,
escaped: string | undefined,
pre: string | undefined,
post: string | undefined,
) =>
escaped
? match
: pre && post && match.replace(/\/\/.*|\/\*[\s\S]*?\*\//g, '')
? '\\s+'
: '',
))
まずreplace
に指定する正規表現の最初に\\[\s\S]
を指定して、これにマッチしたときはそのまま変化させない、とすることでエスケープされた\/*
などが除去されないようにしています。特定のパターンを除いて置換したいときにはいつもこうやってます。
次に1行コメント(//.*
)、ブロックコメント(/\*[\s\S]*?\*/
)、連続する空白文字(\s+
)が連続しているものを検索しています。と同時に前後に英数字がないか((?<=(\w)?)
と(?=(\w)?)
)も確認しています。
エスケープシーケンスであればマッチした文字列そのままとし、前後に英数字があって空白文字がある(コメントを除去してもなおfalsyでない、つまり空文字列でない = 空白文字がある)場合には1文字以上の空白文字(\s+
)に置換、それ以外は空文字列(つまり除去)にしています。
使用例
このタグ付きテンプレートを使うと
const NULL = /null/;
const BOOLEAN = /true|false/;
const NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/;
const STRING = /"[^"\\]*(?:\\.[^"\\]*)*"/;
const PRIMITIVE = regexp`${NULL}|${BOOLEAN}|${NUMBER}|${STRING}`;
const PRIMITIVE_ARRAY = regexp`\[\s*(?:${PRIMITIVE}(?:\s*,\s*${PRIMITIVE})*)?\s*\]`;
const PRIMITIVE_OBJECT = regexp`\{\s*(?:${STRING}\s*:\s*${PRIMITIVE}(?:\s*,\s*${STRING}\s*:\s*${PRIMITIVE})*)?\s*\}`;
こんな感じで正規表現のパターンを使い回したり、
function isSender(address: string): boolean {
return headers.match(regexp`^From\s*:\s*${address}`);
}
メールアドレスで検索したりが簡単になります。
また、たとえば前回エスケープシーケンスの解除に使用した正規表現ですが、普通に書くとこんな風に何が何やらわかりません。
const ESCAPE_SEQUENCE = /\\[\s\S](?:(?<=0)[0-9]|(?<=x)([0-9A-Fa-f]{2})|(?<=u)(?:\{([0-9A-Fa-f]{1,6})\}|([0-9A-Fa-f]{4})))?/g;
これをregexp
を使って書き直すと
const ESCAPE_SEQUENCE = regexp`
// 基本的なエスケープシーケンスはエスケープ文字\とそれに続く1文字
\\[\s\S](?:
// 1文字が0でそのあとに続く1文字が数字なら(エラーにするために)追加する
(?<=0)[0-9]
|
// 1文字がxでそのあとに続く2文字が16進数文字であれば追加する
(?<=x)([0-9A-Fa-f]{2}) // $1
|
// 1文字がuでそのあとに続く文字列が以下の場合追加する。
(?<=u)(?:
// {~}で囲まれた1~6文字の16進数文字
\{([0-9A-Fa-f]{1,6})\} // $2
|
// 4文字の16進数文字
([0-9A-Fa-f]{4}) // $3
)
)?
${{ flags: 'g' }}
`;
途中に改行やインデントを入れたり、コメントを入れたり、わかりやすくできます。
フラグ(大文字小文字の無視i
や繰り返しg
など)には挿入した正規表現のものも反映されますが、フラグだけを指定するオブジェクトも利用できます。
[おまけ] VS Code拡張機能
VS Codeの拡張機能にComment tagged templatesというものがあります。
これを入れていると
const ESCAPE_SEQUENCE = regexp/*regexp*/`
のように書くだけで(regexp
を2回書かなきゃいけないのでちょっと冗長ですが)
正規表現のシンタックスで色づけしてくれるようになります。
こうしておくとグルーピングの括弧なども強調表示してくれるようになるので、regexp
を使う必要がないところでも使いたくなってきますよ。
まとめ
今回の実装も前回と同じリポジトリにあります。
前回からいくつか変更を入れていますので、すでにcloneされている奇特な方もpullしてみてくださいね。