Skip to content

Commit

Permalink
Add support for dynamic commands
Browse files Browse the repository at this point in the history
This patch adds support for commands which the parser may not know about
until runtime. This could be useful if we'd like to discover subcommands
at runtime, e.g. we're using a plugin system and have to enumerate our
plugin repository to know what commands are available.
  • Loading branch information
sadmac7000 authored and erickt committed Jun 22, 2022
1 parent 60b4980 commit 4814be8
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 33 deletions.
3 changes: 3 additions & 0 deletions argh/Cargo.toml
Expand Up @@ -12,3 +12,6 @@ readme = "README.md"
[dependencies]
argh_shared = { version = "0.1.7", path = "../argh_shared" }
argh_derive = { version = "0.1.7", path = "../argh_derive" }

[dev-dependencies]
once_cell = "1.10.0"
38 changes: 31 additions & 7 deletions argh/src/lib.rs
Expand Up @@ -443,6 +443,11 @@ pub trait TopLevelCommand: FromArgs {}
pub trait SubCommands: FromArgs {
/// Info for the commands.
const COMMANDS: &'static [&'static CommandInfo];

/// Get a list of commands that are discovered at runtime.
fn dynamic_commands() -> &'static [&'static CommandInfo] {
&[]
}
}

/// A `FromArgs` implementation that represents a single subcommand.
Expand All @@ -455,6 +460,23 @@ impl<T: SubCommand> SubCommands for T {
const COMMANDS: &'static [&'static CommandInfo] = &[T::COMMAND];
}

/// Trait implemented by values returned from a dynamic subcommand handler.
pub trait DynamicSubCommand: Sized {
/// Info about supported subcommands.
fn commands() -> &'static [&'static CommandInfo];

/// Perform the function of `FromArgs::redact_arg_values` for this dynamic command. If the
/// command being processed is not recognized return `None`.
fn try_redact_arg_values(
command_name: &[&str],
args: &[&str],
) -> Option<Result<Vec<String>, EarlyExit>>;

/// Perform the function of `FromArgs::from_args` for this dynamic command. If the command being
/// processed is not recognized return `None`.
fn try_from_args(command_name: &[&str], args: &[&str]) -> Option<Result<Self, EarlyExit>>;
}

/// Information to display to the user about why a `FromArgs` construction exited early.
///
/// This can occur due to either failed parsing or a flag like `--help`.
Expand All @@ -480,7 +502,7 @@ impl From<String> for EarlyExit {

/// Extract the base cmd from a path
fn cmd<'a>(default: &'a str, path: &'a str) -> &'a str {
std::path::Path::new(path).file_name().map(|s| s.to_str()).flatten().unwrap_or(default)
std::path::Path::new(path).file_name().and_then(|s| s.to_str()).unwrap_or(default)
}

/// Create a `FromArgs` type from the current process's `env::args`.
Expand Down Expand Up @@ -845,6 +867,8 @@ pub struct ParseStructSubCommand<'a> {
// The subcommand commands
pub subcommands: &'static [&'static CommandInfo],

pub dynamic_subcommands: &'a [&'static CommandInfo],

// The function to parse the subcommand arguments.
pub parse_func: &'a mut dyn FnMut(&[&str], &[&str]) -> Result<(), EarlyExit>,
}
Expand All @@ -857,7 +881,7 @@ impl<'a> ParseStructSubCommand<'a> {
arg: &str,
remaining_args: &[&str],
) -> Result<bool, EarlyExit> {
for subcommand in self.subcommands {
for subcommand in self.subcommands.iter().chain(self.dynamic_subcommands.iter()) {
if subcommand.name == arg {
let mut command = cmd_name.to_owned();
command.push(subcommand.name);
Expand Down Expand Up @@ -886,7 +910,7 @@ fn prepend_help<'a>(args: &[&'a str]) -> Vec<&'a str> {
}

#[doc(hidden)]
pub fn print_subcommands(commands: &[&CommandInfo]) -> String {
pub fn print_subcommands<'a>(commands: impl Iterator<Item = &'a CommandInfo>) -> String {
let mut out = String::new();
for cmd in commands {
argh_shared::write_description(&mut out, cmd);
Expand All @@ -903,7 +927,7 @@ fn unrecognized_arg(arg: &str) -> String {
#[derive(Default)]
pub struct MissingRequirements {
options: Vec<&'static str>,
subcommands: Option<&'static [&'static CommandInfo]>,
subcommands: Option<Vec<&'static CommandInfo>>,
positional_args: Vec<&'static str>,
}

Expand All @@ -918,8 +942,8 @@ impl MissingRequirements {

// Add a missing required subcommand.
#[doc(hidden)]
pub fn missing_subcommands(&mut self, commands: &'static [&'static CommandInfo]) {
self.subcommands = Some(commands);
pub fn missing_subcommands(&mut self, commands: impl Iterator<Item = &'static CommandInfo>) {
self.subcommands = Some(commands.collect());
}

// Add a missing positional argument.
Expand Down Expand Up @@ -958,7 +982,7 @@ impl MissingRequirements {
}
}

if let Some(missing_subcommands) = self.subcommands {
if let Some(missing_subcommands) = &self.subcommands {
if !self.options.is_empty() {
output.push('\n');
}
Expand Down
150 changes: 150 additions & 0 deletions argh/tests/lib.rs
Expand Up @@ -94,6 +94,119 @@ fn subcommand_example() {
assert_eq!(two, TopLevel { nested: MySubCommandEnum::Two(SubCommandTwo { fooey: true }) },);
}

#[test]
fn dynamic_subcommand_example() {
#[derive(PartialEq, Debug)]
struct DynamicSubCommandImpl {
got: String,
}

impl argh::DynamicSubCommand for DynamicSubCommandImpl {
fn commands() -> &'static [&'static argh::CommandInfo] {
&[
&argh::CommandInfo { name: "three", description: "Third command" },
&argh::CommandInfo { name: "four", description: "Fourth command" },
&argh::CommandInfo { name: "five", description: "Fifth command" },
]
}

fn try_redact_arg_values(
_command_name: &[&str],
_args: &[&str],
) -> Option<Result<Vec<String>, argh::EarlyExit>> {
Some(Err(argh::EarlyExit::from("Test should not redact".to_owned())))
}

fn try_from_args(
command_name: &[&str],
args: &[&str],
) -> Option<Result<DynamicSubCommandImpl, argh::EarlyExit>> {
let command_name = match command_name.last() {
Some(x) => *x,
None => return Some(Err(argh::EarlyExit::from("No command".to_owned()))),
};
let description = Self::commands().iter().find(|x| x.name == command_name)?.description;
if args.len() > 1 {
Some(Err(argh::EarlyExit::from("Too many arguments".to_owned())))
} else if let Some(arg) = args.get(0) {
Some(Ok(DynamicSubCommandImpl { got: format!("{} got {:?}", description, arg) }))
} else {
Some(Err(argh::EarlyExit::from("Not enough arguments".to_owned())))
}
}
}

#[derive(FromArgs, PartialEq, Debug)]
/// Top-level command.
struct TopLevel {
#[argh(subcommand)]
nested: MySubCommandEnum,
}

#[derive(FromArgs, PartialEq, Debug)]
#[argh(subcommand)]
enum MySubCommandEnum {
One(SubCommandOne),
Two(SubCommandTwo),
#[argh(dynamic)]
ThreeFourFive(DynamicSubCommandImpl),
}

#[derive(FromArgs, PartialEq, Debug)]
/// First subcommand.
#[argh(subcommand, name = "one")]
struct SubCommandOne {
#[argh(option)]
/// how many x
x: usize,
}

#[derive(FromArgs, PartialEq, Debug)]
/// Second subcommand.
#[argh(subcommand, name = "two")]
struct SubCommandTwo {
#[argh(switch)]
/// whether to fooey
fooey: bool,
}

let one = TopLevel::from_args(&["cmdname"], &["one", "--x", "2"]).expect("sc 1");
assert_eq!(one, TopLevel { nested: MySubCommandEnum::One(SubCommandOne { x: 2 }) },);

let two = TopLevel::from_args(&["cmdname"], &["two", "--fooey"]).expect("sc 2");
assert_eq!(two, TopLevel { nested: MySubCommandEnum::Two(SubCommandTwo { fooey: true }) },);

let three = TopLevel::from_args(&["cmdname"], &["three", "beans"]).expect("sc 3");
assert_eq!(
three,
TopLevel {
nested: MySubCommandEnum::ThreeFourFive(DynamicSubCommandImpl {
got: "Third command got \"beans\"".to_owned()
})
},
);

let four = TopLevel::from_args(&["cmdname"], &["four", "boulders"]).expect("sc 4");
assert_eq!(
four,
TopLevel {
nested: MySubCommandEnum::ThreeFourFive(DynamicSubCommandImpl {
got: "Fourth command got \"boulders\"".to_owned()
})
},
);

let five = TopLevel::from_args(&["cmdname"], &["five", "gold rings"]).expect("sc 5");
assert_eq!(
five,
TopLevel {
nested: MySubCommandEnum::ThreeFourFive(DynamicSubCommandImpl {
got: "Fifth command got \"gold rings\"".to_owned()
})
},
);
}

#[test]
fn multiline_doc_comment_description() {
#[derive(FromArgs)]
Expand Down Expand Up @@ -798,6 +911,8 @@ Options:
enum HelpExampleSubCommands {
BlowUp(BlowUp),
Grind(GrindCommand),
#[argh(dynamic)]
Plugin(HelpExamplePlugin),
}

#[derive(FromArgs, PartialEq, Debug)]
Expand All @@ -817,6 +932,39 @@ Options:
safely: bool,
}

#[derive(PartialEq, Debug)]
struct HelpExamplePlugin {
got: String,
}

impl argh::DynamicSubCommand for HelpExamplePlugin {
fn commands() -> &'static [&'static argh::CommandInfo] {
&[&argh::CommandInfo { name: "plugin", description: "Example dynamic command" }]
}

fn try_redact_arg_values(
_command_name: &[&str],
_args: &[&str],
) -> Option<Result<Vec<String>, argh::EarlyExit>> {
Some(Err(argh::EarlyExit::from("Test should not redact".to_owned())))
}

fn try_from_args(
command_name: &[&str],
args: &[&str],
) -> Option<Result<HelpExamplePlugin, argh::EarlyExit>> {
if command_name.last() != Some(&"plugin") {
None
} else if args.len() > 1 {
Some(Err(argh::EarlyExit::from("Too many arguments".to_owned())))
} else if let Some(arg) = args.get(0) {
Some(Ok(HelpExamplePlugin { got: format!("plugin got {:?}", arg) }))
} else {
Some(Ok(HelpExamplePlugin { got: "plugin got no argument".to_owned() }))
}
}
}

#[test]
fn example_parses_correctly() {
let help_example = HelpExample::from_args(
Expand Down Expand Up @@ -850,6 +998,7 @@ Options:
" help\n",
" blow-up\n",
" grind\n",
" plugin\n",
),
);
}
Expand All @@ -873,6 +1022,7 @@ Options:
Commands:
blow-up explosively separate
grind make smaller by many small cuts
plugin Example dynamic command
Examples:
Scribble 'abc' and then run |grind|.
Expand Down
6 changes: 6 additions & 0 deletions argh_derive/src/help.rs
Expand Up @@ -85,6 +85,12 @@ pub(crate) fn help(
subcommand_calculation = quote! {
let subcommands = argh::print_subcommands(
<#subcommand_ty as argh::SubCommands>::COMMANDS
.iter()
.copied()
.chain(
<#subcommand_ty as argh::SubCommands>::dynamic_commands()
.iter()
.copied())
);
};
} else {
Expand Down

0 comments on commit 4814be8

Please sign in to comment.