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
20 changes: 10 additions & 10 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,15 +489,15 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and
allocation-free `parse_from` primitive always has that contract, while
clap-shaped `try_parse_from` includes argv0 by default and honors
`#[command(no_binary_name)]` by routing directly to the primitive.
- [ ] **Command parsing policy** — `arg_required_else_help`,
- [x] **`arg_required_else_help`.** The derive, portable spec, usage-lib,
clap bridge, and generated Go front door show short help when the selected
command receives no argv tokens. This deliberately reads argv rather than
bound values: an environment variable or default may fill a field, but an
empty command line still means help.
- [ ] **Remaining command parsing policy** —
`args_conflicts_with_subcommands`, `subcommand_negates_reqs`,
`subcommand_precedence_over_arg`, `allow_missing_positional`,
and `args_override_self`. One design decision is made ahead of the
implementation: `arg_required_else_help` reads argv, not bound values.
clap counts an environment-supplied value as present (clap#3572), so a
user with a configured environment never sees the help — with MISE_*
set, that is most mise users. An empty command line means help,
whatever the environment holds.
`subcommand_precedence_over_arg`, `allow_missing_positional`, and
`args_override_self`.

**Help output**

Expand Down Expand Up @@ -1143,8 +1143,8 @@ in all of them.
The fleet's workarounds also route around usage, not only clap. These are the
items the survey adds; the ones already tracked as unchecked boxes under **What
clap can say that we cannot** (positionals in relationships, the complete
relationship families, command parsing policy including
`arg_required_else_help`, fixed arity, the full `ValueHint` vocabulary) are not
relationship families, remaining command parsing policy, fixed arity, the full
`ValueHint` vocabulary) are not
repeated here.

- [ ] **The completer channel is unescaped Tera into `sh -c`.** aube's
Expand Down
30 changes: 29 additions & 1 deletion argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ pub struct Command<'a> {
/// unmatched word is taken, remaining tokens — including `--help` — are not parsed
/// as this command's flags.
pub external_subcommand: bool,
/// Show this command's help when no argv token follows its name.
///
/// This is clap's `arg_required_else_help`. It deliberately observes argv rather than
/// bound values: an environment variable or default may fill a field, but neither means
/// the user supplied an argument to this invocation.
pub arg_required_else_help: bool,
/// Accept an unambiguous prefix of a subcommand name or alias.
/// Inherited by nested commands.
pub infer_subcommands: bool,
Expand Down Expand Up @@ -230,6 +236,7 @@ impl Command<'_> {
subcommands: &[],
default_subcommand: ::core::option::Option::None,
external_subcommand: false,
arg_required_else_help: false,
infer_subcommands: false,
infer_long_args: false,
unknown_flags: ::core::option::Option::None,
Expand Down Expand Up @@ -1173,7 +1180,8 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
self.help_span
}

/// Where the command in scope began: the index in `argv` just after its name.
/// Where the command in scope began: the index in `argv` just after its name, or at the
/// unmatched word routed into a default subcommand.
///
/// `argv[command_start()..]` is what that command was given, which is what a completion
/// callback needs to be handed its own command's half-parsed struct rather than the root's.
Expand Down Expand Up @@ -1568,6 +1576,11 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
self.default_taken = true;
self.descend(default)?;
self.pos -= 1;
// Unlike an explicitly named command, the default command receives the
// word that caused descent. Keep its argv boundary at that word so
// command-level policies and completion callbacks see the same input the
// command parser is about to re-read.
self.cmd_start = self.pos;
return Ok(Event::Command(default));
}
}
Expand Down Expand Up @@ -2465,6 +2478,21 @@ mod tests {
);
}

#[test]
fn a_default_subcommand_starts_at_the_word_it_receives() {
let a = argv(["build"]);
let mut parser = Parser::new(&DEFAULTING, &a);
assert_eq!(parser.next_event(), Some(Ok(Event::Command(&RUN))));
assert_eq!(parser.command_start(), 0);
assert_eq!(
parser.next_event(),
Some(Ok(Event::Arg {
arg: &RUN_TASK,
value: b"build"
}))
);
}

#[test]
fn the_default_can_be_named_by_an_alias() {
// usage-lib resolves the name against subcommand names, aliases and hidden aliases
Expand Down
6 changes: 6 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,9 @@ impl Spec<'_> {
if self.root.cmd.external_subcommand {
writeln!(out, "external_subcommand #true")?;
}
if self.root.cmd.arg_required_else_help {
writeln!(out, "arg_required_else_help #true")?;
}
if self.root.cmd.infer_subcommands {
writeln!(out, "infer_subcommands #true")?;
}
Expand Down Expand Up @@ -1328,6 +1331,9 @@ fn write_command<'a>(
if meta.cmd.external_subcommand {
out.push_str(" external_subcommand=#true");
}
if meta.cmd.arg_required_else_help {
out.push_str(" arg_required_else_help=#true");
}
if meta.cmd.infer_subcommands {
out.push_str(" infer_subcommands=#true");
}
Expand Down
6 changes: 0 additions & 6 deletions clap_usage/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ pub enum FidelityFeature {
DistinctValueNames,
GranularHide,
DontDelimitTrailingValues,
ArgRequiredElseHelp,
AllowMissingPositional,
ArgsConflictWithSubcommands,
ArgsOverrideSelf,
Expand Down Expand Up @@ -92,11 +91,6 @@ fn visit(cmd: &Command, ancestors: &[String], losses: &mut BTreeSet<FidelityLoss
FidelityFeature::DontDelimitTrailingValues,
"dont_delimit_trailing_values",
);
command_loss(
cmd.is_arg_required_else_help_set(),
FidelityFeature::ArgRequiredElseHelp,
"arg_required_else_help",
);
command_loss(
cmd.is_allow_missing_positional_set(),
FidelityFeature::AllowMissingPositional,
Expand Down
4 changes: 2 additions & 2 deletions clap_usage/tests/fidelity_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ fn reports_detectable_losses_with_locations() {
.arg(Arg::new("target"))
.group(ArgGroup::new("input").args(["config", "target"]));

let (_spec, report) = spec_with_report(&mut command, "ex");
let (spec, report) = spec_with_report(&mut command, "ex");
assert!(spec.cmd.arg_required_else_help);
let features: Vec<_> = report.losses().iter().map(|loss| loss.feature).collect();
for expected in [
FidelityFeature::ArgRequiredElseHelp,
FidelityFeature::Environment,
FidelityFeature::ValueHint,
FidelityFeature::ValueTerminator,
Expand Down
1 change: 1 addition & 0 deletions conformance/src/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub fn build(
version: false,
unknown_flags,
external_subcommand: cmd.external_subcommand,
arg_required_else_help: cmd.arg_required_else_help,
infer_subcommands: cmd.infer_subcommands,
infer_long_args: cmd.infer_long_args,
key: 0,
Expand Down
98 changes: 98 additions & 0 deletions conformance/tests/arg_required_else_help.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use std::ffi::OsStr;

use usage_argv::Error;
use usage_derive::{Args, Cli, Subcommands};

fn argv<const N: usize>(tokens: [&str; N]) -> [&OsStr; N] {
tokens.map(OsStr::new)
}

#[derive(Cli)]
#[command(bin = "ex", arg_required_else_help)]
struct RootPolicy {
#[arg(long, default = "defaulted")]
value: String,
}

#[derive(Cli)]
#[command(bin = "ex")]
struct NestedPolicy {
#[command(subcommand)]
command: Commands,
}

#[derive(Subcommands)]
enum Commands {
Run(Run),
}

#[derive(Args)]
#[command(arg_required_else_help)]
struct Run {
#[arg(long)]
all: bool,
}

#[derive(Cli)]
#[command(bin = "ex", default_subcommand = "run")]
struct DefaultPolicy {
#[command(subcommand)]
command: Option<DefaultCommands>,
}

#[derive(Subcommands)]
enum DefaultCommands {
Run(DefaultRun),
}

#[derive(Args)]
#[command(arg_required_else_help)]
struct DefaultRun {
task: String,
}

#[test]
fn a_bare_root_asks_for_short_help_even_when_a_default_fills_a_field() {
let Err(Error::Help { cmd, long }) = RootPolicy::parse_from(&[]) else {
panic!("a bare invocation should ask for help");
};
assert_eq!(cmd.name, "ex");
assert!(!long);

let parsed = RootPolicy::parse_from(&argv(["--value", "given"])).expect("argv was supplied");
assert_eq!(parsed.value, "given");
}

#[test]
fn a_nested_command_counts_only_tokens_after_its_own_name() {
let Err(Error::Help { cmd, long }) = NestedPolicy::parse_from(&argv(["run"])) else {
panic!("the command name selects run but is not one of run's arguments");
};
assert_eq!(cmd.name, "run");
assert!(!long);

let parsed = NestedPolicy::parse_from(&argv(["run", "--all"])).expect("run has an argument");
let Commands::Run(run) = parsed.command;
assert!(run.all);
}

#[test]
fn a_default_command_counts_the_unmatched_word_routed_into_it() {
let parsed = DefaultPolicy::parse_from(&argv(["build"])).expect("run received argv");
let Some(DefaultCommands::Run(run)) = parsed.command else {
panic!("the default command should be selected");
};
assert_eq!(run.task, "build");
}

#[test]
fn the_policy_is_emitted_in_the_portable_spec() {
let root = RootPolicy::to_kdl();
assert!(root.contains("arg_required_else_help #true"), "{root}");

let nested = NestedPolicy::to_kdl();
assert!(
nested.contains("cmd \"run\" arg_required_else_help=#true"),
"{nested}"
);
}
13 changes: 13 additions & 0 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ pub fn emit(cli: &Cli) -> TokenStream {
let default_subcommand = option_str(cli.default_subcommand.as_deref());
let multicall = cli.multicall;
let no_binary_name = cli.no_binary_name;
let arg_required_else_help = cli.arg_required_else_help;
let usage = option_str(cli.usage.as_deref());
let restart_token = option_str(cli.restart_token.as_deref());
let mount = option_str(cli.mount.as_deref());
Expand Down Expand Up @@ -422,6 +423,7 @@ pub fn emit(cli: &Cli) -> TokenStream {
unknown_flags: #unknown_flags,
infer_subcommands: #infer_subcommands,
infer_long_args: #infer_long_args,
arg_required_else_help: #arg_required_else_help,
name: #name,
key: #root_key,
flags: #flag_table_ref,
Expand Down Expand Up @@ -560,6 +562,15 @@ pub fn emit(cli: &Cli) -> TokenStream {
apply(partial, &__usage_event);
}

if __usage_parser.command().arg_required_else_help
&& __usage_parser.command_start() == argv.len()
{
return ::std::result::Result::Err(usage_argv::Error::Help {
cmd: __usage_parser.command(),
long: false,
});
}
Comment thread
jdx marked this conversation as resolved.

::std::result::Result::Ok(())
}

Expand Down Expand Up @@ -3305,6 +3316,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream {
let unknown_flags = unknown_flags_tokens(cli);
let infer_subcommands = cli.infer_subcommands;
let infer_long_args = cli.infer_long_args;
let arg_required_else_help = cli.arg_required_else_help;
let before_help = option_expr(cli.before_help.as_ref());
let before_long_help = option_expr(cli.before_long_help.as_ref());
let after_help = option_expr(cli.after_help.as_ref());
Expand Down Expand Up @@ -3416,6 +3428,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream {
unknown_flags: #unknown_flags,
infer_subcommands: #infer_subcommands,
infer_long_args: #infer_long_args,
arg_required_else_help: #arg_required_else_help,
flags: #flag_table_ref,
args: #arg_table_ref,
#sub_commands
Expand Down
1 change: 1 addition & 0 deletions derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
//! On the struct itself: `bin`, `version`, `about`, `long_about`, `before_help`, `after_help`,
//! `verbatim_doc_comment` — preserve doc-comment line breaks and whitespace —
//! `default_subcommand`, `multicall` — argv[0]'s basename selects a subcommand —
//! `arg_required_else_help` — a selected command with no argv of its own shows short help —
//! `infer_subcommands`, `infer_long_args` — accept unambiguous prefixes and inherit that
//! policy through nested commands —
//! `min_usage_version` — the oldest `usage` that can read the emitted
Expand Down
6 changes: 5 additions & 1 deletion derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ pub struct Cli {
pub multicall: bool,
/// Whether clap-shaped `try_parse_from` input omits argv0.
pub no_binary_name: bool,
/// Show help when no argv token follows this command's name.
pub arg_required_else_help: bool,
/// Declared descriptions, for the case a doc comment cannot express: a long form that does
/// not contain the short one.
pub about_attr: Option<proc_macro2::TokenStream>,
Expand Down Expand Up @@ -525,6 +527,7 @@ impl Cli {
default_subcommand: None,
multicall: false,
no_binary_name: false,
arg_required_else_help: false,
about_attr: None,
long_about_attr: None,
before_help: None,
Expand Down Expand Up @@ -662,6 +665,7 @@ impl Cli {
}
"multicall" => cli.multicall = flag_value(&meta)?,
"no_binary_name" => cli.no_binary_name = flag_value(&meta)?,
"arg_required_else_help" => cli.arg_required_else_help = flag_value(&meta)?,
"infer_subcommands" => cli.infer_subcommands = flag_value(&meta)?,
"infer_long_args" => cli.infer_long_args = flag_value(&meta)?,
"restart_token" => cli.restart_token = Some(string_value(&meta)?),
Expand Down Expand Up @@ -694,7 +698,7 @@ impl Cli {
format!(
"unknown option `{other}` on a struct; usage::Cli takes \
`name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `usage`, `verbatim_doc_comment`, `unknown_flags`, \
`default_subcommand`, `multicall`, `no_binary_name`, `infer_subcommands`, \
`default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `infer_subcommands`, \
Comment thread
cursor[bot] marked this conversation as resolved.
`infer_long_args`, `next_help_heading`, `restart_token`, `mount` and \
`group` here, and the description comes from the doc \
comment"
Expand Down
2 changes: 1 addition & 1 deletion docs/rust/clap-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa
| `multicall` | yes | yes | yes | yes | yes | yes | Process-level entry points route using the executable basename. |
| `no_binary_name` | yes | yes | n/a | yes | n/a | yes | `parse_from` is words-only; full-argv helpers honor the command policy. |
| `infer_subcommands`, `infer_long_args` | yes | yes | yes | yes | yes | usage-only | Unambiguous names and aliases are inherited; clap exposes no public getter. |
| `arg_required_else_help` | no | no | no | no | no | no | The planned usage behavior tests argv presence rather than environment-bound values. |
| `arg_required_else_help` | yes | yes | yes | yes | yes | yes | Bare selected commands request short help; defaults and environment values do not count as argv. |
Comment thread
jdx marked this conversation as resolved.
| remaining subcommand/argument policies | no | no | no | no | no | no | `args_conflicts_with_subcommands`, precedence, missing positionals, and self-override are not represented. |
| unknown flags | different | different | different | different | yes | different | usage is permissive by default; `unknown_flags = "error"` opts into strict parsing. |

Expand Down
3 changes: 3 additions & 0 deletions docs/rust/migrating-from-clap.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ Clap's global prefix settings migrate as
clap exposes setters but not getters for these settings, a `clap::Command` bridge cannot infer
that declaration for you.

`#[command(arg_required_else_help)]` migrates in place. usage checks whether the selected
command received an argv token; environment and default fallbacks do not count.

## Fields

Common mappings retain their meaning:
Expand Down
2 changes: 1 addition & 1 deletion docs/rust/subcommands.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ struct Install {
`#[usage(subcommand)]` field, up to a maximum depth of 16.

Variant attributes: `name`, `alias`, `alias_hidden`, `hide`, `effect`, `help`, `long_help`,
`verbatim_doc_comment`, `external_subcommand`. Aliases declared on the variant and on the `Args`
`verbatim_doc_comment`, `external_subcommand`, `arg_required_else_help`. Aliases declared on the variant and on the `Args`
struct are joined.

Two variants wrapping the _same_ struct is a compile error — each command needs its own
Expand Down
13 changes: 13 additions & 0 deletions docs/spec/reference/cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ as this command's flags.

See [the argv grammar](../argv.md#external-subcommands).

### Require an argument or show help

`arg_required_else_help` makes a bare invocation request the command's short help:

```kdl
arg_required_else_help #true // for the whole CLI
cmd "run" arg_required_else_help=#true // or one command
```

The policy observes argv belonging to the selected command. A global flag before a
subcommand does not count as that subcommand's argument, and values supplied later by an
environment variable or default do not suppress help.

### Inferred prefixes

A command can accept an unambiguous prefix of a subcommand name or alias, a long
Expand Down
Loading
Loading