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.List
import qualified Data.List.NonEmpty as NE
import Data.Maybe (isJust)
import Data.Maybe (isJust, fromMaybe)
import Data.Text (pack, Text)
import Data.Time.Clock.POSIX (getPOSIXTime)
import Safe
@ -191,6 +191,7 @@ main :: IO ()
main = withGhcDebug' $ do
-- 0. let's go!
let
-- Trace helpers. These always trace to stderr, even when running `hledger ui`;
-- that's ok as conf is a hledger cli feature for now.
@ -198,24 +199,24 @@ main = withGhcDebug' $ do
dbgIO = ptraceAtIO verboseDebugLevel
dbgIO1 = ptraceAtIO 1
dbgIO2 = ptraceAtIO 2
dbgIO "running" prognameandversion
starttime <- getPOSIXTime
-- give ghc-debug a chance to take control
when (ghcDebugMode == GDPauseAtStart) $ ghcDebugPause'
-- try to encourage user's $PAGER to display ANSI when supported
when useColorOnStdout setupPager
-- Search PATH for addon commands. Exclude any that match builtin command names.
addons <- hledgerAddons <&> filter (not . (`elem` builtinCommandNames) . dropExtension)
---------------------------------------------------------------
-- 1. 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
cliargs <- getArgs
>>= expandArgsAt -- interpolate @ARGFILEs
@ -226,144 +227,167 @@ main = withGhcDebug' $ do
(cliargsbeforecmd, cliargsaftercmd) = second (drop 1) $ break (==clicmdarg) cliargs
dbgIO "cli args" cliargs
dbg1IO "cli args with command first, if any" cliargswithcmdfirst
dbgIO "command argument found" clicmdarg
dbgIO "cli args before command" cliargsbeforecmd
dbgIO "cli args after command" cliargsaftercmd
dbgIO "cli command argument found" clicmdarg
dbgIO "cli args before command" cliargsbeforecmd
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).
-- 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
-- or a wrongly present/missing flag argument, cmd will be "".
---------------------------------------------------------------
dbgIO "\n2. Read the config file if any" ()
-- Identify any --conf/--no-conf options.
-- Run cmdargs on just the args that look conf-related.
let
rawopts1 = cmdargsParse
"for command name"
(mainmode addons)
cliargswithcmdfirstwithoutclispecific
cmd = stringopt "command" rawopts1
-- XXX better error message when cmdargs fails (eg spaced/quoted/malformed flag values) ?
nocmdprovided = null clicmdarg
badcmdprovided = null cmd && not nocmdprovided
isaddoncmd = not (null cmd) && cmd `elem` addons
-- isbuiltincmd = cmd `elem` builtinCommandNames
mcmdmodeaction = findBuiltinCommand cmd
effectivemode = maybe (mainmode []) fst mcmdmodeaction
cliconfargs = dropUnsupportedOpts confflagsmode cliargswithoutcmd
cliconfrawopts = cmdargsParse "for conf options" confflagsmode cliconfargs
-- Read extra general and command-specific args/opts from the config file, if any.
(conf, mconffile) <-
seq cliconfrawopts $ -- order debug output
getConf cliconfrawopts
---------------------------------------------------------------
dbgIO "\n3. Identify a command name from config file or command line" ()
-- 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
dbgIO1 "command found" cmd
dbgIO1 "command found" cmdname
dbgIO "no command provided" nocmdprovided
dbgIO "bad command provided" badcmdprovided
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.
(conf, mconffile) <- getConf rawopts2
let
genargsfromconf = confLookup "general" conf
addoncmdssupportinggenopts = ["ui", "web"] -- addons known to support hledger general options
supportedgenargsfromconf
| cmd `elem` addoncmdssupportinggenopts =
[a | a <- genargsfromconf, not $ any (`isPrefixOf` a) addoncmdssupportinggenopts]
| cmdname `elem` addoncmdssupportinggenopts =
[a | a <- confothergenargs, not $ any (`isPrefixOf` a) addoncmdssupportinggenopts]
| isaddoncmd = []
| otherwise = dropUnsupportedOpts effectivemode genargsfromconf
excludedgenargsfromconf = genargsfromconf \\ supportedgenargsfromconf
cmdargsfromconf
| null cmd = []
| otherwise = confLookup cmd conf & if isaddoncmd then ("--":) else id
| otherwise = dropUnsupportedOpts effectivemode confothergenargs
excludedgenargsfromconf = confothergenargs \\ supportedgenargsfromconf
confcmdargs
| null cmdname = []
| otherwise = confLookup cmdname conf & if isaddoncmd then ("--":) else id
when (isJust mconffile) $ do
dbgIO1 "using extra general args from config file" genargsfromconf
dbgIO1 "using general args from config file" confothergenargs
unless (null 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.
-- A bad flag or flag argument will cause the program to exit with an error here.
dbgIO "\n5. Combine config file and command line args" ()
dbgIO "\n3. Combine command line and config file args" ()
let
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
-- 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.
-- (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,
-- 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.)
let
helpFlag = boolopt "help" rawopts3
tldrFlag = boolopt "tldr" rawopts3
infoFlag = boolopt "info" rawopts3
manFlag = boolopt "man" rawopts3
versionFlag = boolopt "version" rawopts3
helpFlag = boolopt "help" rawopts
tldrFlag = boolopt "tldr" rawopts
infoFlag = boolopt "info" rawopts
manFlag = boolopt "man" rawopts
versionFlag = boolopt "version" rawopts
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 && tldrFlag -> runTldrForPage "hledger"
| nocmdprovided && infoFlag -> runInfoForTopic "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
-- 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"
-- 4.4. no command found, nothing else to do - show the commands list
| nocmdprovided -> dbgIO "" "no command, showing commands list" >> printCommandsList prognameandversion addons
-- 6.4. no command found, nothing else to do - show the commands list
| nocmdprovided -> dbgIO1 "no command, showing commands list" () >> printCommandsList prognameandversion addons
-- 4.5. builtin command found
| Just (cmdmode, cmdaction) <- mcmdmodeaction -> do
-- 6.5. builtin command found
| 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
opts <- rawOptsToCliOpts rawopts3 >>= \opts0 -> return opts0{progstarttime_=starttime}
opts <- rawOptsToCliOpts rawopts >>= \opts0 -> return opts0{progstarttime_=starttime}
dbgIO2 "processed opts" opts
dbgIO "period from opts" (period_ . _rsReportOpts $ reportspec_ opts)
dbgIO "interval from opts" (interval_ . _rsReportOpts $ reportspec_ opts)
dbgIO "query from opts & args" (_rsQuery $ reportspec_ opts)
let
mcmdname = headMay $ modeNames cmdmode
tldrpagename = maybe "hledger" (("hledger-"<>)) mcmdname
let tldrpagename = maybe "hledger" (("hledger-"<>)) mmodecmdname
-- run the builtin command according to its type
if
-- help/doc flag - show command help/docs
-- 6.5.1. help/doc flag - show command help/docs
| helpFlag -> pager $ showModeUsage cmdmode ++ "\n"
| tldrFlag -> runTldrForPage tldrpagename
| infoFlag -> runInfoForTopic "hledger" mcmdname
| manFlag -> runManForTopic "hledger" mcmdname
| infoFlag -> runInfoForTopic "hledger" mmodecmdname
| manFlag -> runManForTopic "hledger" mmodecmdname
-- builtin command which should not require or read the journal - run it
| cmd `elem` ["demo","help","test"] ->
cmdaction opts $ error' $ cmd++" tried to read the journal but is not supposed to"
-- 6.5.2. builtin command which should not require or read the journal - run it
| cmdname `elem` ["demo","help","test"] ->
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
| cmd `elem` ["add","import"] -> do
-- 6.5.3. builtin command which should create the journal if missing - do that and run it
| cmdname `elem` ["add","import"] -> do
ensureJournalFileExists . NE.head =<< journalFilePathFromOpts 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
-- 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
-- and any command-specific opts from the config file.
-- 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.
| isaddoncmd -> do
let
addonargs0 = filter (/="--") $ supportedgenargsfromconf <> cmdargsfromconf <> cliargswithoutcmd
addonargs0 = filter (/="--") $ supportedgenargsfromconf <> confcmdargs <> cliargswithoutcmd
addonargs = dropCliSpecificOpts addonargs0
shellcmd = printf "%s-%s %s" progname cmd (unwords' addonargs) :: String
dbgIO "addon command selected" cmd
shellcmd = printf "%s-%s %s" progname cmdname (unwords' addonargs) :: String
dbgIO "addon command selected" cmdname
dbgIO "addon command arguments after removing cli-specific opts" (map quoteIfNeeded addonargs)
dbgIO1 "running addon" shellcmd
system shellcmd >>= exitWith
-- 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 $
"could not understand the arguments "++show finalargs
<> if null genargsfromconf then "" else "\ngeneral arguments added from config file: "++show genargsfromconf
<> if null cmdargsfromconf then "" else "\ncommand arguments added from config file: "++show cmdargsfromconf
<> if null confothergenargs then "" else "\ngeneral arguments added from config file: "++show confothergenargs
<> 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.
when (ghcDebugMode == GDPauseAtEnd) $ ghcDebugPause'
@ -409,8 +433,8 @@ argsToCliOpts args addons = do
let
(_, _, args0) = moveFlagsAfterCommand args
args1 = replaceNumericFlags args0
rawopts3 = cmdargsParse "for options" (mainmode addons) args1
rawOptsToCliOpts rawopts3
rawopts = cmdargsParse "for options" (mainmode addons) args1
rawOptsToCliOpts rawopts
-- | Parse the given command line arguments/options with the given cmdargs mode,
-- after adding values to any valueless --debug flags,
@ -422,6 +446,7 @@ cmdargsParse desc m args0 = process m (ensureDebugFlagHasVal args0)
& either
(\e -> error' $ e <> " while parsing these args " <> desc <> ": " <> unwords (map quoteIfNeeded 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.
-- 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.
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.
For the special characters mentioned above, use one less level of quoting than
you would at the command prompt.)
Argument files are now superseded by..
If you use quotes, they must enclose the whole line.
For the special characters mentioned above, use one less level of quoting than you would at the command line.
## Config files
As of hledger 1.40, you can optionally save command line options (or arguments)
to be used when running hledger commands, in a config file. Here's a small example:
With hledger 1.40+, you can save extra command line options and arguments
in a more featureful hledger config file. Here's a small example:
```conf
# 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.
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
([#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*.
The config file feature was added in hledger 1.40 and is considered *experimental*.
## Shell completions
@ -741,7 +733,7 @@ or use `--real` to exclude transactions that use them.
#### Beancount costs
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