diff --git a/lib/modules.nix b/lib/modules.nix index 21ffbb9591c5..1adee44f7a33 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -1598,6 +1598,28 @@ let inherit priority content; }; + /** + Applies a function to the value inside a definition, + preserving all surrounding properties (`mkForce`, `mkOrder`, `mkIf`, etc.). + */ + mapDefinitionValue = + f: def: + if def ? _type then + if def._type == "merge" then + def // { contents = map (mapDefinitionValue f) def.contents; } + else if def._type == "if" then + def // { content = mapDefinitionValue f def.content; } + else if def._type == "override" then + def // { content = mapDefinitionValue f def.content; } + else if def._type == "order" then + def // { content = mapDefinitionValue f def.content; } + else if def._type == "definition" then + def // { value = mapDefinitionValue f def.value; } + else + f def + else + f def; + mkBefore = mkOrder 500; defaultOrderPriority = 1000; mkAfter = mkOrder 1500; @@ -2302,6 +2324,7 @@ private importApply importJSON importTOML + mapDefinitionValue mergeDefinitions mergeAttrDefinitionsWithPrio mergeOptionDecls # should be private? diff --git a/lib/tests/misc.nix b/lib/tests/misc.nix index e2872dcafce9..021a7eca25b8 100644 --- a/lib/tests/misc.nix +++ b/lib/tests/misc.nix @@ -5114,4 +5114,96 @@ runTests { ); expected = false; }; + + # mapDefinitionValue + + testMapDefinitionValuePlain = { + expr = lib.modules.mapDefinitionValue (x: x + 1) 5; + expected = 6; + }; + + testMapDefinitionValueMkForce = { + expr = lib.modules.mapDefinitionValue (x: x + 1) (lib.mkForce 5); + expected = lib.mkForce 6; + }; + + testMapDefinitionValueMkDefault = { + expr = lib.modules.mapDefinitionValue (x: x + 1) (lib.mkDefault 5); + expected = lib.mkDefault 6; + }; + + testMapDefinitionValueMkOrder = { + expr = lib.modules.mapDefinitionValue (x: x + 1) (lib.mkOrder 500 5); + expected = lib.mkOrder 500 6; + }; + + testMapDefinitionValueMkOverrideNested = { + expr = lib.modules.mapDefinitionValue (x: x + 1) (lib.mkForce (lib.mkOrder 500 5)); + expected = lib.mkForce (lib.mkOrder 500 6); + }; + + testMapDefinitionValueMkIf = { + expr = lib.modules.mapDefinitionValue (x: x + 1) (lib.mkIf true 5); + expected = lib.mkIf true 6; + }; + + testMapDefinitionValueMkMerge = { + expr = lib.modules.mapDefinitionValue (x: x + 1) ( + lib.mkMerge [ + 5 + 10 + ] + ); + expected = lib.mkMerge [ + 6 + 11 + ]; + }; + + testMapDefinitionValueMkDefinition = { + expr = lib.modules.mapDefinitionValue (x: x + 1) ( + lib.mkDefinition { + file = "test"; + value = 5; + } + ); + expected = lib.mkDefinition { + file = "test"; + value = 6; + }; + }; + + testMapDefinitionValueDeep = { + expr = lib.modules.mapDefinitionValue (x: x + 1) (lib.mkIf true (lib.mkForce (lib.mkOrder 500 5))); + expected = lib.mkIf true (lib.mkForce (lib.mkOrder 500 6)); + }; + + testMapDefinitionValueAllNested = { + expr = lib.modules.mapDefinitionValue (x: x + 1) ( + lib.mkMerge [ + (lib.mkIf true ( + lib.mkForce ( + lib.mkOrder 500 ( + lib.mkDefinition { + file = "test"; + value = lib.mkBefore 5; + } + ) + ) + )) + ] + ); + expected = lib.mkMerge [ + (lib.mkIf true ( + lib.mkForce ( + lib.mkOrder 500 ( + lib.mkDefinition { + file = "test"; + value = lib.mkBefore 6; + } + ) + ) + )) + ]; + }; } diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index 515ab169fe43..4eb86ddd4962 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -899,6 +899,19 @@ checkConfigError 'Did you mean .enable., .ebe. or .enabled.\?' config ./error-ty checkConfigError 'Did you mean .services\.myservice\.port. or .services\.myservice\.enable.\?' config.services.myservice ./error-typo-submodule.nix checkConfigError 'Did you mean .services\.nginx\.virtualHosts\."example\.com"\.ssl\.certificate. or .services\.nginx\.virtualHosts\."example\.com"\.ssl\.certificateKey.\?' config.services.nginx.virtualHosts.\"example.com\" ./error-typo-deeply-nested.nix +# types.attrListOf +checkConfigOutput '"ok"' config.assertions ./declare-attrList.nix +checkConfigError 'A definition for option .attrListInt.badValue.a. is not of type .signed integer.. Definition values:' config.attrListIntStrict.badValue ./declare-attrList.nix +checkConfigError 'A definition for option .attrList.badListElem. is not of type .attribute list of string.. Each list element must be a single-key attribute set, but got 2 keys' config.attrListStrict.badListElem ./declare-attrList.nix +checkConfigError 'A definition for option .attrList.badString. is not of type .attribute list of string.. TypeError: Definition values:' config.attrListStrict.badString ./declare-attrList.nix +checkConfigError 'A definition for option .attrList.badListString. is not of type .attribute list of string.. Each list element must be an attribute set, but got string' config.attrListStrict.badListString ./declare-attrList.nix + +# attrListWith valueMeta.definitions: file propagation +checkConfigError 'the-defs-file\.nix' config.argv ./attrList-valueMeta-definitions-file-diagnostic-forwarding.nix + +# attrListOf does not support type merging +checkConfigError 'The option .merged. in .*/declare-attrList-type-merge.nix. is already declared in .*/declare-attrList-type-merge.nix' config.merged ./declare-attrList-type-merge.nix + cat <0)"; + assert + (attrListOf (enum [ + "a" + "b" + ])).description == "attribute list of (one of \"a\", \"b\")"; + assert + (attrListOf (strMatching "[0-9]+")).description + == "attribute list of string matching the pattern [0-9]+"; + assert + (attrListOf (nonEmptyListOf str)).description == "attribute list of non-empty (list of string)"; + assert (attrListOf (submodule { })).description == "attribute list of (submodule)"; + assert (coercedTo str abort int).description == "signed integer or string convertible to it"; assert (coercedTo int abort str).description == "string or signed integer convertible to it"; assert (coercedTo bool abort str).description == "string or boolean convertible to it"; diff --git a/lib/types.nix b/lib/types.nix index 5a42ada60d84..3cb3c64baf05 100644 --- a/lib/types.nix +++ b/lib/types.nix @@ -20,6 +20,7 @@ let isStorePath isString substring + sort throwIf toDerivation toList @@ -27,6 +28,7 @@ let ; inherit (lib.lists) concatLists + concatMap elemAt filter foldl' @@ -70,6 +72,11 @@ let mergeDefinitions fixupOptionType mergeOptionDecls + defaultOrderPriority + defaultOverridePriority + mkDefinition + mkOrder + mkOverride ; inherit (lib.fileset) isFileset @@ -805,6 +812,179 @@ rec { substSubModules = m: nonEmptyListOf (elemType.substSubModules m); }; + attrListOf = elemType: attrListWith { inherit elemType; }; + + attrListWith = + { + elemType, + asAttrs ? false, + mergeAttrValues ? _name: values: values, + }: + mkOptionType rec { + name = "attrListOf"; + description = "attribute list of ${ + optionDescriptionPhrase (class: class == "noun" || class == "composite") elemType + }"; + descriptionClass = "composite"; + check = { + __functor = _self: x: isList x || isAttrs x; + isV2MergeCoherent = true; + }; + merge = { + __functor = + self: loc: defs: + (self.v2 { inherit loc defs; }).value; + v2 = + { loc, defs }: + let + # Peel order and override properties from a value in any nesting order. + # Returns { value, prio, overridePrio }. + # mkOrder is stripped (we consume it for sorting). + # mkOverride is preserved in value (mergeDefinitions strips it). + peelProperties = + value: + let + type = value._type or null; + in + if type == "order" then + let + inner = peelProperties value.content; + in + { + inherit (inner) value overridePrio; + prio = value.priority; + } + else if type == "override" then + let + inner = peelProperties value.content; + in + { + inherit (inner) prio; + overridePrio = value.priority; + # Re-wrap mkOverride around the inner value (with mkOrder stripped) + value = mkOverride value.priority inner.value; + } + else + { + inherit value; + prio = defaultOrderPriority; + overridePrio = defaultOverridePriority; + }; + + # Extract { file, key, value, prio, overridePrio } from a single-key attrset, + # optionally wrapped in mkOrder at the element level (list format). + extractItem = + file: raw: + let + hasOrder = isType "order" raw; + item = if hasOrder then raw.content else raw; + key = head (attrNames item); + peeled = peelProperties item.${key}; + in + if isAttrs item && length (attrNames item) == 1 then + peeled + // { + inherit file key; + prio = if hasOrder then raw.priority else peeled.prio; + } + else + throw "A definition for option `${showOption loc}' is not of type `${description}'. ${ + if !isAttrs item then + "Each list element must be an attribute set, but got ${builtins.typeOf item}" + else + "Each list element must be a single-key attribute set, but got ${toString (length (attrNames item))} keys" + }.${ + showDefs [ + { + inherit file; + value = raw; + } + ] + }"; + + # Convert a definition to a flat list of { file, key, value, prio, overridePrio } + defToItems = + def: + if isList def.value then + map (extractItem def.file) def.value + else + # isAttrs: properties are on the values directly + map ( + key: + peelProperties def.value.${key} + // { + inherit (def) file; + inherit key; + } + ) (attrNames def.value); + + allItems = concatMap defToItems defs; + + # Per key, find the highest override priority (lowest number) + winningOverridePrio = foldl' ( + acc: item: + let + prev = acc.${item.key} or defaultOverridePriority; + in + if item.overridePrio < prev then + acc // { ${item.key} = item.overridePrio; } + else + # minimize `//` operations + acc + ) { } allItems; + + # Keep only items at the winning override priority for their key + items = sort (a: b: a.prio < b.prio) ( + filter ( + item: item.overridePrio == winningOverridePrio.${item.key} or defaultOverridePriority + ) allItems + ); + + evals = filter (e: e.eval.optionalValue ? value) ( + map (item: { + inherit (item) key file prio; + eval = mergeDefinitions (loc ++ [ item.key ]) elemType [ + { + inherit (item) file value; + } + ]; + }) items + ); + + attrListValue = map (e: { ${e.key} = e.eval.optionalValue.value or e.eval.mergedValue; }) evals; + in + { + headError = checkDefsForError check loc defs; + value = if asAttrs then zipAttrsWith mergeAttrValues attrListValue else attrListValue; + valueMeta.attrList = map (e: e.eval.checkedAndMerged.valueMeta) evals; + /** + The ordered list representation, especially useful when asAttrs is set. + */ + valueMeta.attrListValue = attrListValue; + valueMeta.definitions = map ( + e: + mkDefinition { + inherit (e) file; + value = mkOrder e.prio { ${e.key} = e.eval.optionalValue.value or e.eval.mergedValue; }; + } + ) evals; + }; + }; + emptyValue = { + value = if asAttrs then { } else [ ]; + }; + getSubOptions = prefix: elemType.getSubOptions (prefix ++ [ "*" ]); + getSubModules = elemType.getSubModules; + substSubModules = + m: + attrListWith { + inherit asAttrs mergeAttrValues; + elemType = elemType.substSubModules m; + }; + typeMerge = t: null; # Disable type merging + nestedTypes.elemType = elemType; + }; + attrsOf = elemType: attrsWith { inherit elemType; }; # A version of attrsOf that's lazy in its values at the expense of diff --git a/nixos/doc/manual/development/option-types.section.md b/nixos/doc/manual/development/option-types.section.md index cc195abcc37b..720f5823d068 100644 --- a/nixos/doc/manual/development/option-types.section.md +++ b/nixos/doc/manual/development/option-types.section.md @@ -494,6 +494,47 @@ Composed types are types that take a type as parameter. `listOf Displays the option as `foo.` in the manual. +`types.attrListOf` *`t`* + +: An ordered list of single-attribute attribute sets, where each value is of *`t`* type. + The output is always `[ { name1 = value1; } { name2 = value2; } ... ]`. + + Definitions can be provided in two formats, which may be mixed via `lib.mkMerge`, `imports`, etc: + + - **List format**: `[ { a = 1; } { b = 2; } ]` — each element must be a single-attribute attribute set. + Elements may be wrapped in `lib.mkOrder` (or `lib.mkBefore`/`lib.mkAfter`) to control ordering; + unwrapped elements use the default order priority. + + - **Attribute set format**: `{ a = lib.mkOrder 100 1; b = 2; }` — each name-value pair becomes a single-attribute attribute set in the output. + Values may be wrapped in `lib.mkOrder` (or `lib.mkBefore`/`lib.mkAfter`) to control ordering. + Values without `lib.mkOrder` use the default priority. + + Multiple definitions of the same option are concatenated and then sorted by priority. + Entries at the same priority level preserve their definition order. + +`types.attrListWith` { *`elemType`*, *`asAttrs`* ? false, *`mergeAttrValues`* ? _name: values: values } + +: An ordered list of single-attribute attribute sets, where each value is of *`elemType`* type. + + **Parameters** + + `elemType` (Required) + : Specifies the type of each value in the attribute list. + + `asAttrs` + : When `true`, the option value is an attribute set instead of a list. + Duplicate keys are merged using `mergeAttrValues`. + The ordered list is always available via `valueMeta.attrListValue`. + + `mergeAttrValues` + : A function `name: values: mergedValue` that controls how duplicate keys + are combined when `asAttrs = true`. This is passed as the callback to + `lib.zipAttrsWith`. The `values` list is in order of priority. + By default, all values are collected into a list. + + **Behavior** + + - `attrListWith { elemType = t; }` is equivalent to `attrListOf t` `types.uniq` *`t`*