# Pretty Symlinking with Home Manager

14 min read

I have a confession to make. It might not be appropriate to admit in polite society, but I'm not ashamed: I don't manage all my dot files with Nix.

Unless I get some concrete benefit like templating or cross-library integration, I can't be bothered to rewrite arbitrary dot files into Nix. And beyond that, I just don't want to be forced to rebuild every time I make a tiny tweak to this or that setting1.

On the other hand, I do like the idea that all my configs, Nix or otherwise, are in the same repo. And since I'm already using a tremendously powerful file management tool, Home Manager, I don't want to bring in yet another tool like GNU Stow or chezmoi. Call me simple-minded, but I just want as few tools as reasonably possible2 to achieve my goals.

Luckily, Home Manager got our back. What I just described fits nicely within the capabilities of mkOutOfStoreSymlink. And there are tutorials on how to make use of it for this exact purpose3. But this post is not about the capabilities of Home Manager (or Nix in general), but rather how people use the Nix language itself.

While setting up my system, I was looking for examples around the web, and a typical example of using mkOutOfStoreSymlink would look something like this4:

{
config,
symlinkRoot, # 1
...
}: {
xdg.configFile = { # 2
# 3
"copyq/copyq.conf".source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/copyq/copyq.conf";
"shellcheckrc".source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/shellcheckrc";
"spaceship.zsh".source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/spaceship.zsh";
# 4
"eww" = {
source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/eww";
recursive = true;
};
"fish" = {
source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/fish";
recursive = true;
};
"nushell" = {
source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/nushell";
recursive = true;
};
"nvim" = {
source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/nvim";
recursive = true;
};
"tmux" = {
source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/tmux";
recursive = true;
};
"waybar" = {
source = config.lib.file.mkOutOfStoreSymlink "${symlinkRoot}/waybar";
recursive = true;
};
};
}

This is not particularly complicated Nix code. We get the root directory for the symlinks as an argument (1)5. We then set up xdg.configFile (2), which takes an attribute set and creates matching files in XDG_CONFIG_HOME. Finally we set the actual standalone files (3), and directories (4) with recursive = true.

Great, that works as well as it should, but, oh my eyes... I'm a programmer, damn it. I have DRY tattooed on my forehead. It hurts deep down in my soul to see that much repetition:

  • The repeated fully qualified mkOutOfStoreSymlink calls
  • The repeated symlinkRoot prefix
  • The doubly named files and folders (both keys and sources); typos, anyone?
  • The repeated source and recursive keys

And it's only going to get worse as we keep on adding more and more keys here.

We're so used to using highly constrained and repetitive configuration formats like YAML6, that this kind of never ending tedium is not perceived as something out of the ordinary. Just another heap of text to maintain along with a pile of Kubernetes YAMLs, nothing to see here... Maybe one day someone invents a templating engine for Nix code to relieve some of the noise.

Hyperbole aside, I do suspect that many of the people that write Nix treat it as just another "config syntax". While in reality Nix is a full-fledged programming language, capable of many things you might expect a programming language to do7. DRYing code with Nix is very much possible, and I would claim that it's even desirable.

One might argue that the repetition allows for some flexibility. What if some of the entries do not map the files one-to-one, suppose you want to rename a file on the fly? Maybe, but we don't actually use that flexibility most of the time. So from now on I'll consider the problem of simple, stow-like, symlinking without any special cases. Special cases can be added separately.

Now, not everyone share my sensibilities and sensitivities when it comes to code that you have to maintain (even as a hobby). I'm also, like many software engineers when presented with shiny new toys, prone to over-engineering. So if you're fine with this kind of repetition and it works for you, keep at it. Don't let me infect you with my DRY cough. For the rest of you, how do we clean this code up?

I'll present a series of steps trying to reduce repetition and to (subjectively) aid readability. I don't claim that any of them are necessary, or that the resulting code is "the Nix way" of doing things. What I want to do is show that you can make configuration in Nix less repetitive and more readable, you just need to pick the right tools from whatever the Nix language offers. Hopefully this will be mostly beginner-friendly as well.

Imports

Although Nix doesn't have separate import statements like many mainstream languages, you can simulate them easily with let bindings to make thing more readable:

{
config,
symlinkRoot,
...
}: let
# 1
link = config.lib.file.mkOutOfStoreSymlink;
in {
xdg.configFile = {
# 2
"copyq/copyq.conf".source = link "${symlinkRoot}/copyq/copyq.conf";
"shellcheckrc".source = link "${symlinkRoot}/shellcheckrc";
"spaceship.zsh".source = link "${symlinkRoot}/spaceship.zsh";
"eww" = {
source = link "${symlinkRoot}/eww";
recursive = true;
};
# ... similarly for the rest
};
}

We have the "import" on (1), we just assign the fully qualified function reference to another value (which we can name whatever we want). Then use that instead of the fully qualified name (2). Already much less noisy.

This simulates a renaming import. We can also have a "regular import" with the following syntax:

let
inherit (config.lib.file) mkOutOfStoreSymlink;
in
# ... use mkOutOfStoreSymlink unqualified

The inherit syntax binds any symbol we can reference to its unqualified name. But I like the shorter name for now. We'll use inherit more later on.

Functions

One of the most ancient ways to DRY up code is to use functions. Simple, small, everyday functions. Nix being a functional language has a very pleasant syntax for functions, so there's no reason not to use it.

First step, let's stop repeating the symlinkRoot prefix:

{
config,
symlinkRoot,
...
}: let
link = config.lib.file.mkOutOfStoreSymlink;
# 1
toSrcFile = name: "${symlinkRoot}/${name}";
in {
xdg.configFile = {
# 2
"copyq/copyq.conf".source = link (toSrcFile "copyq/copyq.conf");
"shellcheckrc".source = link (toSrcFile "shellcheckrc");
"spaceship.zsh".source = link (toSrcFile "spaceship.zsh");
"eww" = {
source = link (toSrcFile "eww");
recursive = true;
};
# ...
};
}

Instead of manually prepending symlinkRoot for each entry, we defined a new function toSrcFile (1). It takes the name of the file as an argument and prepends symlinkRoot to it. Now we use toSrcFile in each entry (2) without repeating the reference to symlinkRoot every time.

Looking at the new code though, it seems that we only ever use link in combination with symlinkRoot. Let's redefine link to call toSrcFile and stop repeating this combo:

{
config,
symlinkRoot,
...
}: let
# 1
inherit (config.lib.file) mkOutOfStoreSymlink;
toSrcFile = name: "${symlinkRoot}/${name}";
# 2
link = name: mkOutOfStoreSymlink (toSrcFile name);
in {
xdg.configFile = {
# 3
"copyq/copyq.conf".source = link "copyq/copyq.conf";
"shellcheckrc".source = link "shellcheckrc";
"spaceship.zsh".source = link "spaceship.zsh";
"eww" = {
source = link "eww";
recursive = true;
};
# ...
};
}

inherit is back is business now. We import mkOutOfStoreSymlink (1), and now link is redefined to call mkOutOfStoreSymlink after applying toSrcFile (2). And now the link calls look very clean (3).

Programmatic Keys

Now the starkest bit of repetition are the file paths. Each name is repeated both as a key and as an argument to link. If this was YAML we would be stuck, but in Nix we can create attribute sets with dynamically specified keys. They don't have to be string literals. Here's a function to link a single file:

linkFile = name: {
${name}.source = link name;
};

This function creates an attribute with the provided key name (and source under it). The ${name} syntax lets us splice the dynamic key name into the set without using a string literal.

We can do the same thing with directories8:

linkDir = name: {
${name} = {
source = link name;
recursive = true;
};
};

These functions can now be used to create all the file entries without repeating the path. But we have a wrinkle. Calling linkFile creates a set with a single key. Calling it multiple times will produce multiple sets, each with one key. What we need though, is one set with multiple keys. Since I don't know how to create "bare" key/value pairs, we'll just have to merge all these redundant sets into one using the // operator. Like so:

let
# ...
confFiles = # 1
(linkFile "copyq/copyq.conf")
// (linkFile "shellcheckrc")
// (linkFile "spaceship.zsh");
confDirs = # 2
(linkDir "eww")
// (linkDir "fish")
// (linkDir "nushell")
// (linkDir "nvim")
// (linkDir "tmux")
// (linkDir "waybar");
# 3
links = confFiles // confDirs;
in {
xdg.configFile = links;
}

No more filename repetition! We make multiple calls to linkFile (1) and linkDir (2) and combine the results into one big attribute set with // (3), the resulting links value is an attribute set that we can assign to xdg.confFile.

List Processing

Written out this way, this looks a lot like we have two lists (separated by //9), one for files and one for directories. Each entry is prefixed with either linkFile or linkDir, the last source of repetition in this code. Let's make the list processing explicit.

Nix has a bunch list processing functions, and it's quite easy to set up a "data processing pipeline". First we extract the lists we want to deal with:

confFiles = [
"copyq/copyq.conf"
"shellcheckrc"
"spaceship.zsh"
];
confDirs = [
"eww"
"fish"
"nushell"
"nvim"
"tmux"
"waybar"
];

Now to each entry of a list we need to apply the appropriate function (either linkFile or linkDir). The way to do it is to use map:

let
# ...
inherit (lib) map;
# 1
confFiles = map linkFile [
"copyq/copyq.conf"
"shellcheckrc"
"spaceship.zsh"
];
# 2
confDirs = map linkDir [
"eww"
"fish"
"nushell"
"nvim"
"tmux"
"waybar"
];
# 3
links = confFile ++ confDirs;
in # ...

map takes care of iterating over the list and applying the supplied function to each entry (1, 2). We are dealing with lists now, so we combine the two lists into links using ++ (3). We are almost back to the original links value. But the types don't quite add up.

Previously links was an attribute set, now it's a list of attribute sets. No worries though, that's why we have mergeAttrsList10:

let
inherit (lib) map mergeAttrsList;
confFiles = map linkFile [
"copyq/copyq.conf"
"shellcheckrc"
"spaceship.zsh"
];
confDirs = map linkDir [
"eww"
"fish"
"nushell"
"nvim"
"tmux"
"waybar"
];
links = mergeAttrsList (confFiles ++ confDirs);
in {
xdg.configFile = links;
}

And that's where I'll stop DRYing up the code. We removed all of the egregious repetitions, and I think that with the two explicit lists of files the essence of what's going on is much clearer (contingent on good naming for the custom functions).

Cosmetics

Lastly, let's apply some cosmetic changes to the code, to make it really shine. This is getting very subjective, but I personally like the "DSL aesthetic", with small, well-named, custom functions to do my bidding. I'm sure that many people prefer using built-in functions more explicitly, since it's easier to look up what they do. Even if you don't like the style below, I think the DSL/small library mentality can be useful at times.

A minor tweak we can do to hide away the explicit list manipulation is to extract the map calls into helper functions:

# 1
linkConfFiles = map linkFile;
linkConfDirs = map linkDir;
# 2
confFiles = linkConfFiles [
"copyq/copyq.conf"
"shellcheckrc"
"spaceship.zsh"
];
# 3
confDirs = linkConfDirs [
"eww"
"fish"
"nushell"
"nvim"
"tmux"
"waybar"
];

With map link... assigned to new functions (1), we can apply the new linkConfFiles and linkConfDirs directly to our file lists (2, 3). The main code now flows better without mentioning the plumbing with lists and map. Nix's curried functions make creating these small helpers a breeze. Note how we didn't need to explicitly name the argument to linkConfFiles, we just partially applied map (a.k.a, point-free style).

Next, this pattern of combining attribute set lists with ++ and then merging seems to be pretty useful. Let's make it work more generically. Instead of working with just two lists, we can make it work with a list of lists:

inherit (lib) flatten mergeAttrsList;
flatMerge = sets: mergeAttrsList (flatten sets);

flatMerge is now doing the same thing, but relies on the built-in flatten to concatenate a list of lists, rather than just two lists. We use it like so:

links = flatMerge [confFiles confDirs];

Finally, when you work enough with functional-styled code some patterns tend emerge. One of them is applying a chain of functions one after the other. In the code so far we had two examples of it:

link = name: mkOutOfStoreSymlink (toSrcFile name);
flatMerge = sets: mergeAttrsList (flatten sets);

We first apply one function, and then another to the output of the first one. This is function composition, the same one you learned about in school algebra. It tends to pop out a lot. This pattern can be easily abstracted a way, without us manually writing new functions every time we need to compose a couple of functions.

For some reason, Nix doesn't have a built-in function composition operator. But it does have the pipe function, which takes a value and a list of functions, it then composes them (in reverse) and applies the resulting function to the value.

Convenient though pipe is, its signature is not quite what we want. It would be more reusable for us to first receive a list of functions, and then the value. This is easily remedied with the flip function:

inherit (lib) flatten flip map mergeAttrsList;
# 1
pipe = flip lib.pipe;
# 2
flatMerge = pipe [flatten mergeAttrsList];
# 3
link = pipe [toSrcFile mkOutOfStoreSymlink];

In (1) we override the built-in pipe with the flipped version. With the pipe in hand11 we can turn flatMerge (2) and link (3) into explicit pipelines of composed functions, without manually spelling out the function composition. As a bonus, this easily generalizes to any number of (compatible) functions.

And that's all the cosmetics we'll apply for today. The full code now looks like this:

{
config,
lib,
symlinkRoot,
...
}: let
inherit (config.lib.file) mkOutOfStoreSymlink;
inherit (lib) flatten flip map mergeAttrsList;
pipe = flip lib.pipe;
flatMerge = pipe [flatten mergeAttrsList];
toSrcFile = name: "${symlinkRoot}/${name}";
link = pipe [toSrcFile mkOutOfStoreSymlink];
linkFile = name: {${name}.source = link name;};
linkDir = name: {
${name} = {
source = link name;
recursive = true;
};
};
linkConfFiles = map linkFile;
linkConfDirs = map linkDir;
confFiles = linkConfFiles [
"copyq/copyq.conf"
"shellcheckrc"
"spaceship.zsh"
];
confDirs = linkConfDirs [
"eww"
"fish"
"nushell"
"nvim"
"tmux"
"waybar"
];
links = flatMerge [confFiles confDirs];
in {
xdg.configFile = links;
}

You can find all the different revisions in an executable format in the repo. If you want homework, you can try to apply the same approach to files linked directly to home.file.

My eyes can rest now. But if we were really serious about Nix programming, we would probably turn this into a real, configurable, Nix module, and hide away all the utility functions from our hypothetical users.

If you enjoyed this, reach out for a workshop on Functional Programming, where I teach how to apply Functional Programming to improve code quality in any language.

Subscribe to the newsletter
Get new posts by email:

Footnotes

  1. Although usually I don't tweak configs much after a short period of initial set up, some apps get tweaked continuously. For example, Neovim, whose configuration (gasp) I don't manage through Nix.

  2. Reasonable people may disagree what to consider "reasonable". But I'm fine with using myself as the golden standard for being reasonable.

  3. If you're using flakes, keep in mind how flakes treat relative paths and be careful to use absolute paths when passing arguments to mkOutOfStoreSymlink.

  4. You can find the code for these examples in the repo.

  5. Don't forget that this must be an absolute path if you're using flakes.

  6. Instead of using a more capable solution like Dhall.

  7. Although one might argue that when used for configuring NixOS it might be beneficial to have a non-Turing complete language, something in the vein of what Dhall does.

  8. I leave it as an exercise for the reader to unify these two functions and to reduce that tiny repetition with the source key.

  9. This sure looks a lot like an application of some fold variant. Coincidence?

  10. We could've use a fold instead of mapping and merging. But that would've been clunkier to write, and possibly less performant, since mergeAttrsList seems more optimized with some kind of binary merging.

  11. When all you have is a pipe, everything looks like a functional pipeline...


Comments

More Posts
Previous