diff --git a/argv/src/spec.rs b/argv/src/spec.rs index e12981c60..770c8ccd6 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -611,6 +611,11 @@ pub struct FlagMeta<'a> { /// flag win, this reports it: the combination has no meaning, so honouring one /// side silently would hide a mistake. pub conflicts: &'a [&'a str], + /// Whether this flag must be given on its own. + /// + /// The whole-command form of [`conflicts`](Self::conflicts): everything the command + /// declares counts, positionals included. + pub exclusive: bool, /// Flags that must also be given when this one is. /// /// The positive form of [`conflicts`](Self::conflicts), and the mirror image of @@ -648,6 +653,7 @@ impl FlagMeta<'_> { var_max: None, overrides: &[], conflicts: &[], + exclusive: false, requires: &[], required_if: &[], required_unless: &[], @@ -1151,6 +1157,9 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: write_single_default(out, meta.default)?; write_single_list(out, "overrides", meta.overrides)?; write_single_list(out, "conflicts", meta.conflicts)?; + if meta.exclusive { + out.push_str(" exclusive=#true"); + } write_single_list(out, "requires", meta.requires)?; write_single_list(out, "required_if", meta.required_if)?; write_single_list(out, "required_unless", meta.required_unless)?; @@ -1535,6 +1544,43 @@ pub trait CommandArgs: Sized { Vec::new() } + /// One declaration in this command that ended up being given, if any. + /// + /// Used to enforce relationships across a flattened `CommandArgs` boundary, where the + /// parent can see the nested partial only through this trait. + fn any_given(partial: &Self::Partial) -> Option<&'static str> { + let _ = partial; + None + } + + /// One exclusive flag in this command that was given, if any. + /// + /// Like [`CommandArgs::any_given`], this is the composition point for flattened argument + /// groups and selected subcommands. Parents need the latter to apply whole-invocation + /// exclusivity and its requiredness escape across a command boundary. + fn exclusive_given(partial: &Self::Partial) -> Option<&'static str> { + let _ = partial; + None + } + + /// Fill fields in this command from their declared defaults. + /// + /// Kept separate from [`CommandArgs::check`] so a parent can preserve defaults in a + /// flattened argument group while an unrelated exclusive flag suppresses only that + /// group's missing-value checks. + fn apply_defaults(partial: &mut Self::Partial) { + let _ = partial; + } + + /// Fill fields in this command from their declared environment variables. + /// + /// A parent calls this before relationships that cross a flattened `CommandArgs` + /// boundary, so those relationships see the same values as the nested command's own + /// checks. Empty by default for hand-written implementations. + fn apply_env(partial: &mut Self::Partial) { + let _ = partial; + } + /// Everything this command decides after the last token: required-ness, /// choices, and how many values a variadic got. /// @@ -1621,6 +1667,29 @@ pub trait Subcommands: Sized { Vec::new() } + /// One declaration in the selected command that was given, if any. + fn any_given(partial: &Self::Partial, selected: Option) -> Option<&'static str> { + let _ = (partial, selected); + None + } + + /// One exclusive flag in the selected command that was given, if any. + /// + /// This lets the parent compare its own fields with the selected child's without + /// exposing the child's generated partial type. + fn exclusive_given(partial: &Self::Partial, selected: Option) -> Option<&'static str> { + let _ = (partial, selected); + None + } + + /// Fill fields in the selected command from their declared environment variables. + /// + /// A parent calls this before relationships that cross the subcommand boundary, just as + /// [`CommandArgs::apply_env`] prepares a flattened argument group. + fn apply_env(partial: &mut Self::Partial, selected: Option) { + let _ = (partial, selected); + } + /// Check the selected command's requirements, and nothing else's. /// /// A flag that `install` requires says nothing about an invocation that ran diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 6714d19cf..ad532c40b 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -307,6 +307,7 @@ fn flag_meta( overrides: strs(&f.overrides), conflicts: strs(&f.conflicts), requires: strs(&f.requires), + exclusive: f.exclusive, required_if: strs(&f.required_if), required_unless: strs(&f.required_unless), help_heading: opt(&f.help_heading), diff --git a/conformance/tests/post_binding.rs b/conformance/tests/post_binding.rs index 3532cdafd..880a386ea 100644 --- a/conformance/tests/post_binding.rs +++ b/conformance/tests/post_binding.rs @@ -796,3 +796,320 @@ fn a_conflict_answers_before_an_unsatisfied_group_does() { assert_eq!(two.file.as_deref(), Some("f")); assert!(two.url.is_none() && two.yaml && !two.json); } + +/// A CLI with a flag that has to be alone. +#[derive(Cli)] +#[usage(bin = "ex2")] +struct Exclusively { + /// Dump the spec and leave + #[usage(long, exclusive)] + dump: bool, + /// Print more + #[usage(short = 'v', long)] + verbose: bool, + /// What to act on + target: Option, +} + +#[test] +fn an_exclusive_flag_has_to_be_alone() { + let a = argv(["--dump"]); + let ex = Exclusively::parse_from(&a).expect("alone is the point"); + assert!(ex.dump && !ex.verbose && ex.target.is_none()); + + // Another flag. + let a = argv(["--dump", "-v"]); + assert!(matches!( + Exclusively::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + // And a positional, which is what makes this more than a conflict with every other + // flag: `conflicts` has nowhere to name an argument. + let a = argv(["--dump", "t"]); + assert!(matches!( + Exclusively::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + // Without it, nothing changes. + let a = argv(["-v", "t"]); + let ex = Exclusively::parse_from(&a).expect("the rest of the CLI is unaffected"); + assert!(ex.verbose); + assert_eq!(ex.target.as_deref(), Some("t")); +} + +#[derive(Cli)] +#[usage(bin = "required-ex")] +struct ExclusiveWithRequiredSiblings { + #[usage(long, exclusive)] + dump: bool, + #[usage(long)] + output: String, + target: String, +} + +#[test] +fn an_exclusive_flag_bypasses_required_siblings() { + let a = argv(["--dump"]); + let parsed = ExclusiveWithRequiredSiblings::parse_from(&a) + .expect("exclusive is the command's requiredness escape"); + assert!(parsed.dump); + assert!(parsed.output.is_empty()); + assert!(parsed.target.is_empty()); +} + +#[test] +fn exclusive_reaches_the_spec() { + let kdl = Exclusively::to_kdl(); + assert!(kdl.contains("exclusive=#true"), "{kdl}"); + let spec: usage::Spec = kdl.parse().expect("the emitted spec should parse"); + let dump = spec.cmd.flags.iter().find(|f| f.name == "dump").unwrap(); + assert!(dump.exclusive); +} + +#[derive(Args)] +struct ExtraOutput { + /// Write somewhere + #[usage(long)] + output: Option, +} + +#[derive(Cli)] +#[usage(bin = "flat-ex")] +struct ExclusiveBesideFlatten { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + #[usage(flatten)] + extra: ExtraOutput, +} + +#[derive(Args)] +struct FlattenedDefault { + /// How many jobs to run + #[usage(long, default = "4")] + jobs: u8, +} + +#[derive(Cli)] +#[usage(bin = "flat-default-ex")] +struct ExclusiveBesideFlattenedDefault { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + #[usage(flatten)] + extra: FlattenedDefault, +} + +#[derive(Args)] +struct FlattenedExclusive { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, +} + +#[derive(Cli)] +#[usage(bin = "flat-ex-reverse")] +struct FlattenBesideOther { + /// Print more + #[usage(long)] + verbose: bool, + #[usage(flatten)] + extra: FlattenedExclusive, +} + +#[test] +fn flattening_does_not_hide_either_side_of_exclusivity() { + let a = argv(["--dump", "--output", "somewhere"]); + assert!(matches!( + ExclusiveBesideFlatten::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + let a = argv(["--dump", "--verbose"]); + assert!(matches!( + FlattenBesideOther::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + )); + + let a = argv(["--output", "somewhere"]); + let parsed = ExclusiveBesideFlatten::parse_from(&a).expect("without --dump"); + assert!(!parsed.dump); + assert_eq!(parsed.extra.output.as_deref(), Some("somewhere")); + + let a = argv(["--dump"]); + let parsed = FlattenBesideOther::parse_from(&a).expect("the flattened flag is alone"); + assert!(!parsed.verbose); + assert!(parsed.extra.dump); +} + +#[test] +fn an_exclusive_flag_does_not_skip_flattened_defaults() { + let a = argv(["--dump"]); + let parsed = ExclusiveBesideFlattenedDefault::parse_from(&a) + .expect("exclusive suppresses requiredness, not declared defaults"); + assert!(parsed.dump); + assert_eq!(parsed.extra.jobs, 4); +} + +#[derive(Cli)] +#[usage(bin = "sub-ex")] +struct ExclusiveBesideSubcommand { + /// Print the version and leave + #[usage(long, global, exclusive)] + version: bool, + #[usage(subcommand)] + command: Option, +} + +#[derive(Subcommands)] +enum ExclusiveCommands { + /// Run something + Run, +} + +#[test] +fn selecting_a_subcommand_counts_as_company_for_a_parent_exclusive_flag() { + let a = argv(["--version"]); + let parsed = ExclusiveBesideSubcommand::parse_from(&a).expect("alone is allowed"); + assert!(parsed.version); + assert!(parsed.command.is_none()); + + let a = argv(["--version", "run"]); + assert!(matches!( + ExclusiveBesideSubcommand::parse_from(&a), + Err(Error::ConflictingFlags { + other: "version", + .. + }) + )); +} +#[allow(dead_code)] +#[derive(Args)] +struct ChildExclusive { + #[usage(long, exclusive)] + dump: bool, +} + +#[allow(dead_code)] +#[derive(Subcommands)] +enum ChildExclusiveCommands { + Run(ChildExclusive), +} + +#[allow(dead_code)] +#[derive(Cli)] +#[usage(bin = "child-ex")] +struct ParentBesideChildExclusive { + #[usage(long)] + verbose: bool, + #[usage(subcommand)] + command: Option, +} + +#[test] +fn a_child_exclusive_flag_counts_parent_flags_as_company() { + let a = argv(["run", "--dump"]); + ParentBesideChildExclusive::parse_from(&a).expect("the child flag is alone"); + + let a = argv(["--verbose", "run", "--dump"]); + assert!(matches!( + ParentBesideChildExclusive::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + | Err(Error::ConflictingFlags { name: "dump", .. }) + )); +} + +#[allow(dead_code)] +#[derive(Args)] +struct RedeclaredClean { + /// Clean, as this command means it + #[usage(long = "clean")] + clean: bool, + /// Say more + #[usage(long)] + verbose: bool, +} + +#[allow(dead_code)] +#[derive(Subcommands)] +enum RedeclaredCleanCommands { + Run(RedeclaredClean), +} + +#[allow(dead_code)] +#[derive(Cli)] +#[usage(bin = "orphan-alias-ex")] +struct OrphanAliasExclusive { + /// Clean everything and leave + #[usage(short = 'c', long, global, exclusive)] + clean: bool, + #[usage(subcommand)] + command: Option, +} + +/// A child that re-declares only the long form of an inherited global leaves the short alias +/// with the ancestor — and the ancestor's `exclusive` goes with it. The derive keeps the two +/// declarations as separate fields, so it never had to reconcile them; this holds usage-lib, +/// which merges them into one flag, to the same answer. +#[test] +fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_child_redeclaration() { + let a = argv(["run", "-c"]); + assert!( + matches!( + OrphanAliasExclusive::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + ), + "the ancestor's own spelling is still its exclusive flag" + ); + + let a = argv(["run", "--clean", "--verbose"]); + OrphanAliasExclusive::parse_from(&a) + .expect("the child's spelling drops the exclusivity the child did not restate"); +} + +#[allow(dead_code)] +#[derive(Args)] +struct ExclusiveRedeclaredClean { + /// Clean, and nothing else + #[usage(long = "clean", exclusive)] + clean: bool, + /// Say more + #[usage(long)] + verbose: bool, +} + +#[allow(dead_code)] +#[derive(Subcommands)] +enum ExclusiveRedeclaredCleanCommands { + Run(ExclusiveRedeclaredClean), +} + +#[allow(dead_code)] +#[derive(Cli)] +#[usage(bin = "mixed-alias-ex")] +struct MixedAliasExclusive { + /// Clean everything + #[usage(short = 'c', long, global)] + clean: bool, + #[usage(subcommand)] + command: Option, +} + +/// The other direction, and both aliases at once: the child's spelling is exclusive whatever it +/// was typed beside, so an ancestor-only alias in the same invocation cannot excuse a companion. +#[test] +fn a_child_spelling_stays_exclusive_beside_an_ancestor_spelling() { + let a = argv(["run", "-c", "--clean", "--verbose"]); + assert!( + matches!( + MixedAliasExclusive::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + ), + "the child's exclusive spelling was given, so --verbose is company" + ); + + let a = argv(["run", "-c", "--verbose"]); + MixedAliasExclusive::parse_from(&a).expect("the ancestor's own spelling was never exclusive"); +} diff --git a/conformance/tests/post_binding_env.rs b/conformance/tests/post_binding_env.rs index 6557a5c4e..e9b52b6dc 100644 --- a/conformance/tests/post_binding_env.rs +++ b/conformance/tests/post_binding_env.rs @@ -9,7 +9,8 @@ use std::ffi::OsStr; -use usage_derive::Cli; +use usage_argv::Error; +use usage_derive::{Args, Cli, Subcommands}; fn argv(tokens: [&str; N]) -> [&OsStr; N] { tokens.map(OsStr::new) @@ -96,6 +97,137 @@ struct Ovr { quiet: bool, } +/// An exclusive flag beside a value supplied by the environment. +#[derive(Cli)] +#[usage(bin = "exclusive-env")] +struct ExclusiveEnv { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + /// Where to write + #[usage(long, env = "EXCLUSIVE_ENV_OUT")] + out: Option, +} + +#[test] +fn an_environment_value_counts_for_exclusivity() { + unsafe { std::env::set_var("EXCLUSIVE_ENV_OUT", "from-env") }; + let a = argv(["--dump"]); + assert!(ExclusiveEnv::parse_from(&a).is_err()); + unsafe { std::env::remove_var("EXCLUSIVE_ENV_OUT") }; + + let parsed = ExclusiveEnv::parse_from(&a).expect("without the environment it is alone"); + assert!(parsed.dump); + assert!(parsed.out.is_none()); +} + +#[derive(Args)] +struct FlattenedEnvOutput { + /// Where to write + #[usage(long, env = "FLAT_EXCLUSIVE_ENV_OUT")] + out: Option, +} + +#[derive(Cli)] +#[usage(bin = "flat-exclusive-env")] +struct ExclusiveAcrossFlattenEnv { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, + #[usage(flatten)] + extra: FlattenedEnvOutput, +} + +#[derive(Args)] +struct FlattenedExclusiveEnv { + /// Dump and leave + #[usage(long, exclusive)] + dump: bool, +} + +#[derive(Cli)] +#[usage(bin = "flat-exclusive-env-reverse")] +struct EnvAcrossFlattenExclusive { + /// Where to write + #[usage(long, env = "FLAT_EXCLUSIVE_ENV_REVERSE_OUT")] + out: Option, + #[usage(flatten)] + extra: FlattenedExclusiveEnv, +} + +#[test] +fn flattened_environment_values_are_visible_to_cross_boundary_exclusivity() { + unsafe { std::env::set_var("FLAT_EXCLUSIVE_ENV_OUT", "from-env") }; + let a = argv(["--dump"]); + assert!(ExclusiveAcrossFlattenEnv::parse_from(&a).is_err()); + unsafe { std::env::remove_var("FLAT_EXCLUSIVE_ENV_OUT") }; + + unsafe { std::env::set_var("FLAT_EXCLUSIVE_ENV_REVERSE_OUT", "from-env") }; + let a = argv(["--dump"]); + assert!(EnvAcrossFlattenExclusive::parse_from(&a).is_err()); + unsafe { std::env::remove_var("FLAT_EXCLUSIVE_ENV_REVERSE_OUT") }; + + let a = argv(["--dump"]); + let parsed = ExclusiveAcrossFlattenEnv::parse_from(&a).expect("alone after cleanup"); + assert!(parsed.dump); + assert!(parsed.extra.out.is_none()); + let parsed = EnvAcrossFlattenExclusive::parse_from(&a).expect("alone after cleanup"); + assert!(parsed.out.is_none()); + assert!(parsed.extra.dump); +} + +#[derive(Args)] +struct ChildEnvExclusive { + /// Dump and leave + #[usage(long, exclusive, env = "CHILD_EXCLUSIVE_ENV_DUMP")] + dump: bool, +} + +#[derive(Subcommands)] +enum ChildEnvExclusiveCommands { + Run(ChildEnvExclusive), +} + +#[derive(Cli)] +#[usage(bin = "child-exclusive-env")] +struct ParentOfChildEnvExclusive { + /// Print more + #[usage(long)] + verbose: bool, + /// Required unless an exclusive flag ends the invocation + #[usage(long)] + out: String, + #[usage(subcommand)] + command: Option, +} + +#[test] +fn a_selected_child_environment_value_is_visible_to_parent_exclusivity() { + unsafe { std::env::set_var("CHILD_EXCLUSIVE_ENV_DUMP", "1") }; + + let a = argv(["run"]); + let parsed = ParentOfChildEnvExclusive::parse_from(&a) + .expect("the child exclusive environment value bypasses parent requiredness"); + assert!(!parsed.verbose); + assert!(parsed.out.is_empty()); + let Some(ChildEnvExclusiveCommands::Run(child)) = parsed.command else { + panic!("the selected child should be built"); + }; + assert!( + child.dump, + "the environment value should reach the child field" + ); + + let a = argv(["--verbose", "run"]); + assert!(matches!( + ParentOfChildEnvExclusive::parse_from(&a), + Err(Error::ConflictingFlags { other: "dump", .. }) + | Err(Error::ConflictingFlags { name: "dump", .. }) + )); + + unsafe { std::env::remove_var("CHILD_EXCLUSIVE_ENV_DUMP") }; +} + #[test] fn a_displaced_flag_is_not_revived_by_its_environment_variable() { // The command line says `--stdin` came last, so `--file` lost. Filling it from the diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index d88e6e7ab..99fb57e54 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -912,6 +912,7 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { let overrides = &field.overrides; let conflicts = &field.conflicts; let requires = &field.requires; + let exclusive = field.exclusive; let required_if = &field.required_if; let required_unless = &field.required_unless; @@ -947,6 +948,7 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream { overrides: &[#(#overrides),*], conflicts: &[#(#conflicts),*], requires: &[#(#requires),*], + exclusive: #exclusive, required_if: &[#(#required_if),*], required_unless: &[#(#required_unless),*], ..usage_argv::spec::FlagMeta::EMPTY @@ -1392,6 +1394,103 @@ fn option_str(value: Option<&str>) -> TokenStream { } } +/// The presence summaries a parent needs to enforce exclusivity across a flattened +/// `CommandArgs` boundary. +fn presence_methods(cli: &Cli) -> TokenStream { + let direct_given = cli.fields.iter().filter_map(|field| { + if matches!(field.kind, Kind::Flatten { .. } | Kind::Subcommand { .. }) { + return None; + } + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + Some(quote! { + if partial.#given { + return ::std::option::Option::Some(#name); + } + }) + }); + let flattened_given = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident) + { + return ::std::option::Option::Some(name); + } + }) + }); + let selected = cli.fields.iter().find_map(|field| { + if !matches!(field.kind, Kind::Subcommand { .. }) { + return None; + } + let name = &field.name; + Some(quote! { + if partial.__usage_selected.is_some() { + return ::std::option::Option::Some(#name); + } + }) + }); + let direct_exclusive = cli.fields.iter().filter_map(|field| { + if !field.exclusive { + return None; + } + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + Some(quote! { + if partial.#given { + return ::std::option::Option::Some(#name); + } + }) + }); + let flattened_exclusive = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) + { + return ::std::option::Option::Some(name); + } + }) + }); + let selected_exclusive = cli.fields.iter().find_map(|field| { + let Kind::Subcommand { ty, .. } = &field.kind else { + return None; + }; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as usage_argv::spec::Subcommands>::exclusive_given( + &partial.__usage_sub, + partial.__usage_selected, + ) + { + return ::std::option::Option::Some(name); + } + }) + }); + + quote! { + fn any_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { + #(#direct_given)* + #(#flattened_given)* + #selected + ::std::option::Option::None + } + + fn exclusive_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { + #(#direct_exclusive)* + #(#flattened_exclusive)* + #selected_exclusive + ::std::option::Option::None + } + } +} + /// The struct that collects values while parsing. /// /// One field per declared field, of the type that accumulates it: a `bool` for a @@ -2273,6 +2372,9 @@ fn subcommand_parts(cli: &Cli) -> Option { pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; let runtime = runtime_path(); + let presence = presence_methods(cli); + let apply_defaults = declared_defaults(cli); + let apply_env = env_fallbacks(cli); // A group carries settings the same way a root does, minus the layer: `SettingGiven` is // usage-argv's own vocabulary, so a flattened group can hand its parent what it was given // without either of them naming the config crate. Emitted whenever it has anything to say — @@ -2497,6 +2599,16 @@ pub fn emit_args(cli: &Cli) -> TokenStream { check(partial) } + #presence + + fn apply_defaults(partial: &mut Self::Partial) { + #apply_defaults + } + + fn apply_env(partial: &mut Self::Partial) { + #apply_env + } + #settings_impl fn build<'t, 'v>( @@ -2709,6 +2821,45 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } }); + // The partial holds only the selected subcommand's own, so every one of these asks the + // variant rather than a field: an unselected arm has nothing to have been given. + let any_givens = subs.variants.iter().enumerate().map(|(i, v)| { + let variant = format_ident!("V{i}"); + let ty = &v.ty; + quote! { + ::std::option::Option::Some(#i) => { + if let Partial::#variant(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::any_given(__usage_p) + } else { + ::std::option::Option::None + } + } + } + }); + let exclusive_givens = subs.variants.iter().enumerate().map(|(i, v)| { + let variant = format_ident!("V{i}"); + let ty = &v.ty; + quote! { + ::std::option::Option::Some(#i) => { + if let Partial::#variant(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(__usage_p) + } else { + ::std::option::Option::None + } + } + } + }); + let apply_envs = subs.variants.iter().enumerate().map(|(i, v)| { + let variant = format_ident!("V{i}"); + let ty = &v.ty; + quote! { + ::std::option::Option::Some(#i) => { + if let Partial::#variant(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::apply_env(__usage_p); + } + } + } + }); let selects = subs.variants.iter().enumerate().map(|(i, v)| { let held = format_ident!("V{i}"); let variant = &v.ident; @@ -2826,6 +2977,36 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { } } + fn any_given( + partial: &Self::Partial, + selected: ::std::option::Option, + ) -> ::std::option::Option<&'static str> { + match selected { + #(#any_givens)* + _ => ::std::option::Option::None, + } + } + + fn exclusive_given( + partial: &Self::Partial, + selected: ::std::option::Option, + ) -> ::std::option::Option<&'static str> { + match selected { + #(#exclusive_givens)* + _ => ::std::option::Option::None, + } + } + + fn apply_env( + partial: &mut Self::Partial, + selected: ::std::option::Option, + ) { + match selected { + #(#apply_envs)* + _ => {} + } + } + fn check<'t, 'v>( partial: &mut Self::Partial, selected: usize, @@ -2996,53 +3177,13 @@ fn group_meta_table(cli: &Cli) -> (TokenStream, TokenStream) { ) } -/// Everything decided once the last token has been read. +/// Apply declared defaults to this command and every argument group flattened into it. /// -/// Ordered deliberately. The environment fills what argv left out, so it runs -/// before required-ness — a flag with `env` set is not missing. Choices and bounds -/// come last, because they judge a value however it arrived, including one that came -/// from the environment or a default. -fn post_binding(cli: &Cli) -> TokenStream { - let sub_check = subcommand_parts(cli).map(|p| p.check).unwrap_or_default(); - // A flattened struct declares its own required-ness and choices, and only it knows them. - // - // Run before this command's own required-ness, on the same principle that puts conflicts - // first: what the user typed wrong is more useful to hear about than what they left out. - // `config --format yaml` should say `yaml` is not one of the choices, even if `--file` is - // also missing. - // - // No finer promise than that. These checks are grouped by kind rather than by field, so - // there is no "in declaration order" to offer — a flattened group's errors interleave with - // this command's by kind, not by where the field was written. - let flattened_checks = cli.fields.iter().filter_map(|f| { - let Kind::Flatten { ty } = &f.kind else { - return None; - }; - let ident = &f.ident; - Some(quote! { - <#ty as usage_argv::spec::CommandArgs>::check(&mut partial.#ident)?; - }) - }); - let duplicate_checks = cli.fields.iter().filter(|f| rejects_duplicate(f)).map(|f| { - let duplicated = format_ident!("__duplicated_{}", f.ident); - let name = &f.name; - quote! { - if partial.#duplicated { - return ::std::result::Result::Err( - usage_argv::Error::DuplicateFlag { name: #name }, - ); - } - } - }); - // Applied here rather than in `start`, and this is not a detail: `start` builds the - // partial for *every* command in the CLI, selected or not, so a declared default was - // costing a `String` per default per command — 60 allocations to parse a bare `mise`, - // which is the CLI's size leaking into the invocation. `check` runs for the selected - // command only. - // - // Guarded on `__given_*`, which is what makes this safe to move: a negation that set a - // defaulted `bool` to false during the parse must not be undone here. - let declared_defaults = cli.fields.iter().filter_map(|f| { +/// Separate from the rest of `check` because exclusivity suppresses requiredness, not values a +/// CLI promised to provide by default. A parent can therefore prepare an opaque flattened partial +/// without also asking it to report a missing sibling. +fn declared_defaults(cli: &Cli) -> TokenStream { + let own = cli.fields.iter().filter_map(|f| { if f.default.is_empty() || matches!(f.kind, Kind::Subcommand { .. }) { return None; } @@ -3054,8 +3195,27 @@ fn post_binding(cli: &Cli) -> TokenStream { } }) }); + let flattened = cli.fields.iter().filter_map(|f| { + let Kind::Flatten { ty } = &f.kind else { + return None; + }; + let ident = &f.ident; + Some(quote! { + <#ty as usage_argv::spec::CommandArgs>::apply_defaults(&mut partial.#ident); + }) + }); + quote! { + #(#own)* + #(#flattened)* + } +} - let env_fallbacks = cli.fields.iter().filter_map(|f| { +/// Environment fallbacks for this command and every argument group flattened into it. +/// +/// Kept separate from the rest of post-binding validation because a parent must apply these +/// before it can enforce a relationship whose other side lives across a flatten boundary. +fn env_fallbacks(cli: &Cli) -> TokenStream { + let own = cli.fields.iter().filter_map(|f| { let ident = &f.ident; let given = format_ident!("__given_{}", ident); let var = f.env.as_deref()?; @@ -3067,25 +3227,18 @@ fn post_binding(cli: &Cli) -> TokenStream { }, Shape::Required => quote!(partial.#ident = value.into_bytes();), // Cleared first, so the environment *replaces* a declared default instead of - // adding to it — which is what every other shape does by assigning, and what the - // order here means: a default says what the value is when nobody said anything, - // and the environment is somebody saying something. Nothing else can be in the - // collection at this point: argv sets `__given_*`, which this is guarded on. + // adding to it — which is what every other shape does by assigning. Shape::Many => quote! { partial.#ident.clear(); partial.#ident.push(value.into_bytes()); }, - // A switch reads as on for anything but the spellings of "off", which is - // what every tool that takes a boolean from the environment settles on. Shape::Bool => quote! { partial.#ident = !matches!( value.as_str(), "" | "0" | "false" | "no" | "off" ); }, - // A number, since the environment cannot repeat a flag: `EX_VERBOSE=3` - // is how you say `-vvv`. An unparseable value leaves the field alone - // rather than being counted as given. + // An unparseable count leaves the field alone rather than counting as given. Shape::Count => { let ty = &f.ty; quote! { @@ -3111,6 +3264,118 @@ fn post_binding(cli: &Cli) -> TokenStream { } }) }); + let flattened = cli.fields.iter().filter_map(|f| { + let Kind::Flatten { ty } = &f.kind else { + return None; + }; + let ident = &f.ident; + Some(quote! { + <#ty as usage_argv::spec::CommandArgs>::apply_env(&mut partial.#ident); + }) + }); + let selected = cli.fields.iter().find_map(|f| { + let Kind::Subcommand { ty, .. } = &f.kind else { + return None; + }; + Some(quote! { + <#ty as usage_argv::spec::Subcommands>::apply_env( + &mut partial.__usage_sub, + partial.__usage_selected, + ); + }) + }); + quote! { + #(#own)* + #(#flattened)* + #selected + } +} + +/// Everything decided once the last token has been read. +/// +/// Ordered deliberately. The environment fills what argv left out, so it runs +/// before required-ness — a flag with `env` set is not missing. Choices and bounds +/// come last, because they judge a value however it arrived, including one that came +/// from the environment or a default. +fn post_binding(cli: &Cli) -> TokenStream { + let sub_check = subcommand_parts(cli).map(|p| p.check).unwrap_or_default(); + let direct_exclusive_present = cli.fields.iter().filter_map(|field| { + if !field.exclusive { + return None; + } + let given = format_ident!("__given_{}", field.ident); + Some(quote!(partial.#given)) + }); + let flattened_exclusive_present = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident).is_some() + }) + }); + let selected_exclusive_present = cli.fields.iter().filter_map(|field| { + let Kind::Subcommand { ty, .. } = &field.kind else { + return None; + }; + Some(quote! { + <#ty as usage_argv::spec::Subcommands>::exclusive_given( + &partial.__usage_sub, + partial.__usage_selected, + ).is_some() + }) + }); + let exclusive_present = quote! { + false + #(|| #direct_exclusive_present)* + #(|| #flattened_exclusive_present)* + #(|| #selected_exclusive_present)* + }; + // A flattened struct declares its own required-ness and choices, and only it knows them. + // + // Run before this command's own required-ness, on the same principle that puts conflicts + // first: what the user typed wrong is more useful to hear about than what they left out. + // `config --format yaml` should say `yaml` is not one of the choices, even if `--file` is + // also missing. + // + // No finer promise than that. These checks are grouped by kind rather than by field, so + // there is no "in declaration order" to offer — a flattened group's errors interleave with + // this command's by kind, not by where the field was written. + let flattened_checks = cli.fields.iter().filter_map(|f| { + let Kind::Flatten { ty } = &f.kind else { + return None; + }; + let ident = &f.ident; + Some(quote! { + if !__usage_exclusive_present + || <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) + .is_some() + { + <#ty as usage_argv::spec::CommandArgs>::check(&mut partial.#ident)?; + } + }) + }); + let duplicate_checks = cli.fields.iter().filter(|f| rejects_duplicate(f)).map(|f| { + let duplicated = format_ident!("__duplicated_{}", f.ident); + let name = &f.name; + quote! { + if partial.#duplicated { + return ::std::result::Result::Err( + usage_argv::Error::DuplicateFlag { name: #name }, + ); + } + } + }); + // Applied here rather than in `start`, and this is not a detail: `start` builds the + // partial for *every* command in the CLI, selected or not, so a declared default was + // costing a `String` per default per command — 60 allocations to parse a bare `mise`, + // which is the CLI's size leaking into the invocation. `check` runs for the selected + // command only. The helper also prepares flattened defaults before an exclusive flag can + // suppress those groups' requiredness checks. + let declared_defaults = declared_defaults(cli); + + let env_fallbacks = env_fallbacks(cli); let required_checks = cli.fields.iter().filter_map(|f| { // A `String` has nowhere to put "absent", so the type is the declaration; a collection @@ -3304,6 +3569,130 @@ fn post_binding(cli: &Cli) -> TokenStream { }) }); + // An exclusive flag is a conflict with the whole command rather than with a named + // flag, so it is written as one check per *other* declaration — positionals included, + // which is what makes it more than being in a group with every other flag. + // + // Only what was given counts, as `conflicts` reads it: a defaulted field standing + // beside an exclusive flag is nobody saying anything, and counting it would make the + // flag unusable on any command that has a default. + let exclusive_checks = cli + .fields + .iter() + .filter(|f| f.exclusive) + .flat_map(move |f| { + let given = format_ident!("__given_{}", f.ident); + let name = &f.name; + cli.fields + .iter() + .filter(move |other| other.ident != f.ident) + .filter(|other| { + !matches!(other.kind, Kind::Subcommand { .. } | Kind::Flatten { .. }) + }) + .map(move |other| { + let other_given = format_ident!("__given_{}", other.ident); + let other_name = &other.name; + quote! { + if partial.#given && partial.#other_given { + return ::std::result::Result::Err( + usage_argv::Error::ConflictingFlags { + name: #other_name, + other: #name, + }, + ); + } + } + }) + .collect::>() + }); + + // A flattened partial is intentionally opaque to its parent. Ask through + // `CommandArgs` whether either side of an exclusive relationship was given, so the + // relationship does not disappear merely because the declarations live in reusable + // `Args`. A selected subcommand is itself another declaration in this command; its + // contents remain in the child command's own scope. + let direct_given = cli + .fields + .iter() + .filter(|field| !matches!(field.kind, Kind::Flatten { .. } | Kind::Subcommand { .. })) + .rev() + .fold(quote!(::std::option::Option::None), |rest, field| { + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + quote!(if partial.#given { ::std::option::Option::Some(#name) } else { #rest }) + }); + let direct_exclusive = cli + .fields + .iter() + .filter(|field| field.exclusive) + .rev() + .fold(quote!(::std::option::Option::None), |rest, field| { + let given = format_ident!("__given_{}", field.ident); + let name = &field.name; + quote!(if partial.#given { ::std::option::Option::Some(#name) } else { #rest }) + }); + let has_flatten = cli + .fields + .iter() + .any(|field| matches!(field.kind, Kind::Flatten { .. })); + let flattened_segments = cli.fields.iter().filter_map(|field| { + let Kind::Flatten { ty } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + ( + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident), + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), + ), + }) + }); + let subcommand_segment = cli.fields.iter().find_map(|field| { + let Kind::Subcommand { ty, .. } = &field.kind else { + return None; + }; + let name = &field.name; + Some(quote! { + ( + partial.__usage_selected.map(|_| #name), + <#ty as usage_argv::spec::Subcommands>::exclusive_given( + &partial.__usage_sub, + partial.__usage_selected, + ), + ), + }) + }); + let exclusive_cross_checks = (has_flatten || subcommand_segment.is_some()).then(|| { + quote! { + let __usage_exclusive_segments = [ + (#direct_given, #direct_exclusive), + #(#flattened_segments)* + #subcommand_segment + ]; + for __usage_i in 0..__usage_exclusive_segments.len() { + if let ::std::option::Option::Some(exclusive) = + __usage_exclusive_segments[__usage_i].1 + { + for __usage_j in 0..__usage_exclusive_segments.len() { + if __usage_i == __usage_j { + continue; + } + if let ::std::option::Option::Some(other) = + __usage_exclusive_segments[__usage_j].0 + { + return ::std::result::Result::Err( + usage_argv::Error::ConflictingFlags { + name: other, + other: exclusive, + }, + ); + } + } + } + } + } + }); + // Groups, checked once per group rather than per member: both questions a group asks // — how many members were given, and whether that is enough — are about the set. // @@ -3440,19 +3829,27 @@ fn post_binding(cli: &Cli) -> TokenStream { quote! { // Before the environment, which overrides a default when the flag was not given — // the order `start` used to give them. - #(#declared_defaults)* - #(#env_fallbacks)* + #declared_defaults + #env_fallbacks + let __usage_exclusive_present = #exclusive_present; #(#duplicate_checks)* // Before required-ness: "you gave two flags that cannot go together" is the // more useful of the two answers when a conflict has also left something // unfilled, and it is the one usage-lib reports. #(#conflict_checks)* + #(#exclusive_checks)* + #exclusive_cross_checks #(#group_exclusivity_checks)* - #(#requirement_checks)* #(#flattened_checks)* - #(#required_checks)* - #(#group_required_checks)* - #(#relationship_required_checks)* + // An exclusive occurrence is the command's escape from requiredness, just as in clap: + // `--version` remains usable on a command that otherwise requires an input. Conflicts + // and other validation still ran above; only errors about absent siblings are skipped. + if !__usage_exclusive_present { + #(#requirement_checks)* + #(#required_checks)* + #(#group_required_checks)* + #(#relationship_required_checks)* + } #(#choice_checks)* #(#bound_checks)* #sub_check diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 320a5daac..1d925c273 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -214,6 +214,7 @@ //! | `conflicts = "--other"` | a flag this one cannot be given with | //! | `requires = "--other"` | a flag that must also be given when this one is | //! | `group = "input"` | the group this flag is one of; see below | +//! | `exclusive` | this flag has to be given on its own, positionals included | //! | `required_if = "--other"` | a flag whose presence makes this one necessary | //! | `required_unless = "--other"` | a flag whose presence makes this one unnecessary | //! diff --git a/derive/src/model.rs b/derive/src/model.rs index 14941c398..013727c80 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -205,6 +205,8 @@ pub struct Field { /// one lives on the flag the rule is about, which is where clap puts it and where a /// reader looks for it. pub requires: Vec, + /// Whether this flag must be given on its own — clap's `exclusive`. + pub exclusive: bool, /// The group this flag belongs to, if any. Properties live on the group's own /// declaration; membership lives here, because a field is where a reader looks to /// see what a flag is part of. @@ -1053,6 +1055,7 @@ impl Field { overrides: Vec::new(), conflicts: Vec::new(), requires: Vec::new(), + exclusive: false, group: None, required_if: Vec::new(), required_unless: Vec::new(), @@ -1148,6 +1151,7 @@ impl Field { overrides: Vec::new(), conflicts: Vec::new(), requires: Vec::new(), + exclusive: false, group: None, required_if: Vec::new(), required_unless: Vec::new(), @@ -1207,6 +1211,7 @@ impl Field { let mut conflicts: Vec = Vec::new(); let mut requires: Vec = Vec::new(); let mut group: Option = None; + let mut exclusive = false; let mut required_if: Vec = Vec::new(); let mut required_unless: Vec = Vec::new(); @@ -1300,6 +1305,7 @@ impl Field { "conflicts" => conflicts = selectors(&meta)?, "requires" => requires = selectors(&meta)?, "group" => group = Some(string_value(&meta)?), + "exclusive" => exclusive = flag_value(&meta)?, "required_if" => required_if = selectors(&meta)?, "required_unless" => required_unless = selectors(&meta)?, "value_enum" => value_enum = flag_value(&meta)?, @@ -1343,7 +1349,8 @@ impl Field { `short`, `negate`, `global`, `var`, `variadic`, \ `count`, `hide`, `arg`, `env`, `default`, `choices`, \ `var_min`, `var_max`, `value_enum`, `value_hint`, `overrides`, \ - `conflicts`, `requires`, `group`, `required_if`, \ + `conflicts`, `requires`, `group`, `exclusive`, \ + `required_if`, \ `required_unless`, `help_heading`, `value_name`, \ `verbatim_doc_comment`, \ `required`, and `double_dash`" @@ -1817,6 +1824,17 @@ impl Field { )); } + // `exclusive` is represented by flag metadata and enforced for a flag occurrence. + // Accepting it on a positional would make the derive enforce a rule that its emitted + // spec and documentation silently omit. + if exclusive && !matches!(kind, Kind::Flag { .. }) { + return Err(syn::Error::new( + span, + "`exclusive` describes a flag that has to be given on its own; a positional \ + argument cannot carry it — add `long` or `short` to make this field a flag", + )); + } + // `value_name` names the placeholder a *flag's value* gets in help — `--out `. // A positional argument is named by `name`, and a `bool` or `count` flag has no value // to put a placeholder in, so `arg_meta` never emits it and a valueless flag has nowhere @@ -1890,6 +1908,7 @@ impl Field { overrides, conflicts, requires, + exclusive, group, required_if, required_unless, @@ -2897,6 +2916,30 @@ mod tests { ); } + #[test] + fn exclusive_is_refused_on_a_positional() { + let err = rejection( + r#" + struct Ex { + #[usage(exclusive)] + target: String, + } + "#, + ); + assert!( + err.contains("`exclusive` describes a flag"), + "unhelpful message: {err}" + ); + + cli(r#" + struct Ex { + #[usage(long, exclusive)] + dump: bool, + } + "#) + .expect("exclusive remains valid on a flag"); + } + #[test] fn a_selector_naming_nothing_is_a_compile_error() { let err = rejection( diff --git a/docs/spec/reference/flag.md b/docs/spec/reference/flag.md index bc99f77d8..eb51fe8d4 100644 --- a/docs/spec/reference/flag.md +++ b/docs/spec/reference/flag.md @@ -32,6 +32,7 @@ flag "--file " required_unless="--dir" // either --file or --dir must be p flag "--file " overrides="--stdin" // --file and --stdin override each other; the last one wins flag "--file " conflicts="--stdin" // --file and --stdin cannot be given together flag "--out " requires="--format" // giving --out means --format must be given too +flag "--dump" exclusive=#true // --dump has to be given on its own flag "--stdin" { conflicts "--file" "--url" // several, one per argument @@ -99,6 +100,21 @@ setter with no getter, so [the clap integration](/spec/integrations/clap) cannot back out — a CLI that wants the constraint in its spec has to declare it here. ::: +## `exclusive` + +An exclusive flag has to be given on its own: everything else the command declares is +refused alongside it, **including its positional arguments**, which is what makes this +more than [`conflicts`](#conflicts-and-overrides) with every other flag — a conflict has +nowhere to name an argument. + +`--version` and `--dump-config` are the shape it is for: asking for one means the rest of +the command line has nothing to act on. + +Only what was supplied counts, the same rule `conflicts` follows. A flag with a +[`default`](/spec/reference/flag) standing beside an exclusive one is nobody saying +anything, and counting it would make the exclusive flag unusable on any command that has +a default. + ## `global` A `global` flag is recognized by the command that declares it and by everything below it, so diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 6fd2c3675..17e2f12fc 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -109,6 +109,13 @@ fn merge_subcommand_flags( Some(merged) => merged.clone(), None => { let mut merged = (*global_flag).clone(); + // `exclusive` is deliberately *not* reconciled here, in either direction. + // One object now answers to two alias sets that may disagree: the child + // owns the spellings it declared, the ancestor keeps the ones only it + // declared. A single bool cannot hold both, so the merged flag carries the + // ancestor's and validation resolves the occurrence by the spelling that + // was typed — the ledger it already consults to decide whether selecting + // the child is company. for s in &flag.short { if !merged.short.contains(s) { merged.short.push(*s); @@ -613,6 +620,11 @@ fn parse_partial_with_env( // Keep this internal so adding relationship support remains semver-compatible. The full // parser uses it to prevent defaults and environment values from restoring overridden flags. let mut overridden_flags = HashSet::new(); + // Which spelling supplied each parsed flag. A child may re-declare one long form of an + // inherited global while the merge keeps the ancestor's other aliases on the same `Arc`. + // The declaration object alone then cannot answer whether `--clean` belonged to the child + // or an inherited `-c` belonged to the ancestor. + let mut parsed_flag_spellings: HashMap> = HashMap::new(); // Phase 1: Scan for subcommands and collect global flags // @@ -939,6 +951,10 @@ fn parse_partial_with_env( let split = w.split_once('='); let word = split.map(|(word, _)| word).unwrap_or(&w); if let Some(f) = binding.as_ref().or_else(|| out.available_flags.get(word)) { + parsed_flag_spellings + .entry(Arc::as_ptr(f) as usize) + .or_default() + .insert(word.to_string()); apply_flag_overrides( f, &out.available_flags, @@ -1032,6 +1048,10 @@ fn parse_partial_with_env( .as_ref() .or_else(|| out.available_flags.get(&format!("-{short}"))) { + parsed_flag_spellings + .entry(Arc::as_ptr(f) as usize) + .or_default() + .insert(format!("-{short}")); apply_flag_overrides( f, &out.available_flags, @@ -1166,6 +1186,92 @@ fn parse_partial_with_env( record_cursor(&mut out, next_arg_idx, seen_double_dash); + // `out.flags` is keyed by `SpecFlag`, whose equality is intentionally name-only. Two + // declarations with the same canonical name therefore share one public value entry even + // when both were typed. The spelling ledger is keyed by declaration identity and retains + // both, which is what exclusivity needs. + let flag_was_parsed = + |flag: &Arc| parsed_flag_spellings.contains_key(&(Arc::as_ptr(flag) as usize)); + + // The spellings the selected command's own declaration speaks for, on this object. + // + // Empty unless that declaration really is this object's: a parent and child may each + // declare `--clean` without merging, leaving two flags that share a name, and the + // ancestor's must not be read as the child's. The test is whether every spelling the child + // declared resolves back here — true of a merged flag, and of a plain local one, but not of + // an ancestor whose long form the child took over. + let child_spellings = |flag: &Arc| -> HashSet { + let declared: HashSet = out + .cmd + .flags + .iter() + .filter(|declared| declared.name == flag.name) + .flat_map(flag_keys) + .collect(); + // *Any* of them, not all. All was too strong: a child may declare a spelling that + // some other inherited global already owns — `-c --clean` beside an inherited + // `-c --config` — and that collision is resolved in the other global's favor, so the + // child's `-c` resolves elsewhere. Requiring every spelling to land here let one + // unrelated collision disown the child from the `--clean` it plainly does own. + // + // Still enough to tell the two-object case apart, which is what this guards: when a + // child re-declares a global as global, the child's own spellings resolve to the + // child's separate flag, so none of them lands on the ancestor's. + let speaks_for_this_flag = declared.iter().any(|spelling| { + out.available_flags + .get(spelling) + .is_some_and(|available| Arc::ptr_eq(available, flag)) + }); + if speaks_for_this_flag { + declared + } else { + HashSet::new() + } + }; + + // Whose `exclusive` an occurrence activates, as `(the child's, an ancestor's)`. + // + // A child that re-declares an inherited global merges into one object answering to two + // alias sets whose declarations may disagree, so there is no single owner to name: the + // child owns the spellings it declared and the ancestor keeps the ones only it declared. + // Both sides can be in play at once — `run -c --clean` is the ancestor's alias and the + // child's in one invocation — and each carries its own declaration's answer. + let exclusivity_in_play = |flag: &Arc| -> (bool, bool) { + let child = child_spellings(flag); + let child_exclusive = !child.is_empty() + && out + .cmd + .flags + .iter() + .any(|declared| declared.name == flag.name && declared.exclusive); + match parsed_flag_spellings.get(&(Arc::as_ptr(flag) as usize)) { + Some(spellings) => ( + child_exclusive && spellings.iter().any(|s| child.contains(s)), + flag.exclusive && spellings.iter().any(|s| !child.contains(s)), + ), + // An environment value has no spelling to attribute it by. The declaration the + // selected command has in scope is the one that answers — which is the child's + // when it re-declared the flag, and the ancestor's when it did not. + None => (child_exclusive, flag.exclusive && child.is_empty()), + } + }; + + let exclusive_occurrence = |flag: &Arc| { + let (child, ancestor) = exclusivity_in_play(flag); + child || ancestor + }; + + // clap's `exclusive` is also an escape from requiredness: `--version` has to work on a + // command that otherwise needs an input. Companions are still diagnosed below, but an + // exclusive occurrence suppresses the missing-value checks that would make it unusable + // whether it was alone or not. + let exclusive_present = + unique_flags(out.available_flags.values().chain(out.flags.keys())).any(|flag| { + exclusive_occurrence(flag) + && !overridden_flags.contains(&flag.name) + && (flag_was_parsed(flag) || flag_has_env(flag, custom_env)) + }); + // A command that says it needs a subcommand, given none. Checked on `out.cmd` and nowhere // else, because `out.cmd` *is* the command the words reached: had a subcommand been taken, // the child would be here instead. The spec has carried `subcommand_required` since it was @@ -1190,22 +1296,24 @@ fn parse_partial_with_env( // Not `skip(out.args.len())`: a `--` may have jumped the cursor past an arg that stayed // empty, so position and fill count can disagree. Ask `out.args` which args it holds. - for arg in out.cmd.args.iter() { - if out.args.contains_key(arg) { - continue; - } - // Already reported as needing a `--`; one mistake should not yield two messages. - if double_dash_violations.contains(&arg.name) { - continue; - } - if arg.required && arg.default.is_empty() { - // Check if there's an env var available (custom env map takes precedence) - let has_env = arg - .env - .as_ref() - .is_some_and(|env_var| env_contains(custom_env, env_var)); - if !has_env { - out.errors.push(UsageErr::MissingArg(arg.name.clone())); + if !exclusive_present { + for arg in out.cmd.args.iter() { + if out.args.contains_key(arg) { + continue; + } + // Already reported as needing a `--`; one mistake should not yield two messages. + if double_dash_violations.contains(&arg.name) { + continue; + } + if arg.required && arg.default.is_empty() { + // Check if there's an env var available (custom env map takes precedence) + let has_env = arg + .env + .as_ref() + .is_some_and(|env_var| env_contains(custom_env, env_var)); + if !has_env { + out.errors.push(UsageErr::MissingArg(arg.name.clone())); + } } } } @@ -1246,14 +1354,69 @@ fn parse_partial_with_env( // named it, which is what clap says too: an unmet `requires` is a required // argument that was not provided. Named by its own name, resolved through the // same matcher, so a `requires="-f"` reports `--force` rather than the selector. - for other in &flag.requires { - if !selector_is_satisfied(other, &out, &overridden_flags, custom_env) { - let name = selector_flag_name(other, &out).unwrap_or_else(|| other.clone()); - out.errors.push(UsageErr::MissingFlag(name)); + if !exclusive_present { + for other in &flag.requires { + if !selector_is_satisfied(other, &out, &overridden_flags, custom_env) { + let name = selector_flag_name(other, &out).unwrap_or_else(|| other.clone()); + out.errors.push(UsageErr::MissingFlag(name)); + } } } } + // An exclusive flag is the whole-command form of a conflict: `--version` means the + // rest of the line has nothing to act on. Everything the invocation supplied counts, + // positionals included, which is what distinguishes it from being in a group with + // every other flag. + // + // Only what was *given*, as `conflicts` reads it: a defaulted flag standing beside an + // exclusive one is nobody saying anything, and counting it would make the exclusive + // flag unusable on any command that has a default. Environment values do count, also as + // `conflicts` reads them, so the spec parser and the derive agree. + for flag in unique_flags(out.available_flags.values().chain(out.flags.keys())) { + // `SpecFlag` equality is intentionally name-only for the public parsed-value map, + // but re-declared aliases can leave distinct declarations with that same name in + // scope. Exclusivity is about the declaration the typed spelling resolved to, so + // compare the parser's `Arc`s by identity here. + let given = flag_was_parsed(flag) || flag_has_env(flag, custom_env); + if !exclusive_occurrence(flag) || !given || overridden_flags.contains(&flag.name) { + continue; + } + let other_flag = unique_flags(out.available_flags.values().chain(out.flags.keys())) + .find(|other| { + !Arc::ptr_eq(other, flag) + && !overridden_flags.contains(&other.name) + && (flag_was_parsed(other) || flag_has_env(other, custom_env)) + }) + .map(|other| format!("--{}", other.name)); + let other_arg = out.cmd.args.iter().find(|arg| { + out.args.keys().any(|given| given.name == arg.name) + || arg + .env + .as_ref() + .is_some_and(|env| env_contains(custom_env, env)) + }); + // Selecting a child is company for an exclusive flag declared by an ancestor. An + // exclusive flag belonging to the child itself does not conflict with the command word + // needed to reach that child — so the question is not who owns the flag but whose + // exclusivity is the one being enforced, which is what `exclusivity_in_play` already + // separated. + let (_, ancestor_exclusivity) = exclusivity_in_play(flag); + let selected_subcommand = + (out.cmds.len() > 1 && ancestor_exclusivity).then(|| out.cmd.name.clone()); + let other = other_flag + .or_else(|| other_arg.map(|arg| format!("<{}>", arg.name))) + .or(selected_subcommand); + if let Some(other) = other { + out.errors.push(UsageErr::InvalidFlag { + token: format!("--{}", flag.name), + reason: format!("must be given on its own, and {other} was given too"), + span: (0, 0).into(), + input: format!("--{} {other}", flag.name), + }); + } + } + // Groups, checked once per group rather than per flag: both questions a group asks — // how many members were given, and whether that is enough — are about the set, which // is the whole reason a group exists rather than a pile of pairwise conflicts. @@ -1298,7 +1461,7 @@ fn parse_partial_with_env( .members .iter() .any(|selector| selector_is_satisfied(selector, &out, &overridden_flags, custom_env)); - if group.required && !satisfied { + if group.required && !satisfied && !exclusive_present { // The members are what a user has to type, so they are in the message; the // group's name is there too, since a command with several groups would // otherwise report the same sentence twice with nothing to tell them apart. @@ -1310,23 +1473,24 @@ fn parse_partial_with_env( } out.errors.extend(group_errors); - for flag in unique_flags(out.available_flags.values()) { - if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) { - continue; - } - let has_default = - !flag.default.is_empty() || flag.arg.iter().any(|a| !a.default.is_empty()); - let has_env = flag_has_env(flag, custom_env); - let required_if = flag - .required_if - .iter() - .any(|selector| selector_is_explicit(selector, &out, &overridden_flags, custom_env)); - let required_unless = !flag.required_unless.is_empty() - && !flag.required_unless.iter().any(|selector| { + if !exclusive_present { + for flag in unique_flags(out.available_flags.values()) { + if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) { + continue; + } + let has_default = + !flag.default.is_empty() || flag.arg.iter().any(|a| !a.default.is_empty()); + let has_env = flag_has_env(flag, custom_env); + let required_if = flag.required_if.iter().any(|selector| { selector_is_explicit(selector, &out, &overridden_flags, custom_env) }); - if (flag.required || required_if || required_unless) && !has_default && !has_env { - out.errors.push(UsageErr::MissingFlag(flag.name.clone())); + let required_unless = !flag.required_unless.is_empty() + && !flag.required_unless.iter().any(|selector| { + selector_is_explicit(selector, &out, &overridden_flags, custom_env) + }); + if (flag.required || required_if || required_unless) && !has_default && !has_env { + out.errors.push(UsageErr::MissingFlag(flag.name.clone())); + } } } @@ -2906,6 +3070,284 @@ flag "--file " required_unless="--stdin" } } + #[test] + fn an_exclusive_flag_has_to_be_alone() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--verbose\"\narg \"[target]\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--dump"])).expect("alone is the point"); + + // Any other flag. + let err = parse(&spec, &input(&["ex", "--dump", "--verbose"])).unwrap_err(); + assert!(err.to_string().contains("on its own"), "{err}"); + + // And a positional, which is what makes this more than a conflict with every + // other flag. + let err = parse(&spec, &input(&["ex", "--dump", "t"])).unwrap_err(); + assert!(err.to_string().contains("on its own"), "{err}"); + + // Not given, so it imposes nothing. + parse(&spec, &input(&["ex", "--verbose", "t"])).expect("without it, nothing changes"); + } + + #[test] + fn an_exclusive_flag_is_not_disturbed_by_a_default() { + // Only what was supplied counts, as `conflicts` reads it. A default counting as + // company would make an exclusive flag unusable on any command that has one. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--jobs \" default=\"4\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--dump"])).expect("a default is nobody saying anything"); + assert!(parse(&spec, &input(&["ex", "--dump", "--jobs", "8"])).is_err()); + } + + #[test] + fn an_exclusive_flag_bypasses_required_siblings() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out \" required=#true\narg \"\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--dump"])) + .expect("exclusive is the command's requiredness escape"); + assert!(parse( + &spec, + &input(&["ex", "--dump", "--out", "somewhere", "target"]) + ) + .is_err()); + } + + #[test] + fn an_environment_value_counts_for_an_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out \" env=\"EX_OUT\"\n" + .parse() + .unwrap(); + + assert!(parse_with_env(&spec, &["ex", "--dump"], &[("EX_OUT", "somewhere")]).is_err()); + parse_with_env(&spec, &["ex", "--dump"], &[]).expect("without the value it is alone"); + } + + #[test] + fn a_selected_subcommand_counts_for_an_ancestor_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--version\" global=#true exclusive=#true\ncmd \"run\"\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "--version"])).expect("alone is allowed"); + assert!(parse(&spec, &input(&["ex", "--version", "run"])).is_err()); + } + + #[test] + fn a_child_exclusive_flag_is_not_mistaken_for_a_same_named_parent_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child flag is alone within the child command"); + assert!( + parse(&spec, &input(&["ex", "--clean", "run"])).is_err(), + "the parent flag still conflicts with selecting the child" + ); + } + + #[test] + fn a_child_local_exclusive_redeclaration_belongs_to_the_child() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child-local exclusive flag is alone inside the child command"); + assert!( + parse(&spec, &input(&["ex", "--clean", "run"])).is_err(), + "the ancestor spelling still conflicts with selecting the child" + ); + } + + #[test] + fn a_same_named_parent_flag_is_company_for_a_child_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" global=#true exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])).expect("the child exclusive flag is alone"); + assert!( + parse(&spec, &input(&["ex", "--clean", "run", "--clean"])).is_err(), + "the distinct parent declaration is still company despite sharing a name" + ); + } + + #[test] + fn a_local_child_redeclaration_keeps_its_exclusivity_when_merged() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child exclusive flag is valid alone"); + assert!( + parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])).is_err(), + "merging with the inherited global must not discard child exclusivity" + ); + } + + #[test] + fn an_orphan_parent_alias_does_not_disown_a_child_local_exclusive_flag() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the typed long form belongs to the child declaration"); + assert!( + parse(&spec, &input(&["ex", "run", "-c"])).is_err(), + "the inherited short form still belongs to the ancestor" + ); + assert!( + parse(&spec, &input(&["ex", "run", "-c", "--clean"])).is_err(), + "a child spelling cannot mask the ancestor-exclusive occurrence on the same merged flag" + ); + } + + #[test] + fn an_inherited_alias_keeps_its_ancestor_exclusivity() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" global=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("the child's spelling does not activate the orphan ancestor alias"); + assert!( + parse(&spec, &input(&["ex", "run", "-c"])).is_err(), + "the inherited short alias still belongs to the ancestor exclusive flag" + ); + } + + #[test] + fn an_inherited_negated_alias_keeps_its_ancestor_exclusivity() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" negate=\"--no-clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"-c --clean\" global=#true\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "--no-clean"])).is_err(), + "the inherited negated alias still belongs to the ancestor exclusive flag" + ); + } + + #[test] + fn a_colliding_alias_does_not_disown_the_child_from_the_rest() { + // The child re-declares the inherited `--clean` as exclusive and gives it a `-c` that + // an unrelated inherited global already owns. That collision is resolved in the other + // global's favor, so the child's `-c` resolves elsewhere — but the child plainly owns + // the `--clean` it declared, and its exclusivity holds. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\nflag \"-c --config \" global=#true\ncmd \"run\" {\n flag \"-c --clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])).expect("alone is allowed"); + assert!( + parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])).is_err(), + "one unrelated alias collision cannot disown the child from its own flag" + ); + } + + #[test] + fn a_local_child_declaration_is_not_in_scope_before_the_subcommand() { + // A child's *local* re-declaration describes the flag at the child. Typed ahead of the + // subcommand word the flag can only be the ancestor's, because that is the only one in + // scope there — so the ancestor's exclusivity is the one that answers, whichever way it + // is set. The pair below differ in nothing else, which is what makes this one rule + // rather than two behaviors. + let quiet: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + parse(&quiet, &input(&["ex", "--clean", "run", "--verbose"])) + .expect("the ancestor owns this occurrence, and it is not exclusive"); + assert!( + parse(&quiet, &input(&["ex", "run", "--clean", "--verbose"])).is_err(), + "after the subcommand word the child's declaration is in scope, and it is exclusive" + ); + + let loud: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n" + .parse() + .unwrap(); + assert!( + parse(&loud, &input(&["ex", "--clean", "run"])).is_err(), + "the same rule, with an exclusive ancestor: selecting the child is company for it" + ); + } + + #[test] + fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_plain_child_redeclaration() { + // The mirror of `a_local_child_redeclaration_keeps_its_exclusivity_when_merged`: the + // child owns `--clean` and says nothing about exclusivity, but `-c` is a spelling only + // the ancestor ever declared, so the ancestor's answer still governs it. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\"\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "-c"])).is_err(), + "the orphan ancestor alias is still the ancestor's exclusive flag" + ); + parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])) + .expect("the child's own spelling drops the exclusivity the child did not restate"); + } + + #[test] + fn a_child_spelling_carries_its_exclusivity_even_beside_an_ancestor_spelling() { + // Both spellings of one merged flag, typed together. The child's `--clean` is exclusive + // whatever else was typed alongside it, so `--verbose` is company; attributing the whole + // occurrence to the ancestor because `-c` appeared in it lost that. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + assert!( + parse(&spec, &input(&["ex", "run", "-c", "--clean", "--verbose"])).is_err(), + "the child spelling is exclusive whatever it was typed beside" + ); + parse(&spec, &input(&["ex", "run", "-c", "--verbose"])) + .expect("the ancestor's own spelling was never exclusive"); + } + + #[test] + fn an_environment_value_takes_the_exclusivity_of_the_declaration_in_scope() { + // An environment value has no spelling to attribute, so the declaration the selected + // command has in scope answers — in both directions. Comparing whole alias sets asked + // the ancestor instead, because the merged flag also carries its orphan `-c`. + let added: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true env=\"EX_CLEAN\"\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + assert!( + parse_with_env(&added, &["ex", "run", "--verbose"], &[("EX_CLEAN", "1")]).is_err(), + "the child added exclusivity the environment value has to honor" + ); + + let dropped: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true env=\"EX_CLEAN\"\ncmd \"run\" {\n flag \"--clean\"\n flag \"--verbose\"\n}\n" + .parse() + .unwrap(); + + parse_with_env(&dropped, &["ex", "run", "--verbose"], &[("EX_CLEAN", "1")]) + .expect("the child dropped the exclusivity, and the environment value follows it"); + } + + #[test] + fn a_merged_child_exclusive_flag_still_escapes_requiredness() { + // Exclusivity suppresses missing-value checks, and that has to survive the merge for + // the same reason the companion check does. + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--out \" required=#true\n}\n" + .parse() + .unwrap(); + + parse(&spec, &input(&["ex", "run", "--clean"])) + .expect("a merged child exclusive flag is still the command's requiredness escape"); + } + #[test] fn a_group_allows_one_member_and_refuses_two() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file \"\nflag \"--url \"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n" diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 64e8db12e..479860776 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -116,6 +116,14 @@ pub struct SpecFlag { /// had. #[serde(skip_serializing_if = "Vec::is_empty")] pub requires: Vec, + /// Whether this flag must be given on its own. + /// + /// The whole-command form of [`SpecFlag::conflicts`]: `--version` and `--help` are + /// the shape — asking for one means the rest of the command line has nothing to act + /// on. Everything the command declares counts, positionals included, which is what + /// makes this different from being in a group with every other flag. + #[serde(skip_serializing_if = "is_false")] + pub exclusive: bool, /// Raises the effect of the command when this flag is supplied. /// See [`crate::spec::effect::SpecCommandEffect`]; never lowers it. #[serde(skip_serializing_if = "Option::is_none")] @@ -177,6 +185,7 @@ impl SpecFlag { "overrides" => flag.overrides = vec![v.ensure_string()?], "conflicts" => flag.conflicts = vec![v.ensure_string()?], "requires" => flag.requires = vec![v.ensure_string()?], + "exclusive" => flag.exclusive = v.ensure_bool()?, "effect" => { let raw = v.ensure_string()?; match raw.parse() { @@ -284,6 +293,7 @@ impl SpecFlag { .map(|arg| arg.ensure_string()) .collect::>>()?; } + "exclusive" => flag.exclusive = child.arg(0)?.ensure_bool()?, "requires" => { flag.requires = child .ensure_arg_len(1..)? @@ -448,6 +458,9 @@ impl From<&SpecFlag> for KdlNode { } children.nodes_mut().push(requires); } + if flag.exclusive { + node.push(KdlEntry::new_prop("exclusive", true)); + } if let Some(env) = &flag.env { node.push(string_entry(Some("env"), env)); } @@ -614,6 +627,8 @@ impl From<&clap::Arg> for SpecFlag { // than guessed at, and counted by `gen-shadow` as a thing the clap dialect // cannot carry. requires: vec![], + // This one clap does expose, unlike `requires` just above. + exclusive: c.is_exclusive_set(), help, help_long, help_md: None, @@ -771,6 +786,29 @@ mod tests { assert_eq!(reparsed.cmd.flags[1].requires.len(), 2, "{spec}"); } + #[test] + fn exclusive_round_trips_and_comes_across_from_clap() { + let spec: Spec = "flag \"--dump\" exclusive=#true\nflag \"--verbose\"\n" + .parse() + .unwrap(); + assert!(spec.cmd.flags[0].exclusive); + assert!(!spec.cmd.flags[1].exclusive); + + let reparsed: Spec = spec.to_string().parse().unwrap(); + assert!(reparsed.cmd.flags[0].exclusive, "{spec}"); + + // Unlike `requires`, clap answers for this one — `Arg::is_exclusive_set` — so a + // spec generated from a clap command carries it. + let cmd = clap::Command::new("ex") + .arg(clap::Arg::new("dump").long("dump").exclusive(true)) + .arg(clap::Arg::new("verbose").long("verbose")); + let spec = Spec::from(&cmd); + let dump = spec.cmd.flags.iter().find(|f| f.name == "dump").unwrap(); + assert!(dump.exclusive); + let verbose = spec.cmd.flags.iter().find(|f| f.name == "verbose").unwrap(); + assert!(!verbose.exclusive); + } + #[test] fn requires_cannot_come_across_from_clap() { // Not an oversight to be fixed later: clap 4 has `Arg::requires` as a setter diff --git a/xtask/src/shadow.rs b/xtask/src/shadow.rs index 4d098574f..c62232da0 100644 --- a/xtask/src/shadow.rs +++ b/xtask/src/shadow.rs @@ -842,6 +842,9 @@ fn usage_flag_opts( if !flag.requires.is_empty() { opts.push(selector_list("requires", &flag.requires)); } + if flag.exclusive { + opts.push("exclusive = true".into()); + } if !flag.required_if.is_empty() { opts.push(selector_list("required_if", &flag.required_if)); } @@ -957,6 +960,9 @@ fn clap_flag_opts( if flag.negate.is_some() { skipped.note("a negated flag"); } + if flag.exclusive { + opts.push("exclusive = true".into()); + } if flag.global { opts.push("global = true".into()); }