diff --git a/modules/fs/default.nix b/modules/fs/default.nix index 6ef07557..594212cb 100644 --- a/modules/fs/default.nix +++ b/modules/fs/default.nix @@ -235,25 +235,11 @@ let }); - mkFsConfig = path: opt: mergeTopLevel [ + mkFsConfig = path: opt: sane-lib.mergeTopLevel [ (mkGeneratedConfig path opt) - (lib.mkIf (opt.mount != null) (mkMountConfig path opt)) + (lib.optionalAttrs (opt.mount != null) (mkMountConfig path opt)) ]; - # act as `config = lib.mkMerge [ a b ]` but in a way which avoids infinite recursion, - # by extracting only specific options which are known to not be options in this module. - mergeTopLevel = items: let - # if one of the items is `lib.mkIf cond attrs`, we won't be able to index it until - # after we "push down" the mkIf to each attr. - indexable = lib.pushDownProperties (lib.mkMerge items); - # transform (listOf attrs) to (attrsOf list) by grouping each toplevel attr across lists. - top = lib.zipAttrsWith (name: lib.mkMerge) indexable; - # extract known-good top-level items in a way which errors if a module tries to define something extra. - extract = { fileSystems ? {}, systemd ? {} }@attrs: attrs; - in { - inherit (extract top) fileSystems systemd; - }; - generateWrapperScript = path: gen-opt: { script = '' fspath="$1" @@ -360,5 +346,12 @@ in { }; }; - config = mergeTopLevel (lib.mapAttrsToList mkFsConfig cfg); + config = + let + configs = lib.mapAttrsToList mkFsConfig cfg; + take = f: { + systemd.services = f.systemd.services; + fileSystems = f.fileSystems; + }; + in take (sane-lib.mkTypedMerge take configs); } diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 28454217..7905d99b 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -8,7 +8,15 @@ rec { # like `builtins.listToAttrs` but any duplicated `name` throws error on access. # Type: listToDisjointAttrs :: [{ name :: String, value :: Any }] -> AttrSet - listToDisjointAttrs = l: lib.foldl' lib.attrsets.unionOfDisjoint {} (builtins.map nameValueToAttrs l); + listToDisjointAttrs = l: flattenAttrsets (builtins.map nameValueToAttrs l); + + # true if p is a prefix of l (even if p == l) + # Type: isPrefixOfList :: [Any] -> [Any] -> bool + isPrefixOfList = p: l: (lib.sublist 0 (lib.length p) l) == p; + + # merges N attrsets + # Type: flattenAttrsList :: [AttrSet] -> AttrSet + flattenAttrsets = l: lib.foldl' lib.attrsets.unionOfDisjoint {} l; # evaluate a `{ name, value }` pair in the same way that `listToAttrs` does. # Type: nameValueToAttrs :: { name :: String, value :: Any } -> Any @@ -51,4 +59,100 @@ rec { inherit path value; } ]; + + # like `mkMerge`, but tries to do normal attribute merging by default and only creates `mkMerge` + # entries at the highest point where paths overlap between items. + mergeTopLevel = l: + if builtins.length l == 0 then + lib.mkMerge [] + else if builtins.length l == 1 then + lib.head l + else if builtins.all isAttrsNotMerge l then + # merge each toplevel attribute + lib.zipAttrsWith (_name: mergeTopLevel) l + else + lib.mkMerge l; + + # tests that `i` is a normal attrs, and not something make with `lib.mkMerge`. + isAttrsNotMerge = i: builtins.isAttrs i && i._type or "" != "merge"; + + + # type-checked `lib.mkMerge`, intended to be usable at the top of a file. + # `take` is a function which defines a spec enforced against every item to be merged. + # for example: + # take = f: { x = f.x; y.z = f.y.z; }; + # - the output is guaranteed to have an `x` attribute and a `y.z` attribute and nothing else. + # - each output is a `lib.mkMerge` of the corresponding paths across the input lists. + # - if an item in the input list defines an attr not captured by `f`, this function will throw. + # + # Type: mkTypedMerge :: (Attrs -> Attrs) -> [Attrs] -> Attrs + mkTypedMerge = take: l: + let + pathsToMerge = findTerminalPaths take []; + merged = builtins.map (p: lib.setAttrByPath p (mergeAtPath p l)) pathsToMerge; + in + assert builtins.all (i: assertTakesEveryAttr take i []) l; + flattenAttrsets merged; + + # `take` is as in mkTypedMerge. this function queries which items `take` is interested in. + # for example: + # take = f: { x = f.x; y.z = f.y.z; }; + # - for `path == []` we return the toplevel attr names: [ "x" "y"] + # - for `path == [ "y" ]` we return [ "z" ] + # - for `path == [ "x" ]` or `path == [ "y" "z" ]` we return [] + # + # Type: findSubNames :: (Attrs -> Attrs) -> [String] -> [String] + findSubNames = take: path: + let + # define the current path, but nothing more. + curLevel = lib.setAttrByPath path {}; + # `take` will either set: + # - { $path = path } => { $path = {} }; + # - { $path.next = path.next } => { $path = { next = ?; } } + # so, index $path into the output of `take`, + # and if it has any attrs that means we're interested in those too. + nextLevel = lib.getAttrFromPath path (take curLevel); + in + builtins.attrNames nextLevel; + + # computes a list of all terminal paths that `take` is interested in, + # where each path is a list of attr names to descend to reach that terminal. + # Type: findTerminalPaths :: (Attrs -> Attrs) -> [String] -> [[String]] + findTerminalPaths = take: path: + let + subNames = findSubNames take path; + in if subNames == [] then + [ path ] + else + let + terminalsPerChild = builtins.map (name: findTerminalPaths take (path ++ [name])) subNames; + in + lib.concatLists terminalsPerChild; + + # merges all present values for the provided path + # Type: mergeAtPath :: [String] -> [Attrs] -> (lib.mkMerge) + mergeAtPath = path: l: + let + itemsToMerge = builtins.filter (lib.hasAttrByPath path) l; + in lib.mkMerge (builtins.map (lib.getAttrFromPath path) itemsToMerge); + + # throw if `item` includes any data not wanted by `take`. + # this is recursive: `path` tracks the current location being checked. + assertTakesEveryAttr = take: item: path: + let + takesSubNames = findSubNames take path; + itemSubNames = findSubNames (_: item) path; + unexpectedNames = lib.subtractLists takesSubNames itemSubNames; + takesEverySubAttr = builtins.all (name: assertTakesEveryAttr take item (path ++ [name])) itemSubNames; + in + if takesSubNames == [] then + # this happens when the user takes this whole subtree: i.e. *all* subnames are accepted. + true + else if unexpectedNames != [] then + let + p = lib.concatStringsSep "." (path ++ lib.sublist 0 1 unexpectedNames); + in + throw ''unexpected entry: ${p}'' + else + takesEverySubAttr; } diff --git a/modules/persist/default.nix b/modules/persist/default.nix index b96fb677..fa729939 100644 --- a/modules/persist/default.nix +++ b/modules/persist/default.nix @@ -216,7 +216,7 @@ in method = (sane-lib.withDefault store.defaultMethod) opt.method; fsPathToStoreRelPath = fspath: path.from store.prefix fspath; fsPathToBackingPath = fspath: path.concat [ store.origin (fsPathToStoreRelPath fspath) ]; - in [ + in sane-lib.mergeTopLevel [ { # create destination dir, with correct perms sane.fs."${fspath}" = { @@ -245,10 +245,10 @@ in ); } ]; - configsPerPath = lib.mapAttrsToList cfgFor cfg.byPath; - allConfigs = builtins.concatLists configsPerPath; - in mkIf cfg.enable { - sane.fs = lib.mkMerge (map (c: c.sane.fs) allConfigs); - }; + configs = lib.mapAttrsToList cfgFor cfg.byPath; + take = f: { sane.fs = f.sane.fs; }; + in mkIf cfg.enable ( + take (sane-lib.mkTypedMerge take configs) + ); }