From 0fc26c5b25a931fdd15a74f2d9028112ffd621ba Mon Sep 17 00:00:00 2001 From: Zahary Karadjov Date: Sun, 27 Feb 2022 20:41:25 +0200 Subject: [PATCH] Bugfixes for the config file support - The config files processing was not taking into account the `name` pragma of the configuration object fields. - The required fields were not searched within config files before reporting an error that they are missing. - Fields with the same names were not supported in different case branches - The loaded config file path can now depend on the configuration supplied through the command-line. --- confutils.nim | 71 ++-- confutils/config_file.nim | 319 +++++++++++++----- confutils/toml/defs.nim | 15 + confutils/toml/std/net.nim | 20 ++ confutils/toml/std/uri.nim | 13 + .../current_user/testVendor/testApp.toml | 22 +- .../system_wide/testVendor/testApp.toml | 22 +- tests/test_config_file.nim | 29 +- 8 files changed, 365 insertions(+), 146 deletions(-) create mode 100644 confutils/toml/defs.nim create mode 100644 confutils/toml/std/net.nim create mode 100644 confutils/toml/std/uri.nim diff --git a/confutils.nim b/confutils.nim index e15b646..b5171d0 100644 --- a/confutils.nim +++ b/confutils.nim @@ -1,10 +1,11 @@ import std/[options, strutils, wordwrap], stew/shims/macros, + serialization, confutils/[defs, cli_parser, config_file] export - defs, config_file, options + options, serialization, defs, config_file const useBufferedOutput = defined(nimscript) @@ -816,12 +817,22 @@ macro configurationRtti(RecordType: type): untyped = result = newTree(nnkPar, newLitFixed cmdInfo, fieldSetters) -proc load*(Configuration: type, - cmdLine = commandLineParams(), - version = "", - copyrightBanner = "", - printUsage = true, - quitOnFailure = true): Configuration = +proc addConfigFile*(secondarySources: auto, + Format: type, + path: InputFile) = + secondarySources.data.add loadFile(Format, string path, + type(secondarySources.data[0])) + +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) = nil): Configuration = ## Loads a program configuration by parsing command-line arguments ## and a standard set of config files that can specify: ## @@ -842,11 +853,9 @@ proc load*(Configuration: type, 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) @@ -867,6 +876,8 @@ proc load*(Configuration: type, # TODO: populate this string raise newException(ConfigurationError, "") + let confAddr = addr result + template applySetter(setterIdx: int, cmdLineVal: TaintedString) = try: fieldSetters[setterIdx][1](confAddr[], some(cmdLineVal)) @@ -884,18 +895,6 @@ proc load*(Configuration: type, 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) @@ -1060,9 +1059,39 @@ proc load*(Configuration: type, let defaultCmd = subCmdDiscriminator.subCmds[subCmdDiscriminator.defaultSubCmd] activateCmd(subCmdDiscriminator, defaultCmd) + if secondarySources != nil: + secondarySources(result, secondarySourcesRef) + + proc processMissingOpts(conf: var Configuration, cmd: CmdInfo) = + for opt in cmd.opts: + if fieldCounters[opt.idx] == 0: + if 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[TaintedString]()) + elif opt.required: + fail "The required option '$1' was not specified" % [opt.name] + for cmd in activeCmds: result.processMissingOpts(cmd) +template load*( + Configuration: type, + cmdLine = commandLineParams(), + version = "", + copyrightBanner = "", + printUsage = true, + quitOnFailure = true, + secondarySources: untyped = nil): untyped = + + block: + var secondarySourcesRef = generateSecondarySources(Configuration) + loadImpl(Configuration, cmdLine, version, + copyrightBanner, printUsage, quitOnFailure, + secondarySourcesRef, secondarySources) + proc defaults*(Configuration: type): Configuration = load(Configuration, cmdLine = @[], printUsage = false, quitOnFailure = false) diff --git a/confutils/config_file.nim b/confutils/config_file.nim index 9ebe62e..bab9f25 100644 --- a/confutils/config_file.nim +++ b/confutils/config_file.nim @@ -1,5 +1,5 @@ import - std/[macrocache, typetraits], + std/[tables, macrocache, typetraits], stew/shims/macros, ./defs @@ -18,6 +18,23 @@ Overview of this module: const configFileRegs = CacheSeq"confutils" +type + ConfFileSection = ref object + children: seq[ConfFileSection] + fieldName: string + namePragma: string + typ: NimNode + defaultValue: string + isCommandOrArgument: bool + isCaseBranch: bool + isDiscriminator: bool + + GeneratedFieldInfo = tuple + isCommandOrArgument: bool + path: seq[string] + + OriginalToGeneratedFields = OrderedTable[string, GeneratedFieldInfo] + func isOption(n: NimNode): bool = if n.kind != nnkBracketExpr: return false eqIdent(n[0], "Option") @@ -40,127 +57,255 @@ proc generateOptionalField(fieldName: NimNode, fieldType: NimNode): NimNode = let right = if isOption(fieldType): fieldType else: makeOption(fieldType) newIdentDefs(fieldName, right) -proc optionalizeFields(CF, confType: NimNode): NimNode = - # Generate temporary object type where - # all fields are optional. - result = getAst(objectDecl(CF)) - var recList = newNimNode(nnkRecList) +proc traverseIdent(ident: NimNode, typ: NimNode, isDiscriminator: bool, + isCommandOrArgument = false, defaultValue = "", + namePragma = ""): ConfFileSection = + ident.expectKind nnkIdent + ConfFileSection(fieldName: $ident, namePragma: namePragma, typ: typ, + defaultValue: defaultValue, isCommandOrArgument: isCommandOrArgument, + isDiscriminator: isDiscriminator) - var recordDef = getImpl(confType) - for field in recordFields(recordDef): - if field.readPragma"command" != nil or - field.readPragma"argument" != nil: - continue +proc traversePostfix(postfix: NimNode, typ: NimNode, isDiscriminator: bool, + isCommandOrArgument = false, defaultValue = "", + namePragma = ""): ConfFileSection = + postfix.expectKind nnkPostfix + traverseIdent(postfix[1], typ, isDiscriminator, isCommandOrArgument, + defaultValue, namePragma) - recList.add generateOptionalField(field.name, field.typ) - result.putRecList(recList) +proc traversePragma(pragma: NimNode): + tuple[isCommandOrArgument: bool, defaultValue, namePragma: string] = + pragma.expectKind nnkPragma + for child in pragma: + case child.kind + of nnkSym: + let sym = $child + if sym == "command" or sym == "argument": + result.isCommandOrArgument = true + of nnkExprColonExpr: + let pragma = $child[0] + if pragma == "defaultValue": + result.defaultValue = child[1].repr + elif pragma == "name": + result.namePragma = $child[1] + else: + raiseAssert "[Pragma] Unsupported child node:\n" & child.treeRepr -proc genLoader(i: int, format, ext, path, optType, confType: NimNode): NimNode = - var pathBlock: NimNode - if eqIdent(format, "Envvar"): - pathBlock = quote do: - block: - `path` - elif eqIdent(format, "Winreg"): - pathBlock = quote do: - block: - `path` / vendorName(`confType`) / appName(`confType`) +proc traversePragmaExpr(pragmaExpr: NimNode, typ: NimNode, + isDiscriminator: bool): ConfFileSection = + pragmaExpr.expectKind nnkPragmaExpr + let (isCommandOrArgument, defaultValue, namePragma) = + traversePragma(pragmaExpr[1]) + case pragmaExpr[0].kind + of nnkIdent: + traverseIdent(pragmaExpr[0], typ, isDiscriminator, isCommandOrArgument, + defaultValue, namePragma) + of nnkPostfix: + traversePostfix(pragmaExpr[0], typ, isDiscriminator, isCommandOrArgument, + defaultValue, namePragma) else: - # toml, json, yaml, etc - pathBlock = quote do: - block: - `path` / vendorName(`confType`) / appName(`confType`) & "." & `ext` + raiseAssert "[PragmaExpr] Unsupported expression:\n" & pragmaExpr.treeRepr - result = quote do: - let fullPath = `pathBlock` - try: - result.data[`i`] = `format`.loadFile(fullPath, `optType`) - except: - echo "Error when loading: ", fullPath - echo getCurrentExceptionMsg() +proc traverseIdentDefs(identDefs: NimNode, parent: ConfFileSection, + isDiscriminator: bool): seq[ConfFileSection] = + identDefs.expectKind nnkIdentDefs + doAssert identDefs.len > 2, "This kind of node must have at least 3 children." + let typ = identDefs[^2] + for child in identDefs: + case child.kind + of nnkIdent: + result.add traverseIdent(child, typ, isDiscriminator) + of nnkPostfix: + result.add traversePostfix(child, typ, isDiscriminator) + of nnkPragmaExpr: + result.add traversePragmaExpr(child, typ, isDiscriminator) + of nnkBracketExpr, nnkSym, nnkEmpty, nnkInfix, nnkCall: + discard + else: + raiseAssert "[IdentDefs] Unsupported child node:\n" & child.treeRepr -proc generateSetters(optType, confType, CF: NimNode): (NimNode, NimNode, int) = +proc traverseRecList(recList: NimNode, parent: ConfFileSection): seq[ConfFileSection] + +proc traverseOfBranch(ofBranch: NimNode, parent: ConfFileSection): ConfFileSection = + ofBranch.expectKind nnkOfBranch + result = ConfFileSection(fieldName: repr(ofBranch[0]), isCaseBranch: true) + for child in ofBranch: + case child.kind: + of nnkIdent, nnkDotExpr: + discard + of nnkRecList: + result.children.add traverseRecList(child, result) + else: + raiseAssert "[OfBranch] Unsupported child node:\n" & child.treeRepr + +proc traverseRecCase(recCase: NimNode, parent: ConfFileSection): seq[ConfFileSection] = + recCase.expectKind nnkRecCase + for child in recCase: + case child.kind + of nnkIdentDefs: + result.add traverseIdentDefs(child, parent, true) + of nnkOfBranch: + result.add traverseOfBranch(child, parent) + else: + raiseAssert "[RecCase] Unsupported child node:\n" & child.treeRepr + +proc traverseRecList(recList: NimNode, parent: ConfFileSection): seq[ConfFileSection] = + recList.expectKind nnkRecList + for child in recList: + case child.kind + of nnkIdentDefs: + result.add traverseIdentDefs(child, parent, false) + of nnkRecCase: + result.add traverseRecCase(child, parent) + of nnkNilLit: + discard + else: + raiseAssert "[RecList] Unsupported child node:\n" & child.treeRepr + +proc normalize(root: ConfFileSection) = + ## Moves the default case branches children one level upper in the hierarchy. + ## Also removes case branches without children. + var children: seq[ConfFileSection] + var defaultValue = "" + for child in root.children: + normalize(child) + if child.isDiscriminator: + defaultValue = child.defaultValue + if child.isCaseBranch and child.fieldName == defaultValue: + for childChild in child.children: + children.add childChild + child.children = @[] + elif child.isCaseBranch and child.children.len == 0: + discard + else: + children.add child + root.children = children + +proc generateConfigFileModel(ConfType: NimNode): ConfFileSection = + let confTypeImpl = ConfType.getType[1].getImpl + result = ConfFileSection(fieldName: $confTypeImpl[0]) + result.children = traverseRecList(confTypeImpl[2][2], result) + result.normalize + +proc getRenamedName(node: ConfFileSection): string = + if node.namePragma.len == 0: node.fieldName else: node.namePragma + +proc generateTypes(root: ConfFileSection): seq[NimNode] = + let index = result.len + result.add getAst(objectDecl(genSym(nskType, root.fieldName)))[0] + var recList = newNimNode(nnkRecList) + for child in root.children: + if child.isCommandOrArgument: + continue + if child.isCaseBranch: + if child.children.len > 0: + var types = generateTypes(child) + recList.add generateOptionalField(child.fieldName.ident, types[0][0]) + result.add types + else: + recList.add generateOptionalField(child.getRenamedName.ident, child.typ) + result[index].putRecList(recList) + +proc generateSettersPaths(node: ConfFileSection, result: var OriginalToGeneratedFields) = + var path {.global.}: seq[string] + path.add node.getRenamedName + if node.children.len == 0: + result[node.fieldName] = (node.isCommandOrArgument, path) + else: + for child in node.children: + generateSettersPaths(child, result) + path.del path.len - 1 + +proc generateSettersPaths(root: ConfFileSection): OriginalToGeneratedFields = + for child in root.children: + generateSettersPaths(child, result) + +template cfSetter(a, b: untyped): untyped = + when a is Option: + a = some(b) + else: + a = b + +proc generateSetters(confType, CF: NimNode, fieldsPaths: OriginalToGeneratedFields): + (NimNode, NimNode, int) = var procs = newStmtList() assignments = newStmtList() - recordDef = getImpl(confType) numSetters = 0 - procs.add quote do: - template cfSetter(a, b: untyped): untyped = - when a is Option: - a = b - else: - a = b.get() - - for field in recordFields(recordDef): - if field.readPragma"command" != nil or - field.readPragma"argument" != nil: - + let c = "c".ident + for field, (isCommandOrArgument, path) in fieldsPaths: + if isCommandOrArgument: assignments.add quote do: result.setters[`numSetters`] = defaultConfigFileSetter - inc numSetters continue - let setterName = ident($field.name & "CFSetter") - let fieldName = field.name + var fieldPath = c + var condition: NimNode + for fld in path: + fieldPath = newDotExpr(fieldPath, fld.ident) + let fieldChecker = newDotExpr(fieldPath, "isSome".ident) + if condition == nil: + condition = fieldChecker + else: + condition = newNimNode(nnkInfix).add("and".ident).add(condition).add(fieldChecker) + fieldPath = newDotExpr(fieldPath, "get".ident) + let setterName = genSym(nskProc, field & "CFSetter") + let fieldIdent = field.ident procs.add quote do: - proc `setterName`(s: var `confType`, cf: `CF`): bool {. - nimcall, gcsafe .} = - for c in cf.data: - if c.`fieldName`.isSome(): - cfSetter(s.`fieldName`, c.`fieldName`) + proc `setterName`(s: var `confType`, cf: ref `CF`): bool {.nimcall, gcsafe.} = + for `c` in cf.data: + if `condition`: + cfSetter(s.`fieldIdent`, `fieldPath`) return true assignments.add quote do: result.setters[`numSetters`] = `setterName` - inc numSetters result = (procs, assignments, numSetters) -proc generateConfigFileSetters(optType, CF, confType: NimNode): NimNode = - let T = confType.getType[1] - let arrayLen = configFileRegs.len - let settersType = genSym(nskType, "SettersType") +proc generateConfigFileSetters(confType, optType: NimNode, + fieldsPaths: OriginalToGeneratedFields): NimNode = + let + CF = ident "SecondarySources" + T = confType.getType[1] + arrayLen = configFileRegs.len + optT = optType[0][0] + SetterProcType = genSym(nskType, "SetterProcType") + (setterProcs, assignments, numSetters) = generateSetters(T, CF, fieldsPaths) + stmtList = quote do: + type + `SetterProcType` = proc(s: var `T`, cf: ref `CF`): bool {.nimcall, gcsafe.} - var loaderStmts = newStmtList() - for i in 0..