packages feed

opt-env-conf-0.5.0.0: src/OptEnvConf/Reader.hs

{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}

module OptEnvConf.Reader
  ( Reader (..),

    -- * Common readers
    str,
    auto,
    exists,
    viaStringCodec,

    -- * Constructing your own reader
    maybeReader,
    eitherReader,

    -- * Comma-separated readers
    commaSeparated,
    commaSeparatedList,
    commaSeparatedSet,

    -- * Internal
    runReader,
    renderCommaSeparated,
    parseCommaSeparated,
  )
where

import Autodocodec
import Control.Monad.Reader (MonadReader (..))
import Data.Aeson.Types as JSON
import Data.List (intercalate)
import Data.List.NonEmpty (NonEmpty (..), (<|))
import qualified Data.List.NonEmpty as NE
import Data.Set (Set)
import qualified Data.Set as S
import Data.String
import qualified Data.Text as T
import Text.Read (readMaybe)

newtype Reader a = Reader {unReader :: String -> Either String a}
  deriving (Functor)

instance Applicative Reader where
  pure = Reader . const . Right
  (<*>) (Reader ff) (Reader fa) =
    Reader $ \s ->
      ff s <*> fa s

instance Monad Reader where
  (>>=) (Reader fa) fb = Reader $ \s -> do
    a <- fa s
    unReader (fb a) s

instance MonadReader String Reader where
  ask = Reader Right
  reader f = Reader $ \s -> Right (f s)
  local fs f = Reader $ \s ->
    let s' = fs s
     in unReader f s'

runReader :: Reader a -> String -> Either String a
runReader = unReader

-- | Read a string as-is.
--
-- __This is the reader you will want to use for reading a 'String'.__
--
-- This is different from 'auto' for strings because 'Read' wants to parse quotes when parsing Strings.
str :: (IsString s) => Reader s
str = Reader $ Right . fromString

-- | Read via the 'Read' instance
--
-- You cannot use this for bare strings, because 'Read' for strings parses quotes.
auto :: (Read a) => Reader a
auto = Reader $ \s -> case readMaybe s of
  Nothing -> Left $ "Un-Read-able value: " <> show s
  Just a -> Right a

-- | Always return True
--
-- > exists = Reader $ const $ pure True
exists :: Reader Bool
exists = Reader $ const $ pure True

-- | Read a value as if it were specified as a 'String' and parsed via the given 'Codec'.
viaStringCodec :: (HasCodec a) => Reader a
viaStringCodec = eitherReader $ parseEither $ parseJSONViaCodec . JSON.String . T.pack

-- | Turn a 'Maybe' parsing function into a 'Reader'
maybeReader :: (String -> Maybe a) -> Reader a
maybeReader func = Reader $ \s -> case func s of
  Nothing -> Left $ "Unparsable value: " <> show s
  Just a -> Right a

-- | Turn an 'Either' parsing function into a 'Reader'
--
-- API note: This is a forward-compatible alias for 'Reader'.
eitherReader :: (String -> Either String a) -> Reader a
eitherReader = Reader

-- | Like 'commaSeparated' but uses a set type.
--
-- Note that this will never parse the empty list, so prefer 'commaSeparated'
-- if you want a more accurately typed function.
--
-- Note also that this function throws away any ordering information and
-- ignores any duplicate values.
commaSeparatedSet :: (Ord a) => Reader a -> Reader (Set a)
commaSeparatedSet func = S.fromList <$> commaSeparatedList func

-- | Like 'commaSeparated' but uses a list type.
--
-- Note that this will never parse the empty list, so prefer 'commaSeparated'
-- if you want a more accurately typed function.
commaSeparatedList :: Reader a -> Reader [a]
commaSeparatedList func = NE.toList <$> commaSeparated func

-- | Turn a reader into one that parses comma separated values with that reader.
commaSeparated :: Reader a -> Reader (NonEmpty a)
commaSeparated (Reader func) = Reader $ mapM func . parseCommaSeparated

-- | Separate by commas and escape commas in values
renderCommaSeparated :: NonEmpty String -> String
renderCommaSeparated = intercalate "," . map escape . NE.toList
  where
    escape = concatMap $ \case
      ',' -> "\\,"
      '\\' -> "\\\\"
      c -> [c]

-- | Parse comma separated string, ignore escaped commas
parseCommaSeparated :: String -> NonEmpty String
parseCommaSeparated = go ""
  where
    go acc = \case
      [] -> reverse acc :| []
      '\\' : '\\' : rest -> go ('\\' : acc) rest
      '\\' : ',' : rest -> go (',' : acc) rest
      ',' : rest -> reverse acc <| go "" rest
      c : rest -> go (c : acc) rest