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
9 changes: 8 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,14 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d
Holding it to parity found six more things a spec could say that the derive could not —
value names, required collections, `var` on a count, the spec's own `about`, `hide` on a
command, and help text whose line breaks matter — plus a bug in usage-lib, which printed
everything marked `hide`.
everything marked `hide`. Then three more, found while starting on `--help`: a variant's short
description was hiding the struct's long one, which is the shape every generated CLI has;
a doc comment's lines were trimmed one by one, flattening every indented example in help;
and a program could not describe itself twice over, since a comment's long form always
contains its short one where a spec keeps the two independent. `--help`'s own layout is
written and not yet at parity — 123 of 211 pages differ, each remaining cause a metadata
path where the shadow's description is not the spec's — so it is held back rather than
shipped wrong.
- [ ] **Completions, self-contained** — `<bin> completion <shell>` emits the
script; a hidden `<bin> complete-word` serves requests from the binary's own
embedded spec. Same dispatch shape usage-cli uses today, without requiring
Expand Down
9 changes: 5 additions & 4 deletions benches/shadows/mise-clap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5476,11 +5476,12 @@ pub struct WhichArgs {
pub bin_name: Option<String>,
}

/// Dev tools, env vars, and tasks in one CLI
///
/// mise prepares your development environment before each command runs. https://github.com/jdx/mise
#[derive(Parser)]
#[command(name = "mise")]
#[command(
name = "mise",
about = "Dev tools, env vars, and tasks in one CLI",
long_about = "mise prepares your development environment before each command runs. https://github.com/jdx/mise"
)]
pub struct Cli {
/// Continue running tasks even if one fails
#[arg(long = "continue-on-error", short = 'c', hide = true)]
Expand Down
10 changes: 6 additions & 4 deletions benches/shadows/mise/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5759,11 +5759,13 @@ pub struct WhichArgs {
pub bin_name: ::std::option::Option<::std::string::String>,
}

/// Dev tools, env vars, and tasks in one CLI
///
/// mise prepares your development environment before each command runs. https://github.com/jdx/mise
#[derive(Cli)]
#[usage(bin = "mise", default_subcommand = "run")]
#[usage(
bin = "mise",
about = "Dev tools, env vars, and tasks in one CLI",
long_about = "mise prepares your development environment before each command runs. https://github.com/jdx/mise",
default_subcommand = "run"
)]
pub struct Cli {
/// Continue running tasks even if one fails
#[usage(long = "continue-on-error", short = 'c', hide)]
Expand Down
95 changes: 95 additions & 0 deletions conformance/tests/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,98 @@ fn a_command_can_be_hidden() {
};
assert!(shims.shims);
}

/// A command whose short description is on the enum and whose long one is on the struct.
///
/// The shape every generated CLI has, and mise's own: the variant says what the command is for
/// in a line, and the struct's comment carries the detail.
#[derive(Args)]
/// Initializes mise in the current shell session
///
/// This should go into your shell's rc file.
/// Otherwise, it will only take effect in the current session.
///
/// echo 'eval "$(mise activate zsh)"' >> ~/.zshrc
struct ActivateArgs {
/// Use shims instead of modifying PATH
#[usage(long)]
shims: bool,
}

#[derive(Subcommands)]
enum SplitCommands {
/// Initializes mise in the current shell session
Activate(Box<ActivateArgs>),
}

#[derive(Cli)]
#[usage(
bin = "split",
about = "Dev tools, env vars, and tasks in one CLI",
long_about = "split prepares your development environment before each command runs."
)]
struct Split {
#[usage(subcommand)]
command: Option<SplitCommands>,
}

#[test]
fn a_variants_short_description_does_not_hide_the_structs_long_one() {
// Each falls back on its own. A variant that gave a short description was suppressing the
// struct's long one, so the long form went missing from help for every command written the
// way generated CLIs are written.
let spec: LibSpec = Split::to_kdl().parse().expect("valid spec");
let activate = spec.cmd.subcommands.get("activate").expect("activate");
assert_eq!(
activate.help.as_deref(),
Some("Initializes mise in the current shell session"),
"the variant's line"
);
let long = activate.help_long.as_deref().expect("the struct's detail");
assert!(long.contains("rc file"), "{long}");
}

#[test]
fn an_indented_example_in_help_keeps_its_indentation() {
// A doc comment's lines were trimmed one by one, which flattened every indented block in a
// CLI's help — and an indented block is how a spec shows a command to type. mise's help is
// full of them.
let spec: LibSpec = Split::to_kdl().parse().expect("valid spec");
let activate = spec.cmd.subcommands.get("activate").expect("activate");
let long = activate.help_long.as_deref().expect("long help");
assert!(
long.contains("\n echo 'eval"),
"the example should still be indented:\n{long}"
);
}

#[test]
fn a_program_can_describe_itself_twice_over() {
// A comment's long form always contains its short one, because the short form *is* its
// first paragraph. A spec keeps the two independent, and mise's differ entirely — so there
// is no comment that says both.
let spec: LibSpec = Split::to_kdl().parse().expect("valid spec");
assert_eq!(
spec.about.as_deref(),
Some("Dev tools, env vars, and tasks in one CLI")
);
assert_eq!(
spec.about_long.as_deref(),
Some("split prepares your development environment before each command runs.")
);
}

#[test]
fn the_split_description_cli_still_parses() {
// Reading what the fixture declares, which is also how these structs avoid being dead
// code: a test CLI nobody parses is a warning, and CI denies warnings.
use std::ffi::OsStr;

let argv = [OsStr::new("activate"), OsStr::new("--shims")];
let Some(SplitCommands::Activate(activate)) =
Split::parse_from(&argv).expect("should parse").command
else {
panic!("expected activate")
};
assert!(activate.shims);
}
20 changes: 11 additions & 9 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1615,15 +1615,17 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream {
// A doc comment on the variant wins over the struct's, since that is where a
// reader of the enum expects to describe the command. Absent one, the
// struct's own description carries through.
let (about, long_about) = match &v.help {
Some(_) => (
option_str(v.help.as_deref()),
option_str(v.long_help.as_deref()),
),
None => (
quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.about),
quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.long_about),
),
// Each falls back on its own. A variant that gives a short description was suppressing
// the struct's long one, which is exactly how a generated CLI is shaped: the enum says
// what the command is for in a line, and the struct's own comment carries the rest. The
// long form went missing from help for every command written that way.
let about = match v.help.as_deref() {
Some(help) => option_str(Some(help)),
None => quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.about),
};
let long_about = match v.long_help.as_deref() {
Some(long) => option_str(Some(long)),
None => quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.long_about),
};
// Which of the table's aliases are hidden. The visible ones are not listed
// anywhere: `cmd.aliases` minus these is what help and completions show.
Expand Down
29 changes: 28 additions & 1 deletion derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub struct Cli {
///
/// Only the root has one, and it is what mise sets by hand on the emitted spec today.
pub default_subcommand: Option<String>,
/// Declared descriptions, for the case a doc comment cannot express: a long form that does
/// not contain the short one.
pub about_attr: Option<String>,
pub long_about_attr: Option<String>,
/// The word that starts another invocation of the same command: mise's `:::`.
pub restart_token: Option<String>,
/// A command to run for subcommands discovered at completion time.
Expand Down Expand Up @@ -212,6 +216,8 @@ impl Cli {
long_about,
unknown_flags: None,
default_subcommand: None,
about_attr: None,
long_about_attr: None,
restart_token: None,
mount: None,
fields: Vec::new(),
Expand All @@ -224,6 +230,14 @@ impl Cli {
"name" => cli.name = string_value(&meta)?,
"bin" => cli.bin = Some(string_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`
// independent, and mise's differ entirely: "Dev tools, env vars, and tasks
// in one CLI" against "mise prepares your development environment before
// each command runs." There is no comment that says both, so they can be
// declared.
"about" => cli.about_attr = Some(string_value(&meta)?),
"long_about" => cli.long_about_attr = Some(string_value(&meta)?),
// A Rust CLI usually owns every flag it accepts, which is the
// case the stricter reading is for — but it is still opt-in,
// since a wrapper forwarding options wants the default.
Expand Down Expand Up @@ -261,6 +275,14 @@ impl Cli {
}
}

// Declared descriptions win over the comment, which is the point of declaring them.
if let Some(about) = cli.about_attr.take() {
cli.about = Some(about);
}
if let Some(long) = cli.long_about_attr.take() {
cli.long_about = Some(long);
}

for field in &named.named {
cli.fields.push(Field::from_field(field)?);
}
Expand Down Expand Up @@ -1562,7 +1584,12 @@ fn doc_comment(attrs: &[Attribute]) -> syn::Result<(Option<String>, Option<Strin
lit: Lit::Str(s), ..
}) = &nv.value
{
lines.push(s.value().trim().to_string());
// Only the one space `///` conventionally adds, and trailing space. Trimming
// each line outright flattened every indented example in a CLI's help — and
// mise's help is full of them, since an indented block is how a spec shows a
// command to type.
let raw = s.value();
lines.push(raw.strip_prefix(' ').unwrap_or(&raw).trim_end().to_string());
}
}
}
Expand Down
32 changes: 30 additions & 2 deletions xtask/src/shadow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,28 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r
// The root's own description is the *spec's* `about`, not the root command's help — a
// spec puts it at the top level, and the root command usually has none. Taken from the
// command first all the same, since a spec may say both.
let (about, about_long) = if is_root {
// A comment's long form always contains its short one, because the short form *is* its
// first paragraph. Where a spec's two are independent — mise's are entirely different
// sentences — no comment can say both, so the root declares them instead.
let root_declares_about = is_root
&& match (run.about, run.about_long) {
(Some(a), Some(l)) => !l.trim_start().starts_with(a.trim()),
_ => false,
};
let declared_about: Vec<String> = if root_declares_about {
[
run.about.map(|a| format!("about = {:?}", a.trim())),
run.about_long
.map(|l| format!("long_about = {:?}", l.trim_end())),
]
.into_iter()
.flatten()
.collect()
} else {
Vec::new()
};

let (about, about_long) = if is_root && !root_declares_about {
(
cmd.help.as_deref().or(run.about),
cmd.help_long.as_deref().or(run.about_long),
Expand All @@ -295,6 +316,7 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r
let mut usage_opts: Vec<String> = Vec::new();
if is_root {
usage_opts.push(format!("bin = {bin:?}"));
usage_opts.extend(declared_about.iter().cloned());
Comment thread
cursor[bot] marked this conversation as resolved.
}
for (present, declaration, what) in [
(
Expand Down Expand Up @@ -331,7 +353,13 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r
}
(true, Dialect::Clap) => {
out.push_str("#[derive(Parser)]\n");
out.push_str(&format!("#[command(name = {bin:?})]\n"));
// The descriptions go here too when a comment cannot carry them. clap takes an
// independent `about` and `long_about`, and skipping the comment without writing
// them left the clap shadow not describing the program at all — a fixture for
// comparing two frameworks cannot have one of them missing the CLI's own about.
let mut opts = vec![format!("name = {bin:?}")];
opts.extend(declared_about.iter().cloned());
out.push_str(&format!("#[command({})]\n", opts.join(", ")));
}
(false, Dialect::Usage) => {
out.push_str("#[derive(Args)]\n");
Expand Down