Лайфхаки

Маленькие, полезные хитрости

Создание парсера данных по произвольной грамматике в 400 строк

06.12.2024 в 03:42

Создание парсера данных по произвольной грамматике в 400 строк


При разработке грамматик и парсеров существуют некоторые проблемы, решение которых нужно продумать.

Ключевые слова как идентификаторы

Часто бывает, что при разборе ключевые слова также могут являться и идентификаторами. Например, в C# ключевое словоasync, помещенное перед сигнатурой методаasync Method(), означает, что данный метод является асинхронным. Но если данное слово будет использоваться в качестве идентификатора переменнойvar async = 42;, то код также будет валидным. В ANTLR данная проблема решается двумя способами:

  1. использованием семантического предиката для синтаксического правила:async: {_input.LT(1).GetText() == "async"}? ID ;; при этом сам токен async не будет существовать. Данный подход плох тем, что грамматика становится зависимой от рантайма и некрасиво выглядит;
  2. включением токена в само правило id:

    ASYNC: 'async'; … id : ID … | ASYNC;

Неоднозначность

В естественном языке существуют неоднозначно трактуемые фразы (типа «эти типы стали есть у нас в цеху»). В формальных языках подобные конструкции также могут встречаться. Например, следующий фрагмент:

stat: expr ';' // expression statement | ID '(' ')' ';' // function call statement; ; expr: ID '(' ')' | INT ;

Однако, в отличие от естественных языков, они скорее всего являются следствием неправильно разработанных грамматик. ANTLR не в состоянии обнаруживать такие неоднозначности в процессе генерации парсера, но может обнаруживать их непосредственно в процессе парсинга, если устанавливать опциюLL_EXACT_AMBIG_DETECTION(потому что, как уже говорилось, ALL — динамический алгоритм). Неоднозначность может возникать как в лексере, так и в парсере. В лексере для двух одинаковых лексем, формируется токен, объявленный выше в файле (пример с идентификаторами). Однако в языках, где неоднозначность действительно допустима (например, C++), можно использовать семантические предикаты (вставки кода) для ее разрешения, например:

expr: { isfunc(ID) }? ID '(' expr ')' // func call with 1 arg | { istype(ID) }? ID '(' expr ')' // ctor-style type cast of expr | INT | void ;

Также иногда неоднозначность можно исправить с помощью небольшого переделывания грамматики. Например, в C# существует оператор побитового сдвига вправоRIGHT_SHIFT: '>>'; две угловые скобки могут также использоваться для описания классов-генериков:. Если определить токен, то конструкция из двух списков никогда не сможет распарситься, потому что парсер будет считать, что вместо двух закрывающихся скобок написан оператор. Чтобы решить такую проблему, достаточно просто отказаться от токена. При этом токенможно оставить, поскольку такая последовательность символов при парсинге угловых скобок не может встретиться в валидном коде.

Связанные вопросы и ответы:

Вопрос 1: Что такое парсер данных по произвольной грамматике

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

Вопрос 2: Как работает парсер данных по произвольной грамматике

Парсер данных по произвольной грамматике работает путём разбора текста последовательно, строка за строкой. Он проверяет каждую строку на соответствие заданным правилам грамматики и, если строка соответствует правилам, парсер разбирает её на составляющие и сохраняет полученные данные в удобном для дальнейшей обработки виде.

Вопрос 3: Какие задачи решает парсер данных по произвольной грамматике

Парсер данных по произвольной грамматике может решать различные задачи, такие как извлечение информации из текстовых данных, их классификация и категоризация, а также анализ структуры и содержания текстов.

Вопрос 4: Как создать парсер данных по произвольной грамматике

Создание парсера данных по произвольной грамматике требует знаний в области языковых и формальных систем, а также навыков программирования. Для создания парсера нужно разработать грамматику, которая будет описывать правила построения текстовых данных, и написать программное обеспечение, которое будет использовать эту грамматику для анализа и обработки данных.

Вопрос 5: Какие языки программирования можно использовать для создания парсера данных по произвольной грамматике

Для создания парсера данных по произвольной грамматике можно использовать различные языки программирования, такие как Python, Java, C++, и другие. Выбор языка программирования зависит от специфики задачи и предпочтений программиста.

Вопрос 6: Какие ограничения могут быть у парсера данных по произвольной грамматике

Парсер данных по произвольной грамматике может иметь некоторые ограничения, такие как ограниченная мощность компьютера, ограниченный объем памяти, ограниченная скорость обработки данных, а также ограничения, связанные с грамматикой, которая может быть слишком сложной для анализа.

Вопрос 7: Как оценить качество работы парсера данных по произвольной грамматике

Качество работы парсера данных по произвольной грамматике можно оценить по нескольким критериям, таким как скорость обработки данных, точность и полнота извлечения информации, стабильность работы и устойчивость к ошибкам в текстовых данных.

Что такое парсер данных по произвольной грамматике


Чтобы облегчить себе выбор парсера, мы обратили свой взгляд на проекти недавно прошедшее в его рамках соревнование.Universal Dependencies — это проект по унификации разметки синтаксических корпусов (трибанков) в рамках грамматики зависимостей. В русском языке количество типов синтаксических связей ограничено — подлежащее, сказуемое и т.д. В английском то же самое, но набор уже другой. Например, там появляется артикль, который тоже надо как-то маркировать. Если бы мы хотели написать волшебный парсер, который мог бы обрабатывать все языки, то довольно быстро уперлись бы в проблемы сопоставления разных грамматик. Героическим создателям Universal Dependencies удалось договориться между собой и разметить все корпусы, которые имелись в их распоряжении, в едином формате. Не очень важно, как именно они договорились, главное, что на выходе мы получили некий единообразный формат представления всей этой истории —.CoNLL Shared Task — это соревнование между разработчиками алгоритмов синтаксического парсинга, проводимое в рамках проекта Universal Dependencies. Организаторы берут некоторое количество трибанков и разбивают каждый из них на три части — обучающую, валидационную и тестовую. Первая часть предоставляется участникам соревнования, чтобы они обучили на ней свои модели. Вторая часть тоже используется участниками — чтобы после обучения оценить работу алгоритма. Обучение и оценку участники могут итеративно повторять. Потом они отдают свой лучший алгоритм организаторам, которые прогоняют его на тестовой части, закрытой для участников. Итоги работы моделей на тестовых частях трибанков — это и есть итоги соревнования.

Какие задачи решает парсер данных по произвольной грамматике

Определение формальной грамматики

Формальная грамматика – это математическая система, определяющая язык посредством порождающих правил. Определение Формальной грамматикой называется четверка вида:G=(Vt, Vn, P, S), где:

    Vn- конечное множество нетерминальных символов; Vt- множество терминальных символов грамматики; Р– множество правил вывода грамматики, являющееся конечным подмножеством множества(Vt∪Vn)+ x (Vt∪Vn)*элемент(a, b)множестваРназывается правилом вывода и записывается в видеa → b(читается: «из цепочкиaвыводится цепочкаb»); S- начальный символ грамматики,S∈Vn.

Синтаксический анализ и и иерархия анализаторов

Cинтаксический анализ (или разбор, жарг. парсинг ← англ. parsing) в лингвистике и информатике — процесс сопоставления линейной последовательности лексем (слов, токенов) естественного или формального языка с его формальной грамматикой. Результатом обычно является дерево разбора (синтаксическоедерево).

Синтаксический анализатор (жарг. парсер ← англ. parser) — это программа или часть программы, выполняющая синтаксический анализ.
Задача синтаксического анализатора - провести разбор текста программы, сопоставив его с эталоном, данным в описании языка. Для синтаксического разбора используются контекстно-свободные грамматики (КС-грамматики).
Вход синтаксического анализатора – последовательность лексических и таблицы, например, таблица внешних представлений, которые являются выходом лексического анализатора.
Выход синтаксического анализатора – дерево разбора и таблицы, например, таблица идентификаторов и таблица типов, которые являются входом для следующего просмотра компилятора (например, это может быть просмотр, осуществляющий контроль типов).

Нисходящий парсер (англ. top-down parser) — продукции грамматики раскрываются, начиная со стартового символа, до получения требуемой последовательности токенов.

  • Метод рекурсивного спуска
  • LL-анализатор

Восходящий парсер (англ. bottom-up parser) — продукции восстанавливаются из правых частей, начиная с токенов и кончая стартовым символом.
• LR-анализатор
• GLR-анализатор

КС-грамматика и МП-автоматы

ГрамматикаG=(Vt, Vn, P, S)называется контекстно-свободной грамматикой (КС-грамматикой) , если ее правила вывода имеют вид:A → b, гдеA∈Vnиb∈(Vt∪Vn)*.

Распознавателями для КС-языков являются автоматы с магазинной памятью (МП-автоматы) . МП-автомат можно представить в виде семеркиM=(Q,Vt,Vn,F,q0,N0,Z), где:

    Q– конечное множество состояний автомата; Vt– конечный входной алфавит; Vn– конечный магазинный алфавит; F– магазинная функция, отображающая множество(Qx(Vt∪{e})xVnво множество всех подмножеств множестваQxVn*, т.е.F: (Qx(Vt∪{e})xVn) → P(QxVn*); q0– начальное состояние автомата,q0∈Q; N0– начальный символ магазина,N0∈Vn; Z– множество заключительных состояний автомата,Z∈Q.

Конфигурацией МП-автомата называется тройка вида:(q,w,a)∈(QxVt*xVn*), где

    q- текущее состояние автоматаq∈Q; w- часть входной головки, первый символ которой находится под входной головкой,w∈Vt*; a∈Vn*;

LL(k)-грамматика

КС-грамматика обладает свойством LL(k) для некоторого k>0, если на каждом шаге вывода для однозначного выбора очередной альтернативы МП-автомату достаточно знать символ на верхушке стека и рассмотреть первые k символов от текущего положения считывающей головки во входной строке.

КС-грамматика называется LL(k)-грамматикой , если она обладает свойством LL(k) для некоторого k>0. Буквы L в выражении «LL-анализатор» означают, что входная строка анализируется слева направо (left to right), и при этом строится её левосторонний вывод (leftmost derivation).

Множества FIRST и FOLLOW

Для построения распознавателей для LL(k)-грамматик используются два множества:

  • FIRST(k, a) – множество терминальных цепочек, выводимых из цепочкиa∈(Vt∪Vn)*, укороченных до k символов;FIRST(k,a)={w∈Vt* | ∃ вывод a⇒*w и |w|⩽k или ∃ вывод a⇒*wx и |w|=k; x,a∈(Vt∪Vn)*, k>0};
  • FOLLOW(k, A) – множество укороченных до k символов терминальных цепочек, которые могут следовать непосредственно заA∈Vnв цепочках вывода.FOLLOW(k, A)={w∈Vt* | ∃ вывод S⇒*aAy и w∈FIRST(k, y); a,y∈V*, A∈Vn, k>0}.

LL(1)-грамматика

Наибольший интерес в построении синтаксических анализаторов (парсеров) представляют LL(1)-грамматики, так как для них возможно построение нисходящих парсеров без возврата, то есть без корректировки выбранных правил в грамматике. LL(1)-грамматики являются подмножеством КС-грамматик. Однако для достаточно большого количества формальных языков можно построить LL(1)-грамматику, например, для языка арифметических выражений и даже для некоторых языков программирования, в частности можно и для языка Java.

LL(1)-грамматики очень распространены, потому что соответствующие им LL-анализаторы просматривают поток только на один символ вперед при принятии решения о том, какое правило грамматики необходимо применить. Языки, основанные на грамматиках с большим значением k, традиционно считались трудными для распознавания, хотя при широком распространении генераторов синтаксических анализаторов, поддерживающих LL(k) грамматики с произвольным k, это замечание уже неактуально.

и

Как работает парсер данных по произвольной грамматике

Parsing Expression Grammar (или PEG) — это "формат" грамматики, использующий операторы регулярных выражений. Список операторов:

"abc" - точное совпадение строки abc - совпадение одного из символов в скобках (или промежутка символов, если записаны через `-`) e* - от 0 до бесконечности совпадений e e+ - от 1 до бесконечности совпадений e &e - e должно находиться далее !e - e не должно находиться далее e1 e2 - выражения e1 и e2 должны идти друг за другом e1 / e2 - если выражение e1 не парсится, тогда парсим e2 r = … - создание нового правила с названием r

Простой пример:

// в каждой грамматике должно быть стартовое правило, с которого начинается парсинг // здесь оно названо `root` root = "2" digit digit digit digit =

Это пример грамматики языка всех чисел от 2000 до 2999. Для соответствия правилу root нужно чтобы строка начиналась с двойки. Далее идут три повторения правилаdigit, которое описывает одну цифру.

Поправка

Вообще, чтобы грамматика была верной, нужно добавить!.в конец правилаroot. Это дополнение означает, что после четвертой цифры не должно быть символов (.- означает любой символ).

Есть более известный способ задания грамматик: EBNF или Extended Backus-Naur form (еще есть просто BNF). Его отличие от PEG в том, что одна строка может быть обработана по-разному (давать несколько AST). Существует известнаяпроблема. Суть в том, что для вложенных if-then-else последующий else может относиться как к внешнему if'у, так и к внутреннему.

Разбор случая для EBNF

Пусть дано выражение `if A then if B then C else D`. Для него возможны два варианта AST:

"root": >

или

"root": , " else ", "D" >

У PEG такого нет, парсинг грамматики и выражения однозначный.А еще PEG-парсер проще написать.

Какие методы и алгоритмы используются при создании парсера данных по произвольной грамматике


Перед разговором по теме стоит определиться с основными понятиями, чтобы не было разночтений. Это глоссарий данной статьи. Он может совпадать с общепринятой терминологией, но вообще говоря, не обязан, поскольку показывает картину, формирующуюся в голове автора.Итак:
  • входной символьный поток (далее входной поток или поток ) — поток символов для разбора, подаваемый на вход парсера
  • parser/парсер ( разборщик, анализатор ) — программа, принимающая входной поток и преобразующая его в AST и/или позволяющая привязать исполняемые функции к элементам грамматики
  • AST (Abstract Syntax Tree)/ АСД (Абстрактное синтаксическое дерево) ( выходная структура данных ) — Структура объектов, представляющая иерархию нетерминальных сущностей грамматики разбираемого потока и составляющих их терминалов . Например, алгебраический поток (1 + 2) + 3 можно представить в виде ВЫРАЖЕНИЕ(ВЫРАЖЕНИЕ(ЧИСЛО(1) ОПЕРАТОР(+) ЧИСЛО(2)) ОПЕРАТОР(+) ЧИСЛО(3)). Как правило, потом это дерево как-то обрабатывается клиентом парсера для получения результатов (например, подсчета ответа данного выражения)
  • CFG (Context-free grammar)/ КСГ (Контекстно-свободная грамматика) — вид наиболее распространенной грамматики, используемый для описания входящего потока символов для парсера (не только для этого, разумеется). Характеризуется тем, что использование её правил не зависит от контекста (что не исключает того, что она в некотором роде задает себе контекст сама, например правило для вызова функции не будет иметь значения, если находится внутри фрагмента потока, описываемого правилом комментария). Состоит из правил продукции, заданных для терминальных и не терминальных символов.
  • Терминальные символы ( терминалы ) — для заданного языка разбора — набор всех (атомарных) символов, которые могут встречаться во входящем потоке
  • Не терминальные символы ( не терминалы ) — для заданного языка разбора — набор всех символов, не встречающихся во входном потоке, но участвующих в правилах грамматики.
  • язык разбора (в большинстве случаев будет КСЯ ( контекстно-свободный язык )) — совокупность всех терминальных и не терминальных символов, а также КСГ для данного входного потока. Для примера, в естественных языках терминальными символами будут все буквы, цифры и знаки препинания, используемые языком, не терминалами будут слова и предложения (и другие конструкции, вроде подлежащего, сказуемого, глаголов, наречий и т.п.), а грамматикой собственно грамматика языка.
  • BNF (Backus-Naur Form, Backus normal form)/ БНФ (Бэкуса-Наура форма) — форма, в которой одни синтаксические категории последовательно определяются через другие. Форма представления КСГ, часто используемая непосредственно для задания входа парсеру. Характеризуется тем, что определяемым является всегда ОДИН нетерминальный символ. Классической является форма записи вида:
    ::= | | . . . | Так же существует ряд разновидностей, таких как ABNF(AugmentedBNF), EBNF(ExtendedBNF) и др. В общем, эти формы несколько расширяют синтаксис обычной записи BNF.
  • LL(k), LR(k), SLR,… — виды алгоритмов парсеров. В этой статье мы не будем подробно на них останавливаться, если кого-то заинтересовало, внизу я дам несколько ссылок на материал, из которого можно о них узнать. Однако остановимся подробнее на другом аспекте, на грамматиках парсеров. Грамматика LL/LR групп парсеров является BNF, это верно. Но верно также, что не всякая грамматика BNF является также LL(k) или LR(k). Да и вообще, что значит буква k в записи LL/LR(k)? Она означает, что для разбора грамматики требуется заглянуть вперед максимум на k терминальных символов по потоку. То есть для разбора (0) грамматики требуется знать только текущий символ. Для (1) — требуется знать текущий и 1 следующий символ. Для (2) — текущий и 2 следующих и т.д. Немного подробнее о выборе/составлении BNF для конкретного парсера поговорим ниже.

Как можно оптимизировать работу парсера данных по произвольной грамматике


Когда мы сталкиваемся с задачей создания парсера, решение сводится, как правило, к 4 основным вариантам:
  • Решать задачу в лоб, то есть анализировать посимвольно входящий поток и используя правила грамматики, строить АСД или сразу выполнять нужные нам операции над нужными нам компонентами. Из плюсов — этот вариант наиболее прост, если говорить об алгоритмике и наличии математической базы. Минусы — вероятность случайной ошибки близка к максимальной, поскольку у вас нет никаких формальных критериев того, все ли правила грамматики вы учли при построении парсера. Очень трудоёмкий. В общем случае, не слишком легко модифицируемый и не очень гибкий, особенно, если вы не имплементировали построение АСД. Даже при длительной работе парсера вы не можете быть уверены, что он работает абсолютно корректно. Из плюс-минусов. В этом варианте все зависит от прямоты ваших рук. Рассказывать об этом варианте подробно мы не будем.
  • Используем регулярные выражения! Я не буду сейчас шутить на тему количества проблем и регулярных выражений, но в целом, способ хотя и доступный, но не слишком хороший. В случае сложной грамматики работа с регулярками превратится в ад кромешный, особенно если вы попытаетесь оптимизировать правила для увеличения скорости работы. В общем, если вы выбрали этот способ, мне остается только пожелать вам удачи. Регулярные выражения не для парсинга! И пусть меня не уверяют в обратном. Они предназначены для поиска и замены. Попытка использовать их для других вещей неизбежно оборачивается потерями. С ними мы либо существенно замедляем разбор, проходя по строке много раз, либо теряем мозговые клеточки, пытаясь измыслить способ удалить гланды через задний проход. Возможно, ситуацию чуть улучшит попытка скрестить этот способ с предыдущим. Возможно, нет. В общем, плюсы почти аналогичны прошлому варианту. Только еще нужно знание регулярных выражений, причем желательно не только знать как ими пользоваться, но и иметь представление, насколько быстро работает вариант, который вы используете. Из минусов тоже примерно то же, что и в предыдущем варианте, разве что менее трудоёмко.
  • Воспользуемся кучей инструментов для парсинга BNF! Вот этот вариант уже более интересный. Во-первых, нам предлагается вариант типа lex-yacc или flex-bison, во вторых во многих языках можно найти нативные библиотеки для парсинга BNF. Ключевыми словами для поиска можно взять LL, LR, BNF. Смысл в том, что все они в какой-то форме принимают на вход вариацию BNF, а LL, LR, SLR и прочее — это конкретные алгоритмы, по которым работает парсер. Чаще всего конечному пользователю не особенно интересно, какой именно алгоритм использован, хотя они имеют определенные ограничения разбора грамматики (остановимся подробнее ниже) и могут иметь разное время работы (хотя большинство заявляют O(L), где L — длина потока символов). Из плюсов — стабильный инструментарий, внятная форма записи (БНФ), адекватные оценки времени работы и наличие записи БНФ для большинства современных языков (при желании можно найти для sql, python, json, cfg, yaml, html, csv и многих других). Из минусов — не всегда очевидный и удобный интерфейс инструментов, возможно, придется что-то написать на незнакомом вам ЯП, особенности понимания грамматики разными инструментами.
  • Воспользуемся инструментами для парсинга PEG! Это тоже интересный вариант, плюс, здесь несколько побогаче с библиотеками, хотя они, как правило, уже несколько другой эпохи (PEG предложен Брайаном Фордом в 2004, в то время как корни BNF тянутся в 1980-е), то есть заметно моложе и хуже выглажены и проживают в основном на github. Из плюсов — быстро, просто, часто — нативно. Из минусов — сильно зависите от реализации. Пессимистичная оценка для PEG по спецификации вроде бы O(exp(L)) (другое дело, для создания такой грамматики придется сильно постараться). Сильно зависите от наличия/отсутствия библиотеки. Почему-то многие создатели библиотек PEG считают достаточными операции токенизации и поиска/замены, и никакого вам AST и даже привязки функций к элементам грамматики. Но в целом, тема перспективная.

Как можно протестировать работу парсера данных по произвольной грамматике

Давайте представим, что мы создаём базу данных и должны поддержать язык SQL. Рассмотрим простейший запрос на выборку данных:

SELECT id, name, enabled FROM users;

Задача лексического анализа – получение списка колонок и имени таблицы, на основе которых будет выполнен запрос и извлечение данных. Вспомним, что вместо списка колонок там может быть звёздочка, закроем глаза и представим if/else-блоки, которые потребуются, чтобы навесить логику. 

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

ANTLR позволяет сгенерировать набор интерфейсов, в которые будут передаваться распознанные блоки: каждая колонка из списка или имя таблицы, очищенное от пробелов и слова FROM. ANTLR – либа уже довольно старая и проверенная временем, которую тем не менее непрерывно улучшают, выпускают новые версии. Список использования этого анализатора очень велик, а наиболее близкое нам, джавистам, известное место использования – Hibernate Query Language.

ANTLR – кроссплатформенная утилита, которая позволяет однажды созданный синтаксис использовать, применять в различных проектах и модулях, на разных языках – проще говоря, везде где есть поддержка этой либы. Например, на бекенде в Java, при парсинге текста и на фронтенде в JavaScript, при создании подсветки синтаксиса или валидации ввода данных. Список языков, поддерживающих ANTLR4, можно найти тут .

Принцип работы ANTLR основан на синтаксическом анализе входного потока данных (ну или простой строки), построения синтаксического дерева, обхода этого дерева и предоставления сгенерированного API для внедрения логики для каждого из событий обхода. API генерируется на основе “ грамматики ”, которая определяет структуру предложения, отдельных фраз/слов и конкретных символов. 

Другими словами, грамматика определяет DSL, который ожидается во входящем потоке данных и включает в себя декларацию токенов. Они разбивают входящий поток на сегменты, создавая тем самым события для API. 

Такой вот regexp на максималках. Ладно, хватит болтать, давайте кодить уже!

Какие проблемы могут возникнуть при работе с парсером данных по произвольной грамматике


Если вы задумаетесь о парсинге, то поймёте, что во многом он связан с потреблением входных данных. Давайте напишем функцию, выполняющую эту задачу:

def shift(inp): return bool(inp) and (inp, inp)

Получая входную последовательностьinp, она возвращает первый элементinpи все оставшиеся элементыinp. Если входных данных не было, она возвращаетFalse. Функция выглядит странно, но вот как она работает пошагово для символов строки:

>>> shift('bar') ('b', 'ar') >>> shift('ar') # Применяется к оставшимся символам 'ar' ('a', 'r') >>> shift('r') ('r', '') >>> shift('') False >>>

Всегда полезно бывает создать для каждой функции обратную ей функцию. По крайней мере, этому я научился, работая над ПО для физических расчётов. Что противоположно потреблению входных данных? Разумеется, отсутствие потребления входных данных. Давайте напишем эту функцию:

def nothing(inp): return (None, inp)

nothing()не выполняет обработку. Она возвращаетNoneдля любых входных данных. Также она возвращает полученные входные данные (без изменений).

>>> nothing('bar') (None, 'bar') >>>

nothing()отличается от отсутствия доступных входных данных. Она просто означает, что вы решили НЕ делать ничего с имеющимися входными данными.Обе эти функции являются примерами того, что я буду называть «парсер». Парсер — это функция, определяемая своей сигнатурой вызова и соглашением о возвращаемых данных. В частности, парсер — это любая функция, принимающая какие-то входные данные (inp), а в случае успеха возвращающая кортеж(value, remaining), гдеvalue— это некое нужное значение, аremaining— все оставшиеся входные данные, парсинг которых нужно выполнить. При неудаче парсер возвращаетFalse.Хотя эти функции и так короткие, можно ещё больше их сократить при помощиlambda:

shift = lambda inp: bool(inp) and (inp, inp) nothing = lambda inp: (None, inp)

Преимуществоlambdaв том, что она делает код компактным и наглядным. Кроме того, она не позволяет разработчикам пытаться добавлять значимые имена, документацию или type hint к тому, что будет развёрнуто.Кстати,lambda— это третья проблема. Сдвиг, ничего и лямбда — это три проблемы. Давайте двигаться дальше.

Как можно использовать парсер данных по произвольной грамматике в реальных задачах

Дальше мне предложили несколько вариантов задач: парсер LaTeX, детектор языка разметки или добавить подсветку для Markdown. Я решила заняться парсером LaTeX, потому что он быстрее добавил бы Grazie Plugin возможность проверять тексты в Overleaf/Papeeria, в чем нуждается большинство пользователей данных сервисов. Например, Grammarly, насколько мне известно, в принципе не запускает проверку в редакторе Overleaf’a. 

Изначально я думала, что смогу достаточно быстро выполнить эту задачу и приступить к реализации подсветки для Markdown, более сложной и от этого интересной. Но реализация парсера для LaTeX оказалась сложнее, потому что LaTeX не имеетграмматики. Например,неплохое объяснение с примером того, что грамматика является тьюринг-полной и не является хотя бы). В этой статье можно подробней прочитать про данные грамматики и какую иерархию они образуют. Это проблема, поскольку большинство контекстно-зависимых грамматик нельзя парсить за гарантированно полиномиальное время (задача разрешимости, то есть принадлежности строки языку, для КЗ языков является PSPACE-полной). Многие парсеры делают допущение, что он контекстно-свободный, а для реализации подсветки пишут токенайзеры. Генераторы парсеров также предполагают, что поданная на вход грамматика будет контекстно-свободной. Конечно, для нашей задачи не требовалось полноценного парсера, достаточно было просто научиться извлекать из LaTeX’a естественный текст и проверять его на грамматические, пунктуационные и прочие виды ошибок.