-
-
Notifications
You must be signed in to change notification settings - Fork 49
feat(argv): render the usage line, byte-identical to usage-lib's #854
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3178b76
feat(argv): render the usage line, byte-identical to usage-lib's
jdx 0ec1bb1
fix(argv): a defaulted argument reads as optional, as usage-lib rende…
jdx c2d4575
fix(derive): keep a short-only flag's descriptive value name
jdx 32ff41b
fix(derive): take a flag's placeholder from its form, not its field
jdx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| //! The one-line summary of how a command is invoked. | ||
| //! | ||
| //! `Usage: mise use [OPTIONS] <TOOL@VERSION>…` — the line at the top of `--help`, and the | ||
| //! first thing a CLI framework has to be able to produce. | ||
| //! | ||
| //! Built from the same `&'static` metadata a parse ignores, so a binary that never asks for | ||
| //! help pays nothing for being able to. Nothing here is on the hot path. | ||
| //! | ||
| //! # Matching usage-lib | ||
| //! | ||
| //! usage-lib renders this from a spec, through a tera template over a runtime model. This | ||
| //! crate cannot: there is no `Spec` at run time, only the tables. So the rules are | ||
| //! reimplemented, and the test that matters compares the two outputs over every command in | ||
| //! mise's real spec — 211 of them — because an adopter's help text changing is a visible | ||
| //! regression even when it is a small one. | ||
| //! | ||
| //! Where the two disagree the difference is recorded, in the same spirit as the parser's | ||
| //! corpus: usage-lib is the reference, and a divergence is a decision rather than an accident. | ||
|
|
||
| use core::fmt::Write as _; | ||
|
|
||
| use crate::spec::{ArgMeta, CommandMeta, FlagMeta}; | ||
| use crate::DoubleDash; | ||
|
|
||
| /// How many flags or arguments are listed individually before collapsing to a placeholder. | ||
| /// | ||
| /// usage-lib's number. Beyond it the line would be longer than it is useful, so it becomes | ||
| /// `[FLAGS]` or `[ARGS]…` and the sections below carry the detail. | ||
| const INLINE_LIMIT: usize = 2; | ||
|
|
||
| /// The `Usage:` line's body, without the `Usage: ` prefix. | ||
| /// | ||
| /// `path` is the command as invoked, starting with the binary: `["mise", "config", "ls"]`. | ||
| /// The metadata holds a tree and each node knows its own name, so the path a *particular* | ||
| /// invocation took has to come from the caller — which is the parser, or a `help` command | ||
| /// naming a command explicitly. | ||
| /// | ||
| /// ``` | ||
| /// use usage_argv::help::usage_line; | ||
| /// use usage_argv::spec::{ArgMeta, CommandMeta, FlagMeta}; | ||
| /// use usage_argv::{Arg, Command, Flag}; | ||
| /// | ||
| /// static FORCE: Flag = Flag { name: "force", longs: &["force"], ..Flag::BOOL }; | ||
| /// static TOOL: Arg = Arg { name: "TOOL", ..Arg::REQUIRED }; | ||
| /// static CMD: Command = Command { | ||
| /// name: "use", | ||
| /// flags: &[&FORCE], | ||
| /// args: &[&TOOL], | ||
| /// ..Command::EMPTY | ||
| /// }; | ||
| /// static META: CommandMeta = CommandMeta { | ||
| /// cmd: &CMD, | ||
| /// flags: &[FlagMeta { flag: &FORCE, ..FlagMeta::EMPTY }], | ||
| /// args: &[ArgMeta { arg: &TOOL, required: true, ..ArgMeta::EMPTY }], | ||
| /// ..CommandMeta::EMPTY | ||
| /// }; | ||
| /// | ||
| /// assert_eq!(usage_line(&["mise", "use"], &META), "mise use [--force] <TOOL>"); | ||
| /// ``` | ||
| pub fn usage_line(path: &[&str], meta: &CommandMeta<'_>) -> String { | ||
| let mut out = String::new(); | ||
| for (i, part) in path.iter().enumerate() { | ||
| if i > 0 { | ||
| out.push(' '); | ||
| } | ||
| out.push_str(part); | ||
| } | ||
|
|
||
| // Hidden entries are absent from the line as they are from the sections: help describes | ||
| // what a user is invited to type. | ||
| let flags: usize = meta.flags.iter().filter(|f| !f.hide).count(); | ||
| if flags > 0 { | ||
| let required = meta.flags.iter().any(|f| !f.hide && flag_demanded(f)); | ||
| if flags <= INLINE_LIMIT { | ||
| for flag in meta.flags.iter().filter(|f| !f.hide) { | ||
| // A required flag is angled, like a required argument: the brackets are what | ||
| // say whether leaving it out is allowed. | ||
| let (open, close) = if flag_demanded(flag) { | ||
| ('<', '>') | ||
| } else { | ||
| ('[', ']') | ||
| }; | ||
| let _ = write!(out, " {open}{}{close}", flag_usage(flag)); | ||
| } | ||
| } else if required { | ||
| out.push_str(" <FLAGS>"); | ||
| } else { | ||
| out.push_str(" [FLAGS]"); | ||
| } | ||
| } | ||
|
|
||
| let args: usize = meta.args.iter().filter(|a| !a.hide).count(); | ||
| if args > 0 { | ||
| let required = meta.args.iter().any(|a| !a.hide && demanded(a)); | ||
| if args <= INLINE_LIMIT { | ||
| for arg in meta.args.iter().filter(|a| !a.hide) { | ||
| let _ = write!(out, " {}", arg_usage(arg)); | ||
| } | ||
| } else if required { | ||
| out.push_str(" <ARGS>…"); | ||
| } else { | ||
| out.push_str(" [ARGS]…"); | ||
| } | ||
| } | ||
|
|
||
| if !meta.cmd.subcommands.is_empty() { | ||
| out.push_str(" <SUBCOMMAND>"); | ||
| } | ||
| out | ||
| } | ||
|
|
||
| /// How one flag appears in the usage line: `-f --force`, plus its value if it takes one. | ||
| fn flag_usage(meta: &FlagMeta<'_>) -> String { | ||
| let flag = meta.flag; | ||
| let mut out = String::new(); | ||
|
|
||
| // The declared name, when it is not the one the forms would imply. A flag called | ||
| // `verbose` reachable only as `-v` has to say so, or help would name something the | ||
| // spec does not. | ||
| let implied = flag | ||
| .longs | ||
| .first() | ||
| .copied() | ||
| .or_else(|| flag.shorts.first().map(|_| "")); | ||
| let implied_matches = match (implied, flag.shorts.first()) { | ||
| (Some(long), _) if !long.is_empty() => long == flag.name, | ||
| (Some(_), Some(short)) => { | ||
| let mut buf = [0u8; 4]; | ||
| (*short as char).encode_utf8(&mut buf) == flag.name | ||
| } | ||
| _ => false, | ||
| }; | ||
| if !implied_matches { | ||
| let _ = write!(out, "{}:", flag.name); | ||
| } | ||
| if let Some(short) = flag.shorts.first() { | ||
| if !out.is_empty() { | ||
| out.push(' '); | ||
| } | ||
| let _ = write!(out, "-{}", *short as char); | ||
| } | ||
| if let Some(long) = flag.longs.first() { | ||
| if !out.is_empty() { | ||
| out.push(' '); | ||
| } | ||
| let _ = write!(out, "--{long}"); | ||
| } | ||
|
|
||
| // A repeatable flag, which is the spec's `var=#true` — not one occurrence taking several | ||
| // values, which is the value's own business below. | ||
| if meta.repeatable { | ||
| out.push('…'); | ||
| } | ||
| if flag.takes_value { | ||
| let name = meta.value_name.unwrap_or(flag.name); | ||
| let _ = write!(out, " <{name}>"); | ||
| if flag.variadic { | ||
| out.push('…'); | ||
| } | ||
| } | ||
| out | ||
| } | ||
|
|
||
| /// How one positional argument appears: `<TOOL>`, `[FILES]…`, `-- <ARGS>`. | ||
| /// Whether a flag must be given, which is not quite what `required` says. | ||
| /// | ||
| /// Same rule as [`demanded`], for the same reason: usage-lib clears `required` on a flag that | ||
| /// declares a default before rendering, so reading the flag alone printed `<--out>` for a flag | ||
| /// the parser fills when it is left out. | ||
| fn flag_demanded(meta: &FlagMeta<'_>) -> bool { | ||
| meta.required && meta.default.is_empty() | ||
| } | ||
|
|
||
| /// Whether an argument must be given, which is not quite what `required` says. | ||
| /// | ||
| /// usage-lib clears `required` while *parsing* a spec that declares a default — a defaulted | ||
| /// argument is one the user may leave out — and then renders the usage line from `required` | ||
| /// alone. The derive keeps the two separate, so reading `required` on its own printed `<file>` | ||
| /// where usage-lib prints `[file]`, for an argument the parser is perfectly happy to omit. | ||
| /// | ||
| /// Applied here rather than by clearing the flag in the metadata, because the metadata is what | ||
| /// the emitted spec is built from and `required` there means what the author wrote. | ||
| fn demanded(meta: &ArgMeta<'_>) -> bool { | ||
| meta.required && meta.default.is_empty() | ||
| } | ||
|
|
||
| fn arg_usage(meta: &ArgMeta<'_>) -> String { | ||
| let arg = meta.arg; | ||
| let mut out = String::new(); | ||
| let (open, close) = if demanded(meta) { | ||
| ('<', '>') | ||
| } else { | ||
| ('[', ']') | ||
| }; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| // An argument that only takes what follows a `--` shows the separator, because typing the | ||
| // value without it does not reach this argument at all — and the brackets go *outside* | ||
| // it, as usage-lib writes it: `[-- COMMAND]…`, one optional thing rather than a literal | ||
| // `--` followed by an optional word. | ||
| if arg.double_dash == DoubleDash::Required { | ||
| let _ = write!(out, "{open}-- {}{close}", arg.name); | ||
| } else { | ||
| let _ = write!(out, "{open}{}{close}", arg.name); | ||
| } | ||
| if arg.var { | ||
| out.push('…'); | ||
| } | ||
| out | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| //! Does the rendered usage line match usage-lib's, at mise's scale? | ||
| //! | ||
| //! usage-lib builds this from a spec, through a tera template over a runtime model. | ||
| //! usage-argv has no spec at run time — only `&'static` tables — so the rules are | ||
| //! reimplemented there, and reimplemented rules drift. The check is to run both over mise's | ||
| //! real spec and compare all 211 lines, because an adopter's help text changing is a visible | ||
| //! regression even when the change is one bracket. | ||
| //! | ||
| //! The shadow is generated from the same `mise.usage.kdl` that usage-lib is given here, so | ||
| //! the two sides describe the same CLI by construction rather than by a fixture kept in step | ||
| //! by hand. | ||
|
|
||
| use usage::{Spec as LibSpec, SpecCommand}; | ||
| use usage_argv::help::usage_line; | ||
| use usage_argv::spec::CommandMeta; | ||
|
|
||
| /// mise's committed spec, which the shadow was generated from. | ||
| fn mise_spec() -> LibSpec { | ||
| let kdl = include_str!("../../mise.usage.kdl"); | ||
| kdl.parse().expect("mise's spec should parse") | ||
| } | ||
|
|
||
| /// Every command in the tree, as (path, metadata) — the path being what a user types. | ||
| fn walk<'a>( | ||
| path: Vec<&'a str>, | ||
| meta: &'a CommandMeta<'a>, | ||
| out: &mut Vec<(Vec<&'a str>, &'a CommandMeta<'a>)>, | ||
| ) { | ||
| out.push((path.clone(), meta)); | ||
| for sub in meta.subcommands { | ||
| // Aliases live in the parse table beside the canonical name; help names the command. | ||
| let mut child = path.clone(); | ||
| child.push(sub.cmd.name); | ||
| walk(child, sub, out); | ||
| } | ||
| } | ||
|
|
||
| /// usage-lib's line for the same command, found by following the same path. | ||
| fn lib_command<'a>(spec: &'a LibSpec, path: &[&str]) -> Option<&'a SpecCommand> { | ||
| let mut cmd = &spec.cmd; | ||
| for name in path { | ||
| cmd = cmd.subcommands.get(*name)?; | ||
| } | ||
| Some(cmd) | ||
| } | ||
|
|
||
| #[test] | ||
| fn every_usage_line_matches_the_reference() { | ||
| let spec = mise_spec(); | ||
| let root = shadow_mise::Cli::spec().root; | ||
|
|
||
| let mut commands = Vec::new(); | ||
| walk(vec!["mise"], root, &mut commands); | ||
| assert!( | ||
| commands.len() > 200, | ||
| "the shadow should cover mise's whole tree, found {}", | ||
| commands.len() | ||
| ); | ||
|
|
||
| let mut differences = Vec::new(); | ||
| for (path, meta) in &commands { | ||
| let ours = usage_line(path, meta); | ||
| // usage-lib's `usage()` omits the binary and starts at the command path, so the | ||
| // comparison puts it back — the same string the template writes after `Usage: `. | ||
| let theirs = match lib_command(&spec, &path[1..]) { | ||
| Some(cmd) => format!("mise {}", cmd.usage()).trim().to_string(), | ||
| None => { | ||
| differences.push(format!("{}: not found in the spec at all", path.join(" "))); | ||
| continue; | ||
| } | ||
| }; | ||
| if ours != theirs { | ||
| differences.push(format!( | ||
| "{}\n ours: {ours}\n lib: {theirs}", | ||
| path.join(" ") | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| assert!( | ||
| differences.is_empty(), | ||
| "{} of {} usage lines differ from usage-lib:\n - {}", | ||
| differences.len(), | ||
| commands.len(), | ||
| differences.join("\n - ") | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_root_line_is_what_a_user_would_recognise() { | ||
| // One case spelled out, so a reader can see what the parity test is asserting 211 times. | ||
| let root = shadow_mise::Cli::spec().root; | ||
| let line = usage_line(&["mise"], root); | ||
| assert!( | ||
| line.starts_with("mise "), | ||
| "the line should start with the binary: {line}" | ||
| ); | ||
| assert!( | ||
| line.ends_with("<SUBCOMMAND>"), | ||
| "mise has subcommands, so the line should end by saying so: {line}" | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.