imp: cli: config files can now provide the command name

If the first thing in a config file's general section is a non-flag
argument, that will be used as the command name argument,
taking precedence over any command line arguments.
This commit is contained in:
Simon Michael 2024-10-12 10:38:35 -10:00
parent a928ed994b
commit 46897cd30b
2 changed files with 126 additions and 109 deletions

View File

@ -96,7 +96,7 @@ import Data.Function ((&))
import Data.Functor ((<&>)) import Data.Functor ((<&>))
import Data.List import Data.List
import qualified Data.List.NonEmpty as NE import qualified Data.List.NonEmpty as NE
import Data.Maybe (isJust) import Data.Maybe (isJust, fromMaybe)
import Data.Text (pack, Text) import Data.Text (pack, Text)
import Data.Time.Clock.POSIX (getPOSIXTime) import Data.Time.Clock.POSIX (getPOSIXTime)
import Safe import Safe
@ -191,6 +191,7 @@ main :: IO ()
main = withGhcDebug' $ do main = withGhcDebug' $ do
-- 0. let's go! -- 0. let's go!
let let
-- Trace helpers. These always trace to stderr, even when running `hledger ui`; -- Trace helpers. These always trace to stderr, even when running `hledger ui`;
-- that's ok as conf is a hledger cli feature for now. -- that's ok as conf is a hledger cli feature for now.
@ -198,24 +199,24 @@ main = withGhcDebug' $ do
dbgIO = ptraceAtIO verboseDebugLevel dbgIO = ptraceAtIO verboseDebugLevel
dbgIO1 = ptraceAtIO 1 dbgIO1 = ptraceAtIO 1
dbgIO2 = ptraceAtIO 2 dbgIO2 = ptraceAtIO 2
dbgIO "running" prognameandversion dbgIO "running" prognameandversion
starttime <- getPOSIXTime starttime <- getPOSIXTime
-- give ghc-debug a chance to take control -- give ghc-debug a chance to take control
when (ghcDebugMode == GDPauseAtStart) $ ghcDebugPause' when (ghcDebugMode == GDPauseAtStart) $ ghcDebugPause'
-- try to encourage user's $PAGER to display ANSI when supported -- try to encourage user's $PAGER to display ANSI when supported
when useColorOnStdout setupPager when useColorOnStdout setupPager
-- Search PATH for addon commands. Exclude any that match builtin command names. -- Search PATH for addon commands. Exclude any that match builtin command names.
addons <- hledgerAddons <&> filter (not . (`elem` builtinCommandNames) . dropExtension) addons <- hledgerAddons <&> filter (not . (`elem` builtinCommandNames) . dropExtension)
--------------------------------------------------------------- ---------------------------------------------------------------
-- 1. Preliminary command line parsing.
dbgIO "\n1. Preliminary command line parsing" () dbgIO "\n1. Preliminary command line parsing" ()
-- Naming notes:
-- "arg" often has the most general meaning, including things like: -f, --flag, flagvalue, arg, >file, &, etc.
-- confcmdarg, clicmdarg = the first non-flag argument, from config file or cli = the subcommand name
-- cmdname = the full unabbreviated command name, or ""
-- confcmdargs = arguments for the subcommand, from config file
-- Do some argument preprocessing to help cmdargs -- Do some argument preprocessing to help cmdargs
cliargs <- getArgs cliargs <- getArgs
>>= expandArgsAt -- interpolate @ARGFILEs >>= expandArgsAt -- interpolate @ARGFILEs
@ -226,144 +227,167 @@ main = withGhcDebug' $ do
(cliargsbeforecmd, cliargsaftercmd) = second (drop 1) $ break (==clicmdarg) cliargs (cliargsbeforecmd, cliargsaftercmd) = second (drop 1) $ break (==clicmdarg) cliargs
dbgIO "cli args" cliargs dbgIO "cli args" cliargs
dbg1IO "cli args with command first, if any" cliargswithcmdfirst dbg1IO "cli args with command first, if any" cliargswithcmdfirst
dbgIO "command argument found" clicmdarg dbgIO "cli 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
dbgIO "cli args without command" cliargswithoutcmd
-- Now try to identify the full subcommand name, so we can look for ---------------------------------------------------------------
-- command-specific options in config files (clicmdarg may be only an abbreviation). dbgIO "\n2. Read the config file if any" ()
-- For this do a preliminary cmdargs parse of the arguments with cli-specific options removed.
-- If no command was provided, or if the command line contains a bad flag -- Identify any --conf/--no-conf options.
-- or a wrongly present/missing flag argument, cmd will be "". -- Run cmdargs on just the args that look conf-related.
let let
rawopts1 = cmdargsParse cliconfargs = dropUnsupportedOpts confflagsmode cliargswithoutcmd
"for command name" cliconfrawopts = cmdargsParse "for conf options" confflagsmode cliconfargs
(mainmode addons)
cliargswithcmdfirstwithoutclispecific -- Read extra general and command-specific args/opts from the config file, if any.
cmd = stringopt "command" rawopts1 (conf, mconffile) <-
-- XXX better error message when cmdargs fails (eg spaced/quoted/malformed flag values) ? seq cliconfrawopts $ -- order debug output
nocmdprovided = null clicmdarg getConf cliconfrawopts
badcmdprovided = null cmd && not nocmdprovided
isaddoncmd = not (null cmd) && cmd `elem` addons ---------------------------------------------------------------
-- isbuiltincmd = cmd `elem` builtinCommandNames dbgIO "\n3. Identify a command name from config file or command line" ()
mcmdmodeaction = findBuiltinCommand cmd
effectivemode = maybe (mainmode []) fst mcmdmodeaction -- Try to identify the subcommand name,
-- from the first non-flag general argument in the config file,
-- or if there is none, from the first non-flag argument on the command line.
let
confallgenargs = confLookup "general" conf
-- we don't try to move flags/values preceding a command argument here;
-- if a command name is written in the config file, it must be first
(confcmdarg, confothergenargs) = case confallgenargs of
a:as | not $ isFlagArg a -> (a,as)
as -> ("",as)
cmdarg = if not $ null confcmdarg then confcmdarg else clicmdarg
nocmdprovided = null cmdarg
-- The argument may be an abbreviated command name.
-- Run cmdargs on conf + cli args to get the full command name.
-- If no command argument was provided, or if cmdargs fails because
-- the command line contains a bad flag or wrongly present/missing flag value,
-- cmdname will be "".
args = confallgenargs <> cliargswithcmdfirstwithoutclispecific
cmdname = stringopt "command" $ cmdargsParse "for command name" (mainmode addons) args
badcmdprovided = null cmdname && not nocmdprovided
isaddoncmd = not (null cmdname) && cmdname `elem` addons
-- And get the builtin command's action, if any.
mbuiltincmdaction = findBuiltinCommand cmdname
effectivemode = maybe (mainmode []) fst mbuiltincmdaction
when (isJust mconffile) $ do
unless (null confcmdarg) $
dbgIO1 "using command name argument from config file" confcmdarg
dbgIO "cli args with command first and no cli-specific opts" cliargswithcmdfirstwithoutclispecific dbgIO "cli args with command first and no cli-specific opts" cliargswithcmdfirstwithoutclispecific
dbgIO1 "command found" cmd dbgIO1 "command found" cmdname
dbgIO "no command provided" nocmdprovided dbgIO "no command provided" nocmdprovided
dbgIO "bad command provided" badcmdprovided dbgIO "bad command provided" badcmdprovided
dbgIO "is addon command" isaddoncmd dbgIO "is addon command" isaddoncmd
--------------------------------------------------------------- ---------------------------------------------------------------
-- 2. Read extra options from a config file. dbgIO "\n4. Get applicable options/arguments from config file" ()
dbgIO "\n2. Read options from a config file" ()
-- Identify any --conf/--no-conf options.
-- For this parse with cmdargs a second time, this time with just the args that look conf-related.
let cliconfargs = dropUnsupportedOpts confflagsmode cliargswithoutcmd
dbgIO "cli args without command" cliargswithoutcmd
-- dbgIO "cli conf args" cliconfargs
let rawopts2 = cmdargsParse "for conf options" confflagsmode cliconfargs
-- Read extra general and command-specific args/opts from the config file if found.
-- Ignore any general opts or cli-specific opts not known to be supported by the command. -- Ignore any general opts or cli-specific opts not known to be supported by the command.
(conf, mconffile) <- getConf rawopts2
let let
genargsfromconf = confLookup "general" conf
addoncmdssupportinggenopts = ["ui", "web"] -- addons known to support hledger general options addoncmdssupportinggenopts = ["ui", "web"] -- addons known to support hledger general options
supportedgenargsfromconf supportedgenargsfromconf
| cmd `elem` addoncmdssupportinggenopts = | cmdname `elem` addoncmdssupportinggenopts =
[a | a <- genargsfromconf, not $ any (`isPrefixOf` a) addoncmdssupportinggenopts] [a | a <- confothergenargs, not $ any (`isPrefixOf` a) addoncmdssupportinggenopts]
| isaddoncmd = [] | isaddoncmd = []
| otherwise = dropUnsupportedOpts effectivemode genargsfromconf | otherwise = dropUnsupportedOpts effectivemode confothergenargs
excludedgenargsfromconf = genargsfromconf \\ supportedgenargsfromconf excludedgenargsfromconf = confothergenargs \\ supportedgenargsfromconf
cmdargsfromconf confcmdargs
| null cmd = [] | null cmdname = []
| otherwise = confLookup cmd conf & if isaddoncmd then ("--":) else id | otherwise = confLookup cmdname conf & if isaddoncmd then ("--":) else id
when (isJust mconffile) $ do when (isJust mconffile) $ do
dbgIO1 "using extra general args from config file" genargsfromconf dbgIO1 "using general args from config file" confothergenargs
unless (null excludedgenargsfromconf) $ unless (null excludedgenargsfromconf) $
dbgIO1 "excluded general args from config file, not supported by this command" excludedgenargsfromconf dbgIO1 "excluded general args from config file, not supported by this command" excludedgenargsfromconf
dbgIO1 "using extra command args from config file" cmdargsfromconf dbgIO1 "using subcommand args from config file" confcmdargs
--------------------------------------------------------------- ---------------------------------------------------------------
-- 3. Combine cli and config file args and parse with cmdargs a third time. dbgIO "\n5. Combine config file and command line args" ()
-- A bad flag or flag argument will cause the program to exit with an error here.
dbgIO "\n3. Combine command line and config file args" ()
let let
finalargs = finalargs =
(if null clicmdarg then [] else [clicmdarg]) <> supportedgenargsfromconf <> cmdargsfromconf <> cliargswithoutcmd [cmdarg | not $ null cmdarg] <> supportedgenargsfromconf <> confcmdargs <> cliargswithoutcmd
& replaceNumericFlags -- convert any -NUM opts from the config file & replaceNumericFlags -- convert any -NUM opts from the config file
-- finalargs' <- expandArgsAt finalargs -- expand @ARGFILEs in the config file ? don't bother -- finalargs' <- expandArgsAt finalargs -- expand @ARGFILEs in the config file ? don't bother
let rawopts3 = cmdargsParse "for all options" (mainmode addons) finalargs dbgIO1 "final args" finalargs
-- Run cmdargs on command name + supported conf general args + conf subcommand args + cli args to get the final options.
-- A bad flag or flag argument will cause the program to exit with an error here.
let rawopts = cmdargsParse "final command line" (mainmode addons) finalargs
--------------------------------------------------------------- ---------------------------------------------------------------
-- 4. Finally, select an action and run it. seq rawopts $ -- order debug output
dbgIO "\n6. Select an action and run it" ()
dbgIO "\n4. Select an action" ()
-- We 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
-- right thing with builtin commands and addon commands, gets much too complicated.) -- right thing with builtin commands and addon commands, gets much too complicated.)
let let
helpFlag = boolopt "help" rawopts3 helpFlag = boolopt "help" rawopts
tldrFlag = boolopt "tldr" rawopts3 tldrFlag = boolopt "tldr" rawopts
infoFlag = boolopt "info" rawopts3 infoFlag = boolopt "info" rawopts
manFlag = boolopt "man" rawopts3 manFlag = boolopt "man" rawopts
versionFlag = boolopt "version" rawopts3 versionFlag = boolopt "version" rawopts
if if
-- 4.1. no command and a help/doc flag found - show general help/docs -- 6.1. no command and a help/doc flag found - show general help/docs
| nocmdprovided && helpFlag -> pager $ showModeUsage (mainmode []) ++ "\n" | nocmdprovided && helpFlag -> pager $ showModeUsage (mainmode []) ++ "\n"
| nocmdprovided && tldrFlag -> runTldrForPage "hledger" | nocmdprovided && tldrFlag -> runTldrForPage "hledger"
| nocmdprovided && infoFlag -> runInfoForTopic "hledger" Nothing | nocmdprovided && infoFlag -> runInfoForTopic "hledger" Nothing
| nocmdprovided && manFlag -> runManForTopic "hledger" Nothing | nocmdprovided && manFlag -> runManForTopic "hledger" Nothing
-- 4.2. --version flag found and none of these other conditions - show version -- 6.2. --version flag found and none of these other conditions - show version
| versionFlag && not (isaddoncmd || helpFlag || tldrFlag || infoFlag || manFlag) -> putStrLn prognameandversion | versionFlag && not (isaddoncmd || helpFlag || tldrFlag || infoFlag || manFlag) -> putStrLn prognameandversion
-- 4.3. there's a command argument, but it's bad - show error -- 6.3. there's a command argument, but it's bad - show error
| 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"
-- 4.4. no command found, nothing else to do - show the commands list -- 6.4. no command found, nothing else to do - show the commands list
| nocmdprovided -> dbgIO "" "no command, showing commands list" >> printCommandsList prognameandversion addons | nocmdprovided -> dbgIO1 "no command, showing commands list" () >> printCommandsList prognameandversion addons
-- 4.5. builtin command found -- 6.5. builtin command found
| Just (cmdmode, cmdaction) <- mcmdmodeaction -> do | Just (cmdmode, cmdaction) <- mbuiltincmdaction -> do
let mmodecmdname = headMay $ modeNames cmdmode
dbgIO1 "running builtin command mode" $ fromMaybe "" mmodecmdname
-- validate opts/args more and convert to CliOpts -- validate opts/args more and convert to CliOpts
opts <- rawOptsToCliOpts rawopts3 >>= \opts0 -> return opts0{progstarttime_=starttime} opts <- rawOptsToCliOpts rawopts >>= \opts0 -> return opts0{progstarttime_=starttime}
dbgIO2 "processed opts" opts dbgIO2 "processed opts" opts
dbgIO "period from opts" (period_ . _rsReportOpts $ reportspec_ opts) dbgIO "period from opts" (period_ . _rsReportOpts $ reportspec_ opts)
dbgIO "interval from opts" (interval_ . _rsReportOpts $ reportspec_ opts) dbgIO "interval from opts" (interval_ . _rsReportOpts $ reportspec_ opts)
dbgIO "query from opts & args" (_rsQuery $ reportspec_ opts) dbgIO "query from opts & args" (_rsQuery $ reportspec_ opts)
let let tldrpagename = maybe "hledger" (("hledger-"<>)) mmodecmdname
mcmdname = headMay $ modeNames cmdmode
tldrpagename = maybe "hledger" (("hledger-"<>)) mcmdname
-- run the builtin command according to its type -- run the builtin command according to its type
if if
-- help/doc flag - show command help/docs -- 6.5.1. help/doc flag - show command help/docs
| helpFlag -> pager $ showModeUsage cmdmode ++ "\n" | helpFlag -> pager $ showModeUsage cmdmode ++ "\n"
| tldrFlag -> runTldrForPage tldrpagename | tldrFlag -> runTldrForPage tldrpagename
| infoFlag -> runInfoForTopic "hledger" mcmdname | infoFlag -> runInfoForTopic "hledger" mmodecmdname
| manFlag -> runManForTopic "hledger" mcmdname | manFlag -> runManForTopic "hledger" mmodecmdname
-- builtin command which should not require or read the journal - run it -- 6.5.2. builtin command which should not require or read the journal - run it
| cmd `elem` ["demo","help","test"] -> | cmdname `elem` ["demo","help","test"] ->
cmdaction opts $ error' $ cmd++" tried to read the journal but is not supposed to" cmdaction opts $ error' $ cmdname++" tried to read the journal but is not supposed to"
-- builtin command which should create the journal if missing - do that and run it -- 6.5.3. builtin command which should create the journal if missing - do that and run it
| cmd `elem` ["add","import"] -> do | cmdname `elem` ["add","import"] -> do
ensureJournalFileExists . NE.head =<< journalFilePathFromOpts opts ensureJournalFileExists . NE.head =<< journalFilePathFromOpts opts
withJournalDo opts (cmdaction opts) withJournalDo opts (cmdaction opts)
-- all other builtin commands - read the journal and if successful run the command with it -- 6.5.4. 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
-- 4.6. external addon command found - run it, -- 6.6. external addon command found - run it,
-- passing any cli arguments written after the command name -- passing any cli arguments written after the command name
-- and any command-specific opts from the config file. -- and any command-specific opts from the config file.
-- Any "--" arguments, which sometimes must be used in the command line -- Any "--" arguments, which sometimes must be used in the command line
@ -375,24 +399,24 @@ main = withGhcDebug' $ do
-- are not passed since we can't be sure they're supported. -- are not passed since we can't be sure they're supported.
| isaddoncmd -> do | isaddoncmd -> do
let let
addonargs0 = filter (/="--") $ supportedgenargsfromconf <> cmdargsfromconf <> cliargswithoutcmd addonargs0 = filter (/="--") $ supportedgenargsfromconf <> confcmdargs <> cliargswithoutcmd
addonargs = dropCliSpecificOpts addonargs0 addonargs = dropCliSpecificOpts addonargs0
shellcmd = printf "%s-%s %s" progname cmd (unwords' addonargs) :: String shellcmd = printf "%s-%s %s" progname cmdname (unwords' addonargs) :: String
dbgIO "addon command selected" cmd dbgIO "addon command selected" cmdname
dbgIO "addon command arguments after removing cli-specific opts" (map quoteIfNeeded addonargs) dbgIO "addon command arguments after removing cli-specific opts" (map quoteIfNeeded addonargs)
dbgIO1 "running addon" shellcmd dbgIO1 "running addon" shellcmd
system shellcmd >>= exitWith system shellcmd >>= exitWith
-- deprecated command found -- deprecated command found
-- cmd == "convert" = error' (modeHelp oldconvertmode) >> exitFailure -- cmdname == "convert" = error' (modeHelp oldconvertmode) >> exitFailure
-- 4.7. something else (shouldn't happen) - show an error -- 6.7. 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 confothergenargs then "" else "\ngeneral arguments added from config file: "++show confothergenargs
<> if null cmdargsfromconf then "" else "\ncommand arguments added from config file: "++show cmdargsfromconf <> if null confcmdargs then "" else "\ncommand arguments added from config file: "++show confcmdargs
-- 5. And we're done. -- 7. And we're done.
-- Give ghc-debug a final chance to take control. -- Give ghc-debug a final chance to take control.
when (ghcDebugMode == GDPauseAtEnd) $ ghcDebugPause' when (ghcDebugMode == GDPauseAtEnd) $ ghcDebugPause'
@ -409,8 +433,8 @@ argsToCliOpts args addons = do
let let
(_, _, args0) = moveFlagsAfterCommand args (_, _, args0) = moveFlagsAfterCommand args
args1 = replaceNumericFlags args0 args1 = replaceNumericFlags args0
rawopts3 = cmdargsParse "for options" (mainmode addons) args1 rawopts = cmdargsParse "for options" (mainmode addons) args1
rawOptsToCliOpts rawopts3 rawOptsToCliOpts rawopts
-- | Parse the given command line arguments/options with the given cmdargs mode, -- | Parse the given command line arguments/options with the given cmdargs mode,
-- after adding values to any valueless --debug flags, -- after adding values to any valueless --debug flags,
@ -422,6 +446,7 @@ cmdargsParse desc m args0 = 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))
-- XXX better error message when cmdargs fails (eg spaced/quoted/malformed flag values) ?
-- | cmdargs does not allow flags (options) to appear before the subcommand argument. -- | cmdargs does not allow flags (options) to appear before the subcommand argument.
-- We prefer 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.

View File

@ -512,17 +512,16 @@ You can save a set of command line options and arguments in a file,
and then reuse them by writing `@FILENAME` as a command line argument. and then reuse them by writing `@FILENAME` as a command line argument.
Eg: `hledger bal @foo.args`. Eg: `hledger bal @foo.args`.
(Inside the argument file, each line should contain just one option or argument. An argument file's format is more restrictive than the command line.
Each line should contain just one option or argument.
Don't use spaces except inside quotes; write `=` or nothing between a flag and its argument. Don't use spaces except inside quotes; write `=` or nothing between a flag and its argument.
For the special characters mentioned above, use one less level of quoting than If you use quotes, they must enclose the whole line.
you would at the command prompt.) For the special characters mentioned above, use one less level of quoting than you would at the command line.
Argument files are now superseded by..
## Config files ## Config files
As of hledger 1.40, you can optionally save command line options (or arguments) With hledger 1.40+, you can save extra command line options and arguments
to be used when running hledger commands, in a config file. Here's a small example: in a more featureful hledger config file. Here's a small example:
```conf ```conf
# General options are listed first, one or more per line. # General options are listed first, one or more per line.
@ -570,14 +569,7 @@ Eg (some operating systems need the `-S`, some don't):
You can put not only options, but also arguments in a config file. You can put not only options, but also arguments in a config file.
This is probably more useful in special-purpose config files, not an automatic one. This is probably more useful in special-purpose config files, not an automatic one.
There's an exception to this: a config file can't provide the command argument, currently The config file feature was added in hledger 1.40 and is considered *experimental*.
([#2231](https://github.com/simonmichael/hledger/issues/2231)).
If you need that, you can do it in the shebang line instead. Eg:
```
#!/usr/bin/env -S hledger balance --conf
```
The config file feature has been added in hledger 1.40 and is considered *experimental*.
## Shell completions ## Shell completions
@ -741,7 +733,7 @@ or use `--real` to exclude transactions that use them.
#### Beancount costs #### Beancount costs
Beancount doesn't allow [redundant cost notation](https://hledger.org/hledger.html#combining-costs-and-equity-conversion-postings) Beancount doesn't allow [redundant cost notation](https://hledger.org/hledger.html#combining-costs-and-equity-conversion-postings)
as hledger does. If you have entries like this, you may need to comment out either the costs or the equity postings. as hledger does. If you have entries like this, you will need to comment out either the costs or the equity postings.
#### Beancount operating currency #### Beancount operating currency