JavaScriptで正規表現にマッチしない部分を置換する


はじめに

JavaScriptには文字列置換の方法として replace() がありますが、これは正規表現に「マッチした部分」しか置換できません。では以下の場合どうすればいいでしょうか?

文字列をHTMLエスケープせよ。ただし文字列中の文字参照はエスケープしてはならない。

ここで文字参照とは

  • 文字実体参照 (© など)
  • 数値文字参照 (♪♪など)

を指し、HTMLエスケープは escapeHTML() で行えるものとします。

例えば、

"&copy;<&#9834;&#x266A;>"

&quot;&copy;&lt;&#9834;&#x266A;&gt;&quot;

に置換されます。

マッチする部分の正規表現

文字参照の正規表現は、/&\w+;|&#\d+;|&#x[0-9a-fA-F]+;/ と表せますので、マッチする部分を置換するのであれば簡単です。例えば文字参照をそれが指す実際の文字に置き換えるのなら、

    const str = "&copy;<&#9834;&#x266A;>";
    str.replace(/&\w+;|&#\d+;|&#x[0-9a-fA-F]+;/, toChar)

のようにすればよいでしょう。(ただし文字参照を実際の文字に変換する関数toChar()は別途要定義)

マッチしない部分の正規表現

マッチしない部分の正規表現が書ければ問題は解決ですが、ネットで探しても例がなく、なかなか難しそうです。マッチしない部分 + マッチした部分 の正規表現であれば、

    /(?:(?!${pattern}).)*(?:${pattern})?/g

と書けますので、これを利用することにします(上記はPerl風に正規表現に変数展開していますが、javascriptではこれはできないので一工夫必要です)。実際のケースでは「マッチする部分とマッチしない部分をそれぞれ置換する」という局面が多いのですが、それにも対応できそうです。

マッチしない部分 + マッチした部分 が取得できれば、次は

    /^(.*?)(${pattern})?$/

で、マッチしない部分マッチした部分 に分けることができます。

JavaScriptでの実装

これを実際にJavaScriptで実装すると以下のようになるでしょう。

    const pattern = "&\\w+;|&#\\d+;|&#x[0-9a-fA-F]+;";
    const regexp1 = new RegExp(`(?:(?!${pattern}).)*(?:${pattern})?`,'gi');
    const regexp2 = new RegExp(`^(.*?)(${pattern})?$`,'i');

    function replace(str = '') {
        let rv = '';
        for (let chunk of str.match(regexp1)) {
            let [ , u, m ] = chunk.match(regexp2);
            if (u) rv += unmatch ? unmatch(u) : u;
            if (m) rv += match   ? match(m)   : m;
        }
        return rv;
    }

pattern は文字列リテラルですので、\が2つ必要なことに注意してください。
match() はマッチしたとき、unmatch() はマッチなかったときの置換関数です。

モジュール化

この問題は度々発生するのでモジュール化しておきましょう。

replacer.js
module.exports = function(pattern, match, unmatch, opt = '') {

    const regexp1 = new RegExp(`(?:(?!${pattern}).)*(?:${pattern})?`,'g'+opt);
    const regexp2 = new RegExp(`^(.*?)(${pattern})?$`,opt);

    return function(str = '') {
        let rv = '';
        for (let chunk of str.match(regexp1)) {
            let [ , u, m ] = chunk.match(regexp2);
            if (u) rv += unmatch ? unmatch(u) : u;
            if (m) rv += match   ? match(m)   : m;
        }
        return rv;
    }
}

元々の問題は、

    const str = "&copy;<&#9834;&#x266A;>";
    const replace
            = require('./replacer.js')("&\\w+;|&#\\d+;|&#x[0-9a-fA-F]+;",
                                       null, escapeHTML, 'i');
    replace(str);

で解くことができます。