structured-cli 0.9.4.1 → 2.0.0.0
raw patch · 3 files changed
+513/−369 lines, 3 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
- System.Console.StructuredCLI: CommandsT :: m (a, [Node m]) -> CommandsT m a
- System.Console.StructuredCLI: Options :: String -> [String] -> ParseResult
- System.Console.StructuredCLI: State :: [Node m] -> [String] -> State m
- System.Console.StructuredCLI: [banner] :: Settings -> String
- System.Console.StructuredCLI: [batch] :: Settings -> Bool
- System.Console.StructuredCLI: [history] :: Settings -> Maybe FilePath
- System.Console.StructuredCLI: [labels] :: State m -> [String]
- System.Console.StructuredCLI: [nodes] :: State m -> [Node m]
- System.Console.StructuredCLI: [prompt] :: Settings -> String
- System.Console.StructuredCLI: [runCommandsT] :: CommandsT m a -> m (a, [Node m])
- System.Console.StructuredCLI: data Parser m
- System.Console.StructuredCLI: data State m
- System.Console.StructuredCLI: instance Control.Monad.IO.Class.MonadIO m => Data.Default.Class.Default (System.Console.StructuredCLI.Parser m)
- System.Console.StructuredCLI: instance Data.Default.Class.Default System.Console.StructuredCLI.Settings
- System.Console.StructuredCLI: instance GHC.Exception.Exception System.Console.StructuredCLI.CLIException
- System.Console.StructuredCLI: mkParser :: (MonadIO m) => (Bool -> String -> m ParseResult) -> Parser m
- System.Console.StructuredCLI: newtype CommandsT m a
- System.Console.StructuredCLI: outputStrLn :: MonadIO m => String -> InputT m ()
- System.Console.StructuredCLI: popCommand :: (Monad m) => CState m ()
- System.Console.StructuredCLI: pushCommand :: (Monad m) => Node m -> String -> CState m ()
- System.Console.StructuredCLI: type Action m = CState m Int
+ System.Console.StructuredCLI: Exit :: CLIException
+ System.Console.StructuredCLI: HelpRequested :: [(String, String)] -> CLIException
+ System.Console.StructuredCLI: InternalError :: String -> CLIException
+ System.Console.StructuredCLI: InvalidOperation :: String -> CLIException
+ System.Console.StructuredCLI: LevelUp :: Int -> Action
+ System.Console.StructuredCLI: NewLevel :: Action
+ System.Console.StructuredCLI: NoAction :: Action
+ System.Console.StructuredCLI: NoMatch :: ParseResult
+ System.Console.StructuredCLI: SyntaxError :: String -> CLIException
+ System.Console.StructuredCLI: ToRoot :: Action
+ System.Console.StructuredCLI: UndecisiveInput :: String -> [String] -> CLIException
+ System.Console.StructuredCLI: [getBanner] :: Settings m -> String
+ System.Console.StructuredCLI: [getCompletions] :: ParseResult -> [String]
+ System.Console.StructuredCLI: [getDoneMatched] :: ParseResult -> String
+ System.Console.StructuredCLI: [getDoneRemaining] :: ParseResult -> String
+ System.Console.StructuredCLI: [getFailMessage] :: ParseResult -> String
+ System.Console.StructuredCLI: [getFailRemaining] :: ParseResult -> String
+ System.Console.StructuredCLI: [getHistory] :: Settings m -> Maybe FilePath
+ System.Console.StructuredCLI: [getOutput] :: ParseResult -> String
+ System.Console.StructuredCLI: [getPartialRemaining] :: ParseResult -> String
+ System.Console.StructuredCLI: [getPrompt] :: Settings m -> String
+ System.Console.StructuredCLI: [handleException] :: Settings m -> ExceptionHandler m
+ System.Console.StructuredCLI: [isBatch] :: Settings m -> Bool
+ System.Console.StructuredCLI: data Action
+ System.Console.StructuredCLI: data CLIException
+ System.Console.StructuredCLI: data CommandsT m a
+ System.Console.StructuredCLI: instance Control.Monad.IO.Class.MonadIO m => Data.Default.Class.Default (System.Console.StructuredCLI.Settings m)
+ System.Console.StructuredCLI: instance GHC.Base.Monad m => Data.Default.Class.Default (System.Console.StructuredCLI.Parser m)
+ System.Console.StructuredCLI: instance GHC.Base.Monad m => Data.Default.Class.Default (System.Console.StructuredCLI.Validator m)
+ System.Console.StructuredCLI: instance GHC.Show.Show System.Console.StructuredCLI.Action
+ System.Console.StructuredCLI: newLevel :: (Monad m) => m Action
+ System.Console.StructuredCLI: noAction :: (Monad m) => m Action
+ System.Console.StructuredCLI: type Handler m = String -> m Action
+ System.Console.StructuredCLI: type Parser m = Node m -> String -> m ParseResult
+ System.Console.StructuredCLI: type Validator m = String -> m (Maybe String)
- System.Console.StructuredCLI: Done :: String -> String -> ParseResult
+ System.Console.StructuredCLI: Done :: String -> String -> String -> ParseResult
- System.Console.StructuredCLI: Fail :: String -> (Maybe String) -> ParseResult
+ System.Console.StructuredCLI: Fail :: String -> String -> ParseResult
- System.Console.StructuredCLI: Partial :: ParseResult
+ System.Console.StructuredCLI: Partial :: [String] -> String -> ParseResult
- System.Console.StructuredCLI: Settings :: Maybe FilePath -> String -> String -> Bool -> Settings
+ System.Console.StructuredCLI: Settings :: Maybe FilePath -> String -> String -> Bool -> ExceptionHandler m -> Settings m
- System.Console.StructuredCLI: command :: (MonadIO m) => String -> Maybe String -> Maybe (Action m) -> CommandsT m ()
+ System.Console.StructuredCLI: command :: (Monad m) => String -> String -> m Action -> CommandsT m ()
- System.Console.StructuredCLI: command' :: (MonadIO m) => String -> Maybe String -> Maybe (IORef Bool) -> Maybe (Action m) -> CommandsT m ()
+ System.Console.StructuredCLI: command' :: (Monad m) => String -> String -> m Bool -> m Action -> CommandsT m ()
- System.Console.StructuredCLI: data Settings
+ System.Console.StructuredCLI: data Settings m
- System.Console.StructuredCLI: exit :: (MonadIO m) => Maybe String -> CommandsT m ()
+ System.Console.StructuredCLI: exit :: (Monad m) => m Action
- System.Console.StructuredCLI: param :: (Monad m) => String -> Maybe String -> Parser m -> Maybe (Action m) -> CommandsT m ()
+ System.Console.StructuredCLI: param :: (Monad m) => String -> String -> Validator m -> Handler m -> CommandsT m ()
- System.Console.StructuredCLI: param' :: (Monad m) => String -> Maybe String -> Maybe (IORef Bool) -> Parser m -> Maybe (Action m) -> CommandsT m ()
+ System.Console.StructuredCLI: param' :: (Monad m) => String -> String -> Validator m -> m Bool -> Handler m -> CommandsT m ()
- System.Console.StructuredCLI: runCLI :: (MonadException m) => String -> Maybe Settings -> CommandsT m () -> m ()
+ System.Console.StructuredCLI: runCLI :: (MonadException m) => String -> Settings m -> CommandsT m a -> m (Either CLIException a)
- System.Console.StructuredCLI: top :: (MonadIO m) => Maybe String -> CommandsT m ()
+ System.Console.StructuredCLI: top :: (Monad m) => m Action
Files
- example/Main.hs +28/−32
- src/System/Console/StructuredCLI.hs +483/−335
- structured-cli.cabal +2/−2
example/Main.hs view
@@ -1,12 +1,9 @@ {-# LANGUAGE RecordWildCards, FlexibleContexts #-} module Main where -import Control.Monad.Trans (lift) import Control.Monad.IO.Class (liftIO) import Control.Monad.State.Strict (StateT, evalStateT, gets, modify)-import Data.Char (isSpace) import Data.Default (def)-import Data.List (span) import System.Console.StructuredCLI import Text.Read (readMaybe) @@ -22,52 +19,51 @@ basic :: CommandsT StateM () basic = do- top $ Just "return to the top of the tree"- exit $ Just "go back one level up"+ command "top" "return to the top of the tree" top+ command "exit" "go back one level up" exit foo :: CommandsT StateM () foo =- command "foo" (Just "pity the foo") Nothing >+ do+ command "foo" "pity the foo" (return NewLevel) >+ do basic bar baz bar :: CommandsT StateM ()-bar = param "bar" (Just "<number of bars>") parseBars Nothing >+ do+bar = param "bar" "<number of bars>" parseBars setBars >+ do basic frob+ where setBars str = do+ let b = read str+ bars <- gets bars+ modify $ \s -> s { bars = bars + b }+ return NewLevel baz :: CommandsT StateM ()-baz = command "baz" (Just "do the baz thing") $ Just $ do- n <- lift $ modify incBaz >> gets bazs+baz = command' "baz" "do the baz thing" checkBazs $ do+ n <- modify incBaz >> gets bazs liftIO . putStrLn $ "You have bazzed " ++ show n ++ " times"- return 0+ return NoAction where incBaz s@AppState{..} = s { bazs = bazs + 1 }+ checkBazs = do+ bazCount <- gets bazs+ return $ bazCount < 3 -- after 3 bazs, disable baz command frob :: CommandsT StateM ()-frob = command "frob" (Just "frob this level") $ Just $ do- n <- lift $ gets bars+frob = command "frob" "frob this level" $ do+ n <- gets bars liftIO . putStrLn $ "frobbing " ++ show n ++ " bars"- return 0--parseBars :: Parser StateM-parseBars = mkParser $ readNum "bar"--readNum :: String -> Bool -> String -> StateM ParseResult-readNum name _ "" = return $ failure name-readNum name _ input = do- let (x, remains) = span (not.isSpace) $ dropWhile isSpace input- maybe (complain) (accept x remains) $ readMaybe x- where complain = do- return $ failure name- accept x remaining n = do- modify $ \s@AppState{..} -> s { bars = n }- return $ Done x remaining+ return NoAction -failure :: String -> ParseResult-failure name = Fail name $ Just "<number of times to bar>"+parseBars :: Validator StateM+parseBars = return . fmap show . (readMaybe :: String -> Maybe Int) main :: IO ()-main = evalStateT (runCLI "some CLI" (Just settings) root) $ AppState 0 0- where settings = def { banner = "Some CLI Application\nTab completion is your friend!",- history = Just ".someCLI.history" }+main = do+ let state0 = AppState 0 0+ evalStateT run state0+ where run = do+ result <- runCLI "some CLI" settings root+ either (error.show) return result+ settings = def { getBanner = "Some CLI Application\nTab completion is your friend!",+ getHistory = Just ".someCLI.history" }
src/System/Console/StructuredCLI.hs view
@@ -1,9 +1,14 @@-{-# LANGUAGE ImplicitParams, RecordWildCards, TupleSections #-}+{-# LANGUAGE ImplicitParams,+ FlexibleContexts,+ FlexibleInstances,+ RecordWildCards,+ TupleSections,+ TypeSynonymInstances #-} ----------------------------------------------------------------------------- {- | Module: System.Console.StructuredCLI Description: Application library for building interactive console CLIs-Copyright: (c) Erick Gonzalez, 2017+Copyright: (c) Erick Gonzalez, 2017-2018 License: BSD3 Maintainer: erick@codemonkeylabs.de @@ -15,11 +20,15 @@ module System.Console.StructuredCLI ( -- * How to use this module: -- |--- The following code illustrates a simple but complete--- CLI app:+-- It is often the case that a simple example is the best user guide, at least for the+-- experienced programmer. The following code illustrates a basic but functioning CLI application -- -- @--- import Control.Monad.IO.Class (liftIO)+-- module Main where+--+-- import Control.Monad (void)+-- import Control.Monad.IO.Class (liftIO)+-- import Data.Default (def) -- import System.Console.StructuredCLI -- -- root :: Commands ()@@ -27,121 +36,187 @@ -- world >+ do -- hello -- bye--- exit $ Just "return to previous level"+-- command "exit" "return to previous level" exit -- -- world :: Commands ()--- world = command "world" (Just "enter into world") Nothing+-- world = command "world" "enter into the world" $ return NewLevel -- -- hello :: Commands ()--- hello = command "hello" (Just "prints a greeting") $ Just $ do+-- hello = command "hello" "prints a greeting" $ do -- liftIO . putStrLn $ "Hello world!"--- return 0+-- return NoAction -- -- bye :: Commands ()--- bye = command "bye" (Just "say goodbye") $ Just $ do+-- bye = command "bye" "say goodbye" $ do -- liftIO . putStrLn $ "Sayonara!"--- return 0+-- return NoAction -- -- main :: IO ()--- main = runCLI "Hello CLI" Nothing root+-- main = void $ runCLI "Hello CLI" def root -- @ ----- resulting example session:+-- resulting example CLI session: -- -- >>> Hello CLI > ?--- - world: enter into world+-- - world: enter into the world+-- -- >>> Hello CLI > world--- >>> Hello CLI world >--- bye exit hello+-- >>> Hello CLI world > ?+-- - exit: return to previous level+-- - bye: say goodbye+-- - hello: prints a greeting+-- -- >>> Hello CLI world > hello -- Hello world!+-- >>> Hello CLI world > bye+-- Sayonara! -- >>> Hello CLI world > exit+-- >>> Hello CLI > ----- A good way to get you started is to grab the example code available under <https://github.com/erickg/structured-cli/blob/master/example/Main.hs example/Main.hs> and modify it to suit your needs.- Action,+-- A good way to get you started is to grab the example code available under <http://gitlab.com/codemonkeylabs/structured-cli/blob/master/example/Main.hs example/Main.hs> and modify it to suit your needs.+ Action(..),+ CLIException(..), Commands,- CommandsT(..),+ CommandsT,+ Handler, Parser, ParseResult(..), Settings(..),- State(..),+ Validator, (>+), command, command', exit,- mkParser,- outputStrLn,+ newLevel,+ noAction, param, param',- popCommand,- pushCommand, runCLI, top) where import Control.Applicative (liftA2)-import Control.Exception (Exception, throw)-import Control.Monad (filterM, foldM, replicateM, void, when)+import Control.Monad (foldM, mapM, replicateM_, void, when)+import Control.Monad.Except (ExceptT(..), catchError, runExceptT, throwError) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Trans (MonadTrans, lift) import Control.Monad.Trans.Maybe (MaybeT(..), runMaybeT)-import Control.Monad.State.Strict (StateT, evalStateT, gets, modify)---import Data.Attoparsec.ByteString (Parser,--- Result,--- parse,--- string)+import Control.Monad.State.Strict (StateT, evalStateT, get, gets, modify, put) import Data.Char (isSpace) import Data.Default (Default, def)-import Data.IORef (IORef, readIORef)-import Data.List (filter, isPrefixOf, intercalate, span, sort)-import Data.Maybe (isJust, fromJust)+import Data.List (intercalate, isPrefixOf, sort, span) import Data.Monoid ((<>))-import Data.Typeable (Typeable)-import System.Console.Haskeline (Completion,- InputT,- MonadException,- completeWord,- defaultSettings,- getInputLine,- outputStrLn,- runInputT,- setComplete,- simpleCompletion)+import System.Console.Haskeline (MonadException) -import qualified System.Console.Haskeline as Haskeline+import qualified System.Console.Haskeline as HL -data State m = State { nodes :: [Node m],- labels :: [String] }+data State m = State { stack :: [ Level m ] } -type CState m = StateT (State m) m-type Action m = CState m Int+type Level m = ( String, Node m ) -data Node m = Node { label :: String,- hint :: Maybe String,- disable :: Maybe (IORef Bool),- branches :: [Node m],- parser :: Parser m,- action :: Maybe (Action m) }+type StateM m = StateT (State m) m -data Settings = Settings { history :: Maybe FilePath,- banner :: String,- prompt :: String,- batch :: Bool }+type Handler m = String -> m Action -data CLIException = Exit deriving (Show, Typeable)+-- | An 'Action' is returned as the result of a command handler provided by the user and+-- it instructs the CLI of any changes in the CLI state+data Action+ -- | The command executed is "entered" into, creating a new CLI level.+ = NewLevel+ -- | Do not enter a new level.+ | NoAction+ -- | Reset the CLI state up to a given number of levels.+ | LevelUp Int+ -- | Go back up all the way to the top (root) of the CLI.+ | ToRoot+ deriving (Show) -instance Exception CLIException+data Node m = Node { getLabel :: String,+ getHint :: String,+ getBranches :: [Node m],+ runParser :: Parser m,+ isEnabled :: m Bool,+ handle :: Handler m } -newtype CommandsT m a = CommandsT { runCommandsT :: m (a, [Node m]) }-type Commands = CommandsT IO+type Parser m = Node m -> String -> m ParseResult -newtype Parser m = Parser { runParser :: (Bool -> Node m -> String -> m ParseResult) }+-- | A 'Validator' is a function to which a parsed string is given in order to perform+-- any checks for validity that may be applicable, or even transforming the argument if+-- necessary. Note that the validator runs in the "user" monad+type Validator m = String -> m (Maybe String) -data ParseResult = Done String String- | Fail String (Maybe String)- | Options String [String]- | Partial- deriving Show+type ExceptionHandler m = CLIException -> m (Either CLIException ()) +-- | There is no need to concern oneself with the 'ParseResult' type unless one is writing+-- a custom parser, which should actually be rarer than not.+data ParseResult =+ Done {+ -- | Output string to be fed to the command action handler+ getOutput :: String,+ -- | Part of the string matched during parsing of a command+ getDoneMatched :: String,+ -- | Remaining input data+ getDoneRemaining :: String }+ | Partial {+ -- | List of possible completions after given input for this command+ getCompletions :: [String],+ -- | Remaining input data+ getPartialRemaining :: String }+ | Fail {+ -- | A message string containing a possible hint for correct useage+ getFailMessage :: String,+ -- | Remaining input data+ getFailRemaining :: String }+ -- | Parsing provided input doesnt match this command. The difference between 'Fail' and+ -- 'NoMatch' is a fine but important one. Failure should be used for example when a command+ -- keyword is correct but a required parameter is invalid or contains an error for example.+ -- A 'NoMatch' should be exclusively used when a command keyword does not correspond to the+ -- given input+ | NoMatch+ deriving Show++data Settings m+ -- | CLI Settings provided upon launching the CLI. It is recommended to modify+ -- the settings provided by the 'Default' instance: i.e:+ -- @+ -- def { getBanner = "My CLI" }+ -- @+ -- that way you can use for example the default exception handler which should suit+ -- usual needs, etc.+ = Settings {+ -- | An optional filename to activate and store the CLI command history function+ getHistory :: Maybe FilePath,+ -- | Text to display upon start of the CLI application+ getBanner :: String,+ -- | Prompt characters to display to the right of the current command "stack"+ getPrompt :: String,+ -- | Disable prompt for use with batch scripts+ isBatch :: Bool,+ -- | Exception handler+ handleException :: ExceptionHandler m }++data CLIException = Exit+ | InternalError String+ | SyntaxError String+ | UndecisiveInput String [String]+ | HelpRequested [(String, String)]+ | InvalidOperation String+ deriving Show++-- | The 'CommandsT' transformer monad is the key to building a CLI tree. It is meant to+-- be used as a transformer wrapping an application specific "user" monad (for example, a 'State'+-- monad encapsulating application state). This monad is executed _once_ upon calling 'runCLI'+-- to build the command tree. Keep in mind however that any parsers or actions used in+-- any given command all run in the "user" monad and unlike the process of building the command+-- tree, they will be called multiple times as the user navigates the CLI at runtime.+-- Each 'CommandsT' monadic action corresponds to a single "node" (a.k.a. command) in the CLI.+-- Succesive actions simply add commands to the current "level". It is possible to "nest"+-- a new level to a command by using the '(>+)' operator. When properly indented (see example code+-- above) it provides a pretty self explanatory way to build the CLI tree.+newtype CommandsT m a = CommandsT { runCommandsT :: m (a, [Node m]) }++-- | An alias type for the case where CommandsT wraps IO only (i.e. no state, etc)+type Commands = CommandsT IO+ instance (Functor f) => Functor (CommandsT f) where fmap f = CommandsT . fmap (\(a, w) -> (f a, w)) . runCommandsT @@ -166,70 +241,44 @@ instance (MonadIO m) => MonadIO (CommandsT m) where liftIO = lift . liftIO -instance (MonadIO m) => Default (Parser m) where- def = Parser labelParser--instance Default Settings where- def = Settings Nothing "" " > " False--nextWord :: String -> (String, String)-nextWord = span (not.isSpace) . dropWhile isSpace+instance (MonadIO m) => Default (Settings m) where+ def = Settings Nothing "" " > " False defExceptionHandler -trim :: String -> String-trim = reverse.dropWhile isSpace.reverse+instance (Monad m) => Default (Parser m) where+ def = labelParser -labelParser :: (MonadIO m) => Bool -> Node m -> String -> m ParseResult-labelParser _ Node{..} "" = return $ Fail ("Missing expected keyword " ++ label) hint-labelParser partial Node{..} input = do- let (x, remains) = nextWord input- let result = if label == x then- Done x remains- else do- failure x- return result- where failure x | partial = if x `isPrefixOf` label then Partial else failed- | otherwise = failed- failed = Fail label hint+instance (Monad m) => Default (Validator m) where+ def = return . pure . id -noParse :: (Monad m) => Parser m-noParse = Parser . const . const . const . return $ Fail "" Nothing+type ParserT m = ExceptT CLIException (HL.InputT (StateM m)) -command :: (MonadIO m)- => String- -> Maybe String- -> Maybe (Action m)- -> CommandsT m ()-command name hint action =- CommandsT . return . ((),) . pure $ Node name hint Nothing [] def action+liftStateM :: (Monad m) => StateM m a -> ParserT m a+liftStateM = lift . lift -param :: (Monad m)- => String- -> Maybe String- -> Parser m- -> Maybe (Action m)- -> CommandsT m ()-param name hint parser action =- CommandsT . return . ((),) . pure $ Node name hint Nothing [] parser action+liftInputT :: (Monad m) => HL.InputT (StateM m) a -> ParserT m a+liftInputT = lift -command' :: (MonadIO m)- => String- -> Maybe String- -> Maybe (IORef Bool)- -> Maybe (Action m)- -> CommandsT m ()-command' name hint disable action =- CommandsT . return . ((),) . pure $ Node name hint disable [] def action+liftUserM :: (Monad m) => m a -> ParserT m a+liftUserM = lift . lift . lift -param' :: (Monad m)- => String- -> Maybe String- -> Maybe (IORef Bool)- -> Parser m- -> Maybe (Action m)- -> CommandsT m ()-param' name hint disable parser action =- CommandsT . return . ((),) . pure $ Node name hint disable [] parser action+execCommandsT :: (Monad m) => CommandsT m a -> m [Node m]+execCommandsT = fmap snd . runCommandsT +-- | the CommandsT "nest" operation. It adds a new deeper CLI level to the command on the left+-- side with the commands on the right side, for example:+-- @+-- activate >+ do+-- foo+-- bar+-- baz+-- @+-- Would result in the following CLI command structure:+--+-- >>> > activate+-- >>> activate > ?+-- >>> - foo ..+-- >>> - bar ..+-- >>> - baz .. (>+) :: (Monad m) => CommandsT m () -> CommandsT m () -> CommandsT m () node >+ descendents = do node' <- lift $ execCommandsT node@@ -241,236 +290,335 @@ [predecessor] -> CommandsT $ do ns <- execCommandsT descendents- return ((), [predecessor { branches = ns }])+ return ((), [predecessor { getBranches = ns }]) -execCommandsT :: (Monad m) => CommandsT m a -> m [Node m]-execCommandsT = fmap snd . runCommandsT+-- | Build a command node that is always active and takes no parameters+command :: (Monad m) => String -- ^ Command keyword+ -> String -- ^ Help text for this command+ -> m Action -- ^ Action in the "user" monad (i.e. @return NewLevel@)+ -> CommandsT m ()+command label hint action = do+ command' label hint (return True) action -exit :: (MonadIO m) => Maybe String -> CommandsT m ()-exit hint = command "exit" hint $ Just $ do- ns <- gets nodes- case ns of- [] ->- lostInSpace- _:[] ->- lostInSpace- [_, _] -> do -- only 1 command left in the stack.. (should be root)- liftIO $ putStrLn "Nowhere else to go. Type <ctrl-C> anytime to exit"- return 0- _ ->- return 1 -- pop 1 command from the stack+-- | A variation of 'command' that allows for "disabling" the command at runtime by+-- running the given "enable" monadic action (as always in the "user" monad) to check+-- if the command should be displayed as an option and/or accepted or not.+command' :: (Monad m) => String -- ^ Command keyword+ -> String -- ^ Help text for this command+ -> m Bool -- ^ Enable action in the "user" monad+ -> m Action -- ^ Action in the "user" monad (i.e. @return NewLevel@)+ -> CommandsT m ()+command' label hint enable action = do+ let node = Node { getLabel = label,+ getHint = hint,+ getBranches = [],+ runParser = labelParser,+ isEnabled = enable,+ handle = const action }+ CommandsT . return $ ((), [node]) -top :: (MonadIO m) => Maybe String -> CommandsT m ()-top hint = command "top" hint $ Just $ return (-maxBound)+-- | Build a command node that takes one parameter (delimited by space). The parsed parameter+-- is fed to the validator monadic function (in the "user" monad) and the resulting string+-- if any is fed in turn as an argument to the handler action (also in the "user" monad).+param :: (Monad m) => String -- ^ Command keyword+ -> String -- ^ Help text for this command (including argument description)+ -> Validator m -- ^ Monadic validator (in the "user" monad)+ -> Handler m -- ^ Action in the "user" monad (i.e. @return NewLevel@)+ -> CommandsT m ()+param label hint validator handler =+ param' label hint validator (return True) handler -runCLI :: (MonadException m) => String -> Maybe Settings -> CommandsT m () -> m ()-runCLI name userSettings rootCmds = do- root <- execCommandsT rootCmds- settings <- runMaybeT $ do- s@Settings{..} <- MaybeT . pure $ userSettings- when (not batch) $ liftIO . putStrLn $ banner- return s- let ?settings = maybe def id settings- evalStateT loop $ stateFor root- where stateFor root = State [Node name Nothing Nothing root noParse Nothing] [name]- loop :: (?settings::Settings, MonadException m) => CState m ()- loop = do- settings <- getSettings $ history ?settings- runInputT settings runLevel- loop+-- | A variation of 'param' that allows for "disabling" the command at runtime by+-- running the given "enable" monadic action (as always in the "user" monad) to check+-- if the command should be displayed as an option and/or accepted or not.+param' :: (Monad m) => String -- ^ Command keyword+ -> String -- ^ Help text for this command (including argument description)+ -> Validator m -- ^ Monadic validator (in the "user" monad)+ -> m Bool -- ^ Enable action in the "user" monad+ -> Handler m -- ^ Action in the "user" monad (i.e. @return NewLevel@)+ -> CommandsT m ()+param' label hint validator enable handler = do+ let node = Node { getLabel = label,+ getHint = hint,+ getBranches = [],+ runParser = paramParser hint validator,+ isEnabled = enable,+ handle = handler }+ CommandsT . return $ ((), [node]) -runLevel :: (?settings::Settings, MonadException m) => InputT (CState m) ()-runLevel = do- prompt <- if batch ?settings then return "" else lift getPrompt- nodes0 <- lift $ gets nodes- labels0 <- lift $ gets labels- result <- runMaybeT $ do- line <- MaybeT $ getInputLine prompt- parse line- case result of- Nothing -> do- if batch ?settings then- throw Exit- else- lift $ modify $ \state -> state { nodes = nodes0, -- parse failed or no action- labels = labels0 } -- restore nodes to previous state- Just _ -> do- Node{..} <- lift getCurrentCommand- case action of- Nothing -> return ()- Just x -> lift $ do- nodes <- gets nodes- popDepth <- x- let depth = length nodes- depth0 = length nodes0- depth' = max 1 $ depth0 - popDepth -- there must always be at least a root node- toPop = depth - depth'- void $ replicateM toPop popCommand+-- | A utility action to reset the CLI tree to the root node . Equivalent to @return ToRoot@+top :: (Monad m) => m Action+top = return ToRoot -parse :: (MonadIO m) => String -> MaybeT (InputT (CState m)) [Node m]-parse "" = currentBranches''-parse ws | all isSpace ws = currentBranches''-parse input = do- nodes <- currentBranches''- (n@Node{..}, matched, remaining) <- findNode input nodes [Nothing]- lift $ pushCommand' n $ trim matched- parse remaining+-- | A utility action to "leave" the current CLI level. Equivalent to @return $ LevelUp 1@+exit :: (Monad m) => m Action+exit = return $ LevelUp 1 -tryParse :: (MonadIO m) => String -> [Node m] -> m [Node m]-tryParse "" (x:_) = return $ branches x-tryParse _ [] = return []-tryParse " " (x:_) = return $ branches x-tryParse input (n:_) = do- let nodes = branches n- result <- findNode' input nodes- case result of- Nothing ->- filterNodes input nodes- Just (c, remaining) -> do- tryParse remaining (c:nodes)+-- | A utility action to "nest" into a new CLI level. Equivalent to @return NewLevel@+newLevel :: (Monad m) => m Action+newLevel = return NewLevel -filterNodes :: (MonadIO m) => String -> [Node m] -> m [Node m]-filterNodes input = foldM filterNodes' []- where filterNodes' acc node@Node{..} = do- disabled <- liftIO $ maybe (return False) readIORef disable- if disabled- then return acc- else do- result <- runParser parser True node input- case result of- Fail _ _ ->- return acc- Options _ strs -> do- let nodes = fakeNode node <$> strs- return $ nodes ++ acc- _ ->- return $ node:acc+-- | A utility action to leave the current CLI level untouched. Equivalent to @return NoAction@+noAction :: (Monad m) => m Action+noAction = return NoAction -currentBranches :: (MonadIO m) => (CState m) [Node m]-currentBranches = getCurrentCommand >>= return . branches+labelParser :: (Monad m) => Node m -> String -> m ParseResult+labelParser Node{..} input = do+ case nextWord input of+ (word, remaining) | word == getLabel ->+ return $ Done "" word remaining+ (word, remaining) | word `isPrefixOf` getLabel ->+ return $ Partial [getLabel] remaining+ (_, _) ->+ return $ NoMatch -currentBranches'' :: (MonadIO m,- MonadTrans t,- MonadTrans u,- Monad (u (CState m))) =>- t (u (CState m)) [Node m]-currentBranches'' = lift . lift $ currentBranches+infixr 9 -.-+(-.-) :: (b -> c) -> (a -> a1 -> b) -> a -> a1 -> c+(-.-) = (.).(.) -findNode :: (MonadIO m) =>- String ->- [Node m] ->- [Maybe ParseResult] ->- MaybeT (InputT (CState m)) (Node m, String, String)-findNode input [] results = do- lift $ when (not $ "?" `isPrefixOf` reverse input) $- outputStrLn $ "Syntax error at or around " ++ input- let (keyword,_) = nextWord $ reverse $ dropWhile (== '?') $ reverse input- lift $ mapM_ (outputStrLn.syntaxError) $ filter (matching keyword) results- MaybeT . return $ Nothing- where syntaxError (Just (Fail name hint)) = "- " ++ name ++ (maybe "" (": "++) hint)- syntaxError _ = ""- matching kw (Just (Fail name _)) = kw `isPrefixOf` name- matching _ _ = False-findNode input (node@Node{..}:rest) results = do- disabled <- liftIO $ maybe (return False) readIORef disable- if disabled- then findNode input rest results- else do- result <- lift . lift .lift $ (runParser parser) False node input- case result of- Done matched remaining ->- return (node, matched, remaining)- Partial ->- error $ "Partial match during exact parsing of " ++ input ++ " at or around " ++ label- _ ->- findNode input rest $ (Just result):results+paramParser :: (Monad m) => String -> (String -> m (Maybe String)) -> Node m -> String -> m ParseResult+paramParser hint validator = parseParam -.- labelParser+ where parseParam = flip (>>=) parseParam'+ parseParam' (Done _ matched rest) =+ case nextWord rest of+ ("?", _) ->+ return $ Fail hint rest+ ("", remaining) ->+ return $ Partial [] remaining+ (word, remaining) -> do+ v <- validator word+ return $ maybe (badArg rest) (\x -> Done x (matched ++ ' ':word) remaining) v+ parseParam' result =+ return result+ badArg = Fail hint -findNode' :: (MonadIO m) => String -> [Node m] -> m (Maybe (Node m, String))-findNode' _ [] = return Nothing-findNode' input (node@Node{..}:rest) = do- disabled <- liftIO $ maybe (return False) readIORef disable- if disabled- then findNode' input rest- else do- result <- (runParser parser) False node input- case result of- Done _ remaining ->- return $ Just (node, remaining)- Options input' strs -> do- let nodes = fakeNode node <$> strs- findNode' input' nodes- Partial ->- error $ "Partial match during exact parsing of " ++ input ++ " at or around " ++ label- _ ->- findNode' input rest+nextWord :: String -> (String, String)+nextWord = span (not.isSpace) . dropWhile isSpace -fakeNode :: (MonadIO m) => Node m -> String -> Node m-fakeNode node str = node { label = str, parser = Parser labelParser }+hLineSettingsFrom :: (MonadIO m) => Settings m -> HL.Settings (StateM m)+hLineSettingsFrom Settings{..} =+ HL.setComplete explorer HL.defaultSettings { HL.historyFile = getHistory } -pushCommand' :: (MonadTrans t, Monad m) => Node m -> String -> t (CState m) ()-pushCommand' n = lift . pushCommand n+-- | Launches the CLI application. It doesn't normally return unless an exception is thrown+-- or if it runs out of input in batch mode. Normal return value is that returned by the CommandsT+-- action that built the tree. Remember that 'Settings' is an instance of 'Default'+runCLI :: (MonadException m) => String -> Settings m -> CommandsT m a -> m (Either CLIException a)+runCLI name settings@Settings{..} commands = do+ (value, root) <- runCommandsT commands+ when (not isBatch) $ liftIO . putStrLn $ getBanner+ let ?settings = settings+ withStateM root . HL.runInputT hLineSettings . runExceptT $ do+ loop+ return value+ where hLineSettings = hLineSettingsFrom settings+ withStateM root = flip evalStateT $ state0 root+ processInput = do+ let ?settings = settings+ state <- liftStateM get+ runLevel `catchError` \e -> do+ liftStateM $ put state+ throwError e+ processInput+ dummyParser' _ t = return . (flip Partial) t . fmap getLabel+ dummyParser r s t = dummyParser' s t r+ state0 root = State [(name, mkNode root)]+ mkNode root = Node {+ getLabel = name,+ getHint = mempty,+ getBranches = root,+ runParser = dummyParser root,+ isEnabled = return True,+ handle = const . return $ NewLevel+ }+ loop = do+ void . catchError processInput $+ \e -> do+ exceptionResult <- liftUserM $ handleException e+ either throwError return exceptionResult+ loop -pushCommand :: (Monad m) => Node m -> String -> CState m ()-pushCommand n label = do- ns <- gets nodes- ls <- gets labels- modify $ \state -> state { nodes = n:ns, labels = label:ls }+defExceptionHandler :: (MonadIO m) => CLIException -> m (Either CLIException ())+defExceptionHandler (SyntaxError str) = do+ fmap Right . liftIO . putStrLn $ "SyntaxError at or around " ++ str ++ "\n"+defExceptionHandler (HelpRequested hints) =+ fmap Right . liftIO $ do+ mapM_ display $ hints+ putStrLn ""+ where display (label, hint) =+ putStrLn $ "- " ++ label ++ ": " ++ hint+defExceptionHandler e =+ return . Left $ e -popCommand :: (Monad m) => CState m ()-popCommand = do- (_:cs) <- gets nodes- (_:ls) <- gets labels- modify $ \state -> state { nodes = cs, labels = ls }+runLevel :: (?settings::Settings m, MonadException m) => ParserT m ()+runLevel = do+ prompt <- buildPrompt <$> withLabels+ stack0 <- getStack+ result <- runMaybeT $ do+ line <- MaybeT . liftInputT $ HL.getInputLine prompt+ process line+ case result of+ Nothing ->+ if isBatch ?settings+ then throwError Exit+ else restore stack0+ _ ->+ return () -getSettings :: (MonadIO m) => Maybe FilePath -> CState m (Haskeline.Settings (CState m))-getSettings path =- return $ setComplete explorer defaultSettings { Haskeline.historyFile = path }+ where buildPrompt ns = (intercalate " " . reverse $ ns) ++ getPrompt ?settings+ withLabels = getStack >>= return . fmap fst+ restore stack = liftStateM . modify $ \s -> s { stack = stack } -explorer :: (MonadIO m) => (String, String) -> CState m (String, [Completion])-explorer input@(left, _) = do- nodes <- gets nodes- options <- lift $ getPossibilities left nodes- let keywords = sort $ fmap label options- let complete = completeWord Nothing " " $ \str ->- return $ map simpleCompletion $ filter (str `isPrefixOf`) keywords- complete input+getStack :: (Monad m) => ParserT m [Level m]+getStack = liftStateM $ gets stack -getPossibilities :: (MonadIO m) => String -> [Node m] -> m [Node m]-getPossibilities "" = filterM checkDisabled . branches . head- where checkDisabled Node{..}- | isJust disable = do- isDisabled <- liftIO $ readIORef (fromJust disable)- return $ not isDisabled- | otherwise = return True-getPossibilities input = tryParse $ reverse input+process :: (Monad m) => String -> MaybeT (ParserT m) ()+process input = lift $ do+ stack0 <- getStack+ node <- getCurrentNode+ action <- process' input node NewLevel -- I believe it shouldn't actually matter since it will+ -- simply be overriden by the last action result but+ -- NewLevel als default action is correct in term of the+ -- expected behaviour when parsing a command. We keep+ -- nesting until done.. -getCurrentCommand :: (Monad m) => CState m (Node m)-getCurrentCommand = do- ns <- gets nodes- case ns of+ case action of+ NewLevel ->+ return ()+ LevelUp n ->+ levelUp n stack0+ NoAction ->+ levelUp 0 stack0+ ToRoot ->+ levelUp (-maxBound) stack0+ where levelUp levels stack0 = do+ stack <- getStack+ let depth = length stack+ depth0 = length stack0+ depth' = max 1 $ depth0 - levels -- there must always be at least a root node+ to = depth - depth'+ replicateM_ to pop++process' :: (Monad m) => String -> Node m -> Action -> ParserT m Action+process' "" _ action =+ return action+process' (' ':remaining) node action =+ process' remaining node action+process' input currentNode _ = do+ result <- liftStateM $ findNext currentNode input+ case result of+ ([], _, _, _) ->+ throwError . SyntaxError $ input+ ([node@Node{..}], output, matched, remaining) -> do+ checkForHelp matched [node]+ push matched node+ action <- liftUserM $ handle output+ process' remaining node action+ (nodes, _, matched, _) -> do+ checkForHelp matched nodes+ throwError . UndecisiveInput input $ fmap getLabel nodes+ where checkForHelp "?" nodes =+ void . throwError . HelpRequested $ fmap help nodes+ checkForHelp _ _ =+ return ()++help :: (Monad m) => Node m -> (String , String)+help Node{..} = (getLabel, getHint)++push :: (Monad m) => String -> Node m -> ParserT m ()+push label node =+ liftStateM . modify $ \s@State{..} ->+ s { stack = (label, node) : stack }++pop :: (Monad m) => ParserT m ()+pop = do+ stack <- liftStateM $ gets stack+ case stack of+ (_:remaining) ->+ liftStateM $ modify $ \s -> s { stack = remaining } [] ->- lostInSpace- node:_ ->- return node+ throwError . InvalidOperation $ "Invalid attempt to pop element from empty command stack" -getPrompt :: (?settings::Settings, Monad m) => CState m String-getPrompt = buildPrompt <$> gets labels- where buildPrompt ns = (intercalate " " . reverse $ ns) ++ prompt ?settings+getCurrentNode :: (Monad m) => ParserT m (Node m)+getCurrentNode = do+ stack <- liftStateM $ gets stack+ case stack of+ ((_, node):_) -> return node+ [] -> throwError . InternalError $ "Empty command stack" -lostInSpace :: (Monad m) => m a-lostInSpace = error "The impossible has happened: unknown location in CLI"+findNext :: (Monad m) => Node m -> String -> StateM m ([Node m], String, String, String)+findNext = findNext' False -mkParser :: (MonadIO m) => (Bool -> String -> m ParseResult) -> Parser m-mkParser fun =- Parser $ \partial node@Node{..} input -> do- result <- labelParser partial node input- case result of- Done matched1 remaining1 -> do- r <- fun partial remaining1- return $ case r of- Done matched2 remaining2 ->- Done (matched1 ++ ' ':matched2) remaining2- o ->- o- x ->- return x+findAll :: (Monad m) => Node m -> String -> StateM m ([Node m], String, String, String)+findAll = findNext' True++findNext' :: (Monad m) => Bool -> Node m -> String -> StateM m ([Node m], String, String, String)+findNext' wantsPartial root input = do+ (nodes, output, matched, remaining, _isDone) <- foldM matching ([], "", "", input, False) branches+ return (nodes, output, matched, remaining)+ where matching acc@(nodes, "", _, remaining, False) node@Node{..} = do+ enabled <- lift isEnabled+ if enabled then+ case nextWord remaining of+ (q@"?", _) ->+ return (node:nodes, "", q, remaining, False)+ _ -> do+ result <- lift $ runParser node remaining+ case result of+ Done output matched rest ->+ return ([node], output, matched, rest, True)+ Fail _ rest ->+ case nextWord rest of+ (q@"?", _) ->+ return ([node], "", q, rest, True)+ _ ->+ return acc+ Partial _ remaining' ->+ if wantsPartial+ then return (node:nodes, "", "", remaining', False)+ else return acc+ NoMatch ->+ return acc+ else+ return acc+ matching acc _ = return acc -- short circuit out of the fold if output is not empty+ branches = getBranches root++explorer :: (Monad m) => HL.CompletionFunc (StateM m)+explorer input@(tfel, _) = do+ currentLevel <- gets stack+ possibilities <- case currentLevel of+ (_, currentNode):_ ->+ sort <$> getPossibilities currentNode left+ _ ->+ return []+ let complete = HL.completeWord Nothing " " $ \str ->+ return $ map HL.simpleCompletion $ filter (str `isPrefixOf`) possibilities+ complete input+ where left = reverse tfel++getPossibilities :: (Monad m) => Node m -> String -> StateM m [String]+getPossibilities root input = do+ result <- findAll root input+ case result of+ ([node], _, _, "") -> do+ result' <- lift $ runParser root node input+ case result' of+ Done _ _ _ ->+ return [" "] -- perfect match - complete with space+ Partial possibilities _ ->+ return possibilities+ _ ->+ return []+ ([node], _, _, remaining) ->+ getPossibilities node remaining+ ([], _, _, _) ->+ return []+ (nodes, _, _, _) -> do+ concat <$> mapM getPossibility nodes+ where getPossibility node@Node{..} = do+ result' <- lift $ runParser node input+ case result' of+ Partial matches _ ->+ return matches+ _ ->+ return []
structured-cli.cabal view
@@ -1,5 +1,5 @@ name: structured-cli-version: 0.9.4.1+version: 2.0.0.0 synopsis: Application library for building interactive console CLIs description: This module provides the tools to build a complete "structured" CLI application, similar to those found in systems like Cisco IOS or console configuration utilities etc. It aims to be easy for implementors to use. homepage: https://gitlab.com/codemonkeylabs/structured-cli#readme@@ -22,7 +22,7 @@ , mtl , split , transformers- ghc-options: -Wall+ ghc-options: -Wall -fno-warn-orphans default-language: Haskell2010 executable some-cli