packages feed

git-mediate-1.1.0: src/OptUtils.hs

{-# LANGUAGE OverloadedRecordDot, LambdaCase, FlexibleContexts #-}

module OptUtils
    ( Parser, parseEnvOptions, envSwitch, envOptional, envOption
    ) where

import           Control.Applicative ((<|>))
import           Control.Monad ((>=>), guard, unless)
import           Control.Monad.Reader
import           Control.Monad.State
import           Data.Foldable (traverse_)
import           Data.Functor.Compose (Compose(..))
import qualified Data.Map as M
import           Data.Maybe (catMaybes)
import qualified Data.Set as S
import qualified Options.Applicative as O
import           System.Environment (lookupEnv)
import           Text.Read.Compat (readMaybe)

type Parser = Compose (ReaderT EnvVarName (State EnvContent)) O.Parser

type EnvVarName = String

data EnvContent = EnvContent
    { flags :: S.Set String
    , options :: M.Map String String
    , errors :: [String]
    }

instance Semigroup EnvContent where
    EnvContent f0 o0 e0 <> EnvContent f1 o1 e1 =
        EnvContent (f0 <> f1) (o0 <> o1)
        ( e0 <> e1
            <> (("Duplicate flag: " <>) <$> S.toList (S.intersection f0 f1))
            <> (("Duplicate option: " <>) <$> M.keys (M.intersection o0 o1))
        )

instance Monoid EnvContent where
    mempty = EnvContent mempty mempty mempty

parseEnvOptions :: String -> Parser a -> IO (O.Parser a)
parseEnvOptions name (Compose parser) =
    do
        envOpts <- foldMap (parseEnv . words) <$> lookupEnv name
        let (result, remainder) = runState (runReaderT parser name) envOpts
        let errFmt = formatRemainder remainder
        result <$
            unless (null errFmt)
            (putStrLn (unlines
                (("Warning: unhandled options in " <> name <> ":") : (("  * " <>) <$> errFmt))))

formatRemainder :: EnvContent -> [String]
formatRemainder (EnvContent f o e) =
    (("Unrecognized flag --" <>) <$> S.toList f)
    <> M.foldMapWithKey (\k v -> ["Unrecognized option: --" <> k <> " " <> v]) o
    <> e

parseEnv :: [String] -> EnvContent
parseEnv [] = mempty
parseEnv (('-':'-':flag@(_:_)):rest) = parseEnvFlag flag rest
parseEnv (['-',flag]:rest) = parseEnvFlag [flag] rest
parseEnv (other:rest) = mempty{errors = ["Unknown argument: " <> other]} <> parseEnv rest

parseEnvFlag :: String -> [String] -> EnvContent
parseEnvFlag flag rest =
    case rest of
    [] -> flagRes
    ('-':_):_ -> flagRes <> parseEnv rest
    val:rest' -> mempty{options = M.singleton flag val} <> parseEnv rest'
    where
        flagRes = mempty{flags = S.singleton flag}

-- | A boolean flag which may be initialized by an environment variable.
--
-- If the flag is present in the environment,
-- the corresponding --no-<name> or --<name> flag will be available to override it.
envSwitch :: String -> Bool -> String -> Parser Bool
envSwitch name def desc =
    Compose $
    do
        otherInEnv <- gets (S.member otherMode . flags)
        modify (\x -> x{flags = S.delete otherMode x.flags})
        let flag
                | otherInEnv = defaultMode
                | otherwise = otherMode
        let curDef = def /= otherInEnv
        let actionHelp
                | curDef = "Disable"
                | otherwise = "Enable"
        extraHelp <- (guard otherInEnv >>) <$> overrideHelp otherMode
        let help = actionHelp <> " " <> desc <> extraHelp
        pure ((/= curDef) <$> O.switch (O.long flag <> O.help help))
    where
        noFlag = "no-" <> name
        (defaultMode, otherMode)
            | def = (name, noFlag)
            | otherwise = (noFlag, name)

overrideHelp :: MonadReader EnvVarName m => String -> m String
overrideHelp val = asks (\name -> " (override \"--" <> val <> "\" from " <> name <> ")")

-- | An optional value which may be initialized by an environment variable.
--
-- If the flag is present in the environment,
-- a corresponding --no-<name> flag will be available to disable it.
envOptional :: (Read a, Show a) => String -> String -> String -> (a -> String) -> Parser (Maybe a)
envOptional name valDesc help disableHelp =
    Compose $
    readOption name >>=
    \case
    Just val ->
        do
            oh <- overrideHelp (name <> " " <> show val)
            ov <- envOptionFromEnv val
            opt <- getCompose (envOption name Nothing (commonMods <> ov))
            pure $
                O.optional opt
                <|> f <$> O.switch (O.long ("no-" <> name) <> O.help (disableHelp val <> oh))
        where
            f True = Nothing
            f False = Just val
    Nothing -> pure (O.optional (O.option O.auto (O.long name <> commonMods)))
    where
        commonMods = O.metavar valDesc <> O.help help

readOption :: (Read a, MonadState EnvContent m) => String -> m (Maybe a)
readOption name =
    do
        result <- gets (M.lookup name . options >=> readMaybe)
        result <$ traverse_ (const (modify (\x -> x{options = M.delete name x.options}))) result

-- | An option with a default value which may be initialized by an environment variable.
--
-- (the default value should be specified in the provided @O.Mod@ argument)
envOption :: (Read a, Show a) => String -> Maybe Char -> O.Mod O.OptionFields a -> Parser a
envOption name shortName mods =
    Compose $
    traverse readOption ([name] <> maybe [] (pure . pure) shortName)
    >>=
    \case
    [] -> pure (O.option O.auto baseMods)
    [val] -> O.option O.auto . (baseMods <>) <$> envOptionFromEnv val
    _ ->
        O.option O.auto baseMods <$
        modify (\x -> x{errors = x.errors <> ["Both --" <> name <> " and -" <> maybe [] pure shortName <> " specified"]})
    . catMaybes
    where
        baseMods = O.long name <> foldMap O.short shortName <> mods

envOptionFromEnv :: (MonadReader EnvVarName m, Show a) => a -> m (O.Mod O.OptionFields a)
envOptionFromEnv val =
    asks (\envVar -> O.value val <> O.showDefaultWith (\x -> show x <> ", from " <> envVar))