Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add types.attrTag #284551

Merged
merged 23 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ca81a89
lib.types.attrTag: init
roberth Jan 27, 2024
0feea2d
doc/option-types: Write about sum and union types
roberth Jan 28, 2024
2ceb555
doc/option-types: Move either and oneOf to Union types
roberth Jan 28, 2024
1ad3077
doc/option-types: Move attrTag to Sum types, remove redundant paragraph
roberth Jan 28, 2024
6949bc2
doc/option-types: Move nullOr to Union types
roberth Jan 28, 2024
5b49672
lib.types.attrTag: Support module docs
roberth Jan 29, 2024
42d3b54
lib.types.attrTag: Take options instead of types
roberth Jan 29, 2024
0bc9783
lib.types.attrTag: Support type merging
roberth Jan 29, 2024
e090bb5
lib/types.nix: Fix getSubOptions doc
roberth Jan 29, 2024
4c7d990
lib.types.attrTag: Provide declarations, definitions
roberth Jan 29, 2024
2e1d470
lib.modules.evalOptionValue: Undeprecate for lib.types
roberth Jan 29, 2024
475a55b
lib.types.attrTag: Remove tags from description
roberth Jan 29, 2024
c0f54d3
doc/option-types: Add attrTag example
roberth Jan 29, 2024
fa8b46a
doc/option-types: Make attrTag example self-contained
roberth Feb 4, 2024
bcd7746
lib/tests/modules/types-attrTag: Test against unexpected attrs
roberth Feb 4, 2024
1465777
lib.types.attrTag: Custom error when passing bare type
roberth Feb 4, 2024
47e4a18
types.attrTagWith: remove
roberth Apr 4, 2024
2d791b5
types.attrTag: Remove substSubmodules
roberth Apr 4, 2024
f354686
doc/option-types: Update sum types
roberth Apr 4, 2024
74831d8
lib/tests/modules/types-attrTag.nix: Clean up unneeded comment
roberth Apr 4, 2024
cf4968a
lib/tests/modules/types-attrTag.nix: Test other option doc attrs
roberth Apr 4, 2024
22d7f14
lib.types.attrTag: Fix declarationPositions after merge
roberth Apr 4, 2024
35fe538
types.attrTag: Remove unnecessary definitions override
roberth Apr 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ let
canCleanSource pathIsGitRepo;
inherit (self.modules) evalModules setDefaultModuleLocation
unifyModuleSyntax applyModuleArgsIfFunction mergeModules
mergeModules' mergeOptionDecls evalOptionValue mergeDefinitions
mergeModules' mergeOptionDecls mergeDefinitions
pushDownProperties dischargeProperties filterOverrides
sortProperties fixupOptionType mkIf mkAssert mkMerge mkOverride
mkOptionDefault mkDefault mkImageMediaOverride mkForce mkVMOverride
Expand All @@ -138,6 +138,7 @@ let
mkMergedOptionModule mkChangedOptionModule
mkAliasOptionModule mkDerivedConfig doRename
mkAliasOptionModuleMD;
evalOptionValue = lib.warn "External use of `lib.evalOptionValue` is deprecated. If your use case isn't covered by non-deprecated functions, we'd like to know more and perhaps support your use case well, instead of providing access to these low level functions. In this case please open an issue in https://github.com/nixos/nixpkgs/issues/." self.modules.evalOptionValue;
inherit (self.options) isOption mkEnableOption mkSinkUndeclaredOptions
mergeDefaultOption mergeOneOption mergeEqualOption mergeUniqueOption
getValues getFiles
Expand Down
2 changes: 1 addition & 1 deletion lib/modules.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1307,7 +1307,6 @@ let
inherit
applyModuleArgsIfFunction
dischargeProperties
evalOptionValue
mergeModules
mergeModules'
pushDownProperties
Expand All @@ -1328,6 +1327,7 @@ private //
defaultPriority
doRename
evalModules
evalOptionValue # for use by lib.types
filterOverrides
filterOverrides'
fixMergeModules
Expand Down
12 changes: 12 additions & 0 deletions lib/tests/modules.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ checkConfigError 'The option .sub.wrong2. does not exist. Definition values:' co
checkConfigError '.*This can happen if you e.g. declared your options in .types.submodule.' config.sub ./error-mkOption-in-submodule-config.nix
checkConfigError '.*A definition for option .bad. is not of type .non-empty .list of .submodule...\.' config.bad ./error-nonEmptyListOf-submodule.nix

# types.attrTag
checkConfigOutput '^true$' config.okChecks ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.syntaxError. is not of type .attribute-tagged union' config.intStrings.syntaxError ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.syntaxError2. is not of type .attribute-tagged union' config.intStrings.syntaxError2 ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.syntaxError3. is not of type .attribute-tagged union' config.intStrings.syntaxError3 ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.syntaxError4. is not of type .attribute-tagged union' config.intStrings.syntaxError4 ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.mergeError. is not of type .attribute-tagged union' config.intStrings.mergeError ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.badTagError. is not of type .attribute-tagged union' config.intStrings.badTagError ./types-attrTag.nix
checkConfigError 'A definition for option .intStrings\.badTagTypeError\.left. is not of type .signed integer.' config.intStrings.badTagTypeError.left ./types-attrTag.nix
checkConfigError 'A definition for option .nested\.right\.left. is not of type .signed integer.' config.nested.right.left ./types-attrTag.nix
checkConfigError 'In attrTag, each tag value must be an option, but tag int was a bare type, not wrapped in mkOption.' config.opt.int ./types-attrTag-wrong-decl.nix

# types.pathInStore
checkConfigOutput '".*/store/0lz9p8xhf89kb1c1kk6jxrzskaiygnlh-bash-5.2-p15.drv"' config.pathInStore.ok1 ./types.nix
checkConfigOutput '".*/store/0fb3ykw9r5hpayd05sr0cizwadzq1d8q-bash-5.2-p15"' config.pathInStore.ok2 ./types.nix
Expand Down
41 changes: 41 additions & 0 deletions lib/tests/modules/docs.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
A basic documentation generating module.
Declares and defines a `docs` option, suitable for making assertions about
the extraction "phase" of documentation generation.
*/
{ lib, options, ... }:

let
inherit (lib)
head
length
mkOption
types
;

traceListSeq = l: v: lib.foldl' (a: b: lib.traceSeq b a) v l;

in

{
options.docs = mkOption {
type = types.lazyAttrsOf types.raw;
description = ''
All options to be rendered, without any visibility filtering applied.
'';
};
config.docs =
lib.zipAttrsWith
(name: values:
if length values > 1 then
traceListSeq values
abort "Multiple options with the same name: ${name}"
else
assert length values == 1;
head values
)
(map
(opt: { ${opt.name} = opt; })
(lib.optionAttrSetToDocList options)
);
}
14 changes: 14 additions & 0 deletions lib/tests/modules/types-attrTag-wrong-decl.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{ lib, ... }:
let
inherit (lib) types mkOption;
in
{
options = {
opt = mkOption {
type = types.attrTag {
int = types.int;
};
default = { int = 1; };
};
};
}
135 changes: 135 additions & 0 deletions lib/tests/modules/types-attrTag.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
{ lib, config, options, ... }:
let
inherit (lib) mkOption types;
forceDeep = x: builtins.deepSeq x x;
mergedSubOption = (options.merged.type.getSubOptions options.merged.loc).extensible."merged.<name>";
in
{
options = {
intStrings = mkOption {
type = types.attrsOf
(types.attrTag {
left = mkOption {
type = types.int;
};
right = mkOption {
type = types.str;
};
});
};
nested = mkOption {
type = types.attrTag {
left = mkOption {
type = types.int;
};
right = mkOption {
type = types.attrTag {
left = mkOption {
type = types.int;
};
right = mkOption {
type = types.str;
};
};
};
};
};
merged = mkOption {
type = types.attrsOf (
types.attrTag {
yay = mkOption {
type = types.int;
};
extensible = mkOption {
type = types.enum [ "foo" ];
};
}
);
};
submodules = mkOption {
type = types.attrsOf (
types.attrTag {
foo = mkOption {
type = types.submodule {
options = {
bar = mkOption {
type = types.int;
};
};
};
};
qux = mkOption {
type = types.str;
description = "A qux for when you don't want a foo";
};
}
);
};
okChecks = mkOption {};
};
imports = [
./docs.nix
{
options.merged = mkOption {
type = types.attrsOf (
types.attrTag {
nay = mkOption {
type = types.bool;
};
extensible = mkOption {
type = types.enum [ "bar" ];
};
}
);
};
}
];
config = {
intStrings.syntaxError = 1;
intStrings.syntaxError2 = {};
intStrings.syntaxError3 = { a = true; b = true; };
intStrings.syntaxError4 = lib.mkMerge [ { a = true; } { b = true; } ];
intStrings.mergeError = lib.mkMerge [ { int = throw "do not eval"; } { string = throw "do not eval"; } ];
intStrings.badTagError.rite = throw "do not eval";
intStrings.badTagTypeError.left = "bad";
intStrings.numberOne.left = 1;
intStrings.hello.right = "hello world";
nested.right.left = "not a number";
merged.negative.nay = false;
merged.positive.yay = 100;
merged.extensi-foo.extensible = "foo";
merged.extensi-bar.extensible = "bar";
okChecks = builtins.addErrorContext "while evaluating the assertions" (
assert config.intStrings.hello == { right = "hello world"; };
assert config.intStrings.numberOne == { left = 1; };
assert config.merged.negative == { nay = false; };
assert config.merged.positive == { yay = 100; };
assert config.merged.extensi-foo == { extensible = "foo"; };
assert config.merged.extensi-bar == { extensible = "bar"; };
assert config.docs."submodules.<name>.foo.bar".type == "signed integer";
assert config.docs."submodules.<name>.qux".type == "string";
assert config.docs."submodules.<name>.qux".declarations == [ __curPos.file ];
assert config.docs."submodules.<name>.qux".loc == [ "submodules" "<name>" "qux" ];
assert config.docs."submodules.<name>.qux".name == "submodules.<name>.qux";
assert config.docs."submodules.<name>.qux".description == "A qux for when you don't want a foo";
assert config.docs."submodules.<name>.qux".readOnly == false;
assert config.docs."submodules.<name>.qux".visible == true;
# Not available (yet?)
# assert config.docs."submodules.<name>.qux".declarationsWithPositions == [ ... ];
assert options.submodules.declarations == [ __curPos.file ];
assert lib.length options.submodules.declarationPositions == 1;
assert (lib.head options.submodules.declarationPositions).file == __curPos.file;
assert options.merged.declarations == [ __curPos.file __curPos.file ];
assert lib.length options.merged.declarationPositions == 2;
assert (lib.elemAt options.merged.declarationPositions 0).file == __curPos.file;
assert (lib.elemAt options.merged.declarationPositions 1).file == __curPos.file;
assert (lib.elemAt options.merged.declarationPositions 0).line != (lib.elemAt options.merged.declarationPositions 1).line;
assert mergedSubOption.declarations == [ __curPos.file __curPos.file ];
assert lib.length mergedSubOption.declarationPositions == 2;
assert (lib.elemAt mergedSubOption.declarationPositions 0).file == __curPos.file;
assert (lib.elemAt mergedSubOption.declarationPositions 1).file == __curPos.file;
assert (lib.elemAt mergedSubOption.declarationPositions 0).line != (lib.elemAt mergedSubOption.declarationPositions 1).line;
assert lib.length config.docs."merged.<name>.extensible".declarations == 2;
true);
};
}
102 changes: 101 additions & 1 deletion lib/types.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let
isList
isString
isStorePath
throwIf
toDerivation
toList
;
Expand Down Expand Up @@ -65,6 +66,11 @@ let
fixupOptionType
mergeOptionDecls
;

inAttrPosSuffix = v: name:
let pos = builtins.unsafeGetAttrPos name v; in
if pos == null then "" else " at ${pos.file}:${toString pos.line}:${toString pos.column}";

outer_types =
rec {
__attrsFailEvaluation = true;
Expand Down Expand Up @@ -152,7 +158,7 @@ rec {
# If it doesn't, this should be {}
# This may be used when a value is required for `mkIf false`. This allows the extra laziness in e.g. `lazyAttrsOf`.
emptyValue ? {}
, # Return a flat list of sub-options. Used to generate
, # Return a flat attrset of sub-options. Used to generate
# documentation.
getSubOptions ? prefix: {}
, # List of modules if any, or null if none.
Expand Down Expand Up @@ -614,6 +620,100 @@ rec {
nestedTypes.elemType = elemType;
};

attrTag = tags:
let tags_ = tags; in
let
tags =
mapAttrs
(n: opt:
builtins.addErrorContext "while checking that attrTag tag ${lib.strings.escapeNixIdentifier n} is an option with a type${inAttrPosSuffix tags_ n}" (
throwIf (opt._type or null != "option")
"In attrTag, each tag value must be an option, but tag ${lib.strings.escapeNixIdentifier n} ${
if opt?_type then
if opt._type == "option-type"
then "was a bare type, not wrapped in mkOption."
else "was of type ${lib.strings.escapeNixString opt._type}."
else "was not."}"
opt // {
declarations = opt.declarations or (
let pos = builtins.unsafeGetAttrPos n tags_;
in if pos == null then [] else [ pos.file ]
);
declarationPositions = opt.declarationPositions or (
let pos = builtins.unsafeGetAttrPos n tags_;
in if pos == null then [] else [ pos ]
);
}
))
tags_;
choicesStr = concatMapStringsSep ", " lib.strings.escapeNixIdentifier (attrNames tags);
in
mkOptionType {
name = "attrTag";
description = "attribute-tagged union";
roberth marked this conversation as resolved.
Show resolved Hide resolved
descriptionClass = "noun";
getSubOptions = prefix:
mapAttrs
(tagName: tagOption: {
"${lib.showOption prefix}" =
tagOption // {
loc = prefix ++ [ tagName ];
};
})
tags;
check = v: isAttrs v && length (attrNames v) == 1 && tags?${head (attrNames v)};
merge = loc: defs:
let
choice = head (attrNames (head defs).value);
checkedValueDefs = map
(def:
assert (length (attrNames def.value)) == 1;
if (head (attrNames def.value)) != choice
then throw "The option `${showOption loc}` is defined both as `${choice}` and `${head (attrNames def.value)}`, in ${showFiles (getFiles defs)}."
else { inherit (def) file; value = def.value.${choice}; })
defs;
in
if tags?${choice}
then
{ ${choice} =
(lib.modules.evalOptionValue
(loc ++ [choice])
tags.${choice}
checkedValueDefs
).value;
}
else throw "The option `${showOption loc}` is defined as ${lib.strings.escapeNixIdentifier choice}, but ${lib.strings.escapeNixIdentifier choice} is not among the valid choices (${choicesStr}). Value ${choice} was defined in ${showFiles (getFiles defs)}.";
nestedTypes = tags;
functor = defaultFunctor "attrTag" // {
type = { tags, ... }: types.attrTag tags;
payload = { inherit tags; };
binOp =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential minor issue with supporting type merging, because users may write code like this:

{
  tag1 = 0;
  tag2 = 1;
}.${config.my.option}

And if some third-party module specifies another tag, this code breaks, without any good way to fix it.

Though I guess if we document that code needs to handle arbitrarily many tags, it might be fine.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your example looks like a usage of enum.
Indeed attrTag has that same characteristic, but I don't think this will happen by accident.

Regarding the sameness:

  • enum [str1 .. strn] is dual to an attrset of values.
  • attrTag ... is dual to an attrset of functions.

let
# Add metadata in the format that submodules work with
wrapOptionDecl =
option: { options = option; _file = "<attrTag {...}>"; pos = null; };
in
a: b: {
tags = a.tags // b.tags //
mapAttrs
(tagName: bOpt:
lib.mergeOptionDecls
# FIXME: loc is not accurate; should include prefix
# Fortunately, it's only used for error messages, where a "relative" location is kinda ok.
# It is also returned though, but use of the attribute seems rare?
[tagName]
[ (wrapOptionDecl a.tags.${tagName}) (wrapOptionDecl bOpt) ]
// {
# mergeOptionDecls is not idempotent in these attrs:
declarations = a.tags.${tagName}.declarations ++ bOpt.declarations;
declarationPositions = a.tags.${tagName}.declarationPositions ++ bOpt.declarationPositions;
}
)
(builtins.intersectAttrs a.tags b.tags);
};
};
};

uniq = unique { message = ""; };

unique = { message }: type: mkOptionType rec {
Expand Down
Loading