diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index ce33d0c6..83b9b199 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -41,7 +41,7 @@ jobs: test-full: name: Tests (Full) env: - FLAGS: --features 'wrap_help yaml regex unstable-replace' + FLAGS: --features 'wrap_help yaml regex unstable-replace unstable-multicall' strategy: fail-fast: false matrix: @@ -80,7 +80,7 @@ jobs: - name: Default features run: cargo check --all-targets - name: All features + Debug - run: cargo check --all-targets --features "wrap_help yaml regex unstable-replace debug" + run: cargo check --all-targets --features "wrap_help yaml regex unstable-replace unstable-multicall debug" - name: No features run: cargo check --all-targets --no-default-features --features "std cargo" - name: UI Tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08b8df7e..c3d5f8a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,19 +97,19 @@ jobs: if: matrix.features == 'all' with: command: test - args: --target ${{ matrix.target }} --features "wrap_help yaml regex unstable-replace" + args: --target ${{ matrix.target }} --features "wrap_help yaml regex unstable-replace unstable-multicall" - name: Check debug uses: actions-rs/cargo@v1 if: matrix.features == 'all' with: command: check - args: --target ${{ matrix.target }} --features "wrap_help yaml regex unstable-replace debug" + args: --target ${{ matrix.target }} --features "wrap_help yaml regex unstable-replace unstable-multicall debug" - name: Test release uses: actions-rs/cargo@v1 if: matrix.features == 'release' with: command: test - args: --target ${{ matrix.target }} --features "wrap_help yaml regex unstable-replace" --release + args: --target ${{ matrix.target }} --features "wrap_help yaml regex unstable-replace unstable-multicall" --release nightly: name: Nightly Tests strategy: @@ -139,19 +139,19 @@ jobs: if: matrix.features == 'all' with: command: test - args: --features "wrap_help yaml regex unstable-replace" + args: --features "wrap_help yaml regex unstable-replace unstable-multicall" - name: Check debug uses: actions-rs/cargo@v1 if: matrix.features == 'all' with: command: check - args: --features "wrap_help yaml regex unstable-replace debug" + args: --features "wrap_help yaml regex unstable-replace unstable-multicall debug" - name: Test release uses: actions-rs/cargo@v1 if: matrix.features == 'release' with: command: test - args: --features "wrap_help yaml regex unstable-replace" --release + args: --features "wrap_help yaml regex unstable-replace unstable-multicall" --release wasm: name: Wasm Check runs-on: ubuntu-latest @@ -172,4 +172,4 @@ jobs: uses: actions-rs/cargo@v1 with: command: check - args: --target ${{ matrix.target }} --features "yaml regex unstable-replace" + args: --target ${{ matrix.target }} --features "yaml regex unstable-replace unstable-multicall" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6f68c286..eabb04ba 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -31,7 +31,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: llvm-cov - args: --features "wrap_help yaml regex unstable-replace" --lcov --output-path lcov.info + args: --features "wrap_help yaml regex unstable-replace unstable-multicall" --lcov --output-path lcov.info - name: Coveralls uses: coverallsapp/github-action@master with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6d8490d2..c0df22a6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,7 +32,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: clippy - args: --features "wrap_help yaml regex unstable-replace" -- -D warnings + args: --features "wrap_help yaml regex unstable-replace unstable-multicall" -- -D warnings - name: Format check uses: actions-rs/cargo@v1 with: diff --git a/Cargo.toml b/Cargo.toml index cf9b9a37..86993654 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,14 @@ lazy_static = "1" version-sync = "0.9" criterion = "0.3.2" +[[example]] +name = "busybox" +path = "examples/24a_multicall_busybox.rs" + +[[example]] +name = "hostname" +path = "examples/24b_multicall_hostname.rs" + [features] default = [ "std", @@ -108,6 +116,7 @@ yaml = ["yaml-rust"] # In-work features unstable-replace = [] +unstable-multicall = [] [profile.test] opt-level = 1 @@ -117,7 +126,7 @@ lto = true codegen-units = 1 [package.metadata.docs.rs] -features = ["yaml", "regex", "unstable-replace"] +features = ["yaml", "regex", "unstable-replace", "unstable-multicall"] targets = ["x86_64-unknown-linux-gnu"] [workspace] diff --git a/README.md b/README.md index 0c96f50a..c19124bb 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,7 @@ features = ["std", "suggestions", "color"] These features are opt-in. But be wary that they can contain breaking changes between minor releases. * **unstable-replace**: Enable [`App::replace`](https://github.com/clap-rs/clap/issues/2836) +* **unstable-multicall**: Enable [`AppSettings::Multicall`](https://github.com/clap-rs/clap/issues/2861) ### More Information diff --git a/examples/24a_multicall_busybox.rs b/examples/24a_multicall_busybox.rs new file mode 100644 index 00000000..19e10a66 --- /dev/null +++ b/examples/24a_multicall_busybox.rs @@ -0,0 +1,41 @@ +//! Example of a `busybox-style` multicall program +//! +//! See the documentation for clap::AppSettings::Multicall for rationale. +//! +//! This example omits every command except true and false, +//! which are the most trivial to implement, +//! but includes the `--install` option as an example of why it can be useful +//! for the main program to take arguments that aren't applet subcommands. + +use std::process::exit; + +use clap::{App, AppSettings, Arg}; + +fn main() { + let app = App::new(env!("CARGO_CRATE_NAME")) + .setting(AppSettings::ArgRequiredElseHelp) + .arg( + Arg::new("install") + .long("install") + .about("Install hardlinks for all subcommands in path") + .exclusive(true) + .takes_value(true) + .default_missing_value("/usr/local/bin") + .use_delimiter(false), + ) + .subcommand(App::new("true").about("does nothing successfully")) + .subcommand(App::new("false").about("does nothing unsuccessfully")); + + #[cfg(feature = "unstable-multicall")] + let app = app.setting(AppSettings::Multicall); + let matches = app.get_matches(); + if matches.occurrences_of("install") > 0 { + unimplemented!("Make hardlinks to the executable here"); + } + + exit(match matches.subcommand_name() { + Some("true") => 0, + Some("false") => 1, + _ => 127, + }) +} diff --git a/examples/24b_multicall_hostname.rs b/examples/24b_multicall_hostname.rs new file mode 100644 index 00000000..44476440 --- /dev/null +++ b/examples/24b_multicall_hostname.rs @@ -0,0 +1,25 @@ +//! Example of a `hostname-style` multicall program +//! +//! See the documentation for clap::AppSettings::Multicall for rationale. +//! +//! This example omits the implementation of displaying address config + +use std::process::exit; + +use clap::{App, AppSettings}; + +fn main() { + let app = App::new(env!("CARGO_CRATE_NAME")) + .setting(AppSettings::ArgRequiredElseHelp) + .subcommand(App::new("hostname").about("shot hostname part of FQDN")) + .subcommand(App::new("dnsdomainname").about("show domain name part of FQDN")); + + #[cfg(feature = "unstable-multicall")] + let app = app.setting(AppSettings::Multicall); + + match app.get_matches().subcommand_name() { + Some("hostname") => println!("www"), + Some("dnsdomainname") => println!("example.com"), + _ => exit(127), + } +} diff --git a/src/build/app/debug_asserts.rs b/src/build/app/debug_asserts.rs index dbe5dcab..5d15ee20 100644 --- a/src/build/app/debug_asserts.rs +++ b/src/build/app/debug_asserts.rs @@ -358,8 +358,25 @@ fn assert_app_flags(app: &App) { panic!("{}", s) } } - } + }; + ($a:ident conflicts $($b:ident)|+) => { + if app.is_set($a) { + let mut s = String::new(); + + $( + if app.is_set($b) { + s.push_str(&format!("\nAppSettings::{} conflicts with AppSettings::{}.\n", std::stringify!($b), std::stringify!($a))); + } + )+ + + if !s.is_empty() { + panic!("{}", s) + } + } + }; } checker!(AllowInvalidUtf8ForExternalSubcommands requires AllowExternalSubcommands); + #[cfg(feature = "unstable-multicall")] + checker!(Multicall conflicts NoBinaryName); } diff --git a/src/build/app/mod.rs b/src/build/app/mod.rs index 0284579e..a6e0266e 100644 --- a/src/build/app/mod.rs +++ b/src/build/app/mod.rs @@ -1954,6 +1954,7 @@ impl<'help> App<'help> { /// .get_matches(); /// ``` /// [`env::args_os`]: std::env::args_os() + /// [`App::try_get_matches_from_mut`]: App::try_get_matches_from_mut() #[inline] pub fn get_matches(self) -> ArgMatches { self.get_matches_from(&mut env::args_os()) @@ -2136,6 +2137,55 @@ impl<'help> App<'help> { T: Into + Clone, { let mut it = Input::from(itr.into_iter()); + + #[cfg(feature = "unstable-multicall")] + if self.settings.is_set(AppSettings::Multicall) { + if let Some((argv0, _)) = it.next() { + let argv0 = Path::new(&argv0); + if let Some(command) = argv0.file_name().and_then(|f| f.to_str()) { + // Stop borrowing command so we can get another mut ref to it. + let command = command.to_owned(); + debug!( + "App::try_get_matches_from_mut: Parsed command {} from argv", + command + ); + + let subcommand = self + .subcommands + .iter_mut() + .find(|subcommand| subcommand.aliases_to(&command)); + debug!( + "App::try_get_matches_from_mut: Matched subcommand {:?}", + subcommand + ); + + match subcommand { + None if command == self.name => { + debug!("App::try_get_matches_from_mut: no existing applet but matches name"); + debug!( + "App::try_get_matches_from_mut: Setting bin_name to command name" + ); + self.bin_name.get_or_insert(command); + debug!( + "App::try_get_matches_from_mut: Continuing with top-level parser." + ); + return self._do_parse(&mut it); + } + _ => { + debug!( + "App::try_get_matches_from_mut: existing applet or no program name" + ); + debug!("App::try_get_matches_from_mut: Reinserting command into arguments so subcommand parser matches it"); + it.insert(&[&command]); + debug!("App::try_get_matches_from_mut: Clearing name and bin_name so that displayed command name starts with applet name"); + self.name.clear(); + self.bin_name = None; + return self._do_parse(&mut it); + } + }; + } + } + }; // Get the name of the program (argument 1 of env::args()) and determine the // actual file // that was used to execute the program. This is because a program called diff --git a/src/build/app/settings.rs b/src/build/app/settings.rs index 8defdfb8..383605a1 100644 --- a/src/build/app/settings.rs +++ b/src/build/app/settings.rs @@ -45,6 +45,8 @@ bitflags! { const USE_LONG_FORMAT_FOR_HELP_SC = 1 << 42; const INFER_LONG_ARGS = 1 << 43; const IGNORE_ERRORS = 1 << 44; + #[cfg(feature = "unstable-multicall")] + const MULTICALL = 1 << 45; } } @@ -95,6 +97,9 @@ impl_settings! { AppSettings, AppFlags, => Flags::HELP_REQUIRED, Hidden("hidden") => Flags::HIDDEN, + #[cfg(feature = "unstable-multicall")] + Multicall("multicall") + => Flags::MULTICALL, NoAutoHelp("noautohelp") => Flags::NO_AUTO_HELP, NoAutoVersion("noautoversion") @@ -577,6 +582,89 @@ pub enum AppSettings { /// [`subcommands`]: crate::App::subcommand() DeriveDisplayOrder, + /// Parse the bin name (argv[0]) as a subcommand + /// + /// This adds a small performance penalty to startup + /// as it requires comparing the bin name against every subcommand name. + /// + /// A "multicall" executable is a single executable + /// that contains a variety of applets, + /// and decides which applet to run based on the name of the file. + /// The executable can be called from different names by creating hard links + /// or symbolic links to it. + /// + /// This is desirable when it is convenient to store code + /// for many programs in the same file, + /// such as deduplicating code across multiple programs + /// without loading a shared library at runtime. + /// + /// Multicall can't be used with [`NoBinaryName`] since they interpret + /// the command name in incompatible ways. + /// + /// # Examples + /// + /// Multicall applets are defined as subcommands + /// to an app which has the Multicall setting enabled. + /// + /// Busybox is a common example of a "multicall" executable + /// with a subcommmand for each applet that can be run directly, + /// e.g. with the `cat` applet being run by running `busybox cat`, + /// or with `cat` as a link to the `busybox` binary. + /// + /// This is desirable when the launcher program has additional options + /// or it is useful to run the applet without installing a symlink + /// e.g. to test the applet without installing it + /// or there may already be a command of that name installed. + /// + /// ```rust + /// # use clap::{App, AppSettings}; + /// let mut app = App::new("busybox") + /// .setting(AppSettings::Multicall) + /// .subcommand(App::new("true")) + /// .subcommand(App::new("false")); + /// // When called from the executable's canonical name + /// // its applets can be matched as subcommands. + /// let m = app.try_get_matches_from_mut(&["busybox", "true"]).unwrap(); + /// assert_eq!(m.subcommand_name(), Some("true")); + /// // When called from a link named after an applet that applet is matched. + /// let m = app.get_matches_from(&["true"]); + /// assert_eq!(m.subcommand_name(), Some("true")); + /// ``` + /// + /// `hostname` is another example of a multicall executable. + /// It differs from busybox by not supporting running applets via subcommand + /// and is instead only runnable via links. + /// + /// This is desirable when the executable has a primary purpose + /// rather than being a collection of varied applets, + /// so it is appropriate to name the executable after its purpose, + /// but there is other related functionality that would be convenient to provide + /// and it is convenient for the code to implement it to be in the same executable. + /// + /// This behaviour can be opted-into + /// by naming a subcommand with the same as the program + /// as applet names take priority. + /// + /// ```rust + /// # use clap::{App, AppSettings, ErrorKind}; + /// let mut app = App::new("hostname") + /// .setting(AppSettings::Multicall) + /// .subcommand(App::new("hostname")) + /// .subcommand(App::new("dnsdomainname")); + /// let m = app.try_get_matches_from_mut(&["hostname", "dnsdomainname"]); + /// assert!(m.is_err()); + /// assert_eq!(m.unwrap_err().kind, ErrorKind::UnknownArgument); + /// let m = app.get_matches_from(&["hostname"]); + /// assert_eq!(m.subcommand_name(), Some("hostname")); + /// ``` + /// + /// [`subcommands`]: crate::App::subcommand() + /// [`panic!`]: https://doc.rust-lang.org/std/macro.panic!.html + /// [`NoBinaryName`]: crate::AppSettings::NoBinaryName + /// [`try_get_matches_from_mut`]: crate::App::try_get_matches_from_mut() + #[cfg(feature = "unstable-multicall")] + Multicall, + /// Specifies to use the version of the current command for all [`subcommands`]. /// /// Defaults to `false`; subcommands have independent version strings from their parents. @@ -742,6 +830,7 @@ pub enum AppSettings { /// let cmds: Vec<&str> = m.values_of("cmd").unwrap().collect(); /// assert_eq!(cmds, ["command", "set"]); /// ``` + /// [`try_get_matches_from_mut`]: crate::App::try_get_matches_from_mut() NoBinaryName, /// Places the help string for all arguments on the line after the argument. diff --git a/tests/examples.rs b/tests/examples.rs index ab6e2d7d..15863f92 100644 --- a/tests/examples.rs +++ b/tests/examples.rs @@ -32,10 +32,24 @@ fn examples_are_functional() { for path in example_paths { example_count += 1; - let example_name = path - .file_stem() - .and_then(OsStr::to_str) - .expect("unable to determine example name"); + let example_name = match path.file_name().and_then(OsStr::to_str) { + Some("24a_multicall_busybox.rs") => { + #[cfg(not(feature = "unstable-multicall"))] + continue; + #[allow(unreachable_code)] + "busybox".into() + } + Some("24b_multicall_hostname.rs") => { + #[cfg(not(feature = "unstable-multicall"))] + continue; + #[allow(unreachable_code)] + "hostname".into() + } + _ => path + .file_stem() + .and_then(OsStr::to_str) + .expect("unable to determine example name"), + }; let help_output = run_example(example_name, &["--help"]); assert!( diff --git a/tests/subcommands.rs b/tests/subcommands.rs index 1f3220df..75822917 100644 --- a/tests/subcommands.rs +++ b/tests/subcommands.rs @@ -504,3 +504,49 @@ For more information try --help true )); } + +#[cfg(feature = "unstable-multicall")] +#[test] +fn busybox_like_multicall() { + let app = App::new("busybox") + .setting(AppSettings::Multicall) + .subcommand(App::new("true")) + .subcommand(App::new("false")); + + let m = app.clone().get_matches_from(&["busybox", "true"]); + assert_eq!(m.subcommand_name(), Some("true")); + + let m = app.clone().get_matches_from(&["true"]); + assert_eq!(m.subcommand_name(), Some("true")); + + let m = app.clone().try_get_matches_from(&["a.out"]); + assert!(m.is_err()); + assert_eq!(m.unwrap_err().kind, ErrorKind::UnknownArgument); +} + +#[cfg(feature = "unstable-multicall")] +#[test] +fn hostname_like_multicall() { + let mut app = App::new("hostname") + .setting(AppSettings::Multicall) + .subcommand(App::new("hostname")) + .subcommand(App::new("dnsdomainname")); + + let m = app.clone().get_matches_from(&["hostname"]); + assert_eq!(m.subcommand_name(), Some("hostname")); + + let m = app.clone().get_matches_from(&["dnsdomainname"]); + assert_eq!(m.subcommand_name(), Some("dnsdomainname")); + + let m = app.clone().try_get_matches_from(&["a.out"]); + assert!(m.is_err()); + assert_eq!(m.unwrap_err().kind, ErrorKind::UnknownArgument); + + let m = app.try_get_matches_from_mut(&["hostname", "hostname"]); + assert!(m.is_err()); + assert_eq!(m.unwrap_err().kind, ErrorKind::UnknownArgument); + + let m = app.try_get_matches_from(&["hostname", "dnsdomainname"]); + assert!(m.is_err()); + assert_eq!(m.unwrap_err().kind, ErrorKind::UnknownArgument); +}