From fd70fddee7e0f9146d6b2061681481f1c3edf940 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:24:08 +0000 Subject: [PATCH 1/8] test(spec): a rendering corpus, for the shapes a fixture cannot cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binding has 154 language-neutral vectors and a two-way reference check. Rendering has the parity gate — every command of mise and the six other jdx CLIs, compared against usage-lib byte for byte — and nothing else. That gate is the right check for scale and the wrong one for coverage: it asks only about the vocabulary its CLIs happen to use, and there are three renderers now (usage-lib's templates, `usage_argv::help`, the Go emitter's help table) held in line by it. So `corpus/render/` does for rendering what `corpus/` does for parsing: vectors pairing a spec with the text it must produce, in the same JSON shape so an implementation in any language can run them, each carrying the same two-way `reference` label — a divergence that gets fixed fails with an instruction to delete the label rather than rotting into folklore. Three files. Flag values: every pairing of the flag's brackets against its value's, defaults on the flag against defaults on the value, the variadic and repeatable ellipses. The usage line: positional brackets, `[-- COMMAND]…`, the collapse thresholds and hidden entries not counting towards them, and the ``-with-no-Commands-section oddity mise reaches on `direnv`. Sections: supplied `--help`/`--version`, annotations in both layouts, headings, inherited globals, aliases, negations, the short column, long-help wrapping. `render-oracle` is the authoring aid, mirroring `oracle`: `--json` prints what both implementations rendered in `expect`'s own shape, so a page goes in as a measurement rather than a transcription. Every expectation here was filled in that way. Rendering usage-argv from a runtime spec needs a `Spec` → `CommandMeta` builder, so `conformance/src/tables.rs` is now the one Spec-to-tables builder — hot and cold together, the metadata borrowing the parse-table entry it describes — and `argv.rs` calls it rather than keeping a second. Writing it found one thing immediately: argv gates `--version` on `Command::version`, which the derive sets on the root, and the first builder hardcoded it false. Co-Authored-By: Claude Opus 5 --- conformance/Cargo.toml | 4 + conformance/src/argv.rs | 129 +---------- conformance/src/bin/render-oracle.rs | 91 ++++++++ conformance/src/lib.rs | 2 + conformance/src/render.rs | 232 ++++++++++++++++++++ conformance/src/tables.rs | 305 +++++++++++++++++++++++++++ conformance/tests/render.rs | 120 +++++++++++ corpus/README.md | 7 +- corpus/render/01-flag-values.json | 77 +++++++ corpus/render/02-usage-line.json | 103 +++++++++ corpus/render/03-sections.json | 220 +++++++++++++++++++ corpus/render/README.md | 124 +++++++++++ 12 files changed, 1287 insertions(+), 127 deletions(-) create mode 100644 conformance/src/bin/render-oracle.rs create mode 100644 conformance/src/render.rs create mode 100644 conformance/src/tables.rs create mode 100644 conformance/tests/render.rs create mode 100644 corpus/render/01-flag-values.json create mode 100644 corpus/render/02-usage-line.json create mode 100644 corpus/render/03-sections.json create mode 100644 corpus/render/README.md diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml index f025cfc41..c7df827e9 100644 --- a/conformance/Cargo.toml +++ b/conformance/Cargo.toml @@ -28,3 +28,7 @@ usage-derive = { workspace = true } [[bin]] name = "oracle" path = "src/bin/oracle.rs" + +[[bin]] +name = "render-oracle" +path = "src/bin/render-oracle.rs" diff --git a/conformance/src/argv.rs b/conformance/src/argv.rs index ac70ed94b..8676145c9 100644 --- a/conformance/src/argv.rs +++ b/conformance/src/argv.rs @@ -1,13 +1,8 @@ //! Running the corpus against [`usage_argv`], the compiled parser. //! //! usage-argv reads `static` tables that a derive macro is meant to emit. Nothing -//! emits them yet, so this module builds them from a [`Spec`] instead, which also -//! makes the corpus usable as usage-argv's test suite from the first commit. -//! -//! The tables are leaked. They must outlive the parse and be `'static`-shaped, -//! and a test process that builds a handful of small tables and exits is the one -//! place where leaking is the simplest correct answer. Generated code has no such -//! problem: its tables really are `static`. +//! emits them here, so [`crate::tables`] builds them from a [`Spec`] instead, which +//! also makes the corpus usable as usage-argv's test suite from the first commit. //! //! # Scope //! @@ -21,10 +16,9 @@ use std::collections::BTreeMap; use std::ffi::OsStr; use usage::{Spec, SpecCommand}; -use usage_argv::{ - Arg, Command, DoubleDash, Error, Event, Flag, Parser, UnknownFlags as ArgvUnknownFlags, -}; +use usage_argv::{Command, Error, Event, Parser}; +use crate::tables::{self, convert_unknown_flags, leak}; use crate::{ErrorCode, Expect, Layer, Parsed, Value, Vector}; /// What usage-argv did with a vector. @@ -64,7 +58,7 @@ pub fn run(vector: &Vector) -> Outcome { // hold it. Everything below inherits it, which the parser now does itself rather than // this flattening it on the way in — a second implementation of the same rule, and the // one that hid the parser not having it. - let root = build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)); + let root = tables::build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)).cmd; // `default_subcommand` is a property of the spec rather than of a command, so it is // resolved once, here, against the root's own subcommands. A name that answers to // nothing is left as None: the spec is what it is, and a vector that expects routing @@ -177,14 +171,6 @@ fn code(err: Error<'_, '_>) -> ErrorCode { } } -/// The spec's spelling of the setting, in the parser's terms. -fn convert_unknown_flags(mode: usage::UnknownFlags) -> ArgvUnknownFlags { - match mode { - usage::UnknownFlags::Value => ArgvUnknownFlags::Value, - usage::UnknownFlags::Error => ArgvUnknownFlags::Error, - } -} - /// Which flags accumulate rather than replace. enum Multi { Count, @@ -224,108 +210,3 @@ fn out_of_scope(vector: &Vector) -> Option<&'static str> { ), } } - -/// Build leaked tables mirroring a spec command. -/// -/// `unknown_flags` is carried through as the spec states it — `None` where a command says -/// nothing — because the parser inherits it. The root takes the spec-level setting, since -/// that is the command a spec's own property describes. -fn build( - cmd: &SpecCommand, - root_unknown_flags: Option, -) -> &'static Command<'static> { - let unknown_flags = cmd - .unknown_flags - .map(convert_unknown_flags) - .or(root_unknown_flags); - let flags: Vec<&'static Flag<'static>> = cmd - .flags - .iter() - .map(|f| -> &'static Flag<'static> { - let longs: Vec<&'static str> = f.long.iter().map(|l| leak(l)).collect(); - let shorts: Vec = f.short.iter().map(|c| *c as u8).collect(); - Box::leak(Box::new(Flag { - key: 0, - name: leak(&f.name), - longs: Box::leak(longs.into_boxed_slice()), - shorts: Box::leak(shorts.into_boxed_slice()), - // usage-lib stores the negation with its dashes; the table wants - // the bare name. - negate: f.negate.as_ref().map(|n| leak(n.trim_start_matches('-'))), - takes_value: f.arg.is_some(), - // Only a variadic *argument* is greedy. A `var` flag with a - // single-value argument is repeatable instead: one value per - // occurrence, which the parser gets by not collecting. - variadic: f.arg.as_ref().is_some_and(|a| a.var), - // The bound on one occurrence's values, which is the argument's. A - // repeatable flag's own `var_max` counts occurrences and is checked after - // the parse, so it does not belong in this table. - var_max: f - .arg - .as_ref() - .filter(|a| a.var) - .and_then(|a| a.var_max) - // Saturating rather than truncating: `4294967296 as u32` is zero, - // which would read as "stop at once" rather than "no real limit". - .map(|max| u32::try_from(max).unwrap_or(u32::MAX)), - global: f.global, - })) - }) - .collect(); - - let args: Vec<&'static Arg<'static>> = cmd - .args - .iter() - .map(|a| -> &'static Arg<'static> { - Box::leak(Box::new(Arg { - key: 0, - name: leak(&a.name), - var: a.var, - var_max: a - .var_max - .filter(|_| a.var) - .map(|max| u32::try_from(max).unwrap_or(u32::MAX)), - double_dash: match a.double_dash { - usage::SpecDoubleDashChoices::Required => DoubleDash::Required, - usage::SpecDoubleDashChoices::Preserve => DoubleDash::Preserve, - usage::SpecDoubleDashChoices::Automatic => DoubleDash::Automatic, - _ => DoubleDash::Optional, - }, - })) - }) - .collect(); - - let subcommands: Vec<&'static Command<'static>> = cmd - .subcommands - .values() - // A subcommand states its own or says nothing; there is no spec-level setting to - // hand it, since the root has already taken that. - .map(|sub| build(sub, None)) - .collect(); - - let aliases: Vec<&'static str> = cmd - .aliases - .iter() - .chain(cmd.hidden_aliases.iter()) - .map(|a| leak(a)) - .collect(); - - Box::leak(Box::new(Command { - name: leak(&cmd.name), - aliases: Box::leak(aliases.into_boxed_slice()), - flags: Box::leak(flags.into_boxed_slice()), - args: Box::leak(args.into_boxed_slice()), - subcommands: Box::leak(subcommands.into_boxed_slice()), - // Filled in by the caller for the root, which is the only place a spec declares one. - default_subcommand: None, - unknown_flags, - // The corpus describes argv parsing; `--version` is a question the *caller* answers, - // so no vector turns on it and the harness leaves it off. - version: false, - key: 0, - })) -} - -fn leak(s: &str) -> &'static str { - Box::leak(s.to_string().into_boxed_str()) -} diff --git a/conformance/src/bin/render-oracle.rs b/conformance/src/bin/render-oracle.rs new file mode 100644 index 000000000..07a7ce903 --- /dev/null +++ b/conformance/src/bin/render-oracle.rs @@ -0,0 +1,91 @@ +//! Report what each implementation renders for every vector in the rendering corpus. +//! +//! Authoring aid, not a test. `cargo run -p usage-conformance --bin render-oracle` prints what +//! usage-lib and usage-argv produce beside what the vector expects, which is how an +//! expectation gets filled in with a measurement rather than a guess — and how the `reference` +//! label gets set honestly when the two disagree. +//! +//! `--json` emits the same thing machine-readably, which is what makes it usable for filling +//! in a new vector: pipe it into the file rather than transcribing a page by hand. +//! +//! The test suite (`conformance/tests/render.rs`) is what actually enforces agreement in CI. + +use usage_conformance::render::{self, Outcome, Rendered}; + +fn main() -> Result<(), String> { + let json = std::env::args().any(|a| a == "--json"); + let filter = std::env::args() + .skip(1) + .find(|a| !a.starts_with("--")) + .unwrap_or_default(); + + let files = render::load(render::corpus_dir())?; + let mut rows = Vec::new(); + + for file in &files { + for vector in &file.vectors { + if !filter.is_empty() && !vector.id.contains(&filter) { + continue; + } + rows.push((vector, render::reference(vector), render::argv(vector))); + } + } + + if json { + // The shape a vector's `expect` takes, so a new one can be filled in by copying. + let out: Vec = rows + .iter() + .map(|(vector, lib, argv)| { + serde_json::json!({ + "id": vector.id, + "usage-lib": value(lib), + "usage-argv": value(argv), + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&out).map_err(|e| e.to_string())? + ); + return Ok(()); + } + + for (vector, lib, argv) in &rows { + let lib_diff = lib.difference(&vector.expect); + let argv_diff = argv.difference(&vector.expect); + let mark = match (&lib_diff, &argv_diff) { + (None, None) => "ok ", + (Some(_), Some(_)) => "BOTH", + (Some(_), None) => "LIB ", + (None, Some(_)) => "ARGV", + }; + println!("{mark} {}", vector.id); + if let Some(diff) = lib_diff { + println!(" usage-lib: {}", indent(&diff)); + } + if let Some(diff) = argv_diff { + println!(" usage-argv: {}", indent(&diff)); + } + } + Ok(()) +} + +/// What an implementation produced, in the vector's own shape. +fn value(outcome: &Outcome) -> serde_json::Value { + match outcome { + Outcome::Bad(why) => serde_json::json!({ "error": why }), + Outcome::Rendered(Rendered { + usage, + short_help, + long_help, + }) => serde_json::json!({ + "usage": usage, + "short_help": short_help, + "long_help": long_help, + }), + } +} + +fn indent(text: &str) -> String { + text.replace('\n', "\n ") +} diff --git a/conformance/src/lib.rs b/conformance/src/lib.rs index 0a9d16fe4..e98e27646 100644 --- a/conformance/src/lib.rs +++ b/conformance/src/lib.rs @@ -16,6 +16,8 @@ use serde::{Deserialize, Serialize}; pub mod argv; pub mod config; pub mod reference; +pub mod render; +pub mod tables; /// One `corpus/*.json` file: a themed group of vectors. /// diff --git a/conformance/src/render.rs b/conformance/src/render.rs new file mode 100644 index 000000000..f2f2e3195 --- /dev/null +++ b/conformance/src/render.rs @@ -0,0 +1,232 @@ +//! The rendering corpus: its format, a loader, and the two runners. +//! +//! The argv corpus pins what a command line *binds*. This one pins what a spec *reads as* — +//! the usage line, `-h` and `--help` — for the same reason and in the same shape: the rules +//! are now implemented three times over (usage-lib's templates, `usage_argv::help`, and the Go +//! emitter's help table), and three implementations of a rendering rule drift exactly the way +//! three implementations of a parsing rule do. +//! +//! # Why this exists beside the mise fixture +//! +//! `benches/gate/tests/help.rs` compares every one of mise's 211 commands against usage-lib, +//! byte for byte, and it is the check that decides whether an adopter's help output changes. +//! What it cannot do is cover a shape mise does not use. A flag whose *value* is optional is +//! one: every flag value mise declares is required and undefaulted, so `[--opt [n]]` rendered +//! as `[--opt ]` for as long as usage-argv existed and the 211-command comparison passed +//! throughout. +//! +//! So the two are complements. The fixture answers "does a real CLI still render the same", +//! at a scale no hand-written case reaches. The corpus answers "does every shape a spec can +//! declare render the same", including the ones no single CLI happens to contain. +//! +//! # Scope +//! +//! Presentation only. What a page *says* — that a flag exists, what its help text is — is the +//! spec's business and is checked elsewhere; what this pins is how it is written down. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use usage::Spec; +use usage_argv::help::{long_help, short_help, usage_line}; +use usage_argv::spec::CommandMeta; + +use crate::tables; + +/// One `corpus/render/*.json` file: a themed group of vectors. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct VectorFile { + /// What this file covers, e.g. `"flag-values"`. + pub section: String, + /// What the group establishes, and anything a reader needs in order to judge whether these + /// expectations are the right ones. + pub about: String, + pub vectors: Vec, +} + +/// A single case: render `cmd` out of `spec` and you must get `expect`. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Vector { + /// Stable identifier, unique across the corpus. Failures quote it, so renaming one breaks + /// anybody tracking known failures. + pub id: String, + /// What this vector pins down, in one sentence. + pub doc: String, + /// A complete spec, as KDL. + pub spec: String, + /// Which command's page, as the path below the root. Empty means the root's own. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cmd: Vec, + pub expect: Expect, + /// Whether usage-lib, the reference implementation, agrees with `expect`. + #[serde(default)] + pub reference: Reference, +} + +/// What rendering must produce. +/// +/// The usage line is required, because it is the one line every vector has an opinion about +/// and the shortest thing that can carry a shape. The pages are optional and given as lines +/// rather than one escaped string: a JSON file holding a `\n\n --flag Help\n` is not +/// something a reviewer can read, and a diff over it says nothing about which line moved. +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Expect { + /// The `Usage:` line's body, including the binary. + pub usage: String, + /// Every line of `-h`, if the vector pins the whole page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub short_help: Option>, + /// Every line of `--help`, if the vector pins the whole page. Rendered at 80 columns, + /// which is what both implementations fall back to when `COLUMNS` is unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub long_help: Option>, +} + +/// Whether the reference implementation matches a vector's expectation. +#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Reference { + /// usage-lib produces exactly `expect`. + #[default] + Agrees, + /// usage-lib produces something else. The note says what, and why the corpus keeps its own + /// expectation regardless. + Diverges(String), +} + +/// What one implementation rendered. +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + Rendered(Rendered), + /// The spec would not load, or names no such command. A bug in the vector. + Bad(String), +} + +/// The three renderings, as an implementation produced them. +#[derive(Debug, PartialEq, Eq)] +pub struct Rendered { + pub usage: String, + pub short_help: Vec, + pub long_help: Vec, +} + +impl Outcome { + /// How this differs from what the vector expects, or `None` if it does not. + /// + /// A vector that pins only the usage line is not asserting the pages are empty, so an + /// absent expectation is not compared rather than compared against nothing. + pub fn difference(&self, expect: &Expect) -> Option { + let got = match self { + Outcome::Bad(why) => return Some(why.clone()), + Outcome::Rendered(got) => got, + }; + if got.usage != expect.usage { + return Some(format!( + "usage line\n ours: {}\n expected: {}", + got.usage, expect.usage + )); + } + for (label, want, have) in [ + ("-h", expect.short_help.as_ref(), &got.short_help), + ("--help", expect.long_help.as_ref(), &got.long_help), + ] { + let Some(want) = want else { continue }; + if want != have { + return Some(format!("{label}\n{}", first_diff(have, want))); + } + } + None + } +} + +/// The first line that differs, with a little context — a whole page twice over is not +/// something anyone reads. +fn first_diff(ours: &[String], theirs: &[String]) -> String { + for (i, (a, b)) in ours.iter().zip(theirs).enumerate() { + if a != b { + return format!(" line {}:\n ours: {a:?}\n expected: {b:?}", i + 1); + } + } + format!( + " same for {} lines, then ours has {} and the vector expects {}", + ours.len().min(theirs.len()), + ours.len(), + theirs.len() + ) +} + +/// Render a vector with usage-lib, the reference. +pub fn reference(vector: &Vector) -> Outcome { + let spec: Spec = match vector.spec.parse() { + Ok(spec) => spec, + Err(e) => return Outcome::Bad(format!("the spec would not parse: {e}")), + }; + let mut cmd = &spec.cmd; + for name in &vector.cmd { + cmd = match cmd.subcommands.get(name) { + Some(sub) => sub, + None => return Outcome::Bad(format!("the spec has no command `{name}`")), + }; + } + // usage-lib's `usage()` starts at the command path and omits the binary, which the + // template puts back after `Usage: `. + Outcome::Rendered(Rendered { + usage: format!("{} {}", spec.bin, cmd.usage()).trim().to_string(), + short_help: lines(&usage::docs::cli::render_help(&spec, cmd, false)), + long_help: lines(&usage::docs::cli::render_help(&spec, cmd, true)), + }) +} + +/// Render a vector with usage-argv, from tables built out of the same spec. +pub fn argv(vector: &Vector) -> Outcome { + let spec: Spec = match vector.spec.parse() { + Ok(spec) => spec, + Err(e) => return Outcome::Bad(format!("the spec would not parse: {e}")), + }; + let built = tables::build_spec(&spec); + + // The path a user types and the chain of metadata down to it. Both are needed: the path is + // what the line prints, and the chain is what says which flags are inherited. + let mut path: Vec<&'static str> = vec![built.bin.unwrap_or(built.name)]; + let mut chain: Vec<&'static CommandMeta<'static>> = vec![built.root]; + for name in &vector.cmd { + let here = chain.last().expect("a chain always has its root"); + let next = here.subcommands.iter().find(|sub| sub.cmd.name == *name); + match next { + Some(sub) => { + path.push(sub.cmd.name); + chain.push(sub); + } + None => return Outcome::Bad(format!("the tables have no command `{name}`")), + } + } + let meta = chain.last().expect("a chain always has its root"); + + Outcome::Rendered(Rendered { + usage: usage_line(&path, meta), + short_help: lines(&short_help(built, &path, &chain)), + long_help: lines(&long_help(built, &path, &chain)), + }) +} + +/// A rendered page as lines, without the trailing empty one every page ends with. +/// +/// Both implementations trim the document and put back a single newline, so `lines()` on the +/// result is exactly the page's lines with nothing added or lost. +fn lines(page: &str) -> Vec { + page.lines().map(str::to_string).collect() +} + +/// The rendering corpus directory, resolved against this crate rather than the process's +/// working directory. +pub fn corpus_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../corpus/render") +} + +/// Load every `*.json` file in a rendering corpus directory, sorted by file name. +pub fn load(dir: impl AsRef) -> Result, String> { + crate::load_as(dir) +} diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs new file mode 100644 index 000000000..55eafadb5 --- /dev/null +++ b/conformance/src/tables.rs @@ -0,0 +1,305 @@ +//! Building usage-argv's tables from a [`Spec`]. +//! +//! usage-argv reads `static` tables a derive macro emits. A corpus has a spec instead, so +//! this builds the same shapes at run time — both of them: the hot parse tables a binding +//! reads, and the cold metadata everything else does. One builder rather than two, because +//! the metadata borrows the parse-table entry it describes and the two must agree about which +//! flag is which. +//! +//! The tables are leaked. They must outlive the parse and be `'static`-shaped, and a test +//! process that builds a handful of small tables and exits is the one place where leaking is +//! the simplest correct answer. Generated code has no such problem: its tables really are +//! `static`. +//! +//! This is deliberately *not* a general-purpose bridge. It exists so the corpus can ask +//! usage-argv the questions it asks usage-lib; a program wanting a parser for a spec it read +//! at run time should use usage-lib, which is built for exactly that. + +use usage::{Spec, SpecArg, SpecCommand, SpecFlag}; +use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta}; +use usage_argv::{Arg, Command, DoubleDash, Flag, UnknownFlags as ArgvUnknownFlags}; + +/// A command's two tables, built together so the metadata can borrow the parse table. +pub struct Built { + /// What a binding reads. + pub cmd: &'static Command<'static>, + /// What help, completions and spec emission read. + pub meta: &'static CommandMeta<'static>, +} + +/// The spec's spelling of the setting, in the parser's terms. +pub fn convert_unknown_flags(mode: usage::UnknownFlags) -> ArgvUnknownFlags { + match mode { + usage::UnknownFlags::Value => ArgvUnknownFlags::Value, + usage::UnknownFlags::Error => ArgvUnknownFlags::Error, + } +} + +/// Build leaked tables mirroring a spec command. +/// +/// `root_unknown_flags` is carried through as the spec states it — `None` where a command says +/// nothing — because the parser inherits it. The root takes the spec-level setting, since that +/// is the command a spec's own property describes. +pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> Built { + let unknown_flags = cmd + .unknown_flags + .map(convert_unknown_flags) + .or(root_unknown_flags); + + let flags: Vec<&'static Flag<'static>> = cmd.flags.iter().map(build_flag).collect(); + let args: Vec<&'static Arg<'static>> = cmd.args.iter().map(build_arg).collect(); + let subs: Vec = cmd + .subcommands + .values() + // A subcommand states its own or says nothing; there is no spec-level setting to hand + // it, since the root has already taken that. + .map(|sub| build(sub, None)) + .collect(); + + let aliases: Vec<&'static str> = cmd + .aliases + .iter() + .chain(cmd.hidden_aliases.iter()) + .map(|a| leak(a)) + .collect(); + + let table: &'static Command<'static> = Box::leak(Box::new(Command { + name: leak(&cmd.name), + aliases: Box::leak(aliases.into_boxed_slice()), + flags: Box::leak(flags.clone().into_boxed_slice()), + args: Box::leak(args.clone().into_boxed_slice()), + subcommands: Box::leak( + subs.iter() + .map(|s| s.cmd) + .collect::>() + .into_boxed_slice(), + ), + // Both filled in by the caller for the root, which is the only place a spec declares + // either. + default_subcommand: None, + version: false, + unknown_flags, + key: 0, + })); + + let flag_metas: Vec> = cmd + .flags + .iter() + .zip(&flags) + .map(|(f, table)| flag_meta(f, table)) + .collect(); + let arg_metas: Vec> = cmd + .args + .iter() + .zip(&args) + .map(|(a, table)| arg_meta(a, table)) + .collect(); + + let meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { + cmd: table, + about: opt(&cmd.help), + long_about: opt(&cmd.help_long), + hidden_aliases: Box::leak( + cmd.hidden_aliases + .iter() + .map(|a| leak(a)) + .collect::>() + .into_boxed_slice(), + ), + hide: cmd.hide, + effect: cmd.effect.map(effect), + // A command carries at most one mount in the tables; a spec may list several, and the + // first is the one the tables can hold. + mount: cmd.mounts.first().map(|m| leak(&m.run)), + restart_token: opt(&cmd.restart_token), + subcommand_required: cmd.subcommand_required, + before_help: opt(&cmd.before_help), + before_long_help: opt(&cmd.before_help_long), + after_help: opt(&cmd.after_help), + after_long_help: opt(&cmd.after_help_long), + examples: Box::leak( + cmd.examples + .iter() + .map(|e| Example { + code: leak(&e.code), + header: opt(&e.header), + help: opt(&e.help), + }) + .collect::>() + .into_boxed_slice(), + ), + flags: Box::leak(flag_metas.into_boxed_slice()), + args: Box::leak(arg_metas.into_boxed_slice()), + subcommands: Box::leak( + subs.iter() + .map(|s| s.meta) + .collect::>() + .into_boxed_slice(), + ), + })); + + Built { cmd: table, meta } +} + +/// The whole spec, as usage-argv's cold model of one. +/// +/// A KDL spec has one place for surrounding text and examples — the top level — and usage-lib +/// keeps those on the spec while usage-argv keeps them on the root's metadata. So the two are +/// folded here, root first: a root command that says something of its own keeps it. +pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { + let root = build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)); + // Whether the parser answers `--version` here, which the derive sets on the root of a CLI + // that declares one. It has to be on the *table*, not only on the spec: a page offers + // `--version` where the parser accepts it, and one that offered it otherwise would be + // describing a flag that never binds. + let root_cmd: &'static Command<'static> = Box::leak(Box::new(Command { + version: spec.version.is_some(), + ..*root.cmd + })); + let root_meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { + cmd: root_cmd, + before_help: root.meta.before_help.or(opt(&spec.before_help)), + before_long_help: root.meta.before_long_help.or(opt(&spec.before_help_long)), + after_help: root.meta.after_help.or(opt(&spec.after_help)), + after_long_help: root.meta.after_long_help.or(opt(&spec.after_help_long)), + ..*root.meta + })); + Box::leak(Box::new(usage_argv::spec::Spec { + name: leak(&spec.name), + bin: Some(leak(&spec.bin)), + version: opt(&spec.version), + min_usage_version: None, + about: opt(&spec.about), + long_about: opt(&spec.about_long), + default_subcommand: opt(&spec.default_subcommand), + root: root_meta, + })) +} + +fn build_flag(f: &SpecFlag) -> &'static Flag<'static> { + let longs: Vec<&'static str> = f.long.iter().map(|l| leak(l)).collect(); + let shorts: Vec = f.short.iter().map(|c| *c as u8).collect(); + Box::leak(Box::new(Flag { + key: 0, + name: leak(&f.name), + longs: Box::leak(longs.into_boxed_slice()), + shorts: Box::leak(shorts.into_boxed_slice()), + // usage-lib stores the negation with its dashes; the table wants the bare name. + negate: f.negate.as_ref().map(|n| leak(n.trim_start_matches('-'))), + takes_value: f.arg.is_some(), + // Only a variadic *argument* is greedy. A `var` flag with a single-value argument is + // repeatable instead: one value per occurrence, which the parser gets by not + // collecting. + variadic: f.arg.as_ref().is_some_and(|a| a.var), + // The bound on one occurrence's values, which is the argument's. A repeatable flag's + // own `var_max` counts occurrences and is checked after the parse, so it does not + // belong in this table. + var_max: f + .arg + .as_ref() + .filter(|a| a.var) + .and_then(|a| a.var_max) + // Saturating rather than truncating: `4294967296 as u32` is zero, which would read + // as "stop at once" rather than "no real limit". + .map(|max| u32::try_from(max).unwrap_or(u32::MAX)), + global: f.global, + })) +} + +fn build_arg(a: &SpecArg) -> &'static Arg<'static> { + Box::leak(Box::new(Arg { + key: 0, + name: leak(&a.name), + var: a.var, + var_max: a + .var_max + .filter(|_| a.var) + .map(|max| u32::try_from(max).unwrap_or(u32::MAX)), + double_dash: double_dash(&a.double_dash), + })) +} + +fn flag_meta(f: &SpecFlag, table: &'static Flag<'static>) -> FlagMeta<'static> { + let arg = f.arg.as_ref(); + FlagMeta { + flag: table, + help: opt(&f.help), + long_help: opt(&f.help_long), + value_name: arg.map(|a| leak(&a.name)), + // The value's own bracket bit, folded with the value's own default the way usage-lib + // folds a positional's — a default declared on the *flag* is a different statement and + // stays in `default` below. + value_required: arg.is_none_or(|a| a.required && a.default.is_empty()), + env: opt(&f.env), + default: strs(&f.default), + choices: arg + .and_then(|a| a.choices.as_ref()) + .map(|c| strs(&c.choices)) + .unwrap_or(&[]), + required: f.required, + hide: f.hide, + count: f.count, + repeatable: f.var, + var_min: f.var_min.or(arg.and_then(|a| a.var_min)), + var_max: f.var_max.or(arg.and_then(|a| a.var_max)), + overrides: strs(&f.overrides), + conflicts: strs(&f.conflicts), + requires: strs(&f.requires), + required_if: strs(&f.required_if), + required_unless: strs(&f.required_unless), + help_heading: opt(&f.help_heading), + effect: f.effect.map(effect), + ..FlagMeta::EMPTY + } +} + +fn arg_meta(a: &SpecArg, table: &'static Arg<'static>) -> ArgMeta<'static> { + ArgMeta { + arg: table, + help: opt(&a.help), + long_help: opt(&a.help_long), + env: opt(&a.env), + default: strs(&a.default), + choices: a.choices.as_ref().map(|c| strs(&c.choices)).unwrap_or(&[]), + required: a.required, + hide: a.hide, + var_min: a.var_min, + var_max: a.var_max, + help_heading: opt(&a.help_heading), + ..ArgMeta::EMPTY + } +} + +fn double_dash(mode: &usage::SpecDoubleDashChoices) -> DoubleDash { + match mode { + usage::SpecDoubleDashChoices::Required => DoubleDash::Required, + usage::SpecDoubleDashChoices::Preserve => DoubleDash::Preserve, + usage::SpecDoubleDashChoices::Automatic => DoubleDash::Automatic, + _ => DoubleDash::Optional, + } +} + +fn effect(effect: usage::SpecCommandEffect) -> Effect { + match effect { + usage::SpecCommandEffect::Read => Effect::Read, + usage::SpecCommandEffect::Write => Effect::Write, + usage::SpecCommandEffect::Destructive => Effect::Destructive, + } +} + +fn opt(s: &Option) -> Option<&'static str> { + s.as_deref().map(leak) +} + +fn strs(list: &[String]) -> &'static [&'static str] { + Box::leak( + list.iter() + .map(|s| leak(s)) + .collect::>() + .into_boxed_slice(), + ) +} + +pub fn leak(s: &str) -> &'static str { + Box::leak(s.to_string().into_boxed_str()) +} diff --git a/conformance/tests/render.rs b/conformance/tests/render.rs new file mode 100644 index 000000000..1471bd735 --- /dev/null +++ b/conformance/tests/render.rs @@ -0,0 +1,120 @@ +//! The rendering corpus, run against both Rust implementations. +//! +//! usage-lib renders from a spec through tera templates; usage-argv renders from `static` +//! tables through hand-written code. They must produce the same text, and the mise fixture in +//! `benches/gate/tests/help.rs` proves that at scale for the shapes mise happens to contain. +//! These vectors cover the shapes it does not. + +use usage_conformance::render::{self, Outcome, Reference, VectorFile}; + +fn corpus() -> Vec { + render::load(render::corpus_dir()).expect("the rendering corpus should load") +} + +fn vectors(files: &[VectorFile]) -> impl Iterator { + files.iter().flat_map(|f| &f.vectors) +} + +#[test] +fn every_id_is_unique() { + // Reports quote ids, and two vectors sharing one makes a report ambiguous about which + // case failed. + let files = corpus(); + let mut seen: Vec<&str> = vectors(&files).map(|v| v.id.as_str()).collect(); + let before = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(before, seen.len(), "two vectors share an id"); + assert!(before > 0, "the corpus should not be empty"); +} + +#[test] +fn usage_argv_renders_what_the_corpus_expects() { + let files = corpus(); + let mut differences = Vec::new(); + for vector in vectors(&files) { + if let Some(diff) = render::argv(vector).difference(&vector.expect) { + differences.push(format!("{}: {}\n {diff}", vector.id, vector.doc)); + } + } + assert!( + differences.is_empty(), + "{} vector(s) render differently in usage-argv:\n\n{}", + differences.len(), + differences.join("\n\n") + ); +} + +#[test] +fn the_reference_label_is_true_in_both_directions() { + // The same check `reference.rs` makes of the argv corpus, and for the same reason: a + // vector claiming agreement must agree, and a vector claiming divergence must still + // diverge. So a divergence that gets fixed fails here with an instruction to delete the + // label, and the list cannot quietly rot into folklore. + let files = corpus(); + let mut wrong = Vec::new(); + for vector in vectors(&files) { + let diff = render::reference(vector).difference(&vector.expect); + match (&vector.reference, diff) { + (Reference::Agrees, Some(diff)) => wrong.push(format!( + "{}: labelled as agreeing, but usage-lib differs on {diff}", + vector.id + )), + (Reference::Diverges(note), None) => wrong.push(format!( + "{}: labelled as diverging ({note}), but usage-lib now agrees — delete the label", + vector.id + )), + _ => {} + } + } + assert!( + wrong.is_empty(), + "{} reference label(s) are wrong:\n\n{}", + wrong.len(), + wrong.join("\n\n") + ); +} + +#[test] +fn a_vector_that_pins_a_page_pins_a_whole_one() { + // A page given as lines is easy to truncate by accident, and a short expectation would + // pass against a page that carries on. Every pinned page must end where the renderer's + // does, which the comparison already enforces — this asserts the corpus bothers to pin + // some, since a corpus of usage lines alone would not have caught a Flags-section bug. + let files = corpus(); + let pinned = vectors(&files) + .filter(|v| v.expect.short_help.is_some() || v.expect.long_help.is_some()) + .count(); + assert!( + pinned >= 3, + "only {pinned} vector(s) pin a whole page; the sections need covering too" + ); +} + +#[test] +fn the_two_implementations_agree_with_each_other() { + // Implied by the two tests above for any vector labelled as agreeing, and stated anyway: + // this is the claim the corpus exists to make, and it should fail by name rather than as + // a consequence. + let files = corpus(); + let mut differences = Vec::new(); + for vector in vectors(&files) { + if matches!(vector.reference, Reference::Diverges(_)) { + continue; + } + let (Outcome::Rendered(ours), Outcome::Rendered(theirs)) = + (render::argv(vector), render::reference(vector)) + else { + // A spec that will not load is the other tests' complaint to make. + continue; + }; + if ours != theirs { + differences.push(vector.id.clone()); + } + } + assert!( + differences.is_empty(), + "usage-argv and usage-lib disagree on: {}", + differences.join(", ") + ); +} diff --git a/corpus/README.md b/corpus/README.md index 0e52d6411..d17e72f45 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -1,8 +1,9 @@ # The argv conformance corpus -> Resolving a CLI's _configuration_ — layers, precedence, merge policies — has a -> corpus of its own in [`config/`](config/README.md). This file is about parsing -> a command line. +> Two neighbours have corpora of their own: resolving a CLI's _configuration_ — +> layers, precedence, merge policies — is [`config/`](config/README.md), and what a +> spec _reads as_ — the usage line, `-h`, `--help` — is +> [`render/`](render/README.md). This file is about parsing a command line. Test vectors for [the argv grammar](https://usage.jdx.dev/spec/argv). Each one pairs a spec with a command line and the result parsing them must produce. diff --git a/corpus/render/01-flag-values.json b/corpus/render/01-flag-values.json new file mode 100644 index 000000000..b0bb4ed5f --- /dev/null +++ b/corpus/render/01-flag-values.json @@ -0,0 +1,77 @@ +{ + "section": "flag-values", + "about": "A flag and its value each carry brackets, and the two are decided separately. The flag's say whether the flag may be left out; the value's say whether a value must follow it. A spec can write either without the other, so there are four pairings and no CLI is likely to contain all four — mise contains exactly one, which is how `[--opt [n]]` rendered as `[--opt ]` in usage-argv while a 211-command comparison against usage-lib passed.", + "vectors": [ + { + "id": "flag-optional-value-required", + "doc": "The ordinary pairing: the flag may be left out, and a value must follow it if it is not.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--tool \" help=\"Which tool\"\n", + "expect": { "usage": "ex [--tool ]" } + }, + { + "id": "flag-required-value-required", + "doc": "Both required. The only pairing mise's spec contains.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--v \" required=#true help=\"How loud\"\n", + "expect": { "usage": "ex <--v >" } + }, + { + "id": "flag-optional-value-optional", + "doc": "A value declared `[n]` is square-bracketed, independently of the flag's own brackets.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--opt [n]\" help=\"A number, or not\"\n", + "expect": { "usage": "ex [--opt [n]]" } + }, + { + "id": "flag-required-value-defaulted", + "doc": "A default on the flag's `arg` node relaxes the value's brackets and leaves the flag's alone.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs \" required=#true help=\"How many\" {\n arg \"\" default=\"4\"\n}\n", + "expect": { "usage": "ex <--jobs [n]>" } + }, + { + "id": "flag-default-on-the-flag", + "doc": "The same default written on the flag instead relaxes the other pair: the flag becomes optional and the value stays angled.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs \" default=\"4\" help=\"How many\"\n", + "expect": { "usage": "ex [--jobs ]" } + }, + { + "id": "flag-value-variadic-optional", + "doc": "The ellipsis belongs to the value and sits outside its brackets, whichever pair they are.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--inc [pattern]...\" help=\"Patterns to include\"\n", + "expect": { "usage": "ex [--inc [pattern]…]" } + }, + { + "id": "flag-value-variadic-required", + "doc": "A required variadic value, with the flag required too.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--inc ...\" required=#true help=\"Patterns to include\"\n", + "expect": { "usage": "ex <--inc …>" } + }, + { + "id": "flag-repeatable-with-optional-value", + "doc": "A repeatable flag's own ellipsis follows the spellings, before the value — so a repeatable flag taking an optional value shows both.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs [n]\" var=#true required=#true help=\"How many\"\n", + "expect": { "usage": "ex <--jobs… [n]>" } + }, + { + "id": "flag-value-named-differently", + "doc": "The value's placeholder is the `arg` node's name, not the flag's; a flag whose spellings do not imply its declared name says the name first.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"jobs: -j [n]\" help=\"How many\"\n", + "expect": { "usage": "ex [jobs: -j [n]]" } + }, + { + "id": "flag-value-optional-on-the-page", + "doc": "The same brackets in the Flags section as in the usage line: three renderers read one function, and a page disagreeing with the line above it is the shape this corpus exists to catch.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--opt [n]\" help=\"A number, or not\"\n", + "expect": { + "usage": "ex [--opt [n]]", + "short_help": [ + "An example", + "", + "Usage: ex [--opt [n]]", + "", + "Flags:", + " --opt [n] A number, or not", + " -h, --help Print help" + ] + } + } + ] +} diff --git a/corpus/render/02-usage-line.json b/corpus/render/02-usage-line.json new file mode 100644 index 000000000..4d8eacb96 --- /dev/null +++ b/corpus/render/02-usage-line.json @@ -0,0 +1,103 @@ +{ + "section": "usage-line", + "about": "Everything the one-line summary decides: which entries appear at all, when a list is too long to spell out, and how a positional is written. The collapse thresholds and the hidden-entry rules are the parts a real CLI exercises unevenly — mise has plenty of commands past the threshold and almost none sitting exactly on it.", + "vectors": [ + { + "id": "positional-required", + "doc": "A required positional is angled.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"\"\n", + "expect": { "usage": "ex " } + }, + { + "id": "positional-optional", + "doc": "An optional positional is square-bracketed.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[TOOL]\"\n", + "expect": { "usage": "ex [TOOL]" } + }, + { + "id": "positional-defaulted", + "doc": "A default makes a positional optional, so it is square-bracketed however it was declared.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"\" default=\"-\"\n", + "expect": { "usage": "ex [file]" } + }, + { + "id": "positional-variadic", + "doc": "The ellipsis goes outside the brackets.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[FILES]...\"\n", + "expect": { "usage": "ex [FILES]…" } + }, + { + "id": "positional-after-double-dash", + "doc": "A positional that only takes what follows `--` shows the separator inside its brackets: one optional thing, not a literal `--` and then an optional word.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[COMMAND]...\" double_dash=\"required\"\n", + "expect": { "usage": "ex [-- COMMAND]…" } + }, + { + "id": "flags-collapse-past-two", + "doc": "Three or more flags collapse to a placeholder rather than being spelled out.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--a\"\nflag \"--b\"\nflag \"--c\"\n", + "expect": { "usage": "ex [FLAGS]" } + }, + { + "id": "flags-collapse-when-one-is-required", + "doc": "The collapsed placeholder is angled when any of the flags it stands for is required.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--a\"\nflag \"--b\"\nflag \"--c \" required=#true\n", + "expect": { "usage": "ex " } + }, + { + "id": "flags-two-are-spelled-out", + "doc": "Two is the limit, and at the limit the flags are still named.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--a\"\nflag \"--b\"\n", + "expect": { "usage": "ex [--a] [--b]" } + }, + { + "id": "args-collapse-past-two", + "doc": "Positionals collapse the same way, and the placeholder is always variadic.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"[a]\"\narg \"[b]\"\narg \"[c]\"\n", + "expect": { "usage": "ex [ARGS]…" } + }, + { + "id": "args-collapse-when-one-is-required", + "doc": "Angled when any of the positionals it stands for is required.", + "spec": "name \"ex\"\nbin \"ex\"\narg \"\"\narg \"[b]\"\narg \"[c]\"\n", + "expect": { "usage": "ex …" } + }, + { + "id": "hidden-flag-is-not-counted", + "doc": "A hidden flag is absent from the line, and does not count towards the collapse threshold either.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--a\"\nflag \"--b\"\nflag \"--secret\" hide=#true\n", + "expect": { "usage": "ex [--a] [--b]" } + }, + { + "id": "hidden-flag-does-not-make-the-line-required", + "doc": "A hidden required flag does not angle the placeholder for the visible ones: the line describes what a user is invited to type.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--a\"\nflag \"--b\"\nflag \"--c\"\nflag \"--secret \" hide=#true required=#true\n", + "expect": { "usage": "ex [FLAGS]" } + }, + { + "id": "subcommands-are-named-generically", + "doc": "A command with subcommands ends its line by saying so, whatever they are called.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"go\" help=\"Go\"\ncmd \"stop\" help=\"Stop\"\n", + "expect": { "usage": "ex " } + }, + { + "id": "subcommands-all-hidden-still-say-subcommand", + "doc": "The line is computed before hidden commands are filtered, so a command whose subcommands are all hidden still says `` while listing none. Recorded because it looks like a bug and is the reference's behaviour, which mise reaches on `direnv` and `dotfiles`.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"go\" help=\"Go\" hide=#true\n", + "expect": { "usage": "ex " } + }, + { + "id": "subcommand-line-starts-at-the-command", + "doc": "A subcommand's line names the whole path from the binary down, not the child's own name.", + "spec": "name \"ex\"\nbin \"ex\"\ncmd \"config\" help=\"Config\" {\n cmd \"set\" help=\"Set one\" {\n arg \"\"\n arg \"\"\n }\n}\n", + "cmd": ["config", "set"], + "expect": { "usage": "ex config set " } + }, + { + "id": "flags-and-args-together", + "doc": "Flags come before positionals, each group collapsing on its own count.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--force\"\narg \"\"\narg \"[VERSION]\"\n", + "expect": { "usage": "ex [--force] [VERSION]" } + } + ] +} diff --git a/corpus/render/03-sections.json b/corpus/render/03-sections.json new file mode 100644 index 000000000..fd40d8aa6 --- /dev/null +++ b/corpus/render/03-sections.json @@ -0,0 +1,220 @@ +{ + "section": "sections", + "about": "The body of a page: which sections appear, what goes in each, and how an entry is written down beside its help. These vectors pin whole pages rather than one line, because the failure they are guarding against is a section disagreeing with the usage line above it — and because the column an entry sits in is decided across a whole section, so no single entry can be checked alone.", + "vectors": [ + { + "id": "supplied-help-entry", + "doc": "A page lists `--help` even though no spec declares it, because a reader looking for how to get help should find it on the page.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--force\" help=\"Do it anyway\"\n", + "expect": { + "usage": "ex [--force]", + "short_help": [ + "An example", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help" + ] + } + }, + { + "id": "version-entry-only-with-a-version", + "doc": "`--version` appears only where the spec declares one, and the banner appears with it.", + "spec": "name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nabout \"An example\"\nflag \"--force\" help=\"Do it anyway\"\n", + "expect": { + "usage": "ex [--force]", + "short_help": [ + "ex 1.2.3", + "An example", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + " -V, --version Print version" + ] + } + }, + { + "id": "annotations-on-one-line-and-on-their-own", + "doc": "Choices, environment and default are bracketed after an entry's help in the short form and each get a line in the long form. The two layouts differ in more than width, which is why both are pinned here.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--shell \" help=\"Which shell\" env=\"EX_SHELL\" {\n arg \"\" default=\"bash\" {\n choices \"bash\" \"zsh\"\n }\n}\narg \"[file]\" help=\"Where to write\" default=\"-\"\n", + "expect": { + "usage": "ex [--shell [SHELL]] [file]", + "short_help": [ + "An example", + "", + "Usage: ex [--shell [SHELL]] [file]", + "", + "Arguments:", + " [file] Where to write (default: -)", + "", + "Flags:", + " --shell [SHELL] Which shell [bash, zsh] [env: EX_SHELL]", + " -h, --help Print help" + ], + "long_help": [ + "An example", + "", + "Usage: ex [--shell [SHELL]] [file]", + "", + "Arguments:", + " [file] Where to write", + " (default: -)", + "", + "Flags:", + " --shell [SHELL] Which shell", + " [possible values: bash, zsh]", + " [env: EX_SHELL]", + " -h, --help Print help" + ] + } + }, + { + "id": "flags-grouped-by-heading", + "doc": "A `help_heading` puts a flag in a section of its own; unheaded flags keep the default section and come first, and headings follow in the order they are first seen.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--plain\" help=\"No heading\"\nflag \"--fast\" help=\"Go faster\" help_heading=\"Speed\"\nflag \"--slow\" help=\"Go slower\" help_heading=\"Speed\"\nflag \"--loud\" help=\"Say more\" help_heading=\"Noise\"\n", + "expect": { + "usage": "ex [FLAGS]", + "short_help": [ + "An example", + "", + "Usage: ex [FLAGS]", + "", + "Flags:", + " --plain No heading", + " -h, --help Print help", + "", + "Speed:", + " --fast Go faster", + " --slow Go slower", + "", + "Noise:", + " --loud Say more" + ] + } + }, + { + "id": "global-flags-are-listed-where-they-are-inherited", + "doc": "A flag declared `global` on an ancestor is listed on a descendant's page, under a heading that says where it came from — a flag a user can type and cannot discover is the worst way for help to be wrong.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"-c --config \" global=#true help=\"Config file\"\ncmd \"go\" help=\"Go somewhere\" {\n flag \"--fast\" help=\"Go faster\"\n}\n", + "cmd": ["go"], + "expect": { + "usage": "ex go [--fast]", + "short_help": [ + "Go somewhere", + "", + "Usage: ex go [--fast]", + "", + "Flags:", + " --fast Go faster", + " -h, --help Print help", + "", + "Global flags:", + " -c, --config Config file" + ] + } + }, + { + "id": "commands-list-with-aliases", + "doc": "Subcommands are listed by their rendered usage with visible aliases beside them; a hidden alias works and is not advertised. Note the supplied `help` entry, which is written flush rather than into the column the declared commands share — the reference's behaviour, pinned here so that fixing it is a decision rather than an accident.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\ncmd \"install\" help=\"Install a tool\" {\n alias \"i\"\n alias \"add\" hide=#true\n arg \"\"\n}\ncmd \"remove\" help=\"Remove a tool\" hide=#true\n", + "expect": { + "usage": "ex ", + "short_help": [ + "An example", + "", + "Usage: ex ", + "", + "Commands:", + " install [aliases: i] Install a tool", + " help Print this message or the help of the given subcommand(s)", + "", + "Flags:", + " -h, --help Print help" + ] + } + }, + { + "id": "a-negation-is-shown-beside-its-flag", + "doc": "A flag that declares a negation offers both spellings, since both are things a user may type.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--color\" negate=\"--no-color\" help=\"Colourise output\"\n", + "expect": { + "usage": "ex [--color]", + "short_help": [ + "An example", + "", + "Usage: ex [--color]", + "", + "Flags:", + " --color / --no-color Colourise output", + " -h, --help Print help" + ] + } + }, + { + "id": "a-short-only-flag-does-not-pay-for-the-long-column", + "doc": "The short column is only spent where there is a long form to line up with, so a short-only flag is written flush and the section still lines up.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"-j \" help=\"How many\"\nflag \"-n --dry-run\" help=\"Do nothing\"\nflag \"--github-release\" help=\"Use a release\"\n", + "expect": { + "usage": "ex [FLAGS]", + "short_help": [ + "An example", + "", + "Usage: ex [FLAGS]", + "", + "Flags:", + " -j How many", + " -n, --dry-run Do nothing", + " --github-release Use a release", + " -h, --help Print help" + ] + } + }, + { + "id": "long-help-wraps-and-prefers-the-long-text", + "doc": "The wide layout prefers each entry's long help and wraps it into the column, at the 80 columns both implementations fall back to.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--force\" help=\"Do it anyway\" {\n long_help \"Do it anyway, even when the tool is already installed and the version on disk is the one that was asked for.\"\n}\n", + "expect": { + "usage": "ex [--force]", + "long_help": [ + "An example", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway, even when the tool is already installed and the", + " version on disk is the one that was asked for.", + " -h, --help Print help" + ] + } + }, + { + "id": "hidden-entries-are-absent-from-every-section", + "doc": "`hide` keeps a flag, an argument and a subcommand out of the page as well as out of the usage line.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nflag \"--shown\" help=\"Visible\"\nflag \"--secret\" help=\"Hidden\" hide=#true\narg \"[shown]\" help=\"Visible\"\narg \"[buried]\" help=\"Hidden\" hide=#true\ncmd \"go\" help=\"Go\"\ncmd \"lurk\" help=\"Hidden\" hide=#true\n", + "expect": { + "usage": "ex [--shown] [shown] ", + "short_help": [ + "An example", + "", + "Usage: ex [--shown] [shown] ", + "", + "Commands:", + " go Go", + " help Print this message or the help of the given subcommand(s)", + "", + "Arguments:", + " [shown] Visible", + "", + "Flags:", + " --shown Visible", + " -h, --help Print help" + ] + } + } + ] +} diff --git a/corpus/render/README.md b/corpus/render/README.md new file mode 100644 index 000000000..09367a858 --- /dev/null +++ b/corpus/render/README.md @@ -0,0 +1,124 @@ +# The rendering corpus + +> The corpus one directory up is about parsing a command line; [`config/`](../config/README.md) +> is about resolving a CLI's configuration. This one is about what a spec _reads as_: the usage +> line, `-h`, and `--help`. + +Test vectors for help rendering. Each one pairs a spec with the text rendering it must produce. + +Plain JSON, for the same reason the argv corpus is: an implementation in any language can run +these without reimplementing a test format. If you are rendering help from a usage spec — in +Go, in JavaScript, or as a second Rust implementation — this directory is the definition of +correct. + +## Why this exists beside the mise fixture + +`benches/gate/tests/help.rs` renders all 211 of mise's commands with `usage-argv` and compares +each page against usage-lib byte for byte. That is the check that decides whether an adopter's +help output changes, and no hand-written corpus will ever match it for scale. + +What it cannot do is cover a shape mise does not use. A flag whose _value_ is optional is one: +every flag value mise declares is required and undefaulted, so the fixture runs entirely +through the one combination where the two implementations happen to agree. `[--opt [n]]` +rendered as `[--opt ]` for as long as `usage-argv` existed, and the 211-command comparison +passed the whole time. + +So the two are complements, and the split is worth stating plainly: + +| | asks | covers | +| ---------------- | ---------------------------------------------------- | ---------------------------------- | +| the mise fixture | does a real CLI still render the same? | one CLI, exhaustively | +| this corpus | does every shape a spec can declare render the same? | every shape, one command at a time | + +A rule that only one of them can catch belongs in whichever one catches it. In practice that +means: if you fix a rendering bug, the regression test goes _here_ unless mise already +exercises the shape. + +## Format + +One file per area of rendering. Each has a `section`, an `about` explaining what the group +establishes, and `vectors`: + +```json +{ + "id": "flag-optional-value-optional", + "doc": "A value declared `[n]` is square-bracketed, independently of the flag's own brackets.", + "spec": "name \"ex\"\nbin \"ex\"\nflag \"--opt [n]\"\n", + "expect": { "usage": "ex [--opt [n]]" } +} +``` + +| field | meaning | +| ----------- | --------------------------------------------------------------------------------------------------------------- | +| `id` | unique across the corpus, and stable — reports quote it, so renaming one breaks anybody tracking known failures | +| `doc` | what this vector pins down, in one sentence | +| `spec` | a complete spec, as KDL | +| `cmd` | which command's page, as the path below the root; absent means the root's own | +| `expect` | the rendering; see below | +| `reference` | whether usage-lib agrees; see below | + +### `expect` + +`usage` is required and is the `Usage:` line's body **including the binary** — `ex go `, +not `go `. It is the shortest thing that can carry a shape, and every vector has an +opinion about it. + +`short_help` and `long_help` are optional, and are the whole page as an array of lines. Lines +rather than one escaped string on purpose: a JSON file holding `"\n\nFlags:\n --opt [n]\n"` +is not something a reviewer can read, and a diff over it cannot say which line moved. A page is +pinned in full or not at all — a truncated expectation would pass against a page that carries +on past it. + +Pin a page when the vector is about a _section_: which entries appear, what column they sit in, +where an annotation goes. Pin only the usage line when it is about how one entry is written, +since the line contains that too and a whole page would bury it. + +`long_help` is rendered at 80 columns, which is what every implementation falls back to when +`COLUMNS` is unset. Nothing here reads the real environment. + +### The `reference` field + +Same contract as the argv corpus. usage-lib is one implementation of these rules; where it +differs from what a vector expects, the vector says so: + +```json +"reference": { + "diverges": "usage-lib pads the supplied `help` entry into the commands column and usage-argv does not." +} +``` + +Absent means usage-lib agrees. `conformance/tests/render.rs` checks every label in both +directions, so a divergence that gets fixed fails the suite with an instruction to delete the +label, and the list cannot quietly rot into folklore. + +## Running them + +```sh +cargo test -p usage-conformance --test render +``` + +To see what each implementation actually renders, rather than only whether it matches: + +```sh +cargo run -p usage-conformance --bin render-oracle +``` + +```sh +cargo run -p usage-conformance --bin render-oracle -- --json +``` + +```sh +cargo run -p usage-conformance --bin render-oracle -- flag-value +``` + +## Adding a vector + +Write the spec and the `doc` first, with the rendering the rules say you should get. Then run +the oracle: `--json` prints what both implementations produced, in the shape `expect` takes, so +a page goes in as a measurement rather than a transcription. + +If the two agree and match what you expected, you are done. If they agree and you expected +something else, you have found a rule you had wrong — or a rule worth changing, in which case +change it and keep the vector. If they _disagree_, that is the case this corpus exists for: +decide which is right, fix the other, and leave the vector behind as the thing that would have +caught it. From 1765bc7fb5dbb4303e6c4f27c36c4bb99775de99 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:31:59 +0000 Subject: [PATCH 2/8] fix(conformance): preserve top-level examples --- conformance/src/tables.rs | 33 ++++++++++++++++++++++----------- corpus/render/03-sections.json | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 55eafadb5..abe909afa 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -15,6 +15,7 @@ //! usage-argv the questions it asks usage-lib; a program wanting a parser for a spec it read //! at run time should use usage-lib, which is built for exactly that. +use usage::spec::cmd::SpecExample; use usage::{Spec, SpecArg, SpecCommand, SpecFlag}; use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta}; use usage_argv::{Arg, Command, DoubleDash, Flag, UnknownFlags as ArgvUnknownFlags}; @@ -117,17 +118,7 @@ pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> before_long_help: opt(&cmd.before_help_long), after_help: opt(&cmd.after_help), after_long_help: opt(&cmd.after_help_long), - examples: Box::leak( - cmd.examples - .iter() - .map(|e| Example { - code: leak(&e.code), - header: opt(&e.header), - help: opt(&e.help), - }) - .collect::>() - .into_boxed_slice(), - ), + examples: examples(&cmd.examples), flags: Box::leak(flag_metas.into_boxed_slice()), args: Box::leak(arg_metas.into_boxed_slice()), subcommands: Box::leak( @@ -156,12 +147,15 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { version: spec.version.is_some(), ..*root.cmd })); + let mut root_examples = root.meta.examples.to_vec(); + root_examples.extend(spec.examples.iter().map(example)); let root_meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { cmd: root_cmd, before_help: root.meta.before_help.or(opt(&spec.before_help)), before_long_help: root.meta.before_long_help.or(opt(&spec.before_help_long)), after_help: root.meta.after_help.or(opt(&spec.after_help)), after_long_help: root.meta.after_long_help.or(opt(&spec.after_help_long)), + examples: Box::leak(root_examples.into_boxed_slice()), ..*root.meta })); Box::leak(Box::new(usage_argv::spec::Spec { @@ -300,6 +294,23 @@ fn strs(list: &[String]) -> &'static [&'static str] { ) } +fn example(e: &SpecExample) -> Example<'static> { + Example { + code: leak(&e.code), + header: opt(&e.header), + help: opt(&e.help), + } +} + +fn examples(list: &[SpecExample]) -> &'static [Example<'static>] { + Box::leak( + list.iter() + .map(example) + .collect::>() + .into_boxed_slice(), + ) +} + pub fn leak(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } diff --git a/corpus/render/03-sections.json b/corpus/render/03-sections.json index fd40d8aa6..4556f5f34 100644 --- a/corpus/render/03-sections.json +++ b/corpus/render/03-sections.json @@ -215,6 +215,24 @@ " -h, --help Print help" ] } + }, + { + "id": "top-level-examples-reach-root-help", + "doc": "Top-level examples belong to the whole CLI and are folded onto the root metadata used by usage-argv's help renderer.", + "spec": "name \"ex\"\nbin \"ex\"\nexample \"ex --verbose\" header=\"Verbose\"\n", + "expect": { + "usage": "ex", + "short_help": [ + "Usage: ex", + "", + "Flags:", + " -h, --help Print help", + "", + "Examples:", + " Verbose:", + " $ ex --verbose" + ] + } } ] } From 18f22887d4ad6fe2c6c92880cc37bedb5f90ba2c Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:34:24 +0000 Subject: [PATCH 3/8] test(spec): pin the rest of the examples rule, and skip what argv cannot answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows c53756e, which fixed the top-level examples Bugbot found on #973 and pinned the root's own page. Two cases were left: a page that declares no examples of its own showing the spec's — which is the rule usage-argv's `page_examples` actually implements, and the one a builder reading only `spec.cmd.examples` breaks — and a page that declares its own not also showing them. Both were already covered for the renderer by the gate fixture, which is how the bug was this harness's rather than usage-argv's. `disable_help` came out of checking whether anything else was dropped, and is the opposite problem. usage-lib reads it and drops the supplied `--help` entry; usage-argv has no equivalent, and `lib/src/docs/cli/mod.rs` explains why the two cannot disagree about it — the word is KDL-only, so no spec the derive produces ever carries one. This harness breaks that premise by building tables from KDL, so a vector declaring it is answered by the reference alone and skipped, the way the argv corpus skips a post-binding vector. The count of exemptions is asserted, since a set that can grow unnoticed will. `min_usage_version` was being dropped on the way through too. Nothing renders it, so nothing caught it; carried now because the builder's job is to say what the spec says. Co-Authored-By: Claude Opus 5 --- conformance/src/bin/render-oracle.rs | 4 ++ conformance/src/render.rs | 27 +++++++++ conformance/src/tables.rs | 17 ++++-- conformance/tests/render.rs | 28 +++++++++- corpus/render/03-sections.json | 82 ++++++++++++++++++++++++++++ corpus/render/README.md | 17 ++++++ 6 files changed, 170 insertions(+), 5 deletions(-) diff --git a/conformance/src/bin/render-oracle.rs b/conformance/src/bin/render-oracle.rs index 07a7ce903..c3890248e 100644 --- a/conformance/src/bin/render-oracle.rs +++ b/conformance/src/bin/render-oracle.rs @@ -54,6 +54,9 @@ fn main() -> Result<(), String> { let lib_diff = lib.difference(&vector.expect); let argv_diff = argv.difference(&vector.expect); let mark = match (&lib_diff, &argv_diff) { + // A vector usage-argv is not asked to answer reports as agreeing, so it is called + // what it is rather than being counted as a pass. + _ if matches!(argv, Outcome::OutOfScope(_)) => "skip", (None, None) => "ok ", (Some(_), Some(_)) => "BOTH", (Some(_), None) => "LIB ", @@ -74,6 +77,7 @@ fn main() -> Result<(), String> { fn value(outcome: &Outcome) -> serde_json::Value { match outcome { Outcome::Bad(why) => serde_json::json!({ "error": why }), + Outcome::OutOfScope(why) => serde_json::json!({ "out_of_scope": why }), Outcome::Rendered(Rendered { usage, short_help, diff --git a/conformance/src/render.rs b/conformance/src/render.rs index f2f2e3195..65f81482e 100644 --- a/conformance/src/render.rs +++ b/conformance/src/render.rs @@ -101,10 +101,31 @@ pub enum Reference { #[derive(Debug, PartialEq, Eq)] pub enum Outcome { Rendered(Rendered), + /// The vector turns on something this implementation deliberately cannot express. The + /// string says which. + OutOfScope(&'static str), /// The spec would not load, or names no such command. A bug in the vector. Bad(String), } +/// Why a vector is not usage-argv's to answer, if it isn't. +/// +/// One word so far. `disable_help` turns the parser's answer to `-h` off, and usage-lib drops +/// the supplied entry accordingly; usage-argv has no equivalent, and `lib/src/docs/cli/mod.rs` +/// says why — it is a KDL-only word, so no spec *the derive* can produce ever carries one and +/// the two renderers cannot disagree about it. +/// +/// This harness breaks that premise, since it builds usage-argv's tables from KDL rather than +/// from a Rust type. So a vector declaring it is answered by the reference alone and skipped +/// here, rather than being recorded as a divergence: nothing usage-argv could render would be +/// right, because the question does not reach it. +fn out_of_scope(spec: &Spec) -> Option<&'static str> { + (spec.disable_help == Some(true)).then_some( + "`disable_help` is a KDL-only word with no derive spelling, so usage-argv's tables \ + cannot carry it", + ) +} + /// The three renderings, as an implementation produced them. #[derive(Debug, PartialEq, Eq)] pub struct Rendered { @@ -121,6 +142,9 @@ impl Outcome { pub fn difference(&self, expect: &Expect) -> Option { let got = match self { Outcome::Bad(why) => return Some(why.clone()), + // Not a difference: the vector was never this implementation's to answer, and a + // caller that cares checks for the variant rather than reading it as agreement. + Outcome::OutOfScope(_) => return None, Outcome::Rendered(got) => got, }; if got.usage != expect.usage { @@ -186,6 +210,9 @@ pub fn argv(vector: &Vector) -> Outcome { Ok(spec) => spec, Err(e) => return Outcome::Bad(format!("the spec would not parse: {e}")), }; + if let Some(reason) = out_of_scope(&spec) { + return Outcome::OutOfScope(reason); + } let built = tables::build_spec(&spec); // The path a user types and the chain of metadata down to it. Both are needed: the path is diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index abe909afa..89e2a8fbf 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -134,9 +134,18 @@ pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> /// The whole spec, as usage-argv's cold model of one. /// -/// A KDL spec has one place for surrounding text and examples — the top level — and usage-lib -/// keeps those on the spec while usage-argv keeps them on the root's metadata. So the two are -/// folded here, root first: a root command that says something of its own keeps it. +/// A KDL spec has one place for surrounding text and examples — the top level — and the two +/// implementations put them in different places: usage-lib keeps them on the `Spec` and +/// usage-argv on the root's metadata, where its renderer reads them both as the root's own and +/// as the default for every other page. So they are folded here. The help texts fold root +/// first, a root command that says something of its own keeping it; the examples concatenate, +/// which is the same thing in practice — `example` at the top level parses onto +/// `Spec::examples` and leaves `spec.cmd.examples` empty, so at most one side is ever filled. +/// +/// Examples were dropped on the way through to begin with, which cost every page its Examples +/// section — the one the reference still rendered from the same spec. A fold that copied only +/// the help texts lost them silently, and `render/03-sections.json` pins all three cases now: +/// the root's own page, a page that falls back to them, and a page that has its own instead. pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { let root = build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)); // Whether the parser answers `--version` here, which the derive sets on the root of a CLI @@ -162,7 +171,7 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { name: leak(&spec.name), bin: Some(leak(&spec.bin)), version: opt(&spec.version), - min_usage_version: None, + min_usage_version: opt(&spec.min_usage_version), about: opt(&spec.about), long_about: opt(&spec.about_long), default_subcommand: opt(&spec.default_subcommand), diff --git a/conformance/tests/render.rs b/conformance/tests/render.rs index 1471bd735..3d982aa98 100644 --- a/conformance/tests/render.rs +++ b/conformance/tests/render.rs @@ -45,6 +45,31 @@ fn usage_argv_renders_what_the_corpus_expects() { ); } +/// How many vectors usage-argv is not asked to answer. +/// +/// Asserted rather than counted, for the reason the argv corpus asserts its own: an exemption +/// is a claim that a question does not reach an implementation, and a set that can grow without +/// anybody noticing is a set that will. Every one of these is a word usage-lib reads and the +/// derive has no spelling for, so raising this number means the asymmetry got wider. +const OUT_OF_SCOPE_FOR_ARGV: usize = 1; + +#[test] +fn only_the_declared_vectors_are_out_of_usage_argvs_scope() { + let files = corpus(); + let exempt: Vec = vectors(&files) + .filter_map(|v| match render::argv(v) { + Outcome::OutOfScope(why) => Some(format!("{}: {why}", v.id)), + _ => None, + }) + .collect(); + assert_eq!( + exempt.len(), + OUT_OF_SCOPE_FOR_ARGV, + "the out-of-scope set changed:\n {}", + exempt.join("\n ") + ); +} + #[test] fn the_reference_label_is_true_in_both_directions() { // The same check `reference.rs` makes of the argv corpus, and for the same reason: a @@ -105,7 +130,8 @@ fn the_two_implementations_agree_with_each_other() { let (Outcome::Rendered(ours), Outcome::Rendered(theirs)) = (render::argv(vector), render::reference(vector)) else { - // A spec that will not load is the other tests' complaint to make. + // A spec that will not load is the other tests' complaint to make, and a vector + // out of usage-argv's scope has nothing for the two to agree or disagree about. continue; }; if ours != theirs { diff --git a/corpus/render/03-sections.json b/corpus/render/03-sections.json index 4556f5f34..7fb104671 100644 --- a/corpus/render/03-sections.json +++ b/corpus/render/03-sections.json @@ -233,6 +233,88 @@ " $ ex --verbose" ] } + }, + { + "id": "a-specs-examples-reach-a-page-that-has-none", + "doc": "Top-level `example` nodes are the root's, and every page whose command declares none of its own shows them — the same rule the text around a page follows. They parse onto the spec rather than onto the root command, so an implementation that reads only the command's loses them from every page at once, which is exactly what happened to this corpus's own table builder.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nexample \"ex go --fast\" help=\"the quick way\"\ncmd \"go\" help=\"Go\"\ncmd \"own\" help=\"Own\" {\n example \"ex own --mine\" header=\"Mine\"\n}\n", + "cmd": ["go"], + "expect": { + "usage": "ex go", + "short_help": [ + "Go", + "", + "Usage: ex go", + "", + "Flags:", + " -h, --help Print help", + "", + "Examples:", + " $ ex go --fast" + ], + "long_help": [ + "Go", + "", + "Usage: ex go", + "", + "Flags:", + " -h, --help Print help", + "", + "Examples:", + " the quick way", + " $ ex go --fast" + ] + } + }, + { + "id": "a-page-with-its-own-examples-does-not-also-show-the-specs", + "doc": "A command declaring examples keeps its own and the spec's are not appended; a header is written above the command it introduces, and the long form puts a description before the command rather than after it.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nexample \"ex go --fast\" help=\"the quick way\"\ncmd \"go\" help=\"Go\"\ncmd \"own\" help=\"Own\" {\n example \"ex own --mine\" header=\"Mine\" help=\"the way I like it\"\n}\n", + "cmd": ["own"], + "expect": { + "usage": "ex own", + "short_help": [ + "Own", + "", + "Usage: ex own", + "", + "Flags:", + " -h, --help Print help", + "", + "Examples:", + " Mine:", + " $ ex own --mine" + ], + "long_help": [ + "Own", + "", + "Usage: ex own", + "", + "Flags:", + " -h, --help Print help", + "", + "Examples:", + " Mine:", + " the way I like it", + " $ ex own --mine" + ] + } + }, + { + "id": "disable-help-drops-the-supplied-entry", + "doc": "`disable_help` turns the parser's answer to `-h` off, so the page stops offering it: a page describing an action nothing performs is worse than one that stays quiet. Out of usage-argv's scope — the word is KDL-only and has no derive spelling, so its tables cannot carry one — and answered by the reference alone.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\ndisable_help #true\nflag \"--force\" help=\"Do it anyway\"\n", + "expect": { + "usage": "ex [--force]", + "short_help": [ + "An example", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway" + ] + } } ] } diff --git a/corpus/render/README.md b/corpus/render/README.md index 09367a858..e014a6cd6 100644 --- a/corpus/render/README.md +++ b/corpus/render/README.md @@ -76,6 +76,23 @@ since the line contains that too and a whole page would bury it. `long_help` is rendered at 80 columns, which is what every implementation falls back to when `COLUMNS` is unset. Nothing here reads the real environment. +### What usage-argv is not asked + +A few words reach usage-lib and cannot reach usage-argv at all. `disable_help` is the one so +far: it turns the parser's answer to `-h` off, and it is KDL-only — there is no derive spelling, +so no spec a `#[derive(Cli)]` binary carries can declare one, and the two renderers cannot +disagree about it in the wild. + +This corpus breaks that premise, because it builds usage-argv's tables from KDL rather than from +a Rust type. So a vector declaring one is answered by the reference alone and skipped for +usage-argv, rather than being recorded as a divergence: nothing usage-argv rendered would be +right, because the question never reaches it. + +Nothing marks these in the JSON — the harness works it out from the spec, since the exemption is +a property of the word rather than of the vector. What is asserted is the _count_, in +`conformance/tests/render.rs`. An exemption is a claim that a question does not reach an +implementation, and a set that can grow without anybody noticing is a set that will. + ### The `reference` field Same contract as the argv corpus. usage-lib is one implementation of these rules; where it From 5c06e4dd6ae39c168975c90f188d802cb1360735 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:44:08 +0000 Subject: [PATCH 4/8] fix(spec): carry the fields the corpus's table builder was dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main, which added `Spec::usage` in #965 and broke this file — the literals are exhaustive, so a new field is a build error until somebody says what the spec puts there. Kept that way, and said so in the module docs: this is a mirror, and one that quietly defaults a field describes a CLI the spec did not declare. Carrying `usage` took one line and turned up a rule the two implementations disagree about that nothing had recorded. `usage` is an exact synopsis a spec declares, replacing the generated line on the root's page. usage-argv honours it, which is what #965 added it for; usage-lib honours it in the manpage renderer and *not* in the help renderer. So `an-explicit-synopsis-replaces-the-root-line` expects usage-argv's page and carries the corpus's first `reference` divergence, with the note pointing at the file that would need to change. Fixing usage-lib is not this PR's business — recording it is exactly what the label is for, and the two-way check means whoever fixes it is told to delete the label. `min_usage_version` was being dropped too. Nothing renders it, so nothing caught it; carried now for the same reason. Co-Authored-By: Claude Opus 5 --- conformance/src/tables.rs | 13 +++++++++++++ corpus/render/03-sections.json | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 89e2a8fbf..6498a0284 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -14,6 +14,14 @@ //! This is deliberately *not* a general-purpose bridge. It exists so the corpus can ask //! usage-argv the questions it asks usage-lib; a program wanting a parser for a spec it read //! at run time should use usage-lib, which is built for exactly that. +//! +//! # Every literal here is exhaustive, on purpose +//! +//! Nothing below ends in `..EMPTY`, so a new field in usage-argv's model breaks this file +//! until somebody says what the spec puts there. That is the point: this is a mirror, and a +//! mirror that quietly defaults a field describes a CLI that is not the one the spec declares. +//! `Spec::usage` arrived this way — the build broke, carrying it took one line, and doing so +//! turned up a rule the two implementations disagree about that nothing had recorded. use usage::spec::cmd::SpecExample; use usage::{Spec, SpecArg, SpecCommand, SpecFlag}; @@ -175,6 +183,11 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { about: opt(&spec.about), long_about: opt(&spec.about_long), default_subcommand: opt(&spec.default_subcommand), + // An exact synopsis the spec declares, which replaces the generated line on the root's + // page. usage-lib's manpage renderer honours it and its help renderer does not, so a + // spec that declares one is a case the two disagree about; `render/03-sections.json` + // records it rather than this quietly declining to carry it. + usage: (!spec.usage.trim().is_empty()).then(|| leak(spec.usage.trim())), root: root_meta, })) } diff --git a/corpus/render/03-sections.json b/corpus/render/03-sections.json index 7fb104671..80e7471cf 100644 --- a/corpus/render/03-sections.json +++ b/corpus/render/03-sections.json @@ -315,6 +315,30 @@ " --force Do it anyway" ] } + }, + { + "id": "an-explicit-synopsis-replaces-the-root-line", + "doc": "A spec can declare an exact synopsis, including the `Usage:` prefix, for a CLI whose shape needs alternatives no single command grammar implies. It belongs to the program, so it replaces the root's generated line and leaves every subcommand page deriving its own. The `usage` field below is the *generated* line, which both implementations still compute the same way — the override is applied where the page writes its synopsis, not where the line is built, so the difference shows only in the page.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nusage \"Usage: ex \\n ex --list\"\nflag \"--list\" help=\"List them instead\"\narg \"[TOOL]\" help=\"Which tool\"\n", + "expect": { + "usage": "ex [--list] [TOOL]", + "short_help": [ + "An example", + "", + "Usage: ex ", + " ex --list", + "", + "Arguments:", + " [TOOL] Which tool", + "", + "Flags:", + " --list List them instead", + " -h, --help Print help" + ] + }, + "reference": { + "diverges": "usage-lib's help renderer ignores a declared `usage` and writes the generated line; its manpage renderer honours it (`lib/src/docs/manpage/renderer.rs`). usage-argv honours it on the root page, which is what #965 added it for. Recorded rather than fixed here because it is the reference that needs changing, not the expectation." + } } ] } From 56aa8944f073ecd13a9dca716aa52677e572cf99 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:01:47 +0000 Subject: [PATCH 5/8] fix(spec): map the completion class the table builder was defaulting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot again, and right again: the module doc claims nothing here ends in `..EMPTY`, and `flag_meta` and `arg_meta` both did. The two fields they were leaving to the default are `complete` and `complete_type`. `complete` genuinely cannot come from a spec — it is a Rust function the binary calls, where a spec says `run=`, a shell command. Written out as `None` with the reason attached, so the exhaustiveness the file relies on is real rather than nearly real. `complete_type` can, and now does. Writing the test for it found the mapping did not work: `complete` nodes at the top level hang off `Spec` and leave `spec.cmd.complete` empty, the same shape as the examples and for the third time in this file. Filled from the spec's map where the command's own has nothing, so a `complete` inside a `cmd` block still wins for that command. Both are checked by a unit test rather than by the corpus, because neither reaches a page: `corpus/render` catches a dropped field by the difference it makes to rendered text, which is what caught the examples and what could never have caught these. Co-Authored-By: Claude Opus 5 --- conformance/src/tables.rs | 122 +++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 6498a0284..0a22c77c4 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -24,7 +24,7 @@ //! turned up a rule the two implementations disagree about that nothing had recorded. use usage::spec::cmd::SpecExample; -use usage::{Spec, SpecArg, SpecCommand, SpecFlag}; +use usage::{Spec, SpecArg, SpecCommand, SpecComplete, SpecFlag}; use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta}; use usage_argv::{Arg, Command, DoubleDash, Flag, UnknownFlags as ArgvUnknownFlags}; @@ -95,13 +95,13 @@ pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> .flags .iter() .zip(&flags) - .map(|(f, table)| flag_meta(f, table)) + .map(|(f, table)| flag_meta(f, table, cmd.complete.values())) .collect(); let arg_metas: Vec> = cmd .args .iter() .zip(&args) - .map(|(a, table)| arg_meta(a, table)) + .map(|(a, table)| arg_meta(a, table, cmd.complete.values())) .collect(); let meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { @@ -166,6 +166,31 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { })); let mut root_examples = root.meta.examples.to_vec(); root_examples.extend(spec.examples.iter().map(example)); + // The third thing a spec writes at the top level and hangs off `Spec` rather than off the + // root command. Filled in only where the command's own map had nothing, so a `complete` + // inside a `cmd` block still wins for that command. + let root_flags: Vec> = root + .meta + .flags + .iter() + .map(|f| FlagMeta { + complete_type: f + .complete_type + .or_else(|| complete_type(spec.complete.values(), f.flag.name, f.value_name)), + ..*f + }) + .collect(); + let root_args: Vec> = root + .meta + .args + .iter() + .map(|a| ArgMeta { + complete_type: a + .complete_type + .or_else(|| complete_type(spec.complete.values(), a.arg.name, None)), + ..*a + }) + .collect(); let root_meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { cmd: root_cmd, before_help: root.meta.before_help.or(opt(&spec.before_help)), @@ -173,6 +198,8 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { after_help: root.meta.after_help.or(opt(&spec.after_help)), after_long_help: root.meta.after_long_help.or(opt(&spec.after_help_long)), examples: Box::leak(root_examples.into_boxed_slice()), + flags: Box::leak(root_flags.into_boxed_slice()), + args: Box::leak(root_args.into_boxed_slice()), ..*root.meta })); Box::leak(Box::new(usage_argv::spec::Spec { @@ -235,7 +262,11 @@ fn build_arg(a: &SpecArg) -> &'static Arg<'static> { })) } -fn flag_meta(f: &SpecFlag, table: &'static Flag<'static>) -> FlagMeta<'static> { +fn flag_meta<'a>( + f: &SpecFlag, + table: &'static Flag<'static>, + completers: impl Iterator, +) -> FlagMeta<'static> { let arg = f.arg.as_ref(); FlagMeta { flag: table, @@ -265,11 +296,16 @@ fn flag_meta(f: &SpecFlag, table: &'static Flag<'static>) -> FlagMeta<'static> { required_unless: strs(&f.required_unless), help_heading: opt(&f.help_heading), effect: f.effect.map(effect), - ..FlagMeta::EMPTY + complete_type: complete_type(completers, &f.name, arg.map(|a| a.name.as_str())), + complete: NO_COMPLETER, } } -fn arg_meta(a: &SpecArg, table: &'static Arg<'static>) -> ArgMeta<'static> { +fn arg_meta<'a>( + a: &SpecArg, + table: &'static Arg<'static>, + completers: impl Iterator, +) -> ArgMeta<'static> { ArgMeta { arg: table, help: opt(&a.help), @@ -282,10 +318,42 @@ fn arg_meta(a: &SpecArg, table: &'static Arg<'static>) -> ArgMeta<'static> { var_min: a.var_min, var_max: a.var_max, help_heading: opt(&a.help_heading), - ..ArgMeta::EMPTY + complete_type: complete_type(completers, &a.name, None), + complete: NO_COMPLETER, } } +/// A spec cannot supply one. +/// +/// `Completer` is a Rust function the binary calls to answer for a value. A spec says `run=` +/// instead, which is a shell command — the two are different mechanisms, and the emitted KDL +/// turns the former into the latter rather than the other way round. Written out rather than +/// left to `EMPTY` so that the exhaustiveness this file relies on stays real. +const NO_COMPLETER: Option = None; + +/// The built-in completion class declared for a flag or argument, if any. +/// +/// `complete` nodes name the thing they complete rather than living on it, so this is a lookup. +/// A flag's node may name the flag or its value, and `Spec::to_kdl` writes the value's +/// lowercased, so both are tried — the flag's own name first, as the more specific of the two. +/// +/// Taken as an iterator rather than the `IndexMap` it comes from, so that this crate does not +/// have to depend on `indexmap` to name the type. Each node knows its own name. +fn complete_type<'a>( + completers: impl Iterator, + name: &str, + value_name: Option<&str>, +) -> Option<&'static str> { + let lowered = value_name.map(str::to_ascii_lowercase); + let all: Vec<&SpecComplete> = completers.collect(); + let found = [Some(name), lowered.as_deref()] + .into_iter() + .flatten() + .find_map(|key| all.iter().find(|c| c.name == key)) + .and_then(|c| c.type_.as_deref()); + found.map(leak) +} + fn double_dash(mode: &usage::SpecDoubleDashChoices) -> DoubleDash { match mode { usage::SpecDoubleDashChoices::Required => DoubleDash::Required, @@ -336,3 +404,43 @@ fn examples(list: &[SpecExample]) -> &'static [Example<'static>] { pub fn leak(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The fields no page shows, which is why they need a test of their own. + /// + /// `corpus/render` catches a dropped field by the difference it makes to a rendered page, + /// which is most of them and was how the missing examples surfaced. These two make no + /// difference to any page — they are read by completions and by spec emission — so nothing + /// would have noticed them going missing, and `min_usage_version` had. + #[test] + fn the_fields_that_do_not_reach_a_page_are_carried_too() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nmin_usage_version \"2.1.0\"\n\ + flag \"--out \"\narg \"\"\n\ + complete \"out\" type=\"path\"\ncomplete \"dir\" type=\"dir\"\n" + .parse() + .expect("valid spec"); + let built = build_spec(&spec); + + assert_eq!(built.min_usage_version, Some("2.1.0")); + assert_eq!(built.root.flags[0].complete_type, Some("path")); + assert_eq!(built.root.args[0].complete_type, Some("dir")); + // A Rust completer is a function the binary calls, which a spec's `run=` is not — so + // this stays `None` however a spec is written, and says so rather than defaulting. + assert!(built.root.flags[0].complete.is_none()); + } + + /// A `complete` node may name a flag's *value* rather than the flag, and `Spec::to_kdl` + /// writes the value's name lowercased — so the lookup has to try both spellings. + #[test] + fn a_completer_keyed_by_a_flags_value_is_found_too() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--out \"\n\ + complete \"file\" type=\"path\"\n" + .parse() + .expect("valid spec"); + let built = build_spec(&spec); + assert_eq!(built.root.flags[0].complete_type, Some("path")); + } +} From a37871684c62ceecc988b777062c40d55b9abfce Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:28:08 +0000 Subject: [PATCH 6/8] docs(spec): reframe the rendering corpus around the fleet, not one CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main, which changed both halves of this PR's premise. #969 fixed the optional flag value independently — `FlagMeta::value_optional`, the opposite polarity of the field this branch was carrying, so that commit is dropped and the builder speaks main's spelling. And #972 widened the parity gate from mise alone to all seven jdx CLIs, so "one CLI cannot cover this" is no longer the argument. The argument is better now, and measured rather than asserted. Across all 809 value-taking flags in mise and the fleet, three of the four flag/value bracket pairings appear — 796, 8 and 5 — and `<--jobs [n]>` appears nowhere, nor does a value carrying a `default`, which relaxes the value's brackets by another route. The pairing #969 fixed had exactly five instances, all in pitchfork and aube, which is the whole reason it was visible at all. Seven real CLIs still leave shapes uncovered, and the ones left are not exotic. Also recorded why the gate cannot see the divergence this corpus found: six of the seven CLIs declare a top-level `usage` synopsis, but `xtask gen-shadow` does not carry the node into the shadow, so every shadow's `Spec::usage` is `None` and both sides render the generated line. The corpus builds usage-argv's tables from KDL directly and sees it. Co-Authored-By: Claude Opus 5 --- conformance/src/render.rs | 25 ++++++++------ conformance/src/tables.rs | 10 +++--- corpus/render/01-flag-values.json | 38 +++++++++++++++------ corpus/render/03-sections.json | 2 +- corpus/render/README.md | 55 ++++++++++++++++++++----------- 5 files changed, 85 insertions(+), 45 deletions(-) diff --git a/conformance/src/render.rs b/conformance/src/render.rs index 65f81482e..ff424d957 100644 --- a/conformance/src/render.rs +++ b/conformance/src/render.rs @@ -6,18 +6,23 @@ //! emitter's help table), and three implementations of a rendering rule drift exactly the way //! three implementations of a parsing rule do. //! -//! # Why this exists beside the mise fixture +//! # Why this exists beside the fleet gate //! -//! `benches/gate/tests/help.rs` compares every one of mise's 211 commands against usage-lib, -//! byte for byte, and it is the check that decides whether an adopter's help output changes. -//! What it cannot do is cover a shape mise does not use. A flag whose *value* is optional is -//! one: every flag value mise declares is required and undefaulted, so `[--opt [n]]` rendered -//! as `[--opt ]` for as long as usage-argv existed and the 211-command comparison passed -//! throughout. +//! `benches/gate/tests/help.rs` and `fleet.rs` beside it render every command of mise and the +//! six other jdx CLIs and compare each page against usage-lib byte for byte. That is the check +//! that decides whether an adopter's help output changes, and no hand-written corpus will match +//! it for scale. //! -//! So the two are complements. The fixture answers "does a real CLI still render the same", -//! at a scale no hand-written case reaches. The corpus answers "does every shape a spec can -//! declare render the same", including the ones no single CLI happens to contain. +//! What it cannot do is cover a shape no CLI in the fleet uses. Across all 809 value-taking +//! flags in the fleet, three of the four "must the flag be given" against "must its value be +//! given" pairings appear and the fourth appears nowhere; nor does a flag whose value carries a +//! default, which relaxes the value's brackets by another route. The fleet was one CLI until +//! #972, and widening it found three bugs at once — so the lesson is not that seven is enough, +//! it is that a fixture only asks about the vocabulary its CLIs happen to use. +//! +//! So the two are complements. The gate answers "do real CLIs still render the same", at a scale +//! no hand-written case reaches. This answers "does every shape a spec can declare render the +//! same", including the ones no CLI in the fleet contains. //! //! # Scope //! diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 0a22c77c4..c94203268 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -273,10 +273,12 @@ fn flag_meta<'a>( help: opt(&f.help), long_help: opt(&f.help_long), value_name: arg.map(|a| leak(&a.name)), - // The value's own bracket bit, folded with the value's own default the way usage-lib - // folds a positional's — a default declared on the *flag* is a different statement and - // stays in `default` below. - value_required: arg.is_none_or(|a| a.required && a.default.is_empty()), + // The value's own bracket bit, which is not the flag's — usage-lib renders a flag from + // two independent `required` bits and a spec can write either without the other. Folded + // with the value's own default the way usage-lib folds a positional's, so + // `arg "" default="4"` inside a flag reads as optional; a default declared on the + // *flag* is a different statement and stays in `default` below. + value_optional: arg.is_some_and(|a| !a.required || !a.default.is_empty()), env: opt(&f.env), default: strs(&f.default), choices: arg diff --git a/corpus/render/01-flag-values.json b/corpus/render/01-flag-values.json index b0bb4ed5f..655fe8d75 100644 --- a/corpus/render/01-flag-values.json +++ b/corpus/render/01-flag-values.json @@ -1,60 +1,78 @@ { "section": "flag-values", - "about": "A flag and its value each carry brackets, and the two are decided separately. The flag's say whether the flag may be left out; the value's say whether a value must follow it. A spec can write either without the other, so there are four pairings and no CLI is likely to contain all four — mise contains exactly one, which is how `[--opt [n]]` rendered as `[--opt ]` in usage-argv while a 211-command comparison against usage-lib passed.", + "about": "A flag and its value each carry brackets, and the two are decided separately. The flag's say whether the flag may be left out; the value's say whether a value must follow it. A spec can write either without the other, so there are four pairings — and across all 809 value-taking flags in mise and the fleet, three appear and `<--jobs [n]>` appears nowhere, as does a value carrying a `default`. `[--opt [n]]` was itself rendered `[--opt ]` until #969, and pitchfork is the only reason that was visible; the two rows still at zero have nothing but these vectors holding them.", "vectors": [ { "id": "flag-optional-value-required", "doc": "The ordinary pairing: the flag may be left out, and a value must follow it if it is not.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--tool \" help=\"Which tool\"\n", - "expect": { "usage": "ex [--tool ]" } + "expect": { + "usage": "ex [--tool ]" + } }, { "id": "flag-required-value-required", "doc": "Both required. The only pairing mise's spec contains.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--v \" required=#true help=\"How loud\"\n", - "expect": { "usage": "ex <--v >" } + "expect": { + "usage": "ex <--v >" + } }, { "id": "flag-optional-value-optional", "doc": "A value declared `[n]` is square-bracketed, independently of the flag's own brackets.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--opt [n]\" help=\"A number, or not\"\n", - "expect": { "usage": "ex [--opt [n]]" } + "expect": { + "usage": "ex [--opt [n]]" + } }, { "id": "flag-required-value-defaulted", "doc": "A default on the flag's `arg` node relaxes the value's brackets and leaves the flag's alone.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs \" required=#true help=\"How many\" {\n arg \"\" default=\"4\"\n}\n", - "expect": { "usage": "ex <--jobs [n]>" } + "expect": { + "usage": "ex <--jobs [n]>" + } }, { "id": "flag-default-on-the-flag", "doc": "The same default written on the flag instead relaxes the other pair: the flag becomes optional and the value stays angled.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs \" default=\"4\" help=\"How many\"\n", - "expect": { "usage": "ex [--jobs ]" } + "expect": { + "usage": "ex [--jobs ]" + } }, { "id": "flag-value-variadic-optional", "doc": "The ellipsis belongs to the value and sits outside its brackets, whichever pair they are.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--inc [pattern]...\" help=\"Patterns to include\"\n", - "expect": { "usage": "ex [--inc [pattern]…]" } + "expect": { + "usage": "ex [--inc [pattern]…]" + } }, { "id": "flag-value-variadic-required", "doc": "A required variadic value, with the flag required too.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--inc ...\" required=#true help=\"Patterns to include\"\n", - "expect": { "usage": "ex <--inc …>" } + "expect": { + "usage": "ex <--inc …>" + } }, { "id": "flag-repeatable-with-optional-value", "doc": "A repeatable flag's own ellipsis follows the spellings, before the value — so a repeatable flag taking an optional value shows both.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"--jobs [n]\" var=#true required=#true help=\"How many\"\n", - "expect": { "usage": "ex <--jobs… [n]>" } + "expect": { + "usage": "ex <--jobs… [n]>" + } }, { "id": "flag-value-named-differently", "doc": "The value's placeholder is the `arg` node's name, not the flag's; a flag whose spellings do not imply its declared name says the name first.", "spec": "name \"ex\"\nbin \"ex\"\nflag \"jobs: -j [n]\" help=\"How many\"\n", - "expect": { "usage": "ex [jobs: -j [n]]" } + "expect": { + "usage": "ex [jobs: -j [n]]" + } }, { "id": "flag-value-optional-on-the-page", diff --git a/corpus/render/03-sections.json b/corpus/render/03-sections.json index 80e7471cf..74e65ddbb 100644 --- a/corpus/render/03-sections.json +++ b/corpus/render/03-sections.json @@ -337,7 +337,7 @@ ] }, "reference": { - "diverges": "usage-lib's help renderer ignores a declared `usage` and writes the generated line; its manpage renderer honours it (`lib/src/docs/manpage/renderer.rs`). usage-argv honours it on the root page, which is what #965 added it for. Recorded rather than fixed here because it is the reference that needs changing, not the expectation." + "diverges": "usage-lib's help renderer ignores a declared `usage` and writes the generated line; its manpage renderer honours it (`lib/src/docs/manpage/renderer.rs`). usage-argv honours it on the root page, which is what #965 added it for. The fleet gate cannot see this even though six of its seven CLIs declare one, because `xtask gen-shadow` does not carry the node into the shadow it generates — so each shadow's `Spec::usage` is `None` and both sides render the generated line. Recorded rather than fixed here because it is the reference that needs changing, not the expectation." } } ] diff --git a/corpus/render/README.md b/corpus/render/README.md index e014a6cd6..90ec206fc 100644 --- a/corpus/render/README.md +++ b/corpus/render/README.md @@ -11,28 +11,43 @@ these without reimplementing a test format. If you are rendering help from a usa Go, in JavaScript, or as a second Rust implementation — this directory is the definition of correct. -## Why this exists beside the mise fixture +## Why this exists beside the fleet gate `benches/gate/tests/help.rs` renders all 211 of mise's commands with `usage-argv` and compares -each page against usage-lib byte for byte. That is the check that decides whether an adopter's -help output changes, and no hand-written corpus will ever match it for scale. - -What it cannot do is cover a shape mise does not use. A flag whose _value_ is optional is one: -every flag value mise declares is required and undefaulted, so the fixture runs entirely -through the one combination where the two implementations happen to agree. `[--opt [n]]` -rendered as `[--opt ]` for as long as `usage-argv` existed, and the 211-command comparison -passed the whole time. - -So the two are complements, and the split is worth stating plainly: - -| | asks | covers | -| ---------------- | ---------------------------------------------------- | ---------------------------------- | -| the mise fixture | does a real CLI still render the same? | one CLI, exhaustively | -| this corpus | does every shape a spec can declare render the same? | every shape, one command at a time | - -A rule that only one of them can catch belongs in whichever one catches it. In practice that -means: if you fix a rendering bug, the regression test goes _here_ unless mise already -exercises the shape. +each page against usage-lib byte for byte; `fleet.rs` beside it does the same for the other six +jdx CLIs. Together they are the check that decides whether an adopter's help output changes, and +no hand-written corpus will match them for scale. + +What they cannot do is cover a shape no CLI in the fleet uses. The fleet was one CLI until #972, +and widening it found three bugs in a week — the version banner, an optional flag value, a +description ending in a break. That is the argument for this corpus rather than against it: +seven real CLIs still leave gaps, and the gaps are not exotic. + +Flag values are the worked example. There are four pairings of "must the flag be given" against +"must its value be given", and across all 809 value-taking flags in mise and the fleet: + +| pairing | in the fleet | +| -------------------------------------------------------- | ---------------------------- | +| `[--tool ]` optional flag, required value | 796 | +| `<--v >` both required | 8 | +| `[--opt [n]]` both optional | 5, all in pitchfork and aube | +| `<--jobs [n]>` required flag, optional value | **0** | +| a value carrying a `default`, which relaxes its brackets | **0** | + +The third row is what #969 fixed, and pitchfork is the only reason it was visible. The last two +rows are shapes a spec can declare that nothing in the fleet does — so nothing but a written-down +case will hold them. + +So the two are complements: + +| | asks | covers | +| -------------- | ---------------------------------------------------- | ---------------------------------- | +| the fleet gate | do seven real CLIs still render the same? | those CLIs, exhaustively | +| this corpus | does every shape a spec can declare render the same? | every shape, one command at a time | + +A rule that only one of them can catch belongs in whichever one catches it. In practice: if you +fix a rendering bug, the regression test goes _here_ unless a fleet CLI already exercises the +shape — and if one does, add it there instead, where it is checked at scale. ## Format From 025b527bcd5b304dd505a8500abaa271660955c5 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:25:22 +0000 Subject: [PATCH 7/8] fix(spec): key a completer the way the reference keys it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table builder resolved `complete_type` by three rules of its own, and the reference (`cli/src/cli/complete_word.rs`) uses three different ones. fnox is where all three matter at once: its `complete "key"` is written once at the top level and means the `` argument of a dozen subcommands, and the builder resolved nothing for any of it. - **Case.** `SpecComplete::parse` lowercases a node's name, so `complete "key"` is stored as `key`; the reference looks it up with the argument's name lowercased. Comparing `` as written matched nothing. - **Reach.** Top-level `complete` nodes were folded onto the root's flags and args only. The reference consults them for whichever command is being completed, so they are handed down the tree instead. - **Precedence.** The reference reads the spec's own nodes *before* the command's. The fold had it the other way round. A flag is keyed by its value's name, never by its own — the reference completes a flag by handing its `SpecArg` to the code that completes a positional — with the flag's name kept only as the fallback for a flag that takes no value, which is what `Spec::to_kdl` writes back. Unit tests rather than corpus vectors, because `complete_type` reaches no page and so the rendering corpus, which catches a dropped field by the difference it makes to rendered text, could never catch any of this. Also refuses a non-ASCII short flag rather than truncating it. `'é' as u8` is a byte no UTF-8 line contains, so the cast built a table describing a flag nobody could type; usage-argv holds a short as one byte and has no representation for the rest. Co-Authored-By: Claude Opus 5 --- conformance/src/argv.rs | 10 ++- conformance/src/tables.rs | 176 ++++++++++++++++++++++++++------------ 2 files changed, 128 insertions(+), 58 deletions(-) diff --git a/conformance/src/argv.rs b/conformance/src/argv.rs index 8676145c9..5cf57190f 100644 --- a/conformance/src/argv.rs +++ b/conformance/src/argv.rs @@ -58,7 +58,15 @@ pub fn run(vector: &Vector) -> Outcome { // hold it. Everything below inherits it, which the parser now does itself rather than // this flattening it on the way in — a second implementation of the same rule, and the // one that hid the parser not having it. - let root = tables::build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)).cmd; + // + // No completers, because this harness binds a line and no completion metadata changes what + // binds. `build_spec` resolves a spec's own, for the rendering corpus. + let root = tables::build( + &spec.cmd, + spec.unknown_flags.map(convert_unknown_flags), + &[], + ) + .cmd; // `default_subcommand` is a property of the spec rather than of a command, so it is // resolved once, here, against the root's own subcommands. A name that answers to // nothing is left as None: the spec is what it is, and a vector that expects routing diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index c94203268..6a31926a2 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -49,11 +49,27 @@ pub fn convert_unknown_flags(mode: usage::UnknownFlags) -> ArgvUnknownFlags { /// `root_unknown_flags` is carried through as the spec states it — `None` where a command says /// nothing — because the parser inherits it. The root takes the spec-level setting, since that /// is the command a spec's own property describes. -pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> Built { +/// +/// `spec_completers` are the spec's top-level `complete` nodes, which every command sees: the +/// reference looks a completer up spec-level first and only then on the command +/// (`cli/src/cli/complete_word.rs`), so they are handed down the tree in that order rather than +/// folded onto the root. fnox is the fleet's proof that this matters — its `complete "key"` is +/// written once at the top level and means the `` argument of a dozen subcommands. +pub fn build( + cmd: &SpecCommand, + root_unknown_flags: Option, + spec_completers: &[&SpecComplete], +) -> Built { let unknown_flags = cmd .unknown_flags .map(convert_unknown_flags) .or(root_unknown_flags); + // Spec-level first, so that the first match wins in the reference's own order of preference. + let completers: Vec<&SpecComplete> = spec_completers + .iter() + .copied() + .chain(cmd.complete.values()) + .collect(); let flags: Vec<&'static Flag<'static>> = cmd.flags.iter().map(build_flag).collect(); let args: Vec<&'static Arg<'static>> = cmd.args.iter().map(build_arg).collect(); @@ -61,8 +77,9 @@ pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> .subcommands .values() // A subcommand states its own or says nothing; there is no spec-level setting to hand - // it, since the root has already taken that. - .map(|sub| build(sub, None)) + // it, since the root has already taken that. The completers do carry down, because the + // reference resolves them for whichever command is being completed. + .map(|sub| build(sub, None, spec_completers)) .collect(); let aliases: Vec<&'static str> = cmd @@ -95,13 +112,13 @@ pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> .flags .iter() .zip(&flags) - .map(|(f, table)| flag_meta(f, table, cmd.complete.values())) + .map(|(f, table)| flag_meta(f, table, &completers)) .collect(); let arg_metas: Vec> = cmd .args .iter() .zip(&args) - .map(|(a, table)| arg_meta(a, table, cmd.complete.values())) + .map(|(a, table)| arg_meta(a, table, &completers)) .collect(); let meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { @@ -155,7 +172,15 @@ pub fn build(cmd: &SpecCommand, root_unknown_flags: Option) -> /// the help texts lost them silently, and `render/03-sections.json` pins all three cases now: /// the root's own page, a page that falls back to them, and a page that has its own instead. pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { - let root = build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)); + // The third thing a spec writes at the top level and hangs off `Spec` rather than off the + // root command. Unlike the other two it is not the root's to keep: `build` hands it down to + // every command, because that is where the reference looks for it. + let spec_completers: Vec<&SpecComplete> = spec.complete.values().collect(); + let root = build( + &spec.cmd, + spec.unknown_flags.map(convert_unknown_flags), + &spec_completers, + ); // Whether the parser answers `--version` here, which the derive sets on the root of a CLI // that declares one. It has to be on the *table*, not only on the spec: a page offers // `--version` where the parser accepts it, and one that offered it otherwise would be @@ -166,31 +191,6 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { })); let mut root_examples = root.meta.examples.to_vec(); root_examples.extend(spec.examples.iter().map(example)); - // The third thing a spec writes at the top level and hangs off `Spec` rather than off the - // root command. Filled in only where the command's own map had nothing, so a `complete` - // inside a `cmd` block still wins for that command. - let root_flags: Vec> = root - .meta - .flags - .iter() - .map(|f| FlagMeta { - complete_type: f - .complete_type - .or_else(|| complete_type(spec.complete.values(), f.flag.name, f.value_name)), - ..*f - }) - .collect(); - let root_args: Vec> = root - .meta - .args - .iter() - .map(|a| ArgMeta { - complete_type: a - .complete_type - .or_else(|| complete_type(spec.complete.values(), a.arg.name, None)), - ..*a - }) - .collect(); let root_meta: &'static CommandMeta<'static> = Box::leak(Box::new(CommandMeta { cmd: root_cmd, before_help: root.meta.before_help.or(opt(&spec.before_help)), @@ -198,8 +198,6 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { after_help: root.meta.after_help.or(opt(&spec.after_help)), after_long_help: root.meta.after_long_help.or(opt(&spec.after_help_long)), examples: Box::leak(root_examples.into_boxed_slice()), - flags: Box::leak(root_flags.into_boxed_slice()), - args: Box::leak(root_args.into_boxed_slice()), ..*root.meta })); Box::leak(Box::new(usage_argv::spec::Spec { @@ -221,7 +219,21 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { fn build_flag(f: &SpecFlag) -> &'static Flag<'static> { let longs: Vec<&'static str> = f.long.iter().map(|l| leak(l)).collect(); - let shorts: Vec = f.short.iter().map(|c| *c as u8).collect(); + // A short flag is one byte in the table, so a non-ASCII spelling has no representation there + // at all: the line arrives as UTF-8, where such a character is two bytes or more, and + // whatever single byte a cast produced would match nothing anybody could type. Refusing says + // so; `'é' as u8` would have built a table describing a flag that cannot be reached. + let shorts: Vec = f + .short + .iter() + .map(|c| { + assert!( + c.is_ascii(), + "a short flag must be ASCII for usage-argv's tables, and `-{c}` is not" + ); + *c as u8 + }) + .collect(); Box::leak(Box::new(Flag { key: 0, name: leak(&f.name), @@ -262,10 +274,10 @@ fn build_arg(a: &SpecArg) -> &'static Arg<'static> { })) } -fn flag_meta<'a>( +fn flag_meta( f: &SpecFlag, table: &'static Flag<'static>, - completers: impl Iterator, + completers: &[&SpecComplete], ) -> FlagMeta<'static> { let arg = f.arg.as_ref(); FlagMeta { @@ -303,10 +315,10 @@ fn flag_meta<'a>( } } -fn arg_meta<'a>( +fn arg_meta( a: &SpecArg, table: &'static Arg<'static>, - completers: impl Iterator, + completers: &[&SpecComplete], ) -> ArgMeta<'static> { ArgMeta { arg: table, @@ -335,23 +347,30 @@ const NO_COMPLETER: Option = None; /// The built-in completion class declared for a flag or argument, if any. /// -/// `complete` nodes name the thing they complete rather than living on it, so this is a lookup. -/// A flag's node may name the flag or its value, and `Spec::to_kdl` writes the value's -/// lowercased, so both are tried — the flag's own name first, as the more specific of the two. +/// `complete` nodes name the thing they complete rather than living on it, so this is a lookup, +/// and it has to key the way the reference keys or the two disagree about a spec neither is free +/// to reinterpret. Two rules come from `cli/src/cli/complete_word.rs`: /// -/// Taken as an iterator rather than the `IndexMap` it comes from, so that this crate does not -/// have to depend on `indexmap` to name the type. Each node knows its own name. -fn complete_type<'a>( - completers: impl Iterator, +/// - **The key is the value's name, lowercased.** A completer for a flag is found by the name of +/// the value it takes, never by the flag's own — the reference completes a flag by handing its +/// `SpecArg` to the same code that completes a positional. The flag's name is tried only for a +/// flag that takes no value, which is the fallback `Spec::to_kdl` writes back. +/// - **The comparison ignores case.** `SpecComplete::parse` lowercases the node's name, so a +/// declared `complete "key"` is stored as `key` and matched against `` lowercased. fnox +/// writes exactly that, and comparing the name as written found nothing for any of it. +/// +/// `completers` are already in the reference's order of preference — the spec's own nodes before +/// the command's — so the first match wins. They arrive as a slice of borrows rather than the +/// `IndexMap` they come from so that this crate need not depend on `indexmap` to name the type. +fn complete_type( + completers: &[&SpecComplete], name: &str, value_name: Option<&str>, ) -> Option<&'static str> { - let lowered = value_name.map(str::to_ascii_lowercase); - let all: Vec<&SpecComplete> = completers.collect(); - let found = [Some(name), lowered.as_deref()] - .into_iter() - .flatten() - .find_map(|key| all.iter().find(|c| c.name == key)) + let key = value_name.unwrap_or(name).to_lowercase(); + let found = completers + .iter() + .find(|c| c.name == key) .and_then(|c| c.type_.as_deref()); found.map(leak) } @@ -421,7 +440,7 @@ mod tests { fn the_fields_that_do_not_reach_a_page_are_carried_too() { let spec: Spec = "name \"ex\"\nbin \"ex\"\nmin_usage_version \"2.1.0\"\n\ flag \"--out \"\narg \"\"\n\ - complete \"out\" type=\"path\"\ncomplete \"dir\" type=\"dir\"\n" + complete \"file\" type=\"path\"\ncomplete \"dir\" type=\"dir\"\n" .parse() .expect("valid spec"); let built = build_spec(&spec); @@ -434,15 +453,58 @@ mod tests { assert!(built.root.flags[0].complete.is_none()); } - /// A `complete` node may name a flag's *value* rather than the flag, and `Spec::to_kdl` - /// writes the value's name lowercased — so the lookup has to try both spellings. + /// A completer is keyed by the *value's* name, lowercased, on whichever command asks. + /// + /// All three parts are the reference's, and getting any of them wrong resolves nothing for + /// fnox, whose `complete "key"` is written once at the top level and means the `` of a + /// subcommand. Written as a unit test because `complete_type` reaches no page: the rendering + /// corpus catches a dropped field by the difference it makes to rendered text, and this + /// field makes none. #[test] - fn a_completer_keyed_by_a_flags_value_is_found_too() { - let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--out \"\n\ - complete \"file\" type=\"path\"\n" + fn a_completer_is_keyed_the_way_the_reference_keys_it() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\n\ + flag \"--out \"\n\ + cmd \"get\" {\n arg \"\"\n flag \"--to \"\n}\n\ + complete \"key\" type=\"file\"\ncomplete \"file\" type=\"path\"\n\ + complete \"out\" type=\"dir\"\n" .parse() .expect("valid spec"); let built = build_spec(&spec); + let get = built.root.subcommands[0]; + + // The value's name, not the flag's: the reference completes a flag by handing its value + // to the code that completes a positional, so `complete "out"` answers for nothing. assert_eq!(built.root.flags[0].complete_type, Some("path")); + // `` against a node stored as `key`, on a subcommand, from the top level. + assert_eq!(get.args[0].complete_type, Some("file")); + // And nothing invented for a value no node names. + assert_eq!(get.flags[0].complete_type, None); + } + + /// The spec's own nodes are consulted before the command's, which is the reference's order + /// (`cli/src/cli/complete_word.rs`) and not the intuitive one. + #[test] + fn a_spec_level_completer_wins_over_a_commands_own() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\n\ + cmd \"get\" {\n arg \"\"\n complete \"key\" type=\"dir\"\n}\n\ + complete \"key\" type=\"file\"\n" + .parse() + .expect("valid spec"); + let built = build_spec(&spec); + assert_eq!( + built.root.subcommands[0].args[0].complete_type, + Some("file") + ); + } + + /// usage-argv holds a short flag as one byte, so a spec declaring one it cannot hold is + /// refused rather than mirrored into a table describing a flag nobody can type. + #[test] + #[should_panic(expected = "a short flag must be ASCII")] + fn a_non_ascii_short_flag_is_refused_rather_than_truncated() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-é --etage\"\n" + .parse() + .expect("valid spec"); + build_spec(&spec); } } From 233384d9042f29bc0bf9a25b0b0747539eb4bc6b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:25:28 +0000 Subject: [PATCH 8/8] docs(spec): say what the oracle's --json actually emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Pipe it into the file" was wrong: a row is `{"id", "usage-lib", "usage-argv"}`, and only each renderer's object is an `expect` as a vector writes one. Say so, and show the `jq` that pulls one out — the point of authoring from a measurement survives, but only if the instruction works. Co-Authored-By: Claude Opus 5 --- conformance/src/bin/render-oracle.rs | 10 +++++++--- corpus/render/README.md | 13 +++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/conformance/src/bin/render-oracle.rs b/conformance/src/bin/render-oracle.rs index c3890248e..a6c45b3b1 100644 --- a/conformance/src/bin/render-oracle.rs +++ b/conformance/src/bin/render-oracle.rs @@ -5,8 +5,11 @@ //! expectation gets filled in with a measurement rather than a guess — and how the `reference` //! label gets set honestly when the two disagree. //! -//! `--json` emits the same thing machine-readably, which is what makes it usable for filling -//! in a new vector: pipe it into the file rather than transcribing a page by hand. +//! `--json` emits the same thing machine-readably, which is what makes it usable for filling in +//! a new vector: an array of `{"id", "usage-lib", "usage-argv"}`, where each of the two is a +//! vector's `expect` object exactly as written. So it is not the file's own shape — pick the +//! renderer you have decided is right and copy that object, which `corpus/render/README.md` +//! shows with `jq`. Copying a measurement beats transcribing a page by hand either way. //! //! The test suite (`conformance/tests/render.rs`) is what actually enforces agreement in CI. @@ -32,7 +35,8 @@ fn main() -> Result<(), String> { } if json { - // The shape a vector's `expect` takes, so a new one can be filled in by copying. + // One row per vector, each renderer's result in the shape a vector's `expect` takes, so + // a new one can be filled in by copying the object of whichever renderer is right. let out: Vec = rows .iter() .map(|(vector, lib, argv)| { diff --git a/corpus/render/README.md b/corpus/render/README.md index 90ec206fc..921b9ae0e 100644 --- a/corpus/render/README.md +++ b/corpus/render/README.md @@ -146,8 +146,17 @@ cargo run -p usage-conformance --bin render-oracle -- flag-value ## Adding a vector Write the spec and the `doc` first, with the rendering the rules say you should get. Then run -the oracle: `--json` prints what both implementations produced, in the shape `expect` takes, so -a page goes in as a measurement rather than a transcription. +the oracle: `--json` prints what both implementations produced, so a page goes in as a +measurement rather than a transcription. + +Each row is `{"id", "usage-lib", "usage-argv"}`, and each of the two is an `expect` object +exactly as a vector writes one — the row is not, so pick the renderer you have decided is right +and copy its object out: + +```sh +cargo run -q -p usage-conformance --bin render-oracle -- --json flag-optional-value-optional \ + | jq '.[0]."usage-lib"' +``` If the two agree and match what you expected, you are done. If they agree and you expected something else, you have found a rule you had wrong — or a rule worth changing, in which case