ini 0.2.2 → 0.5.1
raw patch · 5 files changed
Files
- CHANGELOG.md +38/−0
- README.md +63/−0
- ini.cabal +53/−6
- src/Data/Ini.hs +166/−31
- test/Main.hs +191/−0
+ CHANGELOG.md view
@@ -0,0 +1,38 @@+## 0.5.1++_2025-12-20, Jan Hrček_++- Fixed a parser bug where parsing would fail in presence of comments at the end of file+[#12](https://github.com/jhrcek/ini/pull/12)++## 0.5.0++_2023-02-08, Chris Martin_++The behavior of `(<>)` for the `Ini` type has changed+[#2](https://github.com/andreasabel/ini/issues/2)++- `<>` previously discarded all `iniGlobals`. Now it concatenates+ the globals from the two `Ini` values.++- When two `Ini` values contained `iniSections` with the same name,+ `<>` previously returned the section from the left value and+ discarded the section of the same name from the right value.+ Now it concatenates the sections of the same name.++Tested with GHC 7.0 - ghc-9.6.0.20230128.++## 0.4.2++_2022-07-26, Andreas Abel_++- Fail parsing if the input is not completely consumed [#30](https://github.com/chrisdone/ini/pull/30)+- Print global values as well [#28](https://github.com/chrisdone/ini/pull/28)++Tested with GHC 7.0 - 9.4.1 RC1.++## 0.4.1++_2019-01-02, Chris Done_++- Allow global section [#6](https://github.com/chrisdone/ini/issues/6)
+ README.md view
@@ -0,0 +1,63 @@+[](https://hackage.haskell.org/package/ini)+[](https://stackage.org/nightly/package/ini)+[](https://www.stackage.org/package/ini)+[](https://github.com/jhrcek/ini/actions/workflows/haskell-ci.yml)++ini+===++Quick and easy configuration files in the INI format for Haskell.++Format rules and recommendations:++* `foo: bar` or `foo=bar` are allowed.+* The `:` syntax is space-sensitive.+* Keys are case-sensitive.+* Lower-case is recommended.+* Values can be empty.+* Keys cannot contain `:`, `=`, `[`, or `]`.+* Comments must start at the beginning of the line with `;` or `#`.++An example configuration file:++``` ini+# Some comment.+[SERVER]+port=6667+hostname=localhost+[AUTH]+user=hello+pass=world+# Salt can be an empty string.+salt=+```++Parsing example:++``` haskell+> parseIni "[SERVER]\nport: 6667\nhostname: localhost"+Right (Ini {unIni = fromList [("SERVER",fromList [("hostname","localhost")+ ,("port","6667")])]})+```++Extracting values:++``` haskell+> parseIni "[SERVER]\nport: 6667\nhostname: localhost" >>=+ lookupValue "SERVER" "hostname"+Right "localhost"+```++Parsing:++``` haskell+> parseIni "[SERVER]\nport: 6667\nhostname: localhost" >>=+ readValue "SERVER" "port" decimal+Right 6667+```++Import `Data.Text.Read` to use `decimal`.++## Related packages++[`ini-qq`](https://hackage.haskell.org/package/ini-qq) provides a quasiquoter for INI.
ini.cabal view
@@ -1,22 +1,69 @@+cabal-version: >= 1.10 name: ini-version: 0.2.2-synopsis: Quick and easy configuration files in the INI format.+version: 0.5.1+synopsis: Configuration files in the INI format. description: Quick and easy configuration files in the INI format. license: BSD3 license-file: LICENSE author: Chris Done-maintainer: chrisdone@gmail.com+maintainer: Jan Hrček+homepage: https://github.com/jhrcek/ini+bug-reports: https://github.com/jhrcek/ini/issues copyright: 2013 Chris Done category: Data, Configuration build-type: Simple-cabal-version: >=1.8 +tested-with:+ GHC == 9.12.2+ || == 9.10.3+ || == 9.8.4+ || == 9.6.7+ || == 9.4.8+ || == 9.2.8+ || == 9.0.2+ || == 8.10.7+ || == 8.8.4+ || == 8.6.5+ || == 8.4.4+ || == 8.2.2+ || == 8.0.2++extra-source-files:+ CHANGELOG.md+ README.md+ library hs-source-dirs: src/- ghc-options: -Wall -O2- extensions: OverloadedStrings exposed-modules: Data.Ini build-depends: base >= 4 && <5, attoparsec, text, unordered-containers+ if !impl(ghc >= 8)+ build-depends: semigroups >= 0.10 && < 0.21++ default-language: Haskell98+ default-extensions: OverloadedStrings+ TypeOperators++ ghc-options: -Wall+ if impl(ghc >= 8)+ ghc-options: -Wcompat++test-suite ini-test+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ ghc-options: -Wall+ build-depends: base >= 4 && <5+ , QuickCheck+ , ini+ , hspec+ , text+ , unordered-containers++ default-language: Haskell98++source-repository head+ type: git+ location: https://github.com/jhrcek/ini.git
src/Data/Ini.hs view
@@ -41,13 +41,24 @@ readIniFile ,parseIni ,lookupValue+ ,lookupArray ,readValue+ ,readArray ,parseValue+ ,sections+ ,keys -- * Writing- ,writeIniFile ,printIni+ ,writeIniFile+ -- * Advanced writing+ ,KeySeparator(..)+ ,WriteIniSettings(..)+ ,defaultWriteIniSettings+ ,printIniWith+ ,writeIniFileWith -- * Types ,Ini(..)+ ,unIni -- * Parsers ,iniParser ,sectionParser@@ -55,23 +66,45 @@ ) where +import Control.Applicative import Control.Monad-import Control.Applicative ((<*)) import Data.Attoparsec.Combinator import Data.Attoparsec.Text import Data.Char-import Data.HashMap.Strict (HashMap)-import qualified Data.HashMap.Strict as M-import Data.Monoid-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.IO as T-import Prelude hiding (takeWhile)+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as M+import Data.Maybe+import Data.Semigroup+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as T+import Prelude hiding (takeWhile) -- | An INI configuration.-newtype Ini = Ini { unIni :: HashMap Text (HashMap Text Text) }- deriving (Show)+data Ini =+ Ini+ { iniSections :: HashMap Text [(Text, Text)]+ , iniGlobals :: [(Text, Text)]+ }+ deriving (Show, Eq) +-- | '<>' concatenates the lists of entries within each section (since @ini-0.5.0@)+instance Semigroup Ini where+ x <> y =+ Ini+ { iniGlobals = iniGlobals x ++ iniGlobals y+ , iniSections = M.unionWith (++) (iniSections x) (iniSections y)+ }++instance Monoid Ini where+ mempty = Ini {iniGlobals = mempty, iniSections = mempty}+ mappend = (<>)+++{-# DEPRECATED #-}+unIni :: Ini -> HashMap Text (HashMap Text Text)+unIni = fmap M.fromList . iniSections+ -- | Parse an INI file. readIniFile :: FilePath -> IO (Either String Ini) readIniFile = fmap parseIni . T.readFile@@ -80,25 +113,89 @@ parseIni :: Text -> Either String Ini parseIni = parseOnly iniParser --- | Lookup values in the config.-lookupValue :: Text -> Text -> Ini -> Either String Text-lookupValue name key (Ini ini) =- case M.lookup name ini of+-- | Lookup one value in the config.+--+-- Example:+--+-- >>> parseIni "[SERVER]\nport: 6667\nhostname: localhost" >>= lookupValue "SERVER" "hostname"+-- Right "localhost"+lookupValue :: Text -- ^ Section name+ -> Text -- ^ Key+ -> Ini -> Either String Text+lookupValue name key (Ini {iniSections=secs}) =+ case M.lookup name secs of Nothing -> Left ("Couldn't find section: " ++ T.unpack name) Just section ->- case M.lookup key section of+ case lookup key section of Nothing -> Left ("Couldn't find key: " ++ T.unpack key) Just value -> return value +-- | Lookup one value in the config.+--+-- Example:+--+-- >>> parseIni "[SERVER]\nport: 6667\nhostname: localhost" >>= lookupValue "SERVER" "hostname"+-- Right "localhost"+lookupArray :: Text -- ^ Section name+ -> Text -- ^ Key+ -> Ini -> Either String [Text]+lookupArray name key (Ini {iniSections = secs}) =+ case M.lookup name secs of+ Nothing -> Left ("Couldn't find section: " ++ T.unpack name)+ Just section ->+ case mapMaybe+ (\(k, v) ->+ if k == key+ then Just v+ else Nothing)+ section of+ [] -> Left ("Couldn't find key: " ++ T.unpack key)+ values -> return values++-- | Get the sections in the config.+--+-- Example:+--+-- >>> sections <$> parseIni "[SERVER]\nport: 6667\nhostname: localhost"+-- Right ["SERVER"]+sections :: Ini -> [Text]+sections = M.keys . iniSections++-- | Get the keys in a section.+--+-- Example:+--+-- >>> parseIni "[SERVER]\nport: 6667\nhostname: localhost" >>= keys "SERVER"+-- Right ["hostname","port"]+keys :: Text -- ^ Section name+ -> Ini -> Either String [Text]+keys name i =+ case M.lookup name (iniSections i) of+ Nothing -> Left ("Couldn't find section: " ++ T.unpack name)+ Just section -> Right (map fst section)+ -- | Read a value using a reader from "Data.Text.Read".-readValue :: Text -> Text -> (Text -> Either String (a, Text))+readValue :: Text -- ^ Section name+ -> Text -- ^ Key+ -> (Text -> Either String (a, Text)) -> Ini -> Either String a readValue section key f ini = lookupValue section key ini >>= f >>= return . fst +-- | Read an array of values using a reader from "Data.Text.Read".+readArray :: Text -- ^ Section name+ -> Text -- ^ Key+ -> (Text -> Either String (a, Text))+ -> Ini+ -> Either String [a]+readArray section key f ini =+ fmap (map fst) (lookupArray section key ini >>= mapM f)+ -- | Parse a value using a reader from "Data.Attoparsec.Text".-parseValue :: Text -> Text -> Parser a+parseValue :: Text -- ^ Section name+ -> Text -- ^ Key+ -> Parser a -> Ini -> Either String a parseValue section key f ini =@@ -106,42 +203,80 @@ -- | Print the INI config to a file. writeIniFile :: FilePath -> Ini -> IO ()-writeIniFile fp = T.writeFile fp . printIni+writeIniFile = writeIniFileWith defaultWriteIniSettings -- | Print an INI config. printIni :: Ini -> Text-printIni (Ini sections) =- T.concat (map buildSection (M.toList sections))+printIni = printIniWith defaultWriteIniSettings++-- | Either @:@ or @=@.+data KeySeparator+ = ColonKeySeparator+ | EqualsKeySeparator+ deriving (Bounded, Enum, Eq, Show)++-- | Settings determining how an INI file is written.+data WriteIniSettings = WriteIniSettings+ { writeIniKeySeparator :: KeySeparator+ } deriving (Show)++-- | The default settings for writing INI files.+defaultWriteIniSettings :: WriteIniSettings+defaultWriteIniSettings = WriteIniSettings+ { writeIniKeySeparator = ColonKeySeparator+ }++-- | Print the INI config to a file.+writeIniFileWith :: WriteIniSettings -> FilePath -> Ini -> IO ()+writeIniFileWith wis fp = T.writeFile fp . printIniWith wis++-- | Print an INI config.+printIniWith :: WriteIniSettings -> Ini -> Text+printIniWith wis i =+ T.concat $ map buildPair (iniGlobals i) +++ map buildSection (M.toList (iniSections i)) where buildSection (name,pairs) = "[" <> name <> "]\n" <>- T.concat (map buildPair (M.toList pairs))+ T.concat (map buildPair pairs) buildPair (name,value) =- name <> ": " <> value <> "\n"+ name <> separator <> value <> "\n"+ separator = case writeIniKeySeparator wis of+ ColonKeySeparator -> ": "+ EqualsKeySeparator -> "=" -- | Parser for an INI. iniParser :: Parser Ini-iniParser = fmap Ini (fmap M.fromList (many1 sectionParser))+iniParser =+ (\kv secs -> Ini {iniSections = M.fromList secs, iniGlobals = kv}) <$>+ many keyValueParser <*>+ many sectionParser <*+ skipComments <*+ (endOfInput <|> (fail . T.unpack =<< takeWhile (not . isControl))) -- | A section. Format: @[foo]@. Conventionally, @[FOO]@.-sectionParser :: Parser (Text,HashMap Text Text)+sectionParser :: Parser (Text,[(Text, Text)]) sectionParser =- do skipComments+ do skipEndOfLine+ skipComments+ skipEndOfLine _ <- char '[' name <- takeWhile (\c -> c /=']' && c /= '[') _ <- char ']' skipEndOfLine- values <- many1 keyValueParser- return (name,M.fromList values)+ values <- many keyValueParser+ return (T.strip name, values) -- | A key-value pair. Either @foo: bar@ or @foo=bar@. keyValueParser :: Parser (Text,Text) keyValueParser =- do skipComments+ do skipEndOfLine+ skipComments+ skipEndOfLine key <- takeWhile1 (\c -> not (isDelim c || c == '[' || c == ']')) delim <- satisfy isDelim value <- fmap (clean delim) (takeWhile (not . isEndOfLine)) skipEndOfLine- return (key,value)+ return (T.strip key, T.strip value) where clean ':' = T.drop 1 clean _ = id @@ -151,7 +286,7 @@ -- | Skip end of line and whitespace beyond. skipEndOfLine :: Parser ()-skipEndOfLine = skipWhile (\c -> isEndOfLine c)+skipEndOfLine = skipWhile isSpace -- | Skip comments starting at the beginning of the line. skipComments :: Parser ()
+ test/Main.hs view
@@ -0,0 +1,191 @@+{-# LANGUAGE OverloadedStrings #-}+{-# OPTIONS_GHC -Wno-orphans #-}++-- | Simple test suite.++module Main where++import Data.Char (isControl, isSpace)+import qualified Data.HashMap.Strict as HM+import Data.Ini (Ini (..), KeySeparator (..), WriteIniSettings (..), parseIni, printIniWith)+import Data.Text (Text)+import qualified Data.Text as T+import Test.Hspec (describe, hspec, it, shouldBe)+import Test.QuickCheck++main :: IO ()+main =+ hspec $ do+ describe "parseIni" $ do+ it "parses multi-section file with comments" $+ parseIni+ "# Some comment.\n\+ \[SERVER]\n\+ \port=6667\n\+ \hostname=localhost\n\+ \[AUTH]\n\+ \user=hello\n\+ \pass=world\n\+ \# Salt can be an empty string.\n\+ \salt="+ `shouldBe` Right+ ( Ini+ { iniSections =+ HM.fromList+ [+ ( "AUTH"+ ,+ [ ("user", "hello")+ , ("pass", "world")+ , ("salt", "")+ ]+ )+ ,+ ( "SERVER"+ , [("port", "6667"), ("hostname", "localhost")]+ )+ ]+ , iniGlobals = []+ }+ )++ it "parses file with globals" $+ parseIni+ "# Some comment.\n\+ \port=6667\n\+ \hostname=localhost\n\+ \[AUTH]\n\+ \user=hello\n\+ \pass=world\n\+ \# Salt can be an empty string.\n\+ \salt="+ `shouldBe` Right+ ( Ini+ { iniSections =+ HM.fromList+ [+ ( "AUTH"+ ,+ [ ("user", "hello")+ , ("pass", "world")+ , ("salt", "")+ ]+ )+ ]+ , iniGlobals =+ [("port", "6667"), ("hostname", "localhost")]+ }+ )++ it "fails to parse file with invalid keys" $+ parseIni+ "Name=Foo\n\+ \Name[en_GB]=Fubar"+ `shouldBe` Left "Failed reading: Name[en_GB]=Fubar"++ it "parses file ending with comments" $+ parseIni+ "[default]\n\+ \a = 1\n\+ \\n\+ \#[staging-PI]\n\+ \#a = 2\n"+ `shouldBe` Right+ ( Ini+ { iniSections = HM.fromList [("default", [("a", "1")])]+ , iniGlobals = []+ }+ )++ it "parses file with globals only" $+ parseIni+ "global1 = hello\n\+ \global2 = 123\n\+ \# An end of file comment here"+ `shouldBe` Right+ ( Ini+ { iniSections = HM.empty+ , iniGlobals = [("global1", "hello"), ("global2", "123")]+ }+ )++ it "parses empty file" $+ parseIni "" `shouldBe` Right mempty++ it "roundtrips with printIniWith" $+ property $ \ini settings ->+ let printed = printIniWith settings ini+ parsed = parseIni printed+ in counterexample (T.unpack printed) $+ parsed === Right ini++genKey :: Gen Text+genKey = do+ firstChar <- arbitrary `suchThat` isValidFirstKeyChar+ restChars <- listOf $ arbitrary `suchThat` isValidKeyChar+ pure $ T.pack (firstChar : restChars)+ where+ isValidKeyChar c = not (c == '=' || c == ':' || c == '[' || c == ']' || isControl c || isSpace c)+ isValidFirstKeyChar c = isValidKeyChar c && c /= ';' && c /= '#'++genSectionName :: Gen Text+genSectionName = do+ chars <- listOf1 $ arbitrary `suchThat` isValidSectionChar+ let name = T.strip $ T.pack chars+ if T.null name+ then genSectionName -- retry if stripping results in empty+ else pure name+ where+ isValidSectionChar c = c /= '[' && c /= ']' && not (isControl c)++genValue :: Gen Text+genValue = do+ chars <- listOf $ arbitrary `suchThat` (not . isControl)+ pure $ T.strip $ T.pack chars++genKeyValue :: Gen (Text, Text)+genKeyValue = (,) <$> genKey <*> genValue++instance Arbitrary KeySeparator where+ arbitrary = arbitraryBoundedEnum+ shrink ColonKeySeparator = []+ shrink EqualsKeySeparator = [ColonKeySeparator]++instance Arbitrary Ini where+ arbitrary = do+ numSections <- choose (0, 2)+ sections <- vectorOf numSections $ do+ name <- genSectionName+ numPairs <- choose (0, 2)+ pairs <- vectorOf numPairs genKeyValue+ pure (name, pairs)+ numGlobals <- choose (0, 3)+ globals <- vectorOf numGlobals genKeyValue+ pure $+ Ini+ { iniSections = HM.fromList sections+ , iniGlobals = globals+ }+ shrink ini =+ -- Shrink by removing sections+ [ ini{iniSections = HM.fromList secs'}+ | secs' <- shrinkList (const []) (HM.toList (iniSections ini))+ ]+ +++ -- Shrink by removing key-value pairs from sections+ [ ini{iniSections = HM.fromList secs'}+ | secs' <- shrinkOne shrinkSection (HM.toList (iniSections ini))+ ]+ +++ -- Shrink by removing globals+ [ ini{iniGlobals = globals'}+ | globals' <- shrinkList (const []) (iniGlobals ini)+ ]+ where+ shrinkSection (name, pairs) = [(name, pairs') | pairs' <- shrinkList (const []) pairs]+ shrinkOne _ [] = []+ shrinkOne f (x : xs) = [x' : xs | x' <- f x] ++ [x : xs' | xs' <- shrinkOne f xs]++instance Arbitrary WriteIniSettings where+ arbitrary = WriteIniSettings <$> arbitrary+ shrink (WriteIniSettings sep) = WriteIniSettings <$> shrink sep