# confutils # Copyright (c) 2018-2024 Status Research & Development GmbH # Licensed under either of # * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) # * MIT license ([LICENSE-MIT](LICENSE-MIT)) # at your option. # This file may not be copied, modified, or distributed except according to # those terms. {.push raises: [].} import os, std/[enumutils, options, strutils, wordwrap], stew/shims/macros, confutils/[defs, cli_parser, config_file] export options, defs, config_file const hasSerialization = not defined(nimscript) useBufferedOutput = defined(nimscript) noColors = useBufferedOutput or defined(confutils_no_colors) hasCompletions = not defined(nimscript) descPadding = 6 minNameWidth = 24 - descPadding when hasSerialization: import serialization export serialization when not defined(nimscript): import 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 separator: string longDesc: 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 {.push gcsafe, raises: [].} func 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: "") 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 = try: getAppFilename().splitFile.name except OSError: "" 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]) = try: styledWrite stderr, args except IOError, ValueError: discard template helpOutput(args: varargs[untyped]) = try: styledWrite stdout, args except IOError, ValueError: discard 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 writeLongDesc(help: var string, appInfo: HelpAppInfo, desc: string) = let lines = split(desc, {'\n', '\r'}) for line in lines: if line.len > 0: helpOutput "\p" helpOutput padding("", 5 + appInfo.namesWidth) help.writeDesc appInfo, line, "" 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 help.writeLongDesc appInfo, arg.longDesc 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 if opt.separator.len > 0: helpOutput opt.separator helpOutput "\p" # 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 help.writeLongDesc appInfo, opt.longDesc 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 = try: terminalWidth() except ValueError: int.high # https://github.com/nim-lang/Nim/pull/21968 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 -1 proc noMoreArgsError(cmd: CmdInfo): string {.raises: [].} = result = if cmd.isSubCommand: "The command '" & cmd.name & "'" else: appInvocation() result.add " does not accept" if cmd.hasArgs: result.add " additional" result.add " arguments" func 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 func 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 func findCmd(cmds: openArray[CmdInfo], name: string): CmdInfo = for cmd in cmds: if cmpIgnoreStyle(cmd.name, name) == 0: return cmd func 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 func 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: string): T {.raises: [ValueError].} = if not dirExists(p): raise newException(ValueError, "Directory doesn't exist") T(p) proc parseCmdArg*(T: type InputFile, p: string): T {.raises: [ValueError].} = # 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): raise newException(ValueError, "File doesn't exist") when not defined(nimscript): try: let f = system.open(p, fmRead) close f except IOError: raise newException(ValueError, "File not accessible") T(p) proc parseCmdArg*( T: type TypedInputFile, p: string): T {.raises: [ValueError].} = var path = p 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") T(path) func parseCmdArg*(T: type[OutDir|OutFile|OutPath], p: string): T = T(p) proc parseCmdArg*[T]( _: type Option[T], s: string): Option[T] {.raises: [ValueError].} = some(parseCmdArg(T, s)) template parseCmdArg*(T: type string, s: string): string = s func parseCmdArg*( T: type SomeSignedInt, s: string): T {.raises: [ValueError].} = T parseBiggestInt(s) func parseCmdArg*( T: type SomeUnsignedInt, s: string): T {.raises: [ValueError].} = T parseBiggestUInt(s) func parseCmdArg*(T: type SomeFloat, p: string): T {.raises: [ValueError].} = parseFloat(p) func parseCmdArg*(T: type bool, p: string): T {.raises: [ValueError].} = try: p.len == 0 or parseBool(p) except CatchableError: raise newException(ValueError, "'" & p & "' is not a valid boolean value. Supported values are on/off, yes/no, true/false or 1/0") func parseCmdArg*(T: type enum, s: string): T {.raises: [ValueError].} = parseEnum[T](s) proc parseCmdArgAux(T: type, s: string): 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 try: parseCmdArg(T, s) except CatchableError as exc: raise newException(ValueError, exc.msg) func completeCmdArg*(T: type enum, val: string): seq[string] = for e in low(T)..high(T): let as_str = $e if startsWithIgnoreStyle(as_str, val): result.add($e) func completeCmdArg*(T: type SomeNumber, val: string): seq[string] = @[] func completeCmdArg*(T: type bool, val: string): seq[string] = @[] func completeCmdArg*(T: type string, val: string): seq[string] = @[] proc completeCmdArg*(T: type[InputFile|TypedInputFile|InputDir|OutFile|OutDir|OutPath], val: string): 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 func completeCmdArg*[T](_: type seq[T], val: string): seq[string] = @[] proc completeCmdArg*[T](_: type Option[T], val: string): seq[string] = mixin completeCmdArg return completeCmdArg(type(T), val) proc completeCmdArgAux(T: type, val: string): seq[string] = mixin completeCmdArg return completeCmdArg(T, val) template setField[T]( loc: var T, val: Option[string], defaultVal: untyped): 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[string], defaultVal: untyped): untyped = if val.isSome: loc.add parseCmdArgAux(type(loc[0]), val.get) else: type FieldType = type(loc) loc = FieldType(defaultVal) func makeDefaultValue*(T: type): T = default(T) func requiresInput*(T: type): bool = not ((T is seq) or (T is Option) or (T is bool)) func acceptsMultipleValues*(T: type): bool = T is seq template debugMacroResult(macroName: string) {.dirty.} = when defined(debugMacros) or defined(debugConfutils): echo "\n-------- ", macroName, " ----------------------" echo result.repr func parseEnumNormalized[T: enum](s: string): T {.raises: [ValueError].} = # Note: In Nim 1.6 `parseEnum` normalizes the string except for the first # character. Nim 1.2 would normalize for all characters. In config options # the latter behaviour is required so this custom function is needed. genEnumCaseStmt(T, s, default = nil, ord(low(T)), ord(high(T)), normalize) 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: {.push hint[XCannotRaiseY]: off.} result.add quote do: proc `completerName`(val: string): seq[string] {. nimcall gcsafe sideEffect raises: [] .} = return completeCmdArgAux(`fixedFieldType`, val) proc `setterName`(`configVar`: var `RecordType`, val: Option[string]) {. nimcall gcsafe sideEffect raises: [ValueError] .} = 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` = parseEnumNormalized[type(`configField`)](val.get) else: `configField` = `defaultValue` else: setField(`configField`, val, `defaultValue`) result.add quote do: {.pop.} result.add settersArray debugMacroResult "Field Setters" func checkDuplicate(cmd: CmdInfo, opt: OptInfo, fieldName: NimNode) = for x in cmd.opts: if opt.name == x.name: error "duplicate name detected: " & opt.name, fieldName if opt.abbr.len > 0 and opt.abbr == x.abbr: error "duplicate abbr detected: " & opt.abbr, fieldName func validPath(path: var seq[CmdInfo], parent, node: CmdInfo): bool = for x in parent.opts: if x.kind != Discriminator: continue for y in x.subCmds: if y == node: path.add y return true if validPath(path, y, node): path.add y return true false func findPath(parent, node: CmdInfo): seq[CmdInfo] = # find valid path from parent to node result = newSeq[CmdInfo]() doAssert validPath(result, parent, node) result.add parent func toText(n: NimNode): string = if n == nil: "" elif n.kind in {nnkStrLit..nnkTripleStrLit}: n.strVal else: repr(n) 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 = toText(defaultInHelp) separator = field.readPragma"separator" longDesc = field.readPragma"longDesc" 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 if separator != nil: opt.separator = separator.strVal if longDesc != nil: opt.longDesc = longDesc.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) # we respect subcommand hierarchy when looking for duplicate let path = findPath(result, cmd) for n in path: checkDuplicate(n, opt, field.name) # the reason we check for `ignore` pragma here and not using `continue` statement # is we do respect option hierarchy of subcommands if field.readPragma("ignore") == nil: cmd.opts.add opt else: checkDuplicate(result, opt, field.name) if field.readPragma("ignore") == nil: 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) when hasSerialization: proc addConfigFile*(secondarySources: auto, Format: type, path: InputFile) {.raises: [ConfigurationError].} = try: secondarySources.data.add loadFile(Format, string path, type(secondarySources.data[0])) except SerializationError as err: raise newException(ConfigurationError, err.formatMsg(string path), err) except IOError as err: raise newException(ConfigurationError, "Failed to read config file at '" & string(path) & "': " & err.msg) proc addConfigFileContent*(secondarySources: auto, Format: type, content: string) {.raises: [ConfigurationError].} = try: secondarySources.data.add decode(Format, content, type(secondarySources.data[0])) except SerializationError as err: raise newException(ConfigurationError, err.formatMsg(""), err) except IOError: raiseAssert "This should not be possible" func constructEnvKey*(prefix: string, key: string): string {.raises: [].} = ## Generates env. variable names from keys and prefix following the ## IEEE Open Group env. variable spec: https://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html (prefix & "_" & key).toUpperAscii.multiReplace(("-", "_"), (" ", "_")) # On Posix there is no portable way to get the command # line from a DLL and thus the proc isn't defined in this environment. # See https://nim-lang.org/docs/os.html#commandLineParams when not declared(commandLineParams): proc commandLineParams(): seq[string] = discard proc loadImpl[C, SecondarySources]( Configuration: typedesc[C], cmdLine = commandLineParams(), version = "", copyrightBanner = "", printUsage = true, quitOnFailure = true, secondarySourcesRef: ref SecondarySources, secondarySources: proc ( config: Configuration, sources: ref SecondarySources ) {.gcsafe, raises: [ConfigurationError].} = nil, envVarsPrefix = appInvocation() ): Configuration {.raises: [ConfigurationError].} = ## 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. let (rootCmd, fieldSetters) = configurationRtti(Configuration) var fieldCounters: array[fieldSetters.len, int] printCmdTree rootCmd var activeCmds = @[rootCmd] template lastCmd: auto = activeCmds[^1] var nextArgIdx = lastCmd.getNextArgIdx(-1) var help = "" proc suggestCallingHelp = errorOutput "Try ", fgCommand, appInvocation() & " --help" errorOutput " for more information.\p" flushOutputAndQuit QuitFailure template fail(args: varargs[untyped]): untyped = if quitOnFailure: errorOutput args errorOutput "\p" suggestCallingHelp() else: # TODO: populate this string raise newException(ConfigurationError, "") template applySetter( conf: Configuration, setterIdx: int, cmdLineVal: string): untyped = when defined(nimHasWarnBareExcept): {.push warning[BareExcept]:off.} try: fieldSetters[setterIdx][1](conf, some(cmdLineVal)) inc fieldCounters[setterIdx] except: fail("Error while processing the ", fgOption, fieldSetters[setterIdx][0], "=", cmdLineVal, resetStyle, " parameter: ", getCurrentExceptionMsg()) when defined(nimHasWarnBareExcept): {.pop.} when hasCompletions: template getArgCompletions(opt: OptInfo, prefix: string): seq[string] = fieldSetters[opt.idx][2](prefix) template required(opt: OptInfo): bool = fieldSetters[opt.idx][3] and not opt.hasDefault template activateCmd( conf: Configuration, discriminator: OptInfo, activatedCmd: CmdInfo) = let cmd = activatedCmd conf.applySetter(discriminator.idx, if cmd.desc.len > 0: cmd.desc else: 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: try: stdout.writeLine("--", opt.name, trailing) except IOError: discard if argAbbr in filterKind and len(opt.abbr) > 0: try: stdout.writeLine('-', opt.abbr, trailing) except IOError: discard 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): try: stdout.writeLine(arg) except IOError: discard elif cmdStack[^1].hasSubCommands: # Show all the available subcommands for subCmd in subCmds(cmdStack[^1]): if startsWithIgnoreStyle(subCmd.name, cur_word): try: stdout.writeLine(subCmd.name) except IOError: discard 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): when key isnot string: 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: result.activateCmd(subCmdDiscriminator, defaultCmd) else: discard if opt != nil: result.applySetter(opt.idx, val) else: fail "Unrecognized option '" & 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: result.activateCmd(subCmdDiscriminator, subCmd) break processArg if nextArgIdx == -1: fail lastCmd.noMoreArgsError result.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] result.activateCmd(subCmdDiscriminator, defaultCmd) # https://github.com/status-im/nim-confutils/pull/109#discussion_r1820076739 if not isNil(secondarySources): # Nim v2.0.10: `!= nil` broken in nimscript try: secondarySources(result, secondarySourcesRef) except ConfigurationError as err: fail "Failed to load secondary sources: '" & err.msg & "'" proc processMissingOpts( conf: var Configuration, cmd: CmdInfo) {.raises: [ConfigurationError].} = for opt in cmd.opts: if fieldCounters[opt.idx] == 0: let envKey = constructEnvKey(envVarsPrefix, opt.name) try: if existsEnv(envKey): let envContent = getEnv(envKey) conf.applySetter(opt.idx, envContent) elif secondarySourcesRef.setters[opt.idx](conf, secondarySourcesRef): # 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[string]()) elif opt.required: fail "The required option '" & opt.name & "' was not specified" except ValueError as err: fail "Option '" & opt.name & "' failed to parse: '" & err.msg & "'" for cmd in activeCmds: result.processMissingOpts(cmd) template load*( Configuration: type, cmdLine = commandLineParams(), version = "", copyrightBanner = "", printUsage = true, quitOnFailure = true, secondarySources: untyped = nil, envVarsPrefix = appInvocation()): untyped = block: let secondarySourcesRef = generateSecondarySources(Configuration) loadImpl(Configuration, cmdLine, version, copyrightBanner, printUsage, quitOnFailure, secondarySourcesRef, secondarySources, envVarsPrefix) func 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" func 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) {.pop.}