Мета-грамматика для PEG парсера

Oct 30, 2019 06:36 · 1936 words · 10 minute read python peg

Оригинал: ‘A Meta-Grammar for PEG Parsers’ by Guido van Rossum

На этой неделе мы делаем генератор парсеров «самостоятельным», то есть он будет генерировать свой собственный парсер.

Содержание серии статей о PEG-парсере в Python

Итак, у нас уже есть генератор парсера, часть которого является парсером грамматики. Мы могли бы назвать это мета-парсером. Мета-парсер работает аналогично сгенерированным: GrammarParser наследуется от Parser и использует тот же механизм mark() / reset() / hope(). Тем не менее, там всё это было написано вручную. Но правильно ли это?

При проектировании компилятора принято, чтобы компилятор был написан на языке, который он компилирует. Я с любовью вспоминаю, что компилятор Pascal, который я использовал, когда впервые научился программировать, был написан на самом Pascal, GCC написан на C, а компилятор Rust написан на Rust.

Как это сделать? В самом начале реализовать компилятор для подмножества или более ранней версии языка на другом языке. (Напомню, что первоначальный компилятор Pascal был написан на FORTRAN!) Затем новый компилятор пишется на целевом языке и компилируется с помощью bootstrap-компилятора, реализованного в самом начале. Как только новый компилятор начинает работать достаточно хорошо, bootstrap-компилятор удаляется, и каждая следующая версия языка или компилятора ограничивается тем, что может быть скомпилировано с помощью предыдущей версии компилятора.

Давайте сделаем это для нашего мета-парсера. Мы напишем грамматику для грамматик (мета-грамматику), а затем сгенерируем новый мета-парсер из этого. К счастью, я планировал этот ход с самого начала, так что это будет довольно просто. Экшены, которые мы добавили в предыдущем эпизоде, являются важным компонентом, потому что нам не нужно менять генератор, так что нам необходимо создать совместимую структуру данных.

Вот упрощенная версия метаграммы без экшенов:

start: rules ENDMARKER
rules: rule rules | rule
rule: NAME ":" alts NEWLINE
alts: alt "|" alts | alt
alt: items
items: item items | item
item: NAME | STRING

Я покажу как добавить экшены снизу вверх. Вспомните из части 3, что существуют объекты Rule, которые имеют атрибуты name и alts. Изначально alts представлял собой просто список списков строк (внешний список для альтернатив и внутренний для каждого элемента альтернативы), но для реализации экшенов я изменил его так, чтобы альтернативы представлялись объектами Alt с атрибутами items и action. Элементы по-прежнему представлены в виде простых строк. Для item мы получаем:

item: NAME { name.string } | STRING { string.string }

Это требует небольшого пояснения: когда анализатор обрабатывает токен, он возвращает объект TokenInfo, который имеет атрибуты type, string и другие. Мы не хотим, чтобы генератор имел дело с объектами TokenInfo, поэтому экшены здесь извлекают строку из токена. Обратите внимание, что для всех заглавных токенов, таких как NAME, сгенерированный парсер использует строчную версию (здесь name) в качестве имени переменной.

Далее идут items, которые должны возвращать список строк:

items: item items { [item] + items } | item { [item] }

Здесь я использую праворекурсивные правила, поэтому мы не зависим от обработки левой рекурсии, добавленной в части 5. (Почему бы и нет? Всегда хорошо держать вещи максимально простыми, а эта грамматика не сильно выиграет от изменения под левую рекурсию) Обратите внимание, что один item перечислен, но рекурсивно items - нет, так как он уже является списком.

Правило alt для создания объекта Alt:

alt: items { Alt(items) }

Я опущу экшены для rules и start, так как они определяются подобным образом.

Однако, есть два открытых вопроса. Во-первых, как найти определение классов Rule и Alt? Для этого нам нужно добавить несколько операторов import в сгенерированный код. Простейшим способом было бы передать флаг генератору, который говорит «это мета-грамматика», и позволить генератору вставить дополнительный import в начало сгенерированной программы. Но теперь, когда у нас есть экшены, многие другие парсеры также захотят настроить свой импорт, так почему бы нам не посмотреть, можем ли мы реализовать более общий подход.

Есть много способов реализации. Простой и общий механизм состоит в том, чтобы добавить раздел «определения переменных» в верхней части грамматики и позволить генератору использовать эти переменные для управления различными аспектами сгенерированного кода. Я решил использовать символ @, чтобы начать определение переменной, за которым следуют имя переменной (NAME) и значение (STRING). Например, мы можем поместить следующий блок кода в верхнюю часть мета-грамматики:

@subheader "from grammar import Rule, Alt"

Генератор парсера напечатает значение переменной subheader после стандартного импорта, который добавляется по умолчанию (например, для импорта memoize). Если вы хотите несколько элементов import, вы можете использовать строку с тройными кавычками, например,

@subheader """
from token import OP
from grammar import Rule, Alt
"""

Это легко добавить к мета-грамматике: мы разобьём правило start на следующие:

start: metas rules ENDMARKER | rules ENDMARKER
metas: meta metas | meta
meta: "@" NAME STRING NEWLINE

(Я не могу вспомнить, почему я назвал это «мета», но это имя я выбрал, когда писал код, и я буду придерживаться его. :-)

Мы должны добавить это и к bootstrap-метапарсеру. Теперь, когда грамматика - это не просто список правил, давайте добавим объект грамматики с атрибутами metas и rules. Мы можем задать следующие экшены:

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
metas: meta metas { [meta] + metas }
     | meta { [meta] }
meta: "@" NAME STRING { (name.string, eval(string.string)) }

(Обратите внимание, что meta возвращает кортеж; а также, что он использует eval() для обработки строковых кавычек.)

Я не упоминал реализацию экшенов в правилах для alt! Причина в том, что они получаются немного неряшливыми. Но дальше откладывать смысла нет, так что вот:

alt: items action { Alt(items, action) }
   | items { Alt(items, None) }
action: "{" stuffs "}" { stuffs }
stuffs: stuff stuffs { stuff + " " + stuffs }
      | stuff { stuff }
stuff: "{" stuffs "}" { "{" + stuffs + "}" }
     | NAME { name.string }
     | NUMBER { number.string }
     | STRING { string.string }
     | OP { None if op.string in ("{", "}") else op.string }

Грязь в определении вызвана моим желанием сделать допустимым произвольный код на Python между фигурными скобками экшена, в том числе и вложенный в ещё одни фигурные скобки. Для этой цели мы используем специальный токен OP, который генерирует наш токенизатор для всех знаков препинания, распознаваемых Python (возвращая один токен с типом OP для многосимвольных операторов, таких как <= или **). Единственные другие токены, которые могут встречаться в выражениях Python - это имена, числа и строки. Таким образом, код между внешними фигурными скобками экшена, казалось бы, может быть выражен через повторения NAME | NUMBER | STRING | OP.

Увы, это не сработает, потому что OP также соответствует фигурным скобкам, и, поскольку анализатор PEG всегда жадный, это захватит закрывающую скобку, и мы никогда не увидим конца экшена. Поэтому мы добавляем небольшую подстройку, позволяя экшену выбросить ошибку выбора альтернативы, возвращая None. Я не знаю, является ли это стандартным явлением в других PEG-парсерах - я придумал это на месте, когда мне пришлось решить проблему распознавания закрывающей скобки (даже без вложенных пар). Кажется, что это работает хорошо, и я думаю, что это вписывается в общую философию PEG-анализа. Это можно рассматривать как особую форму предвидения (о которой я расскажу ниже).

Используя этот небольшой хак, мы можем сделать так, чтобы сравнение на OP сваливалось на фигурной скобке. Тогда сопоставление по stuff и action станет возможным.

С этими вещами мета-грамматика может быть проанализирована bootstrap-метапарсером, и генератор может превратить её в новый мета-парсер, который может анализировать себя. И, что важно, новый мета-парсер всё ещё может анализировать ту же мета-грамматику. Если мы скомпилируем мета-грамматику с новым мета-компилятором, результат будет таким же: это доказывает, что сгенерированный мета-парсер работает правильно.

Вот полная мета-грамматика с экшенами. Он может проанализировать себя, так как умеет объединять длинные строки:

@subheader """
from grammar import Grammar, Rule, Alt
from token import OP
"""

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
metas: meta metas { [meta] + metas }
     | meta { [meta] }
meta: "@" NAME STRING NEWLINE { (name.string, eval(string.string)) }
rules: rule rules { [rule] + rules }
     | rule { [rule] }
rule: NAME ":" alts NEWLINE { Rule(name.string, alts) }
alts: alt "|" alts { [alt] + alts }
    | alt { [alt] }
alt: items action { Alt(items, action) }
   | items { Alt(items, None) }
items: item items { [item] + items }
     | item { [item] }
item: NAME { name.string }
    | STRING { string.string }
action: "{" stuffs "}" { stuffs }
stuffs: stuff stuffs { stuff + " " + stuffs }
      | stuff { stuff }
stuff: "{" stuffs "}" { "{" + stuffs + "}" }
     | NAME { name.string }
     | NUMBER { number.string }
     | STRING { string.string }
     | OP { None if op.string in ("{", "}") else op.string }

Теперь, когда у нас есть рабочая мета-грамматика, мы почти готовы внести некоторые улучшения.

Но сначала надо немного подумать: пустые строки! Оказывается, модуль stdlib tokenize создаёт дополнительные токены для отслеживания незначимых переносов строк (токен NL) и комментариев (токен COMMENT). Вместо того, чтобы включать их в грамматику (я пробовал, удовольствия мало!), Есть очень простой фрагмент кода, который мы можем добавить в наш класс токенизатора, чтобы фильтровать их. Вот улучшенный метод peek_token:

    def peek_token(self):
        if self.pos == len(self.tokens):
            while True:
                token = next(self.tokengen)
                if token.type in (NL, COMMENT):
                    continue
                break
            self.tokens.append(token)
            self.report()
        return self.tokens[self.pos]

Это полностью убирает токены NL и COMMENT, поэтому нам больше не нужно беспокоиться о них в грамматике.

Наконец, давайте внесем улучшения в мета-грамматику! Они будут чисто косметическими: мне не нравится, когда меня заставляют писать все альтернативы в одну строку. Мета-грамматика, которую я показал выше, на самом деле не разбирает себя из-за таких вещей:

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }

Это связано с тем, что токенизатор создает токен NEWLINE в конце первой строки, и в этот момент мета-парсер будет считать, что это конец правила. Более того, за этой NEWLINE будет следовать токен INDENT, потому что следующая строка имеет отступ. До начала следующего правила также будет присутствовать токен DEDENT.

Вот как с этим справиться. Чтобы понять поведение модуля tokenize, мы можем взглянуть на последовательность токенов, сгенерированных для блоков с отступом, запустив модуль tokenize как скрипт и передав ему некоторый текст:

$ python -m tokenize
foo bar
    baz
    dah
dum
^D

Мы видим, что это производит следующую последовательность токенов (я немного упростил вывод из вышеприведённого кода):

NAME     'foo'
NAME     'bar'
NEWLINE
INDENT
NAME     'baz'
NEWLINE
NAME     'dah'
NEWLINE
DEDENT
NAME     'dum'
NEWLINE

Таким образом выделенная группа строк обозначена токенами INDENT и DEDENT. Теперь мы можем переписать правило мета-грамматики для rule следующим образом:

rule: NAME ":" alts NEWLINE INDENT more_alts DEDENT {
        Rule(name.string, alts + more_alts) }
    | NAME ":" alts NEWLINE { Rule(name.string, alts) }
    | NAME ":" NEWLINE INDENT more_alts DEDENT {
        Rule(name.string, more_alts) }
more_alts: "|" alts NEWLINE more_alts { alts + more_alts }
         | "|" alts NEWLINE { alts }

(Я разбиваю экшены по строкам, чтобы они нормально читались в узкой колонке текста. Это возможно, потому что токенизатор игнорирует разрывы строк внутри соответствующих фигурных скобок.)

Прелесть этого в том, что нам даже не нужно менять генератор: структура данных, создаваемая этой улучшенной мета-грамматикой, такая же, как и раньше. Также обратите внимание на третий вариант для rule: это позволяет нам писать:

start:
    | metas rules ENDMARKER { Grammar(rules, metas) }
    | rules ENDMARKER { Grammar(rules, []) }

что некоторым покажется чище, чем версия, которую я показал ранее. Легко разрешить обе формы, поэтому нам не нужно спорить о стиле.

В следующем посте я покажу, как реализовал различные функции PEG, такие как необязательные элементы, повторы и подсказки. (Чтобы быть честным, я планировал рассказать о них в этой статье, но она уже и так слишком велика. Так что я разделю её на две части.)

Лицензия на эту статью и приведенный код: CC BY-NC-SA 4.0