Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions conformance/tests/dynamic_defaults.rs
Original file line number Diff line number Diff line change
@@ -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)")
);
}
42 changes: 30 additions & 12 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
};
Expand Down Expand Up @@ -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<String>` already says the *flag* is optional and says
// nothing about whether its value is.
let value_optional = field.value_optional;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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! {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down Expand Up @@ -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 |
Expand Down
62 changes: 60 additions & 2 deletions derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<proc_macro2::TokenStream>,
/// A runtime-only function that computes one typed default.
pub default_fn: Option<syn::Path>,
pub help_heading: Option<String>,
/// `#[usage(select)]`: this flag's value picks among the command's outputs.
///
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2526,6 +2536,8 @@ impl Field {
let mut setting = None;
let mut default: Vec<String> = 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;
Expand Down Expand Up @@ -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)?),
Expand Down Expand Up @@ -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`, \
Expand Down Expand Up @@ -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" => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3796,6 +3853,7 @@ impl Field {
setting,
default,
default_value_t,
default_fn,
help_heading,
select,
display_order,
Expand Down
29 changes: 29 additions & 0 deletions docs/rust/args-and-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down