gdritter repos config-ini / master src / Data / Ini / Config / Raw.hs
master

Tree @master (Download .tar.gz)

Raw.hs @masterraw · history · blame

{-|
Module     : Data.Ini.Config.Raw
Copyright  : (c) Getty Ritter, 2017
License    : BSD
Maintainer : Getty Ritter <config-ini@infinitenegativeutility.com>
Stability  : experimental

__Warning!__ This module is subject to change in the future, and therefore should
not be relied upon to have a consistent API.

-}
module Data.Ini.Config.Raw
( -- * INI types
  RawIni(..)
, IniSection(..)
, IniValue(..)
, BlankLine(..)
, NormalizedText(..)
, normalize
  -- * serializing and deserializing
, parseRawIni
, printRawIni
  -- * inspection
, lookupInSection
, lookupSection
, lookupValue
) where

import           Control.Monad (void)
import qualified Data.Foldable as F
import           Data.Monoid ((<>))
import           Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import           Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as LazyText
import qualified Data.Text.Lazy.Builder as Builder
import           Data.Void (Void)
import           Text.Megaparsec
import           Text.Megaparsec.Char

type Parser = Parsec Void Text

-- | The 'NormalizedText' type is an abstract representation of text
-- which has had leading and trailing whitespace removed and been
-- normalized to lower-case, but from which we can still extract the
-- original, non-normalized version. This acts like the normalized
-- text for the purposes of 'Eq' and 'Ord' operations, so
--
-- @
--   'normalize' "  x  " == 'normalize' \"X\"
-- @
--
-- This type is used to store section and key names in the
data NormalizedText = NormalizedText
  { actualText     :: Text
  , normalizedText :: Text
  } deriving (Show)

-- | The constructor function to build a 'NormalizedText' value. You
-- probably shouldn't be using this module directly, but if for some
-- reason you are using it, then you should be using this function to
-- create 'NormalizedText' values.
normalize :: Text -> NormalizedText
normalize t = NormalizedText t (T.toLower (T.strip t))

instance Eq NormalizedText where
  NormalizedText _ x == NormalizedText _ y =
    x == y

instance Ord NormalizedText where
  NormalizedText _ x `compare` NormalizedText _ y =
    x `compare` y

-- | An 'Ini' value is a mapping from section names to
--   'IniSection' values. The section names in this mapping are
--   normalized to lower-case and stripped of whitespace. This
--   sequence retains the ordering of the original source file.
newtype RawIni = RawIni
  { fromRawIni :: Seq (NormalizedText, IniSection)
  } deriving (Eq, Show)

-- | An 'IniSection' consists of a name, a mapping of key-value pairs,
--   and metadata about where the section starts and ends in the
--   file. The section names found in 'isName' are __not__ normalized
--   to lower-case or stripped of whitespace, and thus should appear
--   exactly as they appear in the original source file.
data IniSection = IniSection
  { isName      :: Text
                   -- ^ The name of the section, as it appears in the
                   -- original INI source
  , isVals      :: Seq (NormalizedText, IniValue)
                   -- ^ The key-value mapping within that section. Key
                   -- names here are normalized to lower-case and
                   -- stripped of whitespace. This sequence retains
                   -- the ordering of the original source file.
  , isStartLine :: Int
                   -- ^ The line on which the section begins. This
                   -- field is ignored when serializing, and is only
                   -- used for error messages produced when parsing
                   -- and deserializing an INI structure.
  , isEndLine   :: Int
                   -- ^ The line on which the section ends. This field
                   -- is ignored when serializing, and is only used
                   -- for error messages produced when parsing and
                   -- deserializing an INI structure.
  , isComments  :: Seq BlankLine
                   -- ^ The blank lines and comments that appear prior
                   -- to the section head declaration, retained for
                   -- pretty-printing identical INI files.
  } deriving (Eq, Show)

-- | An 'IniValue' represents a key-value mapping, and also stores the
--   line number where it appears. The key names and values found in
--   'vName' and 'vValue' respectively are _not_ normalized to
--   lower-case or stripped of whitespace, and thus should appear
--   exactly as they appear in the original source file.
data IniValue = IniValue
  { vLineNo       :: Int
                     -- ^ The line on which the key/value mapping
                     -- appears. This field is ignored when
                     -- serializing, and is only used for error
                     -- messages produced when parsing and
                     -- deserializing an INI structure.
  , vName         :: Text
                     -- ^ The name of the key, as it appears in the INI source.
  , vValue        :: Text
                     -- ^ The value of the key
  , vComments     :: Seq BlankLine
  , vCommentedOut :: Bool
    -- ^ Right now, this will never show up in a parsed INI file, but
    --   it's used when emitting a default INI file: it causes the
    --   key-value line to include a leading comment as well.
  , vDelimiter    :: Char
  } deriving (Eq, Show)

-- | We want to keep track of the whitespace/comments in between KV
--   lines, so this allows us to track those lines in a reproducible
--   way.
data BlankLine
  = CommentLine Char Text
  | BlankLine
    deriving (Eq, Show)

-- | Parse a 'Text' value into an 'Ini' value, retaining a maximal
-- amount of structure as needed to reconstruct the original INI file.
parseRawIni :: Text -> Either String RawIni
parseRawIni t = case runParser pIni "ini file" t of
  Left err -> Left (errorBundlePretty err)
  Right v  -> Right v

pIni :: Parser RawIni
pIni = do
  leading <- sBlanks
  pSections leading Seq.empty

sBlanks :: Parser (Seq BlankLine)
sBlanks = Seq.fromList <$> many ((BlankLine <$ void eol) <|> sComment)

sComment :: Parser BlankLine
sComment = do
  c <- oneOf ";#"
  txt <- T.pack `fmap` manyTill anySingle eol
  return (CommentLine c txt)

pSections :: Seq BlankLine -> Seq (NormalizedText, IniSection) -> Parser RawIni
pSections leading prevs =
  pSection leading prevs <|> (RawIni prevs <$ void eof)

pSection :: Seq BlankLine -> Seq (NormalizedText, IniSection) -> Parser RawIni
pSection leading prevs = do
  start <- getCurrentLine
  void (char '[')
  name <- T.pack `fmap` some (noneOf "[]")
  void (char ']')
  void eol
  comments <- sBlanks
  pPairs (T.strip name) start leading prevs comments Seq.empty

pPairs :: Text
       -> Int
       -> Seq BlankLine
       -> Seq (NormalizedText, IniSection)
       -> Seq BlankLine
       -> Seq (NormalizedText, IniValue)
       -> Parser RawIni
pPairs name start leading prevs comments pairs = newPair <|> finishedSection
  where
    newPair = do
      (n, pair) <- pPair comments
      rs <- sBlanks
      pPairs name start leading prevs rs (pairs Seq.|> (n, pair))
    finishedSection = do
      end <- getCurrentLine
      let newSection = IniSection
            { isName      = name
            , isVals      = pairs
            , isStartLine = start
            , isEndLine   = end
            , isComments  = leading
            }
      pSections comments (prevs Seq.|> (normalize name, newSection))

pPair :: Seq BlankLine -> Parser (NormalizedText, IniValue)
pPair leading = do
  pos <- getCurrentLine
  key <- T.pack `fmap` some (noneOf "[]=:")
  delim <- oneOf ":="
  val <- T.pack `fmap` manyTill anySingle eol
  return ( normalize key
         , IniValue
             { vLineNo       = pos
             , vName         = key
             , vValue        = val
             , vComments     = leading
             , vCommentedOut = False
             , vDelimiter    = delim
             } )

getCurrentLine :: Parser Int
getCurrentLine = (fromIntegral . unPos . sourceLine) `fmap` getSourcePos


-- | Serialize an INI file to text, complete with any comments which
-- appear in the INI structure, and retaining the aesthetic details
-- which are present in the INI file.
printRawIni :: RawIni -> Text
printRawIni = LazyText.toStrict . Builder.toLazyText . F.foldMap build . fromRawIni
  where
    build (_, ini) =
      F.foldMap buildComment (isComments ini) <>
      Builder.singleton '[' <>
      Builder.fromText (isName ini) <>
      Builder.fromString "]\n" <>
      F.foldMap buildKV (isVals ini)
    buildComment BlankLine = Builder.singleton '\n'
    buildComment (CommentLine c txt) =
      Builder.singleton c <> Builder.fromText txt <> Builder.singleton '\n'
    buildKV (_, val) =
      F.foldMap buildComment (vComments val) <>
      (if vCommentedOut val then Builder.fromString "# " else mempty) <>
      Builder.fromText (vName val) <>
      Builder.singleton (vDelimiter val) <>
      Builder.fromText (vValue val) <>
      Builder.singleton '\n'

-- | Look up an Ini value by section name and key. Returns the sequence
-- of matches.
lookupInSection :: Text
                -- ^ The section name. Will be normalized prior to
                -- comparison.
                -> Text
                -- ^ The key. Will be normalized prior to comparison.
                -> RawIni
                -- ^ The Ini to search.
                -> Seq.Seq Text
lookupInSection sec opt ini =
    vValue <$> (F.asum (lookupValue opt <$> lookupSection sec ini))

-- | Look up an Ini section by name. Returns a sequence of all matching
-- section records.
lookupSection :: Text
              -- ^ The section name. Will be normalized prior to
              -- comparison.
              -> RawIni
              -- ^ The Ini to search.
              -> Seq.Seq IniSection
lookupSection name ini =
    snd <$> (Seq.filter ((== normalize name) . fst) $ fromRawIni ini)

-- | Look up an Ini key's value in a given section by the key. Returns
-- the sequence of matches.
lookupValue :: Text
            -- ^ The key. Will be normalized prior to comparison.
            -> IniSection
            -- ^ The section to search.
            -> Seq.Seq IniValue
lookupValue name section =
    snd <$> Seq.filter ((== normalize name) . fst) (isVals section)