Skip to content

Commit

Permalink
Add global options to dispatchMulti commands per
Browse files Browse the repository at this point in the history
#198 ,
#128 , request by @ZoomRmc,
and probably others.  This is a big commit.  The main work was:

  - Adding `iterators ids` & `hasId` & using in `parseHelps`, `parseShorts`,
    and `dupBlock`

  - Adding 2 large sections in the main monster dispatchGen macro

This commit also:

  - Adds a single-command example as test/Vars.nim

  - Updates `test/FullyAutoMulti.nim` & `test/FullyAutoMulti.cf` with a
    new global `xx`

  - Updates reference output test/ref and gitignore for new test prog

  - Makes -d:debugMergeParams more useful and more completely used (since
    this helped update `test/FullyAutoMulti.nim` for global opts in cfs)

  - Updates test/PassValues to have a blank `vars` slot
  • Loading branch information
c-blake committed Jan 23, 2024
1 parent 3f77297 commit f584710
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 44 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ test/PassValues
test/PassValuesMulti
test/SpecifiedOverload
test/UserError
test/Vars
cligen/abbrev
cligen/dents
examples/chom
Expand Down
17 changes: 17 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ RELEASE NOTES

Version: 1.7.0
--------------
- New global options for `dispatchMulti` subcommands; Example use shown in
`test/FullyAutoMulti.nim` (including a local that masks the global and a way
to use it in the test/FullyAutoMulti.cf). Requested/discussed in at least:
https://github.com/c-blake/cligen/issues/198 &
https://github.com/c-blake/cligen/issues/128
personal communication with @ZoomRmc & @Vindaar
Presently, extra help for the global option is not auto-generated, but you
can manually document it in the `usage` for "multi" as in FullyAutoMulti.
I plan to let this simmer unreleased / untagged version for a while if
anyone has any API-changing feedback. In particular, right now `vars` is
NOT at the very end of `dispatchGen` & friends (to be positional-only call
compatible), but rather just after a similar series of ident-as-string args
(`positional`, `suppress`, `implicitDefault`). So, you may need to add an
`@[]` if you do call `dispatchGen`/`dispatch` positionally (which you most
likely do not). This slight backward incompatibility does motivate the
pending minor bump, though.

- cligen/mslice.initSep allows eliding backslash for special chars: 0, t, n

Version: 1.6.18
Expand Down
108 changes: 80 additions & 28 deletions cligen.nim
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ proc formalParams(n: NimNode, suppress: seq[NimNode]= @[]): NimNode =
return formalParamExpand(kid, n, suppress)
error "formalParams requires a proc argument."

iterator ids(vars: seq[NimNode], fpars: NimNode, posIx = 0): NimNode =
for v in vars: yield v # Search vars 1st so if name collides with
for i in 1 ..< len(fpars): #..formal param, more local latter wins.
if i != posIx: yield fpars[i][0] # ix cannot==0 => Like ix!=0 and etc

proc hasId(vars: seq[NimNode]; fpars, key: NimNode; posIx = 0): bool =
for id in ids(vars, fpars, posIx): (if id.maybeDestrop == key: return true)

iterator AListPairs(alist: NimNode, msg: string): (NimNode, NimNode) =
if alist.kind == nnkSym:
let imp = alist.getImpl
Expand All @@ -218,26 +226,27 @@ iterator AListPairs(alist: NimNode, msg: string): (NimNode, NimNode) =
else:
for ph in alist: yield (ph[1][0], ph[1][1])

proc parseHelps(helps: NimNode, proNm: auto, fpars: auto):
proc parseHelps(helps: NimNode, proNm: auto, vars: auto, fpars: auto):
Table[string, (string, string)] =
result = initTable[string, (string, string)]() #help key & text for any param
for ph in AListPairs(helps, "`help`"):
let k = ph[0].toString.optionNormalize
if not fpars.containsParam(ident(k)) and k notin builtinOptions:
if not hasId(vars, fpars, k.ident) and k notin builtinOptions:
error $proNm & " has no param matching `help` key \"" & k & "\""
result[k] = (ph[0].toString, ph[1].toString)

proc parseShorts(shorts: NimNode, proNm: auto, fpars: auto): Table[string,char]=
proc parseShorts(shorts: NimNode, proNm: auto, vars: auto, fpars: auto):
Table[string, char] =
result = initTable[string, char]() #table giving user-specified short option
for ls in AListPairs(shorts, "`short`"):
let k = ls[0].toString.optionNormalize
if k.len>0 and not fpars.containsParam(k.ident) and k notin builtinOptions:
if k.len>0 and not hasId(vars, fpars, k.ident) and k notin builtinOptions:
error $proNm & " has no param matching `short` key \"" & k & "\""
if ls[1].kind notin {nnkCharLit, nnkIntLit}:
error "`short` value for \"" & k & "\" not a `char` lit"
result[k] = if shorts.kind==nnkSym: ls[1].toInt.char else: ls[1].intVal.char

proc dupBlock(fpars: NimNode, posIx: int, userSpec: Table[string, char]):
proc dupBlock(vars:auto, fpars: auto, posIx: int, userSpec: Table[string,char]):
Table[string, char] = # Table giving short[param] avoiding collisions
result = initTable[string, char]() # short option for param
var used: set[char] = {} # used shorts; bit vector ok
Expand All @@ -248,19 +257,16 @@ proc dupBlock(fpars: NimNode, posIx: int, userSpec: Table[string, char]):
for lo, sh in userSpec:
result[lo] = sh
used.incl sh
for i in 1 ..< len(fpars): # [0] is proc, not desired here
if i == posIx: continue # positionals get no option char
let parNm = optionNormalize($fpars[i][0])
for id in ids(vars, fpars, posIx): # positionals get no option char
let parNm = optionNormalize($id)
if parNm.len == 1:
if parNm notin userSpec:
result[parNm] = parNm[0]
if parNm[0] in used and result[parNm] != parNm[0]:
error "cannot use unabbreviated param name '" &
$parNm[0] & "' as a short option"
error "cannot use unabbreviated name '" & $parNm[0]&"' as a short option"
used.incl parNm[0]
for i in 1 ..< len(fpars): # [0] is proc, not desired here
if i == posIx: continue # positionals get no option char
let parNm = optionNormalize($fpars[i][0])
for id in ids(vars, fpars, posIx): # positionals get no option char
let parNm = optionNormalize($id)
if parNm.len == 1 and parNm[0] == result["help"]:
error "`"&parNm&"` collides with `short[\"help\"]`. Change help short."
let sh = parNm[0] # abbreviation is 1st character
Expand Down Expand Up @@ -302,8 +308,8 @@ macro dispatchGen*(pro: typed{nkSym}, cmdName: string="", doc: string="",
help: typed={}, short: typed={}, usage: string=clUse, cf: ClCfg=clCfg,
echoResult=false, noAutoEcho=false, positional: static string=AUTO,
suppress: seq[string] = @[], implicitDefault: seq[string] = @[],
dispatchName="", mergeNames: seq[string] = @[], alias: seq[ClAlias] = @[],
stopWords: seq[string] = @[], noHdr=false,
vars: seq[string] = @[], dispatchName="", mergeNames: seq[string] = @[],
alias: seq[ClAlias] = @[], stopWords: seq[string] = @[], noHdr=false,
docs: ptr var seq[string]=cgVarSeqStrNil,
setByParse: ptr var seq[ClParse]=cgSetByParseNil): untyped =
##Generate command-line dispatcher for proc ``pro`` named ``dispatchName``
Expand Down Expand Up @@ -347,6 +353,9 @@ macro dispatchGen*(pro: typed{nkSym}, cmdName: string="", doc: string="",
##to the Nim default value for a type, rather than becoming required, when
##they lack an explicit initializer.
##
##``vars`` is a ``seq[string]`` of outer scope-declared variables bound to the
##CLI (e.g., on CL, `--logLvl=X` converts & assigns to `outer.logLvl = X`).
##
##``stopWords`` is a ``seq[string]`` of words beyond which ``-.*`` no longer
##signifies an option (like the common sole ``--`` command argument).
##
Expand Down Expand Up @@ -379,6 +388,7 @@ macro dispatchGen*(pro: typed{nkSym}, cmdName: string="", doc: string="",
let impl = pro.getImpl
if impl == nil: error "getImpl(" & $pro & ") returned nil."
let fpars = formalParams(impl, toIdSeq(suppress))
let vrs = toIdSeq(vars)
var cmtDoc = toString(doc)
if cmtDoc.len == 0: # allow caller to override commentDoc
collectComments(cmtDoc, impl)
Expand All @@ -388,9 +398,9 @@ macro dispatchGen*(pro: typed{nkSym}, cmdName: string="", doc: string="",
let proNm = $pro # Name of wrapped proc
let cName = if cmdName.toString.len == 0: proNm else: cmdName.toString
let disNm = dispatchId(dispatchName.toString, cName, proNm) # Name of wrapper
let helps = parseHelps(help, proNm, fpars)
let helps = parseHelps(help, proNm, vrs, fpars)
let posIx = posIxGet(positional, fpars) #param slot for positional cmd args|-1
let shOpt = dupBlock(fpars, posIx, parseShorts(short, proNm, fpars))
let shOpt = dupBlock(vrs, fpars, posIx, parseShorts(short, proNm, vrs, fpars))
let shortH = shOpt["help"]
var spars = copyNimTree(fpars) # Create shadow/safe suffixed params.
var dpars = copyNimTree(fpars) # Create default suffixed params.
Expand Down Expand Up @@ -512,6 +522,19 @@ macro dispatchGen*(pro: typed{nkSym}, cmdName: string="", doc: string="",
`apId`.parReq = 0; `apId`.parRend = `helpVsn`[0]
if `helpVsn`[1] != `cf`.hTabSuppress:
`tabId`.add(argHelp(false, `apId`) & `helpVsn`[1]))
for parId in vrs: # Handle user-specified variables
let pNm = optionNormalize($parId)
let sh = $shOpt.getOrDefault(pNm) # Add to perPar helpTab
let hky = helps.getOrDefault(pNm)[0]
let hlp = helps.getOrDefault(pNm)[1]
result.add(quote do:
`apId`.parNm = `pNm`; `apId`.parSh = `sh`; `apId`.parReq = ord(false)
`apId`.parRend = if `hky`.len>0: `hky` else: helpCase(`pNm`, clLongOpt)
let descr = getDescription(`parId`, `pNm`, `hlp`)
if descr != `cf`.hTabSuppress:
`tabId`.add(argHelp(`parId`, `apId`) & mayRend(descr))
`cbId`.incl(`pNm`, move(`apId`.parRend))
`allId`.add(helpCase(`pNm`, clLongOpt)))
if aliasDefL.strVal.len > 0 and aliasRefL.strVal.len > 0:
result.add(quote do: # add opts for user alias system
`cbId`.incl(optionNormalize(`aliasDefL`), `aliasDefL`)
Expand Down Expand Up @@ -674,6 +697,34 @@ macro dispatchGen*(pro: typed{nkSym}, cmdName: string="", doc: string="",
newStrLitNode(lopt), newStrLitNode(parShOpt)).add(apCall))
else: # only a long option
result.add(newNimNode(nnkOfBranch).add(newStrLitNode(lopt)).add(apCall))
for parId in vrs: # add per-var cases
let parNm = $parId #XXX This COULD consult a saved-
let lopt = optionNormalize(parNm) # .. above table of used case
let apCall = quote do: # .. labels and give user a nicer
`apId`.key = `pId`.key # .. error message than "dup case
`apId`.val = `pId`.val # .. labels".
`apId`.sep = `pId`.sep
`apId`.parNm = `parNm`
`apId`.parRend = helpCase(`parNm`, clLongOpt)
`keyCountId`.inc(`parNm`)
`apId`.parCount = `keyCountId`[`parNm`]
if cast[pointer](`setByParseId`) != cgSetByParseNil:
if argParse(`parId`, `parId`, `apId`):
`setByParseId`[].add((`parNm`, move(`pId`.val), "", clOk))
else:
`setByParseId`[].add((`parNm`, move(`pId`.val),
"Cannot parse arg to " & `apId`.key, clBadVal))
if not `prsOnlyId`:
if not argParse(`parId`, `parId`, `apId`):
stderr.write `apId`.msg
raise newException(ParseError, "Cannot parse arg to " & `apId`.key)
discard delItem(`mandId`, `parNm`)
if lopt in shOpt and lopt.len > 1: # both a long and short option
let parShOpt = $shOpt.getOrDefault(lopt)
result.add(newNimNode(nnkOfBranch).add(
newStrLitNode(lopt), newStrLitNode(parShOpt)).add(apCall))
else: # only a long option
result.add(newNimNode(nnkOfBranch).add(newStrLitNode(lopt)).add(apCall))
let ambigReport = quote do:
let ks = `cbId`.valsWithPfx(p.key)
let msg=("Ambiguous long option prefix \"" & `b0` & "$1" & `b1` & "\"" &
Expand Down Expand Up @@ -841,26 +892,27 @@ macro cligenQuitAux*(cmdLine:seq[string], dispatchName: string, cmdName: string,
template dispatchCf*(pro: typed{nkSym}, cmdName="", doc="", help: typed={},
short:typed={},usage=clUse, cf:ClCfg=clCfg,echoResult=false,noAutoEcho=false,
positional=AUTO, suppress:seq[string] = @[], implicitDefault:seq[string] = @[],
dispatchName="", mergeNames: seq[string] = @[], alias: seq[ClAlias] = @[],
stopWords:seq[string] = @[],noHdr=false,cmdLine=commandLineParams()): untyped =
vars: seq[string] = @[], dispatchName="", mergeNames: seq[string] = @[],
alias: seq[ClAlias] = @[], stopWords:seq[string] = @[], noHdr=false,
cmdLine=commandLineParams()): untyped =
## A convenience wrapper to both generate a command-line dispatcher and then
## call the dispatcher & exit; Params are same as the ``dispatchGen`` macro.
dispatchGen(pro, cmdName, doc, help, short, usage, cf, echoResult, noAutoEcho,
positional, suppress, implicitDefault, dispatchName, mergeNames,
alias, stopWords, noHdr)
positional, suppress, implicitDefault, vars, dispatchName,
mergeNames, alias, stopWords, noHdr)
cligenQuitAux(cmdLine, dispatchName, cmdName, pro, echoResult, noAutoEcho)

template dispatch*(pro: typed{nkSym}, cmdName="", doc="", help: typed={},
short:typed={},usage=clUse,echoResult=false,noAutoEcho=false,positional=AUTO,
suppress:seq[string] = @[], implicitDefault:seq[string] = @[], dispatchName="",
mergeNames: seq[string] = @[], alias: seq[ClAlias] = @[],
stopWords: seq[string] = @[], noHdr=false): untyped =
suppress: seq[string] = @[], implicitDefault: seq[string] = @[],
vars: seq[string] = @[], dispatchName="", mergeNames: seq[string] = @[],
alias: seq[ClAlias] = @[], stopWords: seq[string] = @[], noHdr=false): untyped=
## Convenience `dispatchCf` wrapper to silence bogus GcUnsafe warnings at
## verbosity:2. Parameters are the same as `dispatchCf` (except for no `cf`).
proc cligenScope(cf: ClCfg) =
dispatchCf(pro, cmdName, doc, help, short, usage, cf, echoResult, noAutoEcho,
positional, suppress, implicitDefault, dispatchName, mergeNames,
alias, stopWords, noHdr)
positional, suppress, implicitDefault, vars, dispatchName,
mergeNames, alias, stopWords, noHdr)
cligenScope(clCfg)

proc subCmdName(p: NimNode): string =
Expand Down Expand Up @@ -1009,7 +1061,7 @@ macro dispatchMultiGen*(procBkts: varargs[untyped]): untyped =
cases.add(newNimNode(nnkElse).add(quote do:
if `arg0Id` == "":
if `cmdLineId`.len > 0: ambigSubcommand(`subMchsId`, `cmdLineId`[0])
else: echo topLevelHelp(`doc`, `use`,`cmd`,`subCmdsId`, `subDocsId`)
else: echo topLevelHelp(`doc`, `use`,`cmd`, `subCmdsId`, `subDocsId`)
elif `arg0Id` == "help":
if ("dispatch" & `prefix`) in `multiNmsId` and `prefix` != "multi":
echo (" $1 $2 {SUBCMD} [subsubcommand-opts & args]\n" &
Expand Down Expand Up @@ -1168,7 +1220,7 @@ template initFromCLcf*[T](default: T, cmdName: string="", doc: string="",
proc callIt(): T =
initGen(default, T, positional, suppress, "ini")
dispatchGen(ini, cmdName, doc, help, short, usage, cf, false, false, AUTO,
@[], @[], "x", mergeNames, alias)
@[], @[], @[], "x", mergeNames, alias)
try: result = x()
except HelpOnly, VersionOnly: quit(0)
except ParseError: quits(cgParseErrorExitCode)
Expand Down
1 change: 1 addition & 0 deletions cligen/mergeCfgEnv.nim
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ proc mergeParams(cmdNames: seq[string],
result.add cfToCL(cfPath, if cmdNames.len > 1: cmdNames[1] else: "")
result.add envToCL(strutils.toUpperAscii(strutils.join(cmdNames, "_")))
result.add cmdLine
when defined(debugMergeParams): echo "mergeParams returned: ", result
{.pop.}
# Leave hint[Performance]=off as this seems required to avoid warnings.
1 change: 1 addition & 0 deletions cligen/mergeCfgEnvMulMul.nim
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ proc mergeParams(cmdNames: seq[string],
result.add cfToCL(cfPath, if cmdNames.len > 1: cmdNames[1] else: "")
result.add envToCL(strutils.toUpperAscii(strutils.join(cmdNames, "_")))
result.add cmdLine
when defined(debugMergeParams): echo "mergeParams returned: ", result
{.pop.}
# Leave hint[Performance]=off as this seems required to avoid warnings.
1 change: 1 addition & 0 deletions cligen/mergeCfgEnvMulti.nim
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ proc mergeParams(cmdNames: seq[string],
result.add cfToCL(cfPath, if cmdNames.len > 1: cmdNames[1] else: "")
result.add envToCL(strutils.toUpperAscii(strutils.join(cmdNames, "_")))
result.add cmdLine
when defined(debugMergeParams): echo "mergeParams returned: ", result
{.pop.}
# Leave hint[Performance]=off as this seems required to avoid warnings.
3 changes: 3 additions & 0 deletions test/FullyAutoMulti.cf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
# Note that both key = val and --key=val styles are supported and keys are both
# style and kebab insensitive, but single-dash short options do not work.

[_]
xx = 4

[print]
gamma = 9
--iota:3.0
Expand Down
16 changes: 9 additions & 7 deletions test/FullyAutoMulti.nim
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@
## Some more description.

when not declared(addFloat): import std/formatfloat
var xx = 3 # An outer scope variable, e.g. logLvl

proc demo(alpha=1, beta=2.0, verb=false, item="", files: seq[string]) =
## demo entry point with varied, meaningless parameters.
echo "alpha:", alpha, " beta:", beta, " verb:", verb, " item:", item
for i, f in files: echo "args[", i, "]: ", f

proc show(gamma=1, iota=2.0, verb=false, paths: seq[string]): int =
## show entry point with varied, meaningless parameters.
echo "gamma:", gamma, " iota:", iota, " verb:", verb
proc show(gamma=1, iota=2.0, verb=false, xx=3.0, paths: seq[string]): int =
## show entry point with varied, meaningless parameters; Local `xx`.
echo "gamma:", gamma, " iota:", iota, " verb:", verb, " xx: ",xx
for i, p in paths: echo "args[", i, "]: ", p
return 42

proc punt(zeta=1, eta=2.0, verb=false, names: seq[string]): int =
## Another entry point; here we echoResult
## Another entry point; here we echoResult & use global `xx`.
echo "zeta:", zeta, " eta:", eta, " verb:", verb
for i, n in names: echo "args[", i, "]: ", n
return 12345
if xx > 3: 12345 else: 54321

proc nel_Ly(hooves=4, races=9, verb=false, names: seq[string]): string =
## Yet another entry point; here we block autoEcho
Expand All @@ -37,7 +38,7 @@ when isMainModule:
let docLine = nimbleFile.fromNimble("description") & "\n\n"

let topLvlUse = """${doc}Usage:
$command {SUBCMD} [sub-command options & parameters]
$command [-x|--xx=int(2)] {SUBCMD} [sub-command options & parameters]
SUBCMDs:
$subcmds
Expand All @@ -52,7 +53,8 @@ Run "$command help" to get *comprehensive* help.$ifVersion"""
var noVsn = clCfg
{.pop.}
noVsn.version = ""
dispatchMulti([ "multi", doc = docLine, usage = topLvlUse ],
dispatchMulti([ "multi", doc = docLine, usage = topLvlUse, vars = @["xx"],
mergeNames = @[ "FullyAutoMulti", "_" ] ],
[ demo, help = { "verb": "on=chatty, off=quiet" } ],
[ show, cmdName="print", short = { "gamma": 'z' } ],
[ punt, echoResult=true, cf=noVsn ],
Expand Down
2 changes: 1 addition & 1 deletion test/PassValues.nim
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ when isMainModule:
const dispatchName = "demoCL" # This1 tested via --expandMacro:dispatchGen

dispatch(demo, cmdName, doc, help, short, usage, echoResult, noAutoEcho,
positional, suppress, implicitDefault, dispatchName)
positional, suppress, implicitDefault, @[], dispatchName)
13 changes: 13 additions & 0 deletions test/Vars.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
when not declared(addFloat): import std/formatfloat
var bo_go = 1

proc demo(be_ta=2.0, verb=false, item="", args: seq[string]) =
## demo entry point with varied, meaningless parameters and a global. A Nim
## invocation might be: `bogo=2; demo(@[ "hi", "ho" ])` corresponding to the
## CL invocation "demo --bogo=2 hi ho" (assuming executable is named "demo").
echo "bogo:", boGo, " beta:", beta, " verb:", verb, " item:", item
for i, arg in args: echo "positional[", i, "]: ", arg

when isMainModule: import cligen; dispatch demo, vars = @["bOgo"],
short={"b-ogo": 'z', "b-eta": '\0'},
help={"bOGO": "growth constant", "be-ta": "shrink target"}
Loading

2 comments on commit f584710

@ZoomRmc
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great news!
I really like that you call a 150/44 commit "big", btw.

@c-blake
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may not be so big in "absolute delta terms", but it does make macro dispatchGen that much more unwieldy. Or, erm, I mean, if I put on my marketing hat "I meant that it's BIG functionality!" { or "flavor" if it were a food, maybe. ;-) }

Please sign in to comment.