fix: cli: move flags with shadowed names, like -p, more carefully

This commit is contained in:
Simon Michael 2024-07-06 17:02:40 +01:00
parent 17e292eddd
commit 65ac41e155
3 changed files with 79 additions and 45 deletions

View File

@ -70,6 +70,7 @@ etc.
{-# HLINT ignore "Unused LANGUAGE pragma" #-} {-# HLINT ignore "Unused LANGUAGE pragma" #-}
{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TupleSections #-}
module Hledger.Cli ( module Hledger.Cli (
main, main,
@ -84,7 +85,7 @@ module Hledger.Cli (
module Hledger.Cli.Version, module Hledger.Cli.Version,
module Hledger, module Hledger,
-- ** System.Console.CmdArgs.Explicit -- ** System.Console.CmdArgs.Explicit
module System.Console.CmdArgs.Explicit, module CmdArgsWithoutName
) )
where where
@ -93,8 +94,8 @@ import Data.List
import qualified Data.List.NonEmpty as NE import qualified Data.List.NonEmpty as NE
import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Time.Clock.POSIX (getPOSIXTime)
import Safe import Safe
import System.Console.CmdArgs.Explicit hiding (Name) import System.Console.CmdArgs.Explicit
import qualified System.Console.CmdArgs.Explicit as CmdArgs import System.Console.CmdArgs.Explicit as CmdArgsWithoutName hiding (Name)
import System.Environment import System.Environment
import System.Exit import System.Exit
import System.FilePath import System.FilePath
@ -111,7 +112,6 @@ import Hledger.Cli.Version
import Data.Bifunctor (second) import Data.Bifunctor (second)
import Data.Function ((&)) import Data.Function ((&))
import Data.Functor ((<&>)) import Data.Functor ((<&>))
import Data.List.Extra (nubSort)
import Data.Maybe (isJust) import Data.Maybe (isJust)
@ -139,7 +139,7 @@ mainmode addons = defMode {
-- flags in the unnamed group, shown last: -- flags in the unnamed group, shown last:
,groupUnnamed = confflags -- keep synced with dropUnsupportedOpts ,groupUnnamed = confflags -- keep synced with dropUnsupportedOpts
-- flags handled but not shown in the help: -- flags handled but not shown in the help:
,groupHidden = detailedversionflag : hiddenflags ,groupHidden = hiddenflags
} }
,modeHelpSuffix = [] ,modeHelpSuffix = []
-- "Examples:" : -- "Examples:" :
@ -152,7 +152,6 @@ mainmode addons = defMode {
-- ," help [MANUAL] show any of the hledger manuals in various formats" -- ," help [MANUAL] show any of the hledger manuals in various formats"
-- ] -- ]
} }
-- A dummy mode just for parsing --conf/--no-conf flags. -- A dummy mode just for parsing --conf/--no-conf flags.
confflagsmode = defMode{ confflagsmode = defMode{
modeGroupFlags=Group [] confflags [] modeGroupFlags=Group [] confflags []
@ -218,7 +217,7 @@ main = withGhcDebug' $ do
cliargswithcmdfirstwithoutclispecific = dropCliSpecificOpts cliargswithcmdfirst cliargswithcmdfirstwithoutclispecific = dropCliSpecificOpts cliargswithcmdfirst
(cliargsbeforecmd, cliargsaftercmd) = second (drop 1) $ break (==clicmdarg) cliargs (cliargsbeforecmd, cliargsaftercmd) = second (drop 1) $ break (==clicmdarg) cliargs
dbgIO "cli args" cliargs dbgIO "cli args" cliargs
dbgIO "cli args with command first, if any" cliargswithcmdfirst dbg1IO "cli args with command 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
@ -408,8 +407,7 @@ argsToCliOpts args addons = do
-- (useful when cmdargsParse is called more than once). -- (useful when cmdargsParse is called more than once).
-- If parsing fails, exit the program with an informative error message. -- If parsing fails, exit the program with an informative error message.
cmdargsParse :: String -> Mode RawOpts -> [String] -> RawOpts cmdargsParse :: String -> Mode RawOpts -> [String] -> RawOpts
cmdargsParse desc m args0 = cmdargsParse desc m args0 = process m (ensureDebugFlagHasVal args0)
CmdArgs.process m (ensureDebugFlagHasVal args0)
& either & either
(\e -> error' $ e <> " while parsing these args " <> desc <> ": " <> unwords (map quoteIfNeeded args0)) (\e -> error' $ e <> " while parsing these args " <> desc <> ": " <> unwords (map quoteIfNeeded args0))
(traceOrLogAt verboseDebugLevel ("cmdargs: parsing " <> desc <> ": " <> show args0)) (traceOrLogAt verboseDebugLevel ("cmdargs: parsing " <> desc <> ": " <> show args0))
@ -471,24 +469,23 @@ moveFlagsAfterCommand args =
-- 2 (flag with value in the next argument). -- 2 (flag with value in the next argument).
isMovableFlagArg :: String -> Int isMovableFlagArg :: String -> Int
isMovableFlagArg a1 isMovableFlagArg a1
| a1 `elem` novalflagargs = 1 -- short or long no-val flag | a1 `elem` noValFlagArgs = 1 -- short or long no-val flag
| a1 `elem` reqvalflagargs, not $ "--debug" `isPrefixOf` a1 = 2 | a1 `elem` reqValFlagArgs, not $ "--debug" `isPrefixOf` a1 = 2
-- short or long req-val flag, value is in next argument -- short or long req-val flag, value is in next argument
-- --debug is really optional-value (see CliOptions), assume it has no value or joined value -- --debug is really optional-value (see CliOptions), assume it has no value or joined value
| a1 `elem` optvalflagargs = 1 -- long (or short ?) opt-val flag, assume no value | a1 `elem` optValFlagArgs = 1 -- long (or short ?) opt-val flag, assume no value
| any (`isPrefixOf` a1) shortreqvalflagargs = 1 -- short req-val flag, value is joined | any (`isPrefixOf` a1) shortReqValFlagArgs = 1 -- short req-val flag, value is joined
| any (`isPrefixOf` a1) longreqvalflagargs_ = 1 -- long req-val flag, value is joined with = | any (`isPrefixOf` a1) longReqValFlagArgs_ = 1 -- long req-val flag, value is joined with =
| any (`isPrefixOf` a1) longoptvalflagargs_ = 1 -- long opt-val flag, value is joined with = | any (`isPrefixOf` a1) longOptValFlagArgs_ = 1 -- long opt-val flag, value is joined with =
-- | isLongFlagArg a1 && any (takeWhile (/='=') `isPrefixOf`) longreqvalflagargs_ ... -- try to move abbreviated long flags ? -- | isLongFlagArg a1 && any (takeWhile (/='=') `isPrefixOf`) longReqValFlagArgs_ ... -- try to move abbreviated long flags ?
| isFlagArg a1 = 1 -- an addon flag (or mistyped flag) we don't know, assume no value or value is joined | isFlagArg a1 = 1 -- an addon flag (or mistyped flag) we don't know, assume no value or value is joined
| otherwise = 0 -- not a flag | otherwise = 0 -- not a flag
moveFlagArgs (as, moved) = (as, moved) moveFlagArgs (as, moved) = (as, moved)
-- All Flags provided by hledger and its builtin comands.
allbuiltinflags = modeAndSubmodeFlags $ mainmode []
-- Flag arguments are command line arguments beginning with - or -- -- Flag arguments are command line arguments beginning with - or --
-- (followed by a short of long flag name, and possibly joined short flags or a joined value). -- (followed by a short of long flag name, and possibly joined short flags or a joined value).
isFlagArg, isShortFlagArg, isLongFlagArg :: String -> Bool
isFlagArg a = isShortFlagArg a || isLongFlagArg a
isShortFlagArg ('-':c:_) = c /= '-' isShortFlagArg ('-':c:_) = c /= '-'
isShortFlagArg _ = False isShortFlagArg _ = False
@ -496,31 +493,65 @@ isShortFlagArg _ = False
isLongFlagArg ('-':'-':_:_) = True isLongFlagArg ('-':'-':_:_) = True
isLongFlagArg _ = False isLongFlagArg _ = False
isFlagArg a = isShortFlagArg a || isLongFlagArg a -- | Add the leading hyphen(s) to a short or long flag name.
toFlagArg :: Name -> String
toFlagArg f = if length f == 1 then "-"++f else "--"++f
-- Given a list of Flags, return all of their supported short and long flag names as flag arguments -- | Flatten a possibly multi-named Flag to (name, FlagInfo) pairs.
-- (a sorted list of the unique flag names with - or -- prefixes). toFlagInfos :: Flag RawOpts -> [(Name, FlagInfo)]
flagsToArgs flags = nubSort [ if length f == 1 then "-"++f else "--"++f | f <- nubSort $ concatMap flagNames flags] toFlagInfos f = [(n,i) | let i = flagInfo f, n <- flagNames f]
-- hledger flag args grouped by whether their flag expects no value, a required value, or an optional value. -- | Is this flag's value optional ?
novalflagargs = flagsToArgs $ filter ((==FlagNone).flagInfo) allbuiltinflags isOptVal :: FlagInfo -> Bool
reqvalflagargs = flagsToArgs $ filter ((==FlagReq).flagInfo) allbuiltinflags isOptVal = \case
optvalflagargs = flagsToArgs $ filter isOptValFlag allbuiltinflags FlagOpt _ -> True
FlagOptRare _ -> True
_ -> False
-- | All the general flags defined in hledger's main mode.
generalFlags :: [Flag RawOpts]
generalFlags = concatMap snd groupNamed <> groupHidden <> groupUnnamed
where Group{..} = modeGroupFlags $ mainmode []
-- | All the general flag names.
generalFlagNames :: [Name]
generalFlagNames = concatMap flagNames generalFlags
-- | All hledger's builtin subcommand-specific flags.
commandFlags :: [Flag RawOpts]
commandFlags = concatMap (groupUnnamed.modeGroupFlags) commandModes
where where
isOptValFlag f = case flagInfo f of commandModes = concatMap snd groupNamed <> groupUnnamed <> groupHidden
FlagOpt _ -> True where Group{..} = modeGroupModes $ mainmode []
FlagOptRare _ -> True
_ -> False -- | The names of general options flags, grouped by whether they expect a value.
-- There may be some overlaps with command flag names.
noValGeneralFlagNames, reqValGeneralFlagNames, optValGeneralFlagNames :: [Name]
noValGeneralFlagNames = [f | (f,i) <- concatMap toFlagInfos generalFlags, i == FlagNone]
reqValGeneralFlagNames = [f | (f,i) <- concatMap toFlagInfos generalFlags, i == FlagReq]
optValGeneralFlagNames = [f | (f,i) <- concatMap toFlagInfos generalFlags, isOptVal i]
-- | The names of builtin subcommand flags, grouped by whether they expect a value.
-- There may be some overlaps with general flag names.
noValCommandFlagNames, reqValCommandFlagNames, optValCommandFlagNames :: [Name]
noValCommandFlagNames = [f | (f,i) <- concatMap toFlagInfos commandFlags, i == FlagNone]
reqValCommandFlagNames = [f | (f,i) <- concatMap toFlagInfos commandFlags, i == FlagReq]
optValCommandFlagNames = [f | (f,i) <- concatMap toFlagInfos commandFlags, isOptVal i]
-- All flag arguments understood by hledger cli and builtin commands, grouped by whether they expect a value.
-- Any command flags which have the same name as a general flag are excluded.
noValFlagArgs = map toFlagArg $ noValGeneralFlagNames `union` (noValCommandFlagNames \\ generalFlagNames)
reqValFlagArgs = map toFlagArg $ reqValGeneralFlagNames `union` (reqValCommandFlagNames \\ generalFlagNames)
optValFlagArgs = map toFlagArg $ optValGeneralFlagNames `union` (optValCommandFlagNames \\ generalFlagNames)
-- Short flag args that expect a required value. -- Short flag args that expect a required value.
shortreqvalflagargs = filter isShortFlagArg reqvalflagargs shortReqValFlagArgs = filter isShortFlagArg reqValFlagArgs
-- Long flag args that expect a required value or optional value respectively, with = appended. -- Long flag args that expect a required value, with = appended.
longreqvalflagargs_ = map (++"=") $ filter isLongFlagArg reqvalflagargs longReqValFlagArgs_ = map (++"=") $ filter isLongFlagArg reqValFlagArgs
longoptvalflagargs_ = map (++"=") $ filter isLongFlagArg optvalflagargs ++ ["--debug"]
-- Is this flag arg one that requires a value ? -- Long flag args that expect an optional value, with = appended.
isReqValFlagArg a = a `elem` reqvalflagargs longOptValFlagArgs_ = map (++"=") $ filter isLongFlagArg optValFlagArgs ++ ["--debug"]
-- Drop any arguments which look like cli-specific options (--no-conf, --conf CONFFILE, etc.) -- Drop any arguments which look like cli-specific options (--no-conf, --conf CONFFILE, etc.)
-- Keep synced with mainmode's groupUnnamed. -- Keep synced with mainmode's groupUnnamed.
@ -550,13 +581,15 @@ dropUnsupportedOpts m = \case
| otherwise -> a : dropUnsupportedOpts m as | otherwise -> a : dropUnsupportedOpts m as
where where
go = dropUnsupportedOpts m go = dropUnsupportedOpts m
supportsFlag m1 flagarg = elem flagarg $ flagsToArgs $ modeAndSubmodeFlags m1 isReqValFlagArg = (`elem` reqValFlagArgs)
supportsFlag m1 flagarg = elem flagarg $ map toFlagArg $ concatMap flagNames $ modeAndSubmodeFlags m1
-- | Get all the flags defined in a mode or its immediate subcommands, -- | Get all the flags defined in a mode or its immediate subcommands,
-- whether in named, unnamed or hidden groups (does not recurse into subsubcommands). -- whether in named, unnamed or hidden groups.
-- Does not recurse into subsubcommands,
-- and does not deduplicate (general flags are repeated on all hledger subcommands).
modeAndSubmodeFlags :: Mode a -> [Flag a] modeAndSubmodeFlags :: Mode a -> [Flag a]
modeAndSubmodeFlags m@Mode{modeGroupModes=Group{..}} = modeAndSubmodeFlags m@Mode{modeGroupModes=Group{..}} =
modeFlags m <> concatMap modeFlags (concatMap snd groupNamed <> groupUnnamed <> groupHidden) modeFlags m <> concatMap modeFlags (concatMap snd groupNamed <> groupUnnamed <> groupHidden)
-- unit tests (tests_Hledger_Cli) are defined in Hledger.Cli.Commands -- unit tests (tests_Hledger_Cli) are defined in Hledger.Cli.Commands

View File

@ -24,7 +24,6 @@ module Hledger.Cli.CliOptions (
reportflags, reportflags,
helpflags, helpflags,
helpflagstitle, helpflagstitle,
detailedversionflag,
flattreeflags, flattreeflags,
confflags, confflags,
hiddenflags, hiddenflags,
@ -252,10 +251,6 @@ helpflags = [
] ]
-- XXX why are these duplicated in defCommandMode below ? -- XXX why are these duplicated in defCommandMode below ?
-- | A hidden flag just for the hledger executable.
detailedversionflag :: Flag RawOpts
detailedversionflag = flagNone ["version+"] (setboolopt "version+") "show version information with extra detail"
-- | Flags for selecting flat/tree mode, used for reports organised by account. -- | Flags for selecting flat/tree mode, used for reports organised by account.
-- With a True argument, shows some extra help about inclusive/exclusive amounts. -- With a True argument, shows some extra help about inclusive/exclusive amounts.
flattreeflags :: Bool -> [Flag RawOpts] flattreeflags :: Bool -> [Flag RawOpts]

View File

@ -114,3 +114,9 @@ $ hledger --no-conf check -f/dev/null
# ** 15. --conf CONFFILE works with builtin commands. # ** 15. --conf CONFFILE works with builtin commands.
$ hledger --conf /dev/null check -f/dev/null $ hledger --conf /dev/null check -f/dev/null
# ** 16. when moving options written before the command name:
# if the flag name is used in general options and also one or more commands,
# the general option's arity determines whether a value is expected.
# Here -p is a help command flag taking no value, but also a general option requiring a value,
# so the value ("today") is detected.
$ hledger -p today check -f/dev/null