feedback-0.1.0.4: src/Feedback/Common/OptParse.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}
module Feedback.Common.OptParse where
import Autodocodec
import Autodocodec.Yaml
import Control.Applicative
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe
import qualified Data.Text as T
import Data.Version
import Data.Yaml (FromJSON, ToJSON)
import qualified Env
import GHC.Generics (Generic)
import Options.Applicative as OptParse
import qualified Options.Applicative.Help as OptParse (pretty)
import Path
import Path.IO
import Paths_feedback
data LoopSettings = LoopSettings
{ loopSettingRunSettings :: !RunSettings,
loopSettingFilterSettings :: !FilterSettings,
loopSettingOutputSettings :: !OutputSettings,
loopSettingHooksSettings :: !HooksSettings
}
deriving (Show, Eq, Generic)
combineToLoopSettings :: Flags -> Environment -> Maybe OutputConfiguration -> LoopConfiguration -> IO LoopSettings
combineToLoopSettings Flags {..} Environment {} mDefaultOutputConfig LoopConfiguration {..} = do
loopSettingRunSettings <- combineToRunSettings loopConfigRunConfiguration
let loopSettingFilterSettings = combineToFilterSettings loopConfigFilterConfiguration
let outputConfig = maybe loopConfigOutputConfiguration (<> loopConfigOutputConfiguration) mDefaultOutputConfig
let loopSettingOutputSettings = combineToOutputSettings flagOutputFlags outputConfig
loopSettingHooksSettings <- combineToHooksSettings loopConfigHooksConfiguration
pure LoopSettings {..}
data RunSettings = RunSettings
{ runSettingCommand :: !Command,
runSettingExtraEnv :: !(Map String String),
runSettingWorkingDir :: !(Maybe (Path Abs Dir))
}
deriving (Show, Eq, Generic)
combineToRunSettings :: RunConfiguration -> IO RunSettings
combineToRunSettings RunConfiguration {..} = do
let runSettingCommand = runConfigCommand
let runSettingExtraEnv = runConfigExtraEnv
runSettingWorkingDir <- mapM resolveDir' runConfigWorkingDir
pure RunSettings {..}
data FilterSettings = FilterSettings
{ filterSettingGitignore :: !Bool,
filterSettingFind :: !(Maybe String)
}
deriving (Show, Eq, Generic)
combineToFilterSettings :: FilterConfiguration -> FilterSettings
combineToFilterSettings FilterConfiguration {..} =
let filterSettingGitignore = fromMaybe True filterConfigGitignore
filterSettingFind = filterConfigFind
in FilterSettings {..}
data OutputSettings = OutputSettings
{ outputSettingClear :: !Clear
}
deriving (Show, Eq, Generic)
combineToOutputSettings :: OutputFlags -> OutputConfiguration -> OutputSettings
combineToOutputSettings OutputFlags {..} mConf =
let outputSettingClear =
fromMaybe (if outputFlagDebug then DoNotClearScreen else ClearScreen) $
outputFlagClear <|> outputConfigClear mConf
in OutputSettings {..}
data HooksSettings = HooksSettings
{ hooksSettingBeforeAll :: Maybe RunSettings,
hooksSettingAfterFirst :: Maybe RunSettings
}
deriving (Show, Eq, Generic)
combineToHooksSettings :: HooksConfiguration -> IO HooksSettings
combineToHooksSettings HooksConfiguration {..} = do
hooksSettingBeforeAll <- mapM combineToRunSettings hooksConfigurationBeforeAll
hooksSettingAfterFirst <- mapM combineToRunSettings hooksConfigurationAfterFirst
pure HooksSettings {..}
data Configuration = Configuration
{ configLoops :: !(Map String LoopConfiguration),
configOutputConfiguration :: !(Maybe OutputConfiguration)
}
deriving stock (Show, Eq, Generic)
deriving (FromJSON, ToJSON) via (Autodocodec Configuration)
instance HasCodec Configuration where
codec =
object "Configuration" $
Configuration
<$> optionalFieldWithOmittedDefault' "loops" M.empty
.= configLoops
<*> optionalField "output" "default output configuration"
.= configOutputConfiguration
emptyConfiguration :: Configuration
emptyConfiguration =
Configuration
{ configLoops = mempty,
configOutputConfiguration = mempty
}
data LoopConfiguration = LoopConfiguration
{ loopConfigDescription :: !(Maybe String),
loopConfigRunConfiguration :: !RunConfiguration,
loopConfigFilterConfiguration :: !FilterConfiguration,
loopConfigOutputConfiguration :: !OutputConfiguration,
loopConfigHooksConfiguration :: !HooksConfiguration
}
deriving stock (Show, Eq, Generic)
deriving (FromJSON, ToJSON) via (Autodocodec LoopConfiguration)
instance HasCodec LoopConfiguration where
codec =
named
"LoopConfiguration"
( dimapCodec f g $
eitherCodec (codec <?> "A bare command without any extra configuration") $
object "LoopConfiguration" loopConfigurationObjectCodec
)
<??> loopConfigDocs
where
loopConfigDocs =
[ "A LoopConfiguration specifies an entire feedback loop.",
"",
"It consists of four parts:",
"* Filter Configuration: Which files to watch",
"* Run Configuration: What to do when those files change",
"* Output Configuration: What to see",
"* Hooks configuration: What to around commands"
]
f = \case
Left s -> makeLoopConfiguration (CommandScript s)
Right loopConfig -> loopConfig
g loopConfig =
let runConfig = loopConfigRunConfiguration loopConfig
c = runConfigCommand runConfig
in case c of
CommandScript cmd | loopConfig == makeLoopConfiguration c -> Left cmd
_ -> Right loopConfig
loopConfigurationObjectCodec :: JSONObjectCodec LoopConfiguration
loopConfigurationObjectCodec =
LoopConfiguration
<$> optionalField "description" "description of when to use this feedback loop"
.= loopConfigDescription
<*> parseAlternative
(requiredField "run" "run configuration for this loop")
runConfigurationObjectCodec
.= loopConfigRunConfiguration
<*> parseAlternative
(requiredField "filter" "filter configuration for this loop")
filterConfigurationObjectCodec
.= loopConfigFilterConfiguration
<*> parseAlternative
(requiredField "output" "output configuration for this loop")
outputConfigurationObjectCodec
.= loopConfigOutputConfiguration
<*> parseAlternative
(requiredField "hooks" "hooks configuration for this loop")
hooksConfigurationObjectCodec
.= loopConfigHooksConfiguration
makeLoopConfiguration :: Command -> LoopConfiguration
makeLoopConfiguration c =
LoopConfiguration
{ loopConfigDescription = Nothing,
loopConfigRunConfiguration = makeRunConfiguration c,
loopConfigFilterConfiguration = emptyFilterConfiguration,
loopConfigOutputConfiguration = emptyOutputConfiguration,
loopConfigHooksConfiguration = emptyHooksConfiguration
}
data RunConfiguration = RunConfiguration
{ runConfigCommand :: !Command,
runConfigExtraEnv :: !(Map String String),
runConfigWorkingDir :: !(Maybe FilePath)
}
deriving stock (Show, Eq, Generic)
deriving (FromJSON, ToJSON) via (Autodocodec RunConfiguration)
instance HasCodec RunConfiguration where
codec =
named "RunConfiguration" $
dimapCodec f g $
eitherCodec
(codec <?> "A bare command without any extra configuration")
(object "RunConfiguration" runConfigurationObjectCodec)
where
f = \case
Left s -> makeRunConfiguration (CommandScript s)
Right loopConfig -> loopConfig
g runConfig =
let c = runConfigCommand runConfig
in case c of
CommandScript cmd | runConfig == makeRunConfiguration c -> Left cmd
_ -> Right runConfig
runConfigurationObjectCodec :: JSONObjectCodec RunConfiguration
runConfigurationObjectCodec =
RunConfiguration
<$> commandObjectCodec
.= runConfigCommand
<*> optionalFieldWithOmittedDefault "env" M.empty "extra environment variables to set"
.= runConfigExtraEnv
<*> optionalField "working-dir" "where the process will be run"
.= runConfigWorkingDir
makeRunConfiguration :: Command -> RunConfiguration
makeRunConfiguration c =
RunConfiguration
{ runConfigCommand = c,
runConfigExtraEnv = M.empty,
runConfigWorkingDir = Nothing
}
data FilterConfiguration = FilterConfiguration
{ filterConfigGitignore :: !(Maybe Bool),
filterConfigFind :: !(Maybe String)
}
deriving stock (Show, Eq, Generic)
deriving (FromJSON, ToJSON) via (Autodocodec FilterConfiguration)
instance HasCodec FilterConfiguration where
codec =
named
"FilterConfiguration"
( object "FilterConfiguration" filterConfigurationObjectCodec
)
<??> filterConfigurationDocs
where
filterConfigurationDocs =
[ "By default, standard filters are applied and,",
"if in a git repository, only files in the git repository are considered.",
"If either 'git' or 'find' configuration are specified, only those are used."
]
filterConfigurationObjectCodec :: JSONObjectCodec FilterConfiguration
filterConfigurationObjectCodec =
FilterConfiguration
<$> optionalField "git" "whether to ignore files that are not in the git repo\nConcretely, this uses `git ls-files` to find files that are in the repo, so files that have been added but are also ignored by .gitignore will still be watched."
.= filterConfigGitignore
<*> optionalField "find" "arguments for the 'find' command to find files to be notified about"
.= filterConfigFind
emptyFilterConfiguration :: FilterConfiguration
emptyFilterConfiguration =
FilterConfiguration
{ filterConfigGitignore = Nothing,
filterConfigFind = Nothing
}
data OutputConfiguration = OutputConfiguration
{ outputConfigClear :: !(Maybe Clear)
}
deriving stock (Show, Eq, Generic)
deriving (FromJSON, ToJSON) via (Autodocodec OutputConfiguration)
instance HasCodec OutputConfiguration where
codec =
named "OutputConfiguration" $
object "OutputConfiguration" outputConfigurationObjectCodec
outputConfigurationObjectCodec :: JSONObjectCodec OutputConfiguration
outputConfigurationObjectCodec =
OutputConfiguration
<$> optionalField "clear" "whether to clear the screen runs"
.= outputConfigClear
instance Semigroup OutputConfiguration where
(<>) oc1 oc2 =
OutputConfiguration
{ outputConfigClear = outputConfigClear oc1 <|> outputConfigClear oc2
}
emptyOutputConfiguration :: OutputConfiguration
emptyOutputConfiguration =
OutputConfiguration
{ outputConfigClear = Nothing
}
data HooksConfiguration = HooksConfiguration
{ hooksConfigurationBeforeAll :: !(Maybe RunConfiguration),
hooksConfigurationAfterFirst :: !(Maybe RunConfiguration)
}
deriving (Show, Eq, Generic)
instance HasCodec HooksConfiguration where
codec =
named "HooksConfiguration" $
object "HooksConfiguration" hooksConfigurationObjectCodec
hooksConfigurationObjectCodec :: JSONObjectCodec HooksConfiguration
hooksConfigurationObjectCodec =
HooksConfiguration
<$> optionalField "before-all" "The hook to run before the first run"
.= hooksConfigurationBeforeAll
<*> optionalField "after-first" "The hook to run after the first run"
.= hooksConfigurationAfterFirst
emptyHooksConfiguration :: HooksConfiguration
emptyHooksConfiguration =
HooksConfiguration
{ hooksConfigurationBeforeAll = Nothing,
hooksConfigurationAfterFirst = Nothing
}
getConfiguration :: Flags -> Environment -> IO (Maybe Configuration)
getConfiguration Flags {..} Environment {..} = do
fp <- case flagConfigFile <|> envConfigFile of
Nothing -> defaultConfigFile
Just cf -> resolveFile' cf
getConfigurationFromFile fp
getConfigurationFromFile :: Path Abs File -> IO (Maybe Configuration)
getConfigurationFromFile = readYamlConfigFile
defaultConfigFile :: IO (Path Abs File)
defaultConfigFile = do
here <- getCurrentDir
resolveFile here "feedback.yaml"
data Environment = Environment
{ envConfigFile :: !(Maybe FilePath)
}
deriving (Show, Eq, Generic)
getEnvironment :: IO Environment
getEnvironment = Env.parse (Env.header "Environment") environmentParser
environmentParser :: Env.Parser Env.Error Environment
environmentParser =
Env.prefixed "FEEDBACK_" $
Environment
<$> Env.var (fmap Just . Env.str) "CONFIG_FILE" (Env.def Nothing <> Env.help "Config file")
getFlags :: IO Flags
getFlags = customExecParser prefs_ flagsParser
prefs_ :: OptParse.ParserPrefs
prefs_ =
OptParse.defaultPrefs
{ OptParse.prefShowHelpOnError = True,
OptParse.prefShowHelpOnEmpty = True
}
flagsParser :: OptParse.ParserInfo Flags
flagsParser =
OptParse.info
(OptParse.helper <*> parseFlags)
( mconcat
[ OptParse.progDesc versionStr,
OptParse.fullDesc,
OptParse.footerDoc (Just $ OptParse.pretty footerStr)
]
)
where
versionStr =
"Version: " <> showVersion version
footerStr =
unlines
[ Env.helpDoc environmentParser,
"",
"Configuration file format:",
T.unpack (renderColouredSchemaViaCodec @Configuration)
]
data Flags = Flags
{ flagCommand :: !String,
flagConfigFile :: !(Maybe FilePath),
flagOutputFlags :: !OutputFlags
}
deriving (Show, Eq, Generic)
data OutputFlags = OutputFlags
{ outputFlagClear :: !(Maybe Clear),
outputFlagDebug :: Bool
}
deriving (Show, Eq, Generic)
parseFlags :: OptParse.Parser Flags
parseFlags =
Flags
<$> parseCommandFlags
<*> optional
( strOption
( mconcat
[ short 'c',
long "config-file",
help "Path to an altenative config file",
metavar "FILEPATH"
]
)
)
<*> parseOutputFlags
parseCommandFlags :: OptParse.Parser String
parseCommandFlags =
let commandArg =
strArgument
( mconcat
[ help "The command to run",
metavar "COMMAND",
completer (listIOCompleter defaultConfigFileCompleter)
]
)
in unwords <$> many commandArg
defaultConfigFileCompleter :: IO [String]
defaultConfigFileCompleter = do
mConfig <- defaultConfigFile >>= getConfigurationFromFile
pure $ M.keys (maybe [] configLoops mConfig)
parseOutputFlags :: OptParse.Parser OutputFlags
parseOutputFlags =
OutputFlags
<$> parseClearFlag
<*> switch
( mconcat
[ short 'd',
long "debug",
help "show debug information"
]
)
newtype Command = CommandScript {unScript :: String}
deriving (Show, Eq, Generic)
instance HasCodec Command where
codec = dimapCodec CommandScript unScript codec
commandObjectCodec :: JSONObjectCodec Command
commandObjectCodec =
parseAlternative
(requiredField "script" "the script to run on change")
(requiredField "command" "the command to run on change (alias for 'script' for backward compatibility)")
data Clear = ClearScreen | DoNotClearScreen
deriving (Show, Eq, Generic)
instance HasCodec Clear where
codec = dimapCodec f g codec
where
f True = ClearScreen
f False = DoNotClearScreen
g ClearScreen = True
g DoNotClearScreen = False
parseClearFlag :: OptParse.Parser (Maybe Clear)
parseClearFlag =
optional $
flag'
ClearScreen
( mconcat
[ long "clear",
help "clear the screen between feedback"
]
)
<|> flag'
DoNotClearScreen
( mconcat
[ long "no-clear",
help "do not clear the screen between feedback"
]
)