Skip to content

feat(commands): add "documentation only" commands #343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 148 additions & 103 deletions modules/commands.nix
Original file line number Diff line number Diff line change
Expand Up @@ -18,137 +18,173 @@ let

pad = str: num: if num > 0 then pad "${str} " (num - 1) else str;

# Fallback to the package pname if the name is unset
resolveName =
cmd:
if cmd.name == null then
cmd.package.pname or (builtins.parseDrvName cmd.package.name).name
else
cmd.name;

# Fill in default options for a command.
commandToPackage =
cmd:
assert lib.assertMsg (cmd.command == null || cmd.name != cmd.command)
"[[commands]]: ${toString cmd.name} cannot be set to both the `name` and the `command` attributes. Did you mean to use the `package` attribute?";
assert lib.assertMsg (
cmd.package != null || (cmd.command != null && cmd.command != "")
) "[[commands]]: ${resolveName cmd} expected either a command or package attribute.";
if cmd.package == null then
writeDefaultShellScript {
name = cmd.name;
text = cmd.command;
binPrefix = true;
}
else
cmd.package;

commandsToMenu =
cmds:
let
cleanName =
{ name, package, ... }@cmd:
assert lib.assertMsg (
cmd.name != null || cmd.package != null
) "[[commands]]: some command is missing both a `name` or `package` attribute.";
let
name = resolveName cmd;

help = if cmd.help == null then cmd.package.meta.description or "" else cmd.help;
in
cmd // { inherit name help; };

commands = map cleanName cmds;
inherit (config) commands;

commandLengths = map ({ name, ... }: builtins.stringLength name) commands;
commandLengths =
map ({ entry, ... }: builtins.stringLength entry) commands;

maxCommandLength = builtins.foldl' (max: v: if v > max then v else max) 0 commandLengths;

commandCategories = lib.unique (
(zipAttrsWithNames [ "category" ] (name: vs: vs) commands).category
);

commandByCategoriesSorted = builtins.attrValues (
lib.genAttrs commandCategories (
category:
lib.nameValuePair category (
builtins.sort (a: b: a.name < b.name) (builtins.filter (x: x.category == category) commands)
)
)
);
commandByCategoriesSorted =
builtins.attrValues (lib.genAttrs
commandCategories
(category: lib.nameValuePair category (builtins.sort
(a: b: a.entry < b.entry)
(builtins.filter (x: x.category == category) commands)
))
);

opCat =
kv:
let
category = kv.name;
cmd = kv.value;
opCmd =
{ name, help, ... }:
opCmd = { entry, help, ... }:
let
len = maxCommandLength - (builtins.stringLength name);
len = maxCommandLength - (builtins.stringLength entry);
in
if help == null || help == "" then " ${name}" else " ${pad name len} - ${help}";
if help == "" then " ${entry}" else " ${pad entry len} - ${help}";
in
"\n${ansi.bold}[${category}]${ansi.reset}\n\n" + builtins.concatStringsSep "\n" (map opCmd cmd);
in
builtins.concatStringsSep "\n" (map opCat commandByCategoriesSorted) + "\n";

# These are all the options available for the commands.
commandOptions = {
name = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Name of this command. Defaults to attribute name in commands.
'';
};

category = mkOption {
type = types.str;
default = "[general commands]";
description = ''
Set a free text category under which this command is grouped
and shown in the help menu.
'';
};

help = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Describes what the command does in one line of text.
'';
};

command = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
If defined, it will add a script with the name of the command, and the
content of this value.

By default it generates a bash script, unless a different shebang is
provided.
'';
example = ''
#!/usr/bin/env python
print("Hello")
'';
};

package = mkOption {
type = types.nullOr strOrPackage;
default = null;
description = ''
Used to bring in a specific package. This package will be added to the
environment.
'';
# This is the submodule defining all the options available for the commands.
commandModule = { name, config, options, ... }: {
options = {
name = mkOption {
type = types.str;
description = ''
Name of this command. Defaults to attribute name in commands.
'';
};

category = mkOption {
type = types.str;
default = "[general commands]";
description = ''
Set a free text category under which this command is grouped
and shown in the help menu.
'';
};

help = mkOption {
type = types.nullOr types.str;
default = if config.doc_only then "" else config.package.meta.description or "";
description = ''
Describes what the command does in one line of text.
'';
};

command = mkOption {
type = types.str;
description = ''
If defined, it will add a script with the name of the command, and
the content of this value.

By default it generates a bash script, unless a different shebang is
provided.
'';
example = ''
#!/usr/bin/env python
print("Hello")
'';
};

package = mkOption {
type = types.nullOr strOrPackage;
default =
if config.doc_only
then null
else
(assert lib.assertMsg ((!config.doc_only) -> (options.name.isDefined && options.command.isDefined)) "[[commands]]: ${name}: expected either (1) a `name` and `command` attribute or (b) a `package` attribute (${if config.doc_only then "true" else "false"}).";
assert lib.assertMsg (config.name != config.command) "[[commands]]: ${name}: cannot set both the `name` and the `command` attributes to the same value '${config.name}'. Did you mean to use the `package` attribute?";
writeDefaultShellScript
{
name = config.name;
text = config.command;
binPrefix = true;
});
description = ''
Used to bring in a specific package. This package will be added to
the environment.
'';
};

doc_only = mkOption {
type = types.bool;
default = false;
description = ''
Indicate that this command is for documentation only. Its name and
help text will appear in the devshell menu, but no corresponding
script will be added to the devshell environment.
'';
};

warn_if_missing = mkOption {
type = types.bool;
default = config.doc_only;
defaultText = "<command>.doc_only";
description = ''
When enabled, the devshell startup process will issue a warning if
this command cannot be found (as determined by `command -v
<command>`).

Potentially useful for documentation-only commands
(`<command>.doc_only`) that are expected to be available within the
devshell environment but that are not explicitly installed by the
devshell configuration.
'';
};

program = mkOption {
type = types.str;
internal = true;
readOnly = true;
default =
if options.name.isDefined
then config.name
else if config.package ? meta.mainProgram
then config.package.meta.mainProgram
else config.entry;
};

check = mkOption {
type = types.functionTo types.nonEmptyStr;
default = cmd: "command -v ${lib.escapeShellArg cmd.program}";
description = '''';
};

entry = mkOption {
type = types.str;
internal = true;
readOnly = true;
default =
if options.name.isDefined
then config.name
else config.package.pname or (builtins.parseDrvName config.package.name).name;
};


__toString = mkOption {
type = types.functionTo types.str;
internal = true;
readOnly = true;
default = self: self.entry;
};
};
};
in
{
options.commands = mkOption {
type = types.listOf (types.submodule { options = commandOptions; });
type = types.listOf (types.submodule commandModule);
default = [ ];
description = ''
Add commands to the environment.
Expand Down Expand Up @@ -183,6 +219,15 @@ in

# Add the commands to the devshell packages. Either as wrapper scripts, or
# the whole package.
config.devshell.packages = map commandToPackage config.commands;
config.devshell.packages = map (cmd: cmd.package) (lib.filter (cmd: !cmd.doc_only) config.commands);
# config.devshell.motd = "$(motd)";

config.devshell.startup.warn_if_missing_commands.text = lib.pipe config.commands [
(lib.filter (cmd: cmd.warn_if_missing))
(map (cmd: ''
${cmd.check cmd} 1>/dev/null 2>&1 \
|| echo "${ansi.bold}${ansi."11"}warning:${ansi.reset} expected '${cmd.program}' to be available in ${config.devshell.name} but it is missing" 1>&2
''))
(lib.concatStringsSep "\n")
];
}
1 change: 1 addition & 0 deletions nix/writeDefaultShellScript.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ writeTextFile (
inherit name;
text = script;
executable = true;
meta.mainProgram = name;
}
// (lib.optionalAttrs (checkPhase != null) { inherit checkPhase; })
// (lib.optionalAttrs binPrefix { destination = "/bin/${name}"; })
Expand Down
51 changes: 51 additions & 0 deletions tests/core/commands.nix
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,55 @@
# Ideally it would be rewritten with patchShebang.
assert "$(head -n1 "$(type -p python-script)")" == "#!/usr/bin/env python3"
'';

# Documentation-only commands
commands-2 =
let
shell = devshell.mkShell {
devshell.name = "commands-2";
devshell.packages = [ pkgs.coreutils ];
commands = [
{
name = "awol";
category = "ambient";
help = "Not present in the devshell :(";
doc_only = true;
}
{
name = "truant";
category = "ambient";
help = "Not present in the devshell, but no biggie :|";
doc_only = true;
warn_if_missing = false;
}
{
name = "ok";
category = "ambient";
help = "Present in the devshell :)";
doc_only = true;
}
];
};
in
runTest "devshell-2" { } ''
ok() {
: # NOP
}

# Capture output from loading devshell
diag="$({ source ${shell}/env.bash || : ; } |& tee /dev/stderr)"

# Actually load the devshell
source ${shell}/env.bash

[[ "$diag" == *warning:*"expected 'awol' to be available in"*'but it is missing'* ]] || assert "did not get expected message"
[[ "$diag" != *warning:*"expected 'truant' to be available in"*'but it is missing'* ]] || assert "did not get expected message"
[[ "$diag" != *warning:*"expected 'ok' to be available in"*'but it is missing'* ]] || assert "did not get expected message"

menu

# Checks that commands expected to be absent are indeed absent.
! type -p awol
! type -p truant
'';
}
Loading