diff --git a/lib/default.nix b/lib/default.nix index 35e31af364d88..705119daffa09 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -127,7 +127,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 @@ -137,6 +137,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 diff --git a/lib/modules.nix b/lib/modules.nix index 65087e4ca70cb..a120c1b53d61a 100644 --- a/lib/modules.nix +++ b/lib/modules.nix @@ -1305,7 +1305,6 @@ let inherit applyModuleArgsIfFunction dischargeProperties - evalOptionValue mergeModules mergeModules' pushDownProperties @@ -1326,6 +1325,7 @@ private // defaultPriority doRename evalModules + evalOptionValue # for use by lib.types filterOverrides filterOverrides' fixMergeModules diff --git a/lib/tests/modules.sh b/lib/tests/modules.sh index cfd36bfff49bf..715c26dc37532 100755 --- a/lib/tests/modules.sh +++ b/lib/tests/modules.sh @@ -94,6 +94,18 @@ checkConfigOutput '^true$' config.result ./module-argument-default.nix # gvariant checkConfigOutput '^true$' config.assertion ./gvariant.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 diff --git a/lib/tests/modules/docs.nix b/lib/tests/modules/docs.nix new file mode 100644 index 0000000000000..225aa7eac1de8 --- /dev/null +++ b/lib/tests/modules/docs.nix @@ -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) + ); +} diff --git a/lib/tests/modules/types-attrTag-wrong-decl.nix b/lib/tests/modules/types-attrTag-wrong-decl.nix new file mode 100644 index 0000000000000..d03370bc10da4 --- /dev/null +++ b/lib/tests/modules/types-attrTag-wrong-decl.nix @@ -0,0 +1,14 @@ +{ lib, ... }: +let + inherit (lib) types mkOption; +in +{ + options = { + opt = mkOption { + type = types.attrTag { + int = types.int; + }; + default = { int = 1; }; + }; + }; +} diff --git a/lib/tests/modules/types-attrTag.nix b/lib/tests/modules/types-attrTag.nix new file mode 100644 index 0000000000000..b2e5158bb44b6 --- /dev/null +++ b/lib/tests/modules/types-attrTag.nix @@ -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."; +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..foo.bar".type == "signed integer"; + assert config.docs."submodules..qux".type == "string"; + assert config.docs."submodules..qux".declarations == [ __curPos.file ]; + assert config.docs."submodules..qux".loc == [ "submodules" "" "qux" ]; + assert config.docs."submodules..qux".name == "submodules..qux"; + assert config.docs."submodules..qux".description == "A qux for when you don't want a foo"; + assert config.docs."submodules..qux".readOnly == false; + assert config.docs."submodules..qux".visible == true; + # Not available (yet?) + # assert config.docs."submodules..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..extensible".declarations == 2; + true); + }; +} diff --git a/lib/types.nix b/lib/types.nix index 51e58eaa8ab51..91917b171da04 100644 --- a/lib/types.nix +++ b/lib/types.nix @@ -15,6 +15,7 @@ let isList isString isStorePath + throwIf toDerivation toList ; @@ -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 { isType = type: x: (x._type or "") == type; @@ -146,7 +152,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. @@ -602,6 +608,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"; + 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 = + let + # Add metadata in the format that submodules work with + wrapOptionDecl = + option: { options = option; _file = ""; 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); + }; + }; + }; + # Value of given type but with no merging (i.e. `uniq list`s are not concatenated). uniq = elemType: mkOptionType rec { name = "uniq"; diff --git a/nixos/doc/manual/development/option-types.section.md b/nixos/doc/manual/development/option-types.section.md index f9c7ac80018e4..df9d047075d0d 100644 --- a/nixos/doc/manual/development/option-types.section.md +++ b/nixos/doc/manual/development/option-types.section.md @@ -334,6 +334,16 @@ Composed types are types that take a type as parameter. `listOf the line `The option