Свой парсер разметки без библиотек
Слой 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.