diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -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)
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,63 @@
+[![Hackage](https://img.shields.io/hackage/v/ini.svg?color=informational)](https://hackage.haskell.org/package/ini)
+[![ini on Stackage Nightly](https://stackage.org/package/ini/badge/nightly)](https://stackage.org/nightly/package/ini)
+[![Stackage LTS version](https://www.stackage.org/package/ini/badge/lts?label=Stackage)](https://www.stackage.org/package/ini)
+[![Haskell CI](https://github.com/jhrcek/ini/actions/workflows/haskell-ci.yml/badge.svg)](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.
diff --git a/ini.cabal b/ini.cabal
--- a/ini.cabal
+++ b/ini.cabal
@@ -1,28 +1,69 @@
+cabal-version:       >= 1.10
 name:                ini
-version:             0.3.6
-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
-homepage:            http://github.com/chrisdone/ini
-bug-reports:         http://github.com/chrisdone/ini/issues
+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: http://github.com/chrisdone/ini.git
+  location: https://github.com/jhrcek/ini.git
diff --git a/src/Data/Ini.hs b/src/Data/Ini.hs
--- a/src/Data/Ini.hs
+++ b/src/Data/Ini.hs
@@ -1,5 +1,4 @@
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE OverloadedStrings #-}
 
 -- | Clean configuration files in the INI format.
 --
@@ -42,7 +41,9 @@
    readIniFile
   ,parseIni
   ,lookupValue
+  ,lookupArray
   ,readValue
+  ,readArray
   ,parseValue
   ,sections
   ,keys
@@ -57,6 +58,7 @@
   ,writeIniFileWith
    -- * Types
   ,Ini(..)
+  ,unIni
    -- * Parsers
   ,iniParser
   ,sectionParser
@@ -71,17 +73,38 @@
 import           Data.Char
 import           Data.HashMap.Strict        (HashMap)
 import qualified Data.HashMap.Strict        as M
+import Data.Maybe
 import           Data.Semigroup
-import           Data.Monoid (Monoid)
 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, Semigroup, Monoid)
+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
@@ -90,36 +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 (Ini ini) = M.keys ini
+sections = M.keys . iniSections
 
 -- | Get the keys in a section.
-keys :: Text -> Ini -> Either String [Text]
-keys name (Ini ini) =
-  case M.lookup name ini of
+--
+-- 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 (M.keys section)
+    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 =
@@ -137,7 +213,7 @@
 data KeySeparator
   = ColonKeySeparator
   | EqualsKeySeparator
-  deriving (Eq, Show)
+  deriving (Bounded, Enum, Eq, Show)
 
 -- | Settings determining how an INI file is written.
 data WriteIniSettings = WriteIniSettings
@@ -156,11 +232,12 @@
 
 -- | Print an INI config.
 printIniWith :: WriteIniSettings -> Ini -> Text
-printIniWith wis (Ini ini) =
-  T.concat (map buildSection (M.toList ini))
+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 <> separator <> value <> "\n"
         separator = case writeIniKeySeparator wis of
@@ -169,10 +246,15 @@
 
 -- | Parser for an INI.
 iniParser :: Parser Ini
-iniParser = fmap (Ini . M.fromList) (many 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 skipEndOfLine
      skipComments
@@ -182,7 +264,7 @@
      _ <- char ']'
      skipEndOfLine
      values <- many keyValueParser
-     return (T.strip name, M.fromList values)
+     return (T.strip name, values)
 
 -- | A key-value pair. Either @foo: bar@ or @foo=bar@.
 keyValueParser :: Parser (Text,Text)
@@ -204,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 ()
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -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
