diff --git a/.config/dev-session b/.config/dev-session new file mode 100644 index 0000000..d42132d --- /dev/null +++ b/.config/dev-session @@ -0,0 +1,11 @@ +new_window "editor" +run_cmd "$EDITOR" + +new_window +run_cmd "g s" + +new_window "sub" +run_cmd "watchexec make test" +split_h +run_cmd "watchexec make" +moveto "runner" diff --git a/CHANGELOG.md b/CHANGELOG.md index eb87805..221952a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v2.0.0 - 23.05.2024 + +Release 2.0.0 is a major release that includes breaking changes. + +- Rename `--bin` to `--executable` +- Replace `help` subcommand with `--help` flag +- Replace `completions` subcommand with `--completions` flag +- Replace `commands` subcommand with `--commands` flag +- Validate script arguments from Usage comment + +Plus a major refactor of the codebase, new features and changes to the output. + ## v1.1.0 - 03.05.2024 - Add flag `--extension` (`-e`) to `commands` subcommand to filter by file extension diff --git a/Cargo.lock b/Cargo.lock index 30357e6..ba944cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -11,6 +23,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + [[package]] name = "anstream" version = "0.6.13" @@ -59,6 +77,28 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "cc" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown", + "stacker", +] + [[package]] name = "clap" version = "4.5.4" @@ -92,18 +132,67 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "libc" +version = "0.2.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" + [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "proc-macro2" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874" +dependencies = [ + "cc", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + [[package]] name = "regex" version = "1.10.4" @@ -133,6 +222,19 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +[[package]] +name = "stacker" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "winapi", +] + [[package]] name = "strsim" version = "0.11.1" @@ -143,18 +245,64 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" name = "sub" version = "1.1.0" dependencies = [ + "chumsky", "clap", "lazy_static", "regex", "xdg", ] +[[package]] +name = "syn" +version = "2.0.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c993ed8ccba56ae856363b1845da7266a7cb78e1d146c8a32d54b45a8b831fc9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + [[package]] name = "utf8parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.52.0" @@ -233,3 +381,23 @@ name = "xdg" version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 3ef86e1..ba0d6f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ authors = ["Juan Ibiapina "] edition = "2021" [dependencies] -clap = "4" +chumsky = "*" +clap = { version = "*", features = ["string"] } lazy_static = "*" regex = "*" xdg = "*" diff --git a/README.md b/README.md index 66203f3..395c0b2 100644 --- a/README.md +++ b/README.md @@ -2,100 +2,335 @@ Organize groups of scripts into documented CLIs with subcommands. -## Overview +`sub` is a tool designed to help organize groups of scripts into a command-line +interface (CLI) with subcommands. It allows the dynamic creation of a CLI from +a directory (and subdirectories) of scripts. -`sub` is meant to be used as the entry point for a CLI. Given the following -directory structure: +Use the Github table of contents on the top right of this README to navigate +the documentation. + +## Key features + +- **Display help:** Display usage and documentation for scripts. +- **Validate arguments:** Validate arguments to scripts based on documentation. +- **Parse arguments:** Automatically parse arguments to scripts so `getopts` is not needed. +- **Nested subcommands:** Supports nested directories for hierarchical command structures. +- **Aliases:** Supports aliases for subcommands. +- **Completions:** Supports auto completion of subcommands. +- **Cross-platform:** Works on Linux and macOS. + +## Installation + +### Homebrew + +```sh +brew install juanibiapina/tap/sub +``` + +### Nix with Flakes + +Add sub to your flake inputs: + +```nix +{ + inputs = { + sub = { + url = "github:juanibiapina/sub"; + inputs.nixpkgs.follows = "nixpkgs"; # Optional + }; + }; + + # ... +} +``` + +Then add it to your packages: + +```nix +{ + environment.systemPackages = with pkgs; [ + inputs.sub.packages."${pkgs.system}".sub + # ... + ]; +} +``` + +## Setup + +This section explains how to set up a CLI called `hat` using `sub`. + +### As an alias + +The quickest way to get started with `sub` is to define an alias for your CLI +in your shell: + +```sh +alias hat='sub --name hat --absolute /path/to/cli/root --' +``` + +Where `/path/to/cli/root` contains a `libexec` directory with executable +scripts, for example: ``` . -├── bin -│   └── awesomecli └── libexec - ├── list - ├── new - ├── open -    └── nested -    ├── README -    └── command + ├── user-script1 + ├── user-script2 +    └── user-script3 ``` -The entry point in `bin/awesomecli` can then be: +### As an executable + +A more reliable way is to use an executable script as the entry point. Given +the following directory structure: ``` +. +├── bin +│   └── hat +└── libexec + ├── user-script1 + ├── user-script2 +    └── user-script3 +``` + +The entry point in `bin/hat` is then: + +```sh #!/usr/bin/env bash -sub --name awesomecli --bin "${BASH_SOURCE[0]}" --relative ".." -- "$@" +sub --name hat --executable "${BASH_SOURCE[0]}" --relative ".." -- "$@" ``` -The `--name` argument tells `sub` the name of the CLI. This is used when -printing help information. +The `--executable` argument tells `sub` where the CLI entry point is located. +This will almost always be `${BASH_SOURCE[0]}`. The `--relative` argument tells +`sub` how to find the root of the CLI starting from the CLI entry point. In the +line above, just replace `hat` with the name of your CLI. -The `--bin` argument tells `sub` where the binary entry point is located. -Usually this will just be `${BASH_SOURCE[0]}`. The `--relative` argument tells -`sub` how to find the root of the CLI starting from the binary entry point. -These two are separate arguments for cross platform compatibility. `sub` will -canonalize the bin path before merging with the relative path and then canonalize -again. +## Usage -After the root directory is determined, `sub` picks up any executable files in -a `libexec` directory inside root to use as subcommands. Directories create -nested subcommands. Arguments for the subcommands themselves go after the `--`. - -With this setup, invoking `awesomecli` will display all available subcommands -with associated help information. To invoke a subcommand, use: +Once you have set up your CLI (we called it `hat`), you can get help by running: +```sh +$ hat --help ``` -$ awesomecli ``` +Usage: hat [OPTIONS] [commands_with_args]... -Or to invoke a nested subcommand: +Arguments: + [commands_with_args]... -``` -$ awesomecli nested command +Options: + --usage Print usage + -h, --help Print help + --completions Print completions + --commands Print subcommands + --extension Filter subcommands by extension + +Available subcommands: + user-script1 + user-script2 + user-script3 ``` -## Documenting commands +To invoke a subcommand, use: -To get help for a command, use the built in `help` command: +``` +$ hat user-script1 +``` +To get help for a command, use the built in `--help` flag: + +```sh +hat --help ``` -$ awesomecli help +or +```sh +hat --help ``` -In order to display help information, `sub` looks for special comments in each -file. An example documented command: +## Documenting commands + +In order to display help information, `sub` looks for special comments in the +corresponding script. A fully documented `hello` script could look like this: ```sh #!/usr/bin/env bash # -# Summary: One line summary of the command +# Summary: Say hello # -# Usage: {cmd} -# {cmd} [--option] +# Usage: {cmd} [--spanish] # -# --option Activates an option +# Say hello to a user by name. # -# Extended description of what the command does. +# With the --spanish flag, the greeting will be in Spanish. + +set -e + +declare -A args="($_HAT_ARGS)" + +if [[ "${args[spanish]}" == "true" ]]; then + echo "¡Hola, ${args[name]}!" +else + echo "Hello, ${args[name]}!" +fi +``` + +`sub` looks for special comments in a comment block in the beginning of the +file. The special comments are: + +- `Summary:` A short description of the script. +- `Usage:` A description of the arguments the script accepts. Note that the + Usage comment, when present, has specific syntactic rules and is used to + parse command line arguments. See [Validating arguments](#validating-arguments) + and [Parsing arguments](#parsing-arguments) for more information. +- Extended documentation: Any other comment lines in this initial block will be + considered part of the extended documentation. + +## Validating arguments + +`sub` automatically validates arguments to scripts based on the `Usage` +comment when it is present. The syntax for the `Usage` comment is: + +``` +# Usage: {cmd} [optional] [-u] [--long] [--value=VALUE] [--exclusive]! [rest]... +``` + +- `{cmd}`: This special token represents the name of the command and is always required. +- ``: A required positional argument. +- `[optional]`: An optional positional argument. +- `[-u]`: An optional short flag. +- `[--long]`: An optional long flag. +- `[--value=VALUE]`: An optional long flag that takes a value. +- `[--exclusive]!`: An optional long flag that cannot be used with other flags. +- `[rest]...`: A rest argument that consumes all remaining arguments. + +Short and long flags can also be made required by omitting the brackets. + +When invoking a script with invalid arguments, `sub` will display an error. For +example, invoking the `hello` script from the previous section with invalid +arguments: + +```sh +$ hat hello +``` + +``` +error: the following required arguments were not provided: + + +Usage: hat hello --spanish + +For more information, try '--help'. +``` + +## Parsing arguments + +When arguments to a script are valid, `sub` sets an environment variable called +`_HAT_ARGS` (where `HAT` is the capitalized name of your CLI). This variable +holds the parsed arguments as a list of key value pairs. The value of this +variable is a string that can be evaluated to an associative array in bash +scripts: + +```sh +declare -A args="($_HAT_ARGS)" +``` + +Which can then be used to access argument values: + +```sh +echo "${args[positional]}" + +if [[ "${args[long]}" == "true" ]]; then + # ... +fi +``` + +## Nested subcommands + +`sub` supports nested directories for hierarchical command structures. For +example, given the following directory structure: + +``` +. +└── libexec + └── nested + ├── README + └── user-script2 +``` + +`user-script2` can be invoked with: + +```sh +$ hat nested user-script2 +``` + +Directories can be nested arbitrarily deep. + +A `README` file can be placed in a directory to provide a description of the +subcommands in that directory. The `README` file should be formatted like a +script, with a special comment block at the beginning: + +```sh +# Summary: A collection of user scripts # -# The extended description can span multiple lines. +# This directory contains scripts that do magic. +# This help can be as long as you want. +# The Usage comment is ignored in README files. ``` -If the command is a directory, `sub` looks for documentation in a `README` file -inside that directory. +## Aliases -## Sharing code +To define an alias, simply create a symlink. For example, in the `libexec` +directory: + +```sh +ln -s user-script1 us1 +``` + +Aliases can also point to scripts in subdirectories: +```sh +ln -s nested/user-script2 us2 +``` + +The full power of symlinks can be used to create complex command structures. + +## Sharing code between scripts When invoking subcommands, `sub` sets an environment variable called -`_CLINAME_ROOT` (where `CLINAME` is the name of your CLI. This variable holds -the canonicalized path to the root of your CLI. It can be used for instance for -sourcing shared scripts. +`_HAT_ROOT` (where `HAT` is the capitalized name of your CLI. This variable +holds the path to the root of your CLI. It can be used, for instance, for +sourcing shared scripts from a `lib` directory next to `libexec`: + +```sh +source "$_CLINAME_ROOT/lib/shared.sh" +``` ## Caching When invoking subcommands, `sub` sets an environment variable called -`_CLINAME_CACHE` (where `CLINAME` is the name of your CLI. This variable points -to an XDG compliant cache directory that can be used for storing temporary files. +`_HAT_CACHE` (where `HAT` is the capitalized name of your CLI. This variable +points to an XDG compliant cache directory that can be used for storing +temporary files shared between subcommands. + +## Migrating to Sub 2.x + +### change --bin to --executable + +The `--bin` argument was renamed to `--executable` to better reflect its purpose. + +### Usage comments + +Sub 2.x introduces automatic validation and parsing of command line arguments +based on special Usage comments in scripts. If you previously used arbitrary +Usage comments in sub 1.x for the purpose of documenting, you can run `sub` +with the `--validate` flag to check if your scripts are compatible with the new +version. + +### Help, commands and completions + +If you used the `help`, `commands` or `completions` subcommands, they are now +`--help`, `--commands` and `--completions` flags respectively. ## Inspiration diff --git a/integration/commands.bats b/integration/commands.bats index 6be3506..aff3fa0 100644 --- a/integration/commands.bats +++ b/integration/commands.bats @@ -3,44 +3,42 @@ load test_helper @test "commands: lists commands alphabetically" { - fixture "project" + fixture "commands" - run main commands + run main --commands assert_success - assert_output "commands -echo -env -error -help -nested -no-doc" + assert_output "a.sh +b +c.other +invalid-usage +nested" } @test "commands: filter commands by extension" { - fixture "extensions" + fixture "commands" - run main commands --extension=sh + run main --commands --extension=sh assert_success - assert_output "example1.sh" + assert_output "a.sh" } @test "commands: lists nested commands" { - fixture "project" + fixture "commands" - run main commands nested + run main --commands nested assert_success - assert_output "double -echo" + assert_output "d +double" } @test "commands: lists nested subcommands" { - fixture "project" + fixture "commands" - run main commands nested double + run main --commands nested double assert_success - assert_output "echo" + assert_output "e" } diff --git a/integration/completions.bats b/integration/completions.bats index 7fae1dd..2596f45 100644 --- a/integration/completions.bats +++ b/integration/completions.bats @@ -3,27 +3,27 @@ load test_helper @test "completions: without arguments, lists commands" { - fixture "project" + fixture "completions" - run main completions + run main --completions assert_success - assert_output "$(main commands)" + assert_output "$(main --commands)" } @test "completions: fails gracefully when command is not found" { - fixture "project" + fixture "completions" - run main completions not-found + run main --completions not-found assert_failure assert_output "" } @test "completions: invokes command completions" { - fixture "project" + fixture "completions" - run main completions echo + run main --completions with-completions assert_success assert_output "comp1 @@ -31,38 +31,38 @@ comp2" } @test "completions: lists nothing if command provides no completions" { - fixture "project" + fixture "completions" - run main completions error + run main --completions no-completions assert_success assert_output "" } -@test "completions: displays nested commands" { - fixture "project" +@test "completions: displays for directory commands" { + fixture "completions" - run main completions nested + run main --completions directory assert_success - assert_output "$(main commands nested)" + assert_output "$(main --commands directory)" } -@test "completions: displays double nested commands" { - fixture "project" +@test "completions: displays double nested directory commands" { + fixture "completions" - run main completions nested double + run main --completions directory double assert_success - assert_output "$(main commands nested double)" + assert_output "$(main --commands directory double)" } @test "completions: displays double nested subcommands" { - fixture "project" + fixture "completions" - run main completions nested double echo + run main --completions directory double with-completions assert_success - assert_output "compn1 -compn2" + assert_output "comp11 +comp21" } diff --git a/integration/fixtures/commands/bin/main b/integration/fixtures/commands/bin/main new file mode 100755 index 0000000..2791125 --- /dev/null +++ b/integration/fixtures/commands/bin/main @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -e + +$SUB_BIN --color never --name main --executable "${BASH_SOURCE[0]}" --relative ".." -- "$@" diff --git a/integration/fixtures/project/libexec/README b/integration/fixtures/commands/libexec/README similarity index 100% rename from integration/fixtures/project/libexec/README rename to integration/fixtures/commands/libexec/README diff --git a/integration/fixtures/commands/libexec/a.sh b/integration/fixtures/commands/libexec/a.sh new file mode 100755 index 0000000..55d58f2 --- /dev/null +++ b/integration/fixtures/commands/libexec/a.sh @@ -0,0 +1 @@ +# Summary: A sh script diff --git a/integration/fixtures/extensions/libexec/example1.sh b/integration/fixtures/commands/libexec/b similarity index 100% rename from integration/fixtures/extensions/libexec/example1.sh rename to integration/fixtures/commands/libexec/b diff --git a/integration/fixtures/extensions/libexec/example2 b/integration/fixtures/commands/libexec/c.other similarity index 100% rename from integration/fixtures/extensions/libexec/example2 rename to integration/fixtures/commands/libexec/c.other diff --git a/integration/fixtures/commands/libexec/invalid-usage b/integration/fixtures/commands/libexec/invalid-usage new file mode 100755 index 0000000..021fb4b --- /dev/null +++ b/integration/fixtures/commands/libexec/invalid-usage @@ -0,0 +1 @@ +# Usage: diff --git a/integration/fixtures/extensions/libexec/example3.other b/integration/fixtures/commands/libexec/nested/d similarity index 100% rename from integration/fixtures/extensions/libexec/example3.other rename to integration/fixtures/commands/libexec/nested/d diff --git a/integration/fixtures/commands/libexec/nested/double/e b/integration/fixtures/commands/libexec/nested/double/e new file mode 100755 index 0000000..e69de29 diff --git a/integration/fixtures/completions/bin/main b/integration/fixtures/completions/bin/main new file mode 100755 index 0000000..2791125 --- /dev/null +++ b/integration/fixtures/completions/bin/main @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -e + +$SUB_BIN --color never --name main --executable "${BASH_SOURCE[0]}" --relative ".." -- "$@" diff --git a/integration/fixtures/completions/libexec/directory/d b/integration/fixtures/completions/libexec/directory/d new file mode 100755 index 0000000..e69de29 diff --git a/integration/fixtures/completions/libexec/directory/double/with-completions b/integration/fixtures/completions/libexec/directory/double/with-completions new file mode 100755 index 0000000..5d24f95 --- /dev/null +++ b/integration/fixtures/completions/libexec/directory/double/with-completions @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# Provide completions +if [ "$1" = "--complete" ]; then + echo comp11 + echo comp21 + exit +fi diff --git a/integration/fixtures/completions/libexec/no-completions b/integration/fixtures/completions/libexec/no-completions new file mode 100755 index 0000000..e69de29 diff --git a/integration/fixtures/completions/libexec/with-completions b/integration/fixtures/completions/libexec/with-completions new file mode 100755 index 0000000..3a3141b --- /dev/null +++ b/integration/fixtures/completions/libexec/with-completions @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# Provide completions +if [ "$1" = "--complete" ]; then + echo comp1 + echo comp2 + exit +fi diff --git a/integration/fixtures/extensions/bin/main b/integration/fixtures/extensions/bin/main deleted file mode 100755 index 5c7fd27..0000000 --- a/integration/fixtures/extensions/bin/main +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -set -e - -$SUB_BIN --name main --bin "${BASH_SOURCE[0]}" --relative ".." -- "$@" diff --git a/integration/fixtures/project/bin/main b/integration/fixtures/project/bin/main index 5c7fd27..2791125 100755 --- a/integration/fixtures/project/bin/main +++ b/integration/fixtures/project/bin/main @@ -2,4 +2,4 @@ set -e -$SUB_BIN --name main --bin "${BASH_SOURCE[0]}" --relative ".." -- "$@" +$SUB_BIN --color never --name main --executable "${BASH_SOURCE[0]}" --relative ".." -- "$@" diff --git a/integration/fixtures/project/libexec/directory/README b/integration/fixtures/project/libexec/directory/README new file mode 100644 index 0000000..df2fecd --- /dev/null +++ b/integration/fixtures/project/libexec/directory/README @@ -0,0 +1,5 @@ +# Summary: A directory subcommand +# +# Documentation for this group. +# +# Extended documentation. diff --git a/integration/fixtures/project/libexec/directory/double/README b/integration/fixtures/project/libexec/directory/double/README new file mode 100644 index 0000000..1e99958 --- /dev/null +++ b/integration/fixtures/project/libexec/directory/double/README @@ -0,0 +1,5 @@ +# Summary: Run a double nested command +# +# Documentation for this double nested group. +# +# Extended documentation. diff --git a/integration/fixtures/project/libexec/directory/double/with-help b/integration/fixtures/project/libexec/directory/double/with-help new file mode 100755 index 0000000..472f8e8 --- /dev/null +++ b/integration/fixtures/project/libexec/directory/double/with-help @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Summary: Help 3 +# +# Usage: {cmd} [args]... +# +# This is a complete test script with documentation. +# +# The help section can span multiple lines. + +set -e + +echo "$@" diff --git a/integration/fixtures/project/libexec/directory/with-help b/integration/fixtures/project/libexec/directory/with-help new file mode 100755 index 0000000..fde3c67 --- /dev/null +++ b/integration/fixtures/project/libexec/directory/with-help @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Summary: Help 2 +# +# Usage: {cmd} [args]... +# +# This is a complete test script with documentation. +# +# The help section can span multiple lines. + +set -e + +echo "$@" diff --git a/integration/fixtures/project/libexec/echo b/integration/fixtures/project/libexec/echo index 85c1209..ea3d15c 100755 --- a/integration/fixtures/project/libexec/echo +++ b/integration/fixtures/project/libexec/echo @@ -2,11 +2,6 @@ # # Summary: Echo arguments # -# Usage: {cmd} -# {cmd} [] -# -# --complete Provides completions -# # This is a complete test script with documentation. # # The help section can span multiple lines. diff --git a/integration/fixtures/project/libexec/env b/integration/fixtures/project/libexec/env index dacbcbe..c8cb490 100755 --- a/integration/fixtures/project/libexec/env +++ b/integration/fixtures/project/libexec/env @@ -1,6 +1,5 @@ #!/usr/bin/env bash # Summary: Print the value of an environment variable -# Usage: {cmd} set -e diff --git a/integration/fixtures/project/libexec/env-args b/integration/fixtures/project/libexec/env-args new file mode 100755 index 0000000..173f0d0 --- /dev/null +++ b/integration/fixtures/project/libexec/env-args @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Usage: {cmd} [-u] [--long] --value=VALUE [args]... + +set -e + +echo "$_MAIN_ARGS" diff --git a/integration/fixtures/project/libexec/invalid-usage b/integration/fixtures/project/libexec/invalid-usage new file mode 100755 index 0000000..021fb4b --- /dev/null +++ b/integration/fixtures/project/libexec/invalid-usage @@ -0,0 +1 @@ +# Usage: diff --git a/integration/fixtures/project/libexec/nested/double/echo b/integration/fixtures/project/libexec/nested/double/echo index 544954a..60860e9 100755 --- a/integration/fixtures/project/libexec/nested/double/echo +++ b/integration/fixtures/project/libexec/nested/double/echo @@ -2,8 +2,6 @@ # # Summary: Echo arguments 3 # -# Usage: {cmd} [] -# # This is a complete test script with documentation. # # The help section can span multiple lines. diff --git a/integration/fixtures/project/libexec/nested/echo b/integration/fixtures/project/libexec/nested/echo index b70d1b0..c2e265c 100755 --- a/integration/fixtures/project/libexec/nested/echo +++ b/integration/fixtures/project/libexec/nested/echo @@ -2,8 +2,6 @@ # # Summary: Echo arguments 2 # -# Usage: {cmd} [] -# # This is a complete test script with documentation. # # The help section can span multiple lines. diff --git a/integration/fixtures/project/libexec/only-summary b/integration/fixtures/project/libexec/only-summary new file mode 100755 index 0000000..a632e34 --- /dev/null +++ b/integration/fixtures/project/libexec/only-summary @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# +# Summary: Return with error 4 + +set -e diff --git a/integration/fixtures/project/libexec/valid-usage b/integration/fixtures/project/libexec/valid-usage new file mode 100755 index 0000000..6c0b7fd --- /dev/null +++ b/integration/fixtures/project/libexec/valid-usage @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Usage: {cmd} [opt] [-u] [--long] [--exclusive]! [--value=VALUE] [args]... + +set -e + +echo "$@" diff --git a/integration/fixtures/project/libexec/with-help b/integration/fixtures/project/libexec/with-help new file mode 100755 index 0000000..8bdce5c --- /dev/null +++ b/integration/fixtures/project/libexec/with-help @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Summary: Command with complete help +# +# Usage: {cmd} [args]... +# +# This is a complete test script with documentation. +# +# The help section can span multiple lines. + +set -e + +echo "$@" diff --git a/integration/fixtures/v1/libexec/invalid-usage b/integration/fixtures/v1/libexec/invalid-usage new file mode 100755 index 0000000..d9dc325 --- /dev/null +++ b/integration/fixtures/v1/libexec/invalid-usage @@ -0,0 +1 @@ +# Usage: diff --git a/integration/help.bats b/integration/help.bats index a231054..f62c7a9 100644 --- a/integration/help.bats +++ b/integration/help.bats @@ -2,144 +2,177 @@ load test_helper -@test "help: without arguments, displays help for top level command" { - fixture "project" +@test "help: takes short flag" { + fixture "commands" - run main help + run main -h assert_success - assert_output "Usage: main [] [] + assert_output "$(main --help)" +} + +@test "help: displays help for top level command" { + fixture "commands" + + run main --help + + assert_success + assert_output "Top level command summary + +Usage: main [OPTIONS] [commands_with_args]... -Top level command summary +Arguments: + [commands_with_args]... + +Options: + --usage Print usage + -h, --help Print help + --completions Print completions + --commands Print subcommands + --extension Filter subcommands by extension Description of the top level command. Extended documentation. Available subcommands: - commands List available commands - echo Echo arguments - env Print the value of an environment variable - error Return with error 4 - help Display help for a sub command - nested Run a nested command - no-doc - -Use 'main help ' for information on a specific command." + a.sh A sh script + b + c.other + invalid-usage + nested " } @test "help: displays usage for a non documented command" { fixture "project" - run main help no-doc + run main --help no-doc assert_success - assert_output "Usage: main no-doc" + assert_output "Usage: main no-doc [args]... + +Arguments: + [args]... + +Options: + -h, --help Print help" } @test "help: displays help for a subcommand" { fixture "project" - run main help echo + run main --help with-help assert_success - assert_output "Usage: main echo - main echo [] + assert_output "Command with complete help + +Usage: main with-help [args]... - --complete Provides completions +Arguments: + [args]... -Echo arguments +Options: + -h, --help Print help This is a complete test script with documentation. The help section can span multiple lines." } -@test "help: displays summary for subcommand if help is not available" { - fixture "project" - - run main help error - - assert_success - assert_output "Usage: main error - -Return with error 4" -} - @test "help: fails gracefully when requested command doesn't exist" { fixture "project" - run main help not-found + run main --help not-found assert_failure assert_output "main: no such sub command 'not-found'" } -@test "help: displays help for a nested command" { +@test "help: displays help for a directory command" { fixture "project" - run main help nested + run main --help directory assert_success - assert_output "Usage: main nested [] [] + assert_output "A directory subcommand -Run a nested command +Usage: main directory [commands_with_args]... + +Arguments: + [commands_with_args]... + +Options: + -h, --help Print help Documentation for this group. Extended documentation. Available subcommands: - double Run a double nested command - echo Echo arguments 2 - -Use 'main help nested ' for information on a specific command." + double Run a double nested command + with-help Help 2" } @test "help: displays help for a nested subcommand" { fixture "project" - run main help nested echo + run main --help directory with-help assert_success - assert_output "Usage: main nested echo [] + assert_output "Help 2 -Echo arguments 2 +Usage: main directory with-help [args]... + +Arguments: + [args]... + +Options: + -h, --help Print help This is a complete test script with documentation. The help section can span multiple lines." } -@test "help: displays help for a double nested command" { +@test "help: displays help for a double nested directory command" { fixture "project" - run main help nested double + run main --help directory double assert_success - assert_output "Usage: main nested double [] [] + assert_output "Run a double nested command + +Usage: main directory double [commands_with_args]... + +Arguments: + [commands_with_args]... -Run a double nested command +Options: + -h, --help Print help Documentation for this double nested group. Extended documentation. Available subcommands: - echo Echo arguments 3 - -Use 'main help nested double ' for information on a specific command." + with-help Help 3" } @test "help: displays help for a double nested sub command" { fixture "project" - run main help nested double echo + run main --help directory double with-help assert_success - assert_output "Usage: main nested double echo [] + assert_output "Help 3 + +Usage: main directory double with-help [args]... + +Arguments: + [args]... -Echo arguments 3 +Options: + -h, --help Print help This is a complete test script with documentation. diff --git a/integration/nested.bats b/integration/nested.bats index d7ed58c..de2d7e6 100644 --- a/integration/nested.bats +++ b/integration/nested.bats @@ -8,7 +8,23 @@ load test_helper run main nested assert_success - assert_output "$(main help nested)" + assert_output "Run a nested command + +Usage: main nested [commands_with_args]... + +Arguments: + [commands_with_args]... + +Options: + -h, --help Print help + +Documentation for this group. + +Extended documentation. + +Available subcommands: + double Run a double nested command + echo Echo arguments 2" } @test "nested: with a non existent subcommand, displays error message" { @@ -35,5 +51,20 @@ load test_helper run main nested double assert_success - assert_output "$(main help nested double)" + assert_output "Run a double nested command + +Usage: main nested double [commands_with_args]... + +Arguments: + [commands_with_args]... + +Options: + -h, --help Print help + +Documentation for this double nested group. + +Extended documentation. + +Available subcommands: + echo Echo arguments 3" } diff --git a/integration/sub.bats b/integration/sub.bats old mode 100755 new mode 100644 index be77958..0b7be90 --- a/integration/sub.bats +++ b/integration/sub.bats @@ -3,10 +3,17 @@ load test_helper PROJECT_DIR="$SUB_TEST_DIR/project" -@test "sub: reject --bin and --absolute given together" { +@test "sub: when libexec is not a directory, exit with error" { + run $SUB_BIN --name main --absolute "$PROJECT_DIR" + + assert_failure + assert_output "main: libexec directory not found in root" +} + +@test "sub: reject --executable and --absolute given together" { fixture "project" - run $SUB_BIN --name main --bin "$PROJECT_DIR" --absolute "$PROJECT_DIR" + run $SUB_BIN --name main --executable "$PROJECT_DIR" --absolute "$PROJECT_DIR" assert_failure } @@ -24,19 +31,13 @@ PROJECT_DIR="$SUB_TEST_DIR/project" assert_failure } -@test "sub: lists commands alphabetically" { +@test "sub: --infer-long-arguments flag" { fixture "project" - run $SUB_BIN --name main --absolute "$PROJECT_DIR" -- commands + run $SUB_BIN --name main --absolute "$PROJECT_DIR" --infer-long-arguments -- valid-usage --lo pos assert_success - assert_output "commands -echo -env -error -help -nested -no-doc" + assert_output "--lo pos" } @test "sub: sets an env variable with the project root" { @@ -47,3 +48,12 @@ no-doc" assert_success assert_output "$PROJECT_DIR" } + +@test "sub: sets an env variable with argument key value pairs" { + fixture "project" + + run $SUB_BIN --name main --absolute "$PROJECT_DIR" -- env-args --long --value=thing pos ex1 ex2 --more + + assert_success + assert_output 'name "pos" u "false" long "true" value "thing" args "ex1 ex2 --more"' +} diff --git a/integration/subcommands.bats b/integration/subcommands.bats index 2898c2a..3cef3e2 100644 --- a/integration/subcommands.bats +++ b/integration/subcommands.bats @@ -8,7 +8,7 @@ load test_helper run main assert_success - assert_output "$(main help)" + assert_output "$(main --help)" } @test "subcommands: ignores hidden files" { @@ -50,10 +50,10 @@ load test_helper @test "subcommands: accepts dashes in arguments to subcommands" { fixture "project" - run main echo -a -b + run main echo -a --long assert_success - assert_output "-a -b" + assert_output "-a --long" } @test "subcommands: returns the subcommand exit code" { diff --git a/integration/usage.bats b/integration/usage.bats new file mode 100644 index 0000000..3770292 --- /dev/null +++ b/integration/usage.bats @@ -0,0 +1,97 @@ +#!/usr/bin/env bats + +load test_helper + +@test "usage: conflicts with help" { + fixture "project" + + run main --usage --help + + assert_failure + assert_output "error: the argument '--usage' cannot be used with '--help' + +Usage: main --usage [commands_with_args]..." +} + +@test "usage: when command has no Usage docstring prints default usage" { + fixture "project" + + run main --usage no-doc + + assert_success + assert_output "Usage: main no-doc [args]..." +} + +@test "usage: when command has no Usage docstring, accepts any arguments" { + fixture "project" + + run main no-doc arg1 arg2 -a --long other + + assert_success + assert_output "arg1 arg2 -a --long other" +} + +@test "usage: when command has valid usage docstring, print it" { + fixture "project" + + run main --usage valid-usage + + assert_success + assert_output "Usage: main valid-usage [OPTIONS] [opt] [args]..." +} + +@test "usage: when command has invalid usage docstring, error with message" { + fixture "project" + + run main --usage invalid-usage + + assert_failure + assert_output "main: invalid usage string + found end of input but expected \"{\"" +} + +@test "usage: invokes with valid arguments" { + fixture "project" + + run main valid-usage --long pos -u --value=example extra1 extra2 + + assert_success + assert_output "--long pos -u --value=example extra1 extra2" +} + +@test "usage: invoke fails when exclusive argument is combined with another argument" { + fixture "project" + + run main valid-usage --exclusive -u + + assert_failure + assert_output "error: the argument '--exclusive' cannot be used with one or more of the other specified arguments + +Usage: main valid-usage [OPTIONS] [opt] [args]... + +For more information, try '--help'." +} + +@test "usage: invoke succeeds when exclusive argument is used alone" { + fixture "project" + + run main valid-usage --exclusive + + assert_success + assert_output "--exclusive" +} + +@test "usage: invoke with invalid args, prints usage message" { + fixture "project" + + run main valid-usage --invalid + + assert_failure + assert_output "error: unexpected argument '--invalid' found + + tip: to pass '--invalid' as a value, use '-- --invalid' + +Usage: main valid-usage [OPTIONS] [opt] [args]... + +For more information, try '--help'." +} diff --git a/integration/validate.bats b/integration/validate.bats new file mode 100644 index 0000000..fd0f3f8 --- /dev/null +++ b/integration/validate.bats @@ -0,0 +1,14 @@ +#!/usr/bin/env bats +load test_helper + +PROJECT_DIR="$SUB_TEST_DIR/v1" + +@test "sub: validates all subcommands in the project directory" { + fixture "v1" + + run $SUB_BIN --name main --absolute "$PROJECT_DIR" --validate + + assert_failure + assert_output "$PROJECT_DIR/libexec/invalid-usage: invalid usage string + found end of input but expected \"{\"" +} diff --git a/src/commands/directory.rs b/src/commands/directory.rs new file mode 100644 index 0000000..2d7da55 --- /dev/null +++ b/src/commands/directory.rs @@ -0,0 +1,172 @@ +use std::fs; +use std::path::PathBuf; + +use clap::Arg; + +use crate::commands::subcommand; +use crate::commands::Command; +use crate::config::Config; +use crate::error::{Error, Result}; +use crate::parser; +use crate::usage::Usage; + +pub struct DirectoryCommand<'a> { + names: Vec, + path: PathBuf, + usage: Usage, + config: &'a Config, +} + +impl<'a> DirectoryCommand<'a> { + pub fn top_level(names: Vec, path: PathBuf, config: &'a Config) -> Self { + let readme_path = path.join("README"); + + let mut command = config.user_cli_command(&config.name); + + if readme_path.exists() { + let docs = parser::extract_docs(&readme_path); + + if let Some(summary) = docs.summary { + command = command.about(summary); + } + + if let Some(description) = docs.description { + command = command.after_help(description); + } + } + + let usage = Usage::new(command, None); + + return Self { + names, + path, + usage, + config, + }; + } + + pub fn new(name: &str, names: Vec, path: PathBuf, config: &'a Config) -> Self { + let readme_path = path.join("README"); + + let mut command = config.base_command(name); + command = command.arg(Arg::new("commands_with_args").trailing_var_arg(true).allow_hyphen_values(true).num_args(..)); + + if readme_path.exists() { + let docs = parser::extract_docs(&readme_path); + + if let Some(summary) = docs.summary { + command = command.about(summary); + } + + if let Some(description) = docs.description { + command = command.after_help(description); + } + } + + let usage = Usage::new(command, None); + + return Self { + names, + path, + usage, + config, + }; + } +} + +impl<'a> Command for DirectoryCommand<'a> { + fn name(&self) -> &str { + self.names.last().unwrap() + } + + fn summary(&self) -> String { + self.usage.summary() + } + + fn usage(&self) -> Result { + Ok(self.usage.generate().to_string()) + } + + fn help(&self) -> Result { + let mut help = self.usage.help()?; + + let subcommands = self.subcommands(); + if !subcommands.is_empty() { + help.push_str("\nAvailable subcommands:\n"); + + let max_width = subcommands + .iter() + .map(|subcommand| subcommand.name()) + .map(|name| name.len()) + .max() + .unwrap(); + + let width = max_width + 4; + + for subcommand in subcommands { + help.push_str(&format!( + " {:width$}{}\n", + subcommand.name(), + subcommand.summary(), + width = width + )); + } + } + + Ok(help) + } + + fn subcommands(&self) -> Vec> { + let mut libexec_path = self.config.libexec_path(); + libexec_path.extend(&self.names); + + let mut subcommands = Vec::new(); + + if libexec_path.is_dir() { + for entry in fs::read_dir(libexec_path).unwrap() { + let name = entry.unwrap().file_name().to_str().unwrap().to_owned(); + + let mut names = self.names.clone(); + names.push(name); + + if let Ok(subcommand) = subcommand(self.config, names) { + subcommands.push(subcommand); + } + } + } + + subcommands.sort_by(|c1, c2| c1.name().cmp(c2.name())); + + return subcommands; + } + + fn completions(&self) -> Result { + for command in self.subcommands() { + println!("{}", command.name()); + } + + Ok(0) + } + + fn invoke(&self) -> Result { + if !self.path.exists() { + return Err(Error::UnknownSubCommand( + self.names.last().unwrap().to_owned(), + )); + } + + println!("{}", self.help()?); + + Ok(0) + } + + fn validate(&self) -> Vec<(PathBuf, Error)> { + let mut errors = Vec::new(); + + for subcommand in self.subcommands() { + errors.extend(subcommand.validate()); + } + + errors + } +} diff --git a/src/commands/external.rs b/src/commands/external.rs deleted file mode 100644 index 203944b..0000000 --- a/src/commands/external.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::process; - -use crate::config::Config; -use crate::parser; -use crate::error::{Error, Result}; -use crate::commands::Command; -use crate::commands::internal::commands::internal_commands; -use crate::commands::internal::help::internal_help; -use crate::commands::external_subcommand; - -pub struct ExternalCommand<'a> { - pub names: Vec, - pub path: PathBuf, - pub args: Vec, - pub config: &'a Config, -} - -impl<'a> Command for ExternalCommand<'a> { - fn name(&self) -> &str { - self.names.last().unwrap() - } - - fn summary(&self) -> String { - if self.path.is_dir() { - let mut readme_path = self.path.clone(); - readme_path.push("README"); - - if readme_path.exists() { - parser::extract_docs(&readme_path).0 - } else { - "".to_owned() - } - } else { - parser::extract_docs(&self.path).0 - } - } - - fn usage(&self) -> String { - let mut cmd = vec![self.config.name.to_owned()]; - cmd.extend(self.names.iter().map(|s| s.to_owned())); - - let cmd = cmd.join(" "); - - if self.path.is_dir() { - vec!["Usage:", &cmd, "[]", "[]"].join(" ") - } else { - let usage = parser::extract_docs(&self.path).1; - if usage.is_empty() { - format!("Usage: {}", cmd) - } else { - usage.replace("{cmd}", &cmd) - } - } - } - - fn help(&self) -> String { - if self.path.is_dir() { - let mut readme_path = self.path.clone(); - readme_path.push("README"); - - if readme_path.exists() { - parser::extract_docs(&readme_path).2 - } else { - "".to_owned() - } - } else { - parser::extract_docs(&self.path).2 - } - } - - fn subcommands(&self) -> Vec> { - let mut libexec_path = self.config.libexec_path(); - libexec_path.extend(&self.names); - - let mut subcommands = Vec::new(); - - if libexec_path.is_dir() { - for entry in fs::read_dir(libexec_path).unwrap() { - let name = entry.unwrap().file_name().to_str().unwrap().to_owned(); - - let mut names = self.names.clone(); - names.push(name); - - if let Ok(subcommand) = external_subcommand(self.config, names) { - subcommands.push(subcommand); - } - } - } - - subcommands.sort_by(|c1, c2| c1.name().cmp(c2.name())); - - return subcommands; - } - - fn completions(&self) -> Result { - if self.path.is_dir() { - let commands = internal_commands(self.config, self.names.clone()); - commands.invoke() - } else { - if parser::provides_completions(&self.path) { - let mut command = process::Command::new(&self.path); - - command.arg("--complete"); - command.env(format!("_{}_ROOT", self.config.name.to_uppercase()), &self.config.root); - - let status = command.status().unwrap(); - - return match status.code() { - Some(code) => Ok(code), - None => Err(Error::SubCommandInterrupted), - }; - } - Ok(0) - } - } - - fn invoke(&self) -> Result { - if !self.path.exists() { - return Err(Error::UnknownSubCommand(self.names.last().unwrap().to_owned())); - } - - if self.path.is_dir() { - let help_command = internal_help(self.config, self.names.clone()); - help_command.invoke() - } else { - let mut command = process::Command::new(&self.path); - - command.args(&self.args); - - command.env(format!("_{}_ROOT", self.config.name.to_uppercase()), &self.config.root); - command.env(format!("_{}_CACHE", self.config.name.to_uppercase()), &self.config.cache_directory); - - let status = command.status().unwrap(); - - match status.code() { - Some(code) => Ok(code), - None => Err(Error::SubCommandInterrupted), - } - } - } -} diff --git a/src/commands/file.rs b/src/commands/file.rs new file mode 100644 index 0000000..7a964eb --- /dev/null +++ b/src/commands/file.rs @@ -0,0 +1,108 @@ +use std::path::PathBuf; +use std::process; + +use crate::config::Config; +use crate::usage::{self, Usage}; +use crate::parser; +use crate::error::{Error, Result}; +use crate::commands::Command; + +pub struct FileCommand<'a> { + names: Vec, + path: PathBuf, + usage: Usage, + args: Vec, + config: &'a Config, +} + +impl<'a> FileCommand<'a> { + pub fn new(names: Vec, path: PathBuf, args: Vec, config: &'a Config) -> Self { + let mut cmd = vec![config.name.to_owned()]; + cmd.extend(names.iter().map(|s| s.to_owned())); + let cmd = cmd.join(" "); + + let usage = usage::extract_usage(config, &path, &cmd); + + return Self { + names, + path, + usage, + args, + config, + }; + } +} + +impl<'a> Command for FileCommand<'a> { + fn name(&self) -> &str { + self.names.last().unwrap() + } + + fn summary(&self) -> String { + self.usage.summary() + } + + fn usage(&self) -> Result { + self.usage.validate()?; + + Ok(self.usage.generate().to_string()) + } + + fn help(&self) -> Result { + self.usage.validate()?; + + self.usage.help() + } + + fn subcommands(&self) -> Vec> { + let subcommands = Vec::new(); + return subcommands; + } + + fn completions(&self) -> Result { + if parser::provides_completions(&self.path) { + let mut command = process::Command::new(&self.path); + + command.arg("--complete"); + command.env(format!("_{}_ROOT", self.config.name.to_uppercase()), &self.config.root); + + let status = command.status().unwrap(); + + return match status.code() { + Some(code) => Ok(code), + None => Err(Error::SubCommandInterrupted), + }; + } + Ok(0) + } + + fn invoke(&self) -> Result { + self.usage.validate()?; + + if !self.path.exists() { + return Err(Error::UnknownSubCommand(self.names.last().unwrap().to_owned())); + } + + let mut command = process::Command::new(&self.path); + + command.args(&self.args); + + command.env(format!("_{}_ROOT", self.config.name.to_uppercase()), &self.config.root); + command.env(format!("_{}_CACHE", self.config.name.to_uppercase()), &self.config.cache_directory); + command.env(format!("_{}_ARGS", self.config.name.to_uppercase()), &self.usage.parse_into_kv(&self.args)?); + + let status = command.status().unwrap(); + + match status.code() { + Some(code) => Ok(code), + None => Err(Error::SubCommandInterrupted), + } + } + + fn validate(&self) -> Vec<(PathBuf, Error)> { + match self.usage.validate() { + Ok(_) => Vec::new(), + Err(e) => vec![(self.path.clone(), e)], + } + } +} diff --git a/src/commands/internal/commands.rs b/src/commands/internal/commands.rs deleted file mode 100644 index 44bab8c..0000000 --- a/src/commands/internal/commands.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::path::Path; - -use clap::{Arg, Command}; - -use crate::error::Result; -use crate::config::Config; -use crate::commands::internal; -use crate::commands::subcommand; - -struct InternalCommandCommandsArgs { - extension: Option, - - names: Vec, -} - -fn parse_args(args: Vec) -> InternalCommandCommandsArgs { - let args = Command::new("commands") - .no_binary_name(true) - .arg(Arg::new("extension").short('e').long("extension")) - .arg(Arg::new("names").num_args(1..)) - .get_matches_from(args); - - return InternalCommandCommandsArgs { - extension: args.get_one::("extension").cloned(), - names: args.get_many("names").map(|s| s.cloned().collect::>()).unwrap_or_default(), - }; -} - -pub fn internal_commands(config: &Config, args: Vec) -> internal::InternalCommand { - internal::InternalCommand { - name: "commands", - summary: "List available commands", - help: "", - args, - config, - func: |config: &Config, args: Vec| -> Result { - let parsed_args = parse_args(args.clone()); - - for subcommand in subcommand(config, parsed_args.names)?.subcommands() { - // If an extension is provided, only show subcommands with that extension - match parsed_args.extension { - Some(ref extension) => { - if let Some(subcommand_extension) = Path::new(subcommand.name()).extension() { - if *subcommand_extension == **extension { - println!("{}", subcommand.name()); - } - } - }, - None => { - println!("{}", subcommand.name()); - }, - } - } - - Ok(0) - }, - } -} - diff --git a/src/commands/internal/completions.rs b/src/commands/internal/completions.rs deleted file mode 100644 index c422e9d..0000000 --- a/src/commands/internal/completions.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::error::Result; -use crate::commands::internal; -use crate::config::Config; -use crate::commands::subcommand; - -pub fn internal_completions(config: &Config, args: Vec) -> internal::InternalCommand { - internal::InternalCommand { - name: "completions", - summary: "List completions for a sub command", - help: "", - args, - config, - func: |config: &Config, args: Vec| -> Result { - if let Ok(subcommand) = subcommand(config, args) { - subcommand.completions() - } else { - Ok(1) - } - }, - } -} - diff --git a/src/commands/internal/help.rs b/src/commands/internal/help.rs deleted file mode 100644 index 84c871c..0000000 --- a/src/commands/internal/help.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::error::Result; -use crate::config::Config; -use crate::commands::internal; -use crate::commands::subcommand; - -pub fn internal_help(config: &Config, args: Vec) -> internal::InternalCommand { - internal::InternalCommand { - name: "help", - summary: "Display help for a sub command", - help: "A command is considered documented if it starts with a comment block - that has a `Summary:' or `Usage:' section. Usage instructions can - span multiple lines as long as subsequent lines are indented. - The remainder of the comment block is displayed as extended - documentation.", - args, - config, - func: |config: &Config, args: Vec| -> Result { - let subcommand = subcommand(config, args.clone())?; - - let usage = subcommand.usage(); - if !usage.is_empty() { - println!("{}", usage); - println!(); - } - - let summary = subcommand.summary(); - if !summary.is_empty() { - println!("{}", summary); - println!(); - } - - let help = subcommand.help(); - if !help.is_empty() { - println!("{}", help); - } - - let subcommands = subcommand.subcommands(); - if !subcommands.is_empty() { - println!(); - println!("Available subcommands:"); - - let max_width = subcommands - .iter() - .map(|subcommand| subcommand.name()) - .map(|name: &str| name.len()) - .max() - .unwrap(); - - let width = max_width + 4; - - for subcommand in subcommands { - println!(" {:width$}{}", subcommand.name(), subcommand.summary(), width = width); - } - - println!(); - let mut cs = args.clone(); - cs.push("".to_owned()); - println!("Use '{} help {}' for information on a specific command.", config.name, cs.join(" ")); - } - - Ok(0) - }, - } -} diff --git a/src/commands/internal/mod.rs b/src/commands/internal/mod.rs deleted file mode 100644 index e24d8b5..0000000 --- a/src/commands/internal/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::error::Result; -use crate::config::Config; -use crate::commands::Command; - -pub mod help; -pub mod commands; -pub mod completions; - -pub struct InternalCommand<'a> { - pub name: &'static str, - pub summary: &'static str, - pub help: &'static str, - pub args: Vec, - pub config: &'a Config, - pub func: fn(&Config, Vec) -> Result, -} - -impl<'a> Command for InternalCommand<'a> { - fn name(&self) -> &str { - &self.name - } - - fn summary(&self) -> String { - self.summary.to_owned() - } - - fn usage(&self) -> String { - "".to_owned() // TODO - } - - fn help(&self) -> String { - self.help.to_owned() - } - - fn subcommands(&self) -> Vec> { - // none of the internal subcommands currently have any subcommands - return Vec::new(); - } - - fn completions(&self) -> Result { - Ok(0) // TODO - } - - fn invoke(&self) -> Result { - (self.func)(self.config, self.args.clone()) - } -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4939dfa..7f4a34f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,55 +1,40 @@ +pub mod file; +pub mod directory; + +use std::path::PathBuf; use std::os::unix::fs::PermissionsExt; use crate::config::Config; -use crate::commands::external::ExternalCommand; -use crate::commands::toplevel::TopLevelCommand; -use crate::commands::internal::help::internal_help; -use crate::commands::internal::commands::internal_commands; -use crate::commands::internal::completions::internal_completions; +use crate::commands::file::FileCommand; +use crate::commands::directory::DirectoryCommand; use crate::error::Result; use crate::error::Error; -pub mod internal; -pub mod external; -pub mod toplevel; - pub trait Command { fn name(&self) -> &str; fn summary(&self) -> String; - fn usage(&self) -> String; - fn help(&self) -> String; + fn usage(&self) -> Result; fn subcommands(&self) -> Vec>; fn completions(&self) -> Result; fn invoke(&self) -> Result; + fn help(&self) -> Result; + fn validate(&self) -> Vec<(PathBuf, Error)>; } -pub fn subcommand(config: &Config, mut names: Vec) -> Result> { - if names.is_empty() { - return Ok(Box::new(TopLevelCommand { - name: config.name.to_owned(), - path: config.libexec_path(), - config, - })); - } - - let name = &names[0]; - - match name.as_ref() { - "help" => Ok(Box::new(internal_help(config, names.split_off(1)))), - "commands" => Ok(Box::new(internal_commands(config, names.split_off(1)))), - "completions" => Ok(Box::new(internal_completions(config, names.split_off(1)))), - _ => { - external_subcommand(config, names) - }, - } -} - -pub fn external_subcommand(config: &Config, mut args: Vec) -> Result> { +pub fn subcommand(config: &Config, mut cliargs: Vec) -> Result> { let mut path = config.libexec_path(); let mut names = Vec::new(); + if cliargs.is_empty() { + if path.is_dir() { + return Ok(Box::new(DirectoryCommand::top_level(names, path, config))); + } else { + return Err(Error::NoLibexecDir); + } + } + loop { - let head = args[0].clone(); + let head = cliargs[0].clone(); if head.starts_with('.') { return Err(Error::UnknownSubCommand(head.to_owned())); @@ -63,28 +48,20 @@ pub fn external_subcommand(config: &Config, mut args: Vec) -> Result) -> Result { - pub name: String, - pub path: PathBuf, - pub config: &'a Config, -} - -impl<'a> Command for TopLevelCommand<'a> { - fn name(&self) -> &str { - &self.name - } - - fn summary(&self) -> String { - let mut readme_path = self.path.clone(); - readme_path.push("README"); - - if readme_path.exists() { - parser::extract_docs(&readme_path).0 - } else { - "".to_owned() - } - } - - fn usage(&self) -> String { - format!("Usage: {} [] []", self.name) - } - - fn help(&self) -> String { - let mut readme_path = self.path.clone(); - readme_path.push("README"); - - if readme_path.exists() { - parser::extract_docs(&readme_path).2 - } else { - "".to_owned() - } - } - - fn subcommands(&self) -> Vec> { - let libexec_path = self.config.libexec_path(); - - let mut subcommands = Vec::new(); - - if libexec_path.is_dir() { - for entry in fs::read_dir(libexec_path).unwrap() { - let name = entry.unwrap().file_name().to_str().unwrap().to_owned(); - - if let Ok(subcommand) = external_subcommand(self.config, vec![name]) { - subcommands.push(subcommand); - } - } - } - - subcommands.push(Box::new(internal_help(self.config, Vec::new()))); - subcommands.push(Box::new(internal_commands(self.config, Vec::new()))); - - subcommands.sort_by(|c1, c2| c1.name().cmp(c2.name())); - - subcommands - } - - fn completions(&self) -> Result { - let commands = internal_commands(self.config, Vec::new()); - commands.invoke() - } - - fn invoke(&self) -> Result { - let help_command = internal_help(self.config, Vec::new()); - help_command.invoke() - } -} diff --git a/src/config.rs b/src/config.rs index 3e67140..a9c9015 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,87 @@ +use std::process::exit; use std::path::PathBuf; +use clap::{Command, ColorChoice, Arg, ArgGroup}; +use clap::builder::Styles; + +#[derive(Clone)] +pub enum Color { + Auto, + Always, + Never, +} + #[derive(Clone)] pub struct Config { pub name: String, + pub color: Color, pub root: PathBuf, + infer_long_arguments: bool, pub cache_directory: PathBuf, } impl Config { + pub fn new(name: String, root: PathBuf, color: Color, infer_long_arguments: bool) -> Config { + let xdg_dirs = match xdg::BaseDirectories::with_prefix(&name) { + Ok(dir) => dir, + Err(e) => { + println!("Problem determining XDG base directory"); + println!("Original error: {}", e); + exit(1); + } + }; + let cache_directory = match xdg_dirs.create_cache_directory("cache") { + Ok(dir) => dir, + Err(e) => { + println!("Problem determining XDG cache directory"); + println!("Original error: {}", e); + exit(1); + } + }; + + Config { + name, + color, + infer_long_arguments, + root, + cache_directory, + } + } + pub fn libexec_path(&self) -> PathBuf { let mut path = self.root.clone(); path.push("libexec"); return path; } + + pub fn base_command(&self, name: &str) -> Command { + let color_choice = match self.color { + Color::Auto => ColorChoice::Auto, + Color::Always => ColorChoice::Always, + Color::Never => ColorChoice::Never, + }; + + let styles = match self.color { + Color::Auto => Styles::default(), + Color::Always => Styles::default(), + Color::Never => Styles::plain(), + }; + + Command::new(name.to_owned()).color(color_choice).styles(styles).infer_long_args(self.infer_long_arguments) + } + + pub fn user_cli_command(&self, name: &str) -> Command { + self.base_command(name).no_binary_name(true).disable_help_flag(true) + .arg(Arg::new("usage").long("usage").num_args(0).help("Print usage")) + .arg(Arg::new("help").short('h').long("help").num_args(0).help("Print help")) + .arg(Arg::new("completions").long("completions").num_args(0).help("Print completions")) + + .arg(Arg::new("commands").long("commands").num_args(0).help("Print subcommands")) + .arg(Arg::new("extension").long("extension").num_args(1).help("Filter subcommands by extension")) + .group(ArgGroup::new("extension_group").args(["extension"]).requires("commands")) + + .group(ArgGroup::new("exclusion").args(["commands", "completions", "usage", "help"]).multiple(false).required(false)) + + .arg(Arg::new("commands_with_args").trailing_var_arg(true).allow_hyphen_values(true).num_args(..)) + } } diff --git a/src/error.rs b/src/error.rs index da88448..177ac02 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,10 +1,16 @@ use std::result; +use chumsky::prelude::Simple; + pub type Result = result::Result; +#[derive(Clone)] pub enum Error { NoCompletions, NonExecutable(String), + NoLibexecDir, SubCommandInterrupted, UnknownSubCommand(String), + InvalidUsageString(Vec>), + InvalidUTF8, } diff --git a/src/lib.rs b/src/lib.rs index 4de98d2..d39a802 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #[macro_use] extern crate lazy_static; mod parser; +mod usage; pub mod error; pub mod config; pub mod commands; diff --git a/src/main.rs b/src/main.rs index 6474dee..1af03e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,133 +2,302 @@ extern crate sub; extern crate clap; -use clap::{value_parser, Arg, ArgGroup, Command}; +use clap::{value_parser, Arg, ArgAction, ArgGroup, Command}; use std::path::{Path, PathBuf}; use std::process::exit; -use sub::config::Config; use sub::commands::subcommand; +use sub::config::{Color, Config}; use sub::error::Error; fn main() { - let args = parse_cli_args(); + let sub_cli_args = parse_sub_cli_args(); - let xdg_dirs = match xdg::BaseDirectories::with_prefix(&args.name) { - Ok(dir) => dir, - Err(e) => { - println!("Problem determining XDG base directory"); - println!("Original error: {}", e); - exit(1); + let config = Config::new(sub_cli_args.name, sub_cli_args.root, sub_cli_args.color, sub_cli_args.infer_long_arguments); + + if sub_cli_args.validate { + let top_level_command = match subcommand(&config, Vec::new()) { + Ok(subcommand) => subcommand, + Err(error) => handle_error( + &config, + error, + false, + ), + }; + + let errors = top_level_command.validate(); + for error in &errors { + println!("{}: {}", error.0.display(), print_error(error.1.clone())); } - }; - let cache_directory = match xdg_dirs.create_cache_directory("cache") { - Ok(dir) => dir, - Err(e) => { - println!("Problem determining XDG cache directory"); - println!("Original error: {}", e); + + if errors.is_empty() { + exit(0); + } else { exit(1); } - }; + } - let config = Config { - name: args.name, - root: args.root, - cache_directory, - }; + let user_cli_command = config.user_cli_command(&config.name); + let user_cli_args = parse_user_cli_args(&user_cli_command, sub_cli_args.cliargs); - let subcommand = match subcommand(&config, args.commands.clone()) { + let subcommand = match subcommand(&config, user_cli_args.commands_with_args.clone()) { Ok(subcommand) => subcommand, - Err(Error::NoCompletions) => exit(1), - Err(Error::SubCommandInterrupted) => exit(1), - Err(Error::NonExecutable(_)) => exit(1), - Err(Error::UnknownSubCommand(name)) => { - display_unknown_subcommand(&config, &name); - exit(1); - } + Err(error) => handle_error( + &config, + error, + user_cli_args.mode == UserCliMode::Completions, + ), }; - match subcommand.invoke() { - Ok(code) => exit(code), - Err(Error::NoCompletions) => exit(1), - Err(Error::SubCommandInterrupted) => exit(1), - Err(Error::NonExecutable(_)) => exit(1), - Err(Error::UnknownSubCommand(name)) => { - display_unknown_subcommand(&config, &name); - exit(1); + match user_cli_args.mode { + UserCliMode::Invoke => match subcommand.invoke() { + Ok(code) => exit(code), + Err(error) => handle_error(&config, error, false), + }, + UserCliMode::Usage => { + let usage = match subcommand.usage() { + Ok(usage) => usage, + Err(error) => handle_error(&config, error, false), + }; + + println!("{}", usage); + } + UserCliMode::Help => { + let help = match subcommand.help() { + Ok(help) => help, + Err(error) => handle_error(&config, error, false), + }; + + println!("{}", help); + } + UserCliMode::Commands(extension) => { + for subcommand in subcommand.subcommands() { + if let Some(extension) = &extension { + if let Some(subcommand_extension) = Path::new(subcommand.name()).extension() { + if subcommand_extension == extension.as_str() { + println!("{}", subcommand.name()); + } + } + } else { + println!("{}", subcommand.name()); + } + } } + UserCliMode::Completions => match subcommand.completions() { + Ok(code) => exit(code), + Err(error) => handle_error(&config, error, true), + }, } } -pub fn display_unknown_subcommand(config: &Config, name: &str) { - println!("{}: no such sub command '{}'", config.name, name); +fn print_error(error: Error) -> String { + match error { + Error::NoCompletions => "no completions".to_string(), + Error::SubCommandInterrupted => "sub command interrupted".to_string(), + Error::NonExecutable(_) => "non-executable".to_string(), + Error::UnknownSubCommand(name) => format!("unknown sub command '{}'", name), + Error::InvalidUsageString(errors) => { + let mut message = "invalid usage string".to_string(); + for error in errors { + message.push_str(&format!("\n {}", error)); + } + message + } + Error::InvalidUTF8 => "invalid UTF-8".to_string(), + Error::NoLibexecDir => "libexec directory not found in root".to_string(), + } } -struct Args { - name: String, - root: PathBuf, - commands: Vec, +fn handle_error(config: &Config, error: Error, silent: bool) -> ! { + match error { + Error::NoCompletions => exit(1), + Error::SubCommandInterrupted => exit(1), + Error::NonExecutable(_) => exit(1), + Error::UnknownSubCommand(name) => { + if !silent { + println!("{}: no such sub command '{}'", config.name, name); + } + exit(1); + } + Error::InvalidUsageString(errors) => { + if !silent { + println!("{}: invalid usage string", config.name); + for error in errors { + println!(" {}", error); + } + } + exit(1); + } + Error::InvalidUTF8 => { + if !silent { + println!("invalid UTF-8"); + } + exit(1); + } + Error::NoLibexecDir => { + if !silent { + println!("{}: libexec directory not found in root", config.name); + } + exit(1); + } + } } -fn init_cli() -> Command { +fn init_sub_cli() -> Command { Command::new("sub") .version(env!("CARGO_PKG_VERSION")) + .about("Dynamically generate rich CLIs from scripts.") + .arg( + Arg::new("color") + .long("color") + .value_name("WHEN") + .value_parser(["auto", "always", "never"]) + .default_value("auto") + .num_args(1) + .help("Enable colored output for help messages"), + ) + .arg( + Arg::new("infer-long-arguments") + .long("infer-long-arguments") + .num_args(0) + .help("Allow partial matches of long arguments"), + ) .arg( Arg::new("name") .long("name") .required(true) - .help("Sets the binary name"), + .help("Sets the CLI name - used in help and error messages"), ) .arg( - Arg::new("bin") - .long("bin") - .required(true) + Arg::new("executable") + .long("executable") .value_parser(value_parser!(PathBuf)) - .help("Sets the path of the CLI binary"), + .help("Sets the path of the CLI executable; only use in combination with --relative"), ) .arg( Arg::new("relative") .long("relative") .value_parser(value_parser!(PathBuf)) - .conflicts_with("absolute") - .help("Sets how to find the root directory based on the location of the bin"), + .help("Sets how to find the root directory based on the location of the executable; Only use in combination with --executable"), ) .arg( Arg::new("absolute") .long("absolute") + .required(true) + .value_name("PATH") .value_parser(absolute_path) - .help("Sets how to find the root directory as an absolute path"), + .help("Absolute path to the CLI root directory (where libexec lives)"), + ) + .arg( + Arg::new("validate") + .long("validate") + .num_args(0) + .action(ArgAction::SetTrue) + .help("Validate that the CLI is correctly configured"), ) .group( - ArgGroup::new("path") - .args(["bin", "absolute"]) - .required(true), + ArgGroup::new("executable_and_relative") + .args(["executable", "relative"]) + .multiple(true) + .conflicts_with("absolute"), ) .arg( - Arg::new("commands") - .allow_hyphen_values(true) - .trailing_var_arg(true) - .num_args(..), + Arg::new("cliargs") + .raw(true) + .help("Arguments to pass to the CLI"), + ) } #[test] fn verify_cli() { - init_cli().debug_assert(); + init_sub_cli().debug_assert(); +} + +struct SubCliArgs { + name: String, + color: Color, + root: PathBuf, + infer_long_arguments: bool, + validate: bool, + cliargs: Vec, +} + +#[derive(PartialEq)] +enum UserCliMode { + Invoke, + Usage, + Help, + Commands(Option), + Completions, +} + +struct UserCliArgs { + mode: UserCliMode, + commands_with_args: Vec, } -fn parse_cli_args() -> Args { - let app = init_cli(); - let args = app.get_matches(); +fn parse_user_cli_args(cmd: &Command, cliargs: Vec) -> UserCliArgs { + let args = match cmd.clone().try_get_matches_from(cliargs) { + Ok(args) => args, + Err(e) => { + e.print().unwrap(); + exit(1); + } + }; + + UserCliArgs { + mode: if args.get_one::("usage").cloned().unwrap_or(false) { + UserCliMode::Usage + } else if args.get_one::("help").cloned().unwrap_or(false) { + UserCliMode::Help + } else if args.get_one::("commands").cloned().unwrap_or(false) { + UserCliMode::Commands(args.get_one::("extension").cloned()) + } else if args + .get_one::("completions") + .cloned() + .unwrap_or(false) + { + UserCliMode::Completions + } else { + UserCliMode::Invoke + }, + commands_with_args: args + .get_many("commands_with_args") + .map(|cmds| cmds.cloned().collect::>()) + .unwrap_or_default(), + } +} + +fn parse_sub_cli_args() -> SubCliArgs { + let sub_cli = init_sub_cli(); + let args = sub_cli.get_matches(); - Args { + SubCliArgs { name: args .get_one::("name") .expect("`name` is mandatory") .clone(), - commands: args - .get_many("commands") + color: match args + .get_one::("color") + .expect("`color` is mandatory") + .clone() + .as_ref() + { + "auto" => Color::Auto, + "always" => Color::Always, + "never" => Color::Never, + _ => unreachable!(), + }, + + infer_long_arguments: args.get_one::("infer-long-arguments").cloned().unwrap_or(false), + + validate: args.get_flag("validate"), + + cliargs: args + .get_many("cliargs") .map(|cmds| cmds.cloned().collect::>()) .unwrap_or_default(), @@ -136,17 +305,18 @@ fn parse_cli_args() -> Args { Some(path) => path.clone(), None => { let mut path = args - .get_one::("bin") - .expect("Either `bin` or `absolute` is required") + .get_one::("executable") + .expect("Either `executable` or `absolute` is required") .canonicalize() - .expect("Invalid `bin` path") + .expect("Invalid `executable` path") .clone(); - path.pop(); // remove bin name - if let Some(relative) = args.get_one::("relative") { - path.push(relative) - }; - path.canonicalize() - .expect("Invalid `bin` or `relative` arguments") + + path.pop(); // remove executable name + + let relative = args.get_one::("relative").expect("Missing `relative` argument"); + path.push(relative); + + path.canonicalize().expect("Invalid `executable` or `relative` arguments") } }, } diff --git a/src/parser.rs b/src/parser.rs index 91cdb53..c5a4e3e 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -27,23 +27,27 @@ fn extract_initial_comment_block(path: &Path) -> String { #[derive(PartialEq)] enum Mode { Out, - Usage, - Help, + Description, } -pub fn extract_docs(path: &Path) -> (String, String, String) { +pub struct Docs { + pub usage: Option, + pub summary: Option, + pub description: Option, +} + +pub fn extract_docs(path: &Path) -> Docs { lazy_static! { static ref SUMMARY_RE: Regex = Regex::new(r"^# Summary: (.*)$").unwrap(); - static ref USAGE_RE: Regex = Regex::new(r"^# (Usage: .*)$").unwrap(); static ref INDENTED_RE: Regex = Regex::new(r"^# ( .*)$").unwrap(); static ref EXTENDED_RE: Regex = Regex::new(r"^# (.*)$").unwrap(); } let comment_block = extract_initial_comment_block(path); - let mut summary = Vec::new(); - let mut usage = Vec::new(); - let mut help = Vec::new(); + let mut summary = None; + let mut usage = None; + let mut description = Vec::new(); let mut mode = Mode::Out; @@ -55,66 +59,45 @@ pub fn extract_docs(path: &Path) -> (String, String, String) { if let Some(caps) = SUMMARY_RE.captures(&line) { if let Some(m) = caps.get(1) { - summary.push(m.as_str().to_owned()); + summary = Some(m.as_str().trim().to_owned()); continue; } } - if let Some(caps) = USAGE_RE.captures(&line) { - if let Some(m) = caps.get(1) { - usage.push(m.as_str().to_owned()); - mode = Mode::Usage; - continue; - } - } - - if let Some(caps) = EXTENDED_RE.captures(&line) { - if let Some(m) = caps.get(1) { - help.push(m.as_str().to_owned()); - mode = Mode::Help; - continue; - } - } - } - - if mode == Mode::Usage { - if line == "#" { - usage.push("".to_owned()); + if line.starts_with("# Usage:") { + usage = Some(line.to_owned()); continue; } - if let Some(caps) = INDENTED_RE.captures(&line) { - if let Some(m) = caps.get(1) { - usage.push(m.as_str().to_owned()); - continue; - } - } - if let Some(caps) = EXTENDED_RE.captures(&line) { if let Some(m) = caps.get(1) { - help.push(m.as_str().to_owned()); - mode = Mode::Help; + description.push(m.as_str().trim().to_owned()); + mode = Mode::Description; continue; } } } - if mode == Mode::Help { + if mode == Mode::Description { if line == "#" { - help.push("".to_owned()); + description.push("".to_owned()); continue; } if let Some(caps) = EXTENDED_RE.captures(&line) { if let Some(m) = caps.get(1) { - help.push(m.as_str().to_owned()); + description.push(m.as_str().trim().to_owned()); continue; } } } } - (summary.join("\n"), usage.join("\n").trim().to_owned(), help.join("\n").trim().to_owned()) + Docs { + usage, + summary, + description: if description.is_empty() { None } else { Some(description.join("\n")) }, + } } pub fn provides_completions(path: &Path) -> bool { diff --git a/src/usage.rs b/src/usage.rs new file mode 100644 index 0000000..6c878c4 --- /dev/null +++ b/src/usage.rs @@ -0,0 +1,223 @@ +extern crate regex; +extern crate clap; + +use chumsky::prelude::*; +use clap::{Command, Arg}; + +use std::path::Path; + +use crate::parser; +use crate::error::{Error, Result}; +use crate::config::Config; + +#[derive(Debug, PartialEq)] +pub enum ArgBase { + Positional(String), + Short(char), + Long(String, Option), +} + +#[derive(Debug, PartialEq)] +pub struct ArgSpec { + base: ArgBase, + required: bool, + exclusive: bool, +} + +#[derive(Debug, PartialEq)] +struct UsageLang { + arguments: Vec, + rest: Option, +} + +fn usage_parser() -> impl Parser> { + let prefix = just("# Usage:").padded(); + + let cmd_token = just("{cmd}").padded(); + + let ident = filter(|c: &char| c.is_ascii_alphabetic()) + .chain(filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').repeated()) + .collect(); + let value = filter(|c: &char| c.is_ascii_alphabetic() && c.is_uppercase()).repeated().at_least(1).map(|v| v.into_iter().collect::()); + + let short = just("-").ignore_then(filter(|c: &char| c.is_alphabetic())).padded().map(|c| ArgBase::Short(c)); + let long = just("--").ignore_then(ident).then(just('=').ignore_then(value).or_not()).padded().map(|(k, v)| ArgBase::Long(k, v)); + + let optional_positional = ident.padded().map(|s| ArgBase::Positional(s)); + let required_positional = just('<').ignore_then(ident).then_ignore(just('>')).padded().map(|s| ArgBase::Positional(s)); + + let in_optional = short.or(long).or(optional_positional); + let in_required = short.or(long).or(required_positional); + + let optional = just('[').ignore_then(in_optional).then_ignore(just(']')).then(just('!').or_not().map(|e| e.is_some())).padded().map(|(s, e)| ArgSpec { base: s, required: false, exclusive: e }); + let required = in_required.padded().map(|s| ArgSpec { base: s, required: true, exclusive: false }); + + let argument = optional.or(required).then_ignore(none_of(".").ignored().or(end()).rewind()); + + let rest = just('[').ignore_then(ident).then_ignore(just("]...")).padded(); + + prefix.ignore_then(cmd_token).ignore_then(argument.repeated()).then(rest.or_not()).then_ignore(end()).map(|(args, rest)| { + UsageLang { + arguments: args, + rest, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_without_rest() { + let input = "# Usage: {cmd} -f --long [opt] [-o] [--longopt] [--value=VALUE] [--exclusive=EXCLUSIVE]!"; + let result = usage_parser().parse(input).unwrap(); + assert_eq!(result, UsageLang { + arguments: vec![ + ArgSpec{ base: ArgBase::Positional("name".to_owned()), required: true, exclusive: false }, + ArgSpec{ base: ArgBase::Positional("m2-_m".to_owned()), required: true, exclusive: false }, + ArgSpec{ base: ArgBase::Short('f'), required: true, exclusive: false }, + ArgSpec{ base: ArgBase::Long("long".to_owned(), None), required: true, exclusive: false }, + ArgSpec{ base: ArgBase::Positional("opt".to_owned()), required: false, exclusive: false }, + ArgSpec{ base: ArgBase::Short('o'), required: false, exclusive: false }, + ArgSpec{ base: ArgBase::Long("longopt".to_owned(), None), required: false, exclusive: false }, + ArgSpec{ base: ArgBase::Long("value".to_owned(), Some("VALUE".to_owned())), required: false, exclusive: false }, + ArgSpec{ base: ArgBase::Long("exclusive".to_owned(), Some("EXCLUSIVE".to_owned())), required: false, exclusive: true }, + ], + rest: None, + }); + } + + #[test] + fn parse_with_rest() { + let input = "# Usage: {cmd} [opt] [rest]..."; + let result = usage_parser().parse(input).unwrap(); + assert_eq!(result, UsageLang { + arguments: vec![ + ArgSpec{ base: ArgBase::Positional("name".to_owned()), required: true, exclusive: false }, + ArgSpec{ base: ArgBase::Positional("opt".to_owned()), required: false, exclusive: false }, + ], + rest: Some("rest".to_owned()), + }); + } +} + +pub struct Usage { + command: Command, + error: Option, +} + +impl Usage { + pub fn new(command: Command, error: Option) -> Self { + Self { + command, + error, + } + } + + pub fn generate(&self) -> String { + self.command.clone().render_usage().ansi().to_string() + } + + pub fn validate(&self) -> Result<()> { + if let Some(error) = &self.error { + return Err(error.clone()); + } + + Ok(()) + } + + pub fn summary(&self) -> String { + self.command.get_about().map(|s| s.ansi().to_string()).unwrap_or_default() + } + + pub fn help(&self) -> Result { + self.validate()?; + + Ok(self.command.clone().render_help().ansi().to_string()) + } + + pub fn parse_into_kv(&self, args: &Vec) -> Result { + let clap_args = self.command.clone().get_matches_from(args); + + let mut args_parts = Vec::::new(); + + for arg in self.command.get_arguments() { + if let Some(values) = clap_args.get_raw(arg.get_id().as_str()) { + args_parts.push(arg.get_id().to_string()); + + let mut value_parts = Vec::new(); + + for v in values { + value_parts.push(v.to_str().ok_or(Error::InvalidUTF8)?.to_string()); + } + + args_parts.push(format!("\"{}\"", value_parts.join(" "))); + } + } + + Ok(args_parts.join(" ")) + } +} + +pub fn extract_usage(config: &Config, path: &Path, cmd: &str) -> Usage { + let docs = parser::extract_docs(&path); + + let mut command = config.base_command(cmd).no_binary_name(true); + + if let Some(summary) = docs.summary { + command = command.about(summary); + } + + if let Some(description) = docs.description { + command = command.after_help(description); + } + + let mut error = None; + + if let Some(line) = docs.usage { + match usage_parser().parse(line) { + Ok(usage_lang) => { + command = apply_arguments(command, usage_lang); + }, + Err(e) => error = Some(Error::InvalidUsageString(e)), + } + } else { + command = command.arg(Arg::new("args").trailing_var_arg(true).num_args(..).allow_hyphen_values(true)); + } + + return Usage::new(command, error); +} + +fn apply_arguments(mut command: Command, usage_lang: UsageLang) -> Command { + for arg in usage_lang.arguments { + let mut clap_arg = match arg.base { + ArgBase::Positional(ref name) => { + Arg::new(name).required(true) + } + ArgBase::Short(character) => { + Arg::new(character.to_string()).short(character).num_args(0).required(true) + } + ArgBase::Long(ref name, value) => { + let mut arg = Arg::new(name).long(name).required(true); + if let Some(value) = value { + arg = arg.num_args(1).value_name(value); + } else { + arg = arg.num_args(0); + } + arg + } + }; + + clap_arg = clap_arg.exclusive(arg.exclusive); + clap_arg = clap_arg.required(arg.required); + + command = command.arg(clap_arg); + } + + if let Some(rest) = usage_lang.rest { + command = command.arg(Arg::new(rest).trailing_var_arg(true).num_args(..).allow_hyphen_values(true)); + } + + command +}