Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
139 changes: 139 additions & 0 deletions conformance/tests/effect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! What a command does to the world, declared beside the command.
//!
//! Not something clap can express, so a CLI that wants it keeps a side table keyed by command
//! path and applies it to the generated spec afterwards. communique's `command_effects.rs` is
//! two hundred lines of exactly that, and mise has the same file for the same reason.
//!
//! The spec's rule, which this only records: an invocation's effect is the highest of the
//! command's and of every flag supplied, so a flag can raise what a command does and never
//! lower it.

use usage::Spec as LibSpec;
use usage_derive::{Args, Cli, Subcommands};

/// Generate release notes for a git tag
#[derive(Args)]
#[usage(effect = "read")]
struct Generate {
/// Push editorialized notes to the GitHub release
#[usage(long, effect = "write")]
github_release: bool,
/// Generate notes without updating GitHub
#[usage(long)]
dry_run: bool,
}

/// Generate a config file in the repo root
#[derive(Args)]
#[usage(effect = "write")]
struct Init {
/// Overwrite an existing config file
#[usage(long, effect = "destructive")]
force: bool,
}

/// Say nothing about what it does
#[derive(Args)]
struct Undeclared {
#[usage(long)]
plain: bool,
}

#[derive(Subcommands)]
enum Command {
/// Generate release notes for a git tag
Generate(Box<Generate>),
/// Generate a config file in the repo root
Init(Box<Init>),
/// Say nothing about what it does
Undeclared(Box<Undeclared>),
}

/// Editorialized release notes powered by AI
#[derive(Cli)]
#[usage(bin = "communique", name = "communique")]
struct Cli_ {
#[usage(subcommand)]
command: Option<Command>,
}

#[test]
fn a_command_says_what_it_does_to_the_world() {
// Through the spec, because that is what a consumer reads: the point of declaring it is that
// something downstream — a confirmation prompt, an audit, a `--dry-run` wrapper — can tell
// `init` from `generate` without knowing either.
let spec: LibSpec = Cli_::to_kdl().parse().expect("valid spec");
let effect = |name: &str| {
spec.cmd
.subcommands
.get(name)
.unwrap_or_else(|| panic!("{name}"))
.effect
};
assert_eq!(effect("generate"), Some(usage::SpecCommandEffect::Read));
assert_eq!(effect("init"), Some(usage::SpecCommandEffect::Write));

// Unsaid is not "safe". A consumer treats the absence as "ask", so leaving it off has to
// stay distinguishable from declaring `read` — which is why this is an `Option`.
assert_eq!(effect("undeclared"), None);
}

#[test]
fn a_flag_can_raise_what_its_command_does() {
// `generate` only prints; `generate --github-release` writes. The spec takes an invocation's
// effect to be the highest of the command's and of every flag given, so the flag is where
// that difference belongs — and a table keyed by command path cannot say it at all.
let spec: LibSpec = Cli_::to_kdl().parse().expect("valid spec");
let generate = spec.cmd.subcommands.get("generate").expect("generate");
let flag = |name: &str| {
generate
.flags
.iter()
.find(|f| f.name == name)
.unwrap_or_else(|| panic!("{name}"))
.effect
};
assert_eq!(
flag("github-release"),
Some(usage::SpecCommandEffect::Write)
);

// And a flag that changes nothing about what the command does says nothing.
assert_eq!(flag("dry-run"), None);

let init = spec.cmd.subcommands.get("init").expect("init");
assert_eq!(
init.flags
.iter()
.find(|f| f.name == "force")
.unwrap()
.effect,
Some(usage::SpecCommandEffect::Destructive)
);
}

#[test]
fn the_fields_are_bound() {
// Keeps every declared field read, which CI requires of a test CLI.
use std::ffi::OsStr;
let argv = ["init", "--force"].map(OsStr::new);
let cli = Cli_::parse_from(&argv).expect("should parse");
let Some(Command::Init(init)) = cli.command else {
panic!("expected init")
};
assert!(init.force);

let argv = ["generate", "--github-release", "--dry-run"].map(OsStr::new);
let cli = Cli_::parse_from(&argv).expect("should parse");
let Some(Command::Generate(g)) = cli.command else {
panic!("expected generate")
};
assert!(g.github_release && g.dry_run);

let argv = ["undeclared", "--plain"].map(OsStr::new);
let cli = Cli_::parse_from(&argv).expect("should parse");
let Some(Command::Undeclared(u)) = cli.command else {
panic!("expected undeclared")
};
assert!(u.plain);
}
14 changes: 14 additions & 0 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,9 +719,16 @@ fn flag_meta(i: usize, field: &Field, owner: &syn::Ident) -> TokenStream {

let (completer_decl, completer) = completer_tokens(i, field, "flag", owner);

// `None` unless declared, and only on a flag: an argument is not something a user
// supplies to change what happens, it is the thing being acted on.
let effect = field
.effect
.clone()
.unwrap_or_else(|| quote!(::core::option::Option::None));
quote! {
#completer_decl
pub static #name: FlagMeta = FlagMeta {
effect: #effect,
complete: #completer,
flag: &#table,
help: #help,
Expand Down Expand Up @@ -2143,6 +2150,12 @@ pub fn emit_args(cli: &Cli) -> TokenStream {
.filter(|f| !matches!(f.kind, Kind::Subcommand { .. }))
.map(field_final);

// The root cannot carry one — the spec writer asserts it — so this is the non-root
// path's alone, which is also the only path a command's own declaration reaches.
let effect = cli
.effect
.clone()
.unwrap_or_else(|| quote!(::core::option::Option::None));
quote! {
#[doc(hidden)]
#[allow(
Expand Down Expand Up @@ -2178,6 +2191,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream {

pub static COMMAND_META: CommandMeta = CommandMeta {
cmd: &COMMAND,
effect: #effect,
about: #about,
long_about: #long_about,
restart_token: #restart_token,
Expand Down
4 changes: 3 additions & 1 deletion derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@
//! one, generated from the function rather than declared beside it.
//!
//! On the struct itself: `bin`, `version`, `about`, `long_about`, `before_help`, `after_help`,
//! `default_subcommand`, `completion` — which adds the hidden command a generated shell
//! `default_subcommand`, `effect` — what running this command does to the world, on an `Args`
//! rather than on the root, which does nothing itself — `completion`, which adds the hidden command a generated shell
//! script calls, and needs usage-argv's `complete` feature enabled where it is depended on —
//! and `settings`, for a CLI whose bound flags all live in a flattened group (see [Settings]).
//!
Expand All @@ -174,6 +175,7 @@
//! | `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 |
//! | `help_heading = "x"` | the section to list this under in help output |
//! | `hide` | keep it out of help and completions |
//! | `effect = "write"` | what supplying this flag does to the world: `read`, `write` or `destructive` |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
//! | `double_dash = "…"` | how a positional relates to `--`: `optional` (the default), `required` (fillable only after one), `preserve` (the `--` is a value), `automatic` (filling it ends flag parsing, so a wrapper forwards) |
//! | `complete = my_fn` | a function that answers for this value when a shell asks |
//! | `value_enum` | the words come from the field's type, which derives [`ValueEnum`] |
Expand Down
88 changes: 88 additions & 0 deletions derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ pub struct Cli {
/// CLI with a subcommand depend on `usage-config`. A root that binds a setting of its own has
/// already said it, and does not need this.
pub settings: bool,
/// What running this command does to the world, when it says.
///
/// Held as the tokens for an `Option<Effect>`, since the only thing it becomes is a field of
/// a generated `static` — a second enum here would be a copy of the spec's to keep in step.
pub effect: Option<proc_macro2::TokenStream>,
/// Where `#[usage(...)]` was written on the struct, when it was.
///
/// Every position rule in [`Cli::check_position`] is about an attribute in the wrong place,
Expand Down Expand Up @@ -111,6 +116,11 @@ pub struct Field {
/// model checks — a `String` has one place to put a value.
pub default: Vec<String>,
pub help_heading: Option<String>,
/// What supplying this flag does to the world, when it says.
///
/// A flag can only *raise* what its command does — `--dry-run` does not make a writing
/// command read-only — which is the spec's rule and not this crate's to enforce.
pub effect: Option<proc_macro2::TokenStream>,
/// Whether a collecting argument needs at least one value.
///
/// Required-ness is normally the type's to say: a bare `String` has nowhere to put
Expand Down Expand Up @@ -177,6 +187,34 @@ pub enum DoubleDash {
Automatic,
}

/// What a command or a flag does to the world, as the spec spells it.
///
/// Not something clap can express, so a CLI that wants it keeps a side table keyed by command
/// path and applies it to the generated spec afterwards — communique's `command_effects.rs` is
/// two hundred lines of exactly that. Declared beside the command instead, where the code that
/// does the thing is.
fn effect_value(meta: &Meta) -> syn::Result<proc_macro2::TokenStream> {
let value = string_value(meta)?;
let variant = match value.as_str() {
"read" => quote::quote!(Read),
"write" => quote::quote!(Write),
"destructive" => quote::quote!(Destructive),
other => {
return Err(syn::Error::new_spanned(
meta,
format!(
"`effect = \"{other}\"` is not one the spec has; it takes \"read\" for \
something that only looks, \"write\" for something that changes what can \
be made again, and \"destructive\" for something that cannot be undone"
),
));
}
};
Ok(quote::quote!(::core::option::Option::Some(
::usage_argv::spec::Effect::#variant
)))
}

/// Whether a field is a flag or a positional, and how it is addressed.
pub enum Kind {
Flag {
Expand Down Expand Up @@ -277,6 +315,7 @@ impl Cli {
bin: None,
completion: false,
settings: false,
effect: None,
attr_span: input
.attrs
.iter()
Expand Down Expand Up @@ -309,6 +348,7 @@ impl Cli {
// decorative after it.
"completion" => cli.completion = flag_value(&meta)?,
"settings" => cli.settings = flag_value(&meta)?,
"effect" => cli.effect = Some(effect_value(&meta)?),
"version" => cli.version = Some(string_value(&meta)?),
// A doc comment's long form always contains its short one — the short form
// *is* the comment's first paragraph. A spec keeps `about` and `about_long`
Expand Down Expand Up @@ -465,6 +505,10 @@ impl Cli {
for (present, what) in [
(self.mount.is_some(), "mount"),
(self.restart_token.is_some(), "restart_token"),
// Bare `communique` does nothing to the world; one of its commands does. The spec
// writer asserts the root carries none, so declaring one here would trip a
// `debug_assert!` in the writer rather than say anything.
(self.effect.is_some(), "effect"),
] {
if present {
return Err(self.misplaced(
Expand Down Expand Up @@ -748,6 +792,10 @@ impl Field {
kind: Kind::Flatten {
ty: field.ty.clone(),
},
// Neither holds a flag of its own, so neither has an effect to declare: a
// flattened group's flags carry their own, and a subcommand field is a command's
// place rather than a command.
effect: None,
complete: None,
// A flattened field holds declarations, not a value, so none of what describes a
// value applies — the same as a subcommand field.
Expand Down Expand Up @@ -817,6 +865,7 @@ impl Field {
ty: field.ty.clone(),
name: to_kebab(&ident.to_string()),
kind: Kind::Subcommand { ty, optional },
effect: None,
complete: None,
// A subcommand field holds a command, not a value, so none of what
// describes a value applies to it.
Expand Down Expand Up @@ -878,6 +927,7 @@ impl Field {
let mut setting = None;
let mut default: Vec<String> = Vec::new();
let mut help_heading = None;
let mut effect = None;
let mut value_name = None;
let mut required_collection = false;
let mut help_attr: Option<String> = None;
Expand Down Expand Up @@ -984,6 +1034,7 @@ impl Field {
"var_max" => var_max = Some(int_value(&meta)?),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
"default" => default.push(string_value(&meta)?),
"help_heading" => help_heading = Some(string_value(&meta)?),
"effect" => effect = Some(effect_value(&meta)?),
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"value_name" => value_name = Some(string_value(&meta)?),
// Help text a doc comment cannot carry. A comment's first paragraph is
// read the way Rust reads one — line breaks inside it are spaces — so
Expand Down Expand Up @@ -1476,6 +1527,7 @@ impl Field {
setting,
default,
help_heading,
effect,
value_name,
required_collection,
choices,
Expand Down Expand Up @@ -2515,6 +2567,42 @@ mod tests {
.to_string()
}

#[test]
fn an_effect_the_spec_does_not_have_is_refused() {
let err = rejection(
r#"
struct Ex {
#[usage(long, effect = "mostly-harmless")]
force: bool,
}
"#,
);
assert!(err.contains("is not one the spec has"), "unhelpful: {err}");
// The message lists them, because three words are not guessable from the attribute.
assert!(err.contains("destructive"), "unhelpful: {err}");
}

#[test]
fn the_root_does_nothing_to_the_world() {
// Bare `communique` does nothing; one of its commands does. The spec writer asserts the
// root carries no effect, so accepting it here would trade a message for a
// `debug_assert!` in the writer — which is a poor way to learn where an attribute goes.
let err = position_error(
r#"
#[usage(effect = "write")]
struct Ex {
#[usage(long)]
plain: bool,
}
"#,
true,
);
assert!(
err.contains("`effect` belongs on a command"),
"unhelpful: {err}"
);
}

#[test]
fn settings_needs_something_to_collect() {
// The attribute says the flags are declared *elsewhere*, so there has to be an
Expand Down