From 98d8b37d15c22b5d684aae32dabbdd6108ae67a5 Mon Sep 17 00:00:00 2001 From: Juan Ibiapina Date: Thu, 20 Jun 2024 11:30:23 +0200 Subject: [PATCH 1/5] feat: Support documenting options --- .../fixtures/project/libexec/with-help | 5 ++- integration/help.bats | 5 ++- src/error.rs | 1 + src/main.rs | 16 +++++++ src/parser.rs | 26 +++++++++++- src/usage.rs | 42 ++++++++++++++++++- 6 files changed, 88 insertions(+), 7 deletions(-) diff --git a/integration/fixtures/project/libexec/with-help b/integration/fixtures/project/libexec/with-help index 8bdce5c..823ba29 100755 --- a/integration/fixtures/project/libexec/with-help +++ b/integration/fixtures/project/libexec/with-help @@ -2,7 +2,10 @@ # # Summary: Command with complete help # -# Usage: {cmd} [args]... +# Usage: {cmd} [args]... +# +# Options: +# positional: A positional argument # # This is a complete test script with documentation. # diff --git a/integration/help.bats b/integration/help.bats index b07c6e7..7d129df 100644 --- a/integration/help.bats +++ b/integration/help.bats @@ -67,10 +67,11 @@ Options: assert_success assert_output "Command with complete help -Usage: main with-help [args]... +Usage: main with-help [args]... Arguments: - [args]... + A positional argument + [args]... Options: -h, --help Print help diff --git a/src/error.rs b/src/error.rs index 31700d5..36b4005 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,5 +14,6 @@ pub enum Error { SubCommandInterrupted, UnknownSubCommand(String), InvalidUsageString(Vec>), + InvalidOptionString(Vec>), InvalidUTF8, } diff --git a/src/main.rs b/src/main.rs index ce760c9..602dd89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,13 @@ fn print_error(error: Error) -> String { } message } + Error::InvalidOptionString(errors) => { + let mut message = "invalid option 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(), Error::SubCommandIoError(e) => format!("IO Error: {}", e), @@ -117,6 +124,15 @@ fn handle_error(config: &Config, error: Error, silent: bool) -> ! { } exit(1); } + Error::InvalidOptionString(errors) => { + if !silent { + println!("{}: invalid option string", config.name); + for error in errors { + println!(" {}", error); + } + } + exit(1); + } Error::InvalidUTF8 => { if !silent { println!("invalid UTF-8"); diff --git a/src/parser.rs b/src/parser.rs index c5a4e3e..f4cc5a5 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -27,12 +27,14 @@ fn extract_initial_comment_block(path: &Path) -> String { #[derive(PartialEq)] enum Mode { Out, + Options, Description, } pub struct Docs { - pub usage: Option, pub summary: Option, + pub usage: Option, + pub options: Vec, pub description: Option, } @@ -47,6 +49,7 @@ pub fn extract_docs(path: &Path) -> Docs { let mut summary = None; let mut usage = None; + let mut options = Vec::new(); let mut description = Vec::new(); let mut mode = Mode::Out; @@ -69,6 +72,11 @@ pub fn extract_docs(path: &Path) -> Docs { continue; } + if line == "# Options:" { + mode = Mode::Options; + continue; + } + if let Some(caps) = EXTENDED_RE.captures(&line) { if let Some(m) = caps.get(1) { description.push(m.as_str().trim().to_owned()); @@ -78,6 +86,19 @@ pub fn extract_docs(path: &Path) -> Docs { } } + if mode == Mode::Options { + if line == "#" { + mode = Mode::Out; + } + + if let Some(caps) = INDENTED_RE.captures(&line) { + if let Some(m) = caps.get(1) { + options.push(m.as_str().trim().to_owned()); + continue; + } + } + } + if mode == Mode::Description { if line == "#" { description.push("".to_owned()); @@ -94,8 +115,9 @@ pub fn extract_docs(path: &Path) -> Docs { } Docs { - usage, summary, + usage, + options, description: if description.is_empty() { None } else { Some(description.join("\n")) }, } } diff --git a/src/usage.rs b/src/usage.rs index 3b36150..f869436 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -5,6 +5,7 @@ use chumsky::prelude::*; use clap::{Command, Arg}; use std::path::Path; +use std::collections::HashMap; use crate::parser; use crate::error::{Error, Result}; @@ -30,6 +31,25 @@ struct UsageLang { rest: Option, } +#[derive(Debug, PartialEq)] +struct OptionSpec { + name: String, + description: Option, +} + +fn option_parser() -> impl Parser> { + let ident = filter(|c: &char| c.is_ascii_alphabetic()) + .chain(filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').repeated()) + .collect(); + + let description = take_until(end()).padded().map(|(s, _)| s.into_iter().collect()); + + ident.padded().then_ignore(just(':')).then(description.padded()).map(|(name, description)| OptionSpec { + name, + description: Some(description), + }) +} + fn usage_parser() -> impl Parser> { let prefix = just("# Usage:").padded(); @@ -173,12 +193,24 @@ pub fn extract_usage(config: &Config, path: &Path, cmd: &str) -> Usage { command = command.after_help(description); } + // TODO: make this a vec of errors let mut error = None; + let mut options = HashMap::::new(); + + for line in docs.options { + match option_parser().parse(line) { + Ok(option) => { + options.insert(option.name.clone(), option); + }, + Err(e) => error = Some(Error::InvalidOptionString(e)), + } + } + if let Some(line) = docs.usage { match usage_parser().parse(line) { Ok(usage_lang) => { - command = apply_arguments(command, usage_lang); + command = apply_arguments(command, usage_lang, options); }, Err(e) => error = Some(Error::InvalidUsageString(e)), } @@ -191,7 +223,7 @@ pub fn extract_usage(config: &Config, path: &Path, cmd: &str) -> Usage { return Usage::new(command, error); } -fn apply_arguments(mut command: Command, usage_lang: UsageLang) -> Command { +fn apply_arguments(mut command: Command, usage_lang: UsageLang, options: HashMap) -> Command { for arg in usage_lang.arguments { let mut clap_arg = match arg.base { ArgBase::Positional(ref name) => { @@ -214,6 +246,12 @@ fn apply_arguments(mut command: Command, usage_lang: UsageLang) -> Command { clap_arg = clap_arg.exclusive(arg.exclusive); clap_arg = clap_arg.required(arg.required); + if let Some(option) = options.get(clap_arg.get_id().as_str()) { + if let Some(description) = &option.description { + clap_arg = clap_arg.help(description); + } + } + command = command.arg(clap_arg); } From 2918c1c3e1db345e84dcd707fbdc1964d0f8e3a8 Mon Sep 17 00:00:00 2001 From: Juan Ibiapina Date: Thu, 20 Jun 2024 11:33:10 +0200 Subject: [PATCH 2/5] feat: Add args description for commands with default usage --- integration/help.bats | 2 +- src/usage.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/help.bats b/integration/help.bats index 7d129df..40408cb 100644 --- a/integration/help.bats +++ b/integration/help.bats @@ -53,7 +53,7 @@ Available subcommands: assert_output "Usage: main no-doc [args]... Arguments: - [args]... + [args]... other arguments Options: -h, --help Print help" diff --git a/src/usage.rs b/src/usage.rs index f869436..a86b4f3 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -215,7 +215,7 @@ pub fn extract_usage(config: &Config, path: &Path, cmd: &str) -> Usage { Err(e) => error = Some(Error::InvalidUsageString(e)), } } else { - command = command.arg(Arg::new("args").trailing_var_arg(true).num_args(..).allow_hyphen_values(true)); + command = command.arg(Arg::new("args").help("other arguments").trailing_var_arg(true).num_args(..).allow_hyphen_values(true)); } // both command and error are returned because an invalid usage string doesn't prevent the From b930e278adb7c6b6c7e6dba8c9c04d155b331219 Mon Sep 17 00:00:00 2001 From: Juan Ibiapina Date: Thu, 20 Jun 2024 13:36:32 +0200 Subject: [PATCH 3/5] feat: New completion system --- integration/completions-old.bats | 68 +++++++++++++++++ integration/completions.bats | 12 ++- integration/fixtures/completions-old/bin/main | 5 ++ .../completions-old/libexec/directory/d | 0 .../libexec/directory/double/with-completions | 8 ++ .../completions-old/libexec/no-completions | 0 .../completions-old/libexec/with-completions | 8 ++ .../libexec/directory/double/with-completions | 14 ++-- .../completions/libexec/no-completions | 5 ++ .../completions/libexec/with-completions | 31 ++++++-- src/commands/directory.rs | 5 +- src/commands/file.rs | 34 +++++++++ src/usage.rs | 75 +++++++++++++++++-- 13 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 integration/completions-old.bats create mode 100755 integration/fixtures/completions-old/bin/main create mode 100755 integration/fixtures/completions-old/libexec/directory/d create mode 100755 integration/fixtures/completions-old/libexec/directory/double/with-completions create mode 100755 integration/fixtures/completions-old/libexec/no-completions create mode 100755 integration/fixtures/completions-old/libexec/with-completions diff --git a/integration/completions-old.bats b/integration/completions-old.bats new file mode 100644 index 0000000..e3ab1ce --- /dev/null +++ b/integration/completions-old.bats @@ -0,0 +1,68 @@ +#!/usr/bin/env bats + +load test_helper + +@test "completions: without arguments, lists commands" { + fixture "completions-old" + + run main --completions + + assert_success + assert_output "$(main --commands)" +} + +@test "completions: fails gracefully when command is not found" { + fixture "completions-old" + + run main --completions not-found + + assert_failure + assert_output "" +} + +@test "completions: invokes command completions" { + fixture "completions-old" + + run main --completions with-completions + + assert_success + assert_output "comp1 +comp2" +} + +@test "completions: lists nothing if command provides no completions" { + fixture "completions-old" + + run main --completions no-completions + + assert_success + assert_output "" +} + +@test "completions: displays for directory commands" { + fixture "completions-old" + + run main --completions directory + + assert_success + assert_output "$(main --commands directory)" +} + +@test "completions: displays double nested directory commands" { + fixture "completions-old" + + run main --completions directory double + + assert_success + assert_output "$(main --commands directory double)" +} + +@test "completions: displays double nested subcommands" { + fixture "completions-old" + + run main --completions directory double with-completions + + assert_success + assert_output "comp11 +comp21" +} diff --git a/integration/completions.bats b/integration/completions.bats index 2596f45..5e4b9e0 100644 --- a/integration/completions.bats +++ b/integration/completions.bats @@ -20,7 +20,7 @@ load test_helper assert_output "" } -@test "completions: invokes command completions" { +@test "completions: script: invokes command completions for first argument" { fixture "completions" run main --completions with-completions @@ -30,6 +30,16 @@ load test_helper comp2" } +@test "completions: script: invokes command completions for second argument" { + fixture "completions" + + run main --completions with-completions value1 + + assert_success + assert_output "comp3 +comp4" +} + @test "completions: lists nothing if command provides no completions" { fixture "completions" diff --git a/integration/fixtures/completions-old/bin/main b/integration/fixtures/completions-old/bin/main new file mode 100755 index 0000000..2791125 --- /dev/null +++ b/integration/fixtures/completions-old/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-old/libexec/directory/d b/integration/fixtures/completions-old/libexec/directory/d new file mode 100755 index 0000000..e69de29 diff --git a/integration/fixtures/completions-old/libexec/directory/double/with-completions b/integration/fixtures/completions-old/libexec/directory/double/with-completions new file mode 100755 index 0000000..5d24f95 --- /dev/null +++ b/integration/fixtures/completions-old/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-old/libexec/no-completions b/integration/fixtures/completions-old/libexec/no-completions new file mode 100755 index 0000000..e69de29 diff --git a/integration/fixtures/completions-old/libexec/with-completions b/integration/fixtures/completions-old/libexec/with-completions new file mode 100755 index 0000000..3a3141b --- /dev/null +++ b/integration/fixtures/completions-old/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/completions/libexec/directory/double/with-completions b/integration/fixtures/completions/libexec/directory/double/with-completions index 5d24f95..4b93d60 100755 --- a/integration/fixtures/completions/libexec/directory/double/with-completions +++ b/integration/fixtures/completions/libexec/directory/double/with-completions @@ -1,8 +1,10 @@ #!/usr/bin/env bash +# +# Usage: {cmd}