diff --git a/.gitignore b/.gitignore index 54b1b5c..c6c058e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ tests/test_help tests/test_argument tests/test_parsecmdarg tests/test_obsolete +tests/test_flatten_pragma diff --git a/README.md b/README.md index aa09824..21ca97d 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,24 @@ let c = MyConf.load(onLoaded = myLogger) ----------------- +```nim +template flatten* {.pragma.} +``` + +Apply it to an object field to traverse the object options as if they were "top-level". +This allows the object options to be reused in various configurations. + +```nim +template flatten*(v: untyped) {.pragma.} +``` + +The default values for the object options can be overridden +with a tuple of field names and values. For example: +`opts {.flatten: (opt1: "my default").}: OptsConf` where `opt1` +is a `OptsConf` field of type string. + +----------------- + ```nim template implicitlySelectable* {.pragma.} ``` diff --git a/confutils.nim b/confutils.nim index 637df49..147ddae 100644 --- a/confutils.nim +++ b/confutils.nim @@ -14,7 +14,7 @@ import std/[enumutils, options, strutils, wordwrap], results, stew/shims/macros, - confutils/[defs, cli_parser, config_file] + confutils/[defs, cli_parser, config_file, utils] export options, results, defs, config_file @@ -817,6 +817,110 @@ template debugMacroResult(macroName: string) {.dirty.} = echo "\n-------- ", macroName, " ----------------------" echo result.repr +type + ConfFieldDescRef = ref ConfFieldDesc + ConfFieldDesc = object + field: FieldDescription + parent: ConfFieldDescRef + +proc newConfFieldDesc( + field: FieldDescription, parent: ConfFieldDescRef +): ConfFieldDescRef = + ConfFieldDescRef(field: field, parent: parent) + +proc fieldCaseBranch(cf: ConfFieldDesc): NimNode = + if cf.field.caseBranch != nil: + cf.field.caseBranch + elif cf.parent != nil: + fieldCaseBranch(cf.parent[]) + else: + nil + +proc fieldCaseField(cf: ConfFieldDesc): NimNode = + if cf.field.caseField != nil: + cf.field.caseField + elif cf.parent != nil: + fieldCaseField(cf.parent[]) + else: + nil + +proc validateFlattenOptions(field: FieldDescription, children: openArray[ConfFieldDesc]) = + ## Validate flatten tuple options exist + doAssert field.readPragma"flatten" != nil + let ftn = field.readPragma"flatten" + if ftn.kind == nnkTupleConstr: + if ftn.len > 0 and ftn[0].kind != nnkExprColonExpr: + error "invalid flatten options, expected tuple of (k: v)", ftn + var found = newSeq[NimNode]() + for cf in children: + for x in ftn: + if eqIdent(x[0], cf.field.name): + found.add x[0] + for x in ftn: + if x[0] notin found: + error "invalid flatten option: " & $x[0], ftn + +proc confFields(typeImpl: NimNode, parent: ConfFieldDescRef = nil): seq[ConfFieldDesc] = + result = newSeq[ConfFieldDesc]() + for field in recordFields(typeImpl): + let ftn = field.readPragma"flatten" + if ftn != nil: + let firstChildIdx = result.len + for cf in confFields(getImpl(field.typ), newConfFieldDesc(field, parent)): + result.add cf + validateFlattenOptions(field, toOpenArray(result, firstChildIdx, result.len-1)) + else: + result.add ConfFieldDesc(field: field, parent: parent) + +proc genFieldDotExpr(cf: ConfFieldDesc): NimNode = + if cf.parent != nil: + dotExpr(genFieldDotExpr(cf.parent[]), cf.field.name) + else: + cf.field.name + +proc fullFieldName(cf: ConfFieldDesc): string = + if cf.parent != nil: + $fullFieldName(cf.parent[]) & "Dot" & $cf.field.name + else: + $cf.field.name + +proc fieldCaseFieldFullName(cf: ConfFieldDesc): string = + if cf.field.caseField != nil: + if cf.parent != nil: + fullFieldName(cf.parent[]) & "Dot" & $cf.field.caseField.getFieldName + else: + $cf.field.caseField.getFieldName + else: + doAssert cf.parent != nil, "caseField not found" + fieldCaseFieldFullName(cf.parent[]) + +proc flattenDefaultValue(field: FieldDescription, parent: ConfFieldDescRef): NimNode = + if parent == nil: + return nil + let ancestorVal = flattenDefaultValue(field, parent[].parent) + if ancestorVal != nil: + return ancestorVal + let ftn = parent[].field.readPragma"flatten" + case ftn.kind + of nnkSym: nil + of nnkTupleConstr: + var ret: NimNode = nil + for x in ftn: + if eqIdent(x[0], field.name): + ret = x[1] + ret + else: + error "Bad flatten pragma", ftn + nil + +proc readDefaultValueOverride(cf: ConfFieldDesc): NimNode = + flattenDefaultValue(cf.field, cf.parent) + +proc readDefaultValue(cf: ConfFieldDesc): NimNode = + result = cf.readDefaultValueOverride() + if result == nil: + result = cf.field.readPragma"defaultValue" + proc generateFieldSetters(RecordType: NimNode): NimNode = var recordDef = getImpl(RecordType) let makeDefaultValue = bindSym"makeDefaultValue" @@ -824,17 +928,21 @@ proc generateFieldSetters(RecordType: NimNode): NimNode = result = newTree(nnkStmtListExpr) var settersArray = newTree(nnkBracket) - for field in recordFields(recordDef): + for cf in confFields(recordDef): + let field = cf.field var - setterName = ident($field.name & "Setter") + setterName = ident(cf.fullFieldName() & "Setter") fieldName = field.name namePragma = field.readPragma"name" - paramName = if namePragma != nil: namePragma - else: fieldName + paramName = + if namePragma != nil: + namePragma + else: + fieldName configVar = ident "config" - configField = newTree(nnkDotExpr, configVar, fieldName) - defaultValue = field.readPragma"defaultValue" - completerName = ident($field.name & "Complete") + configField = dotExpr(configVar, genFieldDotExpr(cf)) + defaultValue = cf.readDefaultValue() + completerName = ident(cf.fullFieldName() & "Complete") isFieldDiscriminator = newLit field.isDiscriminator defaultValueDesc = field.readPragma"defaultValueDesc" defaultValueHelp = @@ -849,7 +957,7 @@ proc generateFieldSetters(RecordType: NimNode): NimNode = newLit(repr defaultValue) else: newLit("") - defaultValueHelpName = ident($field.name & "DefaultValueHelp") + defaultValueHelpName = ident(cf.fullFieldName() & "DefaultValueHelp") if defaultValue == nil: defaultValue = newCall(makeDefaultValue, newTree(nnkTypeOfExpr, configField)) @@ -917,6 +1025,8 @@ proc generateFieldSetters(RecordType: NimNode): NimNode = debugMacroResult "Field Setters" func checkDuplicate(cmd: CmdInfo, opt: OptInfo, fieldName: NimNode) = + if opt.kind == Discriminator and opt.isCommand: + return for x in cmd.opts: if opt.name == x.name: error "duplicate name detected: " & opt.name, fieldName @@ -955,13 +1065,14 @@ proc cmdInfoFromType(T: NimNode): CmdInfo = var recordDef = getImpl(T) - discriminatorFields = newSeq[OptInfo]() + discriminatorFields = newSeq[(string, OptInfo)]() fieldIdx = 0 - for field in recordFields(recordDef): + for cf in confFields(recordDef): let + field = cf.field isImplicitlySelectable = field.readPragma"implicitlySelectable" != nil - defaultValue = field.readPragma"defaultValue" + defaultValue = cf.readDefaultValue() obsoleteMsg = field.readPragma"obsolete" separator = field.readPragma"separator" longDesc = field.readPragma"longDesc" @@ -992,7 +1103,7 @@ proc cmdInfoFromType(T: NimNode): CmdInfo = inc fieldIdx if field.isDiscriminator: - discriminatorFields.add opt + discriminatorFields.add (cf.fullFieldName(), opt) let cmdType = field.typ.getImpl[^1] if cmdType.kind != nnkEnumTy: error "Only enums are supported as case object discriminators", field.name @@ -1018,20 +1129,25 @@ proc cmdInfoFromType(T: NimNode): CmdInfo = 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) + let caseField = cf.fieldCaseField() + let caseBranch = cf.fieldCaseBranch() + if caseField != nil and caseBranch != nil: + let fieldName = cf.fieldCaseFieldFullName() + var discriminator: OptInfo + for (name, opt) in discriminatorFields: + if fieldName == name: + discriminator = opt if discriminator == nil: - error "Unable to find " & $fieldName + error "Unable to find " & $caseField.getFieldName - if field.caseBranch.kind == nnkElse: + if caseBranch.kind == nnkElse: error "Sub-command parameters cannot appear in an else branch. " & - "Please specify the sub-command branch precisely", field.caseBranch[0] + "Please specify the sub-command branch precisely", caseBranch[0] # support multiple subcommands in the same branch; skip branch body - for enumValIdx in 0 .. field.caseBranch.len - 2: - var branchEnumVal = field.caseBranch[enumValIdx] + for enumValIdx in 0 .. caseBranch.len - 2: + var branchEnumVal = caseBranch[enumValIdx] if branchEnumVal.kind == nnkDotExpr: branchEnumVal = branchEnumVal[1] let cmd = findCmd(discriminator.subCmds, $branchEnumVal) @@ -1060,6 +1176,8 @@ macro configurationRtti(RecordType: type): untyped = result = newTree(nnkPar, newLitFixed cmdInfo, fieldSetters) + debugMacroResult "configurationRtti" + when hasSerialization: template addConfigFileImpl( secondarySources: auto, diff --git a/confutils/config_file.nim b/confutils/config_file.nim index b1b3eea..b61dc81 100644 --- a/confutils/config_file.nim +++ b/confutils/config_file.nim @@ -9,7 +9,8 @@ import std/[tables, macrocache], - stew/shims/macros + stew/shims/macros, + ./utils {.warning[UnusedImport]:off.} import @@ -39,22 +40,26 @@ type isCaseBranch: bool isDiscriminator: bool isIgnore: bool + isFlatten: bool - GeneratedFieldInfo = object - isIgnore: bool - isCommandOrArgument: bool - path: seq[string] - - OriginalToGeneratedFields = OrderedTable[string, GeneratedFieldInfo] + ConfFileSectionTail = object + node: ConfFileSection + path: seq[ConfFileSection] SectionParam = object isCommandOrArgument: bool isIgnore: bool defaultValue: string namePragma: string + isFlatten: bool {.push gcsafe, raises: [].} +template debugMacroResult(macroName: string) {.dirty.} = + when defined(debugMacros) or defined(debugConfutils): + echo "\n-------- ", macroName, " ----------------------" + echo result.repr + func isOption(n: NimNode): bool = if n.kind != nnkBracketExpr: return false eqIdent(n[0], "Option") @@ -77,15 +82,27 @@ proc generateOptionalField(fieldName: NimNode, fieldType: NimNode): NimNode = let right = if isOption(fieldType): fieldType else: makeOption(fieldType) newIdentDefs(fieldName, right) +proc traverseRecList(recList: NimNode, parent: ConfFileSection): seq[ConfFileSection] + proc traverseIdent(ident: NimNode, typ: NimNode, isDiscriminator: bool, param = SectionParam()): ConfFileSection = ident.expectKind nnkIdent - ConfFileSection(fieldName: $ident, - namePragma: param.namePragma, typ: typ, - defaultValue: param.defaultValue, - isCommandOrArgument: param.isCommandOrArgument, - isDiscriminator: isDiscriminator, - isIgnore: param.isIgnore) + if param.isFlatten: + let confTypeImpl = typ.getImpl + let conf = ConfFileSection( + fieldName: $ident, typ: typ, isFlatten: param.isFlatten + ) + conf.children = traverseRecList(confTypeImpl[2][2], conf) + conf + else: + ConfFileSection( + fieldName: $ident, + namePragma: param.namePragma, typ: typ, + defaultValue: param.defaultValue, + isCommandOrArgument: param.isCommandOrArgument, + isDiscriminator: isDiscriminator, + isIgnore: param.isIgnore + ) proc traversePostfix(postfix: NimNode, typ: NimNode, isDiscriminator: bool, param = SectionParam()): ConfFileSection = @@ -128,12 +145,16 @@ proc traversePragma(pragma: NimNode): SectionParam = result.isCommandOrArgument = true elif sym == "ignore": result.isIgnore = true + elif sym == "flatten": + result.isFlatten = true of nnkExprColonExpr: let pragma = $child[0] if pragma == "defaultValue": result.defaultValue = repr(shortEnumName(child[1])) elif pragma == "name": result.namePragma = $child[1] + elif pragma == "flatten": + result.isFlatten = true else: raiseAssert "[Pragma] Unsupported child node:\n" & child.treeRepr @@ -172,8 +193,6 @@ proc traverseIdentDefs(identDefs: NimNode, parent: ConfFileSection, else: raiseAssert "[IdentDefs] Unsupported child node:\n" & child.treeRepr -proc traverseRecList(recList: NimNode, parent: ConfFileSection): seq[ConfFileSection] - proc traverseOfBranch(ofBranch: NimNode, parent: ConfFileSection): ConfFileSection = ofBranch.expectKind nnkOfBranch result = ConfFileSection(fieldName: repr(shortEnumName(ofBranch[0])), isCaseBranch: true) @@ -250,28 +269,43 @@ proc generateTypes(root: ConfFileSection): seq[NimNode] = var types = generateTypes(child) recList.add generateOptionalField(child.fieldName.ident, types[0][0]) result.add types + elif child.isFlatten: + var types = generateTypes(child) + types[0][2][2].expectKind nnkRecList + recList.add types[0][2][2] + for i in 1 ..< types.len: + result.add types[i] else: recList.add generateOptionalField(child.getRenamedName.ident, child.typ) result[index].putRecList(recList) -proc generateSettersPaths(node: ConfFileSection, - result: var OriginalToGeneratedFields, - pathsCache: var seq[string]) = - pathsCache.add node.getRenamedName +proc generateConfTails( + node: ConfFileSection, + result: var seq[ConfFileSectionTail], + pathsCache: var seq[ConfFileSection] +) = + pathsCache.add node if node.children.len == 0: - result[node.fieldName] = GeneratedFieldInfo( - isIgnore: node.isIgnore, - isCommandOrArgument: node.isCommandOrArgument, + result.add ConfFileSectionTail( + node: node, path: pathsCache, ) else: for child in node.children: - generateSettersPaths(child, result, pathsCache) - pathsCache.del pathsCache.len - 1 + generateConfTails(child, result, pathsCache) + pathsCache.setLen pathsCache.len - 1 -proc generateSettersPaths(root: ConfFileSection, pathsCache: var seq[string]): OriginalToGeneratedFields = +proc generateConfTails(root: ConfFileSection, pathsCache: var seq[ConfFileSection]): seq[ConfFileSectionTail] = for child in root.children: - generateSettersPaths(child, result, pathsCache) + generateConfTails(child, result, pathsCache) + +proc fullFieldName(cft: ConfFileSectionTail): string = + result = "" + for cf in cft.path: + if cf.isFlatten: + result.add cf.fieldName + result.add "Dot" + result.add cft.node.fieldName template cfSetter(a, b: untyped): untyped = when a is Option: @@ -279,7 +313,7 @@ template cfSetter(a, b: untyped): untyped = else: a = b -proc generateSetters(confType, CF: NimNode, fieldsPaths: OriginalToGeneratedFields): +proc generateSetters(confType, CF: NimNode, cfst: seq[ConfFileSectionTail]): (NimNode, NimNode, int) = var procs = newStmtList() @@ -287,8 +321,8 @@ proc generateSetters(confType, CF: NimNode, fieldsPaths: OriginalToGeneratedFiel numSetters = 0 let c = "c".ident - for field, param in fieldsPaths: - if param.isCommandOrArgument or param.isIgnore: + for cf in cfst: + if cf.node.isCommandOrArgument or cf.node.isIgnore: assignments.add quote do: result.setters[`numSetters`] = defaultConfigFileSetter inc numSetters @@ -296,22 +330,27 @@ proc generateSetters(confType, CF: NimNode, fieldsPaths: OriginalToGeneratedFiel var fieldPath = c var condition: NimNode - for fld in param.path: - fieldPath = newDotExpr(fieldPath, fld.ident) - let fieldChecker = newDotExpr(fieldPath, "isSome".ident) - if condition == nil: - condition = fieldChecker + let configVar = ident "config" + var configField = configVar + for node in cf.path: + if node.isFlatten: + configField = dotExpr(configField, ident node.fieldName) 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 + fieldPath = newDotExpr(fieldPath, node.getRenamedName.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) + configField = dotExpr(configField, ident cf.node.fieldName) + + let setterName = genSym(nskProc, cf.fullFieldName() & "CFSetter") procs.add quote do: - proc `setterName`(s: var `confType`, cf: ref `CF`): bool {.nimcall, gcsafe.} = + proc `setterName`(`configVar`: var `confType`, cf: ref `CF`): bool {.nimcall, gcsafe.} = for `c` in cf.data: if `condition`: - cfSetter(s.`fieldIdent`, `fieldPath`) + cfSetter(`configField`, `fieldPath`) return true assignments.add quote do: @@ -321,13 +360,13 @@ proc generateSetters(confType, CF: NimNode, fieldsPaths: OriginalToGeneratedFiel result = (procs, assignments, numSetters) proc generateConfigFileSetters(confType, optType: NimNode, - fieldsPaths: OriginalToGeneratedFields): NimNode = + cfs: seq[ConfFileSectionTail]): NimNode = let CF = ident "SecondarySources" T = confType.getType[1] optT = optType[0][0] SetterProcType = genSym(nskType, "SetterProcType") - (setterProcs, assignments, numSetters) = generateSetters(T, CF, fieldsPaths) + (setterProcs, assignments, numSetters) = generateSetters(T, CF, cfs) stmtList = quote do: type `SetterProcType` = proc( @@ -358,12 +397,14 @@ macro generateSecondarySources*(ConfType: type): untyped = model = generateConfigFileModel(ConfType) modelType = generateTypes(model) var - pathsCache: seq[string] + pathsCache: seq[ConfFileSection] result = newTree(nnkStmtList) result.add newTree(nnkTypeSection, modelType) - let settersPaths = model.generateSettersPaths(pathsCache) - result.add generateConfigFileSetters(ConfType, result[^1], settersPaths) + let confTails = model.generateConfTails(pathsCache) + result.add generateConfigFileSetters(ConfType, result[^1], confTails) + + debugMacroResult "ConfigFile SecondarySources" {.pop.} diff --git a/confutils/defs.nim b/confutils/defs.nim index 4f9fe8c..9a5659a 100644 --- a/confutils/defs.nim +++ b/confutils/defs.nim @@ -69,6 +69,8 @@ template hidden* {.pragma.} template ignore* {.pragma.} template debug* {.pragma.} template inlineConfiguration* {.pragma.} +template flatten*(v: untyped) {.pragma.} +template flatten* {.pragma.} template implicitlySelectable* {.pragma.} ## This can be applied to a case object discriminator diff --git a/confutils/utils.nim b/confutils/utils.nim new file mode 100644 index 0000000..10619fc --- /dev/null +++ b/confutils/utils.nim @@ -0,0 +1,18 @@ +# confutils +# Copyright (c) 2018-2025 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. + +import std/macros + +proc dotExpr*(a, b: NimNode): NimNode = + ## Return merged dot expr of `a.b`; + ## `a` or `b` can be dot expr + if b.kind == nnkDotExpr: + dotExpr(dotExpr(a, b[0]), b[1]) + else: + newDotExpr(a, b) diff --git a/tests/config_files/flatten.toml b/tests/config_files/flatten.toml new file mode 100644 index 0000000..6e222ca --- /dev/null +++ b/tests/config_files/flatten.toml @@ -0,0 +1,4 @@ +top-opt1 = "foo" +top-opt2 = true +outer-arg1 = "bar" +top-opt3 = "baz" diff --git a/tests/config_files/flatten_cmd.toml b/tests/config_files/flatten_cmd.toml new file mode 100644 index 0000000..948cd2f --- /dev/null +++ b/tests/config_files/flatten_cmd.toml @@ -0,0 +1,8 @@ +top-opt1 = "foo" +top-opt2 = true +outer-arg = "bar" + +[outerCmd1] +top-opt1 = "baz" +top-opt2 = true +outer-arg1 = "quz" diff --git a/tests/help/snapshots/test_flatten_default_value.txt b/tests/help/snapshots/test_flatten_default_value.txt new file mode 100644 index 0000000..13f3df0 --- /dev/null +++ b/tests/help/snapshots/test_flatten_default_value.txt @@ -0,0 +1,13 @@ +Usage: + +test_flatten_default_value [OPTIONS]... + +The following options are available: + + --help Show this help message and exit. + --opt1 some int [=9000]. + --opt2 some int [=8000]. + --opt3 some str [=abc]. + --opt4 some str [=abc]. + --opt5 some str [=abc]. + --opt6 some bool [=true]. \ No newline at end of file diff --git a/tests/help/test_flatten_default_value.nim b/tests/help/test_flatten_default_value.nim new file mode 100644 index 0000000..ad1bad9 --- /dev/null +++ b/tests/help/test_flatten_default_value.nim @@ -0,0 +1,60 @@ +# confutils +# Copyright (c) 2018-2025 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. + +import ../../confutils + +const intConst = 9000 +const strConst = "abc" + +proc strProc: string {.compileTime.} = "abc" +template strTpl: untyped = "abc" + +type + TestOptsConf = object + opt1 {. + defaultValue: 123 + desc: "some int" + name: "opt1" }: int + + opt2 {. + defaultValue: 123 + desc: "some int" + name: "opt2" }: int + + opt3 {. + defaultValue: "xyz" + desc: "some str" + name: "opt3" }: string + + opt4 {. + defaultValue: "xyz" + desc: "some str" + name: "opt4" }: string + + opt5 {. + defaultValue: "xyz" + desc: "some str" + name: "opt5" }: string + + opt6 {. + defaultValue: false + desc: "some bool" + name: "opt6" }: bool + + TestConf = object + opts {.flatten: ( + opt1: intConst, + opt2: 8000, + opt3: strConst, + opt4: strProc(), + opt5: strTpl(), + opt6: true + ).}: TestOptsConf + +let c = TestConf.load(termWidth = int.high) diff --git a/tests/test_all.nim b/tests/test_all.nim index c403b18..3e94ecb 100644 --- a/tests/test_all.nim +++ b/tests/test_all.nim @@ -13,6 +13,7 @@ import test_dispatch, test_duplicates, test_envvar, + test_flatten_pragma, test_ignore, test_multi_case_values, test_nested_cmd, diff --git a/tests/test_flatten_pragma.nim b/tests/test_flatten_pragma.nim new file mode 100644 index 0000000..d69c67e --- /dev/null +++ b/tests/test_flatten_pragma.nim @@ -0,0 +1,519 @@ +# confutils +# Copyright (c) 2018-2025 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. + +import std/os, unittest2, toml_serialization, ../confutils + +const flattenFilePath = "tests" / "config_files" + +template loadFile(T, file): untyped = + proc ( + config: T, sources: ref SecondarySources + ) {.raises: [ConfigurationError].} = + sources.addConfigFile(Toml, InputFile(flattenFilePath / file)) + +type + TopOptsConf = object + opt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" + name: "top-opt1" .}: string + + opt2 {. + desc: "top opt 2" + defaultValue: false + name: "top-opt2" .}: bool + +suite "test top opts": + test "top opts": + let conf = TopOptsConf.load(cmdLine = @[ + "--top-opt1=foobar" + ]) + check: + conf.opt1 == "foobar" + conf.opt2 == false + + test "top opts file": + let conf = TopOptsConf.load(secondarySources = loadFile(TopOptsConf, "flatten.toml")) + check: + conf.opt1 == "foo" + conf.opt2 == true + +suite "test flatten top opts": + type + TestConfFlat = object + topOpts {.flatten.}: TopOptsConf + + test "top opts flat": + let conf = TestConfFlat.load(cmdLine = @[ + "--top-opt1=foobar", + "--top-opt2=true" + ]) + check: + conf.topOpts.opt1 == "foobar" + conf.topOpts.opt2 == true + + test "top opts flat defaults": + let conf = TestConfFlat.load(cmdLine = newSeq[string]()) + check: + conf.topOpts.opt1 == "top_opt_1" + conf.topOpts.opt2 == false + + test "top opts flat file": + let conf = TestConfFlat.load(secondarySources = loadFile(TestConfFlat, "flatten.toml")) + check: + conf.topOpts.opt1 == "foo" + conf.topOpts.opt2 == true + +suite "test flatten top opts with extra opt": + type + TestConfFlat = object + topOpts {.flatten.}: TopOptsConf + outerArg1 {. + defaultValue: "outerArg1 default" + desc: "outerArg1 desc" + name: "outer-arg1" }: string + + test "top opts arg": + let conf = TestConfFlat.load(cmdLine = @[ + "--top-opt1=foobar", + "--top-opt2=true", + "--outer-arg1=bazquz" + ]) + check: + conf.topOpts.opt1 == "foobar" + conf.topOpts.opt2 == true + conf.outerArg1 == "bazquz" + + test "top opts arg defaults": + let conf = TestConfFlat.load(cmdLine = newSeq[string]()) + check: + conf.topOpts.opt1 == "top_opt_1" + conf.topOpts.opt2 == false + conf.outerArg1 == "outerArg1 default" + + test "top opts arg file": + let conf = TestConfFlat.load(secondarySources = loadFile(TestConfFlat, "flatten.toml")) + check: + conf.topOpts.opt1 == "foo" + conf.topOpts.opt2 == true + conf.outerArg1 == "bar" + +suite "test nested flatten top opts": + type + TopOptsConfFlat = object + opts {.flatten.}: TopOptsConf + opt3 {. + desc: "top opt 3" + defaultValue: "top_opt_3" + name: "top-opt3" .}: string + + TestConfFlat = object + topOpts {.flatten.}: TopOptsConfFlat + outerArg1 {. + defaultValue: "outerArg1 default" + desc: "outerArg1 desc" + name: "outer-arg1" }: string + + test "top opts nested": + let conf = TestConfFlat.load(cmdLine = @[ + "--top-opt1=foo", + "--top-opt2=true", + "--top-opt3=bar", + "--outer-arg1=baz" + ]) + check: + conf.topOpts.opts.opt1 == "foo" + conf.topOpts.opts.opt2 == true + conf.topOpts.opt3 == "bar" + conf.outerArg1 == "baz" + + test "top opts nested defaults": + let conf = TestConfFlat.load(cmdLine = newSeq[string]()) + check: + conf.topOpts.opts.opt1 == "top_opt_1" + conf.topOpts.opts.opt2 == false + conf.topOpts.opt3 == "top_opt_3" + conf.outerArg1 == "outerArg1 default" + + test "top opts nested file": + let conf = TestConfFlat.load( + secondarySources = loadFile(TestConfFlat, "flatten.toml") + ) + check: + conf.topOpts.opts.opt1 == "foo" + conf.topOpts.opts.opt2 == true + conf.topOpts.opt3 == "baz" + conf.outerArg1 == "bar" + +suite "test flatten option redefinition": + test "redefine name top-opt1": + type + TopOptsConfConflict = object + opt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" + name: "top-opt1" .}: string + + TestConfConflict = object + topOpts {.flatten.}: TopOptsConfConflict + outerArg1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" + name: "top-opt1" .}: string + + check not compiles(TestConfConflict.load()) + + test "redefine field opt1": + type + TopOptsConfConflict = object + opt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" .}: string + + TestConfConflict = object + topOpts {.flatten.}: TopOptsConfConflict + opt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" .}: string + + check not compiles(TestConfConflict.load()) + + test "redefine field name opt1": + type + TopOptsConfConflict = object + topOpt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" + name: "opt1" .}: string + + TestConfConflict = object + topOpts {.flatten.}: TopOptsConfConflict + opt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" .}: string + + check not compiles(TestConfConflict.load()) + +type + OuterCmd = enum + noCommand + outerCmd1 + +suite "test flatten opts in subcommand": + type + TestConfCmd = object + case cmd {. + command + defaultValue: OuterCmd.noCommand }: OuterCmd + of OuterCmd.noCommand: + opts {.flatten.}: TopOptsConf + outerArg {. + defaultValue: "outerArg default" + desc: "outerArg desc" + name: "outer-arg" }: string + of OuterCmd.outerCmd1: + opts1 {.flatten.}: TopOptsConf + outerArg1 {. + defaultValue: "outerArg1 default" + desc: "outerArg1 desc" + name: "outer-arg1" }: string + + test "top opts cmd": + let conf = TestConfCmd.load(cmdLine = @[ + "--top-opt1=foobar", + "--top-opt2=true", + "--outer-arg=bazquz" + ]) + check: + conf.cmd == OuterCmd.noCommand + conf.opts.opt1 == "foobar" + conf.opts.opt2 == true + conf.outerArg == "bazquz" + + test "top opts cmd 1": + let conf = TestConfCmd.load(cmdLine = @[ + "outerCmd1", + "--top-opt1=foobar", + "--top-opt2=true", + "--outer-arg1=bazquz" + ]) + check: + conf.cmd == OuterCmd.outerCmd1 + conf.opts1.opt1 == "foobar" + conf.opts1.opt2 == true + conf.outerArg1 == "bazquz" + + test "top opts cmd 1 defaults": + let conf = TestConfCmd.load(cmdLine = @[ + "outerCmd1" + ]) + check: + conf.cmd == OuterCmd.outerCmd1 + conf.opts1.opt1 == "top_opt_1" + conf.opts1.opt2 == false + conf.outerArg1 == "outerArg1 default" + + test "top opts cmd file": + let conf = TestConfCmd.load( + secondarySources = loadFile(TestConfCmd, "flatten_cmd.toml") + ) + check: + conf.cmd == OuterCmd.noCommand + conf.opts.opt1 == "foo" + conf.opts.opt2 == true + conf.outerArg == "bar" + + test "top opts cmd 1 file": + let conf = TestConfCmd.load( + cmdLine = @["outerCmd1"], + secondarySources = loadFile(TestConfCmd, "flatten_cmd.toml") + ) + check: + conf.cmd == OuterCmd.outerCmd1 + conf.opts1.opt1 == "baz" + conf.opts1.opt2 == true + conf.outerArg1 == "quz" + +type + Lvl1Cmd = enum + lvlCmd1 + +suite "test one lvl flatten subcommand": + type + TopSubCmdConf = object + case cmd {.command.}: Lvl1Cmd + of Lvl1Cmd.lvlCmd1: + lvl1Arg1 {. + defaultValue: "lvl1Arg1 default" + desc: "lvl1Arg1 desc" + name: "lvl1-arg1" }: string + + TestConfSubCmdFlat = object + topCmd {.flatten.}: TopSubCmdConf + + test "top cmd": + let conf = TopSubCmdConf.load(cmdLine = @[ + "lvlCmd1", + "--lvl1-arg1=foo" + ]) + check: + conf.cmd == Lvl1Cmd.lvlCmd1 + conf.lvl1Arg1 == "foo" + + test "top cmd flatten": + let conf = TestConfSubCmdFlat.load(cmdLine = @[ + "lvlCmd1", + "--lvl1-arg1=foo" + ]) + check: + conf.topCmd.cmd == Lvl1Cmd.lvlCmd1 + conf.topCmd.lvl1Arg1 == "foo" + + test "redefine cmd flatten opt": + type + TestConfSubCmdFlatConflict = object + lvl1Arg1 {. + defaultValue: "lvl1Arg1 default" + desc: "lvl1Arg1 desc" + name: "lvl1-arg1" }: string + topCmd {.flatten.}: TopSubCmdConf + + check not compiles(TestConfSubCmdFlatConflict.load()) + +type + TopCmd1 = enum + topLvlCmd1 + topLvlCmd2 + +suite "test two lvls flatten subcommands": + type + TopSubCmdConf = object + case cmd {.command.}: Lvl1Cmd + of Lvl1Cmd.lvlCmd1: + lvl1Arg1 {. + defaultValue: "lvl1Arg1 default" + desc: "lvl1Arg1 desc" + name: "lvl1-arg1" }: string + + TestConfSubCmdFlat = object + case cmd {.command.}: TopCmd1 + of TopCmd1.topLvlCmd1: + topCmd1 {.flatten.}: TopSubCmdConf + of TopCmd1.topLvlCmd2: + topCmd2 {.flatten.}: TopSubCmdConf + + test "topLvlCmd1 lvlCmd1": + let conf = TestConfSubCmdFlat.load(cmdLine = @[ + "topLvlCmd1", + "lvlCmd1", + "--lvl1-arg1=foo" + ]) + check: + conf.cmd == TopCmd1.topLvlCmd1 + conf.topCmd1.cmd == Lvl1Cmd.lvlCmd1 + conf.topCmd1.lvl1Arg1 == "foo" + + test "topLvlCmd2 lvlCmd1": + let conf = TestConfSubCmdFlat.load(cmdLine = @[ + "topLvlCmd2", + "lvlCmd1", + "--lvl1-arg1=foo" + ]) + check: + conf.cmd == TopCmd1.topLvlCmd2 + conf.topCmd2.cmd == Lvl1Cmd.lvlCmd1 + conf.topCmd2.lvl1Arg1 == "foo" + +type + Lvl2Cmd = enum + lvlCmd2 + +suite "test nested flatten subcommands": + type + TopSubCmdConf2 = object + case cmd {.command.}: Lvl2Cmd + of Lvl2Cmd.lvlCmd2: + lvl2Arg1 {. + defaultValue: "lvl2Arg1 default" + desc: "lvl2Arg1 desc" + name: "lvl2-arg1" }: string + + TopSubCmdConf = object + case cmd {.command.}: Lvl1Cmd + of Lvl1Cmd.lvlCmd1: + lvl1Arg1 {. + defaultValue: "lvl1Arg1 default" + desc: "lvl1Arg1 desc" + name: "lvl1-arg1" }: string + + topCmd2 {.flatten.}: TopSubCmdConf2 + + TestConfSubCmdFlat = object + case cmd {.command.}: TopCmd1 + of TopCmd1.topLvlCmd1: + topCmd1 {.flatten.}: TopSubCmdConf + of TopCmd1.topLvlCmd2: + discard + + test "topLvlCmd1 defaults": + let conf = TestConfSubCmdFlat.load(cmdLine = @[ + "topLvlCmd1", + "lvlCmd1", + "lvlCmd2" + ]) + check: + conf.cmd == TopCmd1.topLvlCmd1 + conf.topCmd1.cmd == Lvl1Cmd.lvlCmd1 + conf.topCmd1.topCmd2.cmd == Lvl2Cmd.lvlCmd2 + conf.topCmd1.lvl1Arg1 == "lvl1Arg1 default" + conf.topCmd1.topCmd2.lvl2Arg1 == "lvl2Arg1 default" + + test "topLvlCmd1 lvlCmd1 lvlCmd2": + let conf = TestConfSubCmdFlat.load(cmdLine = @[ + "topLvlCmd1", + "lvlCmd1", + "lvlCmd2", + "--lvl1-arg1=foo", + "--lvl2-arg1=bar" + ]) + check: + conf.cmd == TopCmd1.topLvlCmd1 + conf.topCmd1.cmd == Lvl1Cmd.lvlCmd1 + conf.topCmd1.topCmd2.cmd == Lvl2Cmd.lvlCmd2 + conf.topCmd1.lvl1Arg1 == "foo" + conf.topCmd1.topCmd2.lvl2Arg1 == "bar" + +const opt1Const = "override" +const opt2Const = true +const opt3Const = 123 + +suite "test flatten default value override": + type + OptsConf = object + opt1 {. + desc: "top opt 1" + defaultValue: "top_opt_1" + name: "top-opt1" .}: string + + opt2 {. + desc: "top opt 2" + defaultValue: false + name: "top-opt2" .}: bool + + opt3 {. + desc: "top opt 3" + defaultValue: 111 + name: "top-opt3" .}: int + + TestDefaultLitConf = object + opts {.flatten: (opt1: "override", opt2: true, opt3: 123).}: OptsConf + + TestDefaultConstConf = object + opts {.flatten: (opt1: opt1Const, opt2: opt2Const, opt3: opt3Const).}: OptsConf + + TestDefaultNestedConf = object + opts {.flatten: (opt1: "nested").}: TestDefaultLitConf + + TestDefaultFileConf = object + opts {.flatten: (opt1: "file").}: TopOptsConf + + TestDefaultOptNotFoundConf = object + opts {.flatten: (invalid: "invalid").}: OptsConf + + TestDefaultOptExtraConf = object + opts {.flatten: (opt1: "override", opt2: true, opt3: 123, optExtra: 123).}: OptsConf + + TestDefaultInvalidTupleConf = object + opts {.flatten: (1, 2, 3).}: OptsConf + + test "override with literals": + let conf = TestDefaultLitConf.load(cmdLine = @[]) + check: + conf.opts.opt1 == "override" + conf.opts.opt2 == true + conf.opts.opt3 == 123 + + test "override with literals set opts": + let conf = TestDefaultLitConf.load(cmdLine = @[ + "--top-opt1=foo", + "--top-opt2=false" + ]) + check: + conf.opts.opt1 == "foo" + conf.opts.opt2 == false + conf.opts.opt3 == 123 + + test "override with const": + let conf = TestDefaultConstConf.load(cmdLine = @[]) + check: + conf.opts.opt1 == opt1Const + conf.opts.opt2 == opt2Const + conf.opts.opt3 == opt3Const + + test "override deeply nested": + let conf = TestDefaultNestedConf.load(cmdLine = @[]) + check: + conf.opts.opts.opt1 == "nested" + conf.opts.opts.opt2 == true + conf.opts.opts.opt3 == 123 + + test "defaults from file": + let conf = TestDefaultFileConf.load(secondarySources = loadFile(TestDefaultFileConf, "flatten.toml")) + check: + conf.opts.opt1 == "foo" + conf.opts.opt2 == true + + test "defaults with option not found errors out": + check not compiles(TestDefaultOptNotFoundConf.load(cmdLine = @[])) + + test "defaults with option not found errors out": + check not compiles(TestDefaultOptExtraConf.load(cmdLine = @[])) + + test "defaults with invalid tuple errors out": + check not compiles(TestDefaultInvalidTupleConf.load(cmdLine = @[])) diff --git a/tests/test_help.nim b/tests/test_help.nim index 869a0ba..60eef0e 100644 --- a/tests/test_help.nim +++ b/tests/test_help.nim @@ -130,3 +130,6 @@ suite "help message": test "nims test_cli_example": execCmdTest("nim e " & cmdFlags & " " & helpPath / "test_cli_example.nim", "test_cli_example") + + test "test test_flatten_default_value": + cmdTest("test_flatten_default_value")