From 732c3e502c3e580e98e660aa44d3fa8ed2edc2cf Mon Sep 17 00:00:00 2001 From: Adrian Sieber Date: Sun, 4 Feb 2024 20:34:08 +0000 Subject: [PATCH] Add tokenization step to parsing pipeline, add support for options --- cli-contract.ncl | 107 +++++++++- makefile | 2 +- spago.lock | 13 ++ spago.yaml | 1 + src/CliSpec.purs | 177 ++++++++-------- src/CliSpec/Parser.purs | 236 +++++++++++++++++++++ src/CliSpec/Tokenizer.purs | 107 ++++++++++ src/CliSpec/Types.purs | 149 +++++++------ src/CliSpec/readme.md | 2 + test/CliSpec.purs | 421 ++++++++++++++++++++++++++++++++++--- 10 files changed, 1043 insertions(+), 172 deletions(-) create mode 100644 src/CliSpec/Parser.purs create mode 100644 src/CliSpec/Tokenizer.purs create mode 100644 src/CliSpec/readme.md diff --git a/cli-contract.ncl b/cli-contract.ncl index 6657970..b7f9ac9 100644 --- a/cli-contract.ncl +++ b/cli-contract.ncl @@ -5,6 +5,106 @@ let normString : String -> String = in let TrimmedString : _ -> String -> String = fun _label => normString in +let NotRequiredIfList = fun label args => + args + |> std.array.map + ( + fun arg => + if arg."type" == 'List-Text then + if std.record.has_field "optional" arg && arg."optional" == false then + std.contract.blame_with_message + ( + "Incorrect values for argument " + ++ arg.name + ++ ": Arguments of type " + ++ std.string.from_enum arg.type + ++ " must always be optional" + ) + label + else + arg + else + arg + ) +in +let EitherNameOrShortName = fun label options => + options + |> std.array.map + ( + fun option => + let nameIsSet = + std.record.has_field "name" option + && option.name != "" + in + let shortNameIsSet = + std.record.has_field "shortName" option + && option.shortName != "" + in + if !nameIsSet && !shortNameIsSet then + std.contract.blame_with_message + "Option must have either a name or a shortName" + label + else + option + ) +in +let Argument = { + name | String, + description | String, + type + | [| + 'Text, + 'Int, + 'Float, + 'Bool, + 'List-Text, + 'List-Int, + 'List-Float, + 'List-Bool, + |], + "optional" + | optional + | Bool, + "default" + | optional + | String + | Number + | Bool + | Array String + | Array Number + | Array Bool, +} +in +let Option = { + name + | doc "The name of the option (without the leading --)" + | optional + | TrimmedString, + shortName + | doc "The short name of the option" + | optional + | std.string.Character, + description + | doc "The description of the option" + | TrimmedString, + argument + | doc "The argument of the option" + | optional + | Argument + | EitherNameOrShortName, + "optional" + | optional + | Bool, + "default" + | optional + | String + | Number + | Bool + | Array String + | Array Number + | Array Bool, +} +in let Command = { name | doc "The name of the command" @@ -12,9 +112,14 @@ let Command = { description | doc "The description of the command" | TrimmedString, + options + | doc "The list of options" + | optional + | Array Option, arguments | doc "The list of arguments" - | Array String, + | Array Argument + | NotRequiredIfList, funcName | doc "The function to be called" | optional diff --git a/makefile b/makefile index 5a9e37c..cc3763c 100644 --- a/makefile +++ b/makefile @@ -60,7 +60,7 @@ src/CliSpec/JsonEmbed.purs: cli-contract.ncl cli-spec.ncl echo \ '(import "cli-contract.ncl") & (import "cli-spec.ncl")' \ - | nickel export --format json \ + | nickel --color=always export --format json \ >> $@ echo '"""' >> $@ diff --git a/spago.lock b/spago.lock index 6ca9d5d..7f1ccb1 100644 --- a/spago.lock +++ b/spago.lock @@ -35,6 +35,7 @@ workspace: - rationals - result - strings + - stringutils - transformers - tuples - unfoldable @@ -117,6 +118,7 @@ workspace: - spec - st - strings + - stringutils - tailrec - test-unit - transformers @@ -1491,6 +1493,17 @@ packages: - tuples - unfoldable - unsafe-coerce + stringutils: + type: registry + version: 0.0.12 + integrity: sha256-t63QWBlp49U0nRqUcFryKflSJsNKGTQAHKjn24/+ooI= + dependencies: + - arrays + - integers + - maybe + - partial + - prelude + - strings tailrec: type: registry version: 6.1.0 diff --git a/spago.yaml b/spago.yaml index 2caa1cc..6233d36 100644 --- a/spago.yaml +++ b/spago.yaml @@ -33,6 +33,7 @@ package: - rationals - result - strings + - stringutils - transformers - tuples - unfoldable diff --git a/src/CliSpec.purs b/src/CliSpec.purs index 75c4cc5..f7ee2ea 100644 --- a/src/CliSpec.purs +++ b/src/CliSpec.purs @@ -1,24 +1,25 @@ module CliSpec where -import Prelude (Unit, bind, (#), ($), (/=), (<>), (-), (>)) +import CliSpec.Types + +import Prelude (Unit, bind, discard, pure, unit, (#), ($), (-), (<>), (>), (||)) import Ansi.Codes (Color(..)) import Ansi.Output (withGraphics, foreground) +import CliSpec.Parser (tokensToCliArguments) +import CliSpec.Tokenizer (tokenizeCliArguments) import Data.Argonaut.Decode (decodeJson) import Data.Argonaut.Decode.Error (printJsonDecodeError) import Data.Argonaut.Parser (jsonParser) -import Data.Array (drop, head, tail, replicate, foldl, foldMap, fold, find, length) +import Data.Array (drop, find, fold, foldMap, foldl, head, replicate) import Data.Bifunctor (lmap) -import Data.String as Str import Data.Eq ((==)) import Data.Maybe (Maybe(..), fromMaybe) import Data.Result (Result(..), fromEither) +import Data.String as Str import Effect (Effect) -import Effect.Class.Console (log) -import Effect.Exception (throw) -import Node.Process (argv) - -import CliSpec.Types +import Effect.Class.Console (log, error) +import Node.Process (argv, setExitCode) -- TODO: Automatically disable colors if not supported @@ -26,10 +27,16 @@ makeRed :: String -> String makeRed str = withGraphics (foreground Red) str +makeYellow :: String -> String +makeYellow str = + withGraphics (foreground Yellow) str + -errorAndExit :: String -> Effect Unit +errorAndExit :: String -> Effect (Result String Unit) errorAndExit message = do - throw (makeRed message) + error (makeRed message) + setExitCode 1 + pure $ Error message parseCliSpec :: String -> Result String CliSpec @@ -49,52 +56,62 @@ callCommand :: CliSpec -> String -> Array CliArgument - -> (String -> String -> Array CliArgument -> Effect Unit) - -> Effect Unit -callCommand cliSpec usageString args executor = do + -> (String -> String -> Array CliArgument -> Effect (Result String Unit)) + -> Effect (Result String Unit) +callCommand (CliSpec cliSpec) usageString args executor = do case args # head of - Just (CmdArg "help") -> log usageString - Just (FlagLong "help") -> log usageString - Just (FlagShort 'h') -> log usageString - - Just (CmdArg "version") -> log cliSpec.version - Just (FlagLong "version") -> log cliSpec.version - Just (FlagShort 'v') -> log cliSpec.version - - Just (CmdArg cmdName) -> do - let - commandMb = cliSpec.commands - # find (\cmd -> cmd.name == cmdName) - providedArgs = args # drop 1 + Nothing -> do + log "No arguments provided" + setExitCode 1 + pure (Error "No arguments provided") + + Just _mainCmd -> do + case args # drop 1 # head of + Just arg | arg == (CmdArg "help") + || arg == (FlagLong "help") + || arg == (FlagShort 'h') -> do + log usageString + pure $ Ok unit + + Just arg | arg == (CmdArg "version") + || arg == (FlagLong "version") + || arg == (FlagShort 'v') -> do + log (cliSpec.version # fromMaybe "0") + pure $ Ok unit + + Just (CmdArg cmdName) -> do + let + commandMb = cliSpec.commands + # fromMaybe [] + # find (\(CliSpec cmd) -> cmd.name == cmdName) + providedArgs = args # drop 2 + + case commandMb of + Nothing -> do + let errStr = + makeRed ("ERROR: Unknown command \"" <> cmdName <> "\"") + <> "\n\n" + <> usageString + log errStr + setExitCode 1 + pure (Error errStr) + + Just (CliSpec _command) -> do + executor cmdName usageString providedArgs + + Just arg -> do + let errMsg = + "ERROR: First argument must be a command and not \"" + <> cliArgToString arg + <> "\"\n\n" + log $ makeRed $ errMsg <> usageString + setExitCode 1 + pure $ Error errMsg - case commandMb of Nothing -> do - log $ - makeRed ("ERROR: Unknown command \"" <> cmdName <> "\"") - <> "\n\n" - <> usageString - - Just command -> do - if (length providedArgs :: Int) /= (length command.arguments) - then do - log $ - "Usage: " <> command.name - <> (command.arguments # foldMap (\arg -> " " <> arg)) - <> "\n\n" - <> command.description - <> "\n\n" - else do - executor cmdName usageString providedArgs - - Just _ -> do - log $ - makeRed $ - "ERROR: First argument must be a command" - <> "\n\n" - <> usageString - - Nothing -> do - log usageString + log usageString + setExitCode 1 + pure $ Error "No arguments provided" -- | Function to repeat a string n times @@ -105,28 +122,30 @@ repeatString str n = callCliApp :: CliSpec - -> (String -> String -> Array CliArgument -> Effect Unit) - -> Effect Unit -callCliApp cliSpec executor = do + -> (String -> String -> Array CliArgument -> Effect (Result String Unit)) + -> Effect (Result String Unit) +callCliApp cliSpec@(CliSpec cliSpecRaw) executor = do let lengthLongestCmd :: Int lengthLongestCmd = - cliSpec.commands - # foldl (\acc cmd -> + cliSpecRaw.commands + # fromMaybe [] + # foldl (\acc (CliSpec cmd) -> if acc > Str.length cmd.name then acc else Str.length cmd.name ) 0 usageString = - "USAGE: " <> cliSpec.name <> " [options]" + "USAGE: " <> cliSpecRaw.name <> " [options]" <> "\n\n" - <> cliSpec.description + <> cliSpecRaw.description <> "\n\n" <> "COMMANDS:" <> "\n\n" - <> (cliSpec.commands - # foldMap (\cmd -> + <> (cliSpecRaw.commands + # fromMaybe [] + # foldMap (\(CliSpec cmd) -> cmd.name <> (repeatString " " (lengthLongestCmd - Str.length cmd.name)) <> " " <> cmd.description <> "\n" @@ -135,27 +154,13 @@ callCliApp cliSpec executor = do arguments <- argv - -- -- TODO: Show in help - -- binName <- case arguments # head of - -- Nothing -> do - -- errorAndExit usageString - -- pure "" - -- Just name -> pure name - - let binArgs = arguments # tail # fromMaybe [] - - -- -- TODO: Show in help - -- -- E.g. transity, or index.js - -- appName <- case binArgs # head of - -- Nothing -> do - -- errorAndExit usageString - -- pure "" - -- Just name -> pure name - - let toolArgs = binArgs # tail # fromMaybe [] - - callCommand - cliSpec - usageString - (parseCliArguments 0 toolArgs) - executor + let + argsNoInterpreter = arguments # drop 1 -- Drop "node" + cliArgsMb = + tokensToCliArguments + cliSpec + (tokenizeCliArguments argsNoInterpreter) + + case cliArgsMb of + Error err -> errorAndExit err + Ok cliArgs -> callCommand cliSpec usageString cliArgs executor diff --git a/src/CliSpec/Parser.purs b/src/CliSpec/Parser.purs new file mode 100644 index 0000000..a5aaa47 --- /dev/null +++ b/src/CliSpec/Parser.purs @@ -0,0 +1,236 @@ +module CliSpec.Parser + ( findFlagLong + , findSubCmd + , tokensToCliArguments + ) + where + +import Data.Result + +import CliSpec.Tokenizer (CliArgToken(..)) +import CliSpec.Types (CliArgPrim(..), CliArgument(..), CliSpec(..), Option) +import Data.Array (drop, find, foldl, head, last, zip) +import Data.Maybe (Maybe(..), fromMaybe) +import Data.String.CodeUnits (singleton) +import Data.Traversable (sequence) +import Data.Tuple (Tuple(Tuple)) +import Prelude (show, (#), ($), (&&), (/=), (<#>), (<>), (==)) + + +findFlagShort :: Maybe (Array Option) -> Char -> Maybe Option +findFlagShort cliSpecOptionsMb flagChar = do + cliSpecOptionsMb + # fromMaybe [] + # find (\opt -> opt.shortName == Just (singleton flagChar)) + + +findFlagLong :: Maybe (Array Option) -> String -> Maybe Option +findFlagLong cliSpecOptionsMb flagName = do + cliSpecOptionsMb + # fromMaybe [] + # find (\opt -> opt.name == Just flagName) + +findOptionShort :: Maybe (Array Option) -> Char -> Maybe Option +findOptionShort cliSpecOptionsMb flagChar = do + cliSpecOptionsMb + # fromMaybe [] + # find (\opt -> opt.shortName == Just (singleton flagChar)) + + +findOptionLong :: Maybe (Array Option) -> String -> Maybe Option +findOptionLong cliSpecOptionsMb flagName = do + cliSpecOptionsMb + # fromMaybe [] + # find (\opt -> opt.name == Just flagName) + + +findSubCmd :: Maybe (Array CliSpec) -> String -> Maybe CliSpec +findSubCmd cliSpecCommands value = do + cliSpecCommands + # fromMaybe [] + # find (\(CliSpec cmd) -> cmd.name == value) + + +-- | Verify that the remaining tokens are allowed +-- | for the given command specification and return +-- | the corresponding `CliArgument`s. +verifyTokensAreAllowed + :: CliSpec + -> Array CliArgToken + -> Result String (Array CliArgument) +verifyTokensAreAllowed (CliSpec cliSpecRaw) tokens = do + let + argsAndTokens = zip + (cliSpecRaw.arguments # fromMaybe []) + tokens + + argsAndTokens + # (foldl + (\(Tuple cliArgs remainingTokens) (Tuple arg token) -> + if remainingTokens == [] + then + -- Finish looping, but don't change cliArgs anymore + Tuple cliArgs [] + else + case arg.type, token of + "Text", TextToken txt -> + Tuple + (cliArgs <> [Ok $ ValArg (StringArg txt)]) + (remainingTokens # drop 1) + + "List-Text", TextToken _ -> + Tuple + (cliArgs + <> [(remainingTokens + <#> (\tok -> case tok of + TextToken t -> Ok $ StringArg t + _ -> Error $ "Unsupported token: " <> show tok + ) + # sequence + <#> ValArgList + )] + ) + [] -- No more remaining tokens + + _, _ -> + Tuple + (cliArgs <> [Error $ "Invalid argument:" <> show arg]) + (remainingTokens # drop 1) + ) + (Tuple [] tokens) + ) + # (\(Tuple cliArgs _) -> cliArgs) + # sequence + + + +-- | Determine the correct value of the `CliArgToken`s +-- | by matching them against the spec. +-- | Especially for the differentiation between `Option`s and `Flag`s. +tokensToCliArguments + :: CliSpec + -> Array CliArgToken + -> Result String (Array CliArgument) +tokensToCliArguments cliSpec@(CliSpec cliSpecRaw) tokens = do + let + mainCmdRes :: Result String CliSpec + mainCmdRes = case tokens # head of + Just (TextToken cmdName) -> + if + cliSpecRaw.name /= cmdName && + cliSpecRaw.enforceValidName == Just true + then Error $ + "ERROR: \"" + <> cliSpecRaw.name + <> "\" is executed with the differently named executable \"" + <> cmdName + <> "\"" + else Ok cliSpec + _ -> Error $ + "Something went wrong. " + <> "The first token should be a command or a value." + + + -- If the first token after the main command is a subcommand + -- parse recursively the rest of the tokens. + case tokens # drop 1 # head of + Just (TextToken name) -> + case findSubCmd cliSpecRaw.commands name of + -- Is subcommand + Just cmd -> + case tokensToCliArguments cmd (tokens # drop 1) of + Ok args -> case mainCmdRes of + Error err -> Error err + Ok _ -> Ok $ [CmdArg cliSpecRaw.name] <> args + err -> err + -- Is value + Nothing -> + case mainCmdRes of + Error err -> Error err + Ok mainCmd -> + verifyTokensAreAllowed mainCmd (tokens # drop 1) + <#> (\cliArgs -> [CmdArg cliSpecRaw.name] <> cliArgs ) + + _ -> do + let + toError :: CliArgToken -> Result String CliArgument + toError token = Error $ case token of + FlagLongToken flagName -> + "Unknown flag: " <> flagName + FlagShortToken flagChar -> + "Unknown flag: " <> singleton flagChar + OptionShortToken flagChar _ -> + "Unknown option: " <> singleton flagChar + OptionLongToken flagName _ -> + "Unknown option: " <> flagName + _ -> + "Unknown token" + + remainingTokensWithSucc :: Array (Tuple CliArgToken (Maybe CliArgToken)) + remainingTokensWithSucc = + zip + (tokens # drop 1) + ((tokens # drop 2 <#> Just) <> [Nothing]) + + options :: Array (Result String CliArgument) + options = + remainingTokensWithSucc + # foldl + (\acc (Tuple token nextTokenMb) -> case token of + FlagLongToken flagName -> + case findFlagLong cliSpecRaw.options flagName of + Just flagOrOpt -> case flagOrOpt.argument of + Just _arg -> + case nextTokenMb of + Just (TextToken val) -> + -- TODO: Check if val is allowed at this position + acc <> [Ok $ OptionLong flagName (StringArg val)] + _ -> acc <> [Ok $ FlagLong flagName] + Nothing -> acc <> [Ok $ FlagLong flagName] + Nothing -> + -- Maybe it's a long option + case findOptionLong cliSpecRaw.options flagName of + Just _ -> + acc <> [Ok $ OptionLong flagName (StringArg "TODO")] + Nothing -> + acc <> [toError token] + + FlagShortToken flagChar -> + case findFlagShort cliSpecRaw.options flagChar of + Just _ -> + acc <> [Ok $ OptionShort flagChar (StringArg "TODO")] + Nothing -> + -- Maybe it's a short option + case findOptionShort cliSpecRaw.options flagChar of + Just _ -> + acc <> [Ok $ OptionShort flagChar (StringArg "TODO")] + Nothing -> + acc <> [toError token] + + OptionShortToken flagChar _arg -> + case findFlagShort cliSpecRaw.options flagChar of + Just _option -> + acc <> [Ok $ OptionShort flagChar (StringArg "TODO")] + Nothing -> + acc <> [toError token] + + OptionLongToken flagName _arg -> + case findFlagLong cliSpecRaw.options flagName of + Just _option -> + acc <> [Ok $ OptionLong flagName (StringArg "TODO")] + Nothing -> + acc <> [toError token] + + TextToken txt -> + case acc # last of + -- This token was already consumed so don't add it + Just (Ok (OptionLong _ _)) -> acc + _ -> acc <> [Ok $ ValArg (StringArg txt)] + + _ -> [] + ) + [] + + sequence + $ [mainCmdRes <#> (\(CliSpec cmdSpec) -> CmdArg cmdSpec.name)] + <> options diff --git a/src/CliSpec/Tokenizer.purs b/src/CliSpec/Tokenizer.purs new file mode 100644 index 0000000..ecdc292 --- /dev/null +++ b/src/CliSpec/Tokenizer.purs @@ -0,0 +1,107 @@ +module CliSpec.Tokenizer ( + CliArgToken(..), + tokenizeCliArgument, + tokenizeCliArguments +) + where + +import Prelude (class Eq, class Show, map, (#), (&&), (/=), (<#>), (==)) + +import Data.Array (concat, drop, groupBy, null, take, (:)) +import Data.Foldable (elem) +import Data.Generic.Rep (class Generic) +import Data.Show.Generic (genericShow) +import Data.String.CodeUnits (toCharArray, fromCharArray) + +import CliSpec.Types (CliArgPrim(..)) + + +-- | Intermediate representation of CLI arguments. +-- | This might not yet differentiate correctly between `Option`s and `Flag`s +-- | and between `CmdArg`s and `ValArg`s. +data CliArgToken = + TextToken String -- ^ Could be a command or a value argument + | FlagShortToken Char + | FlagLongToken String + | OptionShortToken Char CliArgPrim -- ^ `-n=3` + | OptionShortListToken Char (Array CliArgPrim) -- ^ `-i=3,4,5` + | OptionLongToken String CliArgPrim -- ^ `--num=3` + | OptionLongListToken String (Array CliArgPrim) -- ^ `--items=3,4,5` + | ValArgToken CliArgPrim + | ValArgListToken (Array CliArgPrim) + | SeparatorToken + +derive instance genericCliArgToken :: Generic CliArgToken _ +derive instance eqCliArgToken :: Eq CliArgToken +instance showCliArgToken :: Show CliArgToken where + show = genericShow + + +optionLongTokenFromChars :: Array Char -> Array CliArgToken +optionLongTokenFromChars charsRest = + let + groupedChars = charsRest + # groupBy (\a b -> a /= '=' && b /= '=') + keyPart = ['x'] -- TODO + valuePart = ['y'] -- TODO + in + [OptionLongToken + (keyPart # fromCharArray) + (StringArg (valuePart # fromCharArray)) + ] + + +optionShortokenFromChars :: Array Char -> Array CliArgToken +optionShortokenFromChars charsRest = + let + groupedChars = charsRest + # groupBy (\a b -> a /= '=' && b /= '=') + keyPart = ['a'] -- TODO + valuePart = ['b'] -- TODO + in + case keyPart of + [char] -> + [OptionShortToken char (StringArg (valuePart # fromCharArray))] + + _ -> [] + + +-- | Parse CLI arguments into a list of `CliArgToken`s +-- | One argument can lead to multiple `CliArgToken`s +-- | e.g. `-ab` -> `[FlagShortToken 'a', FlagShortToken 'b']` +tokenizeCliArgument :: String -> Array CliArgToken +tokenizeCliArgument arg = do + let + chars = arg # toCharArray :: Array Char + charsRest = chars # drop 2 :: Array Char + + case chars # take 2 of + ['-', '-'] -> + if charsRest == [] + then [SeparatorToken] + else + if '=' `elem` charsRest + then optionLongTokenFromChars charsRest + else + [FlagLongToken (charsRest # fromCharArray)] + + ['-', singleFlag ] -> + if '=' `elem` charsRest + then optionShortokenFromChars charsRest + else + FlagShortToken singleFlag + : if null charsRest + then [] + else charsRest # map FlagShortToken + + _ -> [TextToken arg] + + + +tokenizeCliArguments :: Array String -> Array CliArgToken +tokenizeCliArguments arguments = do + arguments + <#> tokenizeCliArgument + # concat + + diff --git a/src/CliSpec/Types.purs b/src/CliSpec/Types.purs index 6f486d2..a0b903f 100644 --- a/src/CliSpec/Types.purs +++ b/src/CliSpec/Types.purs @@ -1,12 +1,14 @@ module CliSpec.Types where -import Prelude (class Eq, class Show, map, (#), ($), (+), (==)) - -import Data.Array ((:), drop, take, null, concat, mapWithIndex) +import Data.Argonaut.Decode (decodeJson) +import Data.Argonaut.Decode.Class (class DecodeJson) +import Data.Argonaut.Decode.Generic (genericDecodeJson) import Data.Generic.Rep (class Generic) -import Data.Maybe (Maybe) +import Data.Maybe (Maybe(..)) +import Data.Newtype (class Newtype) import Data.Show.Generic (genericShow) -import Data.String.CodeUnits (toCharArray, fromCharArray) +import Data.String (joinWith) +import Prelude (class Eq, class Show, bind, map, pure, show, (#), (<>)) data CliArgPrim @@ -20,82 +22,111 @@ derive instance genericCliArgPrim :: Generic CliArgPrim _ derive instance eqCliArgPrim :: Eq CliArgPrim instance showCliArgPrim :: Show CliArgPrim where show = genericShow +instance decodeJsonCliArgPrim :: DecodeJson CliArgPrim where + decodeJson = genericDecodeJson + +cliArgPrimToString :: CliArgPrim -> String +cliArgPrimToString arg = case arg of + StringArg str -> str + IntArg int -> show int + NumberArg num -> show num + BooleanArg bool -> show bool + NullArg -> "null" -- | A full CLI invocation is an array of `CliArgument`s data CliArgument = CmdArg String | FlagShort Char | FlagLong String + | OptionShort Char CliArgPrim + | OptionShortList Char (Array CliArgPrim) + | OptionLong String CliArgPrim + | OptionLongList String (Array CliArgPrim) | ValArg CliArgPrim | ValArgList (Array CliArgPrim) + -- TODO: Add support for the following list types + -- | ValArgList (Array String) + -- | ValArgListInt (Array Int) + -- | ValArgListNumber (Array Number) + -- | ValArgListBoolean (Array Boolean) + derive instance genericCliArgument :: Generic CliArgument _ derive instance eqCliArgument :: Eq CliArgument instance showCliArgument :: Show CliArgument where show = genericShow - --- | One argument can lead to multiple `CliArgument`s --- | e.g. `-ab` -> `[FlagShort 'a', FlagShort 'b']` -parseCliArgument :: Int -> String -> Array CliArgument -parseCliArgument index arg = do - let - chars = arg # toCharArray :: Array Char - charsRest = chars # drop 2 :: Array Char - - case chars # take 2 of - ['-', '-'] -> [FlagLong $ charsRest # fromCharArray] - - ['-', singleFlag ] -> FlagShort singleFlag - : if null charsRest - then [] - else charsRest # map FlagShort - - _ -> if index == 0 - then [CmdArg arg] - else [ValArg $ StringArg arg] - - -parseCliArguments :: Int -> Array String -> Array CliArgument -parseCliArguments subcmdLevels arguments = do - arguments - # mapWithIndex (\index -> - parseCliArgument - (if subcmdLevels == 0 then index else index + 1) - ) - # concat - - -type Command = { +cliArgToString :: CliArgument -> String +cliArgToString arg = case arg of + CmdArg cmd -> cmd + FlagShort flag -> "-" <> show flag + FlagLong flag -> "--" <> flag + OptionLong name val -> "--" <> name <> "=" <> cliArgPrimToString val + OptionLongList name vals -> + "--" <> name <> "=" <> (vals # map cliArgPrimToString # joinWith ",") + OptionShort name val -> "-" <> show name <> "=" <> cliArgPrimToString val + OptionShortList name vals -> + "-" <> show name <> "=" <> (vals # map cliArgPrimToString # joinWith ",") + ValArg val -> cliArgPrimToString val + ValArgList vals -> vals # map cliArgPrimToString # joinWith "," + + +type Argument = { name :: String, description :: String, - arguments :: Array String, - funcName :: Maybe String + type :: String, + optional :: Maybe Boolean, + default :: Maybe CliArgPrim +} + +type Option = { + name :: Maybe String, + shortName :: Maybe String, -- TODO: Change to Char + description :: String, + argument :: Maybe Argument, + optional :: Maybe Boolean, + default :: Maybe CliArgPrim } -type CliSpec = +type CliSpecRaw = { name :: String , description :: String - , version :: String - , funcName :: String - , commands :: Array Command - -- , options :: Array String + , version :: Maybe String + , enforceValidName :: Maybe Boolean + , funcName :: Maybe String + , options :: Maybe (Array Option) + , arguments :: Maybe (Array Argument) + , commands :: Maybe (Array CliSpec) } --- derive instance genericCliSpec :: Generic CliSpec _ --- -- derive instance newtypeCliSpec :: Newtype CliSpec _ --- derive newtype instance eqCliSpec :: Eq CliSpec - --- instance showCliSpec :: Show CliSpec --- -- where --- -- show = genericShow +-- | Must be a newtype to avoid circular references +newtype CliSpec = CliSpec CliSpecRaw + +derive instance genericCliSpec :: Generic CliSpec _ +derive instance eqCliSpec :: Eq CliSpec +derive instance newtypeCliSpec :: Newtype CliSpec _ +instance showCliSpec :: Show CliSpec where + show = \(CliSpec specRaw) -> show specRaw +instance decodeJsonCliSpec :: DecodeJson CliSpec where + decodeJson = \json -> do + raw <- decodeJson json + pure (CliSpec raw) + + +emptyCliSpecRaw :: CliSpecRaw +emptyCliSpecRaw = + { name: "" + , description: "" + , funcName: Nothing + , enforceValidName: Nothing + , version: Nothing + , options: Nothing + , arguments: Nothing + , commands: Nothing + } --- instance encodeCliSpec :: EncodeJson CliSpec --- -- where --- -- encodeJson a = genericEncodeJson a --- instance decodeCliSpec :: DecodeJson CliSpec --- -- where --- -- decodeJson json = --- -- toEither $ resultWithJsonDecodeError $ decodeJsonCliSpec json +emptyCliSpec :: CliSpec +emptyCliSpec = + CliSpec emptyCliSpecRaw diff --git a/src/CliSpec/readme.md b/src/CliSpec/readme.md new file mode 100644 index 0000000..d85c4d3 --- /dev/null +++ b/src/CliSpec/readme.md @@ -0,0 +1,2 @@ +# CliSpec + diff --git a/test/CliSpec.purs b/test/CliSpec.purs index 37dfded..82f7bae 100644 --- a/test/CliSpec.purs +++ b/test/CliSpec.purs @@ -1,54 +1,425 @@ module Test.CliSpec where -import Prelude (Unit, (#)) - +import CliSpec (parseCliSpec, callCommand) +import CliSpec.Parser (tokensToCliArguments) +import CliSpec.Tokenizer (CliArgToken(..), tokenizeCliArguments) +import CliSpec.Types + ( CliArgPrim(..), CliArgument(..), CliSpec(..) + , emptyCliSpec, emptyCliSpecRaw + ) import Control.Bind (discard) +import Data.Maybe (Maybe(..)) +import Data.Newtype (over) +import Data.Result (Result(..)) import Data.String (Pattern(..), split) +import Effect.Class (liftEffect) +import Prelude (Unit, pure, unit, (#), ($)) import Test.Spec (Spec, describe, it) -import Test.Spec.Assertions (shouldEqual) - -import CliSpec.Types (CliArgPrim(..), CliArgument(..), parseCliArguments) +import Test.Spec.Assertions (shouldEqual, fail, shouldReturn) -parseCliStr :: String -> Array CliArgument -parseCliStr str = +tokenizeCliStr :: String -> Array CliArgToken +tokenizeCliStr str = str # split (Pattern " ") - # parseCliArguments 0 + # tokenizeCliArguments tests :: Spec Unit tests = do describe "CliSpec" do - describe "Parser" do + describe "Tokenizer" do it "parses a CLI invocation" do - (parseCliStr "xx") - `shouldEqual` [CmdArg "xx"] + (tokenizeCliStr "git") + `shouldEqual` [TextToken "git"] it "parses a standalone flag (for subcommands)" do - (parseCliStr "--help") - `shouldEqual` [FlagLong "help"] + (tokenizeCliStr "--help") + `shouldEqual` [FlagLongToken "help"] it "parses a CLI with an argument" do - (parseCliStr "xx dir") - `shouldEqual` [CmdArg "xx", ValArg (StringArg "dir")] + (tokenizeCliStr "ls dir") + `shouldEqual` [TextToken "ls", TextToken "dir"] it "parses a CLI invocation with a long flag" do - (parseCliStr "xx --version") - `shouldEqual` [CmdArg "xx", FlagLong "version"] + (tokenizeCliStr "git --version") + `shouldEqual` [TextToken "git", FlagLongToken "version"] it "parses a CLI invocation with a short flag" do - (parseCliStr "xx -a") - `shouldEqual` [CmdArg "xx", FlagShort 'a'] + (tokenizeCliStr "git -a") + `shouldEqual` [TextToken "git", FlagShortToken 'a'] it "parses a CLI invocation with several short flags" do - (parseCliStr "xx -ab") - `shouldEqual` [CmdArg "xx", FlagShort 'a', FlagShort 'b'] + (tokenizeCliStr "git -ab") + `shouldEqual` + [TextToken "git", FlagShortToken 'a', FlagShortToken 'b'] it "parses a CLI invocation with a long flag and an argument" do - (parseCliStr "xx --verbose dir") + (tokenizeCliStr "git --verbose dir") `shouldEqual` - [ CmdArg "xx" - , FlagLong "verbose" - , ValArg (StringArg "dir") + [ TextToken "git" + , FlagLongToken "verbose" + , TextToken "dir" ] + + describe "Spec Parser" do + let + cliSpec :: CliSpec + cliSpec = CliSpec (emptyCliSpecRaw + { name = "git" + , description = "The git command" + , funcName = Just "runApp" + , version = Just "1.0.0" + , commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "commit" + , description = "The commit sub-command" + , funcName = Just "runCommit" + , arguments = Just + [ { name: "pathspec" + , description: "File to commit" + , type: "Text" + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + + it "parses a full CLI spec" do + let + cliSpecJson = """ + { "name": "git", + "description": "The git command", + "funcName": "runApp", + "version": "1.0.0", + "commands": [ + { "name": "commit", + "description": "The commit sub-command", + "funcName": "runCommit", + "arguments": [ + { "name": "pathspec", + "description": "File to commit", + "type": "Text" + } + ] + } + ] + } + """ + + case parseCliSpec cliSpecJson of + Error err -> fail err + Ok parsedCliSpec -> parsedCliSpec `shouldEqual` cliSpec + + it "correctly detects a subcommand with one argument" do + let + cliSpecWithFlag :: CliSpec + cliSpecWithFlag = cliSpec # over CliSpec (\spec -> spec + { commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "pull" + , description = "The pull sub-command" + , funcName = Just "runPull" + , arguments = Just + [ { name: "repository" + , description: "Name of the repository" + , type: "Text" + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + tokens = tokenizeCliStr "git pull origin" + + tokens `shouldEqual` + [ TextToken "git" + , TextToken "pull" + , TextToken "origin" + ] + (tokensToCliArguments cliSpecWithFlag tokens) + `shouldEqual` + Ok + [ CmdArg "git" + , CmdArg "pull" + , ValArg (StringArg "origin") + ] + + it "correctly detects a subcommand with one long flag and one argument" do + let + cliSpecWithFlag :: CliSpec + cliSpecWithFlag = cliSpec # over CliSpec (\spec -> spec + { commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "pull" + , description = "The pull sub-command" + , funcName = Just "runPull" + , options = Just + [ { name: Just "progress" + , shortName: Nothing + , description: "Show progress" + , argument: Nothing + , optional : Nothing + , default : Nothing + } + ] + , arguments = Just + [ { name: "repository" + , description: "Name of the repository" + , type: "Text" + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + tokens = tokenizeCliStr "git pull --progress origin" + + tokens `shouldEqual` + [ TextToken "git" + , TextToken "pull" + , FlagLongToken "progress" + , TextToken "origin" + ] + (tokensToCliArguments cliSpecWithFlag tokens) + `shouldEqual` + Ok + [ CmdArg "git" + , CmdArg "pull" + , FlagLong "progress" + , ValArg (StringArg "origin") + ] + + it "redefines a long flag with a value to a long option" do + let + cliSpecWithFlag :: CliSpec + cliSpecWithFlag = cliSpec # over CliSpec (\spec -> spec + { commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "pull" + , description = "The pull sub-command" + , funcName = Just "runPull" + , options = Just + [ { name: Just "strategy" + , shortName: Nothing + , description: "Set the preferred merge strategy" + , argument: Just + { name: "strategy" + , description: "Strategy to use" + , type: "Text" + , optional : Just true + , default : Nothing + } + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + tokens = tokenizeCliStr "git pull --strategy recursive" + + tokens `shouldEqual` + [ TextToken "git" + , TextToken "pull" + , FlagLongToken "strategy" + , TextToken "recursive" + ] + (tokensToCliArguments cliSpecWithFlag tokens) + `shouldEqual` + Ok + [ CmdArg "git" + , CmdArg "pull" + , OptionLong "strategy" (StringArg "recursive") + ] + + it "verifies number of args for variable number of allowed args" do + let + cliSpecWithFlag :: CliSpec + cliSpecWithFlag = emptyCliSpec # over CliSpec (\spec -> spec + { name = "ls" + , arguments = Just + [ { name: "file" + , description: "File to list" + , type: "Text" + , optional : Just false + , default : Nothing + } + , { name: "file" + , description: "Additional files to list" + , type: "List-Text" + , optional : Just true + , default : Nothing + } + ] + }) + + let tokensOne = tokenizeCliStr "ls file1" + (tokensToCliArguments cliSpecWithFlag tokensOne) + `shouldEqual` + Ok + [ CmdArg "ls" + , ValArg (StringArg "file1") + ] + + let tokensTwo = tokenizeCliStr "ls file1 file2" + (tokensToCliArguments cliSpecWithFlag tokensTwo) + `shouldEqual` + Ok + [ CmdArg "ls" + , ValArg (StringArg "file1") + , ValArgList [StringArg "file2"] + ] + + let tokensThree = tokenizeCliStr "ls file1 file2 file3" + (tokensToCliArguments cliSpecWithFlag tokensThree) + `shouldEqual` + Ok + [ CmdArg "ls" + , ValArg (StringArg "file1") + , ValArgList [StringArg "file2", StringArg "file3"] + ] + + describe "Execution" do + it "executes a sub-command with one argument" do + let + cliSpec = CliSpec (emptyCliSpecRaw + { name = "git" + , description = "The git command" + , funcName = Just "runApp" + , version = Just "1.0.0" + , commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "pull" + , description = "The pull sub-command" + , funcName = Just "runPull" + , arguments = Just + [ { name: "dir" + , description: "Path to a directory" + , type: "Text" + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + toolArgs = ["git", "pull", "dir"] + usageString = "Irrelevant" + executor cmdName usageStr providedArgs = do + cmdName `shouldEqual` "pull" + usageStr `shouldEqual` usageString + providedArgs `shouldEqual` [(ValArg (StringArg "dir"))] + pure $ Ok unit + + case tokensToCliArguments cliSpec $ tokenizeCliArguments toolArgs of + Error err -> fail err + Ok cliArgs -> + liftEffect (callCommand + cliSpec + usageString + cliArgs + executor + ) `shouldReturn` (Ok unit) + + it "executes a sub-command with one flag" do + let + cliSpec = CliSpec (emptyCliSpecRaw + { name = "git" + , description = "The git command" + , funcName = Just "runApp" + , version = Just "1.0.0" + , commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "pull" + , description = "The pull sub-command" + , funcName = Just "runPull" + , options = Just + [ { name: Just "stats" + , shortName: Nothing + , description: "Statistics for pull" + , argument: Nothing + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + args = ["git", "pull", "--stats"] + usageString = "Irrelevant" + executor cmdName usageStr providedArgs = do + cmdName `shouldEqual` "pull" + usageStr `shouldEqual` usageString + providedArgs `shouldEqual` [(FlagLong "stats")] + pure $ Ok unit + + case (tokensToCliArguments cliSpec $ tokenizeCliArguments args) of + Error err -> fail err + Ok cliArgs -> + liftEffect (callCommand + cliSpec + usageString + cliArgs + executor + ) `shouldReturn` (Ok unit) + + it "executes a sub-command with one option" do + let + cliSpec = CliSpec (emptyCliSpecRaw + { name = "git" + , description = "The git command" + , funcName = Just "runApp" + , version = Just "1.0.0" + , commands = Just + [ CliSpec (emptyCliSpecRaw + { name = "pull" + , description = "The pull sub-command" + , funcName = Just "runPull" + , options = Just + [ { name: Just "output" + , shortName: Nothing + , description: "Output directory" + , argument: Just + { name: "dir" + , description: "Path to a directory" + , type: "Text" + , optional : Nothing + , default : Nothing + } + , optional : Nothing + , default : Nothing + } + ] + , arguments = Just + [ { name: "dir" + , description: "Path to a directory" + , type: "Text" + , optional : Nothing + , default : Nothing + } + ] + }) + ] + }) + toolArgs = ["git", "pull", "--output", "dir"] + usageString = "Irrelevant" + executor cmdName usageStr providedArgs = do + cmdName `shouldEqual` "pull" + usageStr `shouldEqual` usageString + providedArgs `shouldEqual` [(OptionLong "output" (StringArg "dir"))] + pure $ Ok unit + + case (tokensToCliArguments cliSpec $ tokenizeCliArguments toolArgs) of + Error err -> fail err + Ok cliArgs -> + (liftEffect $ callCommand + cliSpec + usageString + cliArgs + executor + ) `shouldReturn` (Ok unit)