В данной статье я расскажу как написать свой парсер ini-файлов на Haskell. За основу возьму контекстно-свободную грамматику, построенную в моей предыдущей статье. Для построения парсера будет использоваться библиотека Parsec, которая позволяет строить свои собственные парсеры комбинируя готовые примитивные парсеры при помощи парсерных комбинаторов.
Важно: в данной статье предполагается, что читатель знаком с основами Haskell. Если это не так, то я советую сначала прочитать пару статей для новичков (их можно найти в том числе и на Хабре).
Для начала вспомним какую грамматику для ini-фалов мы построили в предыдущей статье:
Её описание нам скоро понадобится.
Начните с установки Parsec (можно взять на официальном сайте или поискать готовые пакеты для вашей OS). Процесс установки для разных систем может быть различным, поэтому я не буду его здесь описывать.
Я постараюсь подробно описать процесс создания парсера на Haskell. Начнём с подключения необходимых модулей. Кроме стандартных System (для получения параметров), Data.Char (для функции isSpace) и Data.List (для функции find) нужно подключить модуль Parsec — Text.ParserCombinators.Parsec.
Определим типы данных: запись — это пара «ключ — значение», секция — это пара «ключ — список записей», все данные ini-файла — это список секций.
Теперь будем переносить грамматику из нотации Бэкуса-Наура в Haskell. Начнём с inidata.
Объясню, что тут написано: inidata состоит из пробелов (это примитивный парсер библиотеки Parsec), за которыми следует (обозначается монадическим оператором >>) много секций, значения которых мы возвращаем (>>= return).
Что значит возвращаем значения? Задача парсера не только проверить соответствие грамматики и данных, но и ещё преобразовать данные в какой-то структурный вид. В нашем случае — это тип данных IniData. Функция many — это комбинатор парсеров, который по некоторому парсеру для нетерминала A строит парсер для {A}.
Теперь переведём в Haskell нетерминал section. section намного сложнее inidata и поэтому я запишу его в do-нотации.
Этот код — почти дословный перевод нетерминала section из нотации Бэкуса-Наура. Функция char создаёт примитивный парсер, который парсит один символ. Стоит обратить внимание на строки 16, 21 и 22. В строке 16 мы сохраняем значение нетерминала ident (имени секции), а в строке 21 сохраняем список записей, которые идут за заголовком секции. В строке 22 мы возвращаем прочитанные название секции и список записей (это соответствует типу Section).
Переходим к записям.
Если вы поняли, как мы построили парсер для section, то тут никаких проблем быть не должно. В кратце: в строчках 25 и 29 сохраняем имя параметра и его значение, и возвращаем пару составленную из них (соответствует типу Entry).
Запишем нетерминал для идентификатора. Мы воспользуемся тем, что в Parsec есть комбинатор many1, который позволит склеить нетерминалы identChar и ident в один (мы не могли сделать это в нотации Бэкуса-Наура, т.к. там нет подобного обозначения).
Комбинатор many1 означает, что идентификатор состоит хотя бы из одного символа. Оператор <|> соответствует знаку "|" в нотации Бэкуса-Наура. letter и digit — примитивные парсеры для букв и цифр соответственно. Функция oneOf для строки эквивалентна (char '_' <|> char '.' <|> .....). Отметим также, что при возвращении значения полученная строки обрезается (при помощи функции trim).
Аналогично поступаем с нетерминалом для значения, но используя парсер noneOf, который обратен к oneOf.
Остался последний нетерминал — stringSpaces (нетерминал spaces уже есть в Parsec).
С грамматикой всё. Осталось определить несколько полезных функций и, конечно, сам main.
Функция trim нужна для удаления лишних пробелов в начале и в конце строки.
Функция split разделяет текст на строки используя разделитель delim, причём сам разделитель остаётся в конце строки.
Функция removeComments удаляет комментарии и пустые строки: она разбивает текст на строки, удаляет те из них, которые начинаются с ";" или "\n", а потом снова их склеивает.
Функция findValue ищет в IniData значение параметра по имени секции и имени параметра (вычисление происходит в монаде Maybe). Сначала находим секцию по имени, а потом среди записей из секции находим нужный параметр. Если в какой-то момент мы ничего не найдём, то функция просто вернёт Nothing.
Переходим к последнему шагу — функции main.
Здесь всё просто как в старом добром C. Строки 57-58 — получаем параметры и имя программы. Далее, если параметров не 3, то выводим usage. Если с параметрами всё ок, то читаем файл (62) и удаляем комментарии (64).
Теперь нужно запустить парсер. Для этого есть функция parse (65), которой нужно передать главный нетерминал, имя текста (используется для вывода ошибок) и сам текст. Функция parse возвращает либо описание ошибки (Left, 65), либо полученные данные (Right, 66). Если всё распарсилось, то в полученных данных мы ищем запись по имени секции и имени параметра (67). Поиск может вернуть либо найденное значение (Just, 68), тогда мы его выводим, либо ничего (Nothing, 69) — тогда выводим сообщение об ошибке.
Теперь код полностью написан. Компилируем его и запускаем на тестовом примере.
Надеюсь, что данная статья поможет вам написать свой собственный парсер =)
Интересное замечание: вы можете сравнить парсер из этой статьи с парсером на С++ из статьи «Создаём парсер для ini-файлов на С++».
PS. Спасибо, что помогли перенести этот пост в блог Haskell.
Важно: в данной статье предполагается, что читатель знаком с основами Haskell. Если это не так, то я советую сначала прочитать пару статей для новичков (их можно найти в том числе и на Хабре).
Грамматика
Для начала вспомним какую грамматику для ini-фалов мы построили в предыдущей статье:
inidata = spaces, {section} .
section = "[", ident, "]", stringSpaces, "\n", {entry} .
entry = ident, stringSpaces, "=", stringSpaces, value, "\n", spaces .
ident = identChar, {identChar} .
identChar = letter | digit | "_" | "." | "," | ":" | "(" | ")" | "{" | "}" | "-" | "#" | "@" | "&" | "*" | "|" .
value = {not "\n"} .
stringSpaces = {" " | "\t"} .
spaces = {" " | "\t" | "\n" | "\r"} .
Её описание нам скоро понадобится.
Haskell и Parsec
Начните с установки Parsec (можно взять на официальном сайте или поискать готовые пакеты для вашей OS). Процесс установки для разных систем может быть различным, поэтому я не буду его здесь описывать.
Я постараюсь подробно описать процесс создания парсера на Haskell. Начнём с подключения необходимых модулей. Кроме стандартных System (для получения параметров), Data.Char (для функции isSpace) и Data.List (для функции find) нужно подключить модуль Parsec — Text.ParserCombinators.Parsec.
1 module Main where
2
3 import System.Environment
4 import Data.Char
5 import Data.List
6 import Text.ParserCombinators.Parsec
Определим типы данных: запись — это пара «ключ — значение», секция — это пара «ключ — список записей», все данные ini-файла — это список секций.
8 type Entry = (String, String)
9 type Section = (String, [Entry])
10 type IniData = [Section]
Теперь будем переносить грамматику из нотации Бэкуса-Наура в Haskell. Начнём с inidata.
12 inidata = spaces >> many section >>= return
Объясню, что тут написано: inidata состоит из пробелов (это примитивный парсер библиотеки Parsec), за которыми следует (обозначается монадическим оператором >>) много секций, значения которых мы возвращаем (>>= return).
Что значит возвращаем значения? Задача парсера не только проверить соответствие грамматики и данных, но и ещё преобразовать данные в какой-то структурный вид. В нашем случае — это тип данных IniData. Функция many — это комбинатор парсеров, который по некоторому парсеру для нетерминала A строит парсер для {A}.
Теперь переведём в Haskell нетерминал section. section намного сложнее inidata и поэтому я запишу его в do-нотации.
14 section = do
15 char '['
16 name <- ident
17 char ']'
18 stringSpaces
19 char '\n'
20 spaces
21 el <- many entry
22 return (name, el)
Этот код — почти дословный перевод нетерминала section из нотации Бэкуса-Наура. Функция char создаёт примитивный парсер, который парсит один символ. Стоит обратить внимание на строки 16, 21 и 22. В строке 16 мы сохраняем значение нетерминала ident (имени секции), а в строке 21 сохраняем список записей, которые идут за заголовком секции. В строке 22 мы возвращаем прочитанные название секции и список записей (это соответствует типу Section).
Переходим к записям.
24 entry = do
25 k <- ident
26 stringSpaces
27 char '='
28 stringSpaces
29 v <- value
30 spaces
31 return (k, v)
Если вы поняли, как мы построили парсер для section, то тут никаких проблем быть не должно. В кратце: в строчках 25 и 29 сохраняем имя параметра и его значение, и возвращаем пару составленную из них (соответствует типу Entry).
Запишем нетерминал для идентификатора. Мы воспользуемся тем, что в Parsec есть комбинатор many1, который позволит склеить нетерминалы identChar и ident в один (мы не могли сделать это в нотации Бэкуса-Наура, т.к. там нет подобного обозначения).
32 ident = many1 (letter <|> digit <|> oneOf "_.,:(){}-#@&*|") >>= return . trim
Комбинатор many1 означает, что идентификатор состоит хотя бы из одного символа. Оператор <|> соответствует знаку "|" в нотации Бэкуса-Наура. letter и digit — примитивные парсеры для букв и цифр соответственно. Функция oneOf для строки эквивалентна (char '_' <|> char '.' <|> .....). Отметим также, что при возвращении значения полученная строки обрезается (при помощи функции trim).
Аналогично поступаем с нетерминалом для значения, но используя парсер noneOf, который обратен к oneOf.
34 value = many (noneOf "\n") >>= return . trim
Остался последний нетерминал — stringSpaces (нетерминал spaces уже есть в Parsec).
36 stringSpaces = many (char ' ' <|> char '\t')
С грамматикой всё. Осталось определить несколько полезных функций и, конечно, сам main.
Функция trim нужна для удаления лишних пробелов в начале и в конце строки.
38 trim = f . f
39 where f = reverse . dropWhile isSpace
Функция split разделяет текст на строки используя разделитель delim, причём сам разделитель остаётся в конце строки.
41 split delim = foldr f [[]]
42 where
43 f x rest@(r:rs)
44 | x == delim = [delim] : rest
45 | otherwise = (x : r) : rs
Функция removeComments удаляет комментарии и пустые строки: она разбивает текст на строки, удаляет те из них, которые начинаются с ";" или "\n", а потом снова их склеивает.
47 removeComments = foldr (++) [] . filter comment . split '\n'
48 where comment [] = False
49 comment (x:_) = (x /= ';') && (x /= '\n')
Функция findValue ищет в IniData значение параметра по имени секции и имени параметра (вычисление происходит в монаде Maybe). Сначала находим секцию по имени, а потом среди записей из секции находим нужный параметр. Если в какой-то момент мы ничего не найдём, то функция просто вернёт Nothing.
51 findValue ini s p = do
52 el <- find (\x->fst x == s) ini
53 v <- find (\x->fst x == p) (snd el)
54 return $ snd $ v
Переходим к последнему шагу — функции main.
56 main = do
57 args <- getArgs
58 prog <- getProgName
59 if (length args) /= 3
60 then putStrLn $ "Usage: " ++ prog ++ " <file.ini> <section> <parameter>"
61 else do
62 file <- readFile $ head args
63 [s,p] <- return $ tail args
64 lns <- return ( removeComments file )
65 case (parse inidata "some text" lns) of
66 Left err -> putStr "Parse error: " >> print err
67 Right x -> case (findValue x s p) of
68 Just x -> putStrLn x
69 Nothing-> putStrLn "Can't find requested parameter"
70 return ()
Здесь всё просто как в старом добром C. Строки 57-58 — получаем параметры и имя программы. Далее, если параметров не 3, то выводим usage. Если с параметрами всё ок, то читаем файл (62) и удаляем комментарии (64).
Теперь нужно запустить парсер. Для этого есть функция parse (65), которой нужно передать главный нетерминал, имя текста (используется для вывода ошибок) и сам текст. Функция parse возвращает либо описание ошибки (Left, 65), либо полученные данные (Right, 66). Если всё распарсилось, то в полученных данных мы ищем запись по имени секции и имени параметра (67). Поиск может вернуть либо найденное значение (Just, 68), тогда мы его выводим, либо ничего (Nothing, 69) — тогда выводим сообщение об ошибке.
Теперь код полностью написан. Компилируем его и запускаем на тестовом примере.
$ ghc --make ini.hs -o ini_hs
[1 of 1] Compiling Main ( ini.hs, ini.o )
Linking ini_hs ...
$ ./ini_hs /usr/lib/firefox-3.0.5/application.ini App ID
{ec8030f7-c20a-464f-9b0e-13a3a9e97384}
$ ./ini_hs /usr/lib/firefox-3.0.5/application.ini App IDD
Can't find requested parameter
Надеюсь, что данная статья поможет вам написать свой собственный парсер =)
Интересное замечание: вы можете сравнить парсер из этой статьи с парсером на С++ из статьи «Создаём парсер для ini-файлов на С++».
PS. Спасибо, что помогли перенести этот пост в блог Haskell.