types.attrList: init (#510698)

This commit is contained in:
Robert Hensing
2026-05-25 11:34:46 +00:00
committed by GitHub
9 changed files with 1333 additions and 0 deletions

View File

@@ -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?

View File

@@ -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;
}
)
)
))
];
};
}

View File

@@ -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 <<EOF
====== module tests ======
$pass Pass

View File

@@ -0,0 +1,25 @@
{ lib, options, ... }:
let
inherit (lib) mkOption mkMerge types;
in
{
imports = [
{
_file = "the-defs-file.nix";
config.flags.my-flag = 3.14;
}
];
options.flags = mkOption {
type = types.attrListWith {
elemType = types.anything;
asAttrs = true;
mergeAttrValues = _name: vs: lib.head vs;
};
};
options.argv = mkOption { type = types.listOf types.str; };
# Feed definitions into argv; the float from the-defs-file.nix should cause
# a type error mentioning that file
config.argv = mkMerge options.flags.valueMeta.definitions;
}

View File

@@ -0,0 +1,12 @@
# Test that attrListOf does not support type merging:
# two declarations of the same option should fail.
{ lib, ... }:
let
inherit (lib) mkOption types;
in
{
imports = [
{ options.merged = mkOption { type = types.attrListOf types.str; }; }
{ options.merged = mkOption { type = types.attrListOf types.str; }; }
];
}

View File

@@ -0,0 +1,925 @@
# Run with:
# cd nixpkgs
# ./lib/tests/modules.sh
{ lib, config, ... }:
let
inherit (lib)
mkOption
mkOrder
mkMerge
mkBefore
mkAfter
mkIf
mkOverride
mkDefault
mkForce
types
;
in
{
options = {
attrList = mkOption {
type = types.lazyAttrsOf (types.attrListOf types.str);
};
attrListInt = mkOption {
type = types.lazyAttrsOf (types.attrListOf types.int);
};
attrListSubmodule = mkOption {
type = types.attrListOf (
types.submodule {
options.port = mkOption {
type = types.int;
description = "Port number";
};
options.host = mkOption {
type = types.str;
default = "localhost";
description = "Hostname";
};
}
);
};
# asAttrs: value is a merged attrset, ordered list in valueMeta
asAttrs = mkOption {
type = types.lazyAttrsOf (
types.attrListWith {
elemType = types.str;
asAttrs = true;
mergeAttrValues = _name: values: lib.last values;
}
);
};
# asAttrs with default mergeAttrValues: duplicates collected into lists
asAttrsDefault = mkOption {
type = types.lazyAttrsOf (
types.attrListWith {
elemType = types.int;
asAttrs = true;
}
);
};
# Strict wrappers that force deep evaluation, for testing error cases
attrListStrict = mkOption {
type = types.lazyAttrsOf types.raw;
};
attrListIntStrict = mkOption {
type = types.lazyAttrsOf types.raw;
};
# either picks attrList when input is list/attrset, int when input is int
eitherAttrListOrInt = mkOption {
type = types.either (types.attrListOf types.str) types.int;
};
eitherAttrListOrIntFallback = mkOption {
type = types.either (types.attrListOf types.str) types.int;
};
eitherIntOrAttrList = mkOption {
type = types.either types.int (types.attrListOf types.str);
};
eitherIntOrAttrListFallback = mkOption {
type = types.either types.int (types.attrListOf types.str);
};
assertions = mkOption { };
};
imports = [
# Second module contributing to multiModule
{
attrListInt.multiModule = [
{ b = 2; }
];
}
];
config = {
# List input: pass-through
attrList.listInput = [
{ a = "alpha"; }
{ b = "beta"; }
];
# Attrset input with explicit ordering
attrList.attrsetOrdered = {
x = mkOrder 200 "x-val";
y = mkOrder 100 "y-val";
};
# Mixed: list elements at default priority, attrset with mkOrder
attrList.mixed = mkMerge [
[
{ m = "from-list"; }
]
{
n = mkOrder 50 "from-attrset";
}
];
# Multiple list definitions from separate modules
attrListInt.multiModule = [
{ a = 1; }
];
# Attrset without mkOrder uses default priority
attrList.attrsetNoOrder = {
foo = "bar";
baz = "qux";
};
# Empty list
attrList.empty = [ ];
# Ordering test: lower priority first
attrList.ordering = mkMerge [
{
last = mkOrder 1500 "last";
}
{
first = mkOrder 500 "first";
}
[
{ middle = "middle"; }
]
];
# List elements support mkOrder/mkBefore/mkAfter
attrList.listOrdering = [
(mkAfter { z = "after"; })
{ m = "default"; }
(mkBefore { a = "before"; })
];
# Plain list entries land at default priority (1000):
# they appear after mkOrder 999 and before mkOrder 1001.
attrList.listDefaultPrio = mkMerge [
{ after = mkOrder 1001 "after"; }
[
{ mid = "list-entry"; }
]
{ before = mkOrder 999 "before"; }
];
# mkBefore and mkAfter
attrList.beforeAfter = mkMerge [
{
z = mkAfter "after";
}
{
a = mkBefore "before";
}
[
{ m = "default"; }
]
];
# mkIf: conditional definition
attrList.withMkIf = mkMerge [
(mkIf true [
{ yes = "included"; }
])
(mkIf false [
{ no = "excluded"; }
])
];
# mkOverride: higher priority override wins
attrList.withOverride = mkMerge [
(mkOverride 100 [
{ replaced = "gone"; }
])
(mkOverride 50 [
{ winner = "wins"; }
])
];
# mkDefault: lower priority than normal
attrList.withDefault = mkMerge [
(mkDefault [
{ default = "overridden"; }
])
[
{ normal = "wins"; }
]
];
# mkForce on the whole option (should discard other defs)
attrList.withForce = mkMerge [
[
{ discarded = "gone"; }
]
(mkForce [
{ forced = "wins"; }
])
];
# mkForce with mkOrder inside
attrList.forceWithOrder = mkForce [
(mkAfter { second = "after"; })
(mkBefore { first = "before"; })
];
# mkForce on individual element values; non-forced entries are discarded.
# Discarded values use a mix of: plain value, mkDefault(abort ...), mkOverride 100 (abort ...).
# The abort variants verify laziness: peelProperties sees the wrapper without forcing the content.
attrListInt.forceElementValue = [
{ a = mkDefault (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
{ a = mkForce 42; }
{ a = mkOverride 100 (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
{ b = 2; }
];
# mkForce on attrset format
attrList.forceAttrset = mkMerge [
[
{ discarded = "gone"; }
]
(mkForce {
x = mkOrder 200 "x-val";
y = mkOrder 100 "y-val";
})
];
# mkForce on repeated key: forced entries override non-forced
attrList.forceRepeatedKey = [
{ x = mkOverride 100 (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
{ x = mkForce "wins"; }
{ x = mkForce "wins 2"; }
];
# mkForce on repeated key across mkMerge
attrList.forceRepeatedKeyMerge = mkMerge [
[
{ x = "unused: overridden by mkForce"; }
{ x = mkDefault (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
]
[
{ x = mkOverride 100 (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
]
[
{ x = mkForce "forced"; }
]
];
# mkForce on repeated key in attrset format across mkMerge
attrList.forceRepeatedKeyAttrs = mkMerge [
{
x = mkDefault (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated");
y = "kept";
}
{ x = mkForce "forced"; }
];
# mkForce only affects the key it's on, other keys survive
attrList.forcePartialAttrs = mkMerge [
{
x = "unused: overridden by mkForce";
y = "normal y";
}
{ x = mkForce "forced x"; }
];
# mkForce in attrset format overrides same key from list format
attrList.forceMixedFormats = mkMerge [
[
{ x = mkOverride 100 (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
{ y = "list y"; }
]
{ x = mkForce "attrset forced x"; }
];
# Nesting: list format, mkOrder on element + mkForce on value
attrList.nestListOrderForce = mkMerge [
[
{ x = mkDefault (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
(mkOrder 500 { x = mkForce "forced-early"; })
(mkOrder 1500 { y = "late"; })
]
[
(mkOrder 100 { z = "earliest"; })
]
];
# Nesting: list format, mkOrder(mkForce(val)) on value
attrList.nestListOrderOfForce = mkMerge [
[
{ x = mkOverride 100 (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
{ y = "plain-early"; }
]
[
{ x = mkOrder 1500 (mkForce "forced-late"); }
{ z = mkOrder 500 "earliest"; }
]
[
{ x = "unused: overridden by mkForce"; }
{ w = mkOrder 1200 "mid"; }
]
];
# Nesting: list format, mkForce(mkOrder(val)) on value
attrList.nestListForceOfOrder = mkMerge [
[
{ x = "unused: overridden by mkForce"; }
{ y = "plain-early"; }
]
[
{ x = mkForce (mkOrder 1500 "forced-late"); }
{ z = mkOrder 500 "earliest"; }
]
[
{ x = mkDefault (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated"); }
{ w = mkOrder 1200 "mid"; }
]
];
# Nesting: attrset format, mkOrder wrapping mkForce
attrList.nestAttrsOrderOfForce = mkMerge [
{
x = mkOverride 100 (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated");
y = "plain-early";
}
{
x = mkOrder 1500 (mkForce "forced-late");
z = mkOrder 500 "earliest";
}
{
x = "unused: overridden by mkForce";
w = mkOrder 1200 "mid";
}
];
# Nesting: attrset format, mkForce wrapping mkOrder
attrList.nestAttrsForceOfOrder = mkMerge [
{
x = "unused: overridden by mkForce";
y = "plain-early";
}
{
x = mkForce (mkOrder 1500 "forced-late");
z = mkOrder 500 "earliest";
}
{
x = mkDefault (abort "overridden by mkForce; laziness guarantee: MUST NOT be evaluated");
w = mkOrder 1200 "mid";
}
];
# mkIf false on individual element value filters it out (list format)
attrListInt.optionalValueList = [
{ a = mkIf true 1; }
{ b = mkIf false 2; }
{ c = 3; }
];
# mkIf false on individual element value filters it out (attrset format)
attrListInt.optionalValueAttrs = {
a = mkIf true 1;
b = mkIf false 2;
c = 3;
};
# submodule elemType: produces real valueMeta
attrListSubmodule = [
{
web = {
port = 80;
};
}
{
db = {
port = 5432;
host = "dbhost";
};
}
];
# asAttrs: unique keys — value is a plain attrset
asAttrs.unique = [
{ a = "alpha"; }
{ b = "beta"; }
];
# asAttrs: duplicate keys — last in order wins
asAttrs.duplicateKeys = mkMerge [
{ x = mkOrder 500 "first"; }
{ x = mkOrder 1500 "last"; }
{ y = "only"; }
];
# asAttrs: with ordering — value is attrset, ordered list in valueMeta
asAttrs.ordered = {
z = mkOrder 200 "z-val";
a = mkOrder 100 "a-val";
};
# asAttrs: with mkForce — forced key overrides
asAttrs.withForce = mkMerge [
{ x = "unused: overridden by mkForce"; }
{
x = mkForce "forced";
y = "kept";
}
];
# asAttrs: empty
asAttrs.empty = [ ];
# asAttrsDefault: unique keys
asAttrsDefault.unique = [
{ a = 1; }
{ b = 2; }
];
# asAttrsDefault: duplicate keys — default collects into lists
asAttrsDefault.duplicates = mkMerge [
{ x = mkOrder 500 10; }
{ x = mkOrder 1500 30; }
{ y = 99; }
[
{ x = 20; }
]
];
# either: attrList branch matches for list input
eitherAttrListOrInt = [
{ a = "hello"; }
{ b = "world"; }
];
# either: int input falls through to int branch
eitherAttrListOrIntFallback = 42;
# either (swapped): int first, attrList second — int input matches int
eitherIntOrAttrList = 42;
# either (swapped): list input falls through to attrList branch
eitherIntOrAttrListFallback = [
{ a = "hello"; }
];
# Bad: string where int expected
attrListInt.badValue = [
{ a = "not-an-int"; }
];
# Bad: list element with multiple keys
attrList.badListElem = [
{
a = "ok";
b = "extra";
}
];
# Bad: plain string instead of list or attrset
attrList.badString = "not-a-container";
# Bad: list element is a bare string, not a singleton attrset
attrList.badListString = [
"not a singleton attribute"
];
attrListStrict = builtins.mapAttrs (k: v: builtins.deepSeq v v) config.attrList;
attrListIntStrict = builtins.mapAttrs (k: v: builtins.deepSeq v v) config.attrListInt;
assertions =
let
c = lib.evalModules {
modules = [ ./declare-attrList.nix ];
};
cfg = c.config;
in
# List input preserves elements
assert
cfg.attrList.listInput == [
{ a = "alpha"; }
{ b = "beta"; }
];
# Attrset input with mkOrder: lower priority comes first
assert
cfg.attrList.attrsetOrdered == [
{ y = "y-val"; }
{ x = "x-val"; }
];
# Mixed input: mkOrder 50 < default 1000
assert
cfg.attrList.mixed == [
{ n = "from-attrset"; }
{ m = "from-list"; }
];
# Multiple definitions from separate modules concatenate
# (import module's definition comes before this module's)
assert
cfg.attrListInt.multiModule == [
{ b = 2; }
{ a = 1; }
];
# Attrset without mkOrder: all at default priority
assert builtins.length cfg.attrList.attrsetNoOrder == 2;
# Empty list stays empty
assert cfg.attrList.empty == [ ];
# List elements support mkOrder/mkBefore/mkAfter
assert
cfg.attrList.listOrdering == [
{ a = "before"; }
{ m = "default"; }
{ z = "after"; }
];
# Plain list entries are at default priority (1000)
assert
cfg.attrList.listDefaultPrio == [
{ before = "before"; }
{ mid = "list-entry"; }
{ after = "after"; }
];
# Ordering: 500 < 1000 (default) < 1500
assert
cfg.attrList.ordering == [
{ first = "first"; }
{ middle = "middle"; }
{ last = "last"; }
];
# mkBefore (500) < default (1000) < mkAfter (1500)
assert
cfg.attrList.beforeAfter == [
{ a = "before"; }
{ m = "default"; }
{ z = "after"; }
];
# mkIf true includes, mkIf false excludes
assert
cfg.attrList.withMkIf == [
{ yes = "included"; }
];
# mkOverride: only lowest priority override survives
assert
cfg.attrList.withOverride == [
{ winner = "wins"; }
];
# mkDefault is overridden by normal definitions
assert
cfg.attrList.withDefault == [
{ normal = "wins"; }
];
# mkForce discards other definitions
assert
cfg.attrList.withForce == [
{ forced = "wins"; }
];
# mkForce with mkOrder inside: ordering still works
assert
cfg.attrList.forceWithOrder == [
{ first = "before"; }
{ second = "after"; }
];
# mkForce on individual element values passes through
assert
cfg.attrListInt.forceElementValue == [
{ a = 42; }
{ b = 2; }
];
# mkForce on attrset format: discards other defs, ordering preserved
assert
cfg.attrList.forceAttrset == [
{ y = "y-val"; }
{ x = "x-val"; }
];
# mkForce on repeated key: forced entries override non-forced
assert
cfg.attrList.forceRepeatedKey == [
{ x = "wins"; }
{ x = "wins 2"; }
];
# mkForce on repeated key across mkMerge: forced wins
assert
cfg.attrList.forceRepeatedKeyMerge == [
{ x = "forced"; }
];
# mkForce on repeated key in attrset format: discards other x, keeps y
assert
cfg.attrList.forceRepeatedKeyAttrs == [
{ y = "kept"; }
{ x = "forced"; }
];
# mkForce only affects its own key
assert
cfg.attrList.forcePartialAttrs == [
{ y = "normal y"; }
{ x = "forced x"; }
];
# mkForce in attrset format overrides same key from list format
assert
cfg.attrList.forceMixedFormats == [
{ y = "list y"; }
{ x = "attrset forced x"; }
];
# Nesting: list format, mkOrder on element + mkForce on value
# z(100) < x-forced(500) < y(1500); x-discarded filtered by mkForce
assert
cfg.attrList.nestListOrderForce == [
{ z = "earliest"; }
{ x = "forced-early"; }
{ y = "late"; }
];
# Nesting: list format, mkOrder(mkForce(val)) on value
# z(500) < y(1000) < w(1200) < x-forced(1500); x-discarded entries filtered
assert
cfg.attrList.nestListOrderOfForce == [
{ z = "earliest"; }
{ y = "plain-early"; }
{ w = "mid"; }
{ x = "forced-late"; }
];
# Nesting: list format, mkForce(mkOrder(val)) on value
# z(500) < y(1000) < w(1200) < x-forced(1500); x-discarded entries filtered
assert
cfg.attrList.nestListForceOfOrder == [
{ z = "earliest"; }
{ y = "plain-early"; }
{ w = "mid"; }
{ x = "forced-late"; }
];
# Nesting: attrset format, mkOrder(mkForce(val))
# z(500) < y(1000) < w(1200) < x-forced(1500); x-discarded entries filtered
assert
cfg.attrList.nestAttrsOrderOfForce == [
{ z = "earliest"; }
{ y = "plain-early"; }
{ w = "mid"; }
{ x = "forced-late"; }
];
# Nesting: attrset format, mkForce(mkOrder(val))
# z(500) < y(1000) < w(1200) < x-forced(1500); x-discarded entries filtered
assert
cfg.attrList.nestAttrsForceOfOrder == [
{ z = "earliest"; }
{ y = "plain-early"; }
{ w = "mid"; }
{ x = "forced-late"; }
];
# mkIf false on individual element value filters it out (list format)
assert
cfg.attrListInt.optionalValueList == [
{ a = 1; }
{ c = 3; }
];
# mkIf false on individual element value filters it out (attrset format)
assert
cfg.attrListInt.optionalValueAttrs == [
{ a = 1; }
{ c = 3; }
];
# submodule: value, option descriptions, and valueMeta with real configuration metadata
assert
cfg.attrListSubmodule == [
{
web = {
host = "localhost";
port = 80;
};
}
{
db = {
host = "dbhost";
port = 5432;
};
}
];
assert
builtins.map (m: m.configuration.config) c.options.attrListSubmodule.valueMeta.attrList == [
{
host = "localhost";
port = 80;
}
{
host = "dbhost";
port = 5432;
}
];
assert
builtins.map (
m:
builtins.mapAttrs (n: o: o.description) (builtins.removeAttrs m.configuration.options [ "_module" ])
) c.options.attrListSubmodule.valueMeta.attrList == [
{
host = "Hostname";
port = "Port number";
}
{
host = "Hostname";
port = "Port number";
}
];
# valueMeta.attrList has one entry per (non-filtered) element
assert
c.options.attrList.valueMeta.attrs.listInput.attrList == [
{ }
{ }
];
assert
c.options.attrList.valueMeta.attrs.attrsetOrdered.attrList == [
{ }
{ }
];
assert
c.options.attrList.valueMeta.attrs.mixed.attrList == [
{ }
{ }
];
assert c.options.attrList.valueMeta.attrs.empty.attrList == [ ];
assert
c.options.attrListInt.valueMeta.attrs.optionalValueList.attrList == [
{ }
{ }
];
# either: headError is null for valid attrList input, so attrList branch is picked
assert
cfg.eitherAttrListOrInt == [
{ a = "hello"; }
{ b = "world"; }
];
# either: headError is non-null for int input, so int branch is picked
assert cfg.eitherAttrListOrIntFallback == 42;
# either (swapped): int first — int input matches
assert cfg.eitherIntOrAttrList == 42;
# either (swapped): list input falls through to attrList branch
assert
cfg.eitherIntOrAttrListFallback == [
{ a = "hello"; }
];
# asAttrs: unique keys — value is a plain attrset
assert
cfg.asAttrs.unique == {
a = "alpha";
b = "beta";
};
# ordered list preserved in valueMeta
assert
c.options.asAttrs.valueMeta.attrs.unique.attrListValue == [
{ a = "alpha"; }
{ b = "beta"; }
];
# asAttrs: duplicate keys — last in order wins
assert
cfg.asAttrs.duplicateKeys == {
x = "last";
y = "only";
};
assert
c.options.asAttrs.valueMeta.attrs.duplicateKeys.attrListValue == [
{ x = "first"; }
{ y = "only"; }
{ x = "last"; }
];
# asAttrs: ordered — value is attrset (unordered), list in valueMeta preserves order
assert
cfg.asAttrs.ordered == {
a = "a-val";
z = "z-val";
};
assert
c.options.asAttrs.valueMeta.attrs.ordered.attrListValue == [
{ a = "a-val"; }
{ z = "z-val"; }
];
# asAttrs: mkForce — forced key overrides, value is attrset
assert
cfg.asAttrs.withForce == {
x = "forced";
y = "kept";
};
# asAttrs: empty — value is empty attrset
assert cfg.asAttrs.empty == { };
# asAttrsDefault: unique keys — each value wrapped in singleton list
assert
cfg.asAttrsDefault.unique == {
a = [ 1 ];
b = [ 2 ];
};
# asAttrsDefault: duplicate keys — values collected into list in order
assert
cfg.asAttrsDefault.duplicates == {
x = [
10
20
30
];
y = [ 99 ];
};
assert
c.options.asAttrsDefault.valueMeta.attrs.duplicates.attrListValue == [
{ x = 10; }
{ y = 99; }
{ x = 20; }
{ x = 30; }
];
# valueMeta.definitions: mkDefinition records with mkOrder-wrapped single-key attrsets
# Use duplicateKeys which has mixed priorities and repeated keys
assert
let
defs = c.options.asAttrs.valueMeta.attrs.duplicateKeys.definitions;
extract = d: {
prio = d.value.priority;
value = d.value.content;
};
in
map extract defs == [
{
prio = 500;
value = {
x = "first";
};
}
{
prio = 1000;
value = {
y = "only";
};
}
{
prio = 1500;
value = {
x = "last";
};
}
];
# Round-trip: feed definitions through mapDefinitionValue + mkMerge into a listOf option
assert
let
rendered = lib.modules.mapDefinitionValue (attr: lib.cli.toCommandLineGNU { } attr) (
mkMerge c.options.asAttrs.valueMeta.attrs.duplicateKeys.definitions
);
result =
(lib.evalModules {
modules = [
{ options.out = mkOption { type = types.listOf types.str; }; }
{ config.out = rendered; }
# Interleave: mkOrder 800 lands between x(500) and y(1000)
{ config.out = mkOrder 800 [ "--interleaved" ]; }
];
}).config.out;
in
result == [
"-xfirst"
"--interleaved"
"-yonly"
"-xlast"
];
# Error cases are tested via checkConfigError in modules.sh
"ok";
};
}

View File

@@ -167,6 +167,28 @@ in
elemType = str;
lazy = false;
}).description == "attribute set of string";
assert (attrListOf str).description == "attribute list of string";
assert (attrListOf int).description == "attribute list of signed integer";
assert (attrListOf bool).description == "attribute list of boolean";
assert (attrListOf (either int str)).description == "attribute list of (signed integer or string)";
assert (attrListOf (nullOr str)).description == "attribute list of (null or string)";
assert (attrListOf (listOf str)).description == "attribute list of list of string";
assert
(attrListOf (attrsOf int)).description == "attribute list of attribute set of signed integer";
assert (attrListOf (attrListOf str)).description == "attribute list of attribute list of string";
assert (attrListOf ints.positive).description == "attribute list of (positive integer, meaning >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";

View File

@@ -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

View File

@@ -494,6 +494,47 @@ Composed types are types that take a type as parameter. `listOf
Displays the option as `foo.<id>` 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`*