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.

12 changes: 10 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,16 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d

### Then: what a CLI framework has to have

- [ ] **Help rendering** — `--help` and `-h` from the static metadata, with
headings and hidden-item filtering.
- [ ] **Help rendering** — `--help` and `-h` from the static metadata, with headings and
hidden-item filtering. The **usage line** is done: `usage_argv::help::usage_line` builds
it from the tables, and all 211 of mise's commands come out byte-identical to usage-lib's,
which is the standard that matters — an adopter's help changing is a visible regression
even when it is one bracket. usage-lib renders through a tera template over a runtime
model, which this crate has no equivalent of, so the rules are reimplemented and the
parity test is what keeps them honest. Getting there also forced a fourth naming fix: a
flag is named after the form it answers to, as usage-lib names it, not after the Rust
field holding it — `type_` had been giving a flag called `type-`, which help printed and
errors reported.
- [ ] **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
208 changes: 208 additions & 0 deletions argv/src/help.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! The one-line summary of how a command is invoked.
//!
//! `Usage: mise use [OPTIONS] <TOOL@VERSION>…` — the line at the top of `--help`, and the
//! first thing a CLI framework has to be able to produce.
//!
//! Built from the same `&'static` metadata a parse ignores, so a binary that never asks for
//! help pays nothing for being able to. Nothing here is on the hot path.
//!
//! # Matching usage-lib
//!
//! usage-lib renders this from a spec, through a tera template over a runtime model. This
//! crate cannot: there is no `Spec` at run time, only the tables. So the rules are
//! reimplemented, and the test that matters compares the two outputs over every command in
//! mise's real spec — 211 of them — because an adopter's help text changing is a visible
//! regression even when it is a small one.
//!
//! Where the two disagree the difference is recorded, in the same spirit as the parser's
//! corpus: usage-lib is the reference, and a divergence is a decision rather than an accident.

use core::fmt::Write as _;

use crate::spec::{ArgMeta, CommandMeta, FlagMeta};
use crate::DoubleDash;

/// How many flags or arguments are listed individually before collapsing to a placeholder.
///
/// usage-lib's number. Beyond it the line would be longer than it is useful, so it becomes
/// `[FLAGS]` or `[ARGS]…` and the sections below carry the detail.
const INLINE_LIMIT: usize = 2;

/// The `Usage:` line's body, without the `Usage: ` prefix.
///
/// `path` is the command as invoked, starting with the binary: `["mise", "config", "ls"]`.
/// The metadata holds a tree and each node knows its own name, so the path a *particular*
/// invocation took has to come from the caller — which is the parser, or a `help` command
/// naming a command explicitly.
///
/// ```
/// use usage_argv::help::usage_line;
/// use usage_argv::spec::{ArgMeta, CommandMeta, FlagMeta};
/// use usage_argv::{Arg, Command, Flag};
///
/// static FORCE: Flag = Flag { name: "force", longs: &["force"], ..Flag::BOOL };
/// static TOOL: Arg = Arg { name: "TOOL", ..Arg::REQUIRED };
/// static CMD: Command = Command {
/// name: "use",
/// flags: &[&FORCE],
/// args: &[&TOOL],
/// ..Command::EMPTY
/// };
/// static META: CommandMeta = CommandMeta {
/// cmd: &CMD,
/// flags: &[FlagMeta { flag: &FORCE, ..FlagMeta::EMPTY }],
/// args: &[ArgMeta { arg: &TOOL, required: true, ..ArgMeta::EMPTY }],
/// ..CommandMeta::EMPTY
/// };
///
/// assert_eq!(usage_line(&["mise", "use"], &META), "mise use [--force] <TOOL>");
/// ```
pub fn usage_line(path: &[&str], meta: &CommandMeta<'_>) -> String {
let mut out = String::new();
for (i, part) in path.iter().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(part);
}

// Hidden entries are absent from the line as they are from the sections: help describes
// what a user is invited to type.
let flags: usize = meta.flags.iter().filter(|f| !f.hide).count();
if flags > 0 {
let required = meta.flags.iter().any(|f| !f.hide && flag_demanded(f));
if flags <= INLINE_LIMIT {
for flag in meta.flags.iter().filter(|f| !f.hide) {
// A required flag is angled, like a required argument: the brackets are what
// say whether leaving it out is allowed.
let (open, close) = if flag_demanded(flag) {
('<', '>')
} else {
('[', ']')
};
let _ = write!(out, " {open}{}{close}", flag_usage(flag));
}
} else if required {
out.push_str(" <FLAGS>");
} else {
out.push_str(" [FLAGS]");
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

let args: usize = meta.args.iter().filter(|a| !a.hide).count();
if args > 0 {
let required = meta.args.iter().any(|a| !a.hide && demanded(a));
if args <= INLINE_LIMIT {
for arg in meta.args.iter().filter(|a| !a.hide) {
let _ = write!(out, " {}", arg_usage(arg));
}
} else if required {
out.push_str(" <ARGS>…");
} else {
out.push_str(" [ARGS]…");
}
}

if !meta.cmd.subcommands.is_empty() {
out.push_str(" <SUBCOMMAND>");
}
out
}

/// How one flag appears in the usage line: `-f --force`, plus its value if it takes one.
fn flag_usage(meta: &FlagMeta<'_>) -> String {
let flag = meta.flag;
let mut out = String::new();

// The declared name, when it is not the one the forms would imply. A flag called
// `verbose` reachable only as `-v` has to say so, or help would name something the
// spec does not.
let implied = flag
.longs
.first()
.copied()
.or_else(|| flag.shorts.first().map(|_| ""));
let implied_matches = match (implied, flag.shorts.first()) {
(Some(long), _) if !long.is_empty() => long == flag.name,
(Some(_), Some(short)) => {
let mut buf = [0u8; 4];
(*short as char).encode_utf8(&mut buf) == flag.name
}
_ => false,
};
if !implied_matches {
let _ = write!(out, "{}:", flag.name);
}
if let Some(short) = flag.shorts.first() {
if !out.is_empty() {
out.push(' ');
}
let _ = write!(out, "-{}", *short as char);
}
if let Some(long) = flag.longs.first() {
if !out.is_empty() {
out.push(' ');
}
let _ = write!(out, "--{long}");
}

// A repeatable flag, which is the spec's `var=#true` — not one occurrence taking several
// values, which is the value's own business below.
if meta.repeatable {
out.push('…');
}
if flag.takes_value {
let name = meta.value_name.unwrap_or(flag.name);
let _ = write!(out, " <{name}>");
if flag.variadic {
out.push('…');
}
}
out
}

/// How one positional argument appears: `<TOOL>`, `[FILES]…`, `-- <ARGS>`.
/// Whether a flag must be given, which is not quite what `required` says.
///
/// Same rule as [`demanded`], for the same reason: usage-lib clears `required` on a flag that
/// declares a default before rendering, so reading the flag alone printed `<--out>` for a flag
/// the parser fills when it is left out.
fn flag_demanded(meta: &FlagMeta<'_>) -> bool {
meta.required && meta.default.is_empty()
}

/// Whether an argument must be given, which is not quite what `required` says.
///
/// usage-lib clears `required` while *parsing* a spec that declares a default — a defaulted
/// argument is one the user may leave out — and then renders the usage line from `required`
/// alone. The derive keeps the two separate, so reading `required` on its own printed `<file>`
/// where usage-lib prints `[file]`, for an argument the parser is perfectly happy to omit.
///
/// Applied here rather than by clearing the flag in the metadata, because the metadata is what
/// the emitted spec is built from and `required` there means what the author wrote.
fn demanded(meta: &ArgMeta<'_>) -> bool {
meta.required && meta.default.is_empty()
}

fn arg_usage(meta: &ArgMeta<'_>) -> String {
let arg = meta.arg;
let mut out = String::new();
let (open, close) = if demanded(meta) {
('<', '>')
} else {
('[', ']')
};
Comment thread
cursor[bot] marked this conversation as resolved.
// An argument that only takes what follows a `--` shows the separator, because typing the
// value without it does not reach this argument at all — and the brackets go *outside*
// it, as usage-lib writes it: `[-- COMMAND]…`, one optional thing rather than a literal
// `--` followed by an optional word.
if arg.double_dash == DoubleDash::Required {
let _ = write!(out, "{open}-- {}{close}", arg.name);
} else {
let _ = write!(out, "{open}{}{close}", arg.name);
}
if arg.var {
out.push('…');
}
out
}
2 changes: 2 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@

use std::ffi::{OsStr, OsString};

#[cfg(feature = "spec")]
pub mod help;
#[cfg(feature = "spec")]
pub mod spec;

Expand Down
8 changes: 7 additions & 1 deletion benches/gate/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@ publish = false
clap = { version = "4", features = ["derive", "env"] }
shadow-mise = { path = "../shadows/mise" }
shadow-mise-clap = { path = "../shadows/mise-clap" }
usage-argv = { path = "../../argv" }
usage-argv = { path = "../../argv", features = ["spec"] }

# The oracle for help rendering. A dev-dependency because nothing the gate *builds* needs it:
# it is here so the shadow's rendered help can be compared against usage-lib's over mise's
# whole spec, which is the only fixture big enough to be convincing.
[dev-dependencies]
usage-lib = { path = "../../lib" }

102 changes: 102 additions & 0 deletions benches/gate/tests/help.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Does the rendered usage line match usage-lib's, at mise's scale?
//!
//! usage-lib builds this from a spec, through a tera template over a runtime model.
//! usage-argv has no spec at run time — only `&'static` tables — so the rules are
//! reimplemented there, and reimplemented rules drift. The check is to run both over mise's
//! real spec and compare all 211 lines, because an adopter's help text changing is a visible
//! regression even when the change is one bracket.
//!
//! The shadow is generated from the same `mise.usage.kdl` that usage-lib is given here, so
//! the two sides describe the same CLI by construction rather than by a fixture kept in step
//! by hand.

use usage::{Spec as LibSpec, SpecCommand};
use usage_argv::help::usage_line;
use usage_argv::spec::CommandMeta;

/// mise's committed spec, which the shadow was generated from.
fn mise_spec() -> LibSpec {
let kdl = include_str!("../../mise.usage.kdl");
kdl.parse().expect("mise's spec should parse")
}

/// Every command in the tree, as (path, metadata) — the path being what a user types.
fn walk<'a>(
path: Vec<&'a str>,
meta: &'a CommandMeta<'a>,
out: &mut Vec<(Vec<&'a str>, &'a CommandMeta<'a>)>,
) {
out.push((path.clone(), meta));
for sub in meta.subcommands {
// Aliases live in the parse table beside the canonical name; help names the command.
let mut child = path.clone();
child.push(sub.cmd.name);
walk(child, sub, out);
}
}

/// usage-lib's line for the same command, found by following the same path.
fn lib_command<'a>(spec: &'a LibSpec, path: &[&str]) -> Option<&'a SpecCommand> {
let mut cmd = &spec.cmd;
for name in path {
cmd = cmd.subcommands.get(*name)?;
}
Some(cmd)
}

#[test]
fn every_usage_line_matches_the_reference() {
let spec = mise_spec();
let root = shadow_mise::Cli::spec().root;

let mut commands = Vec::new();
walk(vec!["mise"], root, &mut commands);
assert!(
commands.len() > 200,
"the shadow should cover mise's whole tree, found {}",
commands.len()
);

let mut differences = Vec::new();
for (path, meta) in &commands {
let ours = usage_line(path, meta);
// usage-lib's `usage()` omits the binary and starts at the command path, so the
// comparison puts it back — the same string the template writes after `Usage: `.
let theirs = match lib_command(&spec, &path[1..]) {
Some(cmd) => format!("mise {}", cmd.usage()).trim().to_string(),
None => {
differences.push(format!("{}: not found in the spec at all", path.join(" ")));
continue;
}
};
if ours != theirs {
differences.push(format!(
"{}\n ours: {ours}\n lib: {theirs}",
path.join(" ")
));
}
}

assert!(
differences.is_empty(),
"{} of {} usage lines differ from usage-lib:\n - {}",
differences.len(),
commands.len(),
differences.join("\n - ")
);
}

#[test]
fn the_root_line_is_what_a_user_would_recognise() {
// One case spelled out, so a reader can see what the parity test is asserting 211 times.
let root = shadow_mise::Cli::spec().root;
let line = usage_line(&["mise"], root);
assert!(
line.starts_with("mise "),
"the line should start with the binary: {line}"
);
assert!(
line.ends_with("<SUBCOMMAND>"),
"mise has subcommands, so the line should end by saying so: {line}"
);
}
Loading