{-# LANGUAGE CPP #-} module Data.Tini.Parser (parseIni) where import Control.Applicative (Alternative, (<|>)) import Control.Monad ((<=<)) #if !MIN_VERSION_base(4, 11, 0) import Data.Monoid #endif #if !MIN_VERSION_base(4, 13, 0) import Prelude hiding (fail) import Control.Monad.Fail (MonadFail, fail) #endif import Data.Char (isSpace) import Data.Function (on) import Data.List (intercalate, sortBy, groupBy) import Data.Tini.Types import Data.Tini.Utils (trim, ltrim) -- | Parses an INI file from the given string. -- -- A valid INI file may contain zero or more lines, where each line is any -- of the following: -- -- * A @[section header]@ in square brackets; -- * a @key = value@ pair; -- * a comment, starting with either @;@ or @#@; or -- * whitespace. -- -- Note that a valid INI file must not contain duplicate section headers, -- and keys must be unique within their section. -- Section headers and keys are case-sensitive. -- Values must be contained on a single line. -- Whitespace is ignored at the start and end of each line, section header, -- key, and value. parseIni :: (Alternative m, MonadFail m) => String -> m Ini parseIni = fmap Ini . undupe "sections" <=< parseSections "" . filterWS . lines where filterWS = filter (not . null) . map ltrim undupe :: (Show k, Ord k, MonadFail m) => String -> [(k, v)] -> m [(k, v)] undupe what ss = case map (fst . head) $ filter duplicate (dupeGroups ss) of [] -> return ss ds -> fail $ "duplicate " <> what <> ": " <> intercalate ", " (map show ds) where duplicate (_:_:_) = True duplicate _ = False dupeGroups = groupBy ((==) `on` fst) . sortBy (compare `on` fst) parseSections :: (Alternative m, MonadFail m) => String -> [String] -> m [Section] parseSections name lns = case break isSectionHead lns of ([], []) -> return [] (section, []) -> do props <- parseProps section return [(SN name, props)] (section, s:ss) -> do name' <- parseSectionHead s props <- parseProps section sections <- parseSections name' ss if null section then return sections else return ((SN name, props):sections) parseProps :: (Alternative m, MonadFail m) => [String] -> m [Property] parseProps = undupe "keys" <=< mapM (\s -> parseComment s <|> parseProp s) isSectionHead :: String -> Bool isSectionHead ('[':_) = True isSectionHead _ = False parseSectionHead :: MonadFail m => String -> m String parseSectionHead ('[':s) = case break (== ']') s of (name, ']':suffix) | all isSpace suffix -> return name | otherwise -> fail $ "section head '" <> name <> "' has trailing garbage" _ -> fail $ "unclosed section head '" <> s <> "'" parseSectionHead s = fail $ "expected section head, but got '" <> s <> "'" parseProp :: MonadFail m => String -> m Property parseProp s = case break (== '=') s of (k@(_:_), '=':v) -> return (KeyPart (trim k), trim v) _ -> fail $ "expected 'key = value', but got '" <> s <> "'" parseComment :: MonadFail m => String -> m Property parseComment (';':s) = return (Comment ";", s) parseComment ('#':s) = return (Comment "#", s) parseComment s = fail $ "expected comment, but got '" <> s <> "'"