summoner-2.0.1.0: src/Summoner/Question.hs
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE ViewPatterns #-}
{- |
Copyright: (c) 2017-2019 Kowainik
SPDX-License-Identifier: MPL-2.0
Maintainer: Kowainik <xrom.xkov@gmail.com>
This module contains function to proper questioning in terminal.
-}
module Summoner.Question
( -- * Choose functions.
choose
, chooseYesNo
, chooseYesNoBool
-- * YesNoPrompt
, YesNoPrompt(..)
, mkDefaultYesNoPrompt
-- * Queries
, query
, queryWithPredicate
, queryNotNull
, queryDef
, queryManyRepeatOnFail
, checkUniqueName
, doesExistProjectName
-- * Customize target message
, targetMessageWithText
, targetMessage
, trueMessage
, falseMessage
) where
import Colourista (blue, bold, cyan, errorMessage, formatWith, formattedMessage, green, italic,
warningMessage)
import System.Directory (doesPathExist, getCurrentDirectory)
import System.FilePath ((</>))
import System.IO (hFlush)
import Summoner.Text (headToUpper, intercalateMap)
import qualified Data.Text as T
{- HLINT ignore "Redundant multi-way if" -}
{- | Build a prompt
For example,
@
YesNoPrompt
{ yesNoTarget = "Cabal"
, yesNoPrompt = "Do you want to add a cabal integration?"}
@
will generate a following prompt message to the user
@
Do you want to add a cabal integration? [y]/n
-> y
[Cabal] will be added to the project
@
-}
data YesNoPrompt = YesNoPrompt
{ yesNoTarget :: !Text -- ^ target (e.g., __TARGET will be added to the project__)
, yesNoPrompt :: !Text -- ^ prompt (e.g., __PROMPT [y]/n__)
}
{- | Build a prompt with the TARGET name only
It will generate a simple default prompt such that
@
Add TARGET? [y]/n
@
-}
mkDefaultYesNoPrompt
:: Text -- ^ target name
-> YesNoPrompt
mkDefaultYesNoPrompt target = YesNoPrompt target ("Add " <> target <> "?")
-- | Represents a user's answer
data Answer
= Y
| N
-- | Parse an answer to 'Answer'
yesOrNo :: Text -> Maybe Answer
yesOrNo (T.toLower -> answer )
| T.null answer = Just Y
| answer `elem` ["yes", "y", "ys"] = Just Y
| answer `elem` ["no", "n"] = Just N
| otherwise = Nothing
----------------------------------------------------------------------------
-- IO Questioning
----------------------------------------------------------------------------
{- | Prints the given question in the following way:
>>> printQuestion "Which option?" [A, B, C]
"Which option? [A]/B/C"
__ Note__ that the first element in the given list is considered as
the default one.
-}
printQuestion
:: Text -- ^ Question text.
-> [Text] -- ^ List of available answers.
-> IO ()
printQuestion question [] = putTextLn question
printQuestion question (def:rest) = do
putStrFlush $ question <> boldDefault def
let restSlash = T.intercalate "/" rest
if restSlash == ""
then putTextLn ""
else putTextLn $ "/" <> restSlash
-- | Allows users to choose one of the given options.
-- It asks the question until the appropriate answer is received.
choose :: Show a
=> (Text -> Maybe a) -- ^ Parse function
-> Text -- ^ Question text.
-> NonEmpty a -- ^ List of available options.
-> IO a -- ^ The chosen option.
choose parser question choices = do
let showChoices = map show $ toList choices
printQuestion question showChoices
answer <- prompt
if T.null answer
then pure (head choices)
else whenNothing (parser answer)
(errorMessage "This wasn't a valid choice." >> choose parser question choices)
-- | Like 'choose' but the possible answer are 'Y' or 'N'.
chooseYesNo
:: YesNoPrompt -- ^ Target and Prompt
-> IO a -- ^ action for 'Y' answer
-> IO a -- ^ action for 'N' answer
-> IO a
chooseYesNo p@YesNoPrompt {..} yesDo noDo = do
printQuestion yesNoPrompt ["y", "n"]
answer <- yesOrNo <$> prompt
case answer of
Nothing -> do
errorMessage "This wasn't a valid choice."
chooseYesNo p yesDo noDo
Just Y -> trueMessage yesNoTarget >> yesDo
Just N -> falseMessage yesNoTarget >> noDo
-- | Like 'chooseYesNo' but returns 'Bool'.
chooseYesNoBool :: YesNoPrompt -> IO Bool
chooseYesNoBool ynPrompt = chooseYesNo ynPrompt (pure True) (pure False)
{- | The message after yes/no questions. The output depends on the answer.
@
__Benchmarks__ will be added to the project
@
-}
targetMessageWithText :: Bool -> Text -> Text -> IO Bool
targetMessageWithText result target text = do
let (color, actionResult) = if result
then (green, " will be " <> text)
else (cyan, " won't be " <> text)
putTextLn $ mconcat
[ formatWith [italic, bold, color] $ " " <> headToUpper target
, formatWith [color] actionResult
]
pure result
-- | Like 'targetMessageWithText' but the text is "added to the project"
targetMessage :: Bool -> Text -> IO Bool
targetMessage result target = targetMessageWithText result target "added to the project"
trueMessage, falseMessage :: Text -> IO Bool
trueMessage = targetMessage True
falseMessage = targetMessage False
{- | Queries for any answer.
@
Short project description:
->
@
-}
query :: Text -> IO Text
query question = putTextLn question >> prompt
{- | Queries for the answer that should satisfy the given predicate, or be empty.
-}
queryWithPredicate :: Text -> [Text] -> Text -> (Text -> Bool) -> IO Text
queryWithPredicate question options instruction p = loop
where
loop :: IO Text
loop = do
printQuestion question options
formattedMessage [italic] instruction
input <- prompt
if input /= "" && not (p input)
then errorMessage ("'" <> input <> "' doesn't satisfy the requirements.") >> loop
else pure input
-- | Queries for an non-empty answer.
queryNotNull :: Text -> IO Text
queryNotNull question = do
putTextLn question
answer <- prompt
if | T.null answer -> do
errorMessage "An answer is required."
queryNotNull question
| otherwise -> pure answer
-- | Like 'query' but has the default answer if no answer is specified.
queryDef :: Text -> Text -> IO Text
queryDef question defAnswer = do
putTextLn $ question <> boldDefault defAnswer
answer <- prompt
if | T.null answer -> pure defAnswer
| otherwise -> pure answer
-- | Queries many answers. If answers are not parsable shows the failing part
-- and queries again
queryManyRepeatOnFail :: forall a . (Text -> Maybe a) -> IO [a]
queryManyRepeatOnFail parser = promptLoop
where
-- TODO: probably a good idea to use 'Validation' here to simplify code
promptLoop :: IO [a]
promptLoop = do
answer <- prompt
let answers = map (id &&& parser) $ words answer -- converts [Text] into [(Text, Maybe a)]
case partitionPairs answers of
Left unparsed -> do
-- TODO: create intercalateMap function
errorMessage $ "Unable to parse the following items: " <> intercalateMap " " quote unparsed
promptLoop
Right results -> pure results
-- puts only those @c@ into Left list where snd is Nothing;
-- returns Left if at least one second element is Nothing
partitionPairs :: forall x y . [(x, Maybe y)] -> Either [x] [y]
partitionPairs [] = Right []
partitionPairs ((x, my):xs) = case my of
Just y -> (y:) <$> partitionPairs xs
Nothing -> case partitionPairs xs of
Left fails -> Left (x : fails)
Right _ -> Left [x]
quote :: Text -> Text
quote t = "'" <> t <> "'"
checkUniqueName :: Text -> IO Text
checkUniqueName nm = do
exist <- doesExistProjectName nm
if exist then do
warningMessage "Project with this name is already exist. Please choose another one"
newNm <- queryNotNull "Project name: "
checkUniqueName newNm
else
pure nm
-- | Check if the folder with the suggested project name is already exist.
doesExistProjectName :: Text -> IO Bool
doesExistProjectName projectName = do
curPath <- getCurrentDirectory
doesPathExist $ curPath </> toString projectName
----------------------------------------------------------------------------
-- Internal implementation details
----------------------------------------------------------------------------
-- | Explicit flush ensures prompt messages are in the correct order on all systems.
putStrFlush :: Text -> IO ()
putStrFlush msg = do
putText msg
hFlush stdout
{- | Read 'Text' from standard input after arrow prompt.
-}
prompt :: IO Text
prompt = do
putStrFlush $ formatWith [blue] " -> "
T.strip <$> getLine
boldDefault :: Text -> Text
boldDefault message = formatWith [bold] $ " [" <> message <> "]"