packages feed

calamity-commands-0.2.0.0: CalamityCommands/Utils.hs

{-# LANGUAGE RecursiveDo #-}

-- | Command handler utilities
module CalamityCommands.Utils (
  buildCommands,
  processCommands,
  handleCommands,
  findCommand,
  CmdInvokeFailReason (..),
) where

import CalamityCommands.AliasType
import CalamityCommands.Command
import CalamityCommands.CommandUtils
import CalamityCommands.Context
import CalamityCommands.Dsl
import CalamityCommands.Error
import CalamityCommands.Group
import CalamityCommands.Handler
import CalamityCommands.Internal.LocalWriter
import CalamityCommands.ParsePrefix

import Control.Lens hiding (Context)
import Control.Monad.Fix (MonadFix)

import Data.Char (isSpace)
import qualified Data.HashMap.Lazy as LH
import qualified Data.Text as T

import GHC.Generics (Generic)

import qualified Polysemy as P
import qualified Polysemy.Error as P
import qualified Polysemy.Fixpoint as P
import qualified Polysemy.Reader as P
import qualified Polysemy.Tagged as P

mapLeft :: (e -> e') -> Either e a -> Either e' a
mapLeft f (Left x) = Left $ f x
mapLeft _ (Right x) = Right x

data CmdInvokeFailReason c
  = NoContext
  | NotFound [T.Text]
  | CommandInvokeError c CommandError
  deriving (Show, Generic)

{- | Manages parsing messages and handling commands for a CommandHandler.

 Returns Nothing if the prefix didn't match.

 Returns Right with the context and result if the command succeeded in parsing
 and running, Left with the reason otherwise.
-}
processCommands ::
  ( Monad m
  , P.Members '[ParsePrefix msg, ConstructContext msg c m a, P.Embed m] r
  , CommandContext m c a
  ) =>
  CommandHandler m c a ->
  -- | The message that invoked the command
  msg ->
  P.Sem r (Maybe (Either (CmdInvokeFailReason c) (c, a)))
processCommands handler msg =
  parsePrefix msg >>= \case
    Just (pre, cmd) -> Just <$> handleCommands handler msg pre cmd
    Nothing -> pure Nothing

{- | Manages finding the invoked command and parsing parameters for a
   CommandHandler.

 Returns Right with the context and result if the command succeeded in parsing
 and running, Left with the reason otherwise.
-}
handleCommands ::
  ( Monad m
  , P.Members '[ConstructContext msg c m a, P.Embed m] r
  , CommandContext m c a
  ) =>
  CommandHandler m c a ->
  -- | The message that invoked the command
  msg ->
  -- | The prefix used
  T.Text ->
  -- | The command string, without a prefix
  T.Text ->
  P.Sem r (Either (CmdInvokeFailReason c) (c, a))
handleCommands handler msg prefix cmd = P.runError $ do
  (command, unparsedParams) <- P.fromEither . mapLeft NotFound $ findCommand handler cmd
  ctx <- P.note NoContext =<< constructContext (prefix, command, unparsedParams) msg
  r <- P.fromEither . mapLeft (CommandInvokeError ctx) =<< invokeCommand ctx (ctxCommand ctx)
  pure (ctx, r)

-- | Run a command DSL, returning the constructed 'CommandHandler'
buildCommands ::
  forall r c m a x.
  (Monad m, MonadFix m, P.Member (P.Final m) r) =>
  P.Sem (DSLState m c a r) x ->
  P.Sem r (CommandHandler m c a, x)
buildCommands m = P.fixpointToFinal $ mdo
  (groups, (cmds, a)) <- inner handler m
  let handler = CommandHandler groups cmds
  pure (handler, a)
 where
  inner ::
    CommandHandler m c a ->
    P.Sem (DSLState m c a r) x ->
    P.Sem
      (P.Fixpoint ': r)
      ( LH.HashMap T.Text (Group m c a, AliasType)
      , (LH.HashMap T.Text (Command m c a, AliasType), x)
      )
  inner h =
    P.runReader h
      . P.runReader []
      . P.runReader defaultHelp
      . P.untag @"original-help"
      . P.runReader defaultHelp
      . P.runReader False
      . P.untag @"hidden"
      . P.runReader Nothing
      . runLocalWriter @(LH.HashMap T.Text (Group m c a, AliasType))
      . runLocalWriter @(LH.HashMap T.Text (Command m c a, AliasType))
  defaultHelp = const "This command or group has no help."

nextWord :: T.Text -> (T.Text, T.Text)
nextWord = T.break isSpace . T.stripStart

{- | Attempt to find what command was used.

 On error: returns the path of existing groups that were found, so @"group0
 group1 group2 notacommand"@ will error with @Left ["group0", "group1",
 "group2"]@

 On success: returns the command that was invoked, and the remaining text
 after it.

 This function isn't greedy, if you have a group and a command at the same
 level, this will find the command first and ignore the group.
-}
findCommand :: forall c a m. CommandHandler m c a -> T.Text -> Either [T.Text] (Command m c a, T.Text)
findCommand handler msg = goH $ nextWord msg
 where
  goH :: (T.Text, T.Text) -> Either [T.Text] (Command m c a, T.Text)
  goH ("", _) = Left []
  goH (x, xs) =
    attachSoFar
      x
      ( ((,xs) <$> attachInitial (LH.lookup x (handler ^. #commands)))
          <> (attachInitial (LH.lookup x (handler ^. #groups)) >>= goG (nextWord xs))
      )

  goG :: (T.Text, T.Text) -> Group m c a -> Either [T.Text] (Command m c a, T.Text)
  goG ("", _) _ = Left []
  goG (x, xs) g =
    attachSoFar
      x
      ( ((,xs) <$> attachInitial (LH.lookup x (g ^. #commands)))
          <> (attachInitial (LH.lookup x (g ^. #children)) >>= goG (nextWord xs))
      )

  attachInitial :: forall a b. Maybe (a, b) -> Either [T.Text] a
  attachInitial (Just (a, _)) = Right a
  attachInitial Nothing = Left []

  attachSoFar :: forall a. T.Text -> Either [T.Text] a -> Either [T.Text] a
  attachSoFar cmd (Left xs) = Left (cmd : xs)
  attachSoFar _ r = r