読者です 読者をやめる 読者になる 読者になる

M12i.

学術書・マンガ・アニメ・映画の消費活動とプログラミングについて

Pygmentsドキュメント「じぶんで字句解析器をつくる」

Python

f:id:m12i:20140915223417p:plain

前回の記事でも触れている構文強調ライブラリPygmentsのドキュメント"Write your own lexer"の翻訳を不完全ながら載せておきます。

そもそもはこのドキュメントでも主な説明対象となっているRegexLexerをJavaにポーティングするために、その仕様理解の助けとして訳出していたものです。

原文はいくつかのセクションからなっていますがここに載せるのは"Advanced state tricks"までです("Using multiple lexers"以降は切り捨て)。また例によって翻訳の精度には期待をしないでください。

    * * *

じぶんで字句解析器をつくる

あなたが使いたいプログラミング言語の"字句解析器"〔訳注:以後、lexerとします〕がPygmentsパッケージに見つからない場合、じぶんでlexerを作成しPygmentsを拡張することが簡単にできます。

必要なものはすべてpygments.lexerモジュールのなかにあります。"API documentation"にあるとおり、lexerはいくつかのキーワード引数(lexerオプション)で初期化されるクラスです。このクラスは、文字列もしくは読み取るべきデータを含むユニコード・オブジェクトを受け取る.get_tokens_unprocessed()メソッドを持ちます。

.get_tokens_unprocessed()]メソッド(index, token, value)というかたちのタプルを包含するイテレータもしくはイテラブルを返さなくてはなりません。サブクラス化して利用可能なlexerのベースクラスがいくつもあるので、通常、このメソッドをじぶんで実装する必要はありません。

RegexLexer

とてもパワフルな(しかしとても簡単に使える)lexerはRegexLexerです。このlexerベース・クラスは、異なる"状態"〔訳注:以後、stateとします〕ごとに用意された正規表現により字句解析ルールを定義します。

stateは正規表現のグループであり、これらの正規表現は現在位置の入力文字列に対して照合されます。もし正規表現のいずれかがマッチしたら、対応するアクションが実行されます(通常、特定の型のトークンが生み出されます)。そして現在位置は直前に正規表現がマッチした位置のあとに移動して、現在のstateの正規表現の最初のものを使って照合処理が再開されます。

lexerのstateはスタックで保持されます: 新しいstateに入るたび、その新しいstateの情報がスタックの上に積まれます。もっとも単純なlexer(DiffLexerのような)では必要なstateの数は1つだけです。

それぞれのstateは(`正規表現`, `アクション`,`新しいstate`)というかたちのタプルのリストとして定義されます。タプルの最後の要素は省略可能です。もっとも基本的なかたちでは、"アクション"はトークンの型( Name.Builtinのような)になります。この場合タプルの意味するところは: もし"正規表現"がマッチしたら、マッチした文字列と指定された"型"の情報とともにトークンを生成し、"新しいstate"をスタックの上に積むこと。この反対に、"新しいstate"の指定が'#pop'の場合、現在のstateはスタックのてっぺんから削除されます。(2つ以上のstateを削除するには、'#pop:2'などと設定します。)'#push'は現在のstateをスタックに積むのと同じ意味になります。

以下の例は、Pygmentsに組み込みのDiffLexerです。namealiasesそしてfilenamesという追加の属性が含まれている点に注意してください。これらはlexerに必須のものではありません。これらの属性は組み込みのlexerを検索する機能のために用意されているものです。

    from pygments.lexer import RegexLexer
    from pygments.token import *

    class DiffLexer(RegexLexer):
        name = 'Diff'
        aliases = ['diff']
        filenames = ['*.diff']

        tokens = {
            'root': [
                (r' .*\n', Text),
                (r'\+.*\n', Generic.Inserted),
                (r'-.*\n', Generic.Deleted),
                (r'@.*\n', Generic.Subheading),
                (r'Index.*\n', Generic.Heading),
                (r'=.*\n', Generic.Heading),
                (r'.*\n', Text),
            ]
        }

ご覧の通り、このlexerはstateを1つしか使用していません。lexerはテキストの読み取りを開始すると、まずはじめに現在の文字は空白文字であるかどうかをチェックします。もしこれが正であれば、lexerはLF文字に達するまでの文字列を読み取り、読み取ったデータをTextトークンとして返します。

この最初のルールに合致しなかった場合、lexerは現在の文字が+記号かどうかをチェックします。あとはこの繰り返しです。

いずれのルールも現在位置に合致しなかった場合、現在の文字は読み取りエラーを示すErrorトークンと見なされます。そして読み取り位置は1文字分前進させられます。

新しいlexerを追加して検証する

じぶんのlexerをPygmentsに認識させるには、次の手順を実施する必要があります:

まず最初に、Pygmentsソースコードが格納されたディレクトに移動します:

$ cd .../pygments-main

次に、あなたのlexerがモジュールの外側から認識されるようにします。pygments.lexers配下のすべてのモジュールは、 __all__属性を定義しています。例えば、" other.py"は次のように:

    __all__ = ['BrainfuckLexer', 'BefungeLexer', ...]

あなたのlexerクラスの名前をリストに追加するだけです。

最後に、lexerマッピングの再構築によってあなたのlexerは他のプログラムから認識できるようになります:

$ make mapfiles

新しいlexerをためすには、適切な拡張子を持ったexampleファイルを" tests/examplefiles"のなかに格納してください。例えば、 DiffLexerをためすには、サンプルのdiff出力結果を含む" tests/examplefiles/example.diff"ファイルを追加します。

すると、pygmentize コマンドでexampleファイルからHTMLファイルをつくることができるようになります:

$ ./pygmentize -O full -f html -o /tmp/example.html tests/examplefiles/example.diff

先頭に" ./"をつけることで、現在のディレクトリ配下にある" pygmentize"が実行されるよう明確に指定していることに注意してください。これによりあなたが加えた変更が出力結果に反映されることが確かなものになります。
こうしないと、すでにインストール済みの、あなたがつくったlexerを含まない、変更を加える前のバージョンが、システムの検索パス($PATH)上で検索され実行されてしまう可能性があります。

結果を見るには、ブラウザで" /tmp/example.html"を開きます。

期待通りに出力されたら、完全なテスト・スイートを実行します:

$ make test

正規表現フラグ

正規表現フラグはパターン内で指定する(r'(?x)foo bar')か、lexerクラスにflagsという属性を追加することで、定義することができます。もし属性が定義されていなければ、デフォルトはre.MULTILINEになります。正規表現フラグに関するより詳細な情報についてはPythonのリファレンスにある" regular expressions"のページを参照してください。

複数トークンを一括で読み取る

より複雑なlexerをご紹介しましょう。このlexerはINIファイルを構文強調するものです。INIファイルはセクション、コメント、そしてキー=値のペアからなります:

    from pygments.lexer import RegexLexer, bygroups
    from pygments.token import *

    class IniLexer(RegexLexer):
        name = 'INI'
        aliases = ['ini', 'cfg']
        filenames = ['*.ini', '*.cfg']

        tokens = {
            'root': [
                (r'\s+', Text),
                (r';.*?$', Comment),
                (r'\[.*?\]$', Keyword),
                (r'(.*?)(\s*)(=)(\s*)(.*?)$',
                 bygroups(Name.Attribute, Text, Operator, Text, String))
            ]
        }

このlexerは最初にホワイトスペース、続いてコメント、セクション名を探します。そして、その後で=記号と空白文字で区切られたキーと値のペアと思われる行を探します。

bygroupsヘルパー関数は、正規表現パターンを構成する各グループのそれぞれから異なる型のトークンを生成させます。はじめに Name.Attributeトークン、次に0個以上の空白文字のためのTextトークン、そのあと等号のための Operatorトークンが続きます。さらに0個以上の空白文字のためのTextトークン。そして最後に、行内の残りの文字列のためのStringトークンです。

この bygroupsを使う場合、トークンと対応付けされるのは捕捉グループ (...)の中に入る文字列であり、しかも入れ子の捕捉グループが存在してはならない点に注意してください。もしどうしてもトークンと対応しないグループが必要である場合は、次の構文で定義される非・捕捉グループを使用してください:r'(?:…)'(開始丸括弧のあとの'?:'が重要です)。

出力対象ではないが後方参照のため捕捉グループを使用せざるを得ない場合(例:r'(<(foo|bar)>)(.*?)()')、bygroupsヘルパー関数にNoneを渡すことで、当該グループを出力対象でなくすことができます。

状態遷移

ご察しのとおり多くのlexerは複数のstateの定義を必要とします。例えば、複数行のコメントの入れ子表記ができるプログラミング言語があります。これは再帰的パターンとなるため、正規表現だけで字句解析することは不可能です。

この問題の解決策は以下のようになります:

    from pygments.lexer import RegexLexer
    from pygments.token import *

    class ExampleLexer(RegexLexer):
        name = 'Example Lexer with states'

        tokens = {
            'root': [
                (r'[^/]+', Text),
                (r'/\*', Comment.Multiline, 'comment'),
                (r'//.*?$', Comment.Singleline),
                (r'/', Text)
            ],
            'comment': [
                (r'[^*/]', Comment.Multiline),
                (r'/\*', Comment.Multiline, '#push'),
                (r'\*/', Comment.Multiline, '#pop'),
                (r'[*/]', Comment.Multiline)
            ]
        }

字句解析は'root'stateから始まります。〔そしてまずは〕スラッシュ('/')が登場するまでのできるだけ多くの文字にマッチします。スラッシュの次の文字がアスタリスク'*')の場合、 RegexLexerはこの2文字を Comment.Multilineとして印付けした上で出力ストリームに送ります。そして'comment' stateで定義されたルールにしたがって読み取りを続けます。

スラッシュのあとがアスタリスクでなかった場合〔複数行コメントの開始ではなかった場合〕、 RegexLexerは単一行コメント(2つのスラッシュが続く)であるかどうかのチェックを行います。単一行でもなかった場合、それは〔コメントではない単なる〕単一のスラッシュにちがいありません(この単一のスラッシュのための正規表現の定義は必須です。これがない場合、スラッシュはエラー・トークンとして印付けされてしまうでしょう)。

'comment'stateの中には先ほどと同じものが登場します。アスタリスクもしくはスラッシュが見つかるまで読み取ること。複数行コメントの開始の場合、'comment'stateをスタックに積み、しかるのち読み取りを再開すること。こうしてまたしても'comment' stateのなかに。一方、複数行コメントの終了チェックは新しいルールです。もしチェック結果がイエスなら、スタックのてっぺんからstateを1つ取り除きます。

注意:空っぽのスタックから要素を取り除こうとするとIndexErrorが発生します。(これを防止する簡単な方法は、'root'state内で'#pop'を使わないことです。)

RegexLexerが改行文字に到達してそれをエラー・トークンとして印し付けると、スタックは空っぽにされ、lexerは'root'stateから読み取りを再開します。この動作は、まちがいの多い入力内容──例えば終了を示す引用符が見つからない単一行文字列表現など──に対して、エラー耐性の高い強調表示のしくみをつくるのに便利です。〔訳注:この改行文字によりスタックのリセットを行うロジックについては補足と訂正が必要でしょう。このロジックが発動する条件をより正確に言うと「現在アクティブなstateに定義されたいずれのルールも適合せず、かつ現在読み取り位置が改行文字である」となります。したがってもし改行文字も含めてマッチするようなルールが存在すればこのリセット・ロジックは発動しません。加えて、少なくとも現在のRegexLexerの実装では改行文字はErrorトークンではなく、Textトークンとして処理されます。しかし「現在アクティブなstateに定義されたいずれのルールも適合せず、かつ現在読み取り位置が改行文字でない」場合にはErrorトークンとして処理されます。そして読み取り位置が+1インクリメントされ、再度現在アクティブなstateのルールとの照合作業が再開されます。エラーが繰り返されればいずれは入力文字列の終端に到達して処理が終わることになります。ちなみに原文で改行文字はnewlineとなっていますが、これはRegexLexerのスーパー・クラスであるLexerの実装で、入力文字列に含まれるCRやCRLFはすべて事前にLFに置換されるという事実に由来しています。〕

高度な状態制御のトリック

stateに関してはまだ述べていないことがあります:

その1。ルールの3つめのパラメータとして、単純な文字列ではなく文字列を要素とするタプルを指定することで、複数のstateをスタックに積むことができます。例えば、あるルールを次に示すようなディレクティブを内包するコメントにマッチさせたいとき:

/* <processing directive>    rest of comment */

次のようなルール指定ができます:

      tokens = {
          'root': [
              (r'/\* <', Comment, ('comment', 'directive')),
              ...
          ],
          'directive': [
              (r'[^>]*', Comment.Directive),
              (r'>', Comment, '#pop'),
          ],
          'comment': [
              (r'[^*]+', Comment),
              (r'\*/', Comment, '#pop'),
              (r'\*', Comment),
          ]
      }

このルールが前述のコメントのサンプルに適用されると、まず'comment''directive'という2つのstateがスタックに積まれ、'>'が登場するまでがディレクティブとして、その後'*/'が登場するまでがコメントとして処理されます。こうして2つのstateがスタックから取り除かれると、解析処理は'root'状態で再開されます。

タプルには'#push''#pop'も含めることができます(ただし '#pop:n'は例外です)。〔訳注: '#pop:n'の代わりに複数回 '#pop'を使えばいいわけですから、この制約は問題にならないでしょう。〕

その2。他のstate定義からルール・セットをインクルードできます。これは pygments.lexerパッケージの includeを使って実現されています:

      from pygments.lexer import RegexLexer, bygroups, include
      from pygments.token import *

      class ExampleLexer(RegexLexer):
          tokens = {
              'comments': [
                  (r'/\*.*?\*/', Comment),
                  (r'//.*?\n', Comment),
              ],
              'root': [
                  include('comments'),
                  (r'(function )(\w+)( {)',
                   bygroups(Keyword, Name, Keyword), 'function'),
                  (r'.', Text),
              ],
              'function': [
                  (r'[^}/]+', Text),
                  include('comments'),
                  (r'/', Text),
                  (r'}', Keyword, '#pop'),
              ]
          }

これは関数とコメントからなる言語のための架空のlexerです。コメントはトップレベルと関数内のいずれにおいても使用可能なので、いずれのstateにもコメントのルールが必要になります。ご覧の通り、 includeヘルパー関数は同じルールを繰り返し記述する事態を回避させてくれます(この例では、コメントのstateそのものへの遷移は決して行われません。〔そのルール・セットが〕'root''function'という2つの状態の中にインクルードされているだけです)。

その3。あるstateを既存の別の状態と合成したい場合があります。 pygments.lexerパッケージの combineヘルパー関数を使えばこれが実現できます。

新規にstateを定義するのではなく、ルールの3つめのパラメータとして combined('state1', 'state2')というふうに記述すると、新しい匿名の状態が生成されます。そしてこのルールが入力文字列にマッチすると、字句解析処理はこの合成されたstateに遷移します。

この機能は頻繁に使用されるものではないでしょうが、場合によっては役に立つ仕組みです。例えば PythonLexerが文字列リテラルを処理する場合がこれにあたります。

その4。字句解析処理を異なるstateから開始させたい場合、 get_tokens_unprocessed()メソッドオーバーロードしてスタックを変更することで対応できます:

      from pygments.lexer import RegexLexer

      class MyLexer(RegexLexer):
          tokens = {...}

          def get_tokens_unprocessed(self, text):
              stack = ['root', 'otherstate']
              for item in RegexLexer.get_tokens_unprocessed(text, stack):
                  yield item

いくつかのlexer実装がこの機能を使っていますが、例えばPhpLexerの場合、入力文字列の先頭のプリ・プロセッサ・コメント'<?php'を必須ではなくすために使用しています。実在しない値をスタックに設定することで、lexerを容易にクラッシュさせられる点に注意をしましょう。そしてまた'root'をスタックから取り除くとおかしなエラーに遭遇することになります。

その5。ルール・セットの末尾に空っぽの正規表現パターンと '#pop'を伴うルールを用意しておくと、明示的な終了マーカの存在しないstateから離脱するのに使用できます。

    * * *

原文ではこのあと"Using multiple lexers"その他のセクションが続くのですが、とりあえず個人的な必要はここまでの内容で満たせてしまったので、訳出はここまでです。