diff --git a/conformance/tests/dynamic_defaults.rs b/conformance/tests/dynamic_defaults.rs new file mode 100644 index 000000000..7c61c0327 --- /dev/null +++ b/conformance/tests/dynamic_defaults.rs @@ -0,0 +1,58 @@ +use std::ffi::OsStr; +use std::sync::atomic::{AtomicU16, Ordering}; + +use usage::Spec as LibSpec; +use usage_derive::Cli; + +static NEXT_PORT: AtomicU16 = AtomicU16::new(4100); + +fn default_port() -> u16 { + NEXT_PORT.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Debug, Cli)] +#[usage(bin = "serve")] +struct Serve { + /// Port to listen on + #[usage(long, default_fn = default_port, default_note = "selected at runtime")] + port: u16, +} + +#[test] +fn a_function_supplies_a_fresh_typed_default() { + let first = Serve::parse_from(&[]).expect("the computed default makes the flag optional"); + let second = Serve::parse_from(&[]).expect("the function is evaluated for each parse"); + assert_eq!(first.port, 4100); + assert_eq!(second.port, 4101); + + let argv = [OsStr::new("--port"), OsStr::new("9000")]; + let explicit = Serve::parse_from(&argv).expect("argv still wins"); + assert_eq!(explicit.port, 9000); +} + +#[test] +fn portable_metadata_describes_but_does_not_invent_the_runtime_value() { + let typed = Serve::spec().root.flags[0]; + assert!(!typed.required); + assert!(typed.default.is_empty()); + assert_eq!( + typed.help, + Some("Port to listen on (default: selected at runtime)") + ); + assert!(typed.long_help.is_none()); + let long = usage_argv::help::render(Serve::spec(), Serve::command(), true) + .expect("the root help page"); + assert!( + long.contains("Port to listen on (default: selected at runtime)"), + "{long}" + ); + + let kdl = Serve::to_kdl(); + assert!(!kdl.contains("default="), "{kdl}"); + let portable: LibSpec = kdl.parse().expect("the emitted spec remains portable"); + assert!(portable.cmd.flags[0].default.is_empty()); + assert_eq!( + portable.cmd.flags[0].help.as_deref(), + Some("Port to listen on (default: selected at runtime)") + ); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 6c1b00387..66430e072 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2296,7 +2296,7 @@ fn arg_table(i: usize, field: &Field) -> TokenStream { let field_name = &field.name; let var = field.shape == Shape::Many; let required = - (field.shape == Shape::Required || field.required_collection) && field.default.is_empty(); + (field.shape == Shape::Required || field.required_collection) && !field.has_default(); let Kind::Arg { double_dash } = &field.kind else { unreachable!("filtered by the caller"); }; @@ -2443,7 +2443,7 @@ fn flag_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStr // A collecting field's type cannot say whether one value is needed, so `required` may // declare it. Every other shape gets its answer from the type. let required = - (field.shape == Shape::Required || field.required_collection) && field.default.is_empty(); + (field.shape == Shape::Required || field.required_collection) && !field.has_default(); // Declared, not inferred: `Option` already says the *flag* is optional and says // nothing about whether its value is. let value_optional = field.value_optional; @@ -2653,7 +2653,7 @@ fn arg_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStre // A collecting field's type cannot say whether one value is needed, so `required` may // declare it. Every other shape gets its answer from the type. let required = - (field.shape == Shape::Required || field.required_collection) && field.default.is_empty(); + (field.shape == Shape::Required || field.required_collection) && !field.has_default(); let (choices, accepted_choices, choice_aliases, choice_details, ignore_case) = choices_tokens(cli, field); let allow_unknown_choices = field.allow_unknown_choices; @@ -4086,7 +4086,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { // runs after `apply_defaults` during relationship validation, so a predicate // that filled the field is still observable even though defaults deliberately // do not set `__given_*`. - let defaulted = !field.default.is_empty(); + let defaulted = field.has_default(); let conditionally_defaulted = default_if_would_apply(cli, field, Lookup::Module).unwrap_or_else(|| quote!(false)); Some(quote! { @@ -5253,9 +5253,27 @@ fn reset_to_default(field: &Field) -> TokenStream { Shape::Many => quote!(partial.#ident.clear();), _ => quote!(partial.#ident = ::std::default::Default::default();), }; - if field.default.is_empty() { + if !field.has_default() { return cleared; } + if let Some(default_fn) = &field.default_fn { + let value_ty = field + .value_ty + .as_ref() + .expect("a computed default belongs to a value-taking field"); + let bytes = quote!({ + let __usage_default: #value_ty = #default_fn(); + ::std::string::ToString::to_string(&__usage_default).into_bytes() + }); + return match field.shape { + Shape::Optional => quote! { + partial.#ident = ::std::option::Option::Some(#bytes); + }, + Shape::Required => quote!(partial.#ident = #bytes;), + // The model rejects these shapes before codegen. + _ => cleared, + }; + } if let Some(value) = &field.default_value_t { let value_ty = field .value_ty @@ -8317,7 +8335,7 @@ fn declared_defaults(cli: &Cli, filter_view: bool) -> TokenStream { if matches!(f.kind, Kind::Subcommand { .. } | Kind::Skip) { return None; } - if f.default.is_empty() && f.default_if.is_empty() { + if !f.has_default() && f.default_if.is_empty() { return None; } let given = format_ident!("__given_{}", f.ident); @@ -8335,7 +8353,7 @@ fn declared_defaults(cli: &Cli, filter_view: bool) -> TokenStream { }) }) .collect(); - if !f.default.is_empty() { + if f.has_default() { let assign = reset_to_default(f); fills.push(quote!(if !__usage_filled { #assign @@ -8928,7 +8946,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // meant a `Vec` marked `required` was reported as one-or-more by the spec, the help, the // manpage and the completions, and accepted zero values from the CLI that actually ran. // One expression cannot disagree with itself. - if !(f.shape == Shape::Required || f.required_collection) || !f.default.is_empty() { + if !(f.shape == Shape::Required || f.required_collection) || f.has_default() { return None; } let given = format_ident!("__given_{}", f.ident); @@ -9210,7 +9228,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // can never fail — the same reason plain required-ness skips such a field. // Decided at compile time, so the check is not merely always-true at run // time, it is not there. - if !other.default.is_empty() { + if other.has_default() { return quote!(); } let other_given = semantic_given(other); @@ -9281,7 +9299,7 @@ fn post_binding(cli: &Cli) -> TokenStream { }; } let other = other.expect("the local relationship was resolved above"); - if !other.default.is_empty() { + if other.has_default() { return quote!(); } let other_given = semantic_given(other); @@ -9543,7 +9561,7 @@ fn post_binding(cli: &Cli) -> TokenStream { .zip(&given) .zip(&active) .map(|((field, given), active)| { - if field.default.is_empty() { + if !field.has_default() { quote!((#active) && (#given)) } else { quote!(#active) @@ -9639,7 +9657,7 @@ fn post_binding(cli: &Cli) -> TokenStream { } // A field with a default is already filled, so no condition can make it // missing. Plain required-ness skips these too, and so does usage-lib. - if !f.default.is_empty() { + if f.has_default() { return None; } let given = format_ident!("__given_{}", f.ident); diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 4fbd871a4..349546b1d 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -218,7 +218,9 @@ //! fields such as `about` and `after_long_help` accept expressions usable as `&'static str`. //! A computed `version` additionally declares `version_spec = "..."`, and a typed field //! default declares both `default_value_t = EXPR` and `default = "..."`: runtime behavior -//! evaluates the expression while portable KDL uses the explicit literal. +//! evaluates the expression while portable KDL uses the explicit literal. A genuinely dynamic +//! value uses `default_fn = function` instead; an optional `default_note = "..."` reaches help, +//! while portable KDL deliberately declares no concrete default it could not reproduce. //! //! A completer is written as //! @@ -286,6 +288,8 @@ //! | `env_fallback("OLD_X", "OLDER_X")` | additional environment variables, consulted in declaration order | //! | `deprecated_env("LEGACY_X")` | deprecated aliases, consulted after ordinary fallbacks and labeled in help | //! | `default = "x"` | the value when the command line does not supply one; a `Vec` may be given several, and starts out holding all of them | +//! | `default_fn = function` | compute one typed default at parse time without claiming a concrete portable value | +//! | `default_note = "x"` | describe a `default_fn` in help; the note is prose, not a value | //! | `help_heading = "x"` | the section to list this under in help output | //! | `display_order = n` | explicit help order; positional parsing still follows declaration order | //! | `verbatim_doc_comment` | preserve line breaks and whitespace in the doc comment instead of flowing its first paragraph | diff --git a/derive/src/model.rs b/derive/src/model.rs index c53637ab7..bec0f44c9 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -327,6 +327,8 @@ pub struct Field { /// A typed Rust default evaluated when parsing starts. `default` remains /// the explicit portable spelling emitted in static metadata. pub default_value_t: Option, + /// A runtime-only function that computes one typed default. + pub default_fn: Option, pub help_heading: Option, /// `#[usage(select)]`: this flag's value picks among the command's outputs. /// @@ -1921,6 +1923,10 @@ fn dup(span: Span, first: Span, message: &str) -> syn::Error { } impl Field { + pub fn has_default(&self) -> bool { + !self.default.is_empty() || self.default_fn.is_some() + } + /// A field marked `#[usage(skip)]`, if this is one. /// /// The field is not an argument, and is filled from `Default` when the struct is built. @@ -1991,6 +1997,7 @@ impl Field { setting: None, default: Vec::new(), default_value_t: None, + default_fn: None, help_heading: None, select: false, display_order: None, @@ -2139,6 +2146,7 @@ impl Field { setting: None, default: Vec::new(), default_value_t: None, + default_fn: None, help_heading: None, select: false, display_order: None, @@ -2291,6 +2299,7 @@ impl Field { setting: None, default: Vec::new(), default_value_t: None, + default_fn: None, help_heading: None, select: false, display_order: None, @@ -2421,6 +2430,7 @@ impl Field { setting: None, default: Vec::new(), default_value_t: None, + default_fn: None, help_heading: None, select: false, display_order: None, @@ -2526,6 +2536,8 @@ impl Field { let mut setting = None; let mut default: Vec = Vec::new(); let mut default_value_t = None; + let mut default_fn = None; + let mut default_note = None; let mut help_heading = None; let mut select = false; let mut display_order = None; @@ -2817,6 +2829,17 @@ impl Field { } }); } + "default_fn" => { + let value = &meta.require_name_value()?.value; + let Expr::Path(path) = value else { + return Err(syn::Error::new_spanned( + value, + "`default_fn` takes a function, as in `default_fn = default_format`", + )); + }; + default_fn = Some(path.path.clone()); + } + "default_note" => default_note = Some(string_value(&meta)?), "help_heading" => help_heading = Some(string_value(&meta)?), "display_order" => display_order = Some(int_value(&meta)?), "effect" => effect = Some(effect_value(&meta)?), @@ -2869,7 +2892,7 @@ impl Field { `short`, `negate`, `global`, `var`, `variadic`, \ `count`, `action`, `hide`, `hide_default_value`, `hide_env`, `hide_env_values`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, \ `hide_possible_values`, `hide_short_help`, `hide_long_help`, \ - `arg`, `env`, `env_fallback`, `deprecated_env`, `default`, `default_value_t`, `choices`, `validate`, \ + `arg`, `env`, `env_fallback`, `deprecated_env`, `default`, `default_value_t`, `default_fn`, `default_note`, `choices`, `validate`, \ `validate_error`, \ `var_min`, `var_max`, `value_enum`, `value_hint`, `overrides`, \ `conflicts`, `requires`, `group`, `exclusive`, \ @@ -3078,6 +3101,26 @@ impl Field { )); } } + if default_fn.is_some() { + if !default.is_empty() || default_value_t.is_some() { + return Err(syn::Error::new( + span, + "`default_fn` computes the runtime default and cannot be combined with `default` or `default_value_t`", + )); + } + if matches!(shape, Shape::Bool | Shape::Count | Shape::Many) { + return Err(syn::Error::new( + span, + "`default_fn` is for one value-taking field; switches, counts, and collections use static defaults", + )); + } + } + if default_note.is_some() && default_fn.is_none() { + return Err(syn::Error::new( + span, + "`default_note` describes a runtime-computed default, so it requires `default_fn`", + )); + } for value in &default { match shape { Shape::Bool if value != "true" && value != "false" => { @@ -3363,7 +3406,21 @@ impl Field { // Declared text wins over the comment, which is the point of declaring it. A comment's // first paragraph is read the way Rust reads one — line breaks inside it become spaces — // so help whose breaks are deliberate has to be given directly. - let (help, long_help) = (help_attr.or(help), long_help_attr.or(long_help)); + let (mut help, mut long_help) = (help_attr.or(help), long_help_attr.or(long_help)); + if let Some(note) = default_note.as_deref().filter(|_| !hide_default_value) { + let annotation = format!("(default: {note})"); + help = Some(match help { + Some(help) if !help.trim().is_empty() => format!("{help} {annotation}"), + _ => annotation.clone(), + }); + if let Some(detail) = long_help.as_mut() { + *detail = if detail.trim().is_empty() { + annotation + } else { + format!("{detail} {annotation}") + }; + } + } // A flag is named after the form it answers to, not after the Rust field holding it. // usage-lib derives the name the same way, so the two agree about what a flag is @@ -3796,6 +3853,7 @@ impl Field { setting, default, default_value_t, + default_fn, help_heading, select, display_order, diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md index 59f255770..e08adac2b 100644 --- a/docs/rust/args-and-flags.md +++ b/docs/rust/args-and-flags.md @@ -96,9 +96,38 @@ The tables below cover the attributes most CLIs need. | `env_fallback("A", "B")` | Try additional environment variables in declaration order | | `deprecated_env("OLD_X")` | Try deprecated aliases last, and report the one that supplied a value | | `default = "…"` | Fall back to this value (repeatable for `Vec` fields) | +| `default_fn = function` | Compute one typed Rust default at parse time without emitting a concrete value | +| `default_note = "…"` | Describe a `default_fn` in help without pretending the note is its value | | `default_missing = "…"` | Value when the flag is given with none (`--color` vs `--color=never`) | | `default_if("--json", "true")` | Default when another flag is given (two args = present, three = equals) | +Use `default_fn` when the answer depends on the current platform, environment, or another runtime +fact. The function returns the field's value type and is called for each parse: + +```rust +use std::io::IsTerminal as _; + +fn default_format() -> OutputFormat { + if std::io::stdout().is_terminal() { + OutputFormat::Pretty + } else { + OutputFormat::Json + } +} + +#[usage( + long, + default_fn = default_format, + default_note = "pretty on a terminal, JSON otherwise" +)] +format: OutputFormat, +``` + +The emitted portable spec marks the field optional and carries the note as help prose, but emits +no `default`: another consumer cannot reproduce a Rust function and should not be told a guessed +value. Use `default_value_t = EXPR` beside `default = "literal"` when the computed Rust expression +does have one stable portable spelling. + **Parsing behavior** — how tokens on the line are read: | Attribute | Effect |