シンタックスハイライトを自作してみた

2021/05/07更新

「個人的実験」のカテゴリでは、私が個人的な興味に基づいて、単に勉強や考察のためにやってみたことをまとめています。実用性を意識した内容ではありませんので、ご了承下さい。

目次

動機

エディタやウェブページでソースコードを表示する際に、コードに色付けをするシンタックスハイライトは、今ではごく一般的な機能であるが、そのしくみがどういうものかは以前から気になっていた。ソースコードの意味を読み取っているわけだから、コンパイラやインタープリタに匹敵するレベルの高度なパーサーなどが必要になるのかと最初は思っていたが、目的とするレベルによってはごく単純な実装でも十分なものが作れると気づき、自力で実装してみることにした。

既存のライブラリ等を使用しての実装ではなく、ゼロからシンタックスハイライトの機能自体を実装しようという試みなので、ご注意いただきたい。

実装

前提

まず、どのようなものを作るかを最初に決める。

  • ウェブページ上で使用できるよう、JavaScriptで実装するものとする。

  • シンタックスハイライトを行なう対象の言語もJavaScriptとする。

  • 文字列として与えられたコードに対して、HTMLとCSSで単純に色付けをするだけとする。

  • 与えられたコードは常に正しいという前提で処理を行なうものとし、カッコの対応や単語のスペリングなど文法ミスが無いかどうかをチェックする機能までは実装しない

  • 簡単のため、原則としてES5以前のJavaScriptを対象とし、ES2015以降で登場した新しい記法の一部には対応しない。

  • コード内の以下の部分について、それぞれ区別して別の色を付けられるようにする。

    • '~'"~"`~`による文字列リテラル(ただし、テンプレート文字列内は、変数や式が使われていても全て同色とする)

    • //~/*~*/によるコメント

    • /~/による正規表現リテラル

    • 数値

    • forifなどのキーワード

    • 変数

    • オブジェクトのプロパティ

    • 関数

この中で特に重要なのは「与えられたコードは常に正しいという前提で処理を行なう」という点である。つまり、シンタックスハイライトを実現するだけであれば、与えられたコードが正しいかどうかを判定するような処理は一切要らないのである。

上記をふまえて、作成するのは「文字列を受け取って、HTMLタグを挿入した文字列を返す関数」とする。HTMLは、色付けされる部分が<span class="s">~</span>のようにクラス名付きの<span>タグで囲まれるようにし、CSSでクラスごとに色を指定することにする。それぞれのクラス名と色は以下の通りとする(色は既存のエディタやライブラリの色を参考にはしたが、最終的には私の好みである)。

クラス名

意味

s

文字列リテラル(string)

#009933

c

コメント(comment)

#999999

r

正規表現リテラル(regular expression)

#cc0066

n

数値(number)

#ff0000

k

キーワード(keyword)

#ff8000

v

変数(variable)

#000000

p

オブジェクトのプロパティ(property)

#9933cc

f

関数(function)

#3333cc

o

その他の文字(other)

#663333

w

空白類(white space)

なし

コードの量にもよるが、タグで囲まれる部分は膨大な数になると予想されるため、HTMLの肥大化を少しでも抑えられるようクラス名は1文字としている。

基本形の確立

前述のような文字列や数値などの「意味のある最小単位」のことをトークンという。与えられたコードをトークンにうまく分解することが最初のステップとなる。

トークンの種類が少なく、複雑な条件などが無い場合は、正規表現とmatch()を使って一発で分解することもできるが、後述の通り実際にはトークンを判定するには前後のトークンの条件なども必要になるため、一発での分解は難しい。そこで、文字列の先頭から1トークンずつ丁寧に切り出していく方針で考えてみる。

まず、文字列からトークンの切り出しを行なう機能を持つクラスを作成する。必要な機能は以下の通りである。

  • 分解したい文字列を与えてインスタンス化する。
    → コンストラクタで文字列を受け取る。

  • あらかじめ用意した正規表現でトークンを切り出し、切り出せたかどうかを判定する。
    → トークンの種類ごとにmatchXXX()メソッドを用意する。

  • 切り出したトークンを取得する。
    tokenプロパティで直前に切り出したトークンを保持する。

  • ループでの使用を想定し、文字列の最後まで到達したかどうかを判定する。
    isEnd()メソッドで真偽値を返す。

// ★文字列の先頭からトークンを切り出すクラス
class TokenSlicer {
    // コンストラクタ
    constructor(str) {
        // str:分解したい文字列
        this.token = ''; // 切り出したトークン
        this.str = str;  // 残りの文字列
    }
    // 文字列を全て切り出したかどうか
    isEnd() {
        return this.str.length == 0;
    }
    // 切り出しの共通メソッド
    match(re) {
        // re:トークンを表す正規表現
        const t = this.str.match(re);
        if (t) {
            // マッチしたらtokenとstrを更新する
            // ※例えば、re=/^[a-z]+/、this.str='aaa,bbb,ccc'なら、
            //   this.token='aaa'、this.str=',bbb,ccc'となる。
            this.token = t[0];
            this.str = this.str.slice(t[0].length);
        }
        return t;
    }
    // トークンの種類ごとの切り出しメソッド
    matchA() { return this.match(/^[a-z]+/); }
    matchB() { return this.match(/^[0-9]+/); }
    …
}

トークンを切り出せたら、種類を判定して保持しておき、最後にHTMLタグを挿入して文字列化する。このように切り出した後のトークンに関する機能を持つクラスも作成しておく。必要な機能は以下の通りである。

  • トークンとその種類を表す値を1つずつ追加して保持する。
    → トークンを追加するadd()メソッドを用意する。トークンの種類を表す定数を用意する。

  • 直前のトークンやその種類を簡単に取得できるようにする。
    lastTokenlastTokenTypeプロパティに直前のトークンとその種類を保持する。

  • 直前のトークンの種類を後から変更できるようにする。
    updateLastTokenType()メソッドを用意する。

  • HTMLを挿入して文字列化する。
    toString()でHTML化する。

// ★トークンの種類を表す定数
const TokenType = {
    // classNameはHTMLで使うクラス名
    A: { className: 'a' },
    B: { className: 'b' },
    …
};

// ★切り出したトークンを表すクラス
class TokenList {
    constructor() {
        this.tokens = []; // トークンのリスト
    }
    // トークンを追加するメソッド
    add(type, token) {
        this.tokens.push([type, token]);
        this.lastTokenType = type; // 直前のトークンの種類
        this.lastToken = token;    // 直前のトークン
        this.lastTokenPos = this.tokens.length - 1;
    }
    // 直前のトークンの種類を後から変更するメソッド
    updateLastTokenType(type) {
        if (this.lastTokenPos >= 0) this.tokens[this.lastTokenPos][0] = type;
    }
    // 最後に文字列化するメソッド
    toString() {
        // 全てのトークンについて処理
        return this.tokens.map(function(val) {
            // クラス名
            const className = val[0].className;
            // トークン(HTML用にエスケープをする)
            const token = val[1].replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
            // クラス名があるものは<span>タグで挟む
            return className ? `<span class="${className}">${token}</span>` : token;
        }).join('');
    }
}

これら2つのクラスを使って、以下のようなwhileループでトークンの切り出しと判定を行なっていく。

// 入力文字列
let str = '~';
// トークンを切り出すオブジェクト
const slicer = new TokenSlicer(str);
// 切り出したトークンのオブジェクト
const tokens = new TokenList();
// 文字列を全て切り出すまでくり返し
while (!slicer.isEnd()) {
    // トークンAにマッチした場合
    if (slicer.matchA()) {
        tokens.add(TokenType.A, slicer.token);
    // トークンBにマッチした場合
    } else if (slicer.matchB()) {
        tokens.add(TokenType.B, slicer.token);
    } else if {
        …
    }
}
// 最後にHTMLを生成
const output = tokens.toString();
console.log(output);

これで、文字列からトークンを切り出してHTMLを生成する準備はできたので、あとは、実際のトークンのパターンを洗い出していけばよい。

実際のパターンの網羅

それでは、ここからは実際のJavaScriptのコードからトークンを切り出していくため、まずはトークンのパターンを洗い出してみる。

トークン

切り出し方法

前後のトークンの条件

文字列リテラル

'~'で囲まれた範囲

なし

"~"で囲まれた範囲

なし

`~`で囲まれた範囲

なし

コメント

//~改行で囲まれた範囲

なし

/*~*/で囲まれた範囲

なし

正規表現リテラル

/~/で囲まれた範囲と後ろに続くフラグ

空白・コメント以外の直前のトークンが、以下のいずれかであること。

  • キーワード

  • その他の文字()]を除く)

※条件を満たさない場合は、/は除算演算子などであるため、その他の文字となる。

数値

0xに続く十六進数

なし

整数とそれに続く小数部・指数部

※簡単のため、アンダースコアを含む数値は考えない。

なし

キーワード

あらかじめ定義した単語に一致し、それ以上後ろに識別子となる文字が続かない

なし

変数

識別子([0-9A-Za-z_$]からなり、先頭は数字でない)

※簡単のため、ASCII以外のUnicode文字を含む識別子は考えない。

以下の2つの条件を満たすこと。

  • 空白・コメント以外の直前のトークンが.でない

  • 空白・コメント以外の直後のトークンが(でない

オブジェクトのプロパティ

以下の2つの条件を満たすこと。

  • 空白・コメント以外の直前のトークンが.

  • 空白・コメント以外の直後のトークンが(でない

関数

  • 空白・コメント以外の直後のトークンが(

空白類

スペース、タブ、改行のいずれか

なし

その他の文字

上記以外の1文字

なし

この表で「切り出し方法」が先のコードのTokenSlicerクラスで実装する内容、「前後のトークンの条件」がwhileループ内の判定ロジックで必要となってくる条件である。例えば、「変数」「オブジェクトのプロパティ」「関数」の3つはまとめて「識別子」のトークンとして切り出した後、whileループ内で「前後のトークンの条件」を使って区別していく。

まず、上記の「切り出し方法」に従ってTokenSlicerクラス内の切り出し用メソッドを実装すると以下のようになる。

class TokenSlicer {
    …
    // 文字列リテラル
    matchString() { return this.match(/^(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)/); }
    // コメント
    matchComment() { return this.match(/^(?:\/\/.*|\/\*[^]*?\*\/)/); }
    // 正規表現リテラル
    matchRegExp() { return this.match(/^\/(?:\\.|[^\/\\\n])+\/[a-z]*/); }
    // 数値
    matchNumber() { return this.match(/^(?:0x[0-9a-fA-F]+|(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?)/); }
    // キーワード
    matchKeyword() { return this.match(/^(?:if|else|for|while|break|continue|switch|in|of|var|let|const|function|typeof|instanceof|return|this|new|true|false|null|async|await|class|extends|super|static|try|catch|finally|throw)(?![0-9A-Za-z_$])/); }
    // 変数・オブジェクトのプロパティ・関数
    matchIdentifier() { return this.match(/^[A-Za-z_$][0-9A-Za-z_$]*/); }
    // 空白類
    matchSpace() { return this.match(/^[ \t\n]+/); }
    // その他の文字
    matchOther() { return this.match(/^[^]/); }
}

ポイントとなる正規表現をいくつか解説する。

  • 文字列リテラルや正規表現リテラルは、単純に'.*?'などとするのでは不十分である。'I don\'t know.'のように\によるエスケープでクォート自身が含まれる可能性があるからである。そこで'(?:\\.|[^'\\\n])*'という正規表現にしている。このように最初に\\.を入れることによって、\に出会った場合はエスケープと見なして必ず次の1文字とセットで拾うようにしている。

  • 範囲コメントを表す\/\*[^]*?\*\/で使用している*?は、*と同じく「0文字以上」という意味であるが、?が付くことによって可能な限り短くマッチするようになる。例えば/*hoge*/ fuga /*piyo*/というように複数コメントが並んでいる場合、*だと最後のコメント終端までが全てマッチしてしまうが、*?だと正しく/*hoge*/だけがマッチする。

  • キーワードの正規表現で使用している(?!~)否定先読みと呼ばれるもので「~が後ろに続かない」という意味である。通常の正規表現と異なる点は、それ自身がマッチ対象に含まれないという点である。これにより、キーワードの部分だけがマッチし、かつその後に識別子となる文字が続かない、という条件を記述できる。もしこの条件が無いと、例えばnewsnewがマッチしたりしてしまう。ちなみに、逆に「~が後ろに続く」という意味の肯定先読み(?=~)というのも存在する。

  • その他の文字の正規表現で使用している[^]は「改行を含む任意の1文字」を表す。通常、任意の1文字を表す正規表現は.であるが、.は(ES2018で追加されたsフラグを使用しない限り)改行にマッチしないため、改行も含めた全ての文字にマッチするよう[^]を使っている。

続いて、トークンの種類を表す定数は以下のようにする。

const TokenType = {
    S: { className: 's' }, // 文字列リテラル
    C: { className: 'c' }, // コメント
    R: { className: 'r' }, // 正規表現リテラル
    N: { className: 'n' }, // 数値
    V: { className: 'v' }, // 変数
    P: { className: 'p' }, // オブジェクトのプロパティ
    F: { className: 'f' }, // 関数
    K: { className: 'k' }, // 予約語
    O: { className: 'o' }, // その他の文字
    W: { className: null } // 空白類
};

空白類には特に色付けをすることはないので、<span>タグで囲まれることのないよう、クラス名はnullとしている。

最後に、whileループの中は以下のようになる。

while (!slicer.isEnd()) {
    // 文字列リテラル
    if (slicer.matchString()) {
        tokens.add(TokenType.S, slicer.token);
    // コメント
    } else if (slicer.matchComment()) {
        tokens.add(TokenType.C, slicer.token);
    // 正規表現リテラル
    } else if ((tokens.lastTokenType == TokenType.K || tokens.lastTokenType == TokenType.O && tokens.lastToken != ')' && tokens.lastToken != ']') && slicer.matchRegExp()) {
        tokens.add(TokenType.R, slicer.token);
    // 数値
    } else if (slicer.matchNumber()) {
        tokens.add(TokenType.N, slicer.token);
    // 予約語
    } else if (slicer.matchKeyword()) {
        tokens.add(TokenType.K, slicer.token);
    // 変数・オブジェクトのプロパティ
    } else if (slicer.matchIdentifier()) {
        // 直前のトークンが「.」ならオブジェクトのプロパティ、さもなければ変数
        tokens.add(tokens.lastToken == '.' ? TokenType.P : TokenType.V, slicer.token);
    // 空白類
    } else if (slicer.matchSpace()) {
        tokens.add(TokenType.W, slicer.token);
    // その他
    } else {
        slicer.matchOther();
        // 「(」で直前のトークンが「変数」か「オブジェクトのプロパティ」なら関数に変更
        if (slicer.token == '(' && (tokens.lastTokenType == TokenType.V || tokens.lastTokenType == TokenType.P)) {
            tokens.updateLastTokenType(TokenType.F);
        }
        tokens.add(TokenType.O, slicer.token);
    }
}

「正規表現リテラル」には、直前のトークンの条件を付けている。「変数」と「オブジェクトのプロパティ」は、matchIdentifier()でどちらも「識別子」として切り出した後、直前のトークンによって区別している。なお、「関数」は、直後のトークンが(のときという条件なので、「その他の文字」で(だった場合に直前のトークンを変更する形としている。

仕上げ

これで全ての部品は揃ったが、最後に一点、TokenListクラスの中で、コメントと空白類を直前のトークンとしてカウントしないようにadd()メソッドを以下のように修正する必要がある。

    // トークンを追加するメソッド
    add(type, token) {
        this.tokens.push([type, token]);
        // ★コメントでも空白類でもない場合のみ直前のトークンを更新する
        if (type != TokenType.C && type != TokenType.W) {
            this.lastTokenType = type; // 直前のトークンの種類
            this.lastToken = token;    // 直前のトークン
            this.lastTokenPos = this.tokens.length - 1;
        }
    }

以上を全てつなげて、以下が完成したシンタックスハイライトを行なう関数である。

const myHighlighter = (function() {

    // 文字列の先頭からトークンを切り出すクラス
    class TokenSlicer {
        // コンストラクタ
        constructor(str) {
            // str:分解したい文字列
            this.token = ''; // 切り出したトークン
            this.str = str;  // 残りの文字列
        }
        // 文字列を全て切り出したかどうか
        isEnd() {
            return this.str.length == 0;
        }
        // 切り出しの共通メソッド
        match(re) {
            // re:トークンを表す正規表現
            const t = this.str.match(re);
            if (t) {
                // マッチしたらtokenとstrを更新する
                this.token = t[0];
                this.str = this.str.slice(t[0].length);
            }
            return t;
        }
        // 文字列リテラル
        matchString() { return this.match(/^(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)/); }
        // コメント
        matchComment() { return this.match(/^(?:\/\/.*|\/\*[^]*?\*\/)/); }
        // 正規表現リテラル
        matchRegExp() { return this.match(/^\/(?:\\.|[^\/\\\n])+\/[a-z]*/); }
        // 数値
        matchNumber() { return this.match(/^(?:0x[0-9a-fA-F]+|(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?)/); }
        // キーワード
        matchKeyword() { return this.match(/^(?:if|else|for|while|break|continue|switch|in|of|var|let|const|function|typeof|instanceof|return|this|new|true|false|null|async|await|class|extends|super|static|try|catch|finally|throw)(?![0-9A-Za-z_$])/); }
        // 変数・オブジェクトのプロパティ・関数
        matchIdentifier() { return this.match(/^[A-Za-z_$][0-9A-Za-z_$]*/); }
        // 空白類
        matchSpace() { return this.match(/^[ \t\n]+/); }
        // その他の文字
        matchOther() { return this.match(/^[^]/); }
    }

    // 切り出したトークンを表すクラス
    class TokenList {
        constructor() {
            this.tokens = []; // トークンのリスト
        }
        // トークンを追加するメソッド
        add(type, token) {
            this.tokens.push([type, token]);
            // コメントでも空白類でもない場合のみ直前のトークンを更新する
            if (type != TokenType.C && type != TokenType.W) {
                this.lastTokenType = type; // 直前のトークンの種類
                this.lastToken = token;    // 直前のトークン
                this.lastTokenPos = this.tokens.length - 1;
            }
        }
        // 直前のトークンの種類を後から変更するメソッド
        updateLastTokenType(type) {
            if (this.lastTokenPos >= 0) this.tokens[this.lastTokenPos][0] = type;
        }
        // 最後に文字列化するメソッド
        toString() {
            // 全てのトークンについて処理
            return this.tokens.map(function(val) {
                // クラス名
                const className = val[0].className;
                // トークン(HTML用にエスケープをする)
                const token = val[1].replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
                // クラス名があるものは<span>タグで挟む
                return className ? `<span class="${className}">${token}</span>` : token;
            }).join('');
        }
    }

    // トークンの種類を表す定数
    const TokenType = {
        S: { className: 's' }, // 文字列リテラル
        C: { className: 'c' }, // コメント
        R: { className: 'r' }, // 正規表現リテラル
        N: { className: 'n' }, // 数値
        V: { className: 'v' }, // 変数
        P: { className: 'p' }, // オブジェクトのプロパティ
        F: { className: 'f' }, // 関数
        K: { className: 'k' }, // キーワード
        O: { className: 'o' }, // その他の文字
        W: { className: null } // 空白類
    };

    // メインとなる関数
    return function(str) {
        const slicer = new TokenSlicer(str); // トークンを切り出すオブジェクト
        const tokens = new TokenList(); // 切り出したトークンのオブジェクト
        while (!slicer.isEnd()) {
            // 文字列リテラル
            if (slicer.matchString()) {
                tokens.add(TokenType.S, slicer.token);
            // コメント
            } else if (slicer.matchComment()) {
                tokens.add(TokenType.C, slicer.token);
            // 正規表現リテラル
            } else if ((tokens.lastTokenType == TokenType.K || tokens.lastTokenType == TokenType.O && tokens.lastToken != ')' && tokens.lastToken != ']') && slicer.matchRegExp()) {
                tokens.add(TokenType.R, slicer.token);
            // 数値
            } else if (slicer.matchNumber()) {
                tokens.add(TokenType.N, slicer.token);
            // 予約語
            } else if (slicer.matchKeyword()) {
                tokens.add(TokenType.K, slicer.token);
            // 変数・オブジェクトのプロパティ
            } else if (slicer.matchIdentifier()) {
                // 直前のトークンが「.」ならオブジェクトのプロパティ、さもなければ変数
                tokens.add(tokens.lastToken == '.' ? TokenType.P : TokenType.V, slicer.token);
            // 空白類
            } else if (slicer.matchSpace()) {
                tokens.add(TokenType.W, slicer.token);
            // その他
            } else {
                slicer.matchOther();
                // 「(」で直前のトークンが「変数」か「オブジェクトのプロパティ」なら関数に変更
                if (slicer.token == '(' && (tokens.lastTokenType == TokenType.V || tokens.lastTokenType == TokenType.P)) {
                    tokens.updateLastTokenType(TokenType.F);
                }
                tokens.add(TokenType.O, slicer.token);
            }
        }
        return tokens.toString();
    };

})();

上記を「myhighlighter.js」とし、あとは以下のようなHTMLとCSSを用意すれば使用することができる。

<style>
.s { color: #009933; } /* 文字列リテラル */
.c { color: #999999; } /* コメント */
.r { color: #cc0066; } /* 正規表現リテラル */
.n { color: #ff0000; } /* 数値 */
.k { color: #ff8000; } /* キーワード */
.v { color: #000000; } /* 変数 */
.p { color: #9933cc; } /* オブジェクトのプロパティ */ 
.f { color: #3333cc; } /* 関数 */
.o { color: #663333; } /* その他の文字 */
</style>
<script src="myhighlighter.js"></script>
<script>
window.onload = function() {
    const jscode = document.getElementsByClassName('jscode');
    for (let i = 0; jscode.length > i; i++) {
        jscode[i].innerHTML = myHighlighter(jscode[i].innerText);
    }
};
</script>
<pre class="jscode">
// シンタックスハイライトを行ないたいコード
const myFunc = function() {
    …
};
</pre>

途中でお気づきの方もおられるかとは思うが、ここまで紹介してきたシンタックスハイライトの実装は、当サイトの本文で使用しているものである。

発展

他の言語の実装

今回はJavaScriptのシンタックスハイライトを実装したが、他の言語も実装したいとなった場合、JavaScriptと似た構造の言語であれば、今回作成したクラスなどは使い回せる。言語ごとに個別に実装する必要があるのは、TokenSlicerクラスの切り出し用メソッドの部分と、whileループの中の判定ロジックだけである。TokenSlicerクラスの切り出し用メソッドはサブクラスに分離すればよいだろう。

// ★切り出し用メソッドは言語ごとのサブクラスとして実装する
class TokenSlicerJS extends TokenSlicer {
    constructor(str) {
        super(str);
    }
    matchString() { … }
    matchComment() { … }
    …
}

JavaScriptのシンタックスハイライトでは、トークンの種類を判定するのに使ったのは前後のトークンの条件だけであったが、言語によってはさらに高度なコンテキスト(文脈)による判断が必要になることもある。例えば、HTMLの場合、開始タグの中か外かでコンテキストを別にすることで、開始タグの中の属性名や属性値などの判定がしやすくなる。

  1. 「タグ外」のコンテキストで、「開始タグの開始部分(<tag)」にマッチしたらコンテキストを「タグ内」に切り替える。

  2. 「タグ内」のコンテキストでは、「属性名(attr=)」や「属性値("val" )」のトークンにマッチする。

  3. 「タグ内」のコンテキストで、「開始タグの終了(>)」にマッチしたらコンテキストを「タグ外」に切り替える。

  4. 「タグ外」のコンテキストでは、「属性名」や「属性値」のトークンにはマッチしない。

// ★コンテキストを表す変数(開始タグの中かどうか)
let insideBeginTag = false;
while (!slicer.isEnd()) {
    // 開始タグの中の場合
    if (insideBeginTag) {
        // 属性名などにマッチする
        if (slicer.matchAttrName()) {
            …
        // ★開始タグの終了にマッチしたらコンテキストを切り替える
        } else if (slicer.matchBeginTagEnd()) {
            …
            insideBeginTag = false;
        }
    // 開始タグの中以外の場合
    } else {
        // ★開始タグの開始にマッチしたらコンテキストを切り替える
        if (slicer.matchBeginTagStart()) {
            …
            insideBeginTag = true;
        } else if …

他の言語を実装する際の最大の問題点は、当たり前であるが、自分がある程度その言語を知らなければ実装できないという点である…

より高度な実装

HTMLの<script>タグや<style>タグのように、ある言語の中に別言語のシンタックスハイライトを埋め込みたいとなった場合は、その範囲をトークンとして丸ごと切り出した上で、最後に文字列化する際に<span>タグで囲む代わりに再帰的に他の言語の関数を呼び出せばよい。

class TokenList {
    …
    toString() {
        return this.tokens.map(function(val) {
            const type = val[0];
            // ★もし他の言語の関数なら、再帰的に実行
            if (typeof type == 'function') {
                return type(val[1]);
            }
            const className = type.className;
            const token = val[1].replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
            return className ? `<span class="${className}">${token}</span>` : token;
        }).join('');
    }
}
…
// JSのシンタックスハイライト
const myHighlighterJs = function(str) {
    …
};
// HTMLのシンタックスハイライト
const myHighlighterHtml = function(str) {
    …
    while (!slicer.isEnd()) {
        // ★<script>タグの中身にマッチした場合
        if (slicer.matchScript()) {
            // ★TokenTypeの代わりにmyHighlighterJsをadd()する
            tokens.add(myHighlighterJs, slicer.token);
        } else if …
            …
        }
    }
    return tokens.toString();
}

ただ、埋め込んだ言語の中で再び同じ言語のシンタックスハイライトが必要となる場合は難しくなる。上記の場合、JavaScriptの中で再びHTMLのシンタックスハイライトが必要となることは通常は無いが、万が一const str = '</script>';のようなJavaScriptの文が存在すると、終了タグの位置を誤判定されてしまい、うまく行かなくなる。

他の例としては、ES2015で登場したJavaScriptのテンプレート文字列`~`は、${~}の中で再び`~`を含む通常のJavaScriptを使うことができるため、それを考慮し始めると急に難易度が増してしまう。

// 今回の実装によるシンタックスハイライトが破綻する例
const str = `テンプレート文字列の中で${`再び${`テンプレート文字列`}が使用できます。`}`;

このようになると、最初の`に対応する終わりの`がどれなのか正しく判定するのは困難である。${~}の部分をまとめて一つのトークンにしようにも、こちらも同様に{に対応する終わりの}がどれなのか正しく判定できない。このように、正規表現で先頭から順に切り出していく方法では、入れ子を正しく処理する必要があるものは対応が困難となる。なお、その他の波カッコ{~}や丸カッコ(~)の場合は、入れ子関係を特に気にしなくてもシンタックスハイライトを実現する上では支障とならないため、特に問題とはならない。

結論

シンタックスハイライト機能をゼロから作らなければいけない状況というのは通常は滅多に無いと思われるが、万が一そのような状況になった場合でも、一度作った経験があれば安心だろう。