1099 lines
34 KiB
Nim
1099 lines
34 KiB
Nim
import
|
|
std/[options, strutils, wordwrap],
|
|
stew/shims/macros,
|
|
confutils/[defs, cli_parser, config_file]
|
|
|
|
export
|
|
defs, config_file, options
|
|
|
|
const
|
|
useBufferedOutput = defined(nimscript)
|
|
noColors = useBufferedOutput or defined(confutils_no_colors)
|
|
hasCompletions = not defined(nimscript)
|
|
descPadding = 6
|
|
minNameWidth = 24 - descPadding
|
|
|
|
when not defined(nimscript):
|
|
import
|
|
os, terminal,
|
|
confutils/shell_completion
|
|
|
|
type
|
|
HelpAppInfo = ref object
|
|
appInvocation: string
|
|
copyrightBanner: string
|
|
hasAbbrs: bool
|
|
maxNameLen: int
|
|
terminalWidth: int
|
|
namesWidth: int
|
|
|
|
CmdInfo = ref object
|
|
name: string
|
|
desc: string
|
|
isHidden: bool
|
|
opts: seq[OptInfo]
|
|
|
|
OptKind = enum
|
|
Discriminator
|
|
CliSwitch
|
|
Arg
|
|
|
|
OptInfo = ref object
|
|
name, abbr, desc, typename: string
|
|
idx: int
|
|
isHidden: bool
|
|
hasDefault: bool
|
|
defaultInHelpText: string
|
|
case kind: OptKind
|
|
of Discriminator:
|
|
isCommand: bool
|
|
isImplicitlySelectable: bool
|
|
subCmds: seq[CmdInfo]
|
|
defaultSubCmd: int
|
|
else:
|
|
discard
|
|
|
|
const
|
|
confutils_description_width {.intdefine.} = 80
|
|
confutils_narrow_terminal_width {.intdefine.} = 36
|
|
|
|
proc getFieldName(caseField: NimNode): NimNode =
|
|
result = caseField
|
|
if result.kind == nnkIdentDefs: result = result[0]
|
|
if result.kind == nnkPragmaExpr: result = result[0]
|
|
if result.kind == nnkPostfix: result = result[1]
|
|
|
|
when defined(nimscript):
|
|
func scriptNameParamIdx: int =
|
|
for i in 1 ..< paramCount():
|
|
var param = paramStr(i)
|
|
if param.len > 0 and param[0] != '-':
|
|
return i
|
|
|
|
proc appInvocation: string =
|
|
let scriptNameIdx = scriptNameParamIdx()
|
|
"nim " & (if paramCount() > scriptNameIdx: paramStr(scriptNameIdx) else: "<nims-script>")
|
|
|
|
type stderr = object
|
|
|
|
template writeLine(T: type stderr, msg: string) =
|
|
echo msg
|
|
|
|
proc commandLineParams(): seq[string] =
|
|
for i in scriptNameParamIdx() + 1 .. paramCount():
|
|
result.add paramStr(i)
|
|
|
|
# TODO: Why isn't this available in NimScript?
|
|
proc getCurrentExceptionMsg(): string =
|
|
""
|
|
|
|
template terminalWidth: int =
|
|
100000
|
|
|
|
else:
|
|
template appInvocation: string =
|
|
getAppFilename().splitFile.name
|
|
|
|
when noColors:
|
|
const
|
|
styleBright = ""
|
|
fgYellow = ""
|
|
fgWhite = ""
|
|
fgGreen = ""
|
|
fgCyan = ""
|
|
fgBlue = ""
|
|
resetStyle = ""
|
|
|
|
when useBufferedOutput:
|
|
template helpOutput(args: varargs[string]) =
|
|
for arg in args:
|
|
help.add arg
|
|
|
|
template errorOutput(args: varargs[string]) =
|
|
helpOutput(args)
|
|
|
|
template flushOutput =
|
|
echo help
|
|
|
|
else:
|
|
template errorOutput(args: varargs[untyped]) =
|
|
styledWrite stderr, args
|
|
|
|
template helpOutput(args: varargs[untyped]) =
|
|
styledWrite stdout, args
|
|
|
|
template flushOutput =
|
|
discard
|
|
|
|
const
|
|
fgSection = fgYellow
|
|
fgDefault = fgWhite
|
|
fgCommand = fgCyan
|
|
fgOption = fgBlue
|
|
fgArg = fgBlue
|
|
|
|
# TODO: Start using these:
|
|
# fgValue = fgGreen
|
|
# fgType = fgYellow
|
|
|
|
template flushOutputAndQuit(exitCode: int) =
|
|
flushOutput
|
|
quit exitCode
|
|
|
|
func isCliSwitch(opt: OptInfo): bool =
|
|
opt.kind == CliSwitch or
|
|
(opt.kind == Discriminator and opt.isCommand == false)
|
|
|
|
func hasOpts(cmd: CmdInfo): bool =
|
|
for opt in cmd.opts:
|
|
if opt.isCliSwitch and not opt.isHidden:
|
|
return true
|
|
|
|
return false
|
|
|
|
func hasArgs(cmd: CmdInfo): bool =
|
|
cmd.opts.len > 0 and cmd.opts[^1].kind == Arg
|
|
|
|
func firstArgIdx(cmd: CmdInfo): int =
|
|
# This will work correctly only if the command has arguments.
|
|
result = cmd.opts.len - 1
|
|
while result > 0:
|
|
if cmd.opts[result - 1].kind != Arg:
|
|
return
|
|
dec result
|
|
|
|
iterator args(cmd: CmdInfo): OptInfo =
|
|
if cmd.hasArgs:
|
|
for i in cmd.firstArgIdx ..< cmd.opts.len:
|
|
yield cmd.opts[i]
|
|
|
|
func getSubCmdDiscriminator(cmd: CmdInfo): OptInfo =
|
|
for i in countdown(cmd.opts.len - 1, 0):
|
|
let opt = cmd.opts[i]
|
|
if opt.kind != Arg:
|
|
if opt.kind == Discriminator and opt.isCommand:
|
|
return opt
|
|
else:
|
|
return nil
|
|
|
|
template hasSubCommands(cmd: CmdInfo): bool =
|
|
getSubCmdDiscriminator(cmd) != nil
|
|
|
|
iterator subCmds(cmd: CmdInfo): CmdInfo =
|
|
let subCmdDiscriminator = cmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil:
|
|
for cmd in subCmdDiscriminator.subCmds:
|
|
yield cmd
|
|
|
|
template isSubCommand(cmd: CmdInfo): bool =
|
|
cmd.name.len > 0
|
|
|
|
func maxNameLen(cmd: CmdInfo): int =
|
|
result = 0
|
|
for opt in cmd.opts:
|
|
if opt.kind == Arg or opt.kind == Discriminator and opt.isCommand:
|
|
continue
|
|
result = max(result, opt.name.len)
|
|
if opt.kind == Discriminator:
|
|
for subCmd in opt.subCmds:
|
|
result = max(result, subCmd.maxNameLen)
|
|
|
|
func hasAbbrs(cmd: CmdInfo): bool =
|
|
for opt in cmd.opts:
|
|
if opt.kind == Arg or opt.kind == Discriminator and opt.isCommand:
|
|
continue
|
|
if opt.abbr.len > 0:
|
|
return true
|
|
if opt.kind == Discriminator:
|
|
for subCmd in opt.subCmds:
|
|
if hasAbbrs(subCmd):
|
|
return true
|
|
|
|
func humaneName(opt: OptInfo): string =
|
|
if opt.name.len > 0: opt.name
|
|
else: opt.abbr
|
|
|
|
template padding(output: string, desiredWidth: int): string =
|
|
spaces(max(desiredWidth - output.len, 0))
|
|
|
|
proc writeDesc(help: var string,
|
|
appInfo: HelpAppInfo,
|
|
desc, defaultValue: string) =
|
|
const descSpacing = " "
|
|
let
|
|
descIndent = (5 + appInfo.namesWidth + descSpacing.len)
|
|
remainingColumns = appInfo.terminalWidth - descIndent
|
|
defaultValSuffix = if defaultValue.len == 0: ""
|
|
else: " [=" & defaultValue & "]"
|
|
fullDesc = desc & defaultValSuffix & "."
|
|
|
|
if remainingColumns < confutils_narrow_terminal_width:
|
|
helpOutput "\p ", wrapWords(fullDesc, appInfo.terminalWidth - 2,
|
|
newLine = "\p ")
|
|
else:
|
|
let wrappingWidth = min(remainingColumns, confutils_description_width)
|
|
helpOutput descSpacing, wrapWords(fullDesc, wrappingWidth,
|
|
newLine = "\p" & spaces(descIndent))
|
|
|
|
proc describeInvocation(help: var string,
|
|
cmd: CmdInfo, cmdInvocation: string,
|
|
appInfo: HelpAppInfo) =
|
|
helpOutput styleBright, "\p", fgCommand, cmdInvocation
|
|
var longestArg = 0
|
|
|
|
if cmd.opts.len > 0:
|
|
if cmd.hasOpts: helpOutput " [OPTIONS]..."
|
|
|
|
let subCmdDiscriminator = cmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil: helpOutput " command"
|
|
|
|
for arg in cmd.args:
|
|
helpOutput " <", arg.name, ">"
|
|
longestArg = max(longestArg, arg.name.len)
|
|
|
|
helpOutput "\p"
|
|
|
|
if cmd.desc.len > 0:
|
|
helpOutput "\p", cmd.desc, ".\p"
|
|
|
|
var argsSectionStarted = false
|
|
|
|
for arg in cmd.args:
|
|
if arg.desc.len > 0:
|
|
if not argsSectionStarted:
|
|
helpOutput "\p"
|
|
argsSectionStarted = true
|
|
|
|
let cliArg = " <" & arg.humaneName & ">"
|
|
helpOutput fgArg, styleBright, cliArg
|
|
helpOutput padding(cliArg, 6 + appInfo.namesWidth)
|
|
help.writeDesc appInfo, arg.desc, arg.defaultInHelpText
|
|
helpOutput "\p"
|
|
|
|
type
|
|
OptionsType = enum
|
|
normalOpts
|
|
defaultCmdOpts
|
|
conditionalOpts
|
|
|
|
proc describeOptions(help: var string,
|
|
cmd: CmdInfo, cmdInvocation: string,
|
|
appInfo: HelpAppInfo, optionsType = normalOpts) =
|
|
if cmd.hasOpts:
|
|
case optionsType
|
|
of normalOpts:
|
|
helpOutput "\pThe following options are available:\p\p"
|
|
of conditionalOpts:
|
|
helpOutput ", the following additional options are available:\p\p"
|
|
of defaultCmdOpts:
|
|
discard
|
|
|
|
for opt in cmd.opts:
|
|
if opt.kind == Arg or
|
|
opt.kind == Discriminator or
|
|
opt.isHidden: continue
|
|
|
|
# Indent all command-line switches
|
|
helpOutput " "
|
|
|
|
if opt.abbr.len > 0:
|
|
helpOutput fgOption, styleBright, "-", opt.abbr, ", "
|
|
elif appInfo.hasAbbrs:
|
|
# Add additional indentatition, so all names are aligned
|
|
helpOutput " "
|
|
|
|
if opt.name.len > 0:
|
|
let switch = "--" & opt.name
|
|
helpOutput fgOption, styleBright,
|
|
switch, padding(switch, appInfo.namesWidth)
|
|
else:
|
|
helpOutput spaces(2 + appInfo.namesWidth)
|
|
|
|
if opt.desc.len > 0:
|
|
help.writeDesc appInfo,
|
|
opt.desc.replace("%t", opt.typename),
|
|
opt.defaultInHelpText
|
|
|
|
helpOutput "\p"
|
|
|
|
if opt.kind == Discriminator:
|
|
for i, subCmd in opt.subCmds:
|
|
if not subCmd.hasOpts: continue
|
|
|
|
helpOutput "\pWhen ", styleBright, fgBlue, opt.humaneName, resetStyle, " = ", fgGreen, subCmd.name
|
|
|
|
if i == opt.defaultSubCmd: helpOutput " (default)"
|
|
help.describeOptions subCmd, cmdInvocation, appInfo, conditionalOpts
|
|
|
|
let subCmdDiscriminator = cmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil:
|
|
let defaultCmdIdx = subCmdDiscriminator.defaultSubCmd
|
|
if defaultCmdIdx != -1:
|
|
let defaultCmd = subCmdDiscriminator.subCmds[defaultCmdIdx]
|
|
help.describeOptions defaultCmd, cmdInvocation, appInfo, defaultCmdOpts
|
|
|
|
helpOutput fgSection, "\pAvailable sub-commands:\p"
|
|
|
|
for i, subCmd in subCmdDiscriminator.subCmds:
|
|
if i != subCmdDiscriminator.defaultSubCmd:
|
|
let subCmdInvocation = cmdInvocation & " " & subCmd.name
|
|
help.describeInvocation subCmd, subCmdInvocation, appInfo
|
|
help.describeOptions subCmd, subCmdInvocation, appInfo
|
|
|
|
proc showHelp(help: var string,
|
|
appInfo: HelpAppInfo,
|
|
activeCmds: openarray[CmdInfo]) =
|
|
if appInfo.copyrightBanner.len > 0:
|
|
helpOutput appInfo.copyrightBanner, "\p\p"
|
|
|
|
let cmd = activeCmds[^1]
|
|
|
|
appInfo.maxNameLen = cmd.maxNameLen
|
|
appInfo.hasAbbrs = cmd.hasAbbrs
|
|
appInfo.terminalWidth = terminalWidth()
|
|
appInfo.namesWidth = min(minNameWidth, appInfo.maxNameLen) + descPadding
|
|
|
|
var cmdInvocation = appInfo.appInvocation
|
|
for i in 1 ..< activeCmds.len:
|
|
cmdInvocation.add " "
|
|
cmdInvocation.add activeCmds[i].name
|
|
|
|
# Write out the app or script name
|
|
helpOutput fgSection, "Usage: \p"
|
|
help.describeInvocation cmd, cmdInvocation, appInfo
|
|
help.describeOptions cmd, cmdInvocation, appInfo
|
|
helpOutput "\p"
|
|
|
|
flushOutputAndQuit QuitSuccess
|
|
|
|
func getNextArgIdx(cmd: CmdInfo, consumedArgIdx: int): int =
|
|
for i in 0 ..< cmd.opts.len:
|
|
if cmd.opts[i].kind == Arg and cmd.opts[i].idx > consumedArgIdx:
|
|
return cmd.opts[i].idx
|
|
|
|
return -1
|
|
|
|
proc noMoreArgsError(cmd: CmdInfo): string =
|
|
result = if cmd.isSubCommand: "The command '$1'" % [cmd.name]
|
|
else: appInvocation()
|
|
result.add " does not accept"
|
|
if cmd.hasArgs: result.add " additional"
|
|
result.add " arguments"
|
|
|
|
proc findOpt(opts: openarray[OptInfo], name: string): OptInfo =
|
|
for opt in opts:
|
|
if cmpIgnoreStyle(opt.name, name) == 0 or
|
|
cmpIgnoreStyle(opt.abbr, name) == 0:
|
|
return opt
|
|
|
|
proc findOpt(activeCmds: openarray[CmdInfo], name: string): OptInfo =
|
|
for i in countdown(activeCmds.len - 1, 0):
|
|
let found = findOpt(activeCmds[i].opts, name)
|
|
if found != nil: return found
|
|
|
|
proc findCmd(cmds: openarray[CmdInfo], name: string): CmdInfo =
|
|
for cmd in cmds:
|
|
if cmpIgnoreStyle(cmd.name, name) == 0:
|
|
return cmd
|
|
|
|
proc findSubCmd(cmd: CmdInfo, name: string): CmdInfo =
|
|
let subCmdDiscriminator = cmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil:
|
|
let cmd = findCmd(subCmdDiscriminator.subCmds, name)
|
|
if cmd != nil: return cmd
|
|
|
|
return nil
|
|
|
|
proc startsWithIgnoreStyle(s: string, prefix: string): bool =
|
|
# Similar in spirit to cmpIgnoreStyle, but compare only the prefix.
|
|
var i = 0
|
|
var j = 0
|
|
|
|
while true:
|
|
# Skip any underscore
|
|
while i < s.len and s[i] == '_': inc i
|
|
while j < prefix.len and prefix[j] == '_': inc j
|
|
|
|
if j == prefix.len:
|
|
# The whole prefix matches
|
|
return true
|
|
elif i == s.len:
|
|
# We've reached the end of `s` without matching the prefix
|
|
return false
|
|
elif toLowerAscii(s[i]) != toLowerAscii(prefix[j]):
|
|
return false
|
|
|
|
inc i
|
|
inc j
|
|
|
|
when defined(debugCmdTree):
|
|
proc printCmdTree(cmd: CmdInfo, indent = 0) =
|
|
let blanks = spaces(indent)
|
|
echo blanks, "> ", cmd.name
|
|
|
|
for opt in cmd.opts:
|
|
if opt.kind == Discriminator:
|
|
for subcmd in opt.subCmds:
|
|
printCmdTree(subcmd, indent + 2)
|
|
else:
|
|
echo blanks, " - ", opt.name, ": ", opt.typename
|
|
|
|
else:
|
|
template printCmdTree(cmd: CmdInfo) = discard
|
|
|
|
# TODO remove the overloads here to get better "missing overload" error message
|
|
proc parseCmdArg*(T: type InputDir, p: TaintedString): T =
|
|
if not dirExists(p.string):
|
|
raise newException(ValueError, "Directory doesn't exist")
|
|
|
|
result = T(p)
|
|
|
|
proc parseCmdArg*(T: type InputFile, p: TaintedString): T =
|
|
# TODO this is needed only because InputFile cannot be made
|
|
# an alias of TypedInputFile at the moment, because of a generics
|
|
# caching issue
|
|
if not fileExists(p.string):
|
|
raise newException(ValueError, "File doesn't exist")
|
|
|
|
when not defined(nimscript):
|
|
try:
|
|
let f = system.open(p.string, fmRead)
|
|
close f
|
|
except IOError:
|
|
raise newException(ValueError, "File not accessible")
|
|
|
|
result = T(p.string)
|
|
|
|
proc parseCmdArg*(T: type TypedInputFile, p: TaintedString): T =
|
|
var path = p.string
|
|
when T.defaultExt.len > 0:
|
|
path = path.addFileExt(T.defaultExt)
|
|
|
|
if not fileExists(path):
|
|
raise newException(ValueError, "File doesn't exist")
|
|
|
|
when not defined(nimscript):
|
|
try:
|
|
let f = system.open(path, fmRead)
|
|
close f
|
|
except IOError:
|
|
raise newException(ValueError, "File not accessible")
|
|
|
|
result = T(path)
|
|
|
|
proc parseCmdArg*(T: type[OutDir|OutFile|OutPath], p: TaintedString): T =
|
|
result = T(p)
|
|
|
|
proc parseCmdArg*[T](_: type Option[T], s: TaintedString): Option[T] =
|
|
return some(parseCmdArg(T, s))
|
|
|
|
template parseCmdArg*(T: type string, s: TaintedString): string =
|
|
string s
|
|
|
|
proc parseCmdArg*(T: type SomeSignedInt, s: TaintedString): T =
|
|
T parseInt(string s)
|
|
|
|
proc parseCmdArg*(T: type SomeUnsignedInt, s: TaintedString): T =
|
|
T parseUInt(string s)
|
|
|
|
proc parseCmdArg*(T: type SomeFloat, p: TaintedString): T =
|
|
result = parseFloat(p)
|
|
|
|
proc parseCmdArg*(T: type bool, p: TaintedString): T =
|
|
try:
|
|
result = p.len == 0 or parseBool(p)
|
|
except CatchableError:
|
|
raise newException(ValueError, "'" & p.string & "' is not a valid boolean value. Supported values are on/off, yes/no, true/false or 1/0")
|
|
|
|
proc parseCmdArg*(T: type enum, s: TaintedString): T =
|
|
parseEnum[T](string(s))
|
|
|
|
proc parseCmdArgAux(T: type, s: TaintedString): T = # {.raises: [ValueError].} =
|
|
# The parseCmdArg procs are allowed to raise only `ValueError`.
|
|
# If you have provided your own specializations, please handle
|
|
# all other exception types.
|
|
mixin parseCmdArg
|
|
parseCmdArg(T, s)
|
|
|
|
proc completeCmdArg*(T: type enum, val: TaintedString): seq[string] =
|
|
for e in low(T)..high(T):
|
|
let as_str = $e
|
|
if startsWithIgnoreStyle(as_str, val):
|
|
result.add($e)
|
|
|
|
proc completeCmdArg*(T: type SomeNumber, val: TaintedString): seq[string] =
|
|
return @[]
|
|
|
|
proc completeCmdArg*(T: type bool, val: TaintedString): seq[string] =
|
|
return @[]
|
|
|
|
proc completeCmdArg*(T: type string, val: TaintedString): seq[string] =
|
|
return @[]
|
|
|
|
proc completeCmdArg*(T: type[InputFile|TypedInputFile|InputDir|OutFile|OutDir|OutPath],
|
|
val: TaintedString): seq[string] =
|
|
when not defined(nimscript):
|
|
let (dir, name, ext) = splitFile(val)
|
|
let tail = name & ext
|
|
# Expand the directory component for the directory walker routine
|
|
let dir_path = if dir == "": "." else: expandTilde(dir)
|
|
# Dotfiles are hidden unless the user entered a dot as prefix
|
|
let show_dotfiles = len(name) > 0 and name[0] == '.'
|
|
|
|
try:
|
|
for kind, path in walkDir(dir_path, relative=true):
|
|
if not show_dotfiles and path[0] == '.':
|
|
continue
|
|
|
|
# Do not show files if asked for directories, on the other hand we must show
|
|
# directories even if a file is requested to allow the user to select a file
|
|
# inside those
|
|
if type(T) is (InputDir or OutDir) and kind notin {pcDir, pcLinkToDir}:
|
|
continue
|
|
|
|
# Note, no normalization is needed here
|
|
if path.startsWith(tail):
|
|
var match = dir_path / path
|
|
# Add a trailing slash so that completions can be chained
|
|
if kind in {pcDir, pcLinkToDir}:
|
|
match &= DirSep
|
|
|
|
result.add(shellPathEscape(match))
|
|
except OSError:
|
|
discard
|
|
|
|
proc completeCmdArg*[T](_: type seq[T], val: TaintedString): seq[string] =
|
|
return @[]
|
|
|
|
proc completeCmdArg*[T](_: type Option[T], val: TaintedString): seq[string] =
|
|
mixin completeCmdArg
|
|
return completeCmdArg(type(T), val)
|
|
|
|
proc completeCmdArgAux(T: type, val: TaintedString): seq[string] =
|
|
mixin completeCmdArg
|
|
return completeCmdArg(T, val)
|
|
|
|
template setField[T](loc: var T, val: Option[TaintedString], defaultVal: untyped) =
|
|
type FieldType = type(loc)
|
|
loc = if isSome(val): parseCmdArgAux(FieldType, val.get)
|
|
else: FieldType(defaultVal)
|
|
|
|
template setField[T](loc: var seq[T], val: Option[TaintedString], defaultVal: untyped) =
|
|
if val.isSome:
|
|
loc.add parseCmdArgAux(type(loc[0]), val.get)
|
|
else:
|
|
type FieldType = type(loc)
|
|
loc = FieldType(defaultVal)
|
|
|
|
proc makeDefaultValue*(T: type): T =
|
|
discard
|
|
|
|
proc requiresInput*(T: type): bool =
|
|
not ((T is seq) or (T is Option) or (T is bool))
|
|
|
|
proc acceptsMultipleValues*(T: type): bool =
|
|
T is seq
|
|
|
|
template debugMacroResult(macroName: string) {.dirty.} =
|
|
when defined(debugMacros) or defined(debugConfutils):
|
|
echo "\n-------- ", macroName, " ----------------------"
|
|
echo result.repr
|
|
|
|
proc generateFieldSetters(RecordType: NimNode): NimNode =
|
|
var recordDef = getImpl(RecordType)
|
|
let makeDefaultValue = bindSym"makeDefaultValue"
|
|
|
|
result = newTree(nnkStmtListExpr)
|
|
var settersArray = newTree(nnkBracket)
|
|
|
|
for field in recordFields(recordDef):
|
|
var
|
|
setterName = ident($field.name & "Setter")
|
|
fieldName = field.name
|
|
namePragma = field.readPragma"name"
|
|
paramName = if namePragma != nil: namePragma
|
|
else: fieldName
|
|
configVar = ident "config"
|
|
configField = newTree(nnkDotExpr, configVar, fieldName)
|
|
defaultValue = field.readPragma"defaultValue"
|
|
completerName = ident($field.name & "Complete")
|
|
|
|
if defaultValue == nil:
|
|
defaultValue = newCall(makeDefaultValue, newTree(nnkTypeOfExpr, configField))
|
|
|
|
# TODO: This shouldn't be necessary. The type symbol returned from Nim should
|
|
# be typed as a tyTypeDesc[tyString] instead of just `tyString`. To be filed.
|
|
var fixedFieldType = newTree(nnkTypeOfExpr, field.typ)
|
|
|
|
settersArray.add newTree(nnkTupleConstr,
|
|
newLit($paramName),
|
|
setterName, completerName,
|
|
newCall(bindSym"requiresInput", fixedFieldType),
|
|
newCall(bindSym"acceptsMultipleValues", fixedFieldType))
|
|
|
|
result.add quote do:
|
|
proc `completerName`(val: TaintedString): seq[string] {.
|
|
nimcall
|
|
gcsafe
|
|
sideEffect
|
|
raises: [Defect]
|
|
.} =
|
|
return completeCmdArgAux(`fixedFieldType`, val)
|
|
|
|
proc `setterName`(`configVar`: var `RecordType`, val: Option[TaintedString]) {.
|
|
nimcall
|
|
gcsafe
|
|
sideEffect
|
|
raises: [Defect, CatchableError]
|
|
.} =
|
|
when `configField` is enum:
|
|
# TODO: For some reason, the normal `setField` rejects enum fields
|
|
# when they are used as case discriminators. File this as a bug.
|
|
if isSome(val):
|
|
`configField` = parseEnum[type(`configField`)](string(val.get))
|
|
else:
|
|
`configField` = `defaultValue`
|
|
else:
|
|
setField(`configField`, val, `defaultValue`)
|
|
|
|
result.add settersArray
|
|
debugMacroResult "Field Setters"
|
|
|
|
proc cmdInfoFromType(T: NimNode): CmdInfo =
|
|
result = CmdInfo()
|
|
|
|
var
|
|
recordDef = getImpl(T)
|
|
discriminatorFields = newSeq[OptInfo]()
|
|
fieldIdx = 0
|
|
|
|
for field in recordFields(recordDef):
|
|
let
|
|
isImplicitlySelectable = field.readPragma"implicitlySelectable" != nil
|
|
defaultValue = field.readPragma"defaultValue"
|
|
defaultValueDesc = field.readPragma"defaultValueDesc"
|
|
defaultInHelp = if defaultValueDesc != nil: defaultValueDesc
|
|
else: defaultValue
|
|
defaultInHelpText = if defaultInHelp == nil: ""
|
|
elif defaultInHelp.kind in {nnkStrLit..nnkTripleStrLit}: defaultInHelp.strVal
|
|
else: repr(defaultInHelp)
|
|
isHidden = field.readPragma("hidden") != nil
|
|
abbr = field.readPragma"abbr"
|
|
name = field.readPragma"name"
|
|
desc = field.readPragma"desc"
|
|
optKind = if field.isDiscriminator: Discriminator
|
|
elif field.readPragma("argument") != nil: Arg
|
|
else: CliSwitch
|
|
|
|
var opt = OptInfo(kind: optKind,
|
|
idx: fieldIdx,
|
|
name: $field.name,
|
|
isHidden: isHidden,
|
|
hasDefault: defaultValue != nil,
|
|
defaultInHelpText: defaultInHelpText,
|
|
typename: field.typ.repr)
|
|
|
|
if desc != nil: opt.desc = desc.strVal
|
|
if name != nil: opt.name = name.strVal
|
|
if abbr != nil: opt.abbr = abbr.strVal
|
|
|
|
inc fieldIdx
|
|
|
|
if field.isDiscriminator:
|
|
discriminatorFields.add opt
|
|
let cmdType = field.typ.getImpl[^1]
|
|
if cmdType.kind != nnkEnumTy:
|
|
error "Only enums are supported as case object discriminators", field.name
|
|
|
|
opt.isImplicitlySelectable = isImplicitlySelectable
|
|
opt.isCommand = field.readPragma"command" != nil
|
|
|
|
for i in 1 ..< cmdType.len:
|
|
let enumVal = cmdType[i]
|
|
var name, desc: string
|
|
if enumVal.kind == nnkEnumFieldDef:
|
|
name = $enumVal[0]
|
|
desc = $enumVal[1]
|
|
else:
|
|
name = $enumVal
|
|
if defaultValue != nil and eqIdent(name, defaultValue):
|
|
opt.defaultSubCmd = i - 1
|
|
opt.subCmds.add CmdInfo(name: name, desc: desc)
|
|
|
|
if defaultValue == nil:
|
|
opt.defaultSubCmd = -1
|
|
else:
|
|
if opt.defaultSubCmd == -1:
|
|
error "The default value is not a valid enum value", defaultValue
|
|
|
|
if field.caseField != nil and field.caseBranch != nil:
|
|
let fieldName = field.caseField.getFieldName
|
|
var discriminator = findOpt(discriminatorFields, $fieldName)
|
|
|
|
if discriminator == nil:
|
|
error "Unable to find " & $fieldName
|
|
|
|
if field.caseBranch.kind == nnkElse:
|
|
error "Sub-command parameters cannot appear in an else branch. " &
|
|
"Please specify the sub-command branch precisely", field.caseBranch[0]
|
|
|
|
var branchEnumVal = field.caseBranch[0]
|
|
if branchEnumVal.kind == nnkDotExpr:
|
|
branchEnumVal = branchEnumVal[1]
|
|
var cmd = findCmd(discriminator.subCmds, $branchEnumVal)
|
|
cmd.opts.add opt
|
|
|
|
else:
|
|
result.opts.add opt
|
|
|
|
macro configurationRtti(RecordType: type): untyped =
|
|
let
|
|
T = RecordType.getType[1]
|
|
cmdInfo = cmdInfoFromType T
|
|
fieldSetters = generateFieldSetters T
|
|
|
|
result = newTree(nnkPar, newLitFixed cmdInfo, fieldSetters)
|
|
|
|
proc load*(Configuration: type,
|
|
cmdLine = commandLineParams(),
|
|
version = "",
|
|
copyrightBanner = "",
|
|
printUsage = true,
|
|
quitOnFailure = true): Configuration =
|
|
## Loads a program configuration by parsing command-line arguments
|
|
## and a standard set of config files that can specify:
|
|
##
|
|
## - working directory settings
|
|
## - user settings
|
|
## - system-wide setttings
|
|
##
|
|
## Supports multiple config files format (INI/TOML, YAML, JSON).
|
|
|
|
# This is an initial naive implementation that will be improved
|
|
# over time.
|
|
|
|
# users can override default `appendConfigFileFormats`
|
|
# `appName`, and `vendorName`
|
|
mixin appendConfigFileFormats
|
|
mixin appName, vendorName
|
|
appendConfigFileFormats(Configuration)
|
|
|
|
let (rootCmd, fieldSetters) = configurationRtti(Configuration)
|
|
var fieldCounters: array[fieldSetters.len, int]
|
|
let configFile = configFile(Configuration)
|
|
|
|
printCmdTree rootCmd
|
|
|
|
let confAddr = addr result
|
|
var activeCmds = @[rootCmd]
|
|
template lastCmd: auto = activeCmds[^1]
|
|
var nextArgIdx = lastCmd.getNextArgIdx(-1)
|
|
|
|
var help = ""
|
|
|
|
proc suggestCallingHelp =
|
|
errorOutput "Try ", fgCommand, ("$1 --help" % appInvocation())
|
|
errorOutput " for more information.\p"
|
|
flushOutputAndQuit QuitFailure
|
|
|
|
template fail(args: varargs[untyped]) =
|
|
if quitOnFailure:
|
|
errorOutput args
|
|
errorOutput "\p"
|
|
suggestCallingHelp()
|
|
else:
|
|
# TODO: populate this string
|
|
raise newException(ConfigurationError, "")
|
|
|
|
template applySetter(setterIdx: int, cmdLineVal: TaintedString) =
|
|
try:
|
|
fieldSetters[setterIdx][1](confAddr[], some(cmdLineVal))
|
|
inc fieldCounters[setterIdx]
|
|
except:
|
|
fail("Error while processing the ",
|
|
fgOption, fieldSetters[setterIdx][0],
|
|
"=", cmdLineVal.string, resetStyle, " parameter: ",
|
|
getCurrentExceptionMsg())
|
|
|
|
when hasCompletions:
|
|
template getArgCompletions(opt: OptInfo, prefix: TaintedString): seq[string] =
|
|
fieldSetters[opt.idx][2](prefix)
|
|
|
|
template required(opt: OptInfo): bool =
|
|
fieldSetters[opt.idx][3] and not opt.hasDefault
|
|
|
|
proc processMissingOpts(conf: var Configuration, cmd: CmdInfo) =
|
|
for opt in cmd.opts:
|
|
if fieldCounters[opt.idx] == 0:
|
|
if opt.required:
|
|
fail "The required option '$1' was not specified" % [opt.name]
|
|
elif configFile.setters[opt.idx](conf, configFile):
|
|
# all work is done in the config file setter,
|
|
# there is nothing left to do here.
|
|
discard
|
|
elif opt.hasDefault:
|
|
fieldSetters[opt.idx][1](conf, none[TaintedString]())
|
|
|
|
template activateCmd(discriminator: OptInfo, activatedCmd: CmdInfo) =
|
|
let cmd = activatedCmd
|
|
applySetter(discriminator.idx, if cmd.desc.len > 0: TaintedString(cmd.desc)
|
|
else: TaintedString(cmd.name))
|
|
activeCmds.add cmd
|
|
nextArgIdx = cmd.getNextArgIdx(-1)
|
|
|
|
when hasCompletions:
|
|
type
|
|
ArgKindFilter = enum
|
|
argName
|
|
argAbbr
|
|
|
|
proc showMatchingOptions(cmd: CmdInfo, prefix: string, filterKind: set[ArgKindFilter]) =
|
|
var matchingOptions: seq[OptInfo]
|
|
|
|
if len(prefix) > 0:
|
|
# Filter the options according to the input prefix
|
|
for opt in cmd.opts:
|
|
if argName in filterKind and len(opt.name) > 0:
|
|
if startsWithIgnoreStyle(opt.name, prefix):
|
|
matchingOptions.add(opt)
|
|
if argAbbr in filterKind and len(opt.abbr) > 0:
|
|
if startsWithIgnoreStyle(opt.abbr, prefix):
|
|
matchingOptions.add(opt)
|
|
else:
|
|
matchingOptions = cmd.opts
|
|
|
|
for opt in matchingOptions:
|
|
# The trailing '=' means the switch accepts an argument
|
|
let trailing = if opt.typename != "bool": "=" else: ""
|
|
|
|
if argName in filterKind and len(opt.name) > 0:
|
|
stdout.writeLine("--", opt.name, trailing)
|
|
if argAbbr in filterKind and len(opt.abbr) > 0:
|
|
stdout.writeLine('-', opt.abbr, trailing)
|
|
|
|
let completion = splitCompletionLine()
|
|
# If we're not asked to complete a command line the result is an empty list
|
|
if len(completion) != 0:
|
|
var cmdStack = @[rootCmd]
|
|
# Try to understand what the active chain of commands is without parsing the
|
|
# whole command line
|
|
for tok in completion[1..^1]:
|
|
if not tok.startsWith('-'):
|
|
let subCmd = findSubCmd(cmdStack[^1], tok)
|
|
if subCmd != nil: cmdStack.add(subCmd)
|
|
|
|
let cur_word = completion[^1]
|
|
let prev_word = if len(completion) > 2: completion[^2] else: ""
|
|
let prev_prev_word = if len(completion) > 3: completion[^3] else: ""
|
|
|
|
if cur_word.startsWith('-'):
|
|
# Show all the options matching the prefix input by the user
|
|
let isFullName = cur_word.startsWith("--")
|
|
var option_word = cur_word
|
|
option_word.removePrefix('-')
|
|
|
|
for i in countdown(cmdStack.len - 1, 0):
|
|
let argFilter =
|
|
if isFullName:
|
|
{argName}
|
|
elif len(cur_word) > 1:
|
|
# If the user entered a single hypen then we show both long & short
|
|
# variants
|
|
{argAbbr}
|
|
else:
|
|
{argName, argAbbr}
|
|
|
|
showMatchingOptions(cmdStack[i], option_word, argFilter)
|
|
elif (prev_word.startsWith('-') or
|
|
(prev_word == "=" and prev_prev_word.startsWith('-'))):
|
|
# Handle cases where we want to complete a switch choice
|
|
# -switch
|
|
# -switch=
|
|
var option_word = if len(prev_word) == 1: prev_prev_word else: prev_word
|
|
option_word.removePrefix('-')
|
|
|
|
let opt = findOpt(cmdStack, option_word)
|
|
if opt != nil:
|
|
for arg in getArgCompletions(opt, cur_word):
|
|
stdout.writeLine(arg)
|
|
elif cmdStack[^1].hasSubCommands:
|
|
# Show all the available subcommands
|
|
for subCmd in subCmds(cmdStack[^1]):
|
|
if startsWithIgnoreStyle(subCmd.name, cur_word):
|
|
stdout.writeLine(subCmd.name)
|
|
else:
|
|
# Full options listing
|
|
for i in countdown(cmdStack.len - 1, 0):
|
|
showMatchingOptions(cmdStack[i], "", {argName, argAbbr})
|
|
|
|
stdout.flushFile()
|
|
|
|
return
|
|
|
|
proc lazyHelpAppInfo: HelpAppInfo =
|
|
HelpAppInfo(
|
|
copyrightBanner: copyrightBanner,
|
|
appInvocation: appInvocation())
|
|
|
|
template processHelpAndVersionOptions(optKey: string) =
|
|
let key = optKey
|
|
if cmpIgnoreStyle(key, "help") == 0:
|
|
help.showHelp lazyHelpAppInfo(), activeCmds
|
|
elif version.len > 0 and cmpIgnoreStyle(key, "version") == 0:
|
|
help.helpOutput version, "\p"
|
|
flushOutputAndQuit QuitSuccess
|
|
|
|
for kind, key, val in getopt(cmdLine):
|
|
let key = string(key)
|
|
case kind
|
|
of cmdLongOption, cmdShortOption:
|
|
processHelpAndVersionOptions key
|
|
|
|
var opt = findOpt(activeCmds, key)
|
|
if opt == nil:
|
|
# We didn't find the option.
|
|
# Check if it's from the default command and activate it if necessary:
|
|
let subCmdDiscriminator = lastCmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil:
|
|
if subCmdDiscriminator.defaultSubCmd != -1:
|
|
let defaultCmd = subCmdDiscriminator.subCmds[subCmdDiscriminator.defaultSubCmd]
|
|
opt = findOpt(defaultCmd.opts, key)
|
|
if opt != nil:
|
|
activateCmd(subCmdDiscriminator, defaultCmd)
|
|
else:
|
|
discard
|
|
|
|
if opt != nil:
|
|
applySetter(opt.idx, val)
|
|
else:
|
|
fail "Unrecognized option '$1'" % [key]
|
|
|
|
of cmdArgument:
|
|
if lastCmd.hasSubCommands:
|
|
processHelpAndVersionOptions key
|
|
|
|
block processArg:
|
|
let subCmdDiscriminator = lastCmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil:
|
|
let subCmd = findCmd(subCmdDiscriminator.subCmds, key)
|
|
if subCmd != nil:
|
|
activateCmd(subCmdDiscriminator, subCmd)
|
|
break processArg
|
|
|
|
if nextArgIdx == -1:
|
|
fail lastCmd.noMoreArgsError
|
|
|
|
applySetter(nextArgIdx, key)
|
|
|
|
if not fieldSetters[nextArgIdx][4]:
|
|
nextArgIdx = lastCmd.getNextArgIdx(nextArgIdx)
|
|
|
|
else:
|
|
discard
|
|
|
|
let subCmdDiscriminator = lastCmd.getSubCmdDiscriminator
|
|
if subCmdDiscriminator != nil and
|
|
subCmdDiscriminator.defaultSubCmd != -1 and
|
|
fieldCounters[subCmdDiscriminator.idx] == 0:
|
|
let defaultCmd = subCmdDiscriminator.subCmds[subCmdDiscriminator.defaultSubCmd]
|
|
activateCmd(subCmdDiscriminator, defaultCmd)
|
|
|
|
for cmd in activeCmds:
|
|
result.processMissingOpts(cmd)
|
|
|
|
proc defaults*(Configuration: type): Configuration =
|
|
load(Configuration, cmdLine = @[], printUsage = false, quitOnFailure = false)
|
|
|
|
proc dispatchImpl(cliProcSym, cliArgs, loadArgs: NimNode): NimNode =
|
|
# Here, we'll create a configuration object with fields matching
|
|
# the CLI proc params. We'll also generate a call to the designated proc
|
|
let configType = genSym(nskType, "CliConfig")
|
|
let configFields = newTree(nnkRecList)
|
|
let configVar = genSym(nskLet, "config")
|
|
var dispatchCall = newCall(cliProcSym)
|
|
|
|
# The return type of the proc is skipped over
|
|
for i in 1 ..< cliArgs.len:
|
|
var arg = copy cliArgs[i]
|
|
|
|
# If an argument doesn't specify a type, we infer it from the default value
|
|
if arg[1].kind == nnkEmpty:
|
|
if arg[2].kind == nnkEmpty:
|
|
error "Please provide either a default value or type of the parameter", arg
|
|
arg[1] = newCall(bindSym"typeof", arg[2])
|
|
|
|
# Turn any default parameters into the confutils's `defaultValue` pragma
|
|
if arg[2].kind != nnkEmpty:
|
|
if arg[0].kind != nnkPragmaExpr:
|
|
arg[0] = newTree(nnkPragmaExpr, arg[0], newTree(nnkPragma))
|
|
arg[0][1].add newColonExpr(bindSym"defaultValue", arg[2])
|
|
arg[2] = newEmptyNode()
|
|
|
|
configFields.add arg
|
|
dispatchCall.add newTree(nnkDotExpr, configVar, skipPragma arg[0])
|
|
|
|
let cliConfigType = nnkTypeSection.newTree(
|
|
nnkTypeDef.newTree(
|
|
configType,
|
|
newEmptyNode(),
|
|
nnkObjectTy.newTree(
|
|
newEmptyNode(),
|
|
newEmptyNode(),
|
|
configFields)))
|
|
|
|
var loadConfigCall = newCall(bindSym"load", configType)
|
|
for p in loadArgs: loadConfigCall.add p
|
|
|
|
result = quote do:
|
|
`cliConfigType`
|
|
let `configVar` = `loadConfigCall`
|
|
`dispatchCall`
|
|
|
|
macro dispatch*(fn: typed, args: varargs[untyped]): untyped =
|
|
if fn.kind != nnkSym or
|
|
fn.symKind notin {nskProc, nskFunc, nskMacro, nskTemplate}:
|
|
error "The first argument to `confutils.dispatch` should be a callable symbol"
|
|
|
|
let fnImpl = fn.getImpl
|
|
result = dispatchImpl(fnImpl.name, fnImpl.params, args)
|
|
debugMacroResult "Dispatch Code"
|
|
|
|
macro cli*(args: varargs[untyped]): untyped =
|
|
if args.len == 0:
|
|
error "The cli macro expects a do block", args
|
|
|
|
let doBlock = args[^1]
|
|
if doBlock.kind notin {nnkDo, nnkLambda}:
|
|
error "The last argument to `confutils.cli` should be a do block", doBlock
|
|
|
|
args.del(args.len - 1)
|
|
|
|
# Create a new anonymous proc we'll dispatch to
|
|
let cliProcName = genSym(nskProc, "CLI")
|
|
var cliProc = newTree(nnkProcDef, cliProcName)
|
|
# Copy everything but the name from the do block:
|
|
for i in 1 ..< doBlock.len: cliProc.add doBlock[i]
|
|
|
|
# Generate the final code
|
|
result = newStmtList(cliProc, dispatchImpl(cliProcName, cliProc.params, args))
|
|
|
|
# TODO: remove this once Nim supports custom pragmas on proc params
|
|
for p in cliProc.params:
|
|
if p.kind == nnkEmpty: continue
|
|
p[0] = skipPragma p[0]
|
|
|
|
debugMacroResult "CLI Code"
|
|
|
|
proc load*(f: TypedInputFile): f.ContentType =
|
|
when f.Format is Unspecified or f.ContentType is Unspecified:
|
|
{.fatal: "To use `InputFile.load`, please specify the Format and ContentType of the file".}
|
|
|
|
when f.Format is Txt:
|
|
# TODO: implement a proper Txt serialization format
|
|
mixin init
|
|
f.ContentType.init readFile(f.string).string
|
|
else:
|
|
mixin loadFile
|
|
loadFile(f.Format, f.string, f.ContentType)
|
|
|