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
145 changes: 145 additions & 0 deletions argv/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,135 @@ pub fn find<'a>(
walk(&mut path, &mut chain, spec.root, cmd).then_some((path, chain))
}

/// The entries for `--help` and `--version`, which the parser supplies and no spec declares.
///
/// Listed because help is written for people: a reader looking for how to get help should find
/// it on the page. This reverses the rule these two used to follow — that a page lists exactly
/// what its spec declares — and the reason is that the spec has its own readers, and they are
/// not the ones reading this.
///
/// Four spellings each, because a CLI may have claimed either form for itself. The parser
/// yields to a declaration (`in_scope` looks a command's own flags up first), so a page that
/// claimed otherwise would be describing a flag that never binds.
mod supplied {
use crate::spec::FlagMeta;
use crate::Flag;

macro_rules! entry {
($name:ident, $flag:ident, $key:expr, $label:expr, $longs:expr, $shorts:expr, $help:expr) => {
static $flag: Flag<'static> = Flag {
key: $key,
name: $label,
longs: $longs,
shorts: $shorts,
..Flag::BOOL
};
pub static $name: FlagMeta<'static> = FlagMeta {
flag: &$flag,
help: Some($help),
..FlagMeta::EMPTY
};
};
}

entry!(
HELP_BOTH,
HB,
crate::HELP_LONG_KEY,
"help",
&["help"],
b"h",
"Print help"
);
entry!(
HELP_LONG_ONLY,
HL,
crate::HELP_LONG_KEY,
"help",
&["help"],
b"",
"Print help"
);
// Named `h`, not `help`: the declared name is judged against the forms the entry shows,
// and a short-only entry called `help` reads as a renamed flag — it printed `help: -h`.
entry!(
HELP_SHORT_ONLY,
HS,
crate::HELP_SHORT_KEY,
"h",
&[],
b"h",
"Print help"
);
entry!(
VERSION_BOTH,
VB,
crate::VERSION_LONG_KEY,
"version",
&["version"],
b"V",
"Print version"
);
entry!(
VERSION_LONG_ONLY,
VL,
crate::VERSION_LONG_KEY,
"version",
&["version"],
b"",
"Print version"
);
entry!(
VERSION_SHORT_ONLY,
VS,
crate::VERSION_SHORT_KEY,
"V",
&[],
b"V",
"Print version"
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

/// The supplied entries a page should list, given what the command already claims.
///
/// `--version` only where the parser actually accepts it: on a command whose table says so,
/// which the derive sets on the root when a version is declared. A page offering one that the
/// parser would refuse is worse than a page that stays quiet.
fn supplied_entries(cmd: &Command<'_>, taken: &[String]) -> Vec<&'static FlagMeta<'static>> {
// Against the same set every other decision on this page uses, so a spelling claimed by a
// hidden declaration or by a negation is claimed here too. Offering a `--help` that
// something else binds is exactly the lie the model exists to prevent.
let pick = |long: &str, short: char, both, l, s| match (
taken.contains(&format!("--{long}")),
taken.contains(&format!("-{short}")),
) {
(true, true) => None,
(true, false) => Some(s),
(false, true) => Some(l),
(false, false) => Some(both),
};

let mut out = Vec::new();
out.extend(pick(
"help",
'h',
&supplied::HELP_BOTH,
&supplied::HELP_LONG_ONLY,
&supplied::HELP_SHORT_ONLY,
));
// Only where the parser accepts one, which is the root of a CLI that declared a version.
if cmd.version {
out.extend(pick(
"version",
'V',
&supplied::VERSION_BOTH,
&supplied::VERSION_LONG_ONLY,
&supplied::VERSION_SHORT_ONLY,
));
}
Comment thread
cursor[bot] marked this conversation as resolved.
out
}

/// Every flag a page should list, split into the command's own and the ones it inherits.
///
/// The rule the parser follows on the way down, and the same one the diagnostics suggest
Expand Down Expand Up @@ -1022,6 +1151,22 @@ fn own_and_global<'a>(
})
.collect();

// Last in the command's own section, which is where clap has them: they carry no
// `help_heading`, so a CLI that groups its flags gets them at the end of the ungrouped
// list rather than inside somebody's section.
//
// Given `taken` rather than the two lists: that set already counts hidden declarations and
// negations, and a `--help` the page offers while something else binds it is exactly the
// lie this whole model exists to prevent.
let mut own = own;
// Forms *and* negations: `long_flag` asks `find_negation` before it offers `--version`,
// so a declared negation beats a supplied flag even though it loses to a long.
let claimed: Vec<String> = taken
.iter()
.cloned()
.chain(taken_negations.iter().cloned())
.collect();
own.extend(supplied_entries(here.cmd, &claimed));
(own, inherited)
}

Expand Down
14 changes: 8 additions & 6 deletions conformance/tests/help_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,15 @@ fn asking_for_help_does_not_stop_the_cli_working() {
}

#[test]
fn a_help_flag_is_not_in_the_spec_it_renders() {
// The two are supplied by the parser and belong to no command's metadata, so they cannot
// appear in help output or in the emitted spec. A page advertising a `--help` that the spec
// it came from does not declare is a page that disagrees with its own CLI.
fn a_help_flag_is_listed_but_still_not_declared() {
// The split that replaced "a page lists exactly what its spec declares". The page names
// `-h, --help`, because help is written for people and someone looking for how to ask for
// help should find it there. The *spec* still does not declare it: the parser supplies it,
// and a spec claiming otherwise would have every reader inventing a flag its CLI never
// declared.
let (_, page) = ask(&["--help"]);
assert!(!page.contains("--help"), "{page}");
assert!(!page.contains("-h "), "{page}");
assert!(page.contains("-h, --help"), "{page}");
assert!(page.contains("Print help"), "{page}");

let kdl = Ex::to_kdl();
assert!(!kdl.contains("help\""), "{kdl}");
Expand Down
99 changes: 83 additions & 16 deletions conformance/tests/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,31 +187,55 @@ fn a_declared_long_wins_too() {
}

#[test]
fn the_flag_is_not_in_the_help_page_or_the_spec() {
// Supplied rather than declared, exactly as `--help` is. A page listing a flag the spec
// does not declare is a page that disagrees with the spec it was rendered from.
fn the_flag_is_listed_but_still_not_declared() {
// Listed, because a reader looking for the version should find it where they are looking.
// Not *declared*: the parser supplies it, and a spec claiming otherwise would have every
// reader inventing a flag its CLI never wrote.
let page = usage_argv::help::render(Versioned::spec(), Versioned::spec().root.cmd, true)
.expect("a page");
assert!(!page.contains("--version"), "{page}");

// Asserted on the metadata rather than by searching the page for `-V`: `go` declares one of
// its own, and it is listed — correctly — in the commands summary. What must be absent is a
// *root* flag nobody declared.
assert!(
!Versioned::spec()
.root
.flags
.iter()
.any(|f| f.flag.shorts.contains(&b'V') || f.flag.longs.contains(&"version")),
"the root lists a flag it does not declare"
);
assert!(page.contains("-V, --version"), "{page}");
assert!(page.contains("Print version"), "{page}");

let kdl = Versioned::to_kdl();
assert!(!kdl.contains("--version"), "{kdl}");
// But the version itself is declared, which is what the flag answers with.
assert!(kdl.contains(r#"version "1.2.3""#), "{kdl}");
}

#[test]
fn a_command_that_cannot_answer_does_not_offer() {
// `--version` is the root's, so a subcommand's page must not list one: the parser would
// refuse it there, and a page offering a flag that gets refused is worse than a quiet one.
let root = Versioned::spec().root.cmd;
let other = root
.subcommands
.iter()
.find(|c| c.name == "other")
.expect("other");
let page = usage_argv::help::render(Versioned::spec(), other, true).expect("a page");
assert!(!page.contains("--version"), "{page}");
// And help, which every command does answer, is still there.
assert!(page.contains("-h, --help"), "{page}");

// Nor does a CLI that declares no version at all.
let page = usage_argv::help::render(Unversioned::spec(), Unversioned::spec().root.cmd, true)
.expect("a page");
assert!(!page.contains("--version"), "{page}");
}

#[test]
fn a_claimed_spelling_is_not_offered_twice() {
// `OwnShort` takes `-V` for `--verbose`. The page must show the spelling that is still
// free and not claim the one that is taken, or it would describe a flag that never binds.
let page = usage_argv::help::render(OwnShort::spec(), OwnShort::spec().root.cmd, true)
.expect("a page");
assert!(page.contains("--version"), "{page}");
assert!(
!page.contains("-V, --version"),
"`-V` belongs to `--verbose` here: {page}"
);
}

#[test]
fn the_fields_are_bound() {
let a = argv(&["--quiet"]);
Expand Down Expand Up @@ -250,3 +274,46 @@ fn a_failure_renders_the_way_a_user_should_read_it() {
"{message}"
);
}

/// Takes `--help` for itself, with a flag nobody can see
#[derive(Cli)]
#[usage(bin = "hidden-help", version = "1.0")]
struct HiddenHelp {
/// Hidden, and still binds — so the page must not offer `--help` as its own
#[usage(long = "help", hide)]
help_of_its_own: bool,
/// Its negation is `-V`'s long form, which counts as claiming it
#[usage(long = "quiet", negate = "--version")]
quiet: bool,
}

#[test]
fn a_hidden_or_negated_claim_still_counts() {
// The parser looks a command's own flags up first and does not care whether they are
// shown, so a hidden `--help` binds and a negation named `--version` binds. Offering
// either as the supplied entry would describe an action that typing it does not perform.
let page = usage_argv::help::render(HiddenHelp::spec(), HiddenHelp::spec().root.cmd, true)
.expect("a page");
let listing = page.split_once("\nFlags:").expect("a flags section").1;

// `--help` is claimed, so only the short form is offered — and named after the form it
// shows, or it renders as a renamed flag: `help: -h`.
assert!(listing.contains(" -h "), "{page}");
assert!(!listing.contains("help: -h"), "{page}");
assert!(!listing.contains("-h, --help"), "{page}");

// `--version` is claimed by a negation, so only `-V` is offered.
assert!(!listing.contains("-V, --version"), "{page}");
}

#[test]
fn the_hidden_help_fields_are_bound() {
use std::ffi::OsStr;
let argv = ["--help"].map(OsStr::new);
let parsed = HiddenHelp::parse_from(&argv).expect("its own flag, not a help request");
assert!(parsed.help_of_its_own);

let argv = ["--version"].map(OsStr::new);
let parsed = HiddenHelp::parse_from(&argv).expect("its own negation, not a version request");
assert!(!parsed.quiet);
}
Loading