Как сделать парсер на c

Обновлено: 06.07.2024

Иногда в проектах на C++, (у кого-то чаще, у кого-то реже) возникает задача разбора какого-либо структурированного текста. То есть по сути, создание парсера того или иного языка. Обычно, к этой задаче подходят одним из следующих способов:

Написание парсера вручную, анализируя строку средствами C++, возможно, используя регулярные выражения.

Генерация парсера с использованием соответствующих утилит, например, Antlr, lex/yacc и т.п.

Несколько иной подход предоставляется библиотекой Spirit в составе Boost. О нем я расскажу ниже.

Boost::Spirit – библиотека, предназначенная для описания текста грамматики вместе с семантическими действиями прямо в C++ коде. Грамматика буквально конструируется из примитивных парсеров путем использования соответствующим образом перегруженных операторов C++. Таким образом, описание грамматики выглядит достаточно близко к классическому описанию, как например БНФ.

Простейшие Парсеры

Приведу пример, который был мною честно списан с Википедии. Это разбор строки, содержащей набор чисел через запятую в список этих чисел. Возможно, то же самое можно было бы сделать и более простыми методами, однако этот пример позволяет понять некоторые базовые принципы Spirit.

using namespace std;

using namespace boost::spirit; // Все типы и функции Boost::Spirit заключены в это пространство имен

* Функция, которая разбирает строку - список чисел через запятую в вектор этих чисел.

* @param str строка, которую необходимо разобрать

* @param v вектор, в который добавляются результаты

* @return true, если разбор прошел успешно

bool parse_numbers(const char* str, vector & v)

return parse( // Вызываем функцию boost::spirit::parse

str, // В качестве первого аргумента передаем строку, которую необходимо разобрать

( real_p[push_back_a(v)] >> *(',' >> real_p[push_back_a(v)]) ), // Парсер, описывающий как разбирать строку

space_p // Грамматика, определяющая какие участки текста можно пропускать

// Функция возвращает структуру boost::spirit::parse_info

// но нас интересует только поле full - была ли строка полностью разобрана

Самое интересное здесь - определения грамматик. В частности, в примере приведены две грамматики.

Начнем со второй, поскольку она значительно проще: space_p - это встроенный примитив, принимающий последовательности пробельных символов, т.е. пробелов, табов и переносов строк.

Перейдем к первой грамматике:

real_p[push_back_a(v)] >> *(',' >> real_p[push_back_a(v)])

real_p - парсер, который считывает из строки число с плавающей точкой.

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

push_back_a - это встроенный функтор, определенный в заголовочном файлеboost/spirit/actor/push_back_actor.hpp, выполняющий добавление передаваемого ему значение в указанную коллекцию. Таким образом, каждая конструкция real_p[push_back_a(v)] определяет парсер, считывающий число из строки и добавляющий его в вектор v.

Оператор >> конструирует парсер, который последовательно применяет левостоящий парсер, а затем правостоящий. В частности, конструкция ',' >> real_p[push_back_a(v)] обозначает считывание из строки запятой, а затем дробного числа. Стоит отметить, что левым аргументом оператора является просто символ ','. Поскольку второй аргумент - парсер, компилятор C++ способен применить его, осуществив неявное преобразование символа к парсеру, который просто считывает заданный символ из строки.

Оператор * конструирует парсер, который осуществляет применение правостоящего парсера 0 и более раз. Т.е. конструкция *(',' >> real_p[push_back_a(v)])

Другие парсеры можно найти в документации Spirit на страницах: Primitives, Numerics, Character Sets и других.

Базовые Примитивы

Как, наверное, понятно из приведенного примера, для того чтоб конструировать новые парсеры, необходим набор примитивных парсеров и операторов, которые позволяют строить более сложные парсеры. Сначала, я приведу список некоторых базовых парсеров. Сразу хочу отметить, что он далеко не полон:

ch_p - парсер, считывающий заданный символ. То есть, парсер считывает один символ и проверяет его на соответствие заданному. Задается он следующим образом: ch_p('a'). К этому парсеру автоматически преобразуются символьные константы, которые встречаются в опеределении грамматики.

str_p - аналогично, только считывает и проверяет не один символ, а непрерывную последовательность. Например: str_p("abcd"). К нему преобразуются строковые константы в определении грамматики.

chseq_p - очень похож на предыдущий, также считывает последовательность символов, заданную в строке-параметре. Отличие: символы в последовательности могут быть разделены пробельными, т.е. теми, которые принимаются парсером, обозначающим какие участки можно пропускать. Т.е. chseq_p("abc") успешно примет "a bc", тогда как str_p("abc") не примет.

anychar_p, alnum_p, alpha_p, digit_p, lower_p, upper_p - парсеры, которые принимают любой символ, букву или цифру, букву, цифру, символ в нижнем регистре и символ в верхнем регистре соответственно.

uint_p, bin_p, oct_p, hex_p - парсеры, считывающие беззнаковое целое число в десятичной, двоичной, восьмеричной и шестнадцатиричной системе счисления соответственно.

int_p - парсер, считывающий целое число в десятичной системе, возможно со знаком.

real_p,ureal_p - парсеры, считывающие знаковое и беззнаковое числа с плавающей точкой соответственно.

Опять же, неполный список операторов:

a >> b - оператор последовательного применения. Создает парсер,получаемый последовательным применением аргументов.

a | b - альтернатива. Создает парсер, который пытается по очереди применить аргументы для того, чтобы разобрать строку, пока какой-либо не выдаст положительный результат.

a & b - пересечение. Парсер, который применяется успешно тогда, когда оба аргумента успешно разбирают строку.

a - b - разность. Получаемый парсер принимает строку тогда, когда первый аргумент ее принимает, а второй нет.

~ a - парсер который принимает все, что не принимает аргумент.

* a - повторение аргумента 0 и более раз.

+ a - повторение аргумента 1 и более раз.

! a - опционально применение аргумента, т.е. его повторение 0 или 1 раз.

a % b - оператор списка. Эквивалентен a >> * ( b >> a ). То есть создает парсер, осуществляющий разбор непустого списка, где первый аргумент - элемент списка и второй аргемент - разделитель.

Другие операторы можно найти в документации Spirit на странице: Operators

Еще Один Пример

Для того, чтобы понять как все это работает, рассмотрим еще один пример. Предположим, мы хотим распарсить набор "свойство-значение", где имя состоит из букв и цифр, а значение свойства - произвольная строка в двойных кавычках. Элементы карты разделены запятой, ключ и значение разделено двоеточием. Будем строить грамматику сверху вниз:

На самый общий взгляд наш текст представляет из себя список некоторых элементов, разделенных запятой. Так и запишем:

Что представляет из себя элемент? Это пара ключ-значение, разделенные двоеточием. Добавим это вместо /*элемент*/

Что есть ключ? Непустая последовательность букв и цифр, без разделителей. Для того, чтобы показать, что какой-то участок текста не должен содержать разделителей используется директива lexeme_d. Тогда, парсер примет вид:

( lexeme_d[ +alnum_p ] >> ':' >> /*значение*/ ) % ','

Значение это произвольная строка без кавычек, заключенная в кавычки. И все это без разделителей. Итоговый парсер принимает вид:

( lexeme_d[ +alnum_p ] >> ':' >> lexeme_d[ '"' >> +~ch_p('"') >> '"' ] ) % ','

Теперь, необходимо как-то добавлять ключ и значение в std::map. Для этого будем использовать следующий подход: при считывании ключа сохраняем его в локальную переменную, а при считывании значения добавляем пару в std::map. Для этого необходимо использовать два встроенных функтора: assign_a и insert_at_a - которые сохраняют значение в переменную и вставляют элемент в контейнер соответственно. Другие встроенные функторы можно найти в документации Spirit на странице: Predefined Actors Полный текст примера примет вид:

using namespace boost::spirit;

bool parseConfig( const char * str, std::map & mp )

return parse(

( ( lexeme_d[+alnum_p][assign_a(key)] >> ':' >> lexeme_d[ '"' >> ( *~ch_p('"') )[insert_at_a(mp,key)] >> '"' ] ) % ',' ),

Тут вы можете оставить комментарий к выбранному абзацу или сообщить об ошибке.


Часто возникает задача периодически парсить какой-нибудь сайт на наличие новой информации. Например, если ты пишешь агрегатор контента с новостного сайта или форума, в котором нет поддержки RSS. Проще всего написать скрепер на Питоне и разобрать полученный HTML через beautifulsoup или регулярками. Однако есть более элегантный способ — самому сделать недостающие API для сайта и получать ответы в привычном JSON, как будто бы у сайта есть нативный API.

Постановка задачи

Ответ должен быть таким:

Фреймворк для веба

WrapAPI — это довольно новый (пара месяцев от роду) сервис для построения мощных кастомных парсеров веба и предоставления к ним доступа по API. Не пугайся, если ничего не понял, сейчас поясню на пальцах. Работает так:

Немного о приватности запросов

Ты наверняка уже задумался о том, насколько безопасно использовать чужой сервис и передавать ему параметры своих запросов с приватными данными. Тем более что по умолчанию для каждого нового API-проекта будет создаваться публичный репозиторий и запускать API из него сможет любой желающий. Не все так плохо:

  1. Каждый API-репозиторий (а соответственно, и все API-запросы в нем) можно сделать приватным. Они не будут показываться в общем списке уже созданных API на платформе WrapAPI. Просто выбери достаточно сложное имя репозитория, и шанс, что на него кто-то забредет случайно, сведется к минимуму.
  2. Любой запрос к WrapAPI требует специального токена, который нужно получить в своей учетке WrapAPI. То есть просто так узнать URL к твоему репозиторию и таскать через него данные не получится. Токены подразделяются на два типа: серверные и клиентские, для использования прямо на веб-страничке через JavaScript. Для последних нужно указать домен, с которого будут поступать запросы.
  3. Ну и наконец, в скором времени разработчик обещает выпустить self-hosted версию WrapAPI, которую ты сможешь поставить на свой сервер и забыть о проблеме утечек данных (конечно, при условии, что в коде WrapAPI не будет бэкдоров).

Приготовления

Несколько простых шагов перед началом.

  1. Идем на сайт WrapAPI, создаем новую учетку и логинимся в нее.
  2. Устанавливаем расширение для Chrome (подойдет любой Chromium-based браузер), открываем консоль разработчика и видим новую вкладку WrapAPI .
  3. Переходим на нее и логинимся.

Это расширение нам понадобится для того, чтобы перехватывать запросы, которые мы собираемся эмулировать, и быстро направлять их в WrapAPI для дальнейшей работы. По логике работы это расширение очень похоже на связку Burp Proxy + Burp Intruder.

Для работы с WrapAPI нужно повторно авторизоваться еще и в расширении в консоли разработчика Chrome

Для работы с WrapAPI нужно повторно авторизоваться еще и в расширении в консоли разработчика Chrome


Другие статьи в выпуске:

Отлавливаем запросы

Запросы, которые генерятся по нажатию на ссылки пагинатора, будем использовать как образец

Запросы, которые генерятся по нажатию на ссылки пагинатора, будем использовать как образец

Запрос пойман, сохраняем его на сервер WrapAPI

Запрос пойман, сохраняем его на сервер WrapAPI

Конфигурируем WrapAPI

После того как ты выбрал нужное имя для твоего репозитория (я взял test001 и endpoint posts ) и сохранил его на сервер WrapAPI через расширение для Chrome, иди на сайт WrapAPI и открывай репозиторий. Самое время настраивать наш API.

Обзор нашего будущего API

Обзор нашего будущего API

Переходи на вкладку Inputs and request. Здесь нам понадобится указать, с какими параметрами WrapAPI должен парсить запрашиваемую страницу, чтобы сервер отдал ему валидный ответ.

Конфигурируем входные параметры запроса

Конфигурируем входные параметры запроса

Аккуратно перебей все параметры из пойманной WrapAPI полезной нагрузки (POST body payload) в поле слева. Для всех параметров, кроме paginated , выставь тип Constant . Это означает, что в запросы к серверу будут поставляться предопределенные значения, управлять которыми мы не сможем (нам это и не нужно). А вот для paginated выставляй Variable API , указав имя page . Это позволит нам потом обращаться к нашему API по URL вида GET /posts?page=5 (с query-параметром page ), а на сервер уже будет уходить полноценный POST со всеми перечисленными параметрами.

Выставляем необходимые POST-параметры в формате form/urlencoded, чтобы наш запрос отработал правильно

Выставляем необходимые POST-параметры в формате form/urlencoded, чтобы наш запрос отработал правильно

Учим WrapAPI недостающим фичам

Теперь нужно указать WrapAPI, как обрабатывать полученный результат и в каком виде его представлять. Переходи на следующую вкладку — Outputs and response.

Шаг настройки постпроцессоров полученного контента

Шаг настройки постпроцессоров полученного контента

Тестовый кейс page1, ответ сервера

Тестовый кейс page1, ответ сервера

JSON output

Первым делом нужно вытащить из объекта JSON значение атрибута content. Создавай новый output типа JSON и в появившемся модальном окне указывай имя параметра content . Сразу же под текстовым полем WrapAPI подсветит найденное значение выходной строки. То, что нам нужно. Сохраняем output и идем дальше.

JSON output для получения значения атрибута content на выход

JSON output для получения значения атрибута content на выход

CSS output

Следующий шаг — вытащить нужные нам поля постов из полученной с сервера верстки, а именно title , excerpt , image , date и id .

Во WrapAPI можно создавать дочерние аутпуты. Нажав на + около существующего output, ты создашь дочерний output, который будет принимать на выход значение предыдущего. Не перепутай! Если просто выбрать пункт Add new output, то будет создан новый root-селектор, который на вход получит голый ответ сервера.

Создаем дочерний CSS output

Создаем дочерний CSS output

В появившемся окне вводим название класса заголовка .title-text . Внимание: обязательно отметь опцию Select all into an array , иначе будет выбран только первый заголовок, а нам нужно получить все десять по количеству постов в одном ответе сервера.

Задаем параметры получения данных из HTML-верстки

Задаем параметры получения данных из HTML-верстки

На выходе в ключе titles у нас окажется массив заголовков, которые вернул CSS output. Согласись, уже неплохо, и все это — без единой строки кода!

Полученные заголовки новостей

Полученные заголовки новостей

Как получить остальные параметры

Как ты помнишь, кроме title , для каждого поста нам нужно получить еще excerpt , image , date и id . Тут все не так здорово: WrapAPI имеет два ограничения:

  • он не позволяет создавать цепочки из более чем одного уровня вложенности дочерних outputs;
  • он не позволяет задавать несколько селекторов для CSS output’a. То есть CSS output может вытащить только title , только date и так далее.

Признаться, мне пришлось немного поломать голову, чтобы обойти эти ограничения. Я сделал много дочерних по отношению к JSON аутпутов CSS — по одному на каждый из параметров. Они выводят мне в итоговый результат несколько массивов: один с заголовками, один с превью статьи, один с датами и так далее.

Массив дочерних аутпутов, каждый из которых выбирает свой атрибут постов

Массив дочерних аутпутов, каждый из которых выбирает свой атрибут постов

В итоге у меня получился вот такой массив данных:

Стоит отметить, что для backgroundImages нужно указать получение не текста HTML-тега, а значения атрибута style , так как URL картинки задан в свойстве inline-CSS, а не в атрибуте src тега img .

Приводим все в порядок

Сейчас наш API уже выглядит вполне читаемым, осталось решить две проблемы:

  • все компоненты поста — заголовок, дата, превью — находятся в разных массивах;
  • в backgroundImages попал кусок CSS, а не чистый URL.

Решить эти проблемы нам поможет следующая вкладка — Post-processing script. Она позволяет написать небольшой синхронный скрипт на JavaScript, который может сделать что-то с нашим контентом перед тем, как он отправится на выход.

Скрипт должен содержать функцию postProcess() , которая принимает один аргумент — текущие результаты парсинга. То, что она вернет, и будет конечным ответом нашего API.

Я набросал небольшой скрипт, который быстро собрал все компоненты в единый массив постов, а также почистил URL картинки. Останавливаться на этом подробнее смысла нет, все, я думаю, и так предельно ясно.

Пишем скрипт для постпроцессинга данных

Пишем скрипт для постпроцессинга данных

Тестируем результат

Перед тем как пробовать наш запрос, нужно получить API-ключ. Ключи WrapAPI бывают двух типов:

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

Для теста получи новый приватный ключ и попробуй сделать запрос к своему API, поставив свой ключ в query-параметр wrapAPIKey .

Получение ключей доступа к API

Получение ключей доступа к API

У меня вышел вот такой запрос:

Ответ сервера показан на скриншоте. Победа! 🙂

Выводы

image

В нашем проекте PT Application Inspector реализовано несколько подходов к анализу исходного кода на различных языках программирования:

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



К разрабатываемому модулю были, в числе прочих, сформулированы следующие требования:

  • поддержка нескольких языков программирования и простое добавление новых;
  • поддержка анализа кода, содержащего синтаксические и семантические ошибки;
  • возможность описания шаблонов на универсальном языке (DSL, domain specific language).

Весь процесс анализа кода может быть разбит на следующие этапы:

  1. парсинг в зависимое от языка представление (abstract syntax tree, AST);
  2. преобразование AST в независимый от языка унифицированный формат;
  3. непосредственное сопоставление с шаблонами, описанными на DSL.

Теория парсинга

Терминология

Те, кто знаком с теорией парсинга, могут пропустить этот раздел.

Парсинг — процесс преобразования исходного кода в структурированный вид. Типичный парсер представляет собой комбинацию лексера и парсера. Лексер группирует символы исходного кода в значащие последовательности, которые называются лексемами. После этого определяется тип лексемы (идентификатор, число, строка и т.п.). Токеном называется совокупность значения лексемы и ее типа. В примере на рисунке ниже токенами являются sp, =, 100. Парсер же из потока токенов строит связную древовидную структуру, которая называется деревом разбора. В данном случае assign является одним из узлов дерева. Абстрактное синтаксическое дерево или AST — дерево разбора на более высоком уровне, из которого удалены не значимые токены, такие как скобки, запятые. Однако существуют парсеры, в которых шаг лексирования и разбора совмещены.

Lexer & Parser description

Для описания различных узлов AST используются правила. Объединение всех правил называютграмматикой языка. Существуют инструменты, генерирующие код под определенную платформу (рантайм) для разбора языков на основе грамматик. Они называются генераторами парсеров. Например, ANTLR, Bison,Coco/R. Однако часто парсер по определенным причинам пишется вручную, например Roslyn. Преимуществами ручного подхода является то, что парсеры, как правило, получаются более производительными и читабельными.

Виды формальных языков

Согласно иерархии Хомского, существуют 4 вида формальных языков:

  • регулярные, пример: a n
  • контекстно-свободные (КС, пример: a n b n )
  • контекстно-зависимые (КЗ, пример: a n b n c n )
  • тьюринг-полные.

Кроме того, язык в одном случае может являться КС, а в другом — КЗ. Если учитывать семантику, т.е. согласованность с определениями языка, в частности, согласованность типов, то язык может рассматриваться как КЗ. Например, T a = new T(). Здесь тип в конструкторе справа должен быть таким же, какой указан слева. Обычно целесообразно проверять семантику после этапа парсинга. Однако существуют синтаксическое конструкции, которые никак не распарсить с помощью КС-грамматик, например Heredoc в PHP: $x = 4 ), по факту скорость парсинга для существующих языков программирования является линейной. Также в четвертой версии сильно эволюционировала возможность восстановления процесса парсинга после обнаружения синтаксических ошибок. Подробнее об алгоритмах ANTLR 4 и их отличиях от других алгоритмов парсинга написано в Adaptive LL(*) Parsing: The Power of Dynamic Analysis.

Roslyn

  • Syntax Node — нетерминальный узел дерева, содержащий в себе несколько других узлов и отображающий определенную конструкцию. Также может содержать опциональный узел (например, ElseClauseSyntax для if);
  • Syntax Token — терминальный узел, отображающий ключевое слово, идентификатор, литерал или пунктуацию;
  • Syntax Trivia — терминальный узел, отображащий пробел, комментарий или директиву препроцессора (может быть безболезненно удален без потери информации о коде). Trivia не может иметь родителя. Данные узлы оказываются незаменимыми при трансформации дерева обратно в код (например, для рефакторинга).

Проблематика парсинга

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

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

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

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


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

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

Обработка пробелов, комментариев.

Еще одной проблемой при парсинге является обработка комментариев. Неудобство здесь в том, что если включать комментарии в грамматику, то она получится переусложненной, поскольку по сути каждый узел будет содержать в себе комментарии. Однако просто выкинуть комментарии тоже нельзя, потому что в них может содержаться важная информация. Для обработки комментариев в ANTLR используются так называемые каналы, которые изолируют множество комментариев от остальных токенов: Comment: ~[\r\n?]+ -> channel(PhpComments);

В Roslyn комментарии включены в узлы дерева, но имеют специальный тип Syntax Trivia. И в ANTLR, и в Roslyn можно получить список тривиальных токенов, ассоциированных с определенным обычным токеном. В ANTLR для токена с индексом i в потоке существует метод, который возвращает все токены из определенного канала слева или справа: getHiddenTokensToRight(int tokenIndex, int channel) , getHiddenTokensToRight(int tokenIndex, int channel) . В Roslyn же такие токены сразу же включаются в терминальные Syntax Token.

Обработка пробелов и комментариев является одной из причин, почему для анализа нельзя использовать код, например, LLVM: в нем они будут просто выкинуты. Кроме комментариев, важна даже обработка пробелов. Например, для детектирования ошибок с одним утверждением в if (пример взят из статьи PVS-Studio покопался в ядре FreeBSD):

Обработка ошибок парсинга.



Важной способностью каждого парсера является обработка ошибок — по следующим причинам:

  • процесс парсинга не должен прерывать только лишь из-за одной ошибки, а должен корректно восстанавливаться и парсить код дальше (например, после пропущенной точки с запятой);
  • поиск релевантной ошибки и ее расположения, вместо множества нерелевантных.

Ошибки в ANTLR

В ANTLR существуют следующие типы ошибок парсинга:

    ошибка распознавания токена (Lexer no viable alt); единственная существующая лексическая ошибка, обозначающая отсутствие правила для формирования токена из существующей лексемы:

class T < int f(x) < a = 3 4 5; >> — здесь такой токен это > в конце;


Более того, в ANTLR 4 можно использовать свой механизм обработки ошибок. Это нужно, к примеру, для увеличения производительности парсера: сначала код парсится с помощью быстрого SLL-алгоритма, который, однако, может неправильно парсить код с неоднозначностью. Если же с помощью этого алгоритма выяснилось, что существует хотя бы одна ошибка (эта может быть как ошибка в коде, так и неоднозначность), код парсится уже с помощью полного, но менее быстрого ALL-алгоритма. Конечно, код с настоящими ошибками (например, пропущенная; ), будет всегда парситься с помощью LL, однако таких файлов обычно меньше, чем файлов без ошибок.

Максимизация скорости парсинга в ANTLR

Ошибки в Roslyn

В Roslyn существуют следующие ошибки парсинга:

  • недостающий синтаксис; Roslyn достраивает соответствующий узел со значением свойства IsMissing = true (типичным пример — Statement без точки с запятой);
  • незавершенный член; создается отдельный узел IncompleteMember ;
  • некорректное значение численного, строкового или символьного литерала (например, слишком большое значение, пустой char): отдельный узел с Kind, равным NumericLiteralToken , StringLiteralToken или CharacterLiteralToken ;
  • лишний синтаксис (например, случайно напечатанный символ): создается отдельный узел с Kind = SkippedTokensTrivia .

Благодаря таким продуманным типам синтаксических ошибок Roslyn может преобразовать дерево с любым количеством ошибок обратно в код символ в символ.

От теории к практике

PHP-грамматика

Регистро-независимые ключевые слова.

Как известно, в PHP все лексемы, за исключением названий переменных (которые начинаются с '$') являются нечувствительными к регистру. В ANTLR нечувствительность к регистру можно реализовать двумя способами:

    Объявление фрагментных лексических правил для всех латинских букв и их использование следующим способом:


Фрагментом в ANTLR является часть лексемы, которую можно использовать в других лексемах,
однако сама по себе она лексемой не является. Это по сути синтаксический сахар для описания лексем. Без использования фрагментов, например, первый токен можно записать так: Abstract: [Aa] [Bb] [Ss] [Tt] [Rr] [Aa] [Cc] [Tt] . Плюс данного подхода в том, что сгенерированный лексер является независимым от рантайма, поскольку символы в верхнем и нижнем регистрах объявляются сразу в грамматике. Из минусов можно отметить то, что производительность лексера при таком подходе ниже, чем во втором способе.

Лексические режимы для PHP, HTML, CSS, JavaScript.


К счастью, в ANTLR есть механизм так называемых режимов (mode), которые позволяют переключаться между различными наборами токенов при определенном условии. Например, режимы SCRIPT и STYLE предназначены для генерации потока токенов для JavaScript и CSS соответственно (однако в этой грамматике они фактически просто игнорируются). В режиме по умолчанию DEFAULT_MODE генерируются HTML-токены. Стоит отметить, что реализовать поддержку Alternative Syntax в ANTLR можно без единой вставки целевого кода в лексер. А именно так: nonEmptyStatement включает в себе правило inlineHtml, которое, в свою очередь, включает в себя токены, полученные в режиме DEFAULT_MODE:

Сложные контекстно-зависимые конструкции.

Стоит отметить, что хотя ANTLR поддерживает только КС-грамматики, в нем существует так называемые экшены, т. е. вставки произвольного кода, которые расширяют допустимое множество языков как минимум до контекстно-зависимых. С помощью таких вставок была реализована обработка конструкций, типа Heredoc и других:

Грамматика T-SQL

Грамматика PL/SQL

Доработка грамматики PL/SQL заняла еще меньше времени, поскольку сама грамматика уже существовала под ANTLR3. Основной сложностью являлось то, что она была разработана под java-runtime. Большая часть java вставок была удалена, так как построить AST можно и без них (как уже говорилось ранее, семантику можно проверять на другом этапе). А такие вставки, как


были заменены на фрагментные лексемы:
decimal_key: D E C I M A L , о которых также говорилось ранее.

Как это ни странно, но доработка грамматики, поддерживающей версии 5 и 6 языка, оказалась достаточно непростым занятием. Главной сложностью была поддержка интерполяции строк и корректная обработка препроцессорных директив. В силу того что эти вещи являются сильно контекстно-зависимыми, лексер и парсер для обработки директив получились зависимыми от runtime.

Директивы препроцессора

После того как значение директивы вычислено и принимает значение true , последующие токены добавляются в список codeTokens , в противном случае — нет. Такой подход позволяет игнорировать неправильные токены (как var 42 = a + ; в этом примере) уже на этапе парсинга. Весь этап парсинга также расписан здесь:CSharpAntlrParser.cs.

Интерполяция строк

Описанные выше вещи продемонстрированы в следующем коде, валидном с точки зрения синтаксиса:


Интерполяция строк была реализован с использованием стека для подсчета уровня текущей интерполируемой строки, а также фигурных скобок. Все это реализовано в CSharpLexer.g4.

Тестирование

Корректность парсеров ANTLR

Парсер Roslyn, очевидно, не нуждается в тестировании корректности парсинга. А тестированию парсеров ANTLR мы уделили большое внимание:

Производительность парсеров ANTLR и Roslyn

Тестирование проводилось в однопоточном режиме и Release конфиге без отладчика. Тестировались версии ANTLR 4 4.5.0-alpha003 и Roslyn (Microsoft.CodeAnalysis) 1.1.1.

WebGoat.PHP

Обработанных файлов — 885. Общее количество строк — 137 248, символов — 4 461 768.
Приблизительное время работы — 00:00:31 сек (лексер 55%, парсер 45%).

PL/SQL Samples

Механизмы обработки ошибок в ANTLR и Roslyn

Заключение

В данной статье мы рассмотрели парсинг исходников с помощью ANTLR и Roslyn. В следующих статьях мы расскажем:

Источники

В нашем телеграм канале мы рассказываем о главных новостях из мира IT, актуальных угрозах и событиях, которые оказывают влияние на обороноспособность стран, бизнес глобальных корпораций и безопасность пользователей по всему миру. Узнай первым как выжить в цифровом кошмаре!

xPath это такой язык запросов, который позволяет среди множества элементов веб-страницы найти нужный, — и обратиться к нему, чтобы достать необходимые данные:

xPath поддерживают платные инструменты для парсинга (например, Screaming Frog Seo Spider), его выражения можно использовать в программировании на JavaScript, PHP и Python, и даже сделать простой бесплатный парсер прямо в Google Таблицах. Разбираемся, как именно — на трех практических примерах.

1. Сбор и проверка заголовков и метатегов

Подготовка таблицы и разбор синтаксиса IMPORTXML

Начать можно с дизайна самой таблицы. Допустим, в первой колонке (A) будут ссылки на страницы, а правее уже результаты, извлеченные данные: H1, тайтл, дескрипшн, ключевые слова.

Практикум по xPath: простой, быстрый и бесплатный способ парсить сайты

Начало работы с парсер-таблицей. В качестве примера разберем заголовки и метатеги главной страницы Webartex — это такая платформа для работы с блогерами и сайтами.

Для импорта данных с сайтов (в форматах HTML, XML, CSV) в Google Таблицах есть функция IMPORTXML. Она принимает такие аргументы:

  1. Полный адрес веб-страницы с указанием протокола (например, "https://"). Можно передать сам URL в кавычках или адрес ячейки, где он лежит.
  2. Непосредственно запрос xPath — тоже в кавычках, так как это тоже текстовая строка.
  3. locale — локальный код для указания языка и региона, необязательный параметр, по умолчанию используются настройки самого документа.

Составление функций для импорта XML с разными запросами xPath

Для парсинга H1 получится довольно просто: =IMPORTXML(A2;"//h1").

Правда, есть нюанс — часть заголовка первого уровня оказывается в ячейке D2, а там нужны совсем другие данные. Все из-за тега
, который внутри

Практикум по xPath: простой, быстрый и бесплатный способ парсить сайты

xPath-локатор работает корректно, можно идти дальше

В ячейке C2 — по тому же принципу, только выражение xPath, соответственно, будет "//title".

А вот для загрузки дескрипшна в соседнюю ячейку D2 нельзя указать просто "//description", потому что такого отдельного тега нет. Эти данные лежат в теге , у которого есть дополнительный параметр (атрибут) — "name" со значением "description".

То есть для решения этой задачи нужно указать элемент так: "//meta[@name='description']".

Практикум по xPath: простой, быстрый и бесплатный способ парсить сайты

Шпаргалка: из чего состоят HTML-элементы, из которых уже состоят веб-страницы (иллюстрация из курса Hexlet по основам HTML, CSS и веб-дизайна).

Это хорошо видно, если открыть исходный код страницы (например, через сочетание клавиш Ctrl + U в Google Chrome). У нет закрывающего тега , как это бывает у многих других, получается, нет и внутреннего содержания. Нужные данные лежат в другом атрибуте — @content.

Практикум по xPath: простой, быстрый и бесплатный способ парсить сайты

Исходный код страницы Webartex, на которых хорошо видно устройство тегов

Решение — дополнить запрос xPath, через "/" указав путь к конкретному атрибуту выбранного элемента. В данном случае вся формула будет такой: =IMPORTXML(A2;"//meta[@name='description']/@content")

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

По такому же принципу составляется запрос для метатега с ключевыми словами — "//meta[@name='keywords']/@content". Если все ок, то, значит, можно протягивать формулы ниже, а в столбец URL добавлять новые адреса.

Практикум по xPath: простой, быстрый и бесплатный способ парсить сайты

Результаты после запуска всех функций. Все формулы написаны верно, данные собираются корректно, все работает нормально.

Если нужно, аналогичным образом можно извлекать и другие данные: подзаголовки H2—H6, метатеги для разметки OpenGraph и Viewport, robots и др.

Бонус: оценка полученных метатегов и заголовков

Допустим, нужно проверить, находится ли длина title и description в пределах нормы. Для этого можно воспользоваться функцией гугл-таблиц ДЛСТР (LEN). Она работает довольно просто: на входе текстовая строка, на выходе — число символов.

Согласно рекомендациям из блога Promopult, отображаемая длина тайтла в Google — до 50-55, а в Яндексе — до 45-55. Поэтому желательно не писать его слишком длинным, по крайней мере в первых 45–55 символах должна быть законченная мысль, самое главное о странице.

В данном примере строка C2 пожелтеет, так как длина title составляет 59 знаков, а не 55. Но в принципе вся ключевая мысль, призыв к действию, умещается в лимит, так что все нормально.

Практикум по xPath: простой, быстрый и бесплатный способ парсить сайты

Настройка условного форматирования Google Таблиц для подсвечивания тайтлов, длина которых больше рекомендуемой

А еще там есть рекомендация не указывать в метатеге keywords больше 10 ключевых слов. Но чтобы проверить это, нужен не подсчет длины, а количества самих слов, разделенных запятыми.

В гугл-таблицах нет специальной функции, которая считает количество вхождений определенных символов в текстовую строку, но эту задачу можно решить через условное форматирование с помощью такой формулы: =COUNTA(SPLIT($E$2:$E;","))>10. Небольшой ликбез:

  • SPLIT — разделяет текст по определенным символам и выводит в разные ячейки. Два обязательных параметра: 1) собственно, текст, который нужно разделить, или ссылку на ячейку с таковым 2) один или несколько символов в кавычках, по которым как раз и нужно разделять текст.
  • СЧЁТЗ (COUNTA) подсчитывает количество значений в наборе данных: принимает неограниченное число аргументов (значений и диапазонов). В данном случае забирает на вход результаты SPLIT, выдающей массив текстовых значений, и подсчитывает их общее число.

2. Парсинг ссылок из топ-10 поисковика

Допустим, нужно регулярно мониторить топ Яндекса по определенному запросу, чтобы узнать, попал ли туда конкретный сайт и на какую позицию. Можно с помощью xPath извлечь все ссылки с органической выдачи, а благодаря текстовым функциям Google Таблиц уже искать совпадения с названием нужного сайта.

Поиск и анализ нужных элементов через DevTools

В коде документа сразу можно заметить древовидную структуру. На самом верху — корневой тег , внутри на одном уровне и , затем раскрывается на десятки

Читайте также: