diff --git a/modules/commands.nix b/modules/commands.nix index d4e74e89..ffb5a486 100644 --- a/modules/commands.nix +++ b/modules/commands.nix @@ -1,163 +1,16 @@ { lib, config, pkgs, ... }: -with lib; let - ansi = import ../nix/ansi.nix; - - # Because we want to be able to push pure JSON-like data into the - # environment. - strOrPackage = import ../nix/strOrPackage.nix { inherit lib pkgs; }; - - writeDefaultShellScript = import ../nix/writeDefaultShellScript.nix { - inherit (pkgs) lib writeTextFile bash; - }; - - 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; - - commandLengths = - map ({ name, ... }: builtins.stringLength name) 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) - )) - ); - - opCat = kv: - let - category = kv.name; - cmd = kv.value; - opCmd = { name, help, ... }: - let - len = maxCommandLength - (builtins.stringLength name); - in - if help == null || help == "" then - " ${name}" - else - " ${pad name 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. - ''; - }; - }; + inherit (import ../nix/commands/devshell.nix { inherit pkgs; }) commandsToMenu commandToPackage devshellMenuCommandName; + inherit (import ../nix/commands/types.nix { inherit pkgs; }) commandsFlatType; in { - options.commands = mkOption { - type = types.listOf (types.submodule { options = commandOptions; }); + options.commands = lib.mkOption { + type = commandsFlatType; default = [ ]; description = '' Add commands to the environment. ''; - example = literalExpression '' + example = lib.literalExpression '' [ { help = "print hello"; @@ -176,7 +29,7 @@ in config.commands = [ { help = "prints this menu"; - name = "menu"; + name = devshellMenuCommandName; command = '' cat <<'DEVSHELL_MENU' ${commandsToMenu config.commands} diff --git a/modules/devshell.nix b/modules/devshell.nix index 2b5186a2..8aac99b4 100644 --- a/modules/devshell.nix +++ b/modules/devshell.nix @@ -12,6 +12,8 @@ let # environment. strOrPackage = import ../nix/strOrPackage.nix { inherit lib pkgs; }; + inherit (import ../nix/commands/devshell.nix { inherit pkgs; }) devshellMenuCommandName; + # Use this to define a flake app for the environment. mkFlakeApp = bin: { type = "app"; @@ -255,7 +257,7 @@ in type = types.str; default = '' {202}🔨 Welcome to ${cfg.name}{reset} - $(type -p menu &>/dev/null && menu) + $(type -p ${devshellMenuCommandName} &>/dev/null && ${devshellMenuCommandName}) ''; apply = replaceStrings (map (key: "{${key}}") (attrNames ansi)) diff --git a/modules/modules-docs.nix b/modules/modules-docs.nix index 547b9f77..a6dbc4ad 100644 --- a/modules/modules-docs.nix +++ b/modules/modules-docs.nix @@ -121,7 +121,7 @@ let # TODO: handle opt.relatedPackages. What is it for? optToMd = opt: - let heading = lib.showOption opt.loc; in + let heading = (lib.showOption (filter isString opt.loc)) + (concatStrings (filter (x: !(isString x)) opt.loc)); in '' ### `${heading}` diff --git a/nix/commands/devshell.nix b/nix/commands/devshell.nix new file mode 100644 index 00000000..23f88b8d --- /dev/null +++ b/nix/commands/devshell.nix @@ -0,0 +1,123 @@ +{ system ? builtins.currentSystem +, pkgs ? import ../nixpkgs.nix { inherit system; } +}: +let + lib = builtins // pkgs.lib; +in +rec { + ansi = import ../ansi.nix; + + writeDefaultShellScript = import ../writeDefaultShellScript.nix { + inherit (pkgs) lib writeTextFile bash; + }; + + devshellMenuCommandName = "menu"; + + pad = str: num: + if num > 0 then + pad "${str} " (num - 1) + else + str; + + resolveName = cmd: + if cmd.name == null then + cmd.package.pname or (lib.parseDrvName cmd.package.name).name + else + cmd.name; + + commandsMessage = "[[commands]]:"; + + # Fill in default options for a command. + commandToPackage = cmd: + if cmd.name != devshellMenuCommandName && cmd.command == null && cmd.package == null then null + else + assert lib.assertMsg (cmd.command == null || cmd.name != cmd.command) "${commandsMessage} in ${lib.generators.toPretty {} cmd}, ${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 != null && cmd.command != "" && cmd.package == null)) "${commandsMessage} ${lib.generators.toPretty {} cmd} expected either a non-empty command or a package attribute, not both."; + if cmd.package == null + then + writeDefaultShellScript + { + name = cmd.name; + text = cmd.command; + binPrefix = true; + } + else if !cmd.expose + then null + else cmd.package; + + commandsToMenu = cmds: + let + cleanName = { name, package, ... }@cmd: + if + cmd.package == null && (cmd.name != devshellMenuCommandName && cmd.command == null) + && (cmd.prefix != "" || (cmd.name != null && cmd.name != "")) + && cmd.help != null + then + cmd // { + name = "${ + if cmd.prefix != null then cmd.prefix else "" + }${ + if cmd.name != null then cmd.name else "" + }"; + } + else + assert lib.assertMsg (cmd.name != null || cmd.package != null) "${commandsMessage} some command is missing a `name`, a `prefix`, and a `package` attributes."; + let + name = lib.pipe cmd [ + resolveName + (x: if x != null && lib.hasInfix " " x then "'${x}'" else x) + (x: "${cmd.prefix}${x}") + ]; + + help = + if cmd.help == null then + cmd.package.meta.description or "" + else + cmd.help; + in + cmd // { + inherit name help; + }; + + commands = map cleanName cmds; + + commandLengths = + map ({ name, ... }: lib.stringLength name) commands; + + maxCommandLength = + lib.foldl' + (max: v: if v > max then v else max) + 0 + commandLengths + ; + + commandCategories = lib.unique ( + (lib.zipAttrsWithNames [ "category" ] (_: vs: vs) commands).category + ); + + commandByCategoriesSorted = + lib.attrValues (lib.genAttrs + commandCategories + (category: lib.nameValuePair category (lib.sort + (a: b: a.name < b.name) + (lib.filter (x: x.category == category) commands) + )) + ); + + opCat = kv: + let + category = kv.name; + cmd = kv.value; + opCmd = { name, help, ... }: + let + len = maxCommandLength - (lib.stringLength name); + in + if help == null || help == "" then + " ${name}" + else + " ${pad name len} - ${help}"; + in + "\n${ansi.bold}[${category}]${ansi.reset}\n\n" + lib.concatStringsSep "\n" (map opCmd cmd); + in + lib.concatStringsSep "\n" (map opCat commandByCategoriesSorted) + "\n"; +} diff --git a/nix/commands/flatOptions.nix b/nix/commands/flatOptions.nix new file mode 100644 index 00000000..d3ab0f72 --- /dev/null +++ b/nix/commands/flatOptions.nix @@ -0,0 +1,78 @@ +{ lib, strOrPackage, flatOptionsType }: +with lib; +# These are all the options available for the commands. +{ + prefix = mkOption { + type = types.str; + default = ""; + description = '' + Prefix of the command name in the devshell menu. + ''; + }; + + name = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Name of this command. + + Defaults to a `package (${flatOptionsType.name})` name or pname if present. + + The value of this option is required for a `command (${flatOptionsType.name})`. + ''; + }; + + category = mkOption { + type = types.str; + default = "[general commands]"; + description = '' + Sets a free text category under which this command is grouped + and shown in the devshell 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 (types.oneOf [ strOrPackage types.package ]); + default = null; + description = '' + Used to bring in a specific package. This package will be added to the + environment. + ''; + }; + + expose = mkOption { + type = types.bool; + default = true; + description = '' + When `true`, the `command (${flatOptionsType.name})` + or the `package (${flatOptionsType.name})` will be added to the environment. + + Otherwise, they will not be added to the environment, but will be printed + in the devshell menu. + ''; + }; +} diff --git a/nix/commands/types.nix b/nix/commands/types.nix new file mode 100644 index 00000000..5805c1bb --- /dev/null +++ b/nix/commands/types.nix @@ -0,0 +1,92 @@ +{ system ? builtins.currentSystem +, pkgs ? import ../nixpkgs.nix { inherit system; } +, lib ? pkgs.lib +}: +with lib; +with builtins; +rec { + # find a package corresponding to the string + resolveKey = arg: + if isString arg && lib.strings.sanitizeDerivationName arg == arg then + attrByPath (splitString "\." arg) null pkgs + else if isDerivation arg then + arg + else null; + + strOrPackage = types.coercedTo types.str resolveKey types.package; + + list2Of = t1: t2: mkOptionType { + name = "list2Of"; + description = "list with two elements of types: [ ${ + concatMapStringsSep " " (types.optionDescriptionPhrase (class: class == "noun" || class == "composite")) [ t1 t2 ] + } ]"; + check = x: isList x && length x == 2 && t1.check (head x) && t2.check (last x); + merge = mergeOneOption; + }; + + flatOptions = import ./flatOptions.nix { inherit lib strOrPackage flatOptionsType; }; + + mkAttrsToString = str: { __toString = _: str; }; + + mkLocLast = name: mkAttrsToString " (${name})"; + + flatOptionsType = + let submodule = types.submodule { options = flatOptions; }; in + submodule // rec { + name = "flatOptions"; + description = name; + getSubOptions = prefix: (mapAttrs + (name_: value: value // { + loc = prefix ++ [ + name_ + (mkLocLast name) + ]; + declarations = [ "${toString ../..}/nix/commands/flatOptions.nix" ]; + }) + (submodule.getSubOptions prefix)); + }; + + pairHelpPackageType = list2Of types.str strOrPackage; + + flatConfigType = + ( + types.oneOf [ + strOrPackage + pairHelpPackageType + flatOptionsType + ] + ) // { + getSubOptions = prefix: { + flat = flatOptionsType.getSubOptions prefix; + }; + } + ; + + commandsFlatType = types.listOf flatConfigType // { + name = "commandsFlat"; + getSubOptions = prefix: { + fakeOption = ( + mkOption + { + type = flatConfigType; + description = '' + A config for a command when the `commands` option is a list. + ''; + example = literalExpression '' + [ + { + category = "scripts"; + package = "black"; + } + [ "[package] print hello" "hello" ] + "nodePackages.yarn" + ] + ''; + } + ) // { + loc = prefix ++ [ "*" ]; + declarations = [ "${toString ../..}/nix/commands/types.nix" ]; + }; + }; + }; +} diff --git a/tests/core/commands.nix b/tests/core/commands.nix index 2376b2f4..d8d390a3 100644 --- a/tests/core/commands.nix +++ b/tests/core/commands.nix @@ -1,4 +1,5 @@ { pkgs, devshell, runTest }: +let inherit (import ../../nix/commands/devshell.nix { inherit pkgs; }) devshellMenuCommandName; in { # Basic devshell usage commands-1 = @@ -33,7 +34,7 @@ # Load the devshell source ${shell}/env.bash - menu + ${devshellMenuCommandName} # Checks that all the commands are available type -p bash-script