年が明けたら Lark の 1.0.0 がリリースされていたのに気が付いたので、以前作ったコマンド形式の入力ファイルをパースするのを、再度やってみた。フレームワーク的なパッケージにまとめて再利用できるようにしてみようと思う……んだけど、どうも挙動がおかしい。
要点を先に書くと、書いたパーサも入力ファイルも同じなのに、実行するたびに結果が異なる。原因はわからない。パーサの書き方が悪いのか、Lark のバグなのか。
順を追って説明する。
コマンド形式の入力ファイル
プログラムには何らかの入力が必要で、入力ファイルにも何らかのフォーマットが必要だ。コマンド形式というのは、入力データの内部表現を、ひとつずつコマンドを実行しながら組み立てようというものだ。この記事で扱うのは、そのフレームワーク的なライブラリで、ファイルからの入力である文字列を、コマンド(と引数)の列にパースするもの。
サンプルとして次のような入力ファイルを使う。
TITLE "example" ADD 1 2 MUL 3 // 5 7 8 9 // this line is comment. REPEAT "Hello!" 5 // this is comment too. JOIN "Hello," "world!" // in-line comment. ADD-2 5 .7e03 NO-ARG-COMMAND COMMAND-WITH-KEYWORD KW1 1 KW2 2 KW3 on KW4 off
文法
全体を「スクリプト」と呼ぶ。文法は次の通り。
- 「スクリプト」は1つ以上の「ステートメント」からなる
- 「ステートメント」は「コマンド」と0個以上の「引数」からなり、空白文字で区切られ、改行文字で終わる
- ただし、空白文字で始まる行は前の行の続きとみなす。なので「コマンド」は必ず行頭からはじまる
- 「引数」には数値、文字列、キーワード、真偽値の4種類がある
//
から行末まではコメント
「引数」について補足しておく。数値は文字通りの数値だけど、整数/実数の区別はなし。文字列はダブルークォートで囲む。真偽値は true
/ false
、yes
/ no
、on
/ off
のいずれか(すべて小文字)。
で、キーワードが今日の主題。「引数」のリストの中で、次の引数が何のデータかを示す目印に使う。要するに Python のメソッドのキーワード引数のような使い方だ。大文字のラテン文字と数字からなり、行頭には来ないことでコマンドと区別できる。実装としては Ruby なら Symbol
をつかうところだけど、Python にはそういうのがないので(ないよね?)、Keyword
クラスを定義した。
文法ファイルは次のようになった。
?script : statement+ statement : line continued* line : command _WS_INLINE arglist _WS_INLINE? _NL | command _WS_INLINE? _NL continued : _INDENT arglist _WS_INLINE? _NL command : CMDNAME CMDNAME : UCASE_LETTER ("-"|UCASE_LETTER|DIGIT)* arglist : arg | arglist _WS_INLINE arg arg : number | string | keyword | boolean number : SIGNED_NUMBER string : ESCAPED_STRING keyword : KWORD KWORD : UCASE_LETTER (UCASE_LETTER|DIGIT)* boolean : true | false true : "true" | "yes" | "on" false : "false" | "no" | "off" _WS_INLINE : WS_INLINE _NL : NEWLINE _INDENT : WS_INLINE %import common.UCASE_LETTER %import common.DIGIT %import common.SIGNED_NUMBER %import common.ESCAPED_STRING %import common.NEWLINE %import common.WS_INLINE %import common.CPP_COMMENT COMMENT : WS_INLINE? CPP_COMMENT NEWLINE COMMENT_INLINE : WS_INLINE? CPP_COMMENT %ignore COMMENT %ignore COMMENT_INLINE
パーサ
from lark import Lark from lark.exceptions import UnexpectedInput from lark.visitors import Interpreter class Parser(): def __init__(self): with open('grammer.lark', 'r') as f: grammer = f.read() self.parser = Lark(grammer, start='script') def parse(self, input_data): try: tree = self.parser.parse(input_data) except UnexpectedInput as e: context = e.get_context(input_data) print(f'Syntax error: line = {e.line} column = {e.column}\n') print(context) exit(1) script = ScriptInterpreter().visit(tree) return script class ScriptInterpreter(Interpreter): def script(self, tree): return [self.visit(c) for c in tree.children] def statement(self, tree): (cmd, args1) = self.visit(tree.children[0]) args2 = flatten([ self.visit(a) for a in tree.children[1:] ]) return (cmd, args1 + args2) def line(self, tree): cmd = self.visit(tree.children[0]) if len(tree.children) > 1: args = flatten(self.visit(tree.children[1])) else: args = [] return (cmd, args) def continued(self, tree): return self.visit(tree.children[0]) def command(self, tree): return tree.children[0] def arglist(self, tree): args = [ self.visit(a) for a in tree.children ] return args def arg(self, tree): return self.visit(tree.children[0]) def number(self, tree): return float(tree.children[0]) def string(self, tree): return tree.children[0].strip('"') def keyword(self, tree): return Keyword(str(tree.children[0])) def boolean(self, tree): return self.visit(tree.children[0]) def true(self, tree): return True def false(self, tree): return False class Keyword(): def __init__(self, val): self.val = val def __str__(self): return f'Keyword<{self.val}>' def __repr__(self): return f'Keyword<{self.val}>' def flatten(lis): result = [] for elem in lis: if isinstance(elem, list): result += flatten(elem) else: result.append(elem) return result
テスト用のスクリプトと実行結果
テスト用なので、入力データ(の内部表現)を組み立てる代わりにコマンドと引数リストを出力する。
from parsers import Parser import sys def main(): parser = Parser() with open(sys.argv[1], 'r') as f: input_data = f.read() script = parser.parse(input_data) print('SCRIPT') for (cmd, args) in script: print(' COMMAND: ' + cmd) print(' ARGS: ' + repr(args)) main()
これを実行すると次のようになる。
takatoh@sofa: inputscriptparser-sample > python main.py example.dat SCRIPT COMMAND: TITLE ARGS: ['example'] COMMAND: ADD ARGS: [1.0, 2.0] COMMAND: MUL ARGS: [3.0, 7.0, 8.0, 9.0] COMMAND: REPEAT ARGS: ['Hello!', 5.0] COMMAND: JOIN ARGS: ['Hello,', 'world!'] COMMAND: ADD-2 ARGS: [5.0, 700.0] COMMAND: NO-ARG-COMMAND ARGS: [] COMMAND: COMMAND-WITH-KEYWORD ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]
これは期待通り。ところが、何度か実行を続けると、時々次のような結果になる。
takatoh@sofa: inputscriptparser-sample > python main.py example.dat SCRIPT COMMAND: TITLE ARGS: ['example'] COMMAND: ADD ARGS: [1.0, 2.0] COMMAND: MUL ARGS: [3.0, 7.0, 8.0, 9.0] COMMAND: REPEAT ARGS: ['Hello!', 5.0, Keyword<JOIN>, 'Hello,', 'world!'] COMMAND: ADD-2 ARGS: [5.0, 700.0] COMMAND: NO-ARG-COMMAND ARGS: [] COMMAND: COMMAND-WITH-KEYWORD ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]
コマンドであるべき JOIN
が、REPEAT
コマンドの引数リストの中に、JOIN
というキーワードとして含まれてしまっている(JOIN
に続く引数もろとも)。
最初に書いたとおり、文法ファイルも入力ファイルも、パーサも何も変えてない。なのに実行するたびに、JOIN
だけコマンドになったりキーワードになったりする。
今のところ全くの原因不明。
気になるところといえば、入力ファイルの JOIN
コマンドの前の行が空白文字とコメントであること。さらにもう一つ前の行の行末(5
のうしろ)に空白文字があること(この記事では見えないけど)だ。だけどこれは文法上は問題ないはずに思える。実際期待通りの結果になることもあるんだし。
というわけで、このままでは安心して使えない。どうしようか。