match-indexという正規表現の補助JavaScriptライブラリを書きました。

例えば、

"ABC ABC"

という文字列から"ABC"という文字列とその位置(index)を取ろうとすると、非常に面倒な書き方をする必要があります。

"ABC ABC".match(/(ABC)/g) では文字列は取れますが、indexを取ることができません。 これをやるにはmatchではなく、gフラグ付き正規表現とexecreplaceを使ってやる必要があります。

これを直感的に行うString.prototype.matchAllというProposalも存在しています。

今回はこのmatchAll的なものと、キャプチャした内容と位置を取得出来るmatchCaptureGroupAllをもったmatch-indexというライブラリを書きました。

const captureGroups = matchCaptureGroupAll("ABC ABC", /(ABC)/);
assert.equal(captureGroups.length, 2);
const [x, y] = captureGroups;
assert.equal(x.text, "ABC");
assert.equal(x.index, 0);
assert.equal(y.text, "ABC");
assert.equal(y.index, 4);

Installation

npm install match-index

でインストールできます。

Usage

match-index は2つの関数を持ってます。

matchCaptureGroupAll(text, regExp): MatchCaptureGroup

()で囲まれた内容を配列で返してくれます。

[{
  text : "strgin",
  index: 5 // 開始位置
}]

配列の中身はtextindexという感じになっています。

// get "ABC" and "EFC that are captured by ( and )
const captureGroups = matchCaptureGroupAll("ABC EFG", /(ABC).*?(EFG)/);
// captureGroups is array of MatchAllGroup
/**
 * @typedef {Object} MatchAllGroup
 * @property {Array} all
 * @property {string} input
 * @property {number} index
 * @property {MatchCaptureGroup[]} captureGroups
 */
assert(captureGroups.length, 2);
const [x, y] = captureGroups;
assert.equal(x.text, "ABC");
assert.equal(x.index, 0);
assert.equal(y.text, "EFG");
assert.equal(y.index, 4);

matchCaptureGroupAll は内部的に次に紹介する matchAll を使っています。

matchAll(text, regExp): MatchAllGroup

一方、matchAll()String.prototype.matchAllと似たような感じですが、キャプチャに関する内容をcaptureGroupsに保持する拡張をしています。

const text = 'test1test2';
const regexp = /t(e)(st\d?)/g;
const captureGroups = matchAll(text, regexp);
/**
 * @typedef {Object} MatchAllGroup
 * @property {Array} all
 * @property {string} input
 * @property {number} index
 * @property {MatchCaptureGroup[]} captureGroups
 */

問題点

これは実装上の問題で、多分正規表現ではなくパーサコンビネータとかで実装すればどうにかできると思いますが、match-indexはネストしたキャプチャを正確に扱えないバグがあります。

例えば、次の例は(st(\d?))がネストしているため、indexの値がおかしくなっています。

const text = 'test1test2';
const regexp = /t(e)(st(\d?))/g;
const captureGroups = matchAll(text, regexp);
/**
 * @typedef {Object} MatchAllGroup
 * @property {Array} all
 * @property {string} input
 * @property {number} index
 * @property {MatchCaptureGroup[]} captureGroups
 */

assert.equal(captureGroups.length, 2);
const [test1, test2] = captureGroups;
assert.equal(test1.index, 0);
assert.equal(test1.input, text);
assert.deepEqual(test1.all, ['test1', 'e', 'st1', '1']);
assert.deepEqual(test1.captureGroups, [
    {
        index: 1,
        text: 'e'
    }, {
        index: 2,
        text: 'st1'
    }, {
        index: -1,// Limitation of capture nest
        text: '1'
    }
]);

制限として受け止めれば使えますが、いい案が思いつかないので修正するPull Requestを募集しています…

このライブラリはtextlint-rule-preset-JTF-style--fixでの自動修正に対応に対応するときに、もっと直感的にマッチしてその位置を取得する方法が欲しくて作りました。

これにより書く効率は上がって、つねにgフラグで扱われるので、一度マッチしても最後までちゃんと繰り返しマッチするようになって、全てのエラーを出せるようになったので書いてよかったと思います。(textlintのようなLintの特性上、その行にある全部のエラーを出せた方がよいため)

小さなライブラリですが、先ほど書いたようなバグができたりするので正規表現は扱うのが結構難しいです…