nobe4 / Posts / Nix-Darwin shell override _

  |   Tech Nix

All code snippets are simplified for clarity.

Moving aliases to Nix 🔗

I recently decided to move my shell aliases, and ZSH options to Nix.

There are predefined Nix options for this, all under programs.zsh and programs.bash.

And since I use the same aliases for zsh and bash, here’s the configuration I came to:

And I can import shell.nix wherever needed, so far so good.

Enter nix-darwin 🔗

On one of my system, I use [nix-darwin] to configure all my macOS settings. It has been a great help.

When running darwin-rebuild switch I was greeted with

error: The option `programs.bash.shellAliases` does not exist.
Did you mean `programs.bash.enable`, ... ?

Upon looking at darwin.programs.bash, it turns out that it is indeed not defined. Uh, OK. What about darwin.programs.zsh? Same absence.

Turns out that neither shellAliases nor setOptions are defined, and some default ZSH options are used.

This got me wondering, how does NixOS actually achieve this?

Back to NixOS 🔗

Looking back at programs.zsh, we can see how the setOptions is done:

bash is similar.

{ config, lib, options, pkgs, ... }:
let
  zshAliases = builtins.concatStringsSep "\n" (
    lib.mapAttrsToList (k: v: "alias -- ${k}=${lib.escapeShellArg v}") (
      lib.filterAttrs (k: v: v != null) cfg.shellAliases
    )
  );
in
{
  options.programs.zsh = {
    shellAliases = lib.mkOption {
      type = with lib.types; attrsOf (nullOr (either str path));
      default = { };
    };
    setOptions = lib.mkOption {
      type = lib.types.listOf lib.types.str;
      default = [ "HIST_IGNORE_DUPS" ];
    };
  };

  config = lib.mkIf cfg.enable {
    environment.etc.zshrc.text = ''
      # /etc/zshrc: DO NOT EDIT -- this file has been generated automatically.

      ${lib.optionalString (cfg.setOptions != [ ]) ''
        # Set zsh options.
        setopt ${builtins.concatStringsSep " " cfg.setOptions}
      ''}

      # Setup aliases.
      ${zshAliases}
    '';
  };
}

The logic is pretty straightforward:

  1. the options are defined following a specific type
  2. the values are formatted
  3. the formatted result is added to /etc/zshrc.

Looks pretty straightforward1.

How hard would it be to add this back into Nix-Darwin?

First attempt: mkAfter 🔗

I remember reading mkAfter in the past and it was my first idea: appends the options/aliases to the environment.etc.zshrc.text value.

Starting with the easiest to format: setOptions , I arrived at a working mkAfter code:

{ config, lib, ... }:
let
  cfg = config.programs.zsh;
in
{
  options = {
    programs.zsh = {
      setOptions = lib.mkOption {
        type = lib.types.listOf lib.types.str;
        default = [ "HIST_IGNORE_DUPS" ];
      };
    };

  };

  config = lib.mkIf cfg.enable {
    environment.etc.zshrc.text = lib.mkAfter ''
      ${lib.optionalString (cfg.setOptions != [ ]) ''
        # Set zsh options.
        setopt ${builtins.concatStringsSep " " cfg.setOptions}
      ''}
    '';
  };
}

This draws inspiration from programs.zsh and was pretty easy to come up with.

Running again, the build worked and /etc/zshrc was updated accordingly:

$ grep setopt /etc/zshrc
setopt HIST_IGNORE_DUPS SHARE_HISTORY HIST_FCNTL_LOCK            # default
setopt ALWAYS_TO_END INTERACTIVE_COMMENTS AUTO_CD AUTO_LIST  ... # custom

This is almost correct, because I might not want to keep the default values. So mkAfter doesn’t work entirely.

Trying builtins.replaceStrings 🔗

My next idea was to replace the default setopt with the custom string.

Here’s what I tried:

  config = lib.mkIf cfg.enable {
    environment.etc.zshrc.text =
      builtins.replaceStrings
        [ "setopt HIST_IGNORE_DUPS SHARE_HISTORY HIST_FCNTL_LOCK" ]
        [
          ''
            ${lib.optionalString (cfg.setOptions != [ ]) ''
              # Set zsh options.
              setopt ${builtins.concatStringsSep " " cfg.setOptions}
            ''}
          ''
        ]
        environment.etc.zshrc.text;
  };

It failed with:

error: infinite recursion encountered

This seems to come from the fact that zshrc.text is not the string value, but a reference to what it will eventually be. Nix is a lazy-evaluated language, after all. My best guess at understanding what happens, it still feels like magic to me, is that zshrc.text becomes dependent on its own value, which create an infinite dependency loop.

Override and disabledModule 🔗

@tebriel suggested that I use an override, whose main idea is to replace a Nix module with another one. Combined with disabledModules it completely replaces a module’s functionality.

E.g.

{}:{
    disabledModules = [ "path/to/module" ];
    imports = [ "path/to/custom/module" ];
}

Using this method, I copied the entire darwin.programs.zsh and darwin.programs.bash into my dotfile repo and added the missing options:

This offered two advantages:

  1. It worked, I could have the zsh options set correctly.
  2. It offered an easy way to prepare a patch to send this change upstream.

Adding the remaining functionalities were similar, so I’ll spare the repetition here. You can view the final changes.

Porting the changes upstream 🔗

Acknowledgements 🔗

Thanks @tebriel for the initial code, the reviews and improvements.


  1. Note that NixOS only installs ZSH options in the global config at /etc/zshrc and not ~/.zshrc↩︎

References: