packages feed

effectful-cli-0.2.0.0: src/Effectful/CLI/IO.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Terminal output, prompts and confirmations.
module Effectful.CLI.IO (
  printOutput,
  printOutputLn,
  printError,
  printLines,
  prompt,
  prompt',
  askForConfirm,
  withConfirm,
  autoOpen,
  chooseItem,
  resolveIndex,
) where

import Control.Monad (unless, void, when)
import Data.CLI.Class (CLIConfig (..))
import Data.Error.CLI (CLIError (..))
import Data.Foldable (toList)
import Data.Functor (($>))
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Void (Void)
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Error.Static (Error, throwError)
import Effectful.Process (Process, callProcess)
import Effectful.Reader.Static (Reader, ask)
import System.IO (hFlush, stderr)
import Text.Layout.Table (columnHeaderTableS, def, rowsG, tableString, titlesH, unicodeS)
import Text.Megaparsec (Parsec, eof, errorBundlePretty, runParser, (<|>))
import Text.Megaparsec.Char (string')
import Text.Megaparsec.Char.Lexer (decimal, signed)
import Text.Printf (printf)

-- | Print to stdout without a trailing newline.
--
-- @since 0.1.0.0
printOutput :: IOE :> es => Text -> Eff es ()
printOutput = liftIO . T.putStr

-- | Print to stdout with a trailing newline.
--
-- @since 0.1.0.0
printOutputLn :: IOE :> es => Text -> Eff es ()
printOutputLn = liftIO . T.putStrLn

-- | Print to stderr without a trailing newline.
--
-- @since 0.1.0.0
printError :: IOE :> es => Text -> Eff es ()
printError = liftIO . T.hPutStr stderr

-- | Print lines to stdout, each followed by a newline.
--
-- @since 0.1.0.0
printLines :: IOE :> es => Foldable f => f Text -> Eff es ()
printLines = printOutput . T.unlines . toList

-- | Read a line from stdin after printing an optional prompt to stderr.
--
-- @since 0.1.0.0
prompt :: IOE :> es => Maybe Text -> Eff es Text
prompt p = liftIO $ do
  T.hPutStr stderr (fromMaybe "" p <> "> ")
  hFlush stderr
  T.getLine

-- | Prompt and parse the answer, throwing 'InputError' on parse failure.
--
-- @since 0.1.0.0
prompt'
  :: Error CLIError :> es => IOE :> es => Maybe Text -> Parsec Void Text a -> Eff es a
prompt' p parser =
  prompt p
    >>= either (throwError . InputError . T.pack . errorBundlePretty) pure
      . runParser parser "prompt"

-- | Ask for confirmation; @y@, @yes@ or an empty answer mean yes.
--
-- @since 0.1.0.0
askForConfirm :: Error CLIError :> es => IOE :> es => Eff es Bool
askForConfirm = prompt' (Just "proceed ?") yesOrNo
 where
  yes = void (string' "y" <|> string' "yes") <|> eof
  yesOrNo = (yes $> True) <|> pure False

-- | Run the action after user confirmation, honoring the config's auto-accept and
-- auto-decline flags; throws 'UserCanceled' when declined.
--
-- Ambiguous in @c@: call as @withConfirm @MyConfig@.
--
-- @since 0.1.0.0
withConfirm
  :: forall c es a
   . CLIConfig c => Error CLIError :> es => IOE :> es => Reader c :> es => Eff es a -> Eff es a
withConfirm action = do
  c <- ask @c
  when (autoDecline c) $ throwError (UserCanceled (Just "auto-declined"))
  unless (autoAccept c) $ do
    ok <- askForConfirm
    unless ok $ throwError (UserCanceled Nothing)
  action

-- | Open a file or URL with @xdg-open@.
--
-- @since 0.1.0.0
autoOpen :: Process :> es => String -> Eff es ()
autoOpen arg = callProcess "xdg-open" [arg]

-- | Let the user pick one item from a table; a single item is returned without prompting, none throws 'NotFoundError'.
--
-- @since 0.1.0.0
chooseItem
  :: Error CLIError :> es
  => IOE :> es
  => Foldable f
  => Text -> [Text] -> (a -> [Text]) -> f a -> Eff es a
chooseItem name headers row = go . toList
 where
  go [] = throwError (NotFoundError ("no item found for " <> name))
  go [x] = pure x
  go xs = pick xs
  pick xs = do
    let hs = "#" : fmap T.unpack headers
        rows = zipWith (\i x -> show i : fmap T.unpack (row x)) [1 :: Int ..] xs
    printOutputLn . T.pack $
      tableString (columnHeaderTableS (fmap (const def) hs) unicodeS (titlesH hs) [rowsG rows])
    i <- prompt' (Just name) (signed (pure ()) decimal)
    either throwError (pure . (xs !!)) (resolveIndex (length xs) i)

-- | Turn a 1-based (or negative, from the end) user index into a 0-based one.
--
-- @since 0.1.0.0
resolveIndex :: Int -> Int -> Either CLIError Int
resolveIndex len i
  | i >= 1 && i <= len = Right (i - 1)
  | i <= -1 && i >= -len = Right (len + i)
  | otherwise = Left (InputError (T.pack $ printf "index %d out of bounds" i))