From 63260350599fe09d5b92bcfda28939e3f2892203 Mon Sep 17 00:00:00 2001 From: nitely Date: Sat, 1 Nov 2025 02:34:07 -0300 Subject: [PATCH 1/8] wip --- .gitignore | 1 + README.md | 9 + confutils.nim | 125 +++++++- confutils/config_file.nim | 131 +++++--- confutils/defs.nim | 1 + confutils/utils.nim | 18 ++ tests/config_files/flatten.toml | 4 + tests/config_files/flatten_cmd.toml | 8 + tests/test_all.nim | 6 + tests/test_flatten_pragma.nim | 444 ++++++++++++++++++++++++++++ 10 files changed, 685 insertions(+), 62 deletions(-) create mode 100644 confutils/utils.nim create mode 100644 tests/config_files/flatten.toml create mode 100644 tests/config_files/flatten_cmd.toml create mode 100644 tests/test_flatten_pragma.nim 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..71866c4 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,15 @@ 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 implicitlySelectable* {.pragma.} ``` diff --git a/confutils.nim b/confutils.nim index 49b0c33..75a2f6b 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 @@ -809,6 +809,64 @@ 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 confFields(typeImpl: NimNode, parent: ConfFieldDescRef = nil): seq[ConfFieldDesc] = + result = newSeq[ConfFieldDesc]() + for field in recordFields(typeImpl): + if field.readPragma"flatten" != nil: + for cf in confFields(getImpl(field.typ), newConfFieldDesc(field, parent)): + result.add cf + 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 generateFieldSetters(RecordType: NimNode): NimNode = var recordDef = getImpl(RecordType) let makeDefaultValue = bindSym"makeDefaultValue" @@ -816,17 +874,18 @@ 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 configVar = ident "config" - configField = newTree(nnkDotExpr, configVar, fieldName) + configField = dotExpr(configVar, genFieldDotExpr(cf)) defaultValue = field.readPragma"defaultValue" - completerName = ident($field.name & "Complete") + completerName = ident(cf.fullFieldName() & "Complete") isFieldDiscriminator = newLit field.isDiscriminator if defaultValue == nil: @@ -877,6 +936,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 @@ -920,11 +981,12 @@ 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" defaultValueDesc = field.readPragma"defaultValueDesc" @@ -962,7 +1024,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 @@ -988,20 +1050,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) @@ -1030,6 +1097,8 @@ macro configurationRtti(RecordType: type): untyped = result = newTree(nnkPar, newLitFixed cmdInfo, fieldSetters) + debugMacroResult "configurationRtti" + when hasSerialization: template addConfigFileImpl( secondarySources: auto, @@ -1530,4 +1599,28 @@ func load*(f: TypedInputFile): f.ContentType = mixin loadFile loadFile(f.Format, f.string, f.ContentType) +proc flattenedAccessorsImpl(RecordType: NimNode): NimNode = + result = newTree(nnkStmtListExpr) + let T = RecordType.getType[1] + let recordDef = getImpl(T) + for cf in confFields(recordDef): + if cf.parent != nil: + let + configVar = ident "config" + configField = dotExpr(configVar, genFieldDotExpr(cf)) + accessorName = if cf.field.isPublic: + newTree(nnkPostfix, ident("*"), cf.field.name) + else: + ident $cf.field.name + result.add quote do: + template `accessorName`(`configVar`: `T`): untyped = + `configField` + + debugMacroResult "Flattened Accessors" + +macro flattenedAccessors*(Configuration: type): untyped = + ## Generates accessors to omit specifying the ``{.flatten.}`` + ## field name when accessing a flattened option. + flattenedAccessorsImpl(Configuration) + {.pop.} diff --git a/confutils/config_file.nim b/confutils/config_file.nim index b1b3eea..df08f03 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,6 +145,8 @@ 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": @@ -172,8 +191,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 +267,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 +311,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 +319,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 +328,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 +358,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 +395,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..32dd784 100644 --- a/confutils/defs.nim +++ b/confutils/defs.nim @@ -69,6 +69,7 @@ template hidden* {.pragma.} template ignore* {.pragma.} template debug* {.pragma.} template inlineConfiguration* {.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/test_all.nim b/tests/test_all.nim index c403b18..346e454 100644 --- a/tests/test_all.nim +++ b/tests/test_all.nim @@ -22,8 +22,14 @@ import test_parsecmdarg, test_pragma, test_qualified_ident, +<<<<<<< HEAD test_results_opt, test_help +======= + test_nested_cmd, + test_help, + test_flatten_pragma +>>>>>>> b884111 (wip) when defined(windows): import test_winreg diff --git a/tests/test_flatten_pragma.nim b/tests/test_flatten_pragma.nim new file mode 100644 index 0000000..c42c4ae --- /dev/null +++ b/tests/test_flatten_pragma.nim @@ -0,0 +1,444 @@ +# 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" + + test "topLvlCmd1 flattened accessors": + let conf = TestConfSubCmdFlat.load(cmdLine = @[ + "topLvlCmd1", + "lvlCmd1", + "lvlCmd2" + ]) + flattenedAccessors(TestConfSubCmdFlat) + check: + conf.cmd == TopCmd1.topLvlCmd1 + conf.topCmd1.cmd == Lvl1Cmd.lvlCmd1 + conf.topCmd1.topCmd2.cmd == Lvl2Cmd.lvlCmd2 + conf.lvl1Arg1 == "lvl1Arg1 default" + conf.lvl2Arg1 == "lvl2Arg1 default" From 2458c36ac00ed39ee3b1ac141f94a7e7351e82c0 Mon Sep 17 00:00:00 2001 From: nitely Date: Fri, 5 Dec 2025 06:17:03 -0300 Subject: [PATCH 2/8] remove flattenedAccessors --- confutils.nim | 75 +++++++++++-------- confutils/config_file.nim | 2 + confutils/defs.nim | 1 + .../snapshots/test_flatten_default_value.txt | 9 +++ tests/help/test_flatten_default_value.nim | 30 ++++++++ tests/test_all.nim | 7 +- tests/test_flatten_pragma.nim | 34 ++++++--- tests/test_help.nim | 3 + 8 files changed, 113 insertions(+), 48 deletions(-) create mode 100644 tests/help/snapshots/test_flatten_default_value.txt create mode 100644 tests/help/test_flatten_default_value.nim diff --git a/confutils.nim b/confutils.nim index 75a2f6b..2321973 100644 --- a/confutils.nim +++ b/confutils.nim @@ -867,6 +867,34 @@ proc fieldCaseFieldFullName(cf: ConfFieldDesc): string = doAssert cf.parent != nil, "caseField not found" fieldCaseFieldFullName(cf.parent[]) +proc flattenDefaultValue(cf: ConfFieldDesc): NimNode = + if cf.parent == nil: + return nil + let ancestorVal = flattenDefaultValue(cf.parent[]) + if ancestorVal != nil: + return ancestorVal + let ftn = cf.parent[].field.readPragma"flatten" + case ftn.kind + of nnkSym: nil + of nnkTupleConstr: + # XXX validate tuple fields are valid opts + var ret: NimNode = nil + for x in ftn: + if eqIdent(x[0], cf.field.name): + ret = x[1] + ret + else: + error "Bad flatten pragma", ftn + nil + +proc readDefaultValueOverride(cf: ConfFieldDesc): NimNode = + cf.flattenDefaultValue() + +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" @@ -880,11 +908,14 @@ proc generateFieldSetters(RecordType: NimNode): NimNode = 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 = dotExpr(configVar, genFieldDotExpr(cf)) - defaultValue = field.readPragma"defaultValue" + defaultValue = cf.readDefaultValue() completerName = ident(cf.fullFieldName() & "Complete") isFieldDiscriminator = newLit field.isDiscriminator @@ -965,6 +996,9 @@ func findPath(parent, node: CmdInfo): seq[CmdInfo] = func toText(n: NimNode): string = if n == nil: "" elif n.kind in {nnkStrLit..nnkTripleStrLit}: n.strVal + elif n.kind == nnkSym and n.getImpl.kind == nnkConstDef: + # this works for `flatten(v: typed)` + toText(n.getImpl[2]) else: repr(n) func readPragmaFlags(field: FieldDescription): set[OptFlag] = @@ -988,10 +1022,15 @@ proc cmdInfoFromType(T: NimNode): CmdInfo = let field = cf.field isImplicitlySelectable = field.readPragma"implicitlySelectable" != nil - defaultValue = field.readPragma"defaultValue" + defaultValue = cf.readDefaultValue() defaultValueDesc = field.readPragma"defaultValueDesc" - defaultInHelp = if defaultValueDesc != nil: defaultValueDesc - else: defaultValue + defaultInHelp = + if cf.readDefaultValueOverride() != nil: + defaultValue + elif defaultValueDesc != nil: + defaultValueDesc + else: + defaultValue defaultInHelpText = toText(defaultInHelp) obsoleteMsg = field.readPragma"obsolete" separator = field.readPragma"separator" @@ -1599,28 +1638,4 @@ func load*(f: TypedInputFile): f.ContentType = mixin loadFile loadFile(f.Format, f.string, f.ContentType) -proc flattenedAccessorsImpl(RecordType: NimNode): NimNode = - result = newTree(nnkStmtListExpr) - let T = RecordType.getType[1] - let recordDef = getImpl(T) - for cf in confFields(recordDef): - if cf.parent != nil: - let - configVar = ident "config" - configField = dotExpr(configVar, genFieldDotExpr(cf)) - accessorName = if cf.field.isPublic: - newTree(nnkPostfix, ident("*"), cf.field.name) - else: - ident $cf.field.name - result.add quote do: - template `accessorName`(`configVar`: `T`): untyped = - `configField` - - debugMacroResult "Flattened Accessors" - -macro flattenedAccessors*(Configuration: type): untyped = - ## Generates accessors to omit specifying the ``{.flatten.}`` - ## field name when accessing a flattened option. - flattenedAccessorsImpl(Configuration) - {.pop.} diff --git a/confutils/config_file.nim b/confutils/config_file.nim index df08f03..b61dc81 100644 --- a/confutils/config_file.nim +++ b/confutils/config_file.nim @@ -153,6 +153,8 @@ proc traversePragma(pragma: NimNode): SectionParam = 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 diff --git a/confutils/defs.nim b/confutils/defs.nim index 32dd784..409b982 100644 --- a/confutils/defs.nim +++ b/confutils/defs.nim @@ -69,6 +69,7 @@ template hidden* {.pragma.} template ignore* {.pragma.} template debug* {.pragma.} template inlineConfiguration* {.pragma.} +template flatten*(v: typed) {.pragma.} template flatten* {.pragma.} template implicitlySelectable* {.pragma.} 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..b958ca9 --- /dev/null +++ b/tests/help/snapshots/test_flatten_default_value.txt @@ -0,0 +1,9 @@ +Usage: + +test_flatten_default_value [OPTIONS]... + +The following options are available: + + --help Show this help message and exit. + --opt1 tcp port [=9000]. + --opt2 udp port [=8000]. \ 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..ae436ff --- /dev/null +++ b/tests/help/test_flatten_default_value.nim @@ -0,0 +1,30 @@ +# 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 defaultEth2TcpPort = 9000 + +type + TestOptsConf = object + opt1 {. + defaultValue: 123 + defaultValueDesc: "123" + desc: "tcp port" + name: "opt1" }: int + + opt2 {. + defaultValue: 123 + desc: "udp port" + name: "opt2" }: int + + TestConf = object + opts {.flatten: (opt1: defaultEth2TcpPort, opt2: 8000).}: TestOptsConf + +let c = TestConf.load(termWidth = int.high) diff --git a/tests/test_all.nim b/tests/test_all.nim index 346e454..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, @@ -22,14 +23,8 @@ import test_parsecmdarg, test_pragma, test_qualified_ident, -<<<<<<< HEAD test_results_opt, test_help -======= - test_nested_cmd, - test_help, - test_flatten_pragma ->>>>>>> b884111 (wip) when defined(windows): import test_winreg diff --git a/tests/test_flatten_pragma.nim b/tests/test_flatten_pragma.nim index c42c4ae..0c07346 100644 --- a/tests/test_flatten_pragma.nim +++ b/tests/test_flatten_pragma.nim @@ -429,16 +429,26 @@ suite "test nested flatten subcommands": conf.topCmd1.lvl1Arg1 == "foo" conf.topCmd1.topCmd2.lvl2Arg1 == "bar" - test "topLvlCmd1 flattened accessors": - let conf = TestConfSubCmdFlat.load(cmdLine = @[ - "topLvlCmd1", - "lvlCmd1", - "lvlCmd2" - ]) - flattenedAccessors(TestConfSubCmdFlat) +suite "test flatten default value override": + proc opt1Str: string = "override" + + const opt1Const = opt1Str() + + type + TestDefaultLitConf = object + topOpts {.flatten: (opt1: "override", opt2: true).}: TopOptsConf + + TestDefaultConstConf = object + topOpts {.flatten: (opt1: opt1Const).}: TopOptsConf + + test "override with literals": + let conf = TestDefaultLitConf.load(cmdLine = @[]) check: - conf.cmd == TopCmd1.topLvlCmd1 - conf.topCmd1.cmd == Lvl1Cmd.lvlCmd1 - conf.topCmd1.topCmd2.cmd == Lvl2Cmd.lvlCmd2 - conf.lvl1Arg1 == "lvl1Arg1 default" - conf.lvl2Arg1 == "lvl2Arg1 default" + conf.topOpts.opt1 == "override" + conf.topOpts.opt2 == true + + test "override with const": + let conf = TestDefaultConstConf.load(cmdLine = @[]) + check: + conf.topOpts.opt1 == opt1Const + conf.topOpts.opt2 == false 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") From 7653acd4b07e8253eb9db96c33b28a6e658cbf04 Mon Sep 17 00:00:00 2001 From: nitely Date: Wed, 28 Jan 2026 23:16:14 -0300 Subject: [PATCH 3/8] wip --- confutils.nim | 20 ++++++++--- .../snapshots/test_flatten_default_value.txt | 7 ++-- tests/help/test_flatten_default_value.nim | 33 ++++++++++++++++--- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/confutils.nim b/confutils.nim index 2321973..b78b4ea 100644 --- a/confutils.nim +++ b/confutils.nim @@ -867,6 +867,21 @@ proc fieldCaseFieldFullName(cf: ConfFieldDesc): string = doAssert cf.parent != nil, "caseField not found" fieldCaseFieldFullName(cf.parent[]) +proc extractTypedValue(n: NimNode): NimNode = + ## Extract const value; return fresh ident + ## node of `n` if it cannot be extracted + case n.kind + of nnkSym: + let impl = n.getImpl + if impl.kind == nnkConstDef: + extractTypedValue(impl[2]) + else: + ident($n) + of nnkIdent: + ident($n) + else: + n + proc flattenDefaultValue(cf: ConfFieldDesc): NimNode = if cf.parent == nil: return nil @@ -881,7 +896,7 @@ proc flattenDefaultValue(cf: ConfFieldDesc): NimNode = var ret: NimNode = nil for x in ftn: if eqIdent(x[0], cf.field.name): - ret = x[1] + ret = extractTypedValue(x[1]) ret else: error "Bad flatten pragma", ftn @@ -996,9 +1011,6 @@ func findPath(parent, node: CmdInfo): seq[CmdInfo] = func toText(n: NimNode): string = if n == nil: "" elif n.kind in {nnkStrLit..nnkTripleStrLit}: n.strVal - elif n.kind == nnkSym and n.getImpl.kind == nnkConstDef: - # this works for `flatten(v: typed)` - toText(n.getImpl[2]) else: repr(n) func readPragmaFlags(field: FieldDescription): set[OptFlag] = diff --git a/tests/help/snapshots/test_flatten_default_value.txt b/tests/help/snapshots/test_flatten_default_value.txt index b958ca9..4df5537 100644 --- a/tests/help/snapshots/test_flatten_default_value.txt +++ b/tests/help/snapshots/test_flatten_default_value.txt @@ -5,5 +5,8 @@ test_flatten_default_value [OPTIONS]... The following options are available: --help Show this help message and exit. - --opt1 tcp port [=9000]. - --opt2 udp port [=8000]. \ No newline at end of file + --opt1 some int [=9000]. + --opt2 some int [=8000]. + --opt3 some str [=abc]. + --opt4 some str [=abc]. + --opt5 some str [=abc]. \ No newline at end of file diff --git a/tests/help/test_flatten_default_value.nim b/tests/help/test_flatten_default_value.nim index ae436ff..bd4b428 100644 --- a/tests/help/test_flatten_default_value.nim +++ b/tests/help/test_flatten_default_value.nim @@ -9,22 +9,47 @@ import ../../confutils -const defaultEth2TcpPort = 9000 +const intConst = 9000 +const strConst = "abc" + +proc strProc: string {.compileTime.} = "abc" +template strTpl: untyped = "abc" type TestOptsConf = object opt1 {. defaultValue: 123 defaultValueDesc: "123" - desc: "tcp port" + desc: "some int" name: "opt1" }: int opt2 {. defaultValue: 123 - desc: "udp port" + 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 + TestConf = object - opts {.flatten: (opt1: defaultEth2TcpPort, opt2: 8000).}: TestOptsConf + opts {.flatten: ( + opt1: intConst, + opt2: 8000, + opt3: strConst, + opt4: strProc(), + opt5: strTpl() + ).}: TestOptsConf let c = TestConf.load(termWidth = int.high) From 9b7e02af605c4747cf9c62e513fc4943fdda4544 Mon Sep 17 00:00:00 2001 From: nitely Date: Wed, 28 Jan 2026 23:38:22 -0300 Subject: [PATCH 4/8] wip --- confutils.nim | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/confutils.nim b/confutils.nim index b78b4ea..78acd29 100644 --- a/confutils.nim +++ b/confutils.nim @@ -868,17 +868,13 @@ proc fieldCaseFieldFullName(cf: ConfFieldDesc): string = fieldCaseFieldFullName(cf.parent[]) proc extractTypedValue(n: NimNode): NimNode = - ## Extract const value; return fresh ident - ## node of `n` if it cannot be extracted case n.kind of nnkSym: let impl = n.getImpl if impl.kind == nnkConstDef: extractTypedValue(impl[2]) else: - ident($n) - of nnkIdent: - ident($n) + n else: n From e27b4ee35d68f380876d0c76ea82c504b9c60192 Mon Sep 17 00:00:00 2001 From: nitely Date: Wed, 28 Jan 2026 23:54:34 -0300 Subject: [PATCH 5/8] wip --- confutils.nim | 8 ++++---- tests/help/snapshots/test_flatten_default_value.txt | 3 ++- tests/help/test_flatten_default_value.nim | 8 +++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/confutils.nim b/confutils.nim index 78acd29..0a4bb41 100644 --- a/confutils.nim +++ b/confutils.nim @@ -871,10 +871,10 @@ proc extractTypedValue(n: NimNode): NimNode = case n.kind of nnkSym: let impl = n.getImpl - if impl.kind == nnkConstDef: - extractTypedValue(impl[2]) - else: - n + case impl.kind + of nnkConstDef: extractTypedValue(impl[2]) + of nnkStrLit .. nnkTripleStrLit: impl # Nim 1.6 compat + else: n else: n diff --git a/tests/help/snapshots/test_flatten_default_value.txt b/tests/help/snapshots/test_flatten_default_value.txt index 4df5537..13f3df0 100644 --- a/tests/help/snapshots/test_flatten_default_value.txt +++ b/tests/help/snapshots/test_flatten_default_value.txt @@ -9,4 +9,5 @@ The following options are available: --opt2 some int [=8000]. --opt3 some str [=abc]. --opt4 some str [=abc]. - --opt5 some str [=abc]. \ No newline at end of file + --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 index bd4b428..aadfe34 100644 --- a/tests/help/test_flatten_default_value.nim +++ b/tests/help/test_flatten_default_value.nim @@ -43,13 +43,19 @@ type 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() + opt5: strTpl(), + opt6: true ).}: TestOptsConf let c = TestConf.load(termWidth = int.high) From 96f312abc9263ad3286f6d03c8208d537cd4be47 Mon Sep 17 00:00:00 2001 From: nitely Date: Fri, 30 Jan 2026 01:32:22 -0300 Subject: [PATCH 6/8] wip --- confutils.nim | 12 +++---- tests/test_flatten_pragma.nim | 61 +++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/confutils.nim b/confutils.nim index 0a4bb41..3444cc9 100644 --- a/confutils.nim +++ b/confutils.nim @@ -878,20 +878,20 @@ proc extractTypedValue(n: NimNode): NimNode = else: n -proc flattenDefaultValue(cf: ConfFieldDesc): NimNode = - if cf.parent == nil: +proc flattenDefaultValue(field: FieldDescription, parent: ConfFieldDescRef): NimNode = + if parent == nil: return nil - let ancestorVal = flattenDefaultValue(cf.parent[]) + let ancestorVal = flattenDefaultValue(field, parent[].parent) if ancestorVal != nil: return ancestorVal - let ftn = cf.parent[].field.readPragma"flatten" + let ftn = parent[].field.readPragma"flatten" case ftn.kind of nnkSym: nil of nnkTupleConstr: # XXX validate tuple fields are valid opts var ret: NimNode = nil for x in ftn: - if eqIdent(x[0], cf.field.name): + if eqIdent(x[0], field.name): ret = extractTypedValue(x[1]) ret else: @@ -899,7 +899,7 @@ proc flattenDefaultValue(cf: ConfFieldDesc): NimNode = nil proc readDefaultValueOverride(cf: ConfFieldDesc): NimNode = - cf.flattenDefaultValue() + flattenDefaultValue(cf.field, cf.parent) proc readDefaultValue(cf: ConfFieldDesc): NimNode = result = cf.readDefaultValueOverride() diff --git a/tests/test_flatten_pragma.nim b/tests/test_flatten_pragma.nim index 0c07346..7d364b7 100644 --- a/tests/test_flatten_pragma.nim +++ b/tests/test_flatten_pragma.nim @@ -433,22 +433,71 @@ suite "test flatten default value override": proc opt1Str: string = "override" const opt1Const = opt1Str() + const opt2Const = true + const opt3Const = 123 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 - topOpts {.flatten: (opt1: "override", opt2: true).}: TopOptsConf + opts {.flatten: (opt1: "override", opt2: true, opt3: 123).}: OptsConf TestDefaultConstConf = object - topOpts {.flatten: (opt1: opt1Const).}: TopOptsConf + opts {.flatten: (opt1: opt1Const, opt2: opt2Const, opt3: opt3Const).}: OptsConf + + TestDefaultNestedConf = object + opts {.flatten: (opt1: "nested").}: TestDefaultLitConf + + TestDefaultFile = object + opts {.flatten: (opt1: "file").}: TopOptsConf test "override with literals": let conf = TestDefaultLitConf.load(cmdLine = @[]) check: - conf.topOpts.opt1 == "override" - conf.topOpts.opt2 == true + 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.topOpts.opt1 == opt1Const - conf.topOpts.opt2 == false + 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 = TestDefaultFile.load(secondarySources = loadFile(TestDefaultFile, "flatten.toml")) + check: + conf.opts.opt1 == "foo" + conf.opts.opt2 == true From b9ed6c2d7c186eb3d100a8750fabaf2d09bc5b53 Mon Sep 17 00:00:00 2001 From: nitely Date: Fri, 30 Jan 2026 03:20:21 -0300 Subject: [PATCH 7/8] wip --- README.md | 9 +++++++++ confutils.nim | 22 ++++++++++++++++++++-- tests/test_flatten_pragma.nim | 16 ++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71866c4..3050f60 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,15 @@ 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: typed) {.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 diff --git a/confutils.nim b/confutils.nim index 3444cc9..7b5c0c0 100644 --- a/confutils.nim +++ b/confutils.nim @@ -836,12 +836,31 @@ proc fieldCaseField(cf: ConfFieldDesc): NimNode = 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): - if field.readPragma"flatten" != nil: + 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) @@ -888,7 +907,6 @@ proc flattenDefaultValue(field: FieldDescription, parent: ConfFieldDescRef): Nim case ftn.kind of nnkSym: nil of nnkTupleConstr: - # XXX validate tuple fields are valid opts var ret: NimNode = nil for x in ftn: if eqIdent(x[0], field.name): diff --git a/tests/test_flatten_pragma.nim b/tests/test_flatten_pragma.nim index 7d364b7..1b36d52 100644 --- a/tests/test_flatten_pragma.nim +++ b/tests/test_flatten_pragma.nim @@ -462,9 +462,15 @@ suite "test flatten default value override": TestDefaultNestedConf = object opts {.flatten: (opt1: "nested").}: TestDefaultLitConf - TestDefaultFile = object + TestDefaultFileConf = object opts {.flatten: (opt1: "file").}: TopOptsConf + TestDefaultOptNotFoundConf = object + opts {.flatten: (invalid: "invalid").}: OptsConf + + TestDefaultInvalidTupleConf = object + opts {.flatten: (1, 2, 3).}: OptsConf + test "override with literals": let conf = TestDefaultLitConf.load(cmdLine = @[]) check: @@ -497,7 +503,13 @@ suite "test flatten default value override": conf.opts.opts.opt3 == 123 test "defaults from file": - let conf = TestDefaultFile.load(secondarySources = loadFile(TestDefaultFile, "flatten.toml")) + 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 invalid tuple errors out": + check not compiles(TestDefaultInvalidTupleConf.load(cmdLine = @[])) From c0df8ea95dc9e31d0397b8e69f1f0ff31aa710e2 Mon Sep 17 00:00:00 2001 From: nitely Date: Fri, 30 Jan 2026 03:31:42 -0300 Subject: [PATCH 8/8] wip --- tests/test_flatten_pragma.nim | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_flatten_pragma.nim b/tests/test_flatten_pragma.nim index 1b36d52..cfaf7e8 100644 --- a/tests/test_flatten_pragma.nim +++ b/tests/test_flatten_pragma.nim @@ -468,6 +468,9 @@ suite "test flatten default value override": 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 @@ -511,5 +514,8 @@ suite "test flatten default value override": 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 = @[]))