Парсер-комбинаторы с нуля, потом megaparsec
Чтобы read из прошлого урока перестал быть заглушкой, нужен парсер. Сначала напишем парсер-комбинаторы своими руками:
newtype Parserкак функция от входа вEither, инстансыFunctor,Applicative,Monad,Alternative, примитивsatisfyи из негоchar,string,many,some,choice,between,sepBy,token. Когда станет понятно, как это устроено изнутри, перепишем то же самое на megaparsec и дальше в проекте пользуемся ей.
Идея
Что вообще такое парсер? Самое полезное определение тут не “программа, которая читает текст”, а вот это: парсер это функция, которая откусывает начало входа и возвращает либо разобранное значение вместе с остатком, либо ошибку. Запиши это типом:
String -> Either String (a, String)
-- ^вход ^ошибка ^значение и непрочитанный хвост
Дай этой функции на вход "abc", попроси откусить одну букву, и получишь Right ('a', "bc"): значение 'a' и остаток "bc". Передай остаток дальше другому парсеру, и так по кусочку разбери всю строку. Из этой простой идеи растёт всё остальное.
Комбинатор это функция, которая из маленьких парсеров собирает большой: many p повторяет p, p <|> q пробует сначала p, потом q, between open close p снимает обёртку. Самое красивое тут в том, что весь словарь комбинаторов это не новая теория, а ровно та иерархия абстракций, которую ты уже прошёл:
Functorчерезfmapменяет разобранное значение, не трогая разбор:fmap (:[]) (char 'a')разберёт ту же букву, но вернёт строку, а не символ.Applicativeсеквенсит:(,) <$> p <*> qзапуститp, потомqна остатке, и склеит результаты в пару.Alternativeдаёт выбор<|>и из него повторmany(ноль и больше) иsome(один и больше).Monadчерез>>=позволяет грамматике ветвиться по уже прочитанному.
Поэтому сначала мы напишем Parser своими руками с этими четырьмя инстансами, увидим, что магии нет, а потом возьмём megaparsec, у которой ровно те же инстансы плюс хорошие сообщения об ошибках.
Прежде чем писать код, прогони комбинаторы в голове по шагам. Виджет ползёт по ленте входа слева направо: всё слева от курсора это consumed, всё справа это remaining. Посмотри, как many повторяет шаг до первого несовпадения, как <|> при неудаче первой альтернативы восстанавливает вход и пробует вторую, и как between снимает рамку и оставляет только середину.
Свой Parser
Оборачиваем функцию-парсер в newtype, чтобы повесить на неё инстансы. Заведи модуль Lishp.Parser.Combinators:
newtype Parser a = Parser {runParser :: String -> Either String (a, String)}
runParser это просто аксессор, который достаёт функцию обратно. Теперь четыре инстанса. Читай их как определения: каждый говорит, что значит скомбинировать парсеры этим способом.
instance Functor Parser where
fmap f (Parser p) = Parser $ \input ->
case p input of
Left err -> Left err -- ошибка проходит насквозь
Right (a, rest) -> Right (f a, rest) -- меняем значение, остаток как был
instance Applicative Parser where
pure x = Parser $ \input -> Right (x, input) -- ничего не съесть, вернуть x
Parser pf <*> Parser px = Parser $ \input ->
case pf input of
Left err -> Left err
Right (f, rest) -> -- первый дал функцию и остаток
case px rest of -- второй идёт по остатку
Left err -> Left err
Right (x, rest') -> Right (f x, rest')
instance Monad Parser where
Parser p >>= f = Parser $ \input ->
case p input of
Left err -> Left err
Right (a, rest) -> runParser (f a) rest -- f a зависит от a, что и нужно
instance Alternative Parser where
empty = Parser $ \_ -> Left "empty: no parse" -- всегда неудача
Parser p <|> Parser q = Parser $ \input ->
case p input of
Left _ -> q input -- p не вышел, пробуем q
success -> success -- p вышел, берём его
Вся идея в том, как Applicative и Monad протаскивают остаток: первый парсер съел кусок и вернул хвост, второй стартует с этого хвоста. Так последовательный разбор получается сам собой, без явного индекса и цикла. Alternative ловит ошибку и пробует запасной вариант, это и есть выбор грамматики.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.