packages feed

EVP-0: src/EVP.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
module EVP
  ( Name
  , Error(..)
  -- * Parsers
  , string
  , yaml
  , parse
  , secret
  -- * Providing a default value
  , stringDefault
  , yamlDefault
  , parseDefault
  -- * Runner
  , Settings(..)
  , def
  , scan
  , scanWith
  , enumerate
  -- * Logger
  , assumePrefix
  , obsolete
  -- * Internal
  , Scan(..)
  ) where

import Control.Monad
import Data.Bifunctor
import Data.Default.Class
import Data.List (isPrefixOf)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.String
import Data.Yaml qualified as Yaml
import Data.Text.Encoding
import System.Environment
import System.Exit
import System.IO

type Name = String

data Error = Missing Name
  | ParseError Name String
  deriving Show

-- | Obtain the environment variable.
string :: (IsString a) => Name -> Scan a
string v = Var v (\case
  Nothing -> Left (Missing v)
  Just x -> Right (x, fromString x)) (Pure id)

stringDefault :: (IsString a) => Name -> String -> Scan a
stringDefault v d = Var v (\case
  Nothing -> Right (d <> " (default)", fromString d)
  Just x -> Right (x, fromString x)) (Pure id)

-- | Parse the environment variable as a YAML value.
yaml :: (Show a, Yaml.FromJSON a) => Name -> Scan a
yaml v = parse v decodeYaml

-- | Parse the environment variable as a YAML value.
yamlDefault :: (Show a, Yaml.FromJSON a) => Name -> a -> Scan a
yamlDefault v d = parseDefault v d decodeYaml

decodeYaml :: Yaml.FromJSON a => String -> Either String a
decodeYaml = first show . Yaml.decodeEither' . encodeUtf8 . fromString

parse :: (Show a) => Name -> (String -> Either String a) -> Scan a
parse v f = Var v (\case
  Nothing -> Left (Missing v)
  Just x -> bimap (ParseError v) withShow $ f x) (Pure id)

parseDefault :: (Show a) => Name -> a -> (String -> Either String a) -> Scan a
parseDefault v d f = Var v (\case
  Nothing -> Right (show d <> " (default)", d)
  Just x -> bimap (ParseError v) withShow $ f x) (Pure id)

-- | Disable logging of parsed values.
secret :: Scan a -> Scan a
secret (Pure a) = Pure a
secret (Var v f k) = Var v (fmap (first (const "<REDACTED>")) . f) (secret k)

withShow :: Show a => a -> (String, a)
withShow x = (show x, x)
  
data Scan a where
  Pure :: a -> Scan a
  Var :: Name -> (Maybe String -> Either Error (String, a)) -> Scan (a -> b) -> Scan b

instance Functor Scan where
  fmap f (Pure a) = Pure (f a)
  fmap f (Var k g c) = Var k g (fmap (f.) c)

instance Applicative Scan where
  pure = Pure
  Pure f <*> k = f <$> k
  Var k f c <*> r = Var k f (flip <$> c <*> r)
   
type EnvMap = Map.Map String String

data Settings = Settings
  { parseLogger :: Name -> String -> IO ()
  , errorLogger :: Error -> IO ()
  , unusedLogger :: Name -> Maybe (IO ())
  , pedantic :: Bool -- ^ exit on warning
  }
  
instance Default Settings where
  def = Settings
    { parseLogger = \name value -> putStrLn $ unwords ["[EVP Info]", name <> ":", value]
    , errorLogger = \e -> hPutStrLn stderr $ unwords ["[EVP Error]", show e]
    , unusedLogger = mempty
    , pedantic = False
    }

-- | Custom logging function for 'unusedLogger'.
-- @'assumePrefix' p@ prints a warning for each unused environment variable prefixed by @p@.
assumePrefix :: String -> Name -> Maybe (IO ())
assumePrefix prefix name
  | isPrefixOf prefix name = Just $ hPutStrLn stderr $ unwords ["[EVP Warn]", name, "is set but not used"]
  | otherwise = Nothing

-- | @'obsolete' names@ prints a warning if any of the @names@ is set but not used.
obsolete :: [Name] -> Name -> Maybe (IO ())
obsolete nameSet name
  | elem name nameSet = Just $ hPutStrLn stderr $ unwords ["[EVP Warn]", name, "is obsolete"]
  | otherwise = Nothing

-- | Enumerate the names of the variables it would parse.
enumerate :: Scan a -> [Name]
enumerate m = Set.toList $ go Set.empty m where
  go :: Set.Set Name -> Scan a -> Set.Set Name
  go !s (Pure _) = s
  go !s (Var k _ cont) = go (Set.insert k s) cont

scan :: Scan a -> IO a
scan = scanWith def

scanWith :: Settings -> Scan a -> IO a
scanWith Settings{..} action = do
  envs0 <- Map.fromList <$> getEnvironment
  (remainder, errors, result) <- go envs0 envs0 action
  mapM_ errorLogger errors
  case foldMap unusedLogger $ Map.keys remainder of
    Nothing -> pure ()
    Just m -> do
      m
      when pedantic exitFailure
  case result of
    Nothing -> exitFailure
    Just a -> pure a
  where
    go :: EnvMap -> EnvMap -> Scan a -> IO (EnvMap, [Error], Maybe a)
    go _ envs (Pure a) = pure (envs, [], Just a)
    go allEnvs envs (Var name parser cont) = case parser (Map.lookup name allEnvs) of
      Left e -> do
        (remainder, errors, _) <- go allEnvs (Map.delete name envs) cont
        pure (remainder, e : errors, Nothing)
      Right (display, v) -> do
        parseLogger name display
        (remainder, errors, func) <- go allEnvs (Map.delete name envs)  cont
        pure (remainder, errors, ($ v) <$> func)