Раздел 21 · Haskell

Свой парсер разметки без библиотек

middle-senior~30 мин

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

Свой парсер разметки без библиотек

Слой HTML есть, но никто не пишет блог вызовами p_ и h_ руками. Нужен входной формат: простая разметка, которую приятно печатать. В прошлом проекте мы парсили Lisp через megaparsec; тут специально пойдём другим путём и напишем парсер вообще без библиотек. Наша разметка построчная, поэтому весь парсер это обычная структурная рекурсия по списку строк с одним аккумулятором. Никаких комбинаторов, никакого backtracking, никакой монады. И главное: на выходе будет тип Document, который по построению не умеет описывать кривую структуру.

Идея

Сначала договоримся о языке разметки. Он строчный, и тип строки задаёт её первый символ:

* Заголовок

Обычный абзац. Несколько строк подряд
склеиваются в один абзац.

- пункт списка
- ещё пункт

# нумерованный пункт
# ещё нумерованный

> строка кода один в один

Пустая строка закрывает текущий блок. Соседние строки одного вида сливаются: три строки - ... это один список из трёх пунктов, а не три списка по одному.

Теперь решающий вопрос дизайна. Что вернёт парсер? Заманчиво вернуть строку HTML сразу. Но это смешивает две заботы (понять структуру и отрендерить её) и заставляет всех ниже по течению гадать, что там в строке. Вместо этого парсер вернёт значение нашего типа Document, в котором структура уже разобрана и зафиксирована. Это и есть принцип “parse, don’t validate”: один раз превращаем бесформенный текст в строго типизированное значение, и дальше вся программа работает с Document, ничего не перепроверяя.

Тип документа

module Press.Markup
  ( Document
  , Structure (..)
  , parse
  )
where

import Data.Maybe (maybeToList)
import Numeric.Natural (Natural)

type Document = [Structure]

data Structure
  = Heading Natural String
  | Paragraph String
  | UnorderedList [String]
  | OrderedList [String]
  | CodeBlock [String]
  deriving (Eq, Show)

Document это плоский список блоков, а Structure это sum-тип со всеми видами блока, которые понимает разметка. Заметь имя типа: Structure тут это другой Structure, не тот, что в Press.Html. Это нормально: они в разных модулях, и при импорте мы разведём их через qualified. Один описывает разобранную разметку, другой готовый HTML-блок. В следующем уроке мост между ними станет отдельной чистой функцией.

deriving (Eq, Show) тут не для красоты: Eq нужен, чтобы сравнивать ожидаемый результат в тестах, Show чтобы видеть, что именно разобралось.

Парсер: рекурсия с одним аккумулятором

Точка входа разбивает текст на строки и запускает рекурсию без активного блока:

parse :: String -> Document
parse = parseLines Nothing . lines

Тип parse :: String -> Document уже многое обещает: на выходе всегда Document, нет Maybe, нет Either. Это тотальная функция: любой текст это какой-то документ, разбирать нечего, ошибки парсинга не существует. Это сознательный выбор: разметка прощающая, мы скорее истолкуем странный ввод, чем отвергнем его.

Вся работа в parseLines. Первый аргумент это аккумулятор: блок, который мы сейчас набираем (многострочный абзац, растущий список, блок кода), или Nothing в промежутке между блоками. Каждая ветка решает, продолжает ли следующая строка текущий блок или закрывает его и начинает новый.

parseLines :: Maybe Structure -> [String] -> Document
parseLines context txts =
  case txts of
    -- Вход кончился: вытолкнуть то, что набирали.
    [] -> maybeToList context
    -- Заголовок: "* " начинает h1.
    ('*' : ' ' : line) : rest ->
      maybe id (:) context (Heading 1 (trim line) : parseLines Nothing rest)
    -- Пункт маркированного списка: "- ".
    ('-' : ' ' : line) : rest ->
      case context of
        Just (UnorderedList list) ->
          parseLines (Just (UnorderedList (list <> [trim line]))) rest
        _ ->
          maybe id (:) context (parseLines (Just (UnorderedList [trim line])) rest)
    -- Пункт нумерованного списка: "# ".
    ('#' : ' ' : line) : rest ->
      case context of
        Just (OrderedList list) ->
          parseLines (Just (OrderedList (list <> [trim line]))) rest
        _ ->
          maybe id (:) context (parseLines (Just (OrderedList [trim line])) rest)
    -- Строка кода: "> " хранит тело дословно, обрезаем только маркер.
    ('>' : ' ' : line) : rest ->
      case context of
        Just (CodeBlock code) ->
          parseLines (Just (CodeBlock (code <> [line]))) rest
        _ ->
          maybe id (:) context (parseLines (Just (CodeBlock [line])) rest)
    -- Всё прочее это текст абзаца. Пустая строка закрывает абзац.
    currentLine : rest ->
      let line = trim currentLine
       in if line == ""
            then maybe id (:) context (parseLines Nothing rest)
            else case context of
              Just (Paragraph paragraph) ->
                parseLines (Just (Paragraph (unwords [paragraph, line]))) rest
              _ ->
                maybe id (:) context (parseLines (Just (Paragraph line)) rest)

trim :: String -> String
trim = unwords . words

Разберём идиому, на которой всё держится: maybe id (:) context. Это аккуратный способ сказать “если есть набранный блок, добавь его в голову результата, иначе ничего не делай”.

  • Если context это Nothing, выходит maybe id (:) Nothing = id, то есть результат идёт дальше без изменений.
  • Если context это Just block, выходит maybe id (:) (Just block) = (block :), то есть блок встаёт в голову списка.

Эта одна функция заменяет ветвление if-then-else и читается как “флашни накопленное, если оно есть”. Логика везде одинаковая: встретили строку нового вида, значит предыдущий блок закончился, выталкиваем его и начинаем новый.

Случаи списков и блока кода тонкие: если мы уже внутри списка того же вида, то не закрываем его, а дополняем аккумулятор (list <> [trim line]) и идём дальше с тем же контекстом. Так соседние - ... сливаются в один UnorderedList. Абзац ведёт себя так же: соседние непустые строки склеиваются через unwords.

trim это unwords . words: схлопывает любые пробелы внутри и по краям до одиночных. Заметь, что в блоке кода мы зовём trim не на теле, а только убираем маркер > : внутри кода пробелы значимы, и портить их нельзя.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

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