diff --git a/confutils.nim b/confutils.nim index 6f36d3f..fd3a35a 100644 --- a/confutils.nim +++ b/confutils.nim @@ -1,10 +1,67 @@ import - os, parseopt, strutils, options, std_shims/macros_shim, typetraits, + os, parseopt, strutils, options, std_shims/macros_shim, typetraits, terminal, confutils/defs export defs +type + CommandDesc = object + name: string + options: seq[OptionDesc] + subCommands: seq[CommandDesc] + fieldIdx: int + argumentsFieldIdx: int + + OptionDesc = object + name, typename, shortform: string + hasDefault: bool + rejectNext: bool + fieldIdx: int + desc: string + +template appName: string = + getAppFilename().splitFile.name + +when not defined(confutils_no_colors): + template write(args: varargs[untyped]) = + stdout.styledWrite(args) +else: + const + styleBright = "" + + template write(args: varargs[untyped]) = + stdout.write(args) + +proc describeCmdOptions(cmd: CommandDesc) = + for opt in cmd.options: + write " --", opt.name, "=", opt.typename + if opt.desc.len > 0: + write repeat(" ", max(0, 40 - opt.name.len - opt.typename.len)), ": ", opt.desc + write "\n" + +proc showHelp(cmd: CommandDesc) = + let app = appName + + write "Usage: ", styleBright, app + if cmd.name.len > 0: write " ", cmd.name + if cmd.options.len > 0: write " [OPTIONS]" + if cmd.subCommands.len > 0: write " " + if cmd.argumentsFieldIdx != -1: write " []" + + if cmd.options.len > 0: + write "\n\nThe following options are supported:\n\n" + describeCmdOptions(cmd) + + if cmd.subCommands.len > 0: + write "\nAvailable sub-commands:\n\n" + for subcmd in cmd.subCommands: + write " ", styleBright, app, " ", subcmd.name, "\n\n" + describeCmdOptions(subcmd) + + write "\n" + quit(0) + proc parseCmdArg*(T: type DirPath, p: TaintedString): T = # TODO: check existence result = DirPath(p) @@ -54,6 +111,16 @@ proc makeDefaultValue*(T: type): T = proc requiresInput*(T: type): bool = result = not ((T is seq) or (T is Option)) +# TODO: The usage of this should be replacable with just `type(x)`, +# but Nim is not able to handle it at the moment. +macro typeof(x: typed): untyped = + result = x.getType + +template debugMacroResult(macroName: string) {.dirty.} = + when defined(debugMacros) or defined(debugConfutils): + echo "\n-------- ", macroName, " ----------------------" + echo result.repr + proc load*(Configuration: type, cmdLine = commandLineParams(), printUsage = true, @@ -75,19 +142,6 @@ proc load*(Configuration: type, type FieldSetter = proc (cfg: var Configuration, val: TaintedString): bool {.nimcall.} - CommandDesc = object - name: string - options: seq[OptionDesc] - subCommands: seq[CommandDesc] - fieldIdx: int - argumentsFieldIdx: int - - OptionDesc = object - name, typename, shortform: string - hasDefault: bool - rejectNext: bool - fieldIdx: int - template readPragma(field, name): NimNode = let p = field.pragmas.findPragma bindSym(name) if p != nil and p.len == 2: p[1] else: p @@ -110,9 +164,13 @@ proc load*(Configuration: type, if defaultValue == nil: defaultValue = newCall(makeDefaultValue, newTree(nnkTypeOfExpr, recordField)) + # 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, newCall(bindSym"FieldSetter", setterName), - newCall(bindSym"requiresInput", newTree(nnkTypeOfExpr, field.typ))) + newCall(bindSym"requiresInput", fixedFieldType)) result.add quote do: proc `setterName`(`recordVar`: var `RecordType`, val: TaintedString): bool {.nimcall.} = @@ -125,6 +183,7 @@ proc load*(Configuration: type, return setField(`recordField`, val, `defaultValue`) result.add settersArray + debugMacroResult "Field Setters" macro buildCommandTree(RecordType: type): untyped = var recordDef = RecordType.getType[1].getImpl @@ -154,6 +213,7 @@ proc load*(Configuration: type, option.name = $field.name option.hasDefault = hasDefault option.typename = field.typ.repr + if desc != nil: option.desc = desc.strVal if longform != nil: option.name = longform.strVal if shortform != nil: option.shortform = shortform.strVal @@ -172,6 +232,7 @@ proc load*(Configuration: type, inc fieldIdx result = newLitFixed(res) + debugMacroResult "Command Tree" let fieldSetters = generateFieldSetters(Configuration) var rootCmd = buildCommandTree(Configuration) @@ -179,6 +240,7 @@ proc load*(Configuration: type, proc fail(msg: string) = if quitOnFailure: stderr.writeLine(msg) + stderr.writeLine("Try '{1} --help' for more information" % appName) quit 1 else: raise newException(ConfigurationError, msg) @@ -201,10 +263,13 @@ proc load*(Configuration: type, template required(opt: OptionDesc): bool = fieldSetters[opt.fieldIdx][1] and not opt.hasDefault - proc checkForMissingOptions(cmd: ptr CommandDesc) = + proc processMissingOptions(conf: var Configuration, cmd: ptr CommandDesc) = for o in cmd.options: - if o.required and o.rejectNext == false: - fail "The required option '$1' was not specified" % [o.name] + if o.rejectNext == false: + if o.required: + fail "The required option '$1' was not specified" % [o.name] + elif o.hasDefault: + discard fieldSetters[o.fieldIdx][0](conf, TaintedString("")) var currentCmd = addr rootCmd var rejectNextArgument = currentCmd.argumentsFieldIdx == -1 @@ -212,6 +277,9 @@ proc load*(Configuration: type, for kind, key, val in getopt(cmdLine): case kind of cmdLongOption, cmdShortOption: + if string(key) == "help": + showHelp currentCmd[] + let option = currentCmd.findOption(key) if option != nil: if option.rejectNext: @@ -221,6 +289,9 @@ proc load*(Configuration: type, fail "Unrecognized option '$1'" % [string(key)] of cmdArgument: + if string(key) == "help" and currentCmd.subCommands.len > 0: + showHelp currentCmd[] + let subCmd = currentCmd.findSubcommand(key) if subCmd != nil: discard fieldSetters[subCmd.fieldIdx][0](result, key) @@ -236,5 +307,81 @@ proc load*(Configuration: type, else: discard - currentCmd.checkForMissingOptions() + result.processMissingOptions(currentCmd) + +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 + # p + 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]) + arg[0].add newTree(nnkPragma, + 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)) + debugMacroResult "CLI Code"