byline-1.1.0.0: src/Byline/Shell.hs
-- |
--
-- Copyright:
-- This file is part of the package byline. It is subject to the
-- license terms in the LICENSE file found in the top-level
-- directory of this distribution and at:
--
-- https://github.com/pjones/byline
--
-- No part of this package, including this file, may be copied,
-- modified, propagated, or distributed except according to the
-- terms contained in the LICENSE file.
--
-- License: BSD-2-Clause
--
-- Interactive shells.
module Byline.Shell
( -- * Shell-like Interfaces
Shell (..),
runShell,
shellHelp,
shellCompletion,
-- * Re-exports
module Byline.Completion,
)
where
import Byline
import Byline.Completion
import qualified Data.Attoparsec.Text as Atto
import Data.Char
import qualified Data.Text as Text
import qualified Options.Applicative as O
import qualified Options.Applicative.Common as O
import qualified Options.Applicative.Types as O
import Relude.Extra.Map
-- | A type that describes how to process user-entered shells.
--
-- @since 1.1.0.0
data Shell a = Shell
{ -- | Optparse-applicative parser preferences. If you don't have
-- any specific needs you can use 'O.defaultPrefs' to get the
-- default parser preferences.
shellPrefs :: O.ParserPrefs,
-- | The shell parser wrapped in a 'O.ParserInfo'. This is
-- generally created with the 'O.info' function.
shellInfo :: O.ParserInfo a,
-- | The prompt to display.
shellPrompt :: Stylized Text
}
-- | Run a single iteration of the shell.
--
-- @since 1.1.0.0
runShell ::
MonadByline m =>
(a -> m ()) ->
Shell a ->
m ()
runShell dispatch Shell {..} = do
input <- askLn shellPrompt Nothing
words <- shellSplit input
unless (null words) (go (map toString words))
where
go words = do
case O.execParserPure shellPrefs shellInfo words of
O.Success a ->
dispatch a
O.Failure help -> do
let str = fst (O.renderFailure help "")
sayLn (text $ toText str)
O.CompletionInvoked _ ->
pure ()
-- | Print a list of shell commands.
--
-- @since 1.1.0.0
shellHelp ::
MonadByline m =>
Shell a ->
m ()
shellHelp Shell {..} = do
let h = O.parserFailure shellPrefs shellInfo (O.ShowHelpText Nothing) mempty
s = fst (O.renderFailure h "")
sayLn (text $ toText s)
-- | A completion function for shell commands.
--
-- Adds completion for subcommand names and their flags.
--
-- @since 1.1.0.0
shellCompletion :: Applicative m => Shell a -> CompletionFunc m
shellCompletion shell input@(left, _) = do
if Text.null left || Text.all (isSpace >>> not) left
then completionFromList CompHead (keys commands) input
else completionFromList CompTail flags input
where
-- Get a list of flags for the current subcommand.
flags :: [Text]
flags = fromMaybe [] $ do
cmd <- Text.words left & viaNonEmpty head
names <- lookup cmd commands
pure $
flip map names $ \case
O.OptShort c -> toText ['-', c]
O.OptLong s -> "--" <> toText s
-- A map of command names and their flags.
commands :: HashMap Text [O.OptName]
commands =
fromList $
concat $
O.mapParser
(const nameAndFlags)
(O.infoParser $ shellInfo shell)
where
nameAndFlags opt =
case O.optMain opt of
O.CmdReader _ cmds p -> (`map` cmds) $ \cmd ->
( toText cmd,
maybe
[]
( O.infoParser
>>> O.mapParser (const optnames)
>>> concat
)
(p cmd)
)
_ -> mempty
optnames opt =
case O.optMain opt of
O.OptReader ns _ _ -> ns
O.FlagReader ns _ -> ns
_ -> mempty
-- | Internal function to split user input into words similar to what
-- a POSIX shell does.
shellSplit :: MonadByline m => Text -> m [Text]
shellSplit t =
let input = Text.strip t
in if Text.null input
then pure []
else case Atto.parseOnly go input of
Left e -> do
sayLn (("invalid input" <> fg red) <> ": " <> text (toText e))
pure []
Right ws ->
pure ws
where
go :: Atto.Parser [Text]
go = Atto.many1 (bare <|> quoted) <* expectEndOfInput
expectEndOfInput :: Atto.Parser ()
expectEndOfInput = (Atto.endOfInput <|>) $ do
leftover <- Atto.many1 Atto.anyChar
fail ("unexpected input: " <> leftover)
-- A bare word (not wrapped in quotes).
bare :: Atto.Parser Text
bare = (Atto.<?> "unquoted word") $ do
word <- Atto.many1 bareChar
void (Atto.many1 Atto.space) <|> Atto.endOfInput
pure (toText word)
-- A run of characters that may have quoted characters.
--
-- Just like with the POSIX shell, the quotes don't have to be on
-- the outsides of the final string.
quoted :: Atto.Parser Text
quoted = do
prefix <- many bareChar
quote <- Atto.satisfy (\c -> c == '\'' || c == '"') Atto.<?> "quote"
(_, ScanState {..}) <-
Atto.runScanner (ScanState [] False) (quoteScanner quote)
when scanEscape (fail "expecting a character after a backslash")
_ <- Atto.char quote Atto.<?> "closing quotation character"
let str = toText prefix <> toText (reverse scanResult)
end <-
(Atto.many1 Atto.space $> True)
<|> (Atto.endOfInput $> True)
<|> pure False
if end then pure str else (str <>) <$> quoted
-- Parse a single character that might be escaped.
bareChar :: Atto.Parser Char
bareChar = do
char <-
Atto.satisfy
( \c ->
not (isSpace c)
&& c /= '\''
&& c /= '"'
&& isPrint c
)
if char == '\\'
then Atto.anyChar Atto.<?> "escaped character"
else pure char
-- | State needed to scan input looking for a closing quote.
data ScanState = ScanState
{ scanResult :: [Char],
scanEscape :: Bool
}
-- | A scanning function that looks for a terminating quote.
quoteScanner ::
-- | The quote character we are searching for.
Char ->
-- | The output of the last invocation.
ScanState ->
-- | The current input character.
Char ->
-- | 'Just' to continue, 'Nothing' to stop.
Maybe ScanState
quoteScanner quote ScanState {..} input
| scanEscape = Just (ScanState (input : scanResult) False)
| input == '\\' = Just (ScanState scanResult True)
| input == quote = Nothing
| otherwise = Just (ScanState (input : scanResult) False)