positional arg parsing revamp

This commit is contained in:
Daylin Morgan 2024-11-18 08:49:54 -06:00
parent 938f9d411d
commit 8f98b53b91
Signed by: daylin
GPG key ID: 950D13E9719334AD
11 changed files with 273 additions and 46 deletions

3
.gitignore vendored
View file

@ -1,6 +1,3 @@
tests/*
!tests/*.nim
!tests/*.nims
nimble.develop nimble.develop
nimble.paths nimble.paths
nimbledeps nimbledeps

View file

@ -3,6 +3,7 @@ import std/[os, strformat, strutils]
task test, "run tests": task test, "run tests":
selfExec "r tests/tbbansi.nim" selfExec "r tests/tbbansi.nim"
selfExec "r tests/tcli.nim" selfExec "r tests/tcli.nim"
selfExec "r tests/cli/tcli.nim"
task develop, "install cligen for development": task develop, "install cligen for development":
exec "nimble install -l 'cligen@1.7.5'" exec "nimble install -l 'cligen@1.7.5'"

View file

@ -3,6 +3,7 @@
]## ]##
import std/[ import std/[
algorithm,
macros, os, sequtils, macros, os, sequtils,
sets, strutils, tables, sets, strutils, tables,
sugar sugar
@ -149,7 +150,9 @@ type
GenerateOnly, ## Don't attach root `runProc()` node GenerateOnly, ## Don't attach root `runProc()` node
NoHelpFlag, ## Remove the builtin help flag NoHelpFlag, ## Remove the builtin help flag
ShowHelp, ## If cmdline empty show help ShowHelp, ## If cmdline empty show help
NoNormalize ## Don't normalize flags and commands NoNormalize, ## Don't normalize flags and commands
NoPositional ## Raise error if any remaing positional arguments
ExactArgs, ## Raise error if missing positional argument
BuiltinFlag = object BuiltinFlag = object
name*: string name*: string
@ -179,6 +182,9 @@ type
header*, footer*, description*, usage*, styles*: NimNode header*, footer*, description*, usage*, styles*: NimNode
CliArg = object CliArg = object
name: string
ident: NimNode
typeNode: NimNode
CliCfg = object CliCfg = object
name*: string name*: string
@ -200,6 +206,11 @@ type
inherit*: Inherit inherit*: Inherit
root*: bool root*: bool
func err(c: CliCfg, msg: string) =
## quit with error while generating cli
error "\nfailed to generate '" & c.name & "' hwylcli: \n" & msg
template `<<<`(s: string) {.used.} = template `<<<`(s: string) {.used.} =
let pos = instantiationInfo() let pos = instantiationInfo()
debugEcho "$1:$2" % [pos.filename, $pos.line] debugEcho "$1:$2" % [pos.filename, $pos.line]
@ -211,7 +222,6 @@ template `<<<`(n: NimNode) {.used.} =
## for debugging macros ## for debugging macros
<<< treeRepr n <<< treeRepr n
func `<<<`(f: CliFlag) {.used.}= func `<<<`(f: CliFlag) {.used.}=
var s: string var s: string
let fields = [ let fields = [
@ -246,6 +256,7 @@ func getFlagParamNode(node: NimNode): NimNode =
result = node[1] result = node[1]
else: bad(node, "flag param") else: bad(node, "flag param")
# TODO: also accept the form `flag: "help"`
func parseFlagParams(f: var CliFlag, node: NimNode) = func parseFlagParams(f: var CliFlag, node: NimNode) =
expectKind node, nnkStmtList expectKind node, nnkStmtList
for n in node: for n in node:
@ -307,6 +318,12 @@ func parseCliFlag(n: NimNode): CliFlag =
result.typeNode = ident"bool" result.typeNode = ident"bool"
func postParse(cfg: var CliCfg) = func postParse(cfg: var CliCfg) =
if cfg.name == "":
error "missing required option: name"
if cfg.args.len != 0 and cfg.subcommands.len != 0:
error "args and subcommands are mutually exclusive"
let defaultTypeNode = cfg.defaultFlagType or ident"bool" let defaultTypeNode = cfg.defaultFlagType or ident"bool"
for f in cfg.flagDefs.mitems: for f in cfg.flagDefs.mitems:
if f.typeNode == nil: if f.typeNode == nil:
@ -314,6 +331,11 @@ func postParse(cfg: var CliCfg) =
if f.group in ["", "global"]: if f.group in ["", "global"]:
cfg.flags.add f cfg.flags.add f
if cfg.args.len > 0:
let count = cfg.args.filterIt(it.typeNode.kind == nnkBracketExpr).len
if count > 1:
cfg.err "more than one positional argument is variadic"
func parseCliFlags(cfg: var CliCfg, node: NimNode) = func parseCliFlags(cfg: var CliCfg, node: NimNode) =
var group: string var group: string
expectKind node, nnkStmtList expectKind node, nnkStmtList
@ -514,11 +536,7 @@ func pasrseCliAlias(cfg: var CliCfg, node: NimNode) =
cfg.alias.incl s cfg.alias.incl s
else: bad(n, "alias") else: bad(n, "alias")
func err(c: CliCfg, msg: string) = func postPropagateCheck(c: CliCfg) =
## quit with error while generating cli
error "failed to generate " & c.name & " hwylcli: \n" & msg
func check(c: CliCfg) =
## verify the cli is valid ## verify the cli is valid
var var
short: Table[char, CliFlag] short: Table[char, CliFlag]
@ -553,7 +571,7 @@ func propagate(c: var CliCfg) =
child.post = c.postSub child.post = c.postSub
child.inheritFrom(c) child.inheritFrom(c)
propagate child propagate child
check child postPropagateCheck child
func parseCliHelp(c: var CliCfg, node: NimNode) = func parseCliHelp(c: var CliCfg, node: NimNode) =
@ -588,7 +606,6 @@ func parseCliHelp(c: var CliCfg, node: NimNode) =
of nnkCall: of nnkCall:
if node[1].kind != nnkStmtList: if node[1].kind != nnkStmtList:
error "expected list of arguments for help" error "expected list of arguments for help"
for n in node[1]: for n in node[1]:
expectLen n, 2 expectLen n, 2
let id = n[0].strVal let id = n[0].strVal
@ -599,7 +616,6 @@ func parseCliHelp(c: var CliCfg, node: NimNode) =
of nnKCall: of nnKCall:
val = n[1][0] val = n[1][0]
else: bad(n, id) else: bad(n, id)
case id: case id:
of "usage": help.usage = val of "usage": help.usage = val
of "description": help.description = val of "description": help.description = val
@ -607,11 +623,46 @@ func parseCliHelp(c: var CliCfg, node: NimNode) =
of "footer": help.footer = val of "footer": help.footer = val
of "styles": help.styles = val of "styles": help.styles = val
else: error "unknown help option: " & id else: error "unknown help option: " & id
else: bad(node, "help") else: bad(node, "help")
c.help = help c.help = help
func badNode(c: CliCfg, node: NimNode, msg: string) =
c.err "unexpected node kind: " & $node.kind & "\n" & msg
func parseCliArg(c: CliCfg, node: NimNode): CliArg =
expectLen node, 2
result.name = node[0].strVal
case node[1].kind
of nnkStmtList:
for n in node[1]:
let id = n[0].strVal
var val: NimNode
case n.kind:
of nnkCommand:
val = n[1]
of nnkCall:
# input seq[string]
if n[1].len == 2:
result.typeNode = n[1][1]
val = n[1][0]
else: bad(n, id)
case id:
of "T": result.typeNode = val
of "ident": result.ident = val
else: c.err("unknown cli param: " & id & "provided for arg: " & result.name)
of nnkIdent, nnkBracketExpr:
result.typeNode = node[1]
else:
c.badNode(node[1], "parsing cli arg: " & result.name)
if result.ident == nil:
result.ident = ident(result.name)
func parseCliArgs(c: var CliCfg, node: NimNode) =
if node.kind != nnkStmtList:
bad(node, "expected node kind nnkStmtList")
for n in node:
c.args.add parseCliArg(c, n)
func parseCliBody(body: NimNode, name = "", root = false): CliCfg = func parseCliBody(body: NimNode, name = "", root = false): CliCfg =
result.name = name result.name = name
result.root = root result.root = root
@ -652,13 +703,13 @@ func parseCliBody(body: NimNode, name = "", root = false): CliCfg =
result.postSub = node[1] result.postSub = node[1]
of "defaultFlagType": of "defaultFlagType":
result.defaultFlagType = node[1] result.defaultFlagType = node[1]
of "args":
parseCliArgs result, node[1]
else: else:
error "unknown hwylCli setting: " & name error "unknown hwylCli setting: " & name
if result.name == "":
error "missing required option: name"
postParse result postParse result
# TODO: validate "required" flags exist here? # TODO: validate "required" flags exist here?
result.addBuiltinFlags() result.addBuiltinFlags()
@ -700,6 +751,7 @@ proc hwylCliError*(msg: string) =
quit $(bb("error ", "red") & bb(msg)) quit $(bb("error ", "red") & bb(msg))
func defaultUsage(cfg: CliCfg): NimNode = func defaultUsage(cfg: CliCfg): NimNode =
# TODO: attempt to handle pos args
var s = "[b]" & cfg.name & "[/]" var s = "[b]" & cfg.name & "[/]"
if cfg.subcommands.len > 0: if cfg.subcommands.len > 0:
s.add " [bold italic]subcmd[/]" s.add " [bold italic]subcmd[/]"
@ -887,8 +939,8 @@ func getNoVals(cfg: CliCfg): tuple[long: NimNode, short: NimNode] =
) )
result = (nnkPrefix.newTree(ident"@",long), short) result = (nnkPrefix.newTree(ident"@",long), short)
func setFlagVars(cfg: CliCfg): NimNode = func setVars(cfg: CliCfg): NimNode =
## generate all variables not covered in global module ## generate all positinal variables and flags not covered in global module
result = nnkVarSection.newTree() result = nnkVarSection.newTree()
let flags = let flags =
if cfg.root: cfg.flags if cfg.root: cfg.flags
@ -897,6 +949,10 @@ func setFlagVars(cfg: CliCfg): NimNode =
result.add flags.mapIt( result.add flags.mapIt(
nnkIdentDefs.newTree(it.ident, it.typeNode, newEmptyNode()) nnkIdentDefs.newTree(it.ident, it.typeNode, newEmptyNode())
) )
if cfg.args.len > 0:
result.add cfg.args.mapIt(
nnkIdentDefs.newTree(it.ident, it.typeNode, newEmptyNode())
)
func literalFlags(f: CliFlag): NimNode = func literalFlags(f: CliFlag): NimNode =
var flags: seq[string] var flags: seq[string]
@ -904,7 +960,116 @@ func literalFlags(f: CliFlag): NimNode =
if f.long != "": flags.add "[b]" & "--" & f.long & "[/]" if f.long != "": flags.add "[b]" & "--" & f.long & "[/]"
result = newLit(flags.join("|")) result = newLit(flags.join("|"))
func addPostParseCheck(cfg: CliCfg, body: NimNode) = type
MultiArgKind = enum
NoMulti, ## No positionals use seq[[T]]
First, ## First positional uses seq[[T]]
Last, ## Last positional uses seq[[T]]
func getMultiArgKind(cfg: CliCfg): MultiArgKind =
if cfg.args.len == 1:
return First
if cfg.args[0].typeNode.kind == nnkBracketExpr:
return First
if cfg.args[^1].typeNode.kind == nnkBracketExpr:
return Last
func parseArgs(p: OptParser, target: var string) =
target = p.key
func parseArgs[T](p: OptParser, target: var seq[T]) =
var val: T
parseArgs(p, val)
target.add val
proc parseArgs*(arg: string, target: var float) =
try: target = parseFloat(arg)
except: hwylCliError("failed to parse as float: [b]" & arg)
func parseArgs*(arg: string, target: var string) =
target = arg
proc parseArgs*(arg: string, target: var int) =
try: target = parseInt(arg)
except: hwylCliError("failed to parse as integer: [b]" & arg)
proc parseArgs*[E: enum](arg: string, target: var E) =
try: target = parseEnum[E](arg)
except:
let choices = enumNames(E).join(",")
hwylCliError("failed to parse as enum: [b]" & arg & "[/], expected one of: " & choices)
proc parseArgs*[T](arg: string, target: var seq[T]) =
var val: T
parseArgs(arg, val)
target.add val
proc parseArgs*[T](args: seq[string], target: var seq[T]) =
for arg in args:
parseArgs(arg, target)
# TODO: rework conditionals and control flow here...
func genPosArgHandler(cfg: CliCfg, body: NimNode) =
## generate code to handle positional arguments
let numArgs = cfg.args.len
let maKind = cfg.getMultiArgKind()
if ExactArgs in cfg.settings:
case maKind:
of NoMulti:
body.add quote do:
if result.len != `numArgs`:
hwylCliError("missing positional args, got: " & $result.len & ", expected: " & $`numArgs`)
else:
body.add quote do:
if result.len < `numArgs`:
hwylCliError("missing positional args, got: " & $result.len & ", expected: " & $`numArgs`)
elif maKind == First:
body.add quote do:
if result.len < `numArgs`:
hwylCliError("missing positional args, got: " & $result.len & ", expected at least: " & $`numArgs`)
elif maKind == Last:
body.add quote do:
if result.len < (`numArgs` - 1):
hwylCliError("missing positional args, got: " & $result.len & ", expected at least: " & $(`numArgs` - 1))
case maKind:
# BUG: this may create index defects,
# if not coupled with ExactArgs or result length checks
of Last:
for i, namedArg in cfg.args[0..^2].mapIt(it.ident):
body.add quote do:
parseArgs(result[`i`], `namedArg`)
let lastArg = cfg.args[^1].ident
body.add quote do:
parseArgs(result[(`numArgs`-1).. ^1],`lastArg`)
of First:
for i, namedArg in cfg.args[1..^1].reversed().mapIt(it.ident):
body.add quote do:
parseArgs(result[^(1+`i`)], `namedArg`)
let firstArg = cfg.args[0].ident
body.add quote do:
parseArgs(result[0..^(`numArgs`)], `firstArg`)
of NoMulti:
for i, namedArg in cfg.args.mapIt(it.name.ident):
body.add quote do:
parseArgs(result[`i`], `namedArg`)
# clear out 'args'
if ExactArgs in cfg.settings:
if maKind == NoMulti:
body.add quote do:
result = @[(`numArgs`)..^1]
else:
body.add quote do:
result = @[`numArgs`..^1]
func addPostParseHook(cfg: CliCfg, body: NimNode) =
## generate block to set defaults and check for required flags ## generate block to set defaults and check for required flags
let flagSet = ident"flagSet" let flagSet = ident"flagSet"
var required, default: seq[CliFlag] var required, default: seq[CliFlag]
@ -931,6 +1096,10 @@ func addPostParseCheck(cfg: CliCfg, body: NimNode) =
if `name` notin `flagSet`: if `name` notin `flagSet`:
`target` = `default` `target` = `default`
if cfg.args.len > 0:
genPosArgHandler cfg, body
func hwylCliImpl(cfg: CliCfg): NimNode func hwylCliImpl(cfg: CliCfg): NimNode
func genSubcommandHandler(cfg: CliCfg): NimNode = func genSubcommandHandler(cfg: CliCfg): NimNode =
@ -961,16 +1130,11 @@ func genSubcommandHandler(cfg: CliCfg): NimNode =
result.add subCommandCase result.add subCommandCase
func parseArgs(p: OptParser, target: var string) = # TODO: collect all strings into a seq and handle prior to subcomamnd parsing?
target = p.key # subcommands are really just a special case of positional args handling
func positionalArgsOfBranch(cfg: CliCfg): NimNode =
func parseArgs[T](p: OptParser, target: var seq[T]) =
var val: T
parseArgs(p, val)
target.add val
func argOfBranch(cfg: CliCfg): NimNode =
result = nnkOfBranch.newTree(ident"cmdArgument") result = nnkOfBranch.newTree(ident"cmdArgument")
# TODO: utilize the NoPositional setting here?
# if cfg.args.len == 0 and cfg.subcommands.len == 0: # if cfg.args.len == 0 and cfg.subcommands.len == 0:
# result.add quote do: # result.add quote do:
# hwylCliError("unexpected positional argument: [b]" & p.key) # hwylCliError("unexpected positional argument: [b]" & p.key)
@ -979,7 +1143,6 @@ func argOfBranch(cfg: CliCfg): NimNode =
inc nArgs inc nArgs
parseArgs(p, result) parseArgs(p, result)
func hwylCliImpl(cfg: CliCfg): NimNode = func hwylCliImpl(cfg: CliCfg): NimNode =
let let
version = cfg.version or newLit("") version = cfg.version or newLit("")
@ -993,9 +1156,7 @@ func hwylCliImpl(cfg: CliCfg): NimNode =
nArgs = ident"nargs" nArgs = ident"nargs"
(longNoVal, shortNoVal) = cfg.getNoVals() (longNoVal, shortNoVal) = cfg.getNoVals()
printHelpProc = generateCliHelpProc(cfg, printHelpName) printHelpProc = generateCliHelpProc(cfg, printHelpName)
flagVars = setFlagVars(cfg) flagVars = setVars(cfg)
result = newTree(nnkStmtList)
var var
parserBody = nnkStmtList.newTree() parserBody = nnkStmtList.newTree()
@ -1035,8 +1196,8 @@ func hwylCliImpl(cfg: CliCfg): NimNode =
nnkCaseStmt.newTree( nnkCaseStmt.newTree(
ident"kind", ident"kind",
nnkOfBranch.newTree(ident("cmdError"), quote do: hwylCliError(p.message)), nnkOfBranch.newTree(ident("cmdError"), quote do: hwylCliError(p.message)),
nnkOfBranch.newTree(ident("cmdEnd"), quote do: assert false), nnkOfBranch.newTree(ident("cmdEnd"), quote do: hwylCliError("reached cmdEnd unexpectedly.")),
argOfBranch(cfg), positionalArgsOfBranch(cfg),
nnkOfBranch.newTree( nnkOfBranch.newTree(
ident("cmdShortOption"), ident("cmdLongOption"), ident("cmdShortOption"), ident("cmdLongOption"),
shortLongCaseStmt(cfg, printHelpName, version) shortLongCaseStmt(cfg, printHelpName, version)
@ -1050,9 +1211,11 @@ func hwylCliImpl(cfg: CliCfg): NimNode =
if commandLineParams().len == 0: if commandLineParams().len == 0:
`printHelpName`(); quit 1 `printHelpName`(); quit 1
addPostParseHook(cfg, parserBody)
let runProcName = ident("run" & name) let runProcName = ident("run" & name)
let runBody = nnkStmtList.newTree() let runBody = nnkStmtList.newTree()
addPostParseCheck(cfg, parserBody)
# move to proc? # move to proc?
if cfg.pre != nil: if cfg.pre != nil:
runBody.add cfg.pre runBody.add cfg.pre
@ -1061,9 +1224,12 @@ func hwylCliImpl(cfg: CliCfg): NimNode =
if cfg.post != nil: if cfg.post != nil:
runBody.add cfg.post runBody.add cfg.post
# args and subcommands need to be mutually exclusive -> implement using a CommandKind?
if cfg.subcommands.len > 0: if cfg.subcommands.len > 0:
runBody.add genSubcommandHandler(cfg) runBody.add genSubcommandHandler(cfg)
result = newTree(nnkStmtList)
result.add quote do: result.add quote do:
# block: # block:
`printHelpProc` `printHelpProc`
@ -1083,7 +1249,6 @@ func hwylCliImpl(cfg: CliCfg): NimNode =
result.add quote do: result.add quote do:
`runProcName`(`args`[1..^1]) `runProcName`(`args`[1..^1])
macro hwylCli*(body: untyped) = macro hwylCli*(body: untyped) =
## generate a CLI styled by `hwylterm` and parsed by `parseopt3` ## generate a CLI styled by `hwylterm` and parsed by `parseopt3`
var cfg = parseCliBody(body, root = true) var cfg = parseCliBody(body, root = true)

5
test.nim Normal file
View file

@ -0,0 +1,5 @@
import std/macros
dumpAstGen:
block:
echo "hello world"

4
tests/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*
!*.nim
!*.nims
!cli/

4
tests/cli/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*
!refs/*
!*.nim
!*.nims

1
tests/cli/config.nims Normal file
View file

@ -0,0 +1 @@
switch("path", "$projectDir/../../src")

29
tests/cli/lib.nim Normal file
View file

@ -0,0 +1,29 @@
import std/[compilesettings, os, osproc, strutils, times, unittest]
const pathToSrc = querySetting(SingleValueSetting.projectPath)
const binDir = pathToSrc / "bin"
const hwylCliSrc = pathToSrc / "../../src/hwylterm/hwylcli.nim"
let hwylCliWriteTime = getFileInfo(hwylCliSrc).lastWriteTime
if not dirExists(binDir):
createDir(binDir)
proc runTestCli(module: string, args: string, code: int = 0): string =
let cmd = binDir / module & " " & args
let (output, exitCode) = execCmdEx(cmd)
check code == exitCode
result = output.strip()
proc preCompileWorkingModule(module: string) =
let exe = binDir / module
let srcModule = pathToSrc / "clis" / (module & ".nim")
if not exe.fileExists or getFileInfo(exe).lastWriteTime < max(getFileInfo(srcModule).lastWriteTime, hwylCliWriteTime):
let cmd = "nim c -o:$1 $2" % [exe, srcModule]
let code = execCmd(cmd)
if code != 0:
echo "cmd: ", cmd
quit "failed to precompile test module"
proc checkRunWithArgs*(module: string, args = "", output = "", code = 0) =
preCompileWorkingModule(module)
check output == runTestCli(module, args, code)

11
tests/cli/tcli.nim Normal file
View file

@ -0,0 +1,11 @@
import std/[unittest]
import ./lib
suite "hwylcli":
test "positionals":
checkRunWithArgs("posFirst", "a b c d e","""first=@["a", "b", "c"], second=d, third=e""")
checkRunWithArgs("posFirst", "a b", "error missing positional args, got: 2, expected at least: 3", code = 1)
checkRunWithArgs("posLast", "a b", """first=a, second=b, third=@[]""")
checkRunWithArgs("posLastExact", "a b c d e", """first=a, second=b, third=@["c", "d", "e"]""")
checkRunWithArgs("posNoMulti", "5 b c", """first=5, second=b, third=c""")
checkRunWithArgs("posNoMulti", "5 b c d", """error missing positional args, got: 4, expected: 3""", code = 1)

View file

@ -70,6 +70,16 @@ hwylCli:
a longer mulitline description that will be visible in the subcommand help a longer mulitline description that will be visible in the subcommand help
and it will automatically be "bb"'ed [bold]this is bold text[/] and it will automatically be "bb"'ed [bold]this is bold text[/]
""" """
# args first, second
# or
args:
# default type is string
# only one 'arg' can be the seq[string]
# order matters here
# by default string
inputs:
T int
second seq[string]
flags: flags:
^something ^something
thing: thing:

View file

@ -1,21 +1,21 @@
# TODO: combine this with tests/cli/
import std/[ import std/[
unittest unittest,
strutils
] ]
import hwylterm, hwylterm/hwylcli import hwylterm, hwylterm/hwylcli
suite "cli": suite "cli":
test "cli": test "cli":
let expected = """[b]test-program[/] [[args...] let expected = """[b]test-program[/] [[args...]
[b cyan]flags[/]: [bold cyan]flags[/]:
[yellow]-h[/] [magenta]--help [/] []show this help[/] [yellow]-h[/yellow] [magenta]--help [/magenta] []show this help[/]
[yellow]-V[/] [magenta]--version[/] []print version[/] [yellow]-V[/yellow] [magenta]--version[/magenta] []print version[/]"""
"""
let cli = let cli =
newHwylCliHelp( newHwylCliHelp(
header = "[b]test-program[/] [[args...]", header = "[b]test-program[/] [[args...]",
flags = [("h","help","show this help",),("V","version","print version")] flags = [("h","help","show this help",),("V","version","print version")]
) )
check $bb(cli) == $bb(expected) check render(cli) == expected
check $bb(render(cli)) == $bb(expected)