Simple INI file parser in Haskell
I know you said you were fine with not using a parser combinator library like parsec, but I thought you might like to see the how the same thing might look using one, so I wrote an Attoparsec based parser for your data types:

{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE OverloadedStrings #-}

module IniParser where

import           Control.Monad               ( void )
import           Data.Attoparsec.Text        ( Parser
                                             , char
                                             , endOfInput
                                             , endOfLine
                                             , many'
                                             , many1
                                             , notInClass
                                             , parseOnly
                                             , satisfy
                                             , space
                                             )
import           Data.Map.Strict             ( Map )
import qualified Data.Map.Strict      as Map
import           Data.Text                   ( Text
                                             , pack
                                             )
import           System.Environment          ( getArgs )

type Ini = Map Text Section

data Section = Section
  { name       :: Text
  , properties :: Map Text Text
  } deriving (Show)

main :: IO ()
main = do
  [path] &lt;- getArgs
  parseIniFile path &gt;&gt;= \case
    Right ini -&gt; putStrLn $ "Parsed INI: " ++ show ini
    Left err  -&gt; putStrLn $ "ERROR parsing ini: " ++ err

parseIniFile :: FilePath -&gt; IO (Either String Ini)
parseIniFile iniFilePath = parseIni . pack &lt;$&gt; readFile iniFilePath

parseIni :: Text -&gt; Either String Ini
parseIni = parseOnly ini

ini :: Parser Ini
ini = do
  defaultSection &lt;- lexeme (Section "" &lt;$&gt; (Map.fromList &lt;$&gt; many' property))
  namedSections  &lt;- lexeme (many' section)
  void $ endOfInput
  let allSections | null (properties defaultSection) = namedSections
                  | otherwise = defaultSection:namedSections
  pure . Map.fromList . map (\section -&gt; (name section, section))
    $ allSections

section :: Parser Section
section = Section &lt;$&gt; sectionName &lt;*&gt; (Map.fromList &lt;$&gt; many' (lexeme property))

sectionName :: Parser Text
sectionName = char '[' *&gt; sectionNameChars &lt;* char ']' &lt;* endOfLine

sectionNameChars :: Parser Text
sectionNameChars = pack &lt;$&gt; many' (satisfy $ notInClass "]\r\n")

property :: Parser (Text, Text)
property = (,) &lt;$&gt; propertyName &lt;*&gt; (lexeme (char '=') *&gt; propertyValue)

propertyName :: Parser Text
propertyName = pack &lt;$&gt; many' (satisfy $ notInClass "=\r\n\t ")

propertyValue :: Parser Text
propertyValue = pack &lt;$&gt; many' (satisfy $ notInClass "\r\n")

lexeme :: Parser a -&gt; Parser a
lexeme p = whitespace *&gt; p &lt;* whitespace

whitespace :: Parser String
whitespace = many' space


I think the main strength of the approach is self pretty self-evident.  It eliminates all the multi-line lambdas, the entire foldr, etc. which (at least IMHO) really obscures the essence of what the code is expressing.

Additionally I've restricted the use of qualified imports to a single one usage where I think it makes the code more readable, though your taste may vary.

You can see the whole stack based project here if you're interested.