imp: cli: allow command options to be written before the command also

It's clearer to write command-specific flags after the command name
argument, but that's no longer required.
(Writing non-builtin, addon-specific flags after -- is still required).

Also, give up on "obey help/doc/version flags even if there's a bad
command/flag", it's too hard to do well.
This commit is contained in:
Simon Michael 2024-06-16 22:34:08 +01:00
parent 570a5472e2
commit 46cda5e7de
5 changed files with 94 additions and 74 deletions

View File

@ -123,7 +123,7 @@ getHledgerUIOpts :: IO UIOpts
--getHledgerUIOpts = processArgs uimode >>= return >>= rawOptsToUIOpts --getHledgerUIOpts = processArgs uimode >>= return >>= rawOptsToUIOpts
getHledgerUIOpts = do getHledgerUIOpts = do
args <- getArgs >>= expandArgsAt args <- getArgs >>= expandArgsAt
let args' = replaceNumericFlags $ ensureDebugHasArg args let args' = replaceNumericFlags args
let cmdargopts = either usageError id $ process uimode args' let cmdargopts = either usageError id $ process uimode args'
rawOptsToUIOpts cmdargopts rawOptsToUIOpts cmdargopts

View File

@ -186,7 +186,7 @@ checkWebOpts = id
getHledgerWebOpts :: IO WebOpts getHledgerWebOpts :: IO WebOpts
getHledgerWebOpts = do getHledgerWebOpts = do
args <- fmap (replaceNumericFlags . ensureDebugHasArg) . expandArgsAt =<< getArgs args <- fmap replaceNumericFlags . expandArgsAt =<< getArgs
rawOptsToWebOpts . either usageError id $ process webmode args rawOptsToWebOpts . either usageError id $ process webmode args
data Permission data Permission

View File

@ -69,6 +69,7 @@ etc.
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Unused LANGUAGE pragma" #-} {-# HLINT ignore "Unused LANGUAGE pragma" #-}
{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE RecordWildCards #-}
module Hledger.Cli ( module Hledger.Cli (
main, main,
@ -111,7 +112,7 @@ import Data.Bifunctor (second)
import Data.Function ((&)) import Data.Function ((&))
import Data.Functor ((<&>)) import Data.Functor ((<&>))
import Control.Monad.Extra (unless) import Control.Monad.Extra (unless)
import Data.Char (isDigit) import Data.List.Extra (nubSort)
-- | The overall cmdargs mode describing hledger's command-line options and subcommands. -- | The overall cmdargs mode describing hledger's command-line options and subcommands.
@ -160,11 +161,12 @@ mainmode addons = defMode {
-- then choose the appropriate builtin operation or addon operation to run, -- then choose the appropriate builtin operation or addon operation to run,
-- then run it in the right way, usually reading input data (eg a journal) first. -- then run it in the right way, usually reading input data (eg a journal) first.
-- --
-- Making the CLI usable and robust with main command, builtin subcommands, -- When making a CLI usable and robust with main command, builtin subcommands,
-- and various kinds of addon commands, while balancing UX, environment, idioms, -- and various kinds of addon commands, while balancing UX, environment, idioms,
-- legacy, and a language and libraries with their own requirements and limitations, -- legacy, and language and libraries and workarounds with their own requirements
-- gets a bit complex. Try to keep this reasonably manageable/testable/clear. -- and limitations, things get complicated and bugs can easily creep in.
-- See also: Hledger.Cli.CliOptions, cli.test, and the debug output below. -- So try to keep the processing below reasonably manageable, testable and clear.
-- See also: Hledger.Cli.CliOptions, cli.test, and --debug=8.
-- --
main :: IO () main :: IO ()
main = withGhcDebug' $ do main = withGhcDebug' $ do
@ -196,7 +198,7 @@ main = withGhcDebug' $ do
dbgIO "cli args" cliargs dbgIO "cli args" cliargs
dbgIO "cli args with command argument first, if any" cliargswithcmdfirst dbgIO "cli args with command argument first, if any" cliargswithcmdfirst
dbgIO "command argument found" clicmdarg dbgIO "command argument found" clicmdarg
dbgIO "cli args before command" cliargsbeforecmd dbgIO "cli args before command" cliargsbeforecmd
dbgIO "cli args after command" cliargsaftercmd dbgIO "cli args after command" cliargsaftercmd
@ -227,7 +229,7 @@ main = withGhcDebug' $ do
cmdargsfromconf = if null cmd then [] else confArgsFor cmd conf cmdargsfromconf = if null cmd then [] else confArgsFor cmd conf
argsfromcli = drop 1 cliargswithcmdfirst argsfromcli = drop 1 cliargswithcmdfirst
finalargs = -- (avoid breaking vs code haskell highlighting..) finalargs = -- (avoid breaking vs code haskell highlighting..)
if null clicmdarg then [] else [clicmdarg] <> genargsfromconf <> cmdargsfromconf <> argsfromcli (if null clicmdarg then [] else [clicmdarg]) <> genargsfromconf <> cmdargsfromconf <> argsfromcli
& replaceNumericFlags -- convert any -NUM opts from the config file & replaceNumericFlags -- convert any -NUM opts from the config file
-- finalargs' <- expandArgsAt finalargs -- expand any @ARGFILEs from the config file ? don't bother -- finalargs' <- expandArgsAt finalargs -- expand any @ARGFILEs from the config file ? don't bother
@ -248,7 +250,7 @@ main = withGhcDebug' $ do
-- Finally, select an action and run it. -- Finally, select an action and run it.
-- Check for the help/doc/version flags first, since they are a high priority. -- We check for the help/doc/version flags first, since they are a high priority.
-- (A perfectionist might think they should be so high priority that adding -h -- (A perfectionist might think they should be so high priority that adding -h
-- to an invalid command line would show help. But cmdargs tends to fail first, -- to an invalid command line would show help. But cmdargs tends to fail first,
-- preventing this, and trying to detect them without cmdargs, and always do the -- preventing this, and trying to detect them without cmdargs, and always do the
@ -259,6 +261,7 @@ main = withGhcDebug' $ do
infoFlag = boolopt "info" $ rawopts_ opts infoFlag = boolopt "info" $ rawopts_ opts
manFlag = boolopt "man" $ rawopts_ opts manFlag = boolopt "man" $ rawopts_ opts
versionFlag = boolopt "version" $ rawopts_ opts versionFlag = boolopt "version" $ rawopts_ opts
if if
-- no command and a help/doc flag found - show general help/docs -- no command and a help/doc flag found - show general help/docs
| nocmdprovided && helpFlag -> pager $ showModeUsage (mainmode []) ++ "\n" | nocmdprovided && helpFlag -> pager $ showModeUsage (mainmode []) ++ "\n"
@ -273,7 +276,7 @@ main = withGhcDebug' $ do
| badcmdprovided -> error' $ "command "++clicmdarg++" is not recognized, run with no command to see a list" | badcmdprovided -> error' $ "command "++clicmdarg++" is not recognized, run with no command to see a list"
-- no command found, nothing else to do - show the commands list -- no command found, nothing else to do - show the commands list
| nocmdprovided -> dbgIO "" "no command, showing commands list" >> printCommandsList prognameandversion addons | nocmdprovided -> dbgIO "" "no command, showing commands list" >> printCommandsList prognameandversion addons
-- builtin command found -- builtin command found
| Just (cmdmode, cmdaction) <- findBuiltinCommand cmd, | Just (cmdmode, cmdaction) <- findBuiltinCommand cmd,
@ -296,7 +299,7 @@ main = withGhcDebug' $ do
withJournalDo opts (cmdaction opts) withJournalDo opts (cmdaction opts)
-- all other builtin commands - read the journal and if successful run the command with it -- all other builtin commands - read the journal and if successful run the command with it
| otherwise -> withJournalDo opts (cmdaction opts) | otherwise -> withJournalDo opts $ cmdaction opts
-- addon command found - run it, passing along all arguments except the command name. -- addon command found - run it, passing along all arguments except the command name.
-- It will process args and read the journal itself as needed. -- It will process args and read the journal itself as needed.
@ -311,7 +314,7 @@ main = withGhcDebug' $ do
-- deprecated command found -- deprecated command found
-- cmd == "convert" = error' (modeHelp oldconvertmode) >> exitFailure -- cmd == "convert" = error' (modeHelp oldconvertmode) >> exitFailure
-- something else (shouldn't happen ?) - show an error -- something else (shouldn't happen) - show an error
| otherwise -> usageError $ | otherwise -> usageError $
"could not understand the arguments "++show finalargs "could not understand the arguments "++show finalargs
<> if null genargsfromconf then "" else "\ngeneral arguments added from config file: "++show genargsfromconf <> if null genargsfromconf then "" else "\ngeneral arguments added from config file: "++show genargsfromconf
@ -342,67 +345,90 @@ argsToCliOpts args addons = do
parseArgsWithCmdargs :: [String] -> [String] -> Either String RawOpts parseArgsWithCmdargs :: [String] -> [String] -> Either String RawOpts
parseArgsWithCmdargs args addons = CmdArgs.process (mainmode addons) args parseArgsWithCmdargs args addons = CmdArgs.process (mainmode addons) args
-- | cmdargs does not allow flags to appear before the subcommand name. -- | cmdargs does not allow flags (options) to appear before the subcommand name.
-- We would like to hide this restriction from the user, making the CLI more forgiving. -- We prefer to hide this restriction from the user, making the CLI more forgiving.
-- So this tries to move flags, and their values, after the command name. -- So this tries to move flags, and their values if any, after the command name.
-- It's tricky because flags can have an argument following a space, and flags can have optional arguments. -- This is tricky because of the flexibility of traditional flag syntax.
-- We don't parse as precisely as cmdargs here, but we make a reasonable attempt like so: -- Short flags can be joined together, some flags can have a value or no value,
-- flags and values can be separated by =, a space, or nothing, etc.
-- --
-- - ensure the optional-argument --debug flag has an argument -- We make a best-effort attempt like so:
-- (XXX Now there are more optional-arg flags for which this should be done, like --forecast, but that's harder) -- whether a flag argument (- or -- followed by a non-space character and zero or more others),
-- and its following argument, are movable, falls into these cases, to be checked in this order:
-- --
-- - move all no-argument input/report/help flags -- - it exactly matches a known short or long no-value flag; move it
-- - it exactly matches a short or long requires-value flag; move it and the following argument
-- - it exactly matches a short optional-value flag; assume these don't exist or we don't have any
-- - it exactly matches a long optional-value flag; assume there's no value, move it
-- - it begins with a short requires-value flag; the value is joined to it, move it
-- - it begins with a long requires-value flag followed by =; likewise
-- - it begins with a long optional-value flag followed by =; likewise
-- --
-- - move all required-argument input/report/help flags and their values, whether space-separated or not -- Notes:
-- --
-- - try not to confuse things further or cause misleading errors. -- - This hackery increases the risk of causing misleading errors, bugs, or confusion.
-- But it should be fairly robust now, being aware of all builtin flags.
-- --
-- Note this currently only moves general flags, not command flags. -- - All general and builtin command flags (and their values) will be moved. It's clearer to
-- So the manual says only "General options can be written either before or after the command name". -- write command flags after the command, but if not we'll handle it (for greater robustness).
--
-- - Unknown flags (from addons) are assumed to be valueless or have a joined value,
-- and will be moved - but later rejected by cmdargs.
-- Instead these should be written to the right of a "--" argument, which hides them.
-- (We could perhaps automate this, but currently don't.)
-- --
moveFlagsAfterCommand :: [String] -> [String] moveFlagsAfterCommand :: [String] -> [String]
moveFlagsAfterCommand args = moveArgs $ ensureDebugHasArg args moveFlagsAfterCommand args = insertFlagsAfterCommand $ moveFlagArgs (args, [])
where where
ensureDebugHasArg as = case break (=="--debug") as of allbuiltinflags = modeAndSubmodeFlags $ mainmode []
(bs,"--debug":c:cs) | null c || not (all isDigit c) -> bs++"--debug=1":c:cs flagsToArgs flags = nubSort [ if length f == 1 then "-"++f else "--"++f | f <- nubSort $ concatMap flagNames flags]
(bs,["--debug"]) -> bs++["--debug=1"] novalflagargs = flagsToArgs $ filter ((==FlagNone).flagInfo) allbuiltinflags
_ -> as reqvalflagargs = flagsToArgs $ filter ((==FlagReq).flagInfo) allbuiltinflags
optvalflagargs = flagsToArgs $ filter isOptValFlag allbuiltinflags
isOptValFlag f = case flagInfo f of
FlagOpt _ -> True
FlagOptRare _ -> True
_ -> False
isshort ('-':c:_) = c /= '-'
isshort _ = False
islong ('-':'-':_:_) = True
islong _ = False
isflag a = isshort a || islong a
shortreqvalflagargs = filter isshort reqvalflagargs
longreqvalflagargs_ = map (++"=") $ filter islong reqvalflagargs
longoptvalflagargs_ = map (++"=") $ filter islong optvalflagargs ++ ["--debug"]
moveArgs args1 = insertFlagsAfterCommand $ moveArgs' (args1, []) -- Is this a short or long flag argument that should be moved,
-- and is its following argument a value that also should be moved ?
-- Returns:
-- 0 (not a flag)
-- 1 (single flag, maybe with joined argument; or multiple joined short flags)
-- 2 (flag with value in the next argument).
isMovableFlagArg :: String -> Int
isMovableFlagArg a
| a `elem` novalflagargs = 1 -- short or long no-val flag
| a `elem` reqvalflagargs = 2 -- short or long req-val flag, value is in next argument
| a `elem` optvalflagargs = 1 -- long (or short ?) opt-val flag, assume no value
| any (`isPrefixOf` a) shortreqvalflagargs = 1 -- short req-val flag, value is joined
| any (`isPrefixOf` a) longreqvalflagargs_ = 1 -- long req-val flag, value is joined with =
| any (`isPrefixOf` a) longoptvalflagargs_ = 1 -- long opt-val flag, value is joined with =
| isflag a = 1 -- an addon flag (or mistyped flag) we don't know, assume no value or value is joined
| otherwise = 0 -- not a flag
moveArgs' ((f:v:a:as), flags) | isMovableReqArgFlag f, isValue v = moveArgs' (a:as, flags ++ [f,v]) -- -f FILE ..., --alias ALIAS ... moveFlagArgs ((a:b:cs), moved)
moveArgs' ((fv:a:as), flags) | isMovableFlagAndValue fv = moveArgs' (a:as, flags ++ [fv]) -- -fFILE ..., --alias=ALIAS ... | isMovableFlagArg a == 2 = moveFlagArgs (cs, moved++[a,b])
moveArgs' ((f:a:as), flags) | isMovableReqArgFlag f, not (isValue a) = moveArgs' (a:as, flags ++ [f]) -- -f(missing arg) | isMovableFlagArg a == 1 = moveFlagArgs (b:cs, moved++[a])
moveArgs' ((f:a:as), flags) | isMovableNoArgFlag f = moveArgs' (a:as, flags ++ [f]) -- -h ..., --version ... | otherwise = (a:b:cs, moved)
moveArgs' (as, flags) = (as, flags) -- anything else moveFlagArgs (as, moved) = (as, moved)
insertFlagsAfterCommand ([], flags) = flags insertFlagsAfterCommand ([], flags) = flags
insertFlagsAfterCommand (command1:args2, flags) = [command1] ++ flags ++ args2 insertFlagsAfterCommand (command1:args2, flags) = [command1] ++ flags ++ args2
movableflags = inputflags ++ reportflags ++ helpflags -- | Get all the flags defined in a mode or its immediate subcommands,
noargmovableflags = concatMap flagNames (filter ((==FlagNone).flagInfo) movableflags) ++ ["tl", "tld"] -- whether in named, unnamed or hidden groups (does not recurse into subsubcommands).
-- include --tldr abbreviations (other help flags have no unambiguous abbreviations) modeAndSubmodeFlags :: Mode a -> [Flag a]
reqargmovableflags = concatMap flagNames $ filter ((==FlagReq ).flagInfo) movableflags modeAndSubmodeFlags m@Mode{modeGroupModes=Group{..}} =
optargmovableflags = concatMap flagNames $ filter (isoptargflag.flagInfo) movableflags modeFlags m <> concatMap modeFlags (concatMap snd groupNamed <> groupUnnamed <> groupHidden)
where
isoptargflag = \case
FlagOpt _ -> True
FlagOptRare _ -> True
_ -> False
isMovableNoArgFlag a = "-" `isPrefixOf` a && dropWhile (=='-') a `elem` optargmovableflags ++ noargmovableflags
isMovableReqArgFlag a = "-" `isPrefixOf` a && dropWhile (=='-') a `elem` reqargmovableflags
isMovableFlagAndValue ('-':'-':a:as) = case break (== '=') (a:as) of
(f:fs,_:_) -> (f:fs) `elem` optargmovableflags ++ reqargmovableflags
_ -> False
isMovableFlagAndValue ('-':shortflag:_:_) = [shortflag] `elem` reqargmovableflags
isMovableFlagAndValue _ = False
isValue "-" = True
isValue ('-':_) = False
isValue _ = True
-- unit tests (tests_Hledger_Cli) are defined in Hledger.Cli.Commands -- unit tests (tests_Hledger_Cli) are defined in Hledger.Cli.Commands

View File

@ -245,8 +245,8 @@ If this causes difficulty, you can always run the add-on directly, without using
# Options # Options
Run `hledger -h` to see general command line help. Run `hledger -h` to see general command line help.
Options can be written either before or after the command name.
The following general options are common to most hledger commands. The following general options are common to most hledger commands.
General options can be written either before or after the command name.
_generaloptions_ _generaloptions_

View File

@ -107,19 +107,13 @@ $ hledger nosuchcommand
# flag positions # flag positions
# ** 10. general flags can go before command # ** 10. general flags can go before command
$ hledger -f /dev/null --alias somealiases --rules-file --debug 1 --daily register $ hledger -f /dev/null --alias foo --daily register
# ** 11. or after it, and spaces in options are optional # ** 11. or after it. And flags & values joined by = or nothing also work.
$ hledger register -f/dev/null --alias=somealiases -h --version --debug 1 --daily $ hledger register -f/dev/null --alias=foo --daily
> /^register \[OPTIONS\]/
# ** 12. general flags before command should work # ** 12. command-specific flags (for builtin commands) can go after command
$ hledger -f /dev/null --daily register $ hledger -f /dev/null print --explicit
# ** 13. command-specific flags can go after command # ** 13. or before it
$ hledger -f /dev/null register --daily $ hledger -f /dev/null --explicit print
# ** 14. but not before it
$ hledger --related register
>2 /Unknown flag: --related/
>= 1