diff --git a/argh/examples/simple_example.rs b/argh/examples/simple_example.rs index 13f3a6e..762ea17 100644 --- a/argh/examples/simple_example.rs +++ b/argh/examples/simple_example.rs @@ -5,8 +5,13 @@ use {argh::FromArgs, std::fmt::Debug}; #[derive(FromArgs, PartialEq, Debug)] +#[argh(global)] /// Top-level command. struct TopLevel { + #[argh(switch, short = 'v')] + /// verbose mode + verbose: bool, + #[argh(subcommand)] nested: MySubCommandEnum, } diff --git a/argh/src/lib.rs b/argh/src/lib.rs index 7decc64..774b76e 100644 --- a/argh/src/lib.rs +++ b/argh/src/lib.rs @@ -495,6 +495,22 @@ pub trait FromArgs: Sized { /// ``` fn from_args(command_name: &[&str], args: &[&str]) -> Result; + /// Implementation of `from_args` that accepts global arguments. + /// This allows subcommands to recognize parent options when `#[argh(global)]` is used. + /// + /// * `command_name` and `args` are the same as in `from_args`. + /// * `parent_options` ("global") is usually passed in by a top-level command. + #[doc(hidden)] + fn from_args_global( + command_name: &[&str], + args: &[&str], + parent_options: Option<&mut ParseStructOptions<'_>>, + ) -> Result { + // Default implementation for types that don't support global arguments + let _ = parent_options; + Self::from_args(command_name, args) + } + /// Get a String with just the argument names, e.g., options, flags, subcommands, etc, but /// without the values of the options and arguments. This can be useful as a means to capture /// anonymous usage statistics without revealing the content entered by the end user. @@ -938,6 +954,7 @@ impl_flag_for_integers![u8, u16, u32, u64, u128, i8, i16, i32, i64, i128,]; /// `parse_options`: Helper to parse optional arguments. /// `parse_positionals`: Helper to parse positional arguments. /// `parse_subcommand`: Helper to parse a subcommand. +/// `parent_options`: Optional parent command's options ("global" arguments). /// `help_func`: Generate a help message. #[doc(hidden)] pub fn parse_struct_args( @@ -946,6 +963,7 @@ pub fn parse_struct_args( mut parse_options: ParseStructOptions<'_>, mut parse_positionals: ParseStructPositionals<'_>, mut parse_subcommand: Option>, + mut parent_options: Option<&mut ParseStructOptions<'_>>, help_func: &dyn Fn() -> String, ) -> Result<(), EarlyExit> { let mut help = false; @@ -970,12 +988,18 @@ pub fn parse_struct_args( return Err("Trailing arguments are not allowed after `help`.".to_string().into()); } - parse_options.parse(next_arg, &mut remaining_args)?; + parse_options.parse(next_arg, &mut remaining_args).or_else( + |err| match parent_options.as_mut() { + Some(parent) => parent.parse(next_arg, &mut remaining_args), + None => Err(err), + }, + )?; continue; } if let Some(ref mut parse_subcommand) = parse_subcommand { - if parse_subcommand.parse(help, cmd_name, next_arg, remaining_args)? { + let parent_options = Some(&mut parse_options); + if parse_subcommand.parse(help, cmd_name, next_arg, remaining_args, parent_options)? { // Unset `help`, since we handled it in the subcommand help = false; break 'parse_args; @@ -1155,7 +1179,11 @@ pub struct ParseStructSubCommand<'a> { // The function to parse the subcommand arguments. #[allow(clippy::type_complexity)] - pub parse_func: &'a mut dyn FnMut(&[&str], &[&str]) -> Result<(), EarlyExit>, + pub parse_func: &'a mut dyn FnMut( + &[&str], + &[&str], + Option<&mut ParseStructOptions<'_>>, + ) -> Result<(), EarlyExit>, } impl ParseStructSubCommand<'_> { @@ -1165,6 +1193,7 @@ impl ParseStructSubCommand<'_> { cmd_name: &[&str], arg: &str, remaining_args: &[&str], + parent_options: Option<&mut ParseStructOptions<'_>>, ) -> Result { for subcommand in self.subcommands.iter().chain(self.dynamic_subcommands.iter()) { if subcommand.name == arg @@ -1180,7 +1209,7 @@ impl ParseStructSubCommand<'_> { remaining_args }; - (self.parse_func)(&command, remaining_args)?; + (self.parse_func)(&command, remaining_args, parent_options)?; return Ok(true); } diff --git a/argh/tests/global_options.rs b/argh/tests/global_options.rs new file mode 100644 index 0000000..69434d4 --- /dev/null +++ b/argh/tests/global_options.rs @@ -0,0 +1,138 @@ +// Copyright (c) 2026 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. + +// Test that #[argh(global)] allows parent command options after subcommands + +use argh::FromArgs; + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(global)] +/// Top-level command with global option placement +struct TopCommand { + #[argh(switch, short = 'v')] + /// verbose mode + verbose: bool, + + #[argh(option, short = 'c')] + /// config file + config: Option, + + #[argh(subcommand)] + sub: SubCmd, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +enum SubCmd { + Run(RunCmd), + Build(BuildCmd), +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "run")] +/// Run the program +struct RunCmd { + #[argh(option, short = 'n')] + /// number of iterations + iterations: usize, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "build")] +/// Build the program +struct BuildCmd { + #[argh(switch, short = 'r')] + /// release mode + release: bool, +} + +fn parse_from(args: &[&str]) -> Result { + TopCommand::from_args(&["test"], args) +} + +#[test] +fn global_option_after_subcommand() { + let result = parse_from(&["run", "-n", "5", "--verbose"]).unwrap(); + assert!(result.verbose); + assert_eq!(result.sub, SubCmd::Run(RunCmd { iterations: 5 })); +} + +#[test] +fn global_option_before_subcommand() { + let result = parse_from(&["--verbose", "run", "-n", "5"]).unwrap(); + assert!(result.verbose); + assert_eq!(result.sub, SubCmd::Run(RunCmd { iterations: 5 })); +} + +#[test] +fn global_multiple_options_after() { + let result = parse_from(&["run", "-n", "5", "--verbose", "-c", "config.toml"]).unwrap(); + assert!(result.verbose); + assert_eq!(result.config, Some("config.toml".to_string())); + assert_eq!(result.sub, SubCmd::Run(RunCmd { iterations: 5 })); +} + +#[test] +fn global_mixed_order() { + let result = parse_from(&["--verbose", "run", "-n", "5", "-c", "config.toml"]).unwrap(); + assert!(result.verbose); + assert_eq!(result.config, Some("config.toml".to_string())); + assert_eq!(result.sub, SubCmd::Run(RunCmd { iterations: 5 })); +} + +#[test] +fn global_short_option_after() { + let result = parse_from(&["run", "-n", "5", "-v"]).unwrap(); + assert!(result.verbose); + assert_eq!(result.sub, SubCmd::Run(RunCmd { iterations: 5 })); +} + +#[test] +fn global_with_build_subcommand() { + let result = parse_from(&["build", "--release", "--verbose"]).unwrap(); + assert!(result.verbose); + assert_eq!(result.sub, SubCmd::Build(BuildCmd { release: true })); +} + +// Test that non-global commands still reject options after subcommands +#[derive(FromArgs, PartialEq, Debug)] +/// Non-global command +struct NonGlobalTop { + #[argh(switch)] + /// verbose mode + verbose: bool, + + #[argh(subcommand)] + sub: NonGlobalSub, +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +enum NonGlobalSub { + Run(NonGlobalRun), +} + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand, name = "run")] +/// Run command +struct NonGlobalRun { + #[argh(option)] + /// iterations + iterations: usize, +} + +#[test] +fn non_global_rejects_option_after_subcommand() { + let result = NonGlobalTop::from_args(&["test"], &["run", "--iterations", "5", "--verbose"]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.output.contains("Unrecognized")); +} + +#[test] +fn non_global_accepts_option_before_subcommand() { + let result = + NonGlobalTop::from_args(&["test"], &["--verbose", "run", "--iterations", "5"]).unwrap(); + assert_eq!(result.verbose, true); +} diff --git a/argh_derive/src/lib.rs b/argh_derive/src/lib.rs index ff439d0..1ecf6aa 100644 --- a/argh_derive/src/lib.rs +++ b/argh_derive/src/lib.rs @@ -383,6 +383,12 @@ fn impl_from_args_struct_from_args<'a>( let append_missing_requirements = append_missing_requirements(&missing_requirements_ident, fields); + let forward_options = if type_attrs.global.is_some() { + quote! { __parent_options } + } else { + quote! { None } + }; + let parse_subcommands = if let Some(subcommand) = subcommand { let name = subcommand.name; let ty = subcommand.ty_without_wrapper; @@ -390,8 +396,8 @@ fn impl_from_args_struct_from_args<'a>( Some(argh::ParseStructSubCommand { subcommands: <#ty as argh::SubCommands>::COMMANDS, dynamic_subcommands: &<#ty as argh::SubCommands>::dynamic_commands(), - parse_func: &mut |__command, __remaining_args| { - #name = Some(<#ty as argh::FromArgs>::from_args(__command, __remaining_args)?); + parse_func: &mut |__command, __remaining_args, __parent_options| { + #name = Some(<#ty as argh::FromArgs>::from_args_global(__command, __remaining_args, #forward_options)?); ::core::result::Result::Ok(()) }, }) @@ -413,6 +419,16 @@ fn impl_from_args_struct_from_args<'a>( let method_impl = quote_spanned! { impl_span => fn from_args(__cmd_name: &[&str], __args: &[&str]) -> ::core::result::Result + { + Self::from_args_global(__cmd_name, __args, None) + } + + #[doc(hidden)] + fn from_args_global( + __cmd_name: &[&str], + __args: &[&str], + mut __parent_options: Option<&mut argh::ParseStructOptions<'_>>, + ) -> ::core::result::Result { #![allow(clippy::unwrap_in_result)] @@ -439,7 +455,8 @@ fn impl_from_args_struct_from_args<'a>( last_is_greedy: #last_positional_is_greedy, }, #parse_subcommands, - &|| #help, + __parent_options.as_deref_mut(), + &|| #help )?; let mut #missing_requirements_ident = argh::MissingRequirements::default(); @@ -534,7 +551,7 @@ fn impl_from_args_struct_redact_arg_values<'a>( Some(argh::ParseStructSubCommand { subcommands: <#ty as argh::SubCommands>::COMMANDS, dynamic_subcommands: &<#ty as argh::SubCommands>::dynamic_commands(), - parse_func: &mut |__command, __remaining_args| { + parse_func: &mut |__command, __remaining_args, _parent_options| { #name = Some(<#ty as argh::FromArgs>::redact_arg_values(__command, __remaining_args)?); ::core::result::Result::Ok(()) }, @@ -585,7 +602,8 @@ fn impl_from_args_struct_redact_arg_values<'a>( last_is_greedy: #last_positional_is_greedy, }, #redact_subcommands, - &|| #help, + None, + &|| #help )?; let mut #missing_requirements_ident = argh::MissingRequirements::default(); @@ -1105,6 +1123,16 @@ fn impl_from_args_enum( impl #impl_generics argh::FromArgs for #name #ty_generics #where_clause { fn from_args(command_name: &[&str], args: &[&str]) -> std::result::Result + { + Self::from_args_global(command_name, args, None) + } + + #[doc(hidden)] + fn from_args_global( + command_name: &[&str], + args: &[&str], + parent_options: Option<&mut argh::ParseStructOptions<'_>>, + ) -> std::result::Result { let subcommand_name = if let Some(subcommand_name) = command_name.last() { *subcommand_name @@ -1119,7 +1147,7 @@ fn impl_from_args_enum( && subcommand_name.starts_with(*<#variant_ty as argh::SubCommand>::COMMAND.short)) { return ::core::result::Result::Ok(#name_repeating::#variant_names( - <#variant_ty as argh::FromArgs>::from_args(command_name, args)? + <#variant_ty as argh::FromArgs>::from_args_global(command_name, args, parent_options)? )); } )* diff --git a/argh_derive/src/parse_attrs.rs b/argh_derive/src/parse_attrs.rs index ca7c1c0..d4062d2 100644 --- a/argh_derive/src/parse_attrs.rs +++ b/argh_derive/src/parse_attrs.rs @@ -282,6 +282,7 @@ pub struct TypeAttrs { pub examples: Vec, pub notes: Vec, pub error_codes: Vec<(syn::LitInt, syn::LitStr)>, + pub global: Option, /// Arguments that trigger printing of the help message pub help_triggers: Option>, pub usage: Option, @@ -335,6 +336,11 @@ impl TypeAttrs { { this.parse_attr_subcommand(errors, ident); } + } else if name.is_ident("global") { + if let Some(ident) = errors.expect_meta_word(&meta).and_then(|p| p.get_ident()) + { + this.parse_attr_global(errors, ident); + } } else if name.is_ident("help_triggers") { if let Some(m) = errors.expect_meta_list(&meta) { Self::parse_help_triggers(m, errors, &mut this); @@ -349,7 +355,7 @@ impl TypeAttrs { concat!( "Invalid type-level `argh` attribute\n", "Expected one of: `description`, `error_code`, `example`, `name`, ", - "`note`, `short`, `subcommand`, `usage`", + "`note`, `short`, `subcommand`, `usage`, `global`", ), ); } @@ -443,6 +449,14 @@ impl TypeAttrs { } } + fn parse_attr_global(&mut self, errors: &Errors, ident: &syn::Ident) { + if let Some(first) = &self.global { + errors.duplicate_attrs("global", first, ident); + } else { + self.global = Some(ident.clone()); + } + } + // get the list of arguments that trigger printing of the help message as a vector of strings (help_arguments("-h", "--help", "help")) fn parse_help_triggers(m: &syn::MetaList, errors: &Errors, this: &mut TypeAttrs) { let parser = Punctuated::::parse_terminated; @@ -712,6 +726,7 @@ pub fn check_enum_type_attrs(errors: &Errors, type_attrs: &TypeAttrs, type_span: examples, notes, error_codes, + global, help_triggers, usage, } = type_attrs; @@ -749,6 +764,9 @@ pub fn check_enum_type_attrs(errors: &Errors, type_attrs: &TypeAttrs, type_span: if let Some(err_code) = error_codes.first() { err_unused_enum_attr(errors, &err_code.0); } + if let Some(global) = global { + err_unused_enum_attr(errors, global); + } if let Some(triggers) = help_triggers { if let Some(trigger) = triggers.first() { err_unused_enum_attr(errors, trigger);