diff --git a/lib/src/modules/default.nix b/lib/src/modules/default.nix index 63105ef..a03a1f6 100644 --- a/lib/src/modules/default.nix +++ b/lib/src/modules/default.nix @@ -142,6 +142,18 @@ lib: { invalid = builtins.removeAttrs module lib.modules.VALID_KEYS; in invalid == { }; + + ## Check that an include object has the required attributes and otherwise + ## only specifies supported attributes. + ## + ## @type (Attrs | String | Path | Function) -> Bool + include = + include: + let + invalidKeys = builtins.removeAttrs include lib.modules.VALID_INCLUDE_KEYS; + namespaceIsString = include ? namespace -> builtins.isString include.namespace; + in + builtins.isAttrs include -> (include ? module && namespaceIsString && invalidKeys == { }); }; ## Modules only support certain keys at the root level. This list determines @@ -159,6 +171,15 @@ lib: { "meta" ]; + ## Include objects only support certain keys. This list determines the + ## valid attributes that users can supply. + ## + ## @type List String + VALID_INCLUDE_KEYS = [ + "module" + "namespace" + ]; + ## Normalize a module to a standard structure. All other information will be ## lost in the conversion. ## @@ -168,12 +189,62 @@ lib: { let invalid = builtins.removeAttrs module lib.modules.VALID_KEYS; invalidKeys = builtins.concatStringsSep ", " (builtins.attrNames invalid); + throwError = msg: builtins.throw ("Module ${key} (${file}) " + msg); + + __key__ = builtins.toString module.__key__ or key; + + normalizeIncludes = + includes: + let + normalized = lib.lists.mapWithIndex1 ( + n: include: + let + invalid = builtins.removeAttrs module lib.modules.VALID_INCLUDE_KEYS; + invalidKeys = builtins.concatStringsSep ", " (builtins.attrNames invalid); + hasInvalidKeys = invalid != { }; + throwError' = msg: throwError ("has invalid include at position ${builtins.toString n}" + msg); + in + if lib.modules.validate.include include then + if builtins.isAttrs include then + { + inherit (include) module; + namespace = include.namespace or null; + } + else + { + module = include; + namespace = null; + } + else if !include ? module then + { + module = include; + namespace = null; + } + else if hasInvalidKeys then + throwError' " with unsupported attribute(s): ${invalidKeys}" + else + throwError' ": namespace is not a string" + ) includes; + + throwOnConflict = + let + filter = + namespaces: + { namespace, ... }: + if namespace != null && builtins.elem namespace namespaces then + throwError "declares several includes under the same namespace '${namespace}'" + else + namespaces ++ [ namespace ]; + in + builtins.foldl' filter [ ] normalized; + in + builtins.seq throwOnConflict normalized; in if lib.modules.validate.keys module then { - __file__ = builtins.toString module.__file__ or file; - __key__ = builtins.toString module.__key__ or key; - includes = module.includes or [ ]; + inherit __key__; + __file__ = builtins.toString (module.__file__ or file); + includes = normalizeIncludes (module.includes or [ ]); excludes = module.excludes or [ ]; options = module.options or { }; config = @@ -201,7 +272,7 @@ lib: { withFreeform (withMeta base); } else - builtins.throw "Module `${key}` (${file}) has unsupported attribute(s): ${invalidKeys}"; + throwError "has unsupported attribute(s): ${invalidKeys}"; ## Convert a module that is either a function or an attribute set into ## a resolved attribute set. If the module was a function then it will @@ -497,19 +568,36 @@ lib: { collect = let load = - args: file: key: module: + args: file: key: module': let + namespace = module'.namespace or null; + module = module'.module or module'; + moduleFromValue = lib.modules.normalize file key (lib.modules.resolve key module args); moduleFromPath = lib.modules.normalize (builtins.toString module) (builtins.toString module) ( lib.modules.resolve (builtins.toString module) (import module) args ); in - if builtins.isAttrs module || builtins.isFunction module then - moduleFromValue - else if builtins.isString module || builtins.isPath module then - moduleFromPath + if namespace == null then + if builtins.isAttrs module || builtins.isFunction module then + moduleFromValue + else if builtins.isString module || builtins.isPath module then + moduleFromPath + else + builtins.throw "The provided module must be either an attribute set, function, or path but got ${builtins.typeOf module}" + # We don't resolve the module yet if we're under a namespace else - builtins.throw "The provided module must be either an attribute set, function, or path but got ${builtins.typeOf module}"; + lib.modules.normalize file key { + __key__ = "${key}:include-${namespace}"; + options.${namespace} = lib.options.create { + description = "options and configuration included from ${namespace}"; + default.value = { }; + type = lib.types.submodules.of { + modules = [ module ]; + description = "include ${namespace}"; + }; + }; + }; normalize = parentFile: parentKey: modules: args: diff --git a/lib/src/modules/default.test.nix b/lib/src/modules/default.test.nix index ff98eb5..cf37ea4 100644 --- a/lib/src/modules/default.test.nix +++ b/lib/src/modules/default.test.nix @@ -200,6 +200,62 @@ in in !value; }; + + "cannot mix include and module keys" = + let + # Simple backtracking algorithm to generate all combinations of a list's + # elements without ordering, in . + combinations = + set: + let + n = builtins.length set; + genIndices = + combinations': + let + prev = builtins.head combinations'; + k = builtins.length prev; + first = builtins.head prev; + next' = + # Generate the next combination of length k + if k > 0 && first + 1 < n then + [ (first + 1) ] ++ (builtins.tail prev) + # We have generated all combinations of length k, generate the first + # (trivial) combination of length k+1 + else + builtins.genList (i: i) (k + 1); + next = builtins.deepSeq next' next'; + in + # Generate the very first (trivial) combination + if combinations' == [ ] then + genIndices [ [ ] ] + # We have generated all combinations of length 0 to n, return + else if k >= n then + combinations' + # Generate the next combination and continue + else + genIndices ([ next ] ++ combinations'); + select = indices: builtins.map (builtins.elemAt set) indices; + in + if set == [ ] then [ ] else builtins.map select (genIndices [ ]); + + keys = combinations (lib.modules.VALID_KEYS ++ lib.modules.VALID_INCLUDE_KEYS); + sets = builtins.map ( + names: + builtins.listToAttrs ( + builtins.map (name: { + value = null; + inherit name; + }) names + ) + ) keys; + validated = builtins.map (set: { + inherit set; + include = lib.modules.validate.include set; + module = lib.modules.validate.keys set; + }) sets; + bothValid = { include, module, ... }: include && module; + in + !builtins.any bothValid validated; }; "normalize" = { @@ -236,6 +292,91 @@ in }; in actual == expected; + + "includes" = { + + "handles an empty list" = + let + expected = [ ]; + actual = (lib.modules.normalize "/aux/example.nix" "example" { includes = [ ]; }).includes; + in + expected == actual; + + "handles a mixed list" = + let + expected = [ + { + module = { }; + namespace = null; + } + { + module = { }; + namespace = "a"; + } + ]; + actual = + (lib.modules.normalize "/aux/example.nix" "example" { + includes = [ + { } + { + module = { }; + namespace = "a"; + } + ]; + }).includes; + in + expected == actual; + + "rejects conflicting namespaces" = + let + normalized = lib.modules.normalize "/aux/example.nix" "example" { + includes = [ + { + module = { }; + namespace = "a"; + } + { + module = { }; + namespace = "a"; + } + ]; + }; + in + !(builtins.tryEval normalized.includes).success; + + "allows multiple without namespace" = + let + normalized = lib.modules.normalize "/aux/example.nix" "example" { + includes = [ + { } + { } + ]; + }; + in + (builtins.tryEval normalized.includes).success; + + "handles multiple without namespace" = + let + expected = [ + { + namespace = null; + module = { }; + } + { + namespace = null; + module = { }; + } + ]; + actual = + (lib.modules.normalize "/aux/example.nix" "example" { + includes = [ + { } + { } + ]; + }).includes; + in + expected == actual; + }; }; "resolve" = { @@ -699,5 +840,28 @@ in }; in (evaluated.config.aux.message == evaluated.config.aux.message2) && evaluated.config.aux.exists; + + "namespaced includes" = + let + expected = { + msg = "hello"; + myinclude.msg = "hello"; + }; + evaluated = lib.modules.run { + modules = [ + { + includes = [ + { + namespace = "myinclude"; + module.options.msg = lib.options.create { default.value = "hello"; }; + } + { options.msg = lib.options.create { default.value = "hello"; }; } + ]; + } + ]; + }; + actual = evaluated.config; + in + expected == actual; }; }