diff --git a/README.md b/README.md index 4e949e4..bb5a463 100644 --- a/README.md +++ b/README.md @@ -174,4 +174,92 @@ struct SubCommandTwo { } ``` +## Attribute summary + +### Type attributes for `argh` + +The attributes used to configure the argh information for a type are defined in +[parse_attrs::TypeAttrs](argh_derive/src/parse_attrs.rs). + +- `subcommand` - a subcommand type. This attribute must appear on both enumeration and each struct that + is a variant for the enumerated subcommand. +- `error_code(code, description)` - an error code for the command. This attribute can appear zero + or more times. +- `examples=` - Formatted text containing examples of how to use this command. This + is an optional attribute. +- `name=` - (required for subcommand variant) the name of the subcommand. +- `notes=` - Formatted text containing usage notes for this command. This + is an optional attribute. + +### Field attributes for `argh` + +The attributes used to configure the argh information for a field are +defined in [parse_attrs.rs](argh_derive/src/parse_attrs.rs). + +- Field kind. This is the first attribute. Valid kinds are: + - `switch` - a boolean flag, its presence on the command sets the field to `true`. + - `option` - a value. This can be a simple type like String, or usize, and enumeration. + This can be a scalar or Vec<> for repeated values. + - `subcommand` - a subcommand. The type of this field is an enumeration with a value for each + subcommand. This attribute must appear on both the "top level" field and each struct that + is a variant for the enumerated subcommand. + - `positional` - a positional argument. This can be scalar or Vec<>. Only the last positional + argument can be Option<>, Vec<>, or defaulted. +- `arg_name=` - the name to use for a positional argument in the help or the value of a `option`. + If not given, the default is the name of the field. +- `default=` - the default value for the `option` or `positional` fields. +- `description=` - the description of the flag or argument. The default value is the doc comment + for the field. +- `from_str_fn` is the name of a custom deserialization function for this field with the signature: + `fn(&str) -> Result`. +- `long=` - the long format of the option or switch name. If `long` is not present, the + flag name defaults to the field name. +- `short=` - the single character for this flag. If `short` is not present, there is no + short equivalent flag. + +## JSON encoded help + +The `FromArgs` trait implements 2 methods for accessing the usage information in a JSON +encoded format. This is intended to facilitate generating reference documentation based on +the command line arguments. In order to use this capability, you need to use `#[derive(FromArgs)]` +in order to generate the method to construct the JSON string. + +`fn help_json_from_args(command: &[&str]) -> Result` returns JSON +encoded help information, using the `command` paramter in the _usage_ field. Alternatively, +you can use `fn help_json() -> Result` to use the command +name used to invoke the application. + +### JSON structure + +The structure returned contains the following fields: + +- **name** - the name of the command or subcommand. +- **usage** - the command line pattern showing the usage of the command. +- **description** - the description of the command or subcommand. +- **flags** - the array of command line flags each with: + - **short** - the 1 letter option, if specified, otherwise the empty string. + - **long** - the value of the _long_ attribute or the field name. + - **description** - the description of the option. + - **arg_name** - the name of the option argument, or the field name if not + specified. + - **optionality** - the type of usage for this option. Can be _optional_, + _required_, _repeated_, or the default value if the `default` attribute is + specified. +- **positional** - the array of positional arguments each with: + - **name** - the name of the argument + - **description** - the description of the argument + - **optionality** - the type of usage for this option. Can be _optional_, + _required_, _repeated_, or the default value if the `default` attribute is + specified. +- **examples** - a formatted string of example usage of the command, as + specified in the `examples` attribute. +- **notes** - a formatted string of notes about the usage of the command, as + specified in the `examples` attribute. +- **error_codes** - the array of error code information for this command + including: + - **name** - the error code return value. + - **description** - the meaning of the error code. +- **subcommands** - the array of subcommands for this command. + + NOTE: This is not an officially supported Google product. diff --git a/argh/src/lib.rs b/argh/src/lib.rs index 1bd6ba9..76f16b7 100644 --- a/argh/src/lib.rs +++ b/argh/src/lib.rs @@ -434,6 +434,23 @@ pub trait FromArgs: Sized { fn redact_arg_values(_command_name: &[&str], _args: &[&str]) -> Result, EarlyExit> { Ok(vec!["<>".into()]) } + + /// Returns a JSON encoded string of the usage information. This is intended to + /// create a "machine readable" version of the help text to enable reference + /// documentation generation. + fn help_json_from_args(_: &[&str]) -> Result { + Err(EarlyExit::from(String::from("Not implemented, use #[derive(FromArgs)]"))) + } + + /// Returns a JSON encoded string of the usage information based on the command line + /// found in argv, identical to `::from_env()`. This is intended to + /// create a "machine readable" version of the help text to enable reference + /// documentation generation. + fn help_json() -> Result { + let strings: Vec = std::env::args().collect(); + let cmd = cmd(&strings[0], &strings[0]); + Self::help_json_from_args(&[cmd]) + } } /// A top-level `FromArgs` implementation that is not a subcommand. diff --git a/argh/tests/help_json_tests.rs b/argh/tests/help_json_tests.rs new file mode 100644 index 0000000..0015445 --- /dev/null +++ b/argh/tests/help_json_tests.rs @@ -0,0 +1,704 @@ +#![cfg(test)] +// Copyright (c) 2020 Google LLC All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +use std::path::PathBuf; +use {argh::FromArgs, std::fmt::Debug}; + +#[test] +fn help_json_test_subcommand() { + #[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), + } + + #[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, + } + + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 []", +"description": "Top-level command.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [{ +"name": "one", +"usage": "test_arg_0 one --x ", +"description": "First subcommand.", +"flags": [{"short": "", "long": "--x", "description": "how many x", "arg_name": "x", "optionality": "required"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +, +{ +"name": "two", +"usage": "test_arg_0 two [--fooey]", +"description": "Second subcommand.", +"flags": [{"short": "", "long": "--fooey", "description": "whether to fooey", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +] +} +"###, + ); + + assert_help_json_string::( + vec!["one"], + r###"{ +"name": "one", +"usage": "test_arg_0 one --x ", +"description": "First subcommand.", +"flags": [{"short": "", "long": "--x", "description": "how many x", "arg_name": "x", "optionality": "required"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_multiline_doc_comment() { + #[derive(FromArgs)] + /// Short description + struct Cmd { + #[argh(switch)] + /// a switch with a description + /// that is spread across + /// a number of + /// lines of comments. + _s: bool, + } + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 [--s]", +"description": "Short description", +"flags": [{"short": "", "long": "--s", "description": "a switch with a description that is spread across a number of lines of comments.", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_basic_args() { + #[allow(dead_code)] + #[derive(FromArgs)] + /// Basic command args demonstrating multiple types and cardinality. "With quotes" + struct Basic { + /// should the power be on. "Quoted value" should work too. + #[argh(switch)] + power: bool, + + /// option that is required because of no default and not Option<>. + #[argh(option, long = "required")] + required_flag: String, + + /// optional speed if not specified it is None. + #[argh(option, short = 's')] + speed: Option, + + /// repeatable option. + #[argh(option, arg_name = "url")] + link: Vec, + } + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 [--power] --required [-s ] [--link ]", +"description": "Basic command args demonstrating multiple types and cardinality. \"With quotes\"", +"flags": [{"short": "", "long": "--power", "description": "should the power be on. \"Quoted value\" should work too.", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--required", "description": "option that is required because of no default and not Option<>.", "arg_name": "required_flag", "optionality": "required"}, +{"short": "s", "long": "--speed", "description": "optional speed if not specified it is None.", "arg_name": "speed", "optionality": "optional"}, +{"short": "", "long": "--link", "description": "repeatable option.", "arg_name": "url", "optionality": "repeating"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +"###, + ); +} + +/* +[{\"short\": \"\", \"long\": \"--power\", \"description\": \"should the power be on. \\\"Quoted value\\\" should work too.\", \"arg_name\": \"\", \"optionality\": \"optional\"},\n{\"short\": \"\", \"long\": \"--required\", \"description\": \"option that is required because of no default and not Option<>.\", \"arg_name\": \"required_flag\", \"optionality\": \"required\"},\n{\"short\": \"s\", \"long\": \"--speed\", \"description\": \"optional speed if not specified it is None.\", \"arg_name\": \"speed\", \"optionality\": \"optional\"},\n{\"short\": \"\", \"long\": \"--link\", \"description\": \"repeatable option.\", \"arg_name\": \"url\", \"optionality\": \"repeating\"},\n{\"short\": \"\", \"long\": \"--help\", \"description\": \"display usage information\", \"arg_name\": \"\", \"optionality\": \"optional\"}],\n\"positional\": [],\n\"examples\": \"\",\n\"notes\": \"\",\n\"error_codes\": [],\n\"subcommands\": []\n}\n +*/ +#[test] +fn help_json_test_positional_args() { + #[allow(dead_code)] + #[derive(FromArgs)] + /// Command with positional args demonstrating. "With quotes" + struct Positional { + /// the "root" position. + #[argh(positional, arg_name = "root")] + root_value: String, + + /// trunk value + #[argh(positional)] + trunk: String, + + /// leaves. There can be many leaves. + #[argh(positional)] + leaves: Vec, + } + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 []", +"description": "Command with positional args demonstrating. \"With quotes\"", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "root", "description": "the \"root\" position.", "optionality": "required"}, +{"name": "trunk", "description": "trunk value", "optionality": "required"}, +{"name": "leaves", "description": "leaves. There can be many leaves.", "optionality": "repeating"}], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_optional_positional_args() { + #[allow(dead_code)] + #[derive(FromArgs)] + /// Command with positional args demonstrating last value is optional + struct Positional { + /// the "root" position. + #[argh(positional, arg_name = "root")] + root_value: String, + + /// trunk value + #[argh(positional)] + trunk: String, + + /// leaves. There can be many leaves. + #[argh(positional)] + leaves: Option, + } + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 []", +"description": "Command with positional args demonstrating last value is optional", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "root", "description": "the \"root\" position.", "optionality": "required"}, +{"name": "trunk", "description": "trunk value", "optionality": "required"}, +{"name": "leaves", "description": "leaves. There can be many leaves.", "optionality": "optional"}], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_default_positional_args() { + #[allow(dead_code)] + #[derive(FromArgs)] + /// Command with positional args demonstrating last value is defaulted. + struct Positional { + /// the "root" position. + #[argh(positional, arg_name = "root")] + root_value: String, + + /// trunk value + #[argh(positional)] + trunk: String, + + /// leaves. There can be many leaves. + #[argh(positional, default = "String::from(\"hello\")")] + leaves: String, + } + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 []", +"description": "Command with positional args demonstrating last value is defaulted.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "root", "description": "the \"root\" position.", "optionality": "required"}, +{"name": "trunk", "description": "trunk value", "optionality": "required"}, +{"name": "leaves", "description": "leaves. There can be many leaves.", "optionality": "String :: from(\"hello\")"}], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_notes_examples_errors() { + #[allow(dead_code)] + #[derive(FromArgs)] + /// Command with Examples and usage Notes, including error codes. + #[argh( + note = r##" + These usage notes appear for {command_name} and how to best use it. + The formatting should be preserved. + one + two + three then a blank + + and one last line with "quoted text"."##, + example = r##" + Use the command with 1 file: + + `{command_name} /path/to/file` + + Use it with a "wildcard": + + `{command_name} /path/to/*` + + a blank line + + and one last line with "quoted text"."##, + error_code(0, "Success"), + error_code(1, "General Error"), + error_code(2, "Some error with \"quotes\"") + )] + struct NotesExamplesErrors { + /// the "root" position. + #[argh(positional, arg_name = "files")] + fields: Vec, + } + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 []", +"description": "Command with Examples and usage Notes, including error codes.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "files", "description": "the \"root\" position.", "optionality": "repeating"}], +"examples": "\n Use the command with 1 file:\n\n `test_arg_0 /path/to/file`\n\n Use it with a \"wildcard\":\n\n `test_arg_0 /path/to/*`\n\n a blank line\n \n and one last line with \"quoted text\".", +"notes": "\n These usage notes appear for test_arg_0 and how to best use it.\n The formatting should be preserved.\n one\n two\n three then a blank\n \n and one last line with \"quoted text\".", +"error_codes": [{"name": "0", "description": "Success"}, +{"name": "1", "description": "General Error"}, +{"name": "2", "description": "Some error with \"quotes\""}], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_subcommands() { + #[allow(dead_code)] + #[derive(FromArgs)] + ///Top level command with "subcommands". + struct TopLevel { + /// show verbose output + #[argh(switch)] + verbose: bool, + + /// this doc comment does not appear anywhere. + #[argh(subcommand)] + cmd: SubcommandEnum, + } + + #[derive(FromArgs)] + #[argh(subcommand)] + /// Doc comments for subcommand enums does not appear in the help text. + enum SubcommandEnum { + Command1(Command1Args), + Command2(Command2Args), + Command3(Command3Args), + } + + /// Command1 args are used for Command1. + #[allow(dead_code)] + #[derive(FromArgs)] + #[argh(subcommand, name = "one")] + struct Command1Args { + /// the "root" position. + #[argh(positional, arg_name = "root")] + root_value: String, + + /// trunk value + #[argh(positional)] + trunk: String, + + /// leaves. There can be many leaves. + #[argh(positional, default = "String::from(\"hello\")")] + leaves: String, + } + /// Command2 args are used for Command2. + #[allow(dead_code)] + #[derive(FromArgs)] + #[argh(subcommand, name = "two")] + struct Command2Args { + /// should the power be on. "Quoted value" should work too. + #[argh(switch)] + power: bool, + + /// option that is required because of no default and not Option<>. + #[argh(option, long = "required")] + required_flag: String, + + /// optional speed if not specified it is None. + #[argh(option, short = 's')] + speed: Option, + + /// repeatable option. + #[argh(option, arg_name = "url")] + link: Vec, + } + /// Command3 args are used for Command3 which has no options or arguments. + #[derive(FromArgs)] + #[argh(subcommand, name = "three")] + struct Command3Args {} + + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 [--verbose] []", +"description": "Top level command with \"subcommands\".", +"flags": [{"short": "", "long": "--verbose", "description": "show verbose output", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [{ +"name": "one", +"usage": "test_arg_0 one []", +"description": "Command1 args are used for Command1.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "root", "description": "the \"root\" position.", "optionality": "required"}, +{"name": "trunk", "description": "trunk value", "optionality": "required"}, +{"name": "leaves", "description": "leaves. There can be many leaves.", "optionality": "String :: from(\"hello\")"}], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +, +{ +"name": "two", +"usage": "test_arg_0 two [--power] --required [-s ] [--link ]", +"description": "Command2 args are used for Command2.", +"flags": [{"short": "", "long": "--power", "description": "should the power be on. \"Quoted value\" should work too.", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--required", "description": "option that is required because of no default and not Option<>.", "arg_name": "required_flag", "optionality": "required"}, +{"short": "s", "long": "--speed", "description": "optional speed if not specified it is None.", "arg_name": "speed", "optionality": "optional"}, +{"short": "", "long": "--link", "description": "repeatable option.", "arg_name": "url", "optionality": "repeating"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +, +{ +"name": "three", +"usage": "test_arg_0 three", +"description": "Command3 args are used for Command3 which has no options or arguments.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +] +} +"###, + ); +} + +#[test] +fn help_json_test_subcommand_notes_examples() { + #[allow(dead_code)] + #[derive(FromArgs)] + ///Top level command with "subcommands". + #[argh( + note = "Top level note", + example = "Top level example", + error_code(0, "Top level success") + )] + struct TopLevel { + /// show verbose output + #[argh(switch)] + verbose: bool, + + /// this doc comment does not appear anywhere. + #[argh(subcommand)] + cmd: SubcommandEnum, + } + + #[derive(FromArgs)] + #[argh(subcommand)] + /// Doc comments for subcommand enums does not appear in the help text. + enum SubcommandEnum { + Command1(Command1Args), + } + + /// Command1 args are used for subcommand one. + #[allow(dead_code)] + #[derive(FromArgs)] + #[argh( + subcommand, + name = "one", + note = "{command_name} is used as a subcommand of \"Top level\"", + example = "\"Typical\" usage is `{command_name}`.", + error_code(0, "one level success") + )] + struct Command1Args { + /// the "root" position. + #[argh(positional, arg_name = "root")] + root_value: String, + + /// trunk value + #[argh(positional)] + trunk: String, + + /// leaves. There can be many leaves. + #[argh(positional, default = "String::from(\"hello\")")] + leaves: String, + } + + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 [--verbose] []", +"description": "Top level command with \"subcommands\".", +"flags": [{"short": "", "long": "--verbose", "description": "show verbose output", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "Top level example", +"notes": "Top level note", +"error_codes": [{"name": "0", "description": "Top level success"}], +"subcommands": [{ +"name": "one", +"usage": "test_arg_0 one []", +"description": "Command1 args are used for subcommand one.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "root", "description": "the \"root\" position.", "optionality": "required"}, +{"name": "trunk", "description": "trunk value", "optionality": "required"}, +{"name": "leaves", "description": "leaves. There can be many leaves.", "optionality": "String :: from(\"hello\")"}], +"examples": "\"Typical\" usage is `test_arg_0 one`.", +"notes": "test_arg_0 one is used as a subcommand of \"Top level\"", +"error_codes": [{"name": "0", "description": "one level success"}], +"subcommands": [] +} +] +} +"###, + ); + + assert_help_json_string::( + vec!["one"], + r###"{ +"name": "one", +"usage": "test_arg_0 one []", +"description": "Command1 args are used for subcommand one.", +"flags": [{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [{"name": "root", "description": "the \"root\" position.", "optionality": "required"}, +{"name": "trunk", "description": "trunk value", "optionality": "required"}, +{"name": "leaves", "description": "leaves. There can be many leaves.", "optionality": "String :: from(\"hello\")"}], +"examples": "\"Typical\" usage is `test_arg_0 one`.", +"notes": "test_arg_0 one is used as a subcommand of \"Top level\"", +"error_codes": [{"name": "0", "description": "one level success"}], +"subcommands": [] +} +"###, + ); +} + +#[test] +fn help_json_test_example() { + #[derive(FromArgs, PartialEq, Debug)] + #[argh( + description = "Destroy the contents of with a specific \"method of destruction\".", + example = "Scribble 'abc' and then run |grind|.\n$ {command_name} -s 'abc' grind old.txt taxes.cp", + note = "Use `{command_name} help ` for details on [] for a subcommand.", + error_code(2, "The blade is too dull."), + error_code(3, "Out of fuel.") + )] + struct HelpExample { + /// force, ignore minor errors. This description is so long that it wraps to the next line. + #[argh(switch, short = 'f')] + force: bool, + + /// documentation + #[argh(switch)] + really_really_really_long_name_for_pat: bool, + + /// write repeatedly + #[argh(option, short = 's')] + scribble: String, + + /// say more. Defaults to $BLAST_VERBOSE. + #[argh(switch, short = 'v')] + verbose: bool, + + #[argh(subcommand)] + command: HelpExampleSubCommands, + } + + #[derive(FromArgs, PartialEq, Debug)] + #[argh(subcommand)] + enum HelpExampleSubCommands { + BlowUp(BlowUp), + Grind(GrindCommand), + } + + #[derive(FromArgs, PartialEq, Debug)] + #[argh(subcommand, name = "blow-up")] + /// explosively separate + struct BlowUp { + /// blow up bombs safely + #[argh(switch)] + safely: bool, + } + + #[derive(FromArgs, PartialEq, Debug)] + #[argh(subcommand, name = "grind", description = "make smaller by many small cuts")] + struct GrindCommand { + /// wear a visor while grinding + #[argh(switch)] + safely: bool, + } + + assert_help_json_string::( + vec![], + r###"{ +"name": "test_arg_0", +"usage": "test_arg_0 [-f] [--really-really-really-long-name-for-pat] -s [-v] []", +"description": "Destroy the contents of with a specific \"method of destruction\".", +"flags": [{"short": "f", "long": "--force", "description": "force, ignore minor errors. This description is so long that it wraps to the next line.", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--really-really-really-long-name-for-pat", "description": "documentation", "arg_name": "", "optionality": "optional"}, +{"short": "s", "long": "--scribble", "description": "write repeatedly", "arg_name": "scribble", "optionality": "required"}, +{"short": "v", "long": "--verbose", "description": "say more. Defaults to $BLAST_VERBOSE.", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "Scribble 'abc' and then run |grind|.\n$ test_arg_0 -s 'abc' grind old.txt taxes.cp", +"notes": "Use `test_arg_0 help ` for details on [] for a subcommand.", +"error_codes": [{"name": "2", "description": "The blade is too dull."}, +{"name": "3", "description": "Out of fuel."}], +"subcommands": [{ +"name": "blow-up", +"usage": "test_arg_0 blow-up [--safely]", +"description": "explosively separate", +"flags": [{"short": "", "long": "--safely", "description": "blow up bombs safely", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +, +{ +"name": "grind", +"usage": "test_arg_0 grind [--safely]", +"description": "make smaller by many small cuts", +"flags": [{"short": "", "long": "--safely", "description": "wear a visor while grinding", "arg_name": "", "optionality": "optional"}, +{"short": "", "long": "--help", "description": "display usage information", "arg_name": "", "optionality": "optional"}], +"positional": [], +"examples": "", +"notes": "", +"error_codes": [], +"subcommands": [] +} +] +} +"###, + ); +} + +/* +{ +\"name\": \"test_arg_0\", +\"usage\": \"test_arg_0 [-f] [--really-really-really-long-name-for-pat] -s [-v] []\", +\"description\": \"Destroy the contents of with a specific \\\"method of destruction\\\".\", +\"flags\": [{\"short\": \"f\", \"long\": \"--force\", \"description\": \"force, ignore minor errors. This description is so long that it wraps to the next line.\", \"arg_name\": \"\", \"optionality\": \"optional\"}, +{\"short\": \"\", \"long\": \"--really-really-really-long-name-for-pat\", \"description\": \"documentation\", \"arg_name\": \"\", \"optionality\": \"optional\"}, +{\"short\": \"s\", \"long\": \"--scribble\", \"description\": \"write repeatedly\", \"arg_name\": \"scribble\", \"optionality\": \"required\"}, +{\"short\": \"v\", \"long\": \"--verbose\", \"description\": \"say more. Defaults to $BLAST_VERBOSE.\", \"arg_name\": \"\", \"optionality\": \"optional\"}, +{\"short\": \"\", \"long\": \"--help\", \"description\": \"display usage information\", \"arg_name\": \"\", \"optionality\": \"optional\"}] +,\n\"positional\": [], +\"examples\": \"Scribble 'abc' and then run |grind|.\\n$ test_arg_0 -s 'abc' grind old.txt taxes.cp\",\n\"notes\": \"Use `test_arg_0 help ` for details on [] for a subcommand.\", +\"error_codes\": [{\"name\": \"2\", \"description\": \"The blade is too dull.\", \"optionality\": \"\"},\n{\"name\": \"3\", \"description\": \"Out of fuel.\", \"optionality\": \"\"}], +\"subcommands\": [{\n\"name\": \"blow-up\",\n\"usage\": \"test_arg_0 blow-up [--safely]\", +\"description\": \"explosively separate\", +\"flags\": [{\"short\": \"\", \"long\": \"--safely\", \"description\": \"blow up bombs safely\", \"arg_name\": \"\", \"optionality\": \"optional\"}, +{\"short\": \"\", \"long\": \"--help\", \"description\": \"display usage information\", \"arg_name\": \"\", \"optionality\": \"optional\"}], +\"positional\": [], +\"examples\": \"\", +\"notes\": \"\", +\"error_codes\": [],\n\"subcommands\": []\n}\n,\n{ + \"name\": \"grind\",\n\"usage\": \"test_arg_0 grind [--safely]\",\n\"description\": \"make smaller by many small cuts\", + \"flags\": [{\"short\": \"\", \"long\": \"--safely\", \"description\": \"wear a visor while grinding\", \"arg_name\": \"\", \"optionality\": \"optional\"},\n{\"short\": \"\", \"long\": \"--help\", \"description\": \"display usage information\", \"arg_name\": \"\", \"optionality\": \"optional\"}], +\"positional\": [],\n\"examples\": \"\",\n\"notes\": \"\",\n\"error_codes\": [],\n\"subcommands\": []\n}\n]\n} + +*/ + +fn assert_help_json_string(args: Vec<&str>, help_str: &str) { + let mut command_args = vec!["test_arg_0"]; + command_args.extend_from_slice(&args); + let actual_value = T::help_json_from_args(&command_args) + .expect("unexpected error getting help_json_from_args"); + assert_eq!(help_str, actual_value) +} diff --git a/argh/tests/lib.rs b/argh/tests/lib.rs index e01e6d0..44d4eaf 100644 --- a/argh/tests/lib.rs +++ b/argh/tests/lib.rs @@ -13,6 +13,8 @@ use {argh::FromArgs, std::fmt::Debug}; +mod help_json_tests; + #[test] fn basic_example() { #[derive(FromArgs, PartialEq, Debug)] @@ -766,7 +768,7 @@ Options: #[derive(FromArgs, PartialEq, Debug)] #[argh( - description = "Destroy the contents of .", + description = "Destroy the contents of with a specific \"method of destruction\".", example = "Scribble 'abc' and then run |grind|.\n$ {command_name} -s 'abc' grind old.txt taxes.cp", note = "Use `{command_name} help ` for details on [] for a subcommand.", error_code(2, "The blade is too dull."), @@ -859,7 +861,7 @@ Options: assert_help_string::( r###"Usage: test_arg_0 [-f] [--really-really-really-long-name-for-pat] -s [-v] [] -Destroy the contents of . +Destroy the contents of with a specific "method of destruction". Options: -f, --force force, ignore minor errors. This description is so long that @@ -1352,18 +1354,30 @@ fn subcommand_does_not_panic() { argh::EarlyExit { output: "no subcommand name".into(), status: Err(()) }, ); + assert_eq!( + SubCommandEnum::help_json_from_args(&[]).unwrap_err(), + argh::EarlyExit { output: "no subcommand name".into(), status: Err(()) }, + ); + // Passing unknown subcommand name to an emum assert_eq!( SubCommandEnum::from_args(&["fooey"], &["5"]).unwrap_err(), - argh::EarlyExit { output: "no subcommand matched".into(), status: Err(()) }, + argh::EarlyExit { output: "no subcommand matched fooey".into(), status: Err(()) }, ); assert_eq!( SubCommandEnum::redact_arg_values(&["fooey"], &["5"]).unwrap_err(), - argh::EarlyExit { output: "no subcommand matched".into(), status: Err(()) }, + argh::EarlyExit { output: "no subcommand matched fooey".into(), status: Err(()) }, + ); + + assert_eq!( + SubCommandEnum::help_json_from_args(&["fooey"]).unwrap_err(), + argh::EarlyExit { output: "no subcommand matched fooey".into(), status: Err(()) }, ); // Passing unknown subcommand name to a struct + // Not testing from_args since it will assign 5 to x as a positional. + // Also not testing help_json_from_args since it is a valid to get help. assert_eq!( SubCommand::redact_arg_values(&[], &["5"]).unwrap_err(), argh::EarlyExit { output: "no subcommand name".into(), status: Err(()) }, diff --git a/argh_derive/src/help.rs b/argh_derive/src/help.rs index c295825..260925b 100644 --- a/argh_derive/src/help.rs +++ b/argh_derive/src/help.rs @@ -15,6 +15,10 @@ use { const SECTION_SEPARATOR: &str = "\n\n"; +// Define constants for strings used for both help formats. +pub(crate) const HELP_FLAG: &str = "--help"; +pub(crate) const HELP_DESCRIPTION: &str = "display usage information"; + /// Returns a `TokenStream` generating a `String` help message. /// /// Note: `fields` entries with `is_subcommand.is_some()` will be ignored @@ -28,38 +32,16 @@ pub(crate) fn help( ) -> TokenStream { let mut format_lit = "Usage: {command_name}".to_string(); - let positional = fields.iter().filter(|f| f.kind == FieldKind::Positional); - let mut has_positional = false; - for arg in positional.clone() { - has_positional = true; - format_lit.push(' '); - positional_usage(&mut format_lit, arg); - } - - let options = fields.iter().filter(|f| f.long_name.is_some()); - for option in options.clone() { - format_lit.push(' '); - option_usage(&mut format_lit, option); - } - - if let Some(subcommand) = subcommand { - format_lit.push(' '); - if !subcommand.optionality.is_required() { - format_lit.push('['); - } - format_lit.push_str(""); - if !subcommand.optionality.is_required() { - format_lit.push(']'); - } - format_lit.push_str(" []"); - } + build_usage_command_line(&mut format_lit, fields, subcommand); format_lit.push_str(SECTION_SEPARATOR); let description = require_description(errors, Span::call_site(), &ty_attrs.description, "type"); format_lit.push_str(&description); - if has_positional { + let mut positional = fields.iter().filter(|f| f.kind == FieldKind::Positional).peekable(); + + if positional.peek().is_some() { format_lit.push_str(SECTION_SEPARATOR); format_lit.push_str("Positional Arguments:"); for arg in positional { @@ -69,11 +51,12 @@ pub(crate) fn help( format_lit.push_str(SECTION_SEPARATOR); format_lit.push_str("Options:"); + let options = fields.iter().filter(|f| f.long_name.is_some()); for option in options { option_description(errors, &mut format_lit, option); } // Also include "help" - option_description_format(&mut format_lit, None, "--help", "display usage information"); + option_description_format(&mut format_lit, None, HELP_FLAG, HELP_DESCRIPTION); let subcommand_calculation; let subcommand_format_arg; @@ -252,3 +235,34 @@ fn option_description_format( let info = argh_shared::CommandInfo { name: &*name, description }; argh_shared::write_description(out, &info); } + +/// Builds the usage description command line and appends it to "out". +pub(crate) fn build_usage_command_line( + out: &mut String, + fields: &[StructField<'_>], + subcommand: Option<&StructField<'_>>, +) { + let positional = fields.iter().filter(|f| f.kind == FieldKind::Positional); + for arg in positional.clone() { + out.push(' '); + positional_usage(out, arg); + } + + let options = fields.iter().filter(|f| f.long_name.is_some()); + for option in options.clone() { + out.push(' '); + option_usage(out, option); + } + + if let Some(subcommand) = subcommand { + out.push(' '); + if !subcommand.optionality.is_required() { + out.push('['); + } + out.push_str(""); + if !subcommand.optionality.is_required() { + out.push(']'); + } + out.push_str(" []"); + } +} diff --git a/argh_derive/src/help_json.rs b/argh_derive/src/help_json.rs new file mode 100644 index 0000000..8a11ef5 --- /dev/null +++ b/argh_derive/src/help_json.rs @@ -0,0 +1,287 @@ +// Copyright (c) 2020 Google LLC All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +use { + crate::{ + errors::Errors, + help::{build_usage_command_line, require_description, HELP_DESCRIPTION, HELP_FLAG}, + parse_attrs::{FieldKind, TypeAttrs}, + Optionality, StructField, + }, + proc_macro2::{Span, TokenStream}, + quote::quote, +}; + +struct OptionHelp { + short: String, + long: String, + description: String, + arg_name: String, + optionality: String, +} + +struct PositionalHelp { + name: String, + description: String, + optionality: String, +} +struct HelpJSON { + usage: String, + description: String, + positional_args: Vec, + options: Vec, + examples: String, + notes: String, + error_codes: Vec, +} + +fn option_elements_json(options: &[OptionHelp]) -> String { + let mut retval = String::from(""); + for opt in options { + if !retval.is_empty() { + retval.push_str(",\n"); + } + retval.push_str(&format!( + "{{\"short\": \"{}\", \"long\": \"{}\", \"description\": \"{}\", \"arg_name\": \"{}\", \"optionality\": \"{}\"}}", + opt.short, + opt.long, + escape_json(&opt.description), + opt.arg_name, + escape_json(&opt.optionality) + )); + } + retval +} +fn help_elements_json(elements: &[PositionalHelp], skip_optionality: bool) -> String { + let mut retval = String::from(""); + for pos in elements { + if !retval.is_empty() { + retval.push_str(",\n"); + } + retval.push_str(&format!( + "{{\"name\": \"{}\", \"description\": \"{}\"", + pos.name, + escape_json(&pos.description), + )); + if !skip_optionality { + retval.push_str(&format!(", \"optionality\": \"{}\"", escape_json(&pos.optionality))); + } + retval.push('}'); + } + retval +} + +/// Returns a `TokenStream` generating a `String` help message containing JSON. +/// +/// Note: `fields` entries with `is_subcommand.is_some()` will be ignored +/// in favor of the `subcommand` argument. +pub(crate) fn help_json( + errors: &Errors, + cmd_name_str_array_ident: &syn::Ident, + ty_attrs: &TypeAttrs, + fields: &[StructField<'_>], +) -> TokenStream { + let mut usage_format_pattern = "{command_name}".to_string(); + + let mut subcommands_iter = + fields.iter().filter(|field| field.kind == FieldKind::SubCommand).fuse(); + let subcommand: Option<&StructField<'_>> = subcommands_iter.next(); + + build_usage_command_line(&mut usage_format_pattern, fields, subcommand); + + let mut help_obj = HelpJSON { + usage: String::from(""), + description: String::from(""), + positional_args: vec![], + options: vec![], + examples: String::from(""), + notes: String::from(""), + error_codes: vec![], + }; + + // Add positional args to the help object. + let positional = fields.iter().filter(|f| f.kind == FieldKind::Positional); + for arg in positional { + let mut description = String::from(""); + if let Some(desc) = &arg.attrs.description { + description = desc.content.value().trim().to_owned(); + } + let optionality = match &arg.optionality { + Optionality::None => String::from("required"), + Optionality::Optional => String::from("optional"), + Optionality::Repeating => String::from("repeating"), + Optionality::Defaulted(ts) => ts.to_string(), + }; + help_obj.positional_args.push(PositionalHelp { + name: arg.arg_name(), + description, + optionality, + }); + } + + // Add options to the help object. + let options = fields.iter().filter(|f| f.long_name.is_some()); + for option in options { + let field_kind = match &option.attrs.field_type { + Some(field_type) => field_type.kind, + _ => unreachable!("Field type not set"), + }; + let short = match option.attrs.short.as_ref().map(|s| s.value()) { + Some(c) => String::from(c), + None => String::from(""), + }; + let long_with_leading_dashes = + option.long_name.as_ref().expect("missing long name for option"); + let description = + require_description(errors, option.name.span(), &option.attrs.description, "field"); + + let arg_name = match field_kind { + FieldKind::Option | FieldKind::Positional => match &option.attrs.arg_name { + Some(_) => option.arg_name(), + // None if field_kind != FieldKind::Switch => option.name.to_string(), + None => option.name.to_string(), + }, + FieldKind::Switch | FieldKind::SubCommand => String::from(""), + }; + + let optionality = match &option.optionality { + Optionality::None => String::from("required"), + Optionality::Optional => String::from("optional"), + Optionality::Repeating => String::from("repeating"), + Optionality::Defaulted(ts) => ts.to_string(), + }; + help_obj.options.push(OptionHelp { + short, + long: long_with_leading_dashes.to_owned(), + description, + arg_name, + optionality, + }); + } + // Also include "help" + help_obj.options.push(OptionHelp { + short: String::from(""), + long: String::from(HELP_FLAG), + description: String::from(HELP_DESCRIPTION), + arg_name: String::from(""), + optionality: String::from("optional"), + }); + + let subcommand_calculation; + if let Some(subcommand) = subcommand { + let subcommand_ty = subcommand.ty_without_wrapper; + subcommand_calculation = quote! { + let mut subcommands = String::from(""); + for cmd in <#subcommand_ty as argh::SubCommands>::COMMANDS { + if !subcommands.is_empty() { + subcommands.push_str(",\n"); + } + let mut command = __cmd_name.to_owned(); + command.push(cmd.name); + subcommands.push_str(&<#subcommand_ty as argh::FromArgs>::help_json_from_args(&command)?); + } + }; + } else { + subcommand_calculation = quote! { + let subcommands = String::from(""); + }; + } + + help_obj.usage = usage_format_pattern.clone(); + + help_obj.description = + require_description(errors, Span::call_site(), &ty_attrs.description, "type"); + + let mut example: String = String::from(""); + for lit in &ty_attrs.examples { + example.push_str(&lit.value()); + } + help_obj.examples = example; + + let mut note: String = String::from(""); + for lit in &ty_attrs.notes { + note.push_str(&lit.value()); + } + help_obj.notes = note; + + if !ty_attrs.error_codes.is_empty() { + for (code, text) in &ty_attrs.error_codes { + help_obj.error_codes.push(PositionalHelp { + name: code.to_string(), + description: text.value().to_string(), + optionality: String::from(""), + }); + } + } + + let help_options_json = option_elements_json(&help_obj.options); + let help_positional_json = help_elements_json(&help_obj.positional_args, false); + let help_error_codes_json = help_elements_json(&help_obj.error_codes, true); + + let help_description = escape_json(&help_obj.description); + let help_examples: TokenStream; + let help_notes: TokenStream; + + let notes_pattern = escape_json(&help_obj.notes); + // check if we need to interpolate the string. + if notes_pattern.contains("{command_name}") { + help_notes = quote! { + json_help_string.push_str(&format!(#notes_pattern,command_name = #cmd_name_str_array_ident.join(" "))); + }; + } else { + help_notes = quote! { + json_help_string.push_str(#notes_pattern); + }; + } + let examples_pattern = escape_json(&help_obj.examples); + if examples_pattern.contains("{command_name}") { + help_examples = quote! { + json_help_string.push_str(&format!(#examples_pattern,command_name = #cmd_name_str_array_ident.join(" "))); + }; + } else { + help_examples = quote! { + json_help_string.push_str(#examples_pattern); + }; + } + + let name_string: TokenStream; + if let Some(name) = &ty_attrs.name { + name_string = quote! { json_help_string.push_str(#name);}; + } else { + name_string = quote! { + json_help_string.push_str(&#cmd_name_str_array_ident.join(" ")); + }; + } + + quote! {{ + #subcommand_calculation + + // Build up the string for json. The name of the command needs to be dereferenced, so it + // can't be done in the macro. + let mut json_help_string = "{\n".to_string(); + json_help_string.push_str("\"name\": \""); + #name_string; + json_help_string.push_str("\",\n"); + let usage_value = format!(#usage_format_pattern,command_name = #cmd_name_str_array_ident.join(" ")); + json_help_string.push_str(&format!("\"usage\": \"{}\",\n",usage_value)); + json_help_string.push_str(&format!("\"description\": \"{}\",\n", #help_description)); + json_help_string.push_str(&format!("\"flags\": [{}],\n", #help_options_json)); + json_help_string.push_str(&format!("\"positional\": [{}],\n", #help_positional_json)); + json_help_string.push_str("\"examples\": \""); + #help_examples; + json_help_string.push_str("\",\n"); + json_help_string.push_str("\"notes\": \""); + #help_notes; + json_help_string.push_str("\",\n"); + json_help_string.push_str(&format!("\"error_codes\": [{}],\n", #help_error_codes_json)); + json_help_string.push_str(&format!("\"subcommands\": [{}]\n", subcommands)); + json_help_string.push_str("}\n"); + json_help_string + }} +} + +/// Escape characters in strings to be JSON compatible. +fn escape_json(value: &str) -> String { + value.replace("\n", r#"\n"#).replace("\"", r#"\""#) +} diff --git a/argh_derive/src/lib.rs b/argh_derive/src/lib.rs index ab72a40..7602814 100644 --- a/argh_derive/src/lib.rs +++ b/argh_derive/src/lib.rs @@ -21,6 +21,7 @@ use { mod errors; mod help; +mod help_json; mod parse_attrs; /// Entrypoint for `#[derive(FromArgs)]`. @@ -194,7 +195,7 @@ impl<'a> StructField<'a> { FieldKind::SubCommand | FieldKind::Positional => None, }; - Some(StructField { field, attrs, kind, optionality, ty_without_wrapper, name, long_name }) + Some(StructField { field, attrs, name, kind, ty_without_wrapper, optionality, long_name }) } pub(crate) fn arg_name(&self) -> String { @@ -242,6 +243,8 @@ fn impl_from_args_struct( let redact_arg_values_method = impl_from_args_struct_redact_arg_values(errors, type_attrs, &fields); + let json_help_method = impl_help_json(errors, type_attrs, &fields); + let top_or_sub_cmd_impl = top_or_sub_cmd_impl(errors, name, type_attrs); let trait_impl = quote_spanned! { impl_span => @@ -250,6 +253,8 @@ fn impl_from_args_struct( #from_args_method #redact_arg_values_method + + #json_help_method } #top_or_sub_cmd_impl @@ -273,7 +278,6 @@ fn impl_from_args_struct_from_args<'a>( .last() .map(|field| field.optionality == Optionality::Repeating) .unwrap_or(false); - let flag_output_table = fields.iter().filter_map(|field| { let field_name = &field.field.ident; match field.kind { @@ -325,7 +329,6 @@ fn impl_from_args_struct_from_args<'a>( -> std::result::Result { #![allow(clippy::unwrap_in_result)] - #( #init_fields )* argh::parse_struct_args( @@ -483,6 +486,28 @@ fn impl_from_args_struct_redact_arg_values<'a>( method_impl } +fn impl_help_json<'a>( + errors: &Errors, + type_attrs: &TypeAttrs, + fields: &'a [StructField<'a>], +) -> TokenStream { + let impl_span = Span::call_site(); + + // Identifier referring to a value containing the name of the current command as an `&[&str]`. + let cmd_name_str_array_ident = syn::Ident::new("__cmd_name", impl_span); + let help_json = help_json::help_json(errors, &cmd_name_str_array_ident, type_attrs, fields); + + let method_impl = quote_spanned! { impl_span => + fn help_json_from_args(__cmd_name: &[&str]) + -> Result + { + Ok(String::from(#help_json)) + } + }; + + method_impl +} + /// Ensures that only the last positional arg is non-required. fn ensure_only_last_positional_is_optional(errors: &Errors, fields: &[StructField<'_>]) { let mut first_non_required_span = None; @@ -879,7 +904,7 @@ fn impl_from_args_enum( } )* - Err(argh::EarlyExit::from("no subcommand matched".to_owned())) + Err(argh::EarlyExit::from(format!("no subcommand matched {}",subcommand_name).to_owned())) } fn redact_arg_values(command_name: &[&str], args: &[&str]) -> std::result::Result, argh::EarlyExit> { @@ -895,8 +920,25 @@ fn impl_from_args_enum( } )* - Err(argh::EarlyExit::from("no subcommand matched".to_owned())) + Err(argh::EarlyExit::from(format!("no subcommand matched {}",subcommand_name).to_owned())) } + + fn help_json_from_args(command_name: &[&str]) -> std::result::Result + { + let subcommand_name = if let Some(subcommand_name) = command_name.last() { + *subcommand_name + } else { + return Err(argh::EarlyExit::from("no subcommand name".to_owned())); + }; + + #( + if subcommand_name == <#variant_ty as argh::SubCommand>::COMMAND.name { + return #variant_ty::help_json_from_args(command_name) + ; + } + )* + Err(argh::EarlyExit::from(format!("no subcommand matched {}",subcommand_name).to_owned())) + } } impl argh::SubCommands for #name {