2025, Nov 18 12:02
Надёжная грамматика Lark с необязательным финальным переводом строки
Как написать грамматику Lark, устойчивую к отсутствию финального перевода строки: разбор EOF и NEWLINE, избегание UnexpectedEOF, два надёжных подхода.
Разбор необязательных завершающих переводов строки в Lark может внезапно оказаться непростым. Отсутствие NL в конце файла меняет группировку последней строки и иногда даёт лишнее дерево там, где вы рассчитывали на два. Суть проблемы не в содержимом строки, а в том, как грамматика разрешает или запрещает границы строк в момент наступления EOF.
Минимальный пример, воспроизводящий проблему
Следующий скрипт показывает, как необязательный перевод строки в конце файла способен породить лишнее дерево в результате. Единственное отличие между «работает» и «ломается» — наличие финального NL.
from lark import Lark, Transformer, Token, Discard
RAW_INPUT = """__ 95 95 36 __ 95 __ 95 __
__ __ 95 36 32 __ __ __ __"""
SYNTAX = """
start : NEWLINE? map+
map : [coord coord*] NEWLINE?
coord : HEX | FILL
HEX : ("A".."F" | DIGIT)+
FILL : "__"
%import common.DIGIT
%import common.NEWLINE
%import common.WS_INLINE
%ignore WS_INLINE
"""
class TreeFold(Transformer):
def start(self, items: list) -> list:
return items
def NEWLINE(self, _):
return Discard
def coord(self, parts: list[Token]) -> str:
return parts[0].value
def run_parse(text: str):
engine = Lark(SYNTAX, start='start')
parsed = engine.parse(text)
[print(node) for node in TreeFold().transform(parsed)]
run_parse(RAW_INPUT)
Когда завершающий перевод строки есть, строки группируются как ожидается. Без него вторая строка может разделиться и превратиться в дополнительное дерево. Обязательный NEWLINE после каждой строки исправляет одну ситуацию, но ломается, если файл оканчивается без перевода строки, — падает с UnexpectedEOF.
Почему так происходит
Грамматика совмещает сразу две «необязательности»: строка может завершаться как NEWLINE?, а последняя — ещё и EOF. Когда доступны оба пути, парсер вынужден решать, съесть ли замыкающий NEWLINE или считать EOF естественным завершением правила. В зависимости от того, где именно наступает EOF, такая свобода дробит последнюю логическую «строку» на несколько деревьев. Конструкция [coord coord*] тоже создаёт два пути там, где хватает одного; выражение coord+ передаёт ту же идею яснее.
Надёжная грамматика, допускающая необязательный финальный NL
Чтобы корректно обрабатывать оба сценария — когда файл заканчивается переводом строки и когда нет — разделите понятия «строка с NL» и «строка, возможно, без NL» на отдельные правила и разрешите оба варианта в корневом правиле. Так вы не навязываете перевод строки, сохраняя при этом стабильную группировку.
from lark import Lark, Transformer, Token, Discard
EXAMPLES = [
# перевод строки в начале и в конце
"""
__ 95 95 36 __ 95 __ 95 __
__ __ 95 36 32 __ __ __ __
""",
# перевод строки в конце, но не в начале
"""__ 95 95 36 __ 95 __ 95 __
__ __ 95 36 32 __ __ __ __
""",
# перевод строки в начале, но не в конце
"""
__ 95 95 36 __ 95 __ 95 __
__ __ 95 36 32 __ __ __ __""",
# ни в начале, ни в конце перевода строки нет
"""__ 95 95 36 __ 95 __ 95 __
__ __ 95 36 32 __ __ __ __""",
# пустые строки повсюду
"""
__ 95 95 36 __ 95 __ 95 __
__ __ 95 36 32 __ __ __ __
"""
]
GRAMMAR_OK = """
start : NEWLINE? mapnl* (mapnl | map)
mapnl : coord+ NEWLINE
map : coord+
coord : HEX | FILL
HEX : ("A".."F" | DIGIT)+
FILL : "__"
%import common.DIGIT
%import common.NEWLINE
%import common.WS_INLINE
%ignore WS_INLINE
"""
class NodeReducer(Transformer):
def start(self, nodes: list) -> list:
return nodes
def NEWLINE(self, _):
return Discard
def coord(self, toks: list[Token]) -> str:
return toks[0].value
def exec_parse(text: str):
parser = Lark(GRAMMAR_OK, start='start')
tree = parser.parse(text)
[print(node) for node in NodeReducer().transform(tree)]
for sample in EXAMPLES:
exec_parse(sample)
print()
Эта версия стабильно группирует полные строки — независимо от того, начинается ли ввод с перевода строки, оканчивается ли им, содержит ли пустые строки или вовсе не имеет завершающего NL.
Равнозначная альтернатива
Если вам ближе иной верхнеуровневый вариант, опишите последнюю строку отдельным правилом с необязательным завершающим переводом строки, а для остальных переиспользуйте форму «строка с обязательным NL».
GRAMMAR_ALT = """
start : NEWLINE? map* mapoptnl
map : coord+ NEWLINE
mapoptnl : coord+ NEWLINE?
coord : HEX | FILL
HEX : ("A".."F" | DIGIT)+
FILL : "__"
%import common.DIGIT
%import common.NEWLINE
%import common.WS_INLINE
%ignore WS_INLINE
"""
Оба подхода дают одинаковую структуру, различаются лишь названиями правил.
Почему этот нюанс важен
Опора на NEWLINE? в правилах строки и на EOF как неявный терминатор приводит к неоднозначным концовкам и «расщеплению» последней записи. Это проявляется как лишние деревья или, если сделать NEWLINE обязательным, как UnexpectedEOF, если в файле нет завершающего перевода строки. Практический вывод совпадает с распространённой рекомендацией: принудительно добавлять перевод строки в конец ввода перед разбором — «лечит» проблему, но остаётся обходным манёвром, а не архитектурным решением. Как метко заметил один из практиков, обработка EOF — постоянная боль, и типичный совет — добавить перевод строки; приятно понимать, что это не ошибка пользователя.
Предложенный разработчиком «фикс» — «перед разбором принудительно добавить перевод строки в конец ввода». Ваш ответ подтвердил, что дело было не в моей ошибке.
Заключение и рекомендации
Если грамматика должна принимать данные как с завершающим переводом строки, так и без него, явно опишите случай «последняя строка» вместо того, чтобы повсюду делать NEWLINE необязательным. Отдавайте предпочтение coord+ вместо [coord coord*], чтобы не плодить лишние пути. Если вы контролируете входной конвейер и нужен быстрый обходной путь, добавление финального перевода строки уместно, но закладывать эту опцию в грамматику надёжнее — так деревья разбора остаются стабильными вне зависимости от того, чем завершается файл.