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..5cf57190f 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,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 = build(&spec.cmd, spec.unknown_flags.map(convert_unknown_flags)); + // + // 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 @@ -177,14 +179,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 +218,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..a6c45b3b1 --- /dev/null +++ b/conformance/src/bin/render-oracle.rs @@ -0,0 +1,99 @@ +//! 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: 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. + +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 { + // 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)| { + 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) { + // 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 ", + (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::OutOfScope(why) => serde_json::json!({ "out_of_scope": 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..ff424d957 --- /dev/null +++ b/conformance/src/render.rs @@ -0,0 +1,264 @@ +//! 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 fleet gate +//! +//! `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. +//! +//! 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 +//! +//! 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 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 { + 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()), + // 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 { + 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}")), + }; + 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 + // 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..6a31926a2 --- /dev/null +++ b/conformance/src/tables.rs @@ -0,0 +1,510 @@ +//! 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. +//! +//! # 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, SpecComplete, 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. +/// +/// `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(); + 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. 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 + .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, &completers)) + .collect(); + let arg_metas: Vec> = cmd + .args + .iter() + .zip(&args) + .map(|(a, table)| arg_meta(a, table, &completers)) + .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: examples(&cmd.examples), + 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 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> { + // 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 + // 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 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 { + name: leak(&spec.name), + bin: Some(leak(&spec.bin)), + version: opt(&spec.version), + min_usage_version: opt(&spec.min_usage_version), + 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, + })) +} + +fn build_flag(f: &SpecFlag) -> &'static Flag<'static> { + let longs: Vec<&'static str> = f.long.iter().map(|l| leak(l)).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), + 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>, + completers: &[&SpecComplete], +) -> 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, 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 + .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), + 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>, + completers: &[&SpecComplete], +) -> 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), + 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, +/// 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`: +/// +/// - **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 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) +} + +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(), + ) +} + +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()) +} + +#[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 \"file\" 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 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_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); + } +} diff --git a/conformance/tests/render.rs b/conformance/tests/render.rs new file mode 100644 index 000000000..3d982aa98 --- /dev/null +++ b/conformance/tests/render.rs @@ -0,0 +1,146 @@ +//! 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") + ); +} + +/// 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 + // 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, and a vector + // out of usage-argv's scope has nothing for the two to agree or disagree about. + 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..655fe8d75 --- /dev/null +++ b/corpus/render/01-flag-values.json @@ -0,0 +1,95 @@ +{ + "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 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 ]" + } + }, + { + "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..74e65ddbb --- /dev/null +++ b/corpus/render/03-sections.json @@ -0,0 +1,344 @@ +{ + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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. 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 new file mode 100644 index 000000000..921b9ae0e --- /dev/null +++ b/corpus/render/README.md @@ -0,0 +1,165 @@ +# 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 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; `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 + +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. + +### 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 +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, 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 +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.