Раздел 31 · Janet на практике

Захваты, именованные правила и парсер конфига

middle-senior~30 мин

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

Захваты, именованные правила и парсер конфига

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

Захваты

(capture x), кратко (<- x) сохраняет совпавший текст:

Несколько захватов складываются в массив по порядку:

(group x) оборачивает захваты подшаблона в отдельный массив:

Это главный инструмент структурирования. Без group результат разбора нескольких строк превратится в плоский список, где непонятно, где кончилась одна запись и началась следующая.

(accumulate x), кратко (% x) склеивает захваты подшаблона в одну строку:

(replace x f), кратко (/ x f) применяет функцию к захвату:

Обрати внимание на ,scan-number: внутри процитированного шаблона нужен unquote, чтобы подставить саму функцию. Это ровно та же механика квазицитаты, что в уроке про код как данные.

Вспомогательные захваты:

По полстроки о том, зачем каждый нужен. number избавляет от связки / и scan-number в самом частом случае. constant кладёт в захваты метку: так помечают, какая из альтернатив выбора сработала. ($) захватывает текущую позицию, пригодится для сообщений об ошибках разбора.

Именованные правила

Шаблон можно записать таблицей правил. Разбор начинается с правила :main, остальные ссылаются друг на друга по имени:

Правила могут ссылаться сами на себя, и вот она, та самая вложенность, недоступная регулярным выражениям:

Кириллица и байты

Вспомни урок про литералы: строка в Janet это байты, а не символы. Для PEG это имеет прямые последствия.

Класс :a не работает с кириллицей. Классы :a, :w, :d описаны в терминах ASCII. Русская буква занимает два байта и под :a не подходит:

Наивная грамматика со (some :a) молча не разберёт русский текст. Молча, это ключевое слово: ошибки не будет, будет nil или обрезанный результат.

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

(if-not :s 1) читается как “если здесь не пробел, съесть один байт”. Такой шаблон работает с любой кодировкой, потому что не делает предположений о содержимом, он опирается только на разделители, а те почти всегда ASCII.

Позиции тоже байтовые. Всё, что возвращает смещение в строке, а это ($), peg/find, peg/find-all и третий аргумент peg/match, считает его в байтах, а не в символах:

Если ты используешь такую позицию, чтобы резать строку через string/slice, всё сойдётся: он тоже работает в байтах. А вот показывать её пользователю как “номер символа” нельзя.

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

Практика: парсер конфига

Соберём разбор формата ключ = значение, по паре на строку. Задача маленькая, но в ней есть всё: правила, захваты, группировка и работа с UTF-8.

(def config-peg
  ~{# Горизонтальные пробелы вокруг знака равенства
    :spaces (any (set " \t"))

    # Ключ, любые байты до пробела, знака = или конца строки.
    # Через отрицание, чтобы работала кириллица.
    :key (<- (some (if-not (set " \t=\n") 1)))

    # Значение, всё до конца строки
    :value (<- (any (if-not "\n" 1)))

    # Пара захватов на строку, обёрнутая в group
    :line (group (* :spaces :key :spaces "=" :spaces :value))

    :main (* :line (any (* "\n" :line)) (any "\n") -1)})

Проверяем:

Осталось превратить пары в таблицу:

(defn parse-config [text]
  (def result @{})
  (each [key value] (or (peg/match config-peg text) [])
    (put result key value))
  result)

Сними галочку group в стенде ниже и посмотри, во что превращается результат. Это лучший аргумент за группировку из всех, что я могу привести словами:

Без group захваты легли бы в один плоский массив @["имя" "Janet" "версия" "1.41"], и разбивать его на пары пришлось бы руками, по индексам. Группировка делает структуру результата явной прямо в грамматике, а не в коде, который потом эту грамматику разгребает.

Утилиты

Напоследок четыре инструмента вокруг peg/match, сведённые в одно место.

peg/compile компилирует шаблон заранее. Если грамматика работает в цикле, это экономит заметное время:

peg/find и peg/find-all ищут по всей строке и возвращают байтовые позиции:

Почему find-all вернул три позиции? Он пробует совпадение с каждой позиции подряд. В b22 совпадение находится и с позиции 4 (22), и с позиции 5 (2), обе попадают в результат. Если нужны непересекающиеся совпадения, разбирай строку грамматикой целиком, а не поиском.

peg/replace-all заменяет все совпадения:

Результат это буфер, а не строка. При необходимости приведи через (string ...).

Смещение третьим аргументом начинает разбор не с нуля:

Что запомнить

  • (<- x) захватывает текст, (group x) собирает захваты подшаблона в отдельный массив.
  • (% x) склеивает, (/ x f) пропускает через функцию, (number x) сразу даёт число.
  • Внутри шаблона функцию подставляют через запятую: ,scan-number.
  • Таблица правил стартует с :main, правила могут ссылаться сами на себя.
  • Классы :a, :w это ASCII. Для кириллицы описывай границы через if-not.
  • Позиции от ($), peg/find и peg/find-all считаются в байтах, не в символах.
  • peg/compile окупается в цикле, peg/find-all пробует каждую позицию, peg/replace-all возвращает буфер.
  • Структуру результата задавай грамматикой, а не постобработкой.

Упражнения

Дальше

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

домашка

Домашка