A Nix library for filesystem-based tree structures with functional algorithms.
Add srctree to your flake inputs:
inputs = {
srctree.url = "github:z1-0/srctree-nix";
};Say you keep one Nix file per host in ./hosts, each exposing { enable, system, ... }. Load the tree, keep only the hosts that opted in, wrap each remaining host in nixosSystem, and expose them as nixosConfigurations:
{ srctree, nixpkgs }:
let
lib = srctree.lib;
# 1. Load the tree: ./hosts/foo.nix becomes a file Node
hosts = lib.load ./hosts;
# 2. Filter leaves: drop hosts with enable = false; dirs survive if any child does
enabled = lib.alg.filterLeaves (node:
node.content.enable or false
) hosts;
# 3. Map leaves: turn each remaining host config into a NixOS system
systems = lib.alg.mapLeaves (node:
node // {
content = nixpkgs.lib.nixosSystem {
system = node.content.system;
modules = [ node.content ];
};
}
) enabled;
# 4. View the tree as an attribute set: systems.<hostname>.<attr>
nixosConfigurations = lib.toAttrs systems;
in
nixosConfigurationsEvery directory and .nix file is a Node with one of two shapes:
{
name = "dirname";
type = "dir";
path = /absolute/path/dir;
children = [ ... ]; # child nodes
}Only .nix files are loaded (symlinks to them included). Everything else is skipped.
{
name = "filename";
type = "file";
path = /absolute/path/f.nix;
content = ...; # evaluated result
}Load a directory of .nix files using import directly. Returns a root Node, or null if the directory does not exist; throws if the path exists but is not a directory.
lib.load :: Path -> Node | nulltoAttrs makes the tree read like a filesystem, haumea-style: each dir node also indexes its children, so the file at x/y/z.nix shows up as attrs.x.y.z, holding the Node itself. The tree itself doesn't change: name, type, path, and children stay, and every lib.alg function keeps working on the result. The one gotcha: the index is a snapshot of the tree at the moment toAttrs ran, so if you transform the tree afterwards, children updates but the index stays stale. Transform the tree first, then toAttrs last.
lib.toAttrs :: Node -> AttrsExample:
lib.toAttrs (lib.load ./config-dir)
# {
# name = "config-dir";
# type = "dir";
# path = /absolute/path/config-dir;
# children = [ ... ]; # canonical, unchanged
# foo = { ... }; # child dir, indexed by path segment
# bar = <Node>; # child file node, indexed by path segment
# }Warning
name, type, path, children, and content are reserved: a child with one of those names makes the colliding attribute a lazy error that fires only when touched, reminding you to rename the child.
Test whether a predicate holds for every / at least one node.
lib.alg.all :: (Node -> Bool) -> Node -> Bool
lib.alg.any :: (Node -> Bool) -> Node -> BoolExample (are all file contents positive?):
lib.alg.all (node: node.type != "file" || node.content > 0) treeExample (is there any file with negative content?):
lib.alg.any (node: node.type == "file" && node.content < 0) treePrune the tree by predicate. The predicate is tested against every node: a failed file is dropped; a failed dir is pruned with its whole subtree. Returns null if the root does not survive.
lib.alg.filter :: (Node -> Bool) -> Node -> (Node | null)Example (keep every node except the dir named "sub"):
lib.alg.filter (node:
node.type == "file" || node.name != "sub"
) treePrune the tree by predicate, testing file nodes only. Dirs are kept automatically as long as at least one child survives.
lib.alg.filterLeaves :: (Node -> Bool) -> Node -> (Node | null)Example (keep only files whose content is greater than 1):
lib.alg.filterLeaves (node:
node.content > 1
) treeDepth-first search for the first matching node (pre-order).
lib.alg.find :: (Node -> Bool) -> Node -> (Node | null)Example (find the first file whose content is 42):
lib.alg.find (node: node.type == "file" && node.content == 42) treeFlat list of all nodes (files and directories) via depth-first traversal (post-order).
lib.alg.flatten :: Node -> [Node]Example (collect all paths):
map (node: node.path) (lib.alg.flatten tree)Depth-first fold (catamorphism). Good for aggregating metadata or counting.
lib.alg.fold :: (acc -> Node -> acc) -> acc -> Node -> accExample 1 (count all file nodes in the tree):
lib.alg.fold (acc: node:
acc + (if node.type == "file" then 1 else 0)
) 0 treeExample 2 (collect all node paths into a list):
lib.alg.fold (acc: node:
acc ++ [ node.path ]
) [ ] treeFlat list of file nodes only, depth first (post-order).
lib.alg.leaves :: Node -> [Node]Example (list names of all Nix files):
map (fileNode: fileNode.name) (lib.alg.leaves tree)Transform every node bottom-up (children first, then the parent). Tree structure stays intact.
lib.alg.map :: (Node -> Node) -> Node -> NodeExample (uppercase all file node content, assuming string content):
lib.alg.map (node:
if node.type == "file"
then node // { content = builtins.toUpper node.content; }
else node
) treeMap a function over the file nodes only; dirs pass through untouched.
lib.alg.mapLeaves :: (Node -> Node) -> Node -> NodeExample (uppercase all file node content, assuming string content):
lib.alg.mapLeaves (node:
node // { content = builtins.toUpper node.content; }
) treeLow-level bottom-up traversal primitive used to build the combinators above. Receives one callback for file nodes and one for dir nodes (whose children are already transformed).
lib.alg.traverse :: (Node -> a) -> (Node -> a) -> Node -> aExample (uppercase all file node content, dirs unchanged):
lib.alg.traverse
(node: node // { content = builtins.toUpper node.content; })
(node: node)
tree