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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 18 additions & 10 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -824,16 +824,24 @@ feature list is not an exhaustive audit.
`parse_from_argv` is the explicit full-argv helper: it strips the binary
for ordinary CLIs and applies the same basename-selected applet rewrite as
`parse()` for multicall CLIs, while returning errors for tests and embedders.
- [ ] **Generated micro-conformance against clap.** One minimal CLI per matrix row,
compared on accepted and rejected argv, typed values, error kind and exit
status, stdout versus stderr, short and long help, usage/version output, and
completion candidates. Include setting-specific diagnostics: for example,
clap explains that `--flag=value` is required when `require_equals` rejects
a detached or missing value, while usage currently reports only a generic
missing value and forced Aube to adapt that error locally. Run the portable
cases on Unix and Windows and the byte-value cases on Unix. The mise fuzzer
remains the scale test; this is the configuration-space test it cannot be.
- [ ] **Combination and stateful tests.** Pairwise-cover settings that interact:
- [~] **Generated micro-conformance against clap.** A paired typed-CLI harness now
compares `require_equals`, defaults, delimiters, negative and hyphenated values,
value enums, scalar overrides, fixed arity, globals through subcommands, required
flags, conflicts/requires, required exclusive groups, optional values with equals,
external subcommands, `arg_required_else_help`, and subcommand requirement/conflict
policies. It
records typed values, error classifications, exit status and stream, relevant help,
and version output; the remaining matrix rows and completion candidates still need
equivalent minimal pairs. One minimal CLI per matrix row,
compared on accepted and rejected argv, typed values, error kind and exit
status, stdout versus stderr, short and long help, usage/version output, and
completion candidates. Include setting-specific diagnostics: for example,
clap explains that `--flag=value` is required when `require_equals` rejects
a detached or missing value, while usage currently reports only a generic
missing value and forced Aube to adapt that error locally. Run the portable
cases on Unix and Windows and the byte-value cases on Unix. The mise fuzzer
remains the scale test; this is the configuration-space test it cannot be.
- [x] **Combination and stateful tests.** Focused typed conformance cases pair
defaults with env and delimiters, optional values with `require_equals`,
globals with overrides, subcommands with required positionals, groups with
defaults, and help/version collisions. Add `update_from` cases if that API is
Expand Down
29 changes: 20 additions & 9 deletions argv/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,14 +588,22 @@ pub fn render(
.find(|m| core::ptr::eq(m.flag, *flag))
.and_then(|m| m.value_name)
})
.map(|v| format!(" <{v}>"))
.unwrap_or_default();
let _ = writeln!(
out,
"{} a value is required for '{}' but none was supplied",
style.error("error:"),
style.invalid(&format!("{name}{value}"))
);
.unwrap_or(flag.name);
if flag.require_equals {
let _ = writeln!(
out,
"{} equal sign is needed when assigning values to '{}'",
style.error("error:"),
style.invalid(&format!("{name}=<{value}>"))
);
} else {
let _ = writeln!(
out,
"{} a value is required for '{}' but none was supplied",
style.error("error:"),
style.invalid(&format!("{name} <{value}>"))
);
}
}
Error::InvalidChoice { name, choices } => {
let shown_name = shown(here, name);
Expand Down Expand Up @@ -703,7 +711,10 @@ pub fn render(
// message would hide that rather than help.
// Neither is a failure, and a caller that has not handled them before reaching here
// has a bug this cannot paper over.
Error::Help { .. } | Error::HelpAll { .. } | Error::Version { .. } => {
Error::Help { .. }
| Error::MissingArgsHelp { .. }
| Error::HelpAll { .. }
| Error::Version { .. } => {
return String::new();
}
}
Expand Down
99 changes: 85 additions & 14 deletions argv/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,21 @@ impl Style {
/// Colour when stdout is a terminal and the environment permits it.
pub fn auto() -> Style {
use std::io::IsTerminal as _;
Self::auto_for(std::io::stdout().is_terminal())
}

/// Colour when stderr is a terminal and the environment permits it.
pub fn auto_stderr() -> Style {
use std::io::IsTerminal as _;
Self::auto_for(std::io::stderr().is_terminal())
}

fn auto_for(is_terminal: bool) -> Style {
let forced = std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0");
let refused = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
if refused {
Style::PLAIN
} else if forced || std::io::stdout().is_terminal() {
} else if forced || is_terminal {
Comment thread
jdx marked this conversation as resolved.
Style::COLOURED
} else {
Style::PLAIN
Expand Down Expand Up @@ -92,7 +102,9 @@ fn styled_flag_usage(usage: &str, style: Style) -> String {
let end = rest[start..]
.char_indices()
.skip(1)
.find_map(|(i, c)| (c.is_whitespace() || matches!(c, ',' | ']' | '>')).then_some(i))
.find_map(|(i, c)| {
(c.is_whitespace() || matches!(c, ',' | '=' | '[' | ']' | '>')).then_some(i)
})
Comment thread
jdx marked this conversation as resolved.
.unwrap_or(rest.len() - start)
+ start;
out.push_str(&rest[..start]);
Expand Down Expand Up @@ -503,11 +515,6 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String {
if flag.takes_value {
// Angled where the value must be given, squared where it need not — the same brackets
// an argument uses, and for the same reason. pitchfork's `--bump` is the fleet's case.
let (open, close) = if meta.value_optional {
('[', ']')
} else {
('<', '>')
};
let exact = exact_arity(meta.value_var_min, meta.value_var_max);
if meta.value_names.len() <= 1 && exact.is_some_and(|n| n > 1) {
let name = meta
Expand All @@ -516,8 +523,14 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String {
.copied()
.or(meta.value_name)
.unwrap_or(flag.name);
for _ in 0..exact.unwrap() {
let _ = write!(out, " {open}{name}{close}");
for index in 0..exact.unwrap() {
append_flag_value(
&mut out,
name,
meta.value_optional,
flag.require_equals,
index == 0,
);
}
} else if meta.value_names.len() <= 1 {
let name = meta
Expand All @@ -526,10 +539,22 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String {
.copied()
.or(meta.value_name)
.unwrap_or(flag.name);
let _ = write!(out, " {open}{name}{close}");
append_flag_value(
&mut out,
name,
meta.value_optional,
flag.require_equals,
true,
);
} else {
for name in meta.value_names {
let _ = write!(out, " {open}{name}{close}");
for (index, name) in meta.value_names.iter().enumerate() {
append_flag_value(
&mut out,
name,
meta.value_optional,
flag.require_equals,
index == 0,
);
}
}
if flag.variadic && meta.value_names.len() <= 1 && exact.is_none() {
Expand All @@ -539,6 +564,22 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String {
out
}

fn append_flag_value(
out: &mut String,
name: &str,
optional: bool,
require_equals: bool,
first: bool,
) {
if first && optional && require_equals {
let _ = write!(out, "[={name}]");
} else {
let separator = if first && require_equals { "=" } else { " " };
let (open, close) = if optional { ('[', ']') } else { ('<', '>') };
let _ = write!(out, "{separator}{open}{name}{close}");
}
}

/// How one positional argument appears: `<TOOL>`, `[FILES]…`, `-- <ARGS>`.
/// Whether a flag must be given, which is not quite what `required` says.
///
Expand Down Expand Up @@ -2366,12 +2407,30 @@ fn recursive_help<'a>(
#[cfg(test)]
mod style_tests {
use super::{
commands_section, flag_notes, flat_commands_short, inline_environment_notes, styled_help,
Style,
commands_section, flag_notes, flag_usage, flat_commands_short, inline_environment_notes,
styled_flag_usage, styled_help, Style,
};
use crate::spec::{CommandMeta, FlagMeta};
use crate::{Command, Flag};

#[test]
fn optional_equals_values_put_the_equals_inside_the_brackets() {
let flag = Flag {
name: "color",
longs: &["color"],
require_equals: true,
..Flag::VALUE
};
let meta = FlagMeta {
flag: &flag,
value_name: Some("WHEN"),
value_optional: true,
..FlagMeta::EMPTY
};

assert_eq!(flag_usage(&meta), "--color[=WHEN]");
}

#[test]
fn flattened_next_line_deprecation_follows_help_without_a_blank_row() {
let flag = Flag {
Expand Down Expand Up @@ -2536,6 +2595,18 @@ mod style_tests {
assert_eq!(strip_ansi(&coloured), page);
}

#[test]
fn equals_separates_a_coloured_flag_from_its_value() {
assert_eq!(
styled_flag_usage("--output=<FILE>", Style::COLOURED),
"\u{1b}[36m--output\u{1b}[0m=<FILE>"
);
assert_eq!(
styled_flag_usage("--color[=WHEN]", Style::COLOURED),
"\u{1b}[36m--color\u{1b}[0m[=WHEN]"
);
}

fn strip_ansi(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars().peekable();
Expand Down
6 changes: 6 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,12 @@ pub enum Error<'t, 'v> {
/// clap has them. The caller renders — this crate does not print, because a library that
/// writes to stdout on its own is one an adopter cannot embed.
Help { cmd: &'t Command<'t>, long: bool },
/// `arg_required_else_help` found no command-line arguments for `cmd`.
///
/// Unlike an explicit help request, this is a usage failure: clap prints the short help to
/// stderr and exits with status 2. Keeping the shape separate lets embedders preserve that
/// terminal contract without guessing why [`Error::Help`] was returned.
MissingArgsHelp { cmd: &'t Command<'t> },
/// Recursive long help was requested for `cmd` and every visible descendant.
HelpAll { cmd: &'t Command<'t> },
/// `--version` or `-V` was asked for. Not a failure either — the caller prints and leaves.
Expand Down
1 change: 1 addition & 0 deletions conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ usage-cli = { workspace = true }
usage-lib = { workspace = true }

[dev-dependencies]
clap = { version = "4", features = ["derive", "env"] }
insta = "1"
usage-derive = { workspace = true }
usage-validation = { workspace = true }
Expand Down
6 changes: 2 additions & 4 deletions conformance/tests/arg_required_else_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,21 @@ struct DefaultRun {

#[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 {
let Err(Error::MissingArgsHelp { cmd }) = 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 {
let Err(Error::MissingArgsHelp { cmd }) = 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;
Expand Down
Loading
Loading