2026, Jan 05 21:02
Почему LALR(1) в Lark ломается на директивных патчах и что делать
Разбираем в Lark, почему LALR(1) падает на директивных патчах: неоднозначности грамматики, конфликты лексера и рабочая альтернатива — линейный сканер/FSM.
Разбор аннотаций, похожих на патчи и встроенных в исходные файлы, кажется простым — пока производственная грамматика не сталкивается с ограничениями реальности. Типичный прием — обрамлять «find»-текст и его замену четко отделенными директивами, а затем проходить по файлу и применять правки. Но стоит отдать такой формат генератору парсеров, как всплывают тонкие неоднозначности и несовместимости лексера, приводящие к запутанным ошибкам — особенно при переходе с Earley на LALR(1). В этом материале на конкретном примере показывается, почему конфигурация LALR(1) падает, и предлагается прагматичная альтернатива, лучше подходящая для задачи.
Постановка задачи
Входной формат следует предсказуемой последовательности директив и содержимого. Инженеры фиксируют в репозитории текстовые блоки, ограниченные маркерами; вокруг них могут встречаться пробелы или «комментарий‑подобный» текст. Упрощенный пример:
//find_start asdf
a
//find_end
//replace_start
A
//replace_end
//find_start
b
//find_end
this should be ignored
//replace_start
B
//replace_end
//find_start
c
//find_end
//replace_start
C
C
//replace_end
Следующая грамматика Lark корректно разбирает эту структуру парсером Earley. Но с парсером LALR(1) она падает во время разбора с ошибкой «unexpected token».
Грамматика, демонстрирующая проблему
Вот грамматика с эквивалентной логикой и переименованными символами. Она сохраняет исходное поведение, но делает идентификаторы различимыми:
root: stanza+
stanza: skipzone? seek_chunk skipzone? swap_chunk skipzone?
seek_chunk: "//find_start" [note_text] payload "//find_end"
swap_chunk: "//replace_start" payload "//replace_end"
_EOL: /\n/+
ROW.-10: /. +/
ANNOT: /\s.+/
payload: (ROW _EOL)+
skipzone: (ROW _EOL)+
note_text: (ANNOT _EOL)
%import common.NEWLINE
%import common.WS
%ignore WS
%ignore NEWLINE
Попытка разобрать пример с LALR(1) приводит к такой ошибке (с именами, соответствующими исходному отчёту):
lark.exceptions.UnexpectedToken: Unexpected token Token('LINE', 'a') at line 3, column 1.
Expected one of:
* _NL
Previous tokens: [Token('LINE', 'asdf')]
Почему это ломается с LALR(1)
Здесь сходятся две разные проблемы. Во‑первых, грамматика неоднозначна: область пропуска может трактоваться как хвост одной секции или как начало следующей. Earley умеет жить с неоднозначностями, LALR(1) — нет. Обычно это проявилось бы как конфликт при построении парсера, но в данном случае сбой возникает уже на этапе разбора.
Непосредственная причина ошибки во время выполнения — обработка пробельных символов. Определения токенов конца строки и «псевдокомментариев» используют регулярные выражения, в которых участвуют перевод строки и пробелы, а в грамматике параллельно объявлены %ignore WS и %ignore NEWLINE. Если лексеру велено глобально игнорировать WS и NEWLINE, он не сможет одновременно распознавать токены, зависящие от этих символов — например, кастомные токены конца строки и комментариев. В итоге строки схлопываются в поток обобщённых текстовых токенов — ровно на это и намекает ошибка, когда сообщает о двух подряд токенах строки при отсутствии ожидаемого конца строки. Примечательно, что это противоречие формально должно задевать и Earley; почему тот же ввод проходит там без ошибки — неочевидно.
Более простой и надёжный подход
В этом сценарии полноценный парсер не нужен. Структура линейна и управляется директивами, поэтому прямой скан проще и меньше подвержен ошибкам. Ниже — логика одноразового прохода по файлу: строки выводятся или отбрасываются по маркерам, давая желаемый результат патча. Поведение повторяет описанный процесс, не привнося неоднозначностей грамматики и конфликтов по пробелам.
repeat_forever:
read_and_forward until line_begins_with("//find_start")
read_and_discard until line_begins_with("//find_end")
take_one_line_and_drop_it
ensure_next_line_begins_with("//replace_start")
read_and_forward until line_begins_with("//replace_end")
end_repeat
Обрабатывайте конец файла и ошибки в рамках этого цикла.
Почему это важно
Патч‑форматы, основанные на директивах, разумнее трактовать как поток, а не как язык. Попытки втиснуть их в LALR(1)-грамматики проявляют две хрупкости: неоднозначности на границах блоков и конфликты токенов лексера из‑за глобального игнорирования пробелов. Решение в стиле сканера проще сопровождать, лучше соответствует рабочей модели патчера и не страдает от чувствительности парсера к политике токенизации. Если требуется дополнительная сложность — например, параметризация и опции после //find_start — её можно наслоить поверх простого конечного автомата (FSM): разбирать только эти короткие фрагменты отдельной грамматикой, а внешний обход файла оставить конечным автоматом.
Заключительные рекомендации
Если без грамматики не обойтись, убедитесь, что политика обработки пробелов не противоречит определениям токенов, а соседние необязательные области не создают неоднозначности на границах блоков. Если инструмент можно выбирать свободно, отдайте предпочтение линейному сканированию для этого директивного патч‑формата, а парсер оставьте для небольших, изолированных фрагментов, которым он действительно нужен.