packages feed

EVP-0.1: src/EVP.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NoFieldSelectors #-}
{-# LANGUAGE RecordWildCards #-}
module EVP
  ( Name
  , Error(..)
  -- * Parsers
  , Scan
  , Var(..)
  , string
  , yaml
  , parse
  , secret
  , group
  , defaultsTo
  -- * Runner
  , Settings(..)
  , def
  , scan
  , scanWith
  , enumerate
  , help
  -- * Logger
  , GroupStack
  , renderError
  , assumePrefix
  , obsolete
  -- * Advanced
  , modifyError
  ) where

import Control.Applicative
import Control.Monad
import Data.Bifunctor
import Data.Default.Class
import Data.List (isPrefixOf, intercalate)
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 qualified as T
import Data.Text.Lazy qualified as TL
import Data.Text.Encoding
import Data.Typeable
import EVP.Internal
import System.Environment
import System.Exit
import System.IO

data Var a = Var
  { name :: Name
  , defaultValue :: Maybe a
  , metavar :: Maybe String
  } deriving (Show, Eq)

-- | Provide a default value.
defaultsTo :: Var a -> a -> Var a
defaultsTo v a = v { defaultValue = Just a }
infixl 1 `defaultsTo`

instance Typeable a => IsString (Var a) where
  fromString name = Var name Nothing Nothing

-- | Obtain the environment variable.
string :: (IsString a, Show a, Typeable a) => Var a -> Scan a
string Var{..} = Scan ScanF
  { name
  , parser = \case
    Nothing -> case defaultValue of
      Nothing -> Left (Missing name)
      Just d -> Right (show d <> " (default)", d)
    Just x -> Right (x, fromString x)
  , metavar = metavar <|> toString <$> defaultValue
  } (Pure id)

toString :: (Typeable a, Show a) => a -> String
toString val
  | Just str <- cast val = str
  | Just str <- cast val = T.unpack str
  | Just str <- cast val = TL.unpack str
  | Just str <- cast val = T.unpack $ decodeUtf8 str
  | otherwise = show val

-- | Parse the environment variable as a YAML value.
yaml :: forall a. (Show a, Typeable a, Yaml.FromJSON a, Yaml.ToJSON a) => Var a -> Scan a
yaml Var{..} = parse Var
  { metavar = metavar
    <|> fmap (T.unpack . T.strip . decodeUtf8 . Yaml.encode) defaultValue
    <|> Just (show $ typeRep (Proxy :: Proxy a))
  , ..
  }
  decodeYaml

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

-- | Parse the environment variable with a custom parser.
parse :: (Show a) => Var a -> (String -> Either String a) -> Scan a
parse Var{..} f = Scan ScanF
  { name
  , parser = \case
    Nothing -> case defaultValue of
      Nothing -> Left (Missing name)
      Just d -> Right (show d <> " (default)", d)
    Just x -> bimap (ParseError name x) withShow $ f x
  , metavar
  }
  (Pure id)

-- | Disable logging of parsed values.
secret :: Scan a -> Scan a
secret (Pure a) = Pure a
secret (Scan v k) = Scan (v { parser = fmap (first (const "<REDACTED>")) . parser v }) (secret k)
secret (Group name s) = Group name $ secret s

-- | Give a name to a group of parsers.
group :: String -> Scan a -> Scan a
group = Group

withShow :: Show a => a -> (String, a)
withShow x = (show x, x)

type EnvMap = Map.Map String String

type GroupStack = [String]

renderError :: Error -> String
renderError (Missing name) = unwords ["Missing environment variable", name]
renderError (ParseError name value reason) = unwords ["Failed to parse", name <> "=" <> value <> ":", reason]
renderError (CustomError reason) = reason

data Settings = Settings
  { parseLogger :: GroupStack -> Name -> String -> IO ()
  , errorLogger :: GroupStack -> Error -> IO ()
  , unusedLogger :: Name -> Maybe (IO ())
  , pedantic :: Bool -- ^ exit on warning
  , helpFlag :: Maybe Name -- ^ when an environment varialbe with this name is set, print the help message and exit
  }

header :: GroupStack -> String -> String
header [] level = "[EVP " <> level <> "] "
header xs level = mconcat ["[EVP ", level, concatMap ("/"<>) $ reverse xs, "] "]

instance Default Settings where
  def = Settings
    { parseLogger = \stack name value -> do
      isTerminal <- hIsTerminalDevice stdout
      hPutStrLn (if isTerminal then stdout else stderr)
        $ header stack "Info" <> name <> ": " <> value
    , errorLogger = \stack e -> hPutStrLn stderr $ intercalate "\n" $ map (header stack "Error" <>) $ lines $ renderError e
    , unusedLogger = mempty
    , pedantic = False
    , helpFlag = Just "EVP_HELP"
    }

-- | 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 (Scan k cont) = go (Set.insert (name k) s) cont
  go !s (Group _ cont) = go s cont

-- | Parse environment variables with the default settings.
scan :: Scan a -> IO a
scan = scanWith def

-- | Parse environment variables with custom settings.
scanWith :: Settings -> Scan a -> IO a
scanWith Settings{..} action = do
  envs0 <- Map.fromList <$> getEnvironment

  forM_ helpFlag $ \flag -> do
    when (flag `Map.member` envs0) $ do
      putStrLn $ help action
      exitSuccess

  (remainder, errors, result) <- go envs0 envs0 [] action
  mapM_ (uncurry 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 -> GroupStack -> Scan a -> IO (EnvMap, [(GroupStack, Error)], Maybe a)
    go _ envs _ (Pure a) = pure (envs, [], Just a)
    go allEnvs envs groupStack (Group name inner) = do
      let stack = name : groupStack
      go allEnvs envs stack inner
    go allEnvs envs groupStack (Scan ScanF{..} cont) = case parser (Map.lookup name allEnvs) of
      Left e -> do
        (remainder, errors, _) <- go allEnvs (Map.delete name envs) groupStack cont
        pure (remainder, (groupStack, e) : errors, Nothing)
      Right (display, v) -> do
        parseLogger groupStack name display
        (remainder, errors, func) <- go allEnvs (Map.delete name envs) groupStack cont
        pure (remainder, errors, ($ v) <$> func)

modifyError :: (Error -> Error) -> Scan a -> Scan a
modifyError func = go where
  go :: Scan a -> Scan a
  go (Pure a) = Pure a
  go (Scan v k) = Scan (apply v) (go k)
  go (Group name k) = Group name (go k)

  apply ScanF{..} = ScanF
    { name
    , parser = first func . parser
    , metavar
    }

-- | Display the list of environment variables and their default values in the dotenv format.
help :: Scan a -> String
help = unlines . go 1 where

  go :: Int -> Scan a -> [String]
  go _ (Pure _) = []
  go depth (Scan v k) = format v : go depth k
  go depth (Group name k) = (replicate depth '#' <> " " <> name) : go (depth + 1) k

  format ScanF{..} = name <> "=" <> maybe "" id metavar