nixpkgs/nixos/modules/programs/fish.nix
Jeremy Fleischman 69b2e5e0b2
nixos/fish: programs.fish.generateCompletions fix spaces in filenames
`linux-manual` has this fun filename in it:

```console
$ find -L $(nix-build -A linux-manual) -name 'station table - introduction.9.gz'
/nix/store/q45mzaksp2ll04c363mxpyi252j6anrq-linux-manual-7.1.3/share/man/man9/station table - introduction.9.gz
```

Our `find ... | xargs ...` didn't handle this nicely, and would vomit in the
following way:

`repro.nix`:

```nix
$ cat repro.nix
let
  pkgs = import ./. { };
  nixosConfig = pkgs.nixos (
    { config, pkgs, ... }: {
      system.stateVersion = config.system.nixos.release;

      programs.fish.enable = true;
      programs.fish.generateCompletions = true;

      environment.systemPackages = [ pkgs.linux-manual ];
    }
  );
in
nixosConfig.config.environment.etc."fish/generated_completions".source
```

The crash:

```console
$ nix-build repro.nix
these 2 derivations will be built:
  /nix/store/qs213n0r723w59jdvfz5w87azd3wh405-linux-manual-7.1.3_fish-completions.drv
  /nix/store/1pwps30drz71icj5v4rdiaarmh37xcn8-system_fish-completions.drv
building '/nix/store/qs213n0r723w59jdvfz5w87azd3wh405-linux-manual-7.1.3_fish-completions.drv'...
usage: create_manpage_completions.py [-h] [-c CLEANUP_IN] [-d DIRECTORY] [-k]
                                     [-m] [-p] [-s] [-v {0,1,2}] [-z]
                                     [file_paths ...]
create_manpage_completions.py: error: unrecognized arguments: -introduction.9.gz /nix/store/q45mzaksp2ll04c363mxpyi252j6anrq-linux-manual-7.1.3/share/man/man9/DRBD State macros.9.gz /nix/store/q45mzaksp2ll04c363mxpyi252j6anrq-linux-manual-7.1.3/share/man/man9/DRM Client usage stats.9.gz /nix/store/q45mzaksp2ll04c363mxpyi252j6anrq-linux-manual-7.1.3/share/man/man9/DRM RAS Node Management.9.gz /nix/store/q45mzaksp2ll04c363mxpyi252j6anrq-linux-manual-7.1.3/share/man/man9/DRM_ACCEL_FOPS.9.gz
```

This fix is simple, use the `find -print0 ... | xargs -0 ...` pattern.
2026-07-05 09:38:35 -07:00

374 lines
13 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
cfge = config.environment;
cfg = config.programs.fish;
fishAbbrs = lib.concatStringsSep "\n" (
lib.mapAttrsToList (k: v: "abbr -a ${k} -- ${lib.escapeShellArg v}") cfg.shellAbbrs
);
fishAliases = lib.concatStringsSep "\n" (
lib.mapAttrsToList (k: v: "alias ${k} ${lib.escapeShellArg v}") (
lib.filterAttrs (k: v: v != null) cfg.shellAliases
)
);
envShellInit = pkgs.writeText "shellInit" cfge.shellInit;
envLoginShellInit = pkgs.writeText "loginShellInit" cfge.loginShellInit;
envInteractiveShellInit = pkgs.writeText "interactiveShellInit" cfge.interactiveShellInit;
# Need to use --no-config to prevent fish_indent from trying to read from config
# See https://github.com/fish-shell/fish-shell/issues/12079
indentFishFile =
name: text:
pkgs.runCommandLocal name {
nativeBuildInputs = [ cfg.package ];
inherit text;
passAsFile = [ "text" ];
} "fish --no-config -c 'fish_indent $textPath' > $out";
sourceEnv =
file:
if cfg.useBabelfish then
"source /etc/fish/${file}.fish"
else
''
set fish_function_path ${pkgs.fishPlugins.foreign-env}/share/fish/vendor_functions.d $fish_function_path
fenv source /etc/fish/foreign-env/${file} > /dev/null
set -e fish_function_path[1]
'';
babelfishTranslate =
path: name:
pkgs.runCommandLocal "${name}.fish" {
nativeBuildInputs = [ pkgs.babelfish ];
} "exec -a ${lib.getExe pkgs.babelfish} babelfish < ${path} > $out;";
in
{
options = {
programs.fish = {
enable = lib.mkOption {
default = false;
description = ''
Whether to configure fish as an interactive shell.
'';
type = lib.types.bool;
};
package = lib.mkPackageOption pkgs "fish" { };
useBabelfish = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
If enabled, the configured environment will be translated to native fish using [babelfish](https://github.com/bouk/babelfish).
Otherwise, [foreign-env](https://github.com/oh-my-fish/plugin-foreign-env) will be used.
'';
};
generateCompletions = lib.mkEnableOption "generating completion files from man pages" // {
default = true;
example = false;
};
extraCompletionPackages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "config.users.users.alice.packages";
description = ''
Additional packages to generate completions from, if {option}`programs.fish.generateCompletions` is enabled.
'';
};
vendor.config.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether fish should source configuration snippets provided by other packages.
'';
};
vendor.completions.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether fish should use completion files provided by other packages.
'';
};
vendor.functions.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether fish should autoload fish functions provided by other packages.
'';
};
shellAbbrs = lib.mkOption {
default = { };
example = {
gco = "git checkout";
npu = "nix-prefetch-url";
};
description = ''
Set of fish abbreviations.
'';
type = with lib.types; attrsOf str;
};
shellAliases = lib.mkOption {
default = { };
description = ''
Set of aliases for fish shell, which overrides {option}`environment.shellAliases`.
See {option}`environment.shellAliases` for an option format description.
'';
type = with lib.types; attrsOf (nullOr (either str path));
};
shellInit = lib.mkOption {
default = "";
description = ''
Shell script code called during fish shell initialisation.
'';
type = lib.types.lines;
};
loginShellInit = lib.mkOption {
default = "";
description = ''
Shell script code called during fish login shell initialisation.
'';
type = lib.types.lines;
};
interactiveShellInit = lib.mkOption {
default = "";
description = ''
Shell script code called during interactive fish shell initialisation.
'';
type = lib.types.lines;
};
promptInit = lib.mkOption {
default = "";
description = ''
Shell script code used to initialise fish prompt.
'';
type = lib.types.lines;
};
};
};
config = lib.mkIf cfg.enable {
programs.fish.shellAliases = lib.mapAttrs (name: lib.mkDefault) cfge.shellAliases;
# Required for man completions
documentation.man.cache.enable = lib.mkDefault true;
documentation.man.cache.generateAtRuntime = lib.mkDefault true;
environment = lib.mkMerge [
(lib.mkIf cfg.useBabelfish {
etc."fish/setEnvironment.fish".source =
babelfishTranslate config.system.build.setEnvironment "setEnvironment";
etc."fish/shellInit.fish".source = babelfishTranslate envShellInit "shellInit";
etc."fish/loginShellInit.fish".source = babelfishTranslate envLoginShellInit "loginShellInit";
etc."fish/interactiveShellInit.fish".source =
babelfishTranslate envInteractiveShellInit "interactiveShellInit";
})
(lib.mkIf (!cfg.useBabelfish) {
etc."fish/foreign-env/shellInit".source = envShellInit;
etc."fish/foreign-env/loginShellInit".source = envLoginShellInit;
etc."fish/foreign-env/interactiveShellInit".source = envInteractiveShellInit;
})
{
etc."fish/nixos-env-preinit.fish".source =
if cfg.useBabelfish then
indentFishFile "nixos-env-preinit.fish" ''
# source the NixOS environment config
if [ -z "$__NIXOS_SET_ENVIRONMENT_DONE" ]
source /etc/fish/setEnvironment.fish
end
''
else
indentFishFile "nixos-env-preinit.fish" ''
# This happens before embedded:config.fish sets fish_function_path, so it is currently
# unset. We set it and then completely erase it, leaving its configuration to embedded:config.fish
set fish_function_path ${pkgs.fishPlugins.foreign-env}/share/fish/vendor_functions.d
# source the NixOS environment config
if [ -z "$__NIXOS_SET_ENVIRONMENT_DONE" ]
fenv source ${config.system.build.setEnvironment}
end
# clear fish_function_path so that it will be correctly set when we return to embedded:config.fish
set -e fish_function_path
'';
}
{
etc."fish/config.fish".source = indentFishFile "config.fish" ''
# /etc/fish/config.fish: DO NOT EDIT -- this file has been generated automatically.
# if we haven't sourced the general config, do it
if not set -q __fish_nixos_general_config_sourced
${sourceEnv "shellInit"}
${cfg.shellInit}
# and leave a note so we don't source this config section again from
# this very shell (children will source the general config anew)
set -g __fish_nixos_general_config_sourced 1
end
# if we haven't sourced the login config, do it
status is-login; and not set -q __fish_nixos_login_config_sourced
and begin
${sourceEnv "loginShellInit"}
${cfg.loginShellInit}
# and leave a note so we don't source this config section again from
# this very shell (children will source the general config anew)
set -g __fish_nixos_login_config_sourced 1
end
# if we haven't sourced the interactive config, do it
status is-interactive; and not set -q __fish_nixos_interactive_config_sourced
and begin
${fishAbbrs}
${fishAliases}
${sourceEnv "interactiveShellInit"}
${cfg.promptInit}
${cfg.interactiveShellInit}
# and leave a note so we don't source this config section again from
# this very shell (children will source the general config anew,
# allowing configuration changes in, e.g, aliases, to propagate)
set -g __fish_nixos_interactive_config_sourced 1
end
'';
}
(lib.mkIf cfg.generateCompletions {
etc."fish/generated_completions".source =
let
# fish embeds the generator script in the binary, so extract it.
generator =
pkgs.runCommandLocal "fish_completion-generator"
{
nativeBuildInputs = [ cfg.package ];
}
''
mkdir -p $out
fish --no-config -c 'status get-file tools/create_manpage_completions.py' \
> $out/create_manpage_completions.py
'';
generateCompletions =
package:
pkgs.runCommandLocal
(
let
inherit (lib.strings) stringLength substring storeDir;
storeLength = stringLength storeDir + 34; # Nix' StorePath::HashLen + 2 for the separating slash and dash
pathName = substring storeLength (stringLength package - storeLength) package;
in
(package.name or pathName) + "_fish-completions"
)
(
{
inherit package;
}
// lib.optionalAttrs (package ? meta.priority) { meta.priority = package.meta.priority; }
)
''
mkdir -p $out
if [ -d $package/share/man ]; then
find -L $package/share/man -type f -print0 \
| xargs -0 ${pkgs.python3.pythonOnBuildForHost.interpreter} \
${generator}/create_manpage_completions.py --directory $out \
>/dev/null
fi
# The generator emits a header comment containing the man page store
# path. Strip it so identical completions from different packages
# don't collide and so we don't retain runtime references to the
# inputs. Fail if generated files lack the expected header so that a
# change in the upstream format gets noticed.
shopt -s nullglob
for f in $out/*.fish; do
if ! grep -q '^# Autogenerated from ' "$f"; then
echo "error: expected '# Autogenerated from' header not found in $f" >&2
exit 1
fi
sed -i '/^# Autogenerated from /d' "$f"
done
'';
packages =
if
config.documentation.enable && config.documentation.nixos.enable && config.documentation.man.enable
then
builtins.filter (pkg: pkg != config.system.build.manual.nixos-configuration-reference-manpage) (
cfge.systemPackages ++ cfg.extraCompletionPackages
)
else
cfge.systemPackages ++ cfg.extraCompletionPackages;
in
pkgs.buildEnv {
name = "system_fish-completions";
ignoreCollisions = true;
paths = map generateCompletions packages;
};
})
# include programs that bring their own completions
{
pathsToLink =
lib.optional cfg.vendor.config.enable "/share/fish/vendor_conf.d"
++ lib.optional cfg.vendor.completions.enable "/share/fish/vendor_completions.d"
++ lib.optional cfg.vendor.functions.enable "/share/fish/vendor_functions.d";
}
{ systemPackages = [ cfg.package ]; }
{
shells = [
"/run/current-system/sw/bin/fish"
(lib.getExe cfg.package)
];
}
];
programs.fish.interactiveShellInit =
lib.optionalString cfg.generateCompletions ''
# add completions generated by NixOS to $fish_complete_path
begin
# joins with null byte to accommodate all characters in paths, then respectively gets all paths before (exclusive) / after (inclusive) the first one including "generated_completions",
# splits by null byte, and then removes all empty lines produced by using 'string'
set -l prev (string join0 $fish_complete_path | string match --regex "^.*?(?=\x00[^\x00]*generated_completions.*)" | string split0 | string match -er ".")
set -l post (string join0 $fish_complete_path | string match --regex "[^\x00]*generated_completions.*" | string split0 | string match -er ".")
set fish_complete_path $prev "/etc/fish/generated_completions" $post
end
''
+ ''
# prevent fish from generating completions on first run
if not test -d $__fish_user_data_dir/generated_completions
${pkgs.coreutils}/bin/mkdir $__fish_user_data_dir/generated_completions
end
'';
};
meta.maintainers = with lib.maintainers; [
llakala
sigmasquadron
];
}