Deep JavaScript
この書籍をサポートしてください:購入する または 寄付する
(広告です。ブロックしないでください。)

16 正規表現:例で見る先読み・後読みアサーション



この章では、例を使用して正規表現の先読み・後読みアサーションについて説明します。先読み・後読みアサーションはキャプチャを行わず、入力文字列の現在位置の前後にある(または無い)ものに一致する(または一致しない)必要があります。

16.1 チートシート:先読み・後読みアサーション

表 4:使用可能な先読み・後読みアサーションの概要。
パターン 名称
(?=«pattern») 肯定先読み ES3
(?!«pattern») 否定先読み ES3
(?<=«pattern») 肯定後読み ES2018
(?<!«pattern») 否定後読み ES2018

4つの先読み・後読みアサーションがあります(表 4

16.2 この章に関する注意事項

16.3 例:一致の前後に来るものを指定する(肯定先読み・後読み)

以下のインタラクションでは、引用符で囲まれた単語を抽出します

> 'how "are" "you" doing'.match(/(?<=")[a-z]+(?=")/g)
[ 'are', 'you' ]

ここでは、2つの先読み・後読みアサーションが役立ちます

先読み・後読みアサーションは、一致全体(キャプチャグループ 0)を返す /g モードの .match() に特に便利です。先読み・後読みアサーションのパターンが一致するものはキャプチャされません。先読み・後読みアサーションがないと、引用符が結果に表示されます

> 'how "are" "you" doing'.match(/"([a-z]+)"/g)
[ '"are"', '"you"' ]

16.4 例:一致の前後に来ないものを指定する(否定先読み・後読み)

前のセクションで行ったことの逆を行い、文字列からすべての引用符で囲まれていない単語を抽出するにはどうすればよいでしょうか?

最初の試みは、単に肯定先読み・後読みアサーションを否定先読み・後読みアサーションに変換することです。残念ながら、これは失敗します

> 'how "are" "you" doing'.match(/(?<!")[a-z]+(?!")/g)
[ 'how', 'r', 'o', 'doing' ]

問題は、引用符で囲まれていない文字シーケンスを抽出することです。つまり、文字列 '"are"' では、途中の「r」は引用符で囲まれていないと見なされます。これは、「a」が先行し、「e」が後続するためです。

接頭辞と接尾辞が引用符でも文字でもないことを指定することで、これを修正できます

> 'how "are" "you" doing'.match(/(?<!["a-z])[a-z]+(?!["a-z])/g)
[ 'how', 'doing' ]

別の解決策は、\b を使用して、文字シーケンス [a-z]+ が単語境界で開始および終了することを要求することです

> 'how "are" "you" doing'.match(/(?<!")\b[a-z]+\b(?!")/g)
[ 'how', 'doing' ]

否定後読みと否定先読みの良いところは、例で示されているように、文字列の先頭または末尾でもそれぞれ機能することです。

16.4.1 否定先読み・後読みアサーションに代わる簡単な方法はない

否定先読み・後読みアサーションは強力なツールであり、通常、他の正規表現手段でエミュレートすることはできません。

それらを使用しない場合は、通常、完全に異なるアプローチをとる必要があります。たとえば、この場合、文字列を(引用符で囲まれた単語と引用符で囲まれていない単語に)分割してから、それらをフィルタリングできます

const str = 'how "are" "you" doing';

const allWords = str.match(/"?[a-z]+"?/g);
const unquotedWords = allWords.filter(
  w => !w.startsWith('"') || !w.endsWith('"'));
assert.deepEqual(unquotedWords, ['how', 'doing']);

このアプローチの利点

16.5 インターリュード:先読み・後読みアサーションを内側に向ける

これまでに見てきたすべての例に共通しているのは、先読み・後読みアサーションが、一致の前後に何が来るべきかを指示する一方で、それらの文字を一致に含めないことです。

この章の残りの部分で示されている正規表現は異なります。それらの先読み・後読みアサーションは内側を向き、一致の内容を制限します。

16.6 例:'abc' で始まらない文字列に一致させる

'abc' で始まらないすべての文字列に一致させたいとしましょう。最初の試みは、正規表現 /^(?!abc)/ である可能性があります。

これは .test() ではうまく機能します

> /^(?!abc)/.test('xyz')
true

ただし、.exec() は空の文字列を返します

> /^(?!abc)/.exec('xyz')
{ 0: '', index: 0, input: 'xyz', groups: undefined }

問題は、先読み・後読みアサーションなどのアサーションは、一致したテキストを拡張しないことです。つまり、入力文字をキャプチャせず、入力の現在位置に関する要求を行うだけです。

したがって、解決策は、入力文字をキャプチャするパターンを追加することです

> /^(?!abc).*$/.exec('xyz')
{ 0: 'xyz', index: 0, input: 'xyz', groups: undefined }

意図通り、この新しい正規表現は 'abc' で始まる文字列を拒否します

> /^(?!abc).*$/.exec('abc')
null
> /^(?!abc).*$/.exec('abcd')
null

そして、完全な接頭辞を持たない文字列を受け入れます

> /^(?!abc).*$/.exec('ab')
{ 0: 'ab', index: 0, input: 'ab', groups: undefined }

16.7 例:'.mjs' を含まない部分文字列に一致させる

次の例では、以下を見つけたいと思います

import ··· from '«module-specifier»';

ここで、module-specifier'.mjs' で終わりません。

const code = `
import {transform} from './util';
import {Person} from './person.mjs';
import {zip} from 'lodash';
`.trim();
assert.deepEqual(
  code.match(/^import .*? from '[^']+(?<!\.mjs)';$/umg),
  [
    "import {transform} from './util';",
    "import {zip} from 'lodash';",
  ]);

ここで、後読みアサーション (?<!\.mjs) は *ガード* として機能し、正規表現がこの場所で '.mjs' を含む文字列と一致するのを防ぎます。

16.8 例:コメント行をスキップする

シナリオ:コメントをスキップしながら、設定行を解析したいとします。例えば

const RE_SETTING = /^(?!#)([^:]*):(.*)$/

const lines = [
  'indent: 2', // setting
  '# Trim trailing whitespace:', // comment
  'whitespace: trim', // setting
];
for (const line of lines) {
  const match = RE_SETTING.exec(line);
  if (match) {
    const key = JSON.stringify(match[1]);
    const value = JSON.stringify(match[2]);
    console.log(`KEY: ${key} VALUE: ${value}`);
  }
}

// Output:
// 'KEY: "indent" VALUE: " 2"'
// 'KEY: "whitespace" VALUE: " trim"'

正規表現 RE_SETTING はどのようにして導き出されたのでしょうか?

設定の次の正規表現から始めました

/^([^:]*):(.*)$/

直感的には、これは次の部分のシーケンスです

この正規表現は、*いくつか* のコメントを拒否します

> /^([^:]*):(.*)$/.test('# Comment')
false

しかし、他のコメント(コロンが含まれているもの)は受け入れます

> /^([^:]*):(.*)$/.test('# Comment:')
true

ガードとして (?!#) を前に付けることで、これを修正できます。直感的には、「入力文字列の現在位置の後に文字 # があってはならない」という意味です。

新しい正規表現は意図通りに機能します

> /^(?!#)([^:]*):(.*)$/.test('# Comment:')
false

16.9 例:スマートクォート

直線の二重引用符のペアを中かっこ引用符に変換したいとしましょう

これは私たちの最初の試みです

> `The words "must" and "should".`.replace(/"(.*)"/g, '“$1”')
'The words “must" and "should”.'

最初と最後の引用符のみが中かっこになっています。ここでの問題は、* 量指定子が *貪欲に* (できるだけ多く)一致することです。

* の後に疑問符を付けると、*非貪欲に* 一致します

> `The words "must" and "should".`.replace(/"(.*?)"/g, '“$1”')
'The words “must” and “should”.'

16.9.1 バックスラッシュによるエスケープのサポート

バックスラッシュによる引用符のエスケープを許可したい場合はどうでしょうか?引用符の前にガード (?<!\\) を使用することで、これを行うことができます

> const regExp = /(?<!\\)"(.*?)(?<!\\)"/g;
> String.raw`\"straight\" and "curly"`.replace(regExp, '“$1”')
'\\"straight\\" and “curly”'

後処理ステップとして、次の処理を行う必要があります

.replace(/\\"/g, `"`)

ただし、バックスラッシュでエスケープされたバックスラッシュがある場合、この正規表現は失敗する可能性があります

> String.raw`Backslash: "\\"`.replace(/(?<!\\)"(.*?)(?<!\\)"/g, '“$1”')
'Backslash: "\\\\"'

2番目のバックスラッシュにより、引用符が中かっこになりませんでした。

ガードをより洗練されたものにすることで、これを修正できます(?: はグループを非キャプチャにします)

(?<=[^\\](?:\\\\)*)

新しいガードは、引用符の前にバックスラッシュのペアを許可します

> const regExp = /(?<=[^\\](?:\\\\)*)"(.*?)(?<=[^\\](?:\\\\)*)"/g;
> String.raw`Backslash: "\\"`.replace(regExp, '“$1”')
'Backslash: “\\\\”'

1つの問題が残っています。このガードは、最初の引用符が文字列の先頭にある場合に一致するのを防ぎます

> const regExp = /(?<=[^\\](?:\\\\)*)"(.*?)(?<=[^\\](?:\\\\)*)"/g;
> `"abc"`.replace(regExp, '“$1”')
'"abc"'

最初のガードを (?<=[^\\](?:\\\\)*|^) に変更することで、これを修正できます

> const regExp = /(?<=[^\\](?:\\\\)*|^)"(.*?)(?<=[^\\](?:\\\\)*)"/g;
> `"abc"`.replace(regExp, '“$1”')
'“abc”'

16.10 謝辞

16.11 参考文献