PLY (Python Lex-Yacc) を使う

2016年11月12日

はじめに

PLY (Python Lex-Yacc) を使って、簡単な書式を持つテキストの読み込みをしてみる。

環境

  • PLY 3.8
  • Windows 7 64 bit
  • Cygwin/MinGW w64
  • Anaconda3 4.1.1

目標

PLY (Python Lex-Yacc) を用いて、OpenFOAM の設定ファイル風のテキストの解釈を行ってみる。次のようなファイルを想定する。

/*
  comment2
 */
// comment1

dict
{
    key value;
    
    dict2
    {
       key2 value2;
    }
}

これは辞書 (dictionary) 形式と呼ばれており、辞書は "<辞書名> { ... }" の形で書く。辞書の中には "<キーワード> 値;" の形でキーワードとその値を書ける。セミコロンで区切る。辞書の中に辞書を書くこともできる。C++ 風のコメントを受け付ける。

このテキストを解釈できるようにする。

字句解析

字句解析には Lex モジュールを使う。実装例を示す。

dict_lex.py

# -*- encoding: utf-8 -*-

import ply.lex as lex


tokens = (
    'NAME',
    'NUMBER',
    'LBRACE',
    'RBRACE',
    'SEMI',
)

t_NAME = '[a-zA-Z][a-zA-Z0-9]*'
t_NUMBER = '[0-9.edED+-]+'
t_LBRACE = '{'
t_RBRACE = '}'
t_SEMI = ';'
t_ignore_COMMENT = '/\*[\s\S]*?\*/|//.*'
t_ignore = ' \t'


def t_newline(t):
    r'\n+'
    t.lexer.lineno += len(t.value)


def t_error(t):
    print("Illegal character '%s'" % t.value[0])
    t.lexer.skip(1)


lexer = lex.lex()


def lex_test():
    f = open('data.txt', 'r')
    data = f.read()
    f.close()

    lexer.input(data)

    while True:
        tok = lexer.token()
        if not tok: 
            break
        print(tok)


if __name__ == '__main__':
    lex_test()

データは次のようなものとする。

data.txt

// sample dictionary

dict
{
    key 1;

    dict2
    {
        key2 2;
    }
}

実行結果は次の通り。

$ python dict_lex.py
LexToken(NAME,'dict',3,22)
LexToken(LBRACE,'{',4,27)
LexToken(NAME,'key',5,33)
LexToken(NUMBER,'1',5,37)
LexToken(SEMI,';',5,38)
LexToken(NAME,'dict2',7,45)
LexToken(LBRACE,'{',8,55)
LexToken(NAME,'key2',9,65)
LexToken(NUMBER,'2',9,70)
LexToken(SEMI,';',9,71)
LexToken(RBRACE,'}',10,77)
LexToken(RBRACE,'}',11,79)

字句に適切に分けられているのが確認できる。

dict_sample.py を見てみよう。字句は tokens で定義される。

tokens = (
    'NAME',
    'NUMBER',
    'LBRACE',
    'RBRACE',
    'SEMI',
)

それぞれの具体的な定義は、次の部分で行われている。

t_NAME = '[a-zA-Z][a-zA-Z0-9]*'
t_NUMBER = '[0-9.edED+-]+'
t_LBRACE = '{'
t_RBRACE = '}'
t_SEMI = ';'

NAME, NUMBER の定義には、正規表現が用いられている。ここでは NAME は先頭文字がアルファベット、その後がアルファベットあるいは数字で構成される。記号も許すなら、正規表現を修正すればよい。

コメントの定義は次の通りである。

t_ignore_COMMENT = '/\*[\s\S]*?\*/|//.*'

"ignore_" を付ければ無視される。次も無視するものの指定である。

t_ignore = ' \t'

t_newline() では改行を検出して行をカウントしている (自動的にはカウントしてくれない)。t_error() はエラー処理である。

"lex.lex()" でオブジェクトを作成、"lexer.input(data)" でデータを取り込み、"lexer.token()" で字句を取り出している。

コメントについて、上記の方法による取扱いのでは、エラー処理のときに正しいエラー箇所の行番号を指定できない。コメントも含めた行番号を得たい場合は、改行のようにコメントでも行をカウントさせる必要がある。

def t_COMMENT(t):
    '/\*[\s\S]*?\*/|//.*'
    t.lexer.lineno += t.value.count('\n')

構文解析

構文解析には Yacc モジュールを使う。Lex で字句が得られているとする。辞書データは次のように定義できる。

  • 辞書ファイルは、辞書の要素群からなる。
  • 辞書の要素群は、辞書の要素 + 辞書の要素群、あるいは辞書の要素からなる。
  • 辞書の要素は、キーワード、あるいは辞書からなる。
  • キーワードは、名前 + 数値 + セミコロンからなる。
  • 辞書は、名前 + "{" + 辞書の要素群 + "}"、あるいは名前 + "{" + "}" からなる。

以上の構文解析を実装すると、次のようになる。

dict_yacc.py

# -*- encoding: utf-8 -*-

import ply.yacc as yacc

from dict_lex import tokens


def p_dictionary_file(p):
    'dictionary_file : elements'
    p[0] = p[1]


def p_elements(p):
    '''elements : element elements
                | element'''
    if len(p) == 2:
        p[0] = [p[1]]
    else:
        for v in p[2:]:
            p[0] = [p[1]] + v


def p_element(p):
    '''element : keyword
               | dictionary'''
    p[0] = p[1]


def p_keyword(p):
    '''keyword : NAME NUMBER SEMI'''
    p[0] = ('keyword', p[1], p[2])


def p_dictionary(p):
    '''dictionary : NAME LBRACE elements RBRACE
                  | NAME LBRACE RBRACE'''
    if p[3] != '}':
        p[0] = ('dictionary', p[1], p[3])
    else:
        p[0] = ('dictionary', p[1], '')


def p_error(p):
    print('Syntax error in input!')


def yacc_test():
    f = open('data.txt', 'r')
    data = f.read()
    f.close()

    parser = yacc.yacc()
    result = parser.parse(data)
    print('result: ', result)


if __name__ == '__main__':
    yacc_test()

実行結果は次の通り。

$ python dict_yacc.py
result:  [('dictionary', 'dict', [('keyword', 'key', '1'), ('dictionary', 'dict2
', [('keyword', 'key2', '2')])])]

読み取った内容をタプルによるリストの形に変換している。

dict_yacc.py の内容は以下の通りである。まず、字句の定義が必要なのでインポートしている。

from dict_lex import tokens

p_dicionary_file() は、"辞書ファイル" を定義している。

def p_dictionary_file(p):
    'dictionary_file : elements'
    p[0] = p[1]

コメントの部分で、"辞書ファイルは、辞書の要素群からなる" を表現している。p[0] は返り値を設定するところで、p[1], p[2] ... は構文の右辺にそれぞれ当てはまった具体的な値である。

def p_elements(p):
    '''elements : element elements
                | element'''
    if len(p) == 2:
        p[0] = [p[1]]
    else:
        for v in p[2:]:
            p[0] = [p[1]] + v

p_elements() では "辞書の要素群は、辞書の要素 + 辞書の要素群、あるいは辞書の要素からなる" を表現している。これは再帰的な表現で、"辞書の要素 + 辞書の要素群" では要するに「要素群は要素の集まりからなる」ということを表している。「あるいは辞書の要素」の部分は、要素群は 1 つの要素からなる場合の表現と、再帰的な要素群表現の停止条件になっている (これがないと要素群は無限の要素を要求する)。コメントの下は、要素群をリストにまとめる処理である。

以下同様である。

"yacc.yacc()" でパーサーを作り、"parser.parse(data)" でデータをパースして結果を取り出している。

エラー処理

シンタックスエラーのとき、エラー箇所を表示したい場合は、次のようにする。

def p_error(p):
    if p:
        print('Syntax error: %d: %r' % (p.lineno, p.value))
        exit()
    else:
        print('Syntax error: EOF')

p.lineno で行番号、p.value でトークンの値が得られる。ただし、コメントでの改行も行数にカウントしておかないと正しい行番号にならない。