年が明けたら 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
のうしろ)に空白文字があること(この記事では見えないけど)だ。だけどこれは文法上は問題ないはずに思える。実際期待通りの結果になることもあるんだし。
というわけで、このままでは安心して使えない。どうしようか。