diff --git a/docs/src/modules_schema.md b/docs/src/modules_schema.md index 245d7d6b..f2d356b5 100644 --- a/docs/src/modules_schema.md +++ b/docs/src/modules_schema.md @@ -61,7 +61,7 @@ Add commands to the environment. ``` -**Type**: list of (submodule) +**Type**: list of ((package or string convertible to it) or (list with two elements of types: [ string (package or string convertible to it) ]) or (flatOptions)) **Example value**: ```nix @@ -72,7 +72,23 @@ Add commands to the environment. Declared in: * [modules/commands.nix](https://github.com/numtide/devshell/tree/main/modules/commands.nix) -## `commands.*.package` +## `commands.*` + +A config for a command when the `commands` option is a list. + + +**Type**: (package or string convertible to it) or (list with two elements of types: [ string (package or string convertible to it) ]) or (flatOptions) + +**Example value**: +```nix +{"_type":"literalExpression","text":"[\n {\n category = \"scripts\";\n package = \"black\";\n }\n [ \"[package] print hello\" \"hello\" ]\n \"nodePackages.yarn\"\n]\n"} +``` + + +Declared in: +* [nix/commands/types.nix](https://github.com/numtide/devshell/tree/main/nix/commands/types.nix) + +## `commands.*.package (flatOptions)` Used to bring in a specific package. This package will be added to the environment. @@ -84,15 +100,15 @@ environment. ``` -**Type**: null or (package or string convertible to it) +**Type**: null or (package or string convertible to it) or package Declared in: -* [modules/commands.nix](https://github.com/numtide/devshell/tree/main/modules/commands.nix) +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) -## `commands.*.category` +## `commands.*.category (flatOptions)` -Set a free text category under which this command is grouped -and shown in the help menu. +Sets a free text category under which this command is grouped +and shown in the devshell menu. **Default value**: @@ -104,9 +120,9 @@ and shown in the help menu. **Type**: string Declared in: -* [modules/commands.nix](https://github.com/numtide/devshell/tree/main/modules/commands.nix) +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) -## `commands.*.command` +## `commands.*.command (flatOptions)` If defined, it will add a script with the name of the command, and the content of this value. @@ -130,9 +146,29 @@ provided. Declared in: -* [modules/commands.nix](https://github.com/numtide/devshell/tree/main/modules/commands.nix) +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) + +## `commands.*.expose (flatOptions)` + +When `true`, the `(flatOptions) command` +or the `(flatOptions) package` will be added to the environment. + +Otherwise, they will not be added to the environment, but will be printed +in the devshell menu. + + +**Default value**: +```nix +{"_type":"literalExpression","text":"true"} +``` -## `commands.*.help` + +**Type**: boolean + +Declared in: +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) + +## `commands.*.help (flatOptions)` Describes what the command does in one line of text. @@ -146,11 +182,15 @@ Describes what the command does in one line of text. **Type**: null or string Declared in: -* [modules/commands.nix](https://github.com/numtide/devshell/tree/main/modules/commands.nix) +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) -## `commands.*.name` +## `commands.*.name (flatOptions)` -Name of this command. Defaults to attribute name in commands. +Name of this command. + +Defaults to a `package (flatOptions)` name or pname if present. + +The value of this option is required for a `command (flatOptions)`. **Default value**: @@ -162,7 +202,23 @@ Name of this command. Defaults to attribute name in commands. **Type**: null or string Declared in: -* [modules/commands.nix](https://github.com/numtide/devshell/tree/main/modules/commands.nix) +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) + +## `commands.*.prefix (flatOptions)` + +Prefix of the command name in the devshell menu. + + +**Default value**: +```nix +{"_type":"literalExpression","text":"\"\""} +``` + + +**Type**: string + +Declared in: +* [nix/commands/flatOptions.nix](https://github.com/numtide/devshell/tree/main/nix/commands/flatOptions.nix) ## `devshell.packages` diff --git a/modules/commands.nix b/modules/commands.nix index d4e74e89..792b3580 100644 --- a/modules/commands.nix +++ b/modules/commands.nix @@ -1,163 +1,17 @@ { 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/convert.nix { inherit pkgs; }) commandsToMenu commandToPackage; + inherit (import ../nix/commands/devshellMenu.nix { inherit pkgs; }) mkDevshellMenuCommand; + 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"; @@ -173,20 +27,8 @@ in ''; }; - config.commands = [ - { - help = "prints this menu"; - name = "menu"; - command = '' - cat <<'DEVSHELL_MENU' - ${commandsToMenu config.commands} - DEVSHELL_MENU - ''; - } - ]; - # 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 commandToPackage ([ (mkDevshellMenuCommand config.commands) ] ++ config.commands); # config.devshell.motd = "$(motd)"; } diff --git a/modules/modules-docs.nix b/modules/modules-docs.nix index c44636a1..cce25bbe 100644 --- a/modules/modules-docs.nix +++ b/modules/modules-docs.nix @@ -119,8 +119,9 @@ let let # TODO: handle opt.relatedPackages. What is it for? optToMd = opt: + let heading = (lib.showOption (filter isString opt.loc)) + (concatStrings (filter (x: !(isString x)) opt.loc)); in '' - ## `${opt.name}` + ## `${heading}` '' + (lib.optionalString opt.internal "\n**internal**\n") diff --git a/nix/commands/convert.nix b/nix/commands/convert.nix new file mode 100644 index 00000000..f0713166 --- /dev/null +++ b/nix/commands/convert.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; + }; + + inherit (import ./devshellMenu.nix { inherit pkgs; }) devshellMenuCommandName; + + 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/devshellMenu.nix b/nix/commands/devshellMenu.nix new file mode 100644 index 00000000..be9d079d --- /dev/null +++ b/nix/commands/devshellMenu.nix @@ -0,0 +1,34 @@ +{ system ? builtins.currentSystem +, pkgs ? import ../nixpkgs.nix { inherit system; } +}: +let + lib = builtins // pkgs.lib; + inherit (import ./types.nix { inherit pkgs; }) flatOptionsType; + inherit (import ./convert.nix { inherit pkgs; }) commandsToMenu; +in +rec { + devshellMenuCommandName = "menu"; + + mkDevshellMenuCommand = commands: flatOptionsType.merge [ ] [ + { + file = lib.unknownModule; + value = { + help = "prints this menu"; + name = devshellMenuCommandName; + command = '' + cat <<'DEVSHELL_MENU' + ${commandsToMenu + ( + let + commands_ = [ commandMenu ] ++ commands; + commandMenu = mkDevshellMenuCommand commands_; + in + commands_ + ) + } + DEVSHELL_MENU + ''; + }; + } + ]; +} diff --git a/nix/commands/flatOptions.nix b/nix/commands/flatOptions.nix new file mode 100644 index 00000000..8219ebb2 --- /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 `(${flatOptionsType.name}) command` + or the `(${flatOptionsType.name}) package` 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/nix/nixpkgs.nix b/nix/nixpkgs.nix index be69a415..731d2bbd 100644 --- a/nix/nixpkgs.nix +++ b/nix/nixpkgs.nix @@ -1,8 +1,10 @@ +{ system ? builtins.currentSystem }: let - # nixpkgs is only used for development. Don't add it to the flake.lock. - gitRev = "2c2a09678ce2ce4125591ac4fe2f7dfaec7a609c"; + lock = builtins.fromJSON (builtins.readFile ../flake.lock); + nixpkgs = + fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/${lock.nodes.nixpkgs.locked.rev}.tar.gz"; + sha256 = lock.nodes.nixpkgs.locked.narHash; + }; in -builtins.fetchTarball { - url = "https://github.com/NixOS/nixpkgs/archive/${gitRev}.tar.gz"; - sha256 = "1pkz5bq8f5p9kxkq3142lrrq1592d7zdi75fqzrf02cl1xy2cwvn"; -} +import nixpkgs { inherit system; }