From ef8997e69eaf215eb99edacacd24edbca16b0c9d Mon Sep 17 00:00:00 2001 From: Matt Schreiber Date: Mon, 31 Jul 2023 12:43:19 -0400 Subject: [PATCH] feat(commands): add "documentation only" commands that aren't installed into the devshell environment, but nonetheless appear in the output of `menu`. --- modules/commands.nix | 251 +++++++++++++++++++------------- nix/writeDefaultShellScript.nix | 1 + tests/core/commands.nix | 51 +++++++ 3 files changed, 200 insertions(+), 103 deletions(-) diff --git a/modules/commands.nix b/modules/commands.nix index 3e1e109f..8d99f69a 100644 --- a/modules/commands.nix +++ b/modules/commands.nix @@ -18,49 +18,13 @@ 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; @@ -68,87 +32,159 @@ let (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 = ".doc_only"; + description = '' + When enabled, the devshell startup process will issue a warning if + this command cannot be found (as determined by `command -v + `). + + Potentially useful for documentation-only commands + (`.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. @@ -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") + ]; } diff --git a/nix/writeDefaultShellScript.nix b/nix/writeDefaultShellScript.nix index 4c0215a2..8c6ec027 100644 --- a/nix/writeDefaultShellScript.nix +++ b/nix/writeDefaultShellScript.nix @@ -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}"; }) diff --git a/tests/core/commands.nix b/tests/core/commands.nix index 43b5eb6f..cef01f60 100644 --- a/tests/core/commands.nix +++ b/tests/core/commands.nix @@ -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 + ''; }