diff --git a/AGENTS.md b/AGENTS.md index 9a7ae8cf9..781a0e853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,10 @@ The `parse()` function parses command-line arguments against a spec, returning: - Matched command path - Parsed args and flags with values - Env var and default fallbacks applied +- Provenance: `tokens` says what each word of argv became, and + `flag_origins`/`arg_origins` say where a value came from when no token supplied + it. `Parser::explain` returns all of it with the errors kept rather than bailing + on the first, which is what `usage explain` renders. ### Documentation Generation (`lib/src/docs/`) diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index 21c4d254e..06851f8a6 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -194,6 +194,63 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: "explain", + description: "Explain what a command line binds to", + options: [ + { + name: ["-f", "--file"], + description: + 'A usage spec file or script with a usage shebang, use "-" to read from stdin', + isRepeatable: false, + args: { + name: "file", + template: "filepaths", + }, + }, + { + name: ["-s", "--spec"], + description: "Raw string spec input", + isRepeatable: false, + args: { + name: "spec", + }, + }, + { + name: "--format", + description: "Output format", + isRepeatable: false, + args: { + name: "format", + suggestions: ["text", "json"], + }, + }, + { + name: "--view", + description: "A spec-declared executable view to explain", + isRepeatable: false, + args: { + name: "view", + }, + }, + { + name: ["-e", "--env"], + description: + "Environment to explain against, as KEY=VALUE, repeatable", + isRepeatable: true, + args: { + name: "env", + }, + }, + ], + args: { + name: "argv", + description: + "The command line to explain, starting with the program name", + isOptional: true, + isVariadic: true, + }, + }, { name: "fish", description: "Execute a shell script using fish", diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index d14bd7d80..4d8a39797 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -32,6 +32,9 @@ Execute a script, parsing args and exposing them as environment variables \fIAliases: \fRx .RE .TP +\fBexplain\fR +Explain what a command line binds to +.TP \fBfish\fR Execute a shell script with the specified shell .TP @@ -176,6 +179,49 @@ path to script to execute .TP \fB\fR arguments to pass to script +.SH "USAGE EXPLAIN" +Explain what a command line binds to + +Prints a row per argv token saying what it became, then the values that came from +somewhere other than argv, then anything that went wrong. Exits 0 even when the explained +command line does not parse: the report succeeded, and that is the case worth a report. +.PP +\fBUsage:\fR usage explain [OPTIONS] [] ... +.PP +\fBOptions:\fR +.PP +.TP +\fB\-f, \-\-file\fR \fI\fR +A usage spec file or script with a usage shebang, use "\-" to read from stdin +.TP +\fB\-s, \-\-spec\fR \fI\fR +Raw string spec input +.TP +\fB\-\-format\fR \fI\fR +Output format +.RS +\fIDefault: \fRtext +.RE +.TP +\fB\-\-view\fR \fI\fR +A spec\-declared executable view to explain +.TP +\fB\-e, \-\-env\fR \fI\fR +Environment to explain against, as KEY=VALUE, repeatable + +Given at all, these are the *whole* environment: an explanation pasted into a bug +report has to mean the same thing on the machine that reads it. Omitted, the process +environment is used, which is what an execution would see. +\fBArguments:\fR +.PP +.TP +\fB\fR +The command line to explain, starting with the program name + +`usage`'s own flags come before it, and flag parsing ends at the program name, so +both `explain \-f f.kdl mycli \-\-env=prod` and `explain \-f f.kdl \-\- mycli \-\-env=prod` +work. Separate with `\-\-` when the explained line carries its own: the first `\-\-` is +still `usage`'s separator, so `explain \-f f.kdl mycli a \-\- b` loses one. .SH "USAGE FISH" Execute a shell script with the specified shell diff --git a/cli/src/cli/explain.rs b/cli/src/cli/explain.rs new file mode 100644 index 000000000..c2b938f6f --- /dev/null +++ b/cli/src/cli/explain.rs @@ -0,0 +1,1079 @@ +//! What a command line binds to, and where each value came from. +//! +//! `docs/spec/argv.md` exists to define "which token binds to which flag or argument", and +//! until now nothing would show you that for a given command line. The parser knew and threw +//! it away. So a spec author debugging a flag that will not take a value, and a reader +//! learning why `--color bar` leaves `bar` to the positionals, both had to reason about the +//! grammar rather than ask. +//! +//! Two tables, deliberately. A table keyed by token cannot show a value that came from +//! nowhere in argv; a table keyed by declaration cannot show a token that bound to nothing. +//! jdx/mise discussion #8883 — `mise --env=production` silently ignored while +//! `mise --env production` worked — lives in the first. "Why is my default not applying" +//! lives in the second. + +use std::collections::HashMap; +use std::path::PathBuf; + +use itertools::Itertools; +use miette::Result; +use usage::error::UsageErr; +use usage::parse::{ParseOutput, Parser, TokenRole, ValueOrigin}; +use usage::{Spec, SpecArg, SpecFlag}; + +use crate::cli::generate::{file_or_spec, select_view}; +use crate::cli::{empty_mount_answers, OutputFormat}; + +/// Explain what a command line binds to +/// +/// Prints a row per argv token saying what it became, then the values that came from +/// somewhere other than argv, then anything that went wrong. Exits 0 even when the explained +/// command line does not parse: the report succeeded, and that is the case worth a report. +#[derive(Debug, usage_rs::Args)] +// No `unknown_flags = "value"`, unlike `exec`: `double_dash="automatic"` on `argv` below +// already ends this command's own flag parsing at the program name, so a well-formed +// invocation never offers a foreign flag here. Keeping the root's `error` means +// `usage explain -f f.kdl --nope` says there is no such flag rather than silently explaining +// nothing. +#[usage(effect = "read", verbatim_doc_comment)] +pub struct Explain { + /// A usage spec file or script with a usage shebang, use "-" to read from stdin + #[usage(short, long, value_hint = usage_rs::ValueHint::FilePath)] + file: Option, + + /// Raw string spec input + #[usage(short, long, required_unless = "--file", overrides = "--file")] + spec: Option, + + /// Output format + #[usage(long, default = "text", value_enum)] + format: OutputFormat, + + /// A spec-declared executable view to explain + #[usage(long)] + view: Option, + + /// Environment to explain against, as KEY=VALUE, repeatable + /// + /// Given at all, these are the *whole* environment: an explanation pasted into a bug + /// report has to mean the same thing on the machine that reads it. Omitted, the process + /// environment is used, which is what an execution would see. + #[usage(short, long, var = true)] + env: Vec, + + /// The command line to explain, starting with the program name + /// + /// `usage`'s own flags come before it, and flag parsing ends at the program name, so + /// both `explain -f f.kdl mycli --env=prod` and `explain -f f.kdl -- mycli --env=prod` + /// work. Separate with `--` when the explained line carries its own: the first `--` is + /// still `usage`'s separator, so `explain -f f.kdl mycli a -- b` loses one. + #[usage(double_dash = "automatic", value_hint = usage_rs::ValueHint::CommandWithArguments)] + argv: Vec, +} + +impl usage_rs::Run for Explain { + type Output = Result<()>; + + fn run(self) -> Self::Output { + let spec = select_view(file_or_spec(&self.file, &self.spec)?, self.view.as_deref())?; + let env = self.env_map()?; + // A bare invocation is a legitimate question — it asks which defaults and + // environment values fire with no command line at all — so the program name is + // filled in rather than refused. + let argv = if self.argv.is_empty() { + vec![spec.bin.clone()] + } else { + self.argv.clone() + }; + + let explanation = explain(&spec, &argv, env); + match self.format { + OutputFormat::Text => print!("{}", explanation.render()), + OutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&explanation) + .map_err(|e| miette::miette!("failed to serialize the explanation: {e}"))? + ), + } + Ok(()) + } +} + +impl Explain { + fn env_map(&self) -> Result>> { + if self.env.is_empty() { + return Ok(None); + } + let mut env = HashMap::new(); + for entry in &self.env { + // An empty key is refused with the rest: no such variable can exist, so a + // report naming one would be describing an environment nothing could produce. + let (key, value) = entry + .split_once('=') + .filter(|(key, _)| !key.is_empty()) + .ok_or_else(|| miette::miette!("--env wants KEY=VALUE, got `{entry}`"))?; + env.insert(key.to_string(), value.to_string()); + } + Ok(Some(env)) + } +} + +/// The explanation as data rather than as printed lines. +/// +/// A seam for the same reason `lint_spec` is one: the layout is worth testing against a value +/// instead of against stdout. +pub fn explain(spec: &Spec, argv: &[String], env: Option>) -> Explanation { + // Never runs another program. A `mount` names its commands only when the command it + // names is run, and a report on two inputs has no business spawning whatever a spec file + // happens to say — least of all a spec file a bug report arrived with. Injected answers + // are what turns that from a policy into a fact: usage-lib resolves a command's mounts on + // the way into it, and given a map it looks the answer up instead of running anything. + // + // Empty answers first, so a line inside the command's own vocabulary is explained + // exactly. A spec declaring nothing, as the answer to every mount, second: it lets a line + // under a mounting command be explained on the declarations that *are* readable, rather + // than refusing the whole report over a subcommand nobody asked about. + let parser = |mounts: HashMap| { + let parser = Parser::new(spec).with_mount_outputs(mounts); + match env.clone() { + Some(env) => parser.with_env(env), + None => parser, + } + }; + let needs_a_mount = |err: &miette::Error| { + matches!( + err.downcast_ref::(), + Some(UsageErr::MissingMountOutput(_)) + ) + }; + + let mut refused = match parser(HashMap::new()).explain(argv) { + Ok(out) => return Explanation::from_parse(argv, &out, true, None), + Err(err) => err, + }; + if needs_a_mount(&refused) { + match parser(empty_mount_answers(&spec.cmd)).explain(argv) { + Ok(out) => return Explanation::from_parse(argv, &out, true, None), + Err(err) => refused = err, + } + } + + // The parse could not carry on: a word no declaration can take, a value refused by + // `choices`, a flag left waiting. Ask the binding phase what it managed on its own — it + // either finished and the failure came after it, in which case everything argv supplied + // is still there, or it is where the parse died and the tokens it read by then are what + // there is. Either way the fallbacks did not run, and the report says so. + let refused = Some(refused.to_string()); + match parser(empty_mount_answers(&spec.cmd)).explain_refused(argv) { + Ok(out) => Explanation::from_parse(argv, &out, false, refused), + Err(tokens) => Explanation { + argv: argv.to_vec(), + tokens: tokens.iter().map(TokenRow::from_binding).collect(), + refused, + fallbacks_applied: false, + ..Explanation::default() + }, + } +} + +#[derive(Debug, Default, serde::Serialize)] +pub struct Explanation { + /// The command line, as it was given. + pub argv: Vec, + /// The command path the parse resolved to. + pub command: Vec, + pub tokens: Vec, + pub values: Vec, + /// Declared defaults that did not win, and what won instead. + pub shadowed: Vec, + /// Flags a later occurrence removed, and the flag that removed them. + pub overridden: Vec, + pub errors: Vec, + /// The one failure the parse could not continue past, if there was one. + pub refused: Option, + /// Whether the environment-and-defaults phase ran. False when only the binding phase + /// could be reported. + pub fallbacks_applied: bool, +} + +#[derive(Debug, serde::Serialize)] +pub struct TokenRow { + pub index: usize, + pub text: String, + /// The parser read something else here — the basename of a multicall program, the tail + /// of a short bundle. `text` is what the caller wrote. + pub synthesized: bool, + pub roles: Vec, +} + +/// What one token did, named rather than pointing at a declaration. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RoleRow { + Program, + Subcommand { + name: String, + }, + Flag { + name: String, + spelling: String, + negated: bool, + }, + Value { + name: String, + values: Vec, + attached: bool, + }, + Arg { + name: String, + values: Vec, + }, + Separator, + Builtin { + spelling: String, + }, + ValueTerminator { + ends: String, + }, + Restart, + UnknownFlag { + bound_as: Option, + }, + Refused { + reason: String, + }, + External, + Unread, + /// A role this report is too old to name. See the conversion in [`RoleRow::from_role`]. + Unnamed, +} + +#[derive(Debug, serde::Serialize)] +pub struct ValueRow { + /// `flag` or `arg`. + pub kind: String, + pub name: String, + /// How the declaration is spelled in help: `--jobs`, ``. + pub display: String, + pub value: String, + /// Where the value came from. Several when one declaration took values from more than + /// one place, which a `var` flag can. + pub origins: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum OriginRow { + /// Typed, at these argv positions. + Argv { + tokens: Vec, + }, + DefaultMissing, + Env { + name: String, + }, + Default, + DefaultIf { + selector: String, + when: Option, + }, +} + +#[derive(Debug, serde::Serialize)] +pub struct ShadowRow { + pub kind: String, + pub name: String, + pub display: String, + /// The value the declaration would have supplied. + pub value: String, + pub lost_to: Vec, +} + +#[derive(Debug, serde::Serialize)] +pub struct OverrideRow { + pub name: String, + /// How the flag is spelled in help, as every other row spells one. + pub display: String, + pub by: String, + pub by_display: String, +} + +impl Explanation { + fn from_parse( + argv: &[String], + out: &ParseOutput, + fallbacks_applied: bool, + refused: Option, + ) -> Self { + let tokens = out.tokens.iter().map(TokenRow::from_binding).collect_vec(); + let mut values = vec![]; + let mut shadowed = vec![]; + + for (flag, value) in &out.flags { + let origins = flag_origins(out, flag); + values.push(ValueRow { + kind: "flag".to_string(), + name: flag.name.clone(), + display: flag_display(flag), + value: value.to_string(), + origins: origins.clone(), + }); + // A declared default that did not supply the value lost to whatever did. This is + // the most common thing a spec author is confused about, and the parser records + // exactly enough to answer it without guessing. + if let Some(declared) = declared_default(flag) { + if !origins.iter().any(|o| matches!(o, OriginRow::Default)) { + shadowed.push(ShadowRow { + kind: "flag".to_string(), + name: flag.name.clone(), + display: flag_display(flag), + value: declared, + lost_to: origins, + }); + } + } + } + for (arg, value) in &out.args { + let origins = arg_origins(out, arg); + values.push(ValueRow { + kind: "arg".to_string(), + name: arg.name.clone(), + display: arg.usage(), + value: value.to_string(), + origins: origins.clone(), + }); + if !arg.default.is_empty() && !origins.iter().any(|o| matches!(o, OriginRow::Default)) { + shadowed.push(ShadowRow { + kind: "arg".to_string(), + name: arg.name.clone(), + display: arg.usage(), + value: arg.default.join(" "), + lost_to: origins, + }); + } + } + + Self { + argv: argv.to_vec(), + command: out.cmds.iter().map(|cmd| cmd.name.clone()).collect(), + tokens, + values, + shadowed, + overridden: out + .overridden_flags + .iter() + .map(|(name, by)| OverrideRow { + name: name.clone(), + display: spelling_of(out, name), + by: by.clone(), + by_display: spelling_of(out, by), + }) + .collect(), + // Help and version are not failures: the invocation worked and the answer is a + // page of text. Listing that page under `errors` because usage-lib carries it in + // one is how a working command line reads as a broken one — the token says + // `built-in --help`, which is the fact worth reporting. + errors: out + .errors + .iter() + .filter(|e| !matches!(e, UsageErr::Help(_) | UsageErr::Version(_))) + .map(|e| e.to_string()) + .collect(), + refused, + fallbacks_applied, + } + } + + /// The text report, as `usage explain` prints it. + pub fn render(&self) -> String { + let mut out = String::new(); + out.push_str(&format!("{}\n", self.argv.join(" "))); + if !self.command.is_empty() { + out.push_str(&format!("command {}\n", self.command.join(" "))); + } + if !self.fallbacks_applied { + out.push_str( + "\nthe parse stopped before the environment and defaults were applied,\n\ + so only what argv bound is shown\n", + ); + } + + out.push_str(&render_table("tokens", &self.tokens, |token| { + let roles = if token.roles.is_empty() { + // Not the same as "not read": the word was read and did nothing this parser + // records, which is worth seeing rather than quietly omitting. + "bound nothing".to_string() + } else { + token.roles.iter().map(render_role).join(", ") + }; + vec![format!("[{}]", token.index), token.text.clone(), roles] + })); + + out.push_str(&render_table("values", &self.values, |row| { + vec![ + row.kind.clone(), + row.display.clone(), + row.value.clone(), + render_origins(&row.origins), + ] + })); + out.push_str(&render_table("shadowed", &self.shadowed, |row| { + vec![ + row.kind.clone(), + row.display.clone(), + format!("default {}", row.value), + format!("lost to {}", render_origins(&row.lost_to)), + ] + })); + out.push_str(&render_table("overridden", &self.overridden, |row| { + vec![row.display.clone(), format!("by {}", row.by_display)] + })); + + if !self.errors.is_empty() { + out.push_str("\nerrors\n"); + for error in &self.errors { + for line in error.lines() { + out.push_str(&format!(" {line}\n")); + } + } + } + if let Some(refused) = &self.refused { + out.push_str("\nrefused\n"); + for line in refused.lines() { + out.push_str(&format!(" {line}\n")); + } + } + out + } +} + +/// A titled block of aligned columns, or nothing at all when there are no rows. +/// +/// Every column but the last is padded to its widest cell, which is what makes the report +/// greppable: a reader scanning for "where did this come from" reads down one column. +fn render_table(title: &str, rows: &[T], columns: impl Fn(&T) -> Vec) -> String { + if rows.is_empty() { + return String::new(); + } + let rendered = rows.iter().map(columns).collect_vec(); + let widths = column_widths(&rendered); + let mut out = format!("\n{title}\n"); + for row in &rendered { + out.push_str(" "); + out.push_str(&pad_row(row, &widths)); + out.push('\n'); + } + out +} + +fn column_widths(rows: &[Vec]) -> Vec { + let count = rows.iter().map(Vec::len).max().unwrap_or(0); + (0..count) + .map(|column| { + rows.iter() + .filter_map(|row| row.get(column)) + .map(|cell| cell.chars().count()) + .max() + .unwrap_or(0) + }) + .collect() +} + +fn pad_row(row: &[String], widths: &[usize]) -> String { + row.iter() + .enumerate() + .map(|(column, cell)| { + // The last cell is never padded: trailing whitespace is invisible to a reader + // and annoying to everything else. + if column + 1 == row.len() { + cell.clone() + } else { + format!("{cell:width$}", width = widths[column]) + } + }) + .join(" ") + .trim_end() + .to_string() +} + +impl TokenRow { + fn from_binding(token: &usage::parse::TokenBinding) -> Self { + Self { + index: token.index, + text: token.word.clone(), + synthesized: token.synthesized, + roles: token.roles.iter().map(RoleRow::from_role).collect(), + } + } +} + +impl RoleRow { + fn from_role(role: &TokenRole) -> Self { + match role { + TokenRole::Program => Self::Program, + TokenRole::Command { name } => Self::Subcommand { name: name.clone() }, + TokenRole::Flag { + flag, + spelling, + negated, + } => Self::Flag { + name: flag.name.clone(), + spelling: spelling.clone(), + negated: *negated, + }, + TokenRole::Value { + flag, + values, + attached, + } => Self::Value { + name: flag.name.clone(), + values: values.clone(), + attached: *attached, + }, + TokenRole::Arg { arg, values } => Self::Arg { + name: arg.name.clone(), + values: values.clone(), + }, + TokenRole::Separator => Self::Separator, + TokenRole::ValueTerminator { ends } => Self::ValueTerminator { ends: ends.clone() }, + TokenRole::Restart => Self::Restart, + TokenRole::UnknownFlag { bound_as } => Self::UnknownFlag { + bound_as: bound_as.as_ref().map(|arg| arg.name.clone()), + }, + TokenRole::Refused { reason } => Self::Refused { + reason: reason.clone(), + }, + TokenRole::External => Self::External, + TokenRole::Unread => Self::Unread, + TokenRole::Builtin { spelling } => Self::Builtin { + spelling: spelling.clone(), + }, + // The lib's role list is `#[non_exhaustive]`, so a role added there has to land + // somewhere rather than fail to compile. Not `Unread`, which is what it used to + // be: that is a claim about the word — the parser never got to it — and saying it + // about a word the parser acted on is worse than admitting the report is behind. + _ => Self::Unnamed, + } + } +} + +fn render_role(role: &RoleRow) -> String { + match role { + RoleRow::Program => "program".to_string(), + RoleRow::Subcommand { name } => format!("subcommand {name}"), + RoleRow::Flag { + spelling, negated, .. + } => { + if *negated { + format!("flag {spelling} (negated)") + } else { + format!("flag {spelling}") + } + } + RoleRow::Value { + name, + values, + attached, + } => { + let attached = if *attached { ", attached" } else { "" }; + format!("value of {name} = {}{attached}", render_values(values)) + } + RoleRow::Arg { name, values } => format!("arg {name} = {}", render_values(values)), + RoleRow::Separator => "separator".to_string(), + RoleRow::Builtin { spelling } => format!("built-in {spelling}"), + RoleRow::ValueTerminator { ends } => format!("value terminator, ends {ends}"), + RoleRow::Restart => "restart, positional arguments start over".to_string(), + RoleRow::UnknownFlag { bound_as } => match bound_as { + // Under the default `unknown_flags="value"` an unmatched flag-like word is data. + // Saying which argument took it is the difference between "you have a typo" and + // "this argument took your typo". + Some(arg) => format!("unknown flag, bound as {arg}"), + None => "unknown flag".to_string(), + }, + RoleRow::Refused { reason } => format!("refused, {reason}"), + RoleRow::External => "forwarded to an external command".to_string(), + RoleRow::Unread => "not read".to_string(), + RoleRow::Unnamed => "bound, but this report cannot name how".to_string(), + } +} + +fn render_values(values: &[String]) -> String { + values.iter().map(|value| format!("{value:?}")).join(", ") +} + +fn render_origins(origins: &[OriginRow]) -> String { + if origins.is_empty() { + // Everything the parser filled has an origin; a bool flag that was simply named has + // no value to trace, and neither does a value from a source not yet modelled. + return "-".to_string(); + } + origins + .iter() + .map(|origin| match origin { + OriginRow::Argv { tokens } => { + format!("argv [{}]", tokens.iter().map(|i| i.to_string()).join(", ")) + } + OriginRow::DefaultMissing => "default_missing".to_string(), + OriginRow::Env { name } => format!("env {name}"), + OriginRow::Default => "default".to_string(), + OriginRow::DefaultIf { selector, when } => match when { + Some(when) => format!("default_if {selector} when={when:?}"), + None => format!("default_if {selector}"), + }, + }) + .join(", ") +} + +/// The argv positions that supplied a flag, then whatever the fallback phase recorded. +/// +/// The token trace is the record of what was typed, so it is scanned rather than duplicated +/// into a second map: a flag's own token counts when the flag holds no separate value, which +/// is how a bool or a counted flag gets an argv origin at all. +fn flag_origins(out: &ParseOutput, flag: &SpecFlag) -> Vec { + let mut origins = vec![]; + let mut tokens = vec![]; + for token in &out.tokens { + for role in &token.roles { + match role { + TokenRole::Value { flag: f, .. } if f.name == flag.name => tokens.push(token.index), + TokenRole::Flag { flag: f, .. } if f.name == flag.name && f.arg.is_none() => { + tokens.push(token.index) + } + _ => {} + } + } + } + tokens.dedup(); + if !tokens.is_empty() { + origins.push(OriginRow::Argv { tokens }); + } + origins.extend( + out.flag_origins + .iter() + .filter(|(f, _)| f.name == flag.name) + .flat_map(|(_, recorded)| recorded.iter().map(origin_row)), + ); + origins +} + +fn arg_origins(out: &ParseOutput, arg: &SpecArg) -> Vec { + let mut origins = vec![]; + let mut tokens = vec![]; + for token in &out.tokens { + for role in &token.roles { + match role { + TokenRole::Arg { arg: a, .. } if a.name == arg.name => tokens.push(token.index), + TokenRole::UnknownFlag { bound_as: Some(a) } if a.name == arg.name => { + tokens.push(token.index) + } + _ => {} + } + } + } + tokens.dedup(); + if !tokens.is_empty() { + origins.push(OriginRow::Argv { tokens }); + } + origins.extend( + out.arg_origins + .iter() + .filter(|(a, _)| a.name == arg.name) + .flat_map(|(_, recorded)| recorded.iter().map(origin_row)), + ); + origins +} + +fn origin_row(origin: &ValueOrigin) -> OriginRow { + match origin { + ValueOrigin::DefaultMissing => OriginRow::DefaultMissing, + ValueOrigin::Env(name) => OriginRow::Env { name: name.clone() }, + ValueOrigin::Default => OriginRow::Default, + ValueOrigin::DefaultIf { selector, when } => OriginRow::DefaultIf { + selector: selector.clone(), + when: when.clone(), + }, + // `ValueOrigin` is `#[non_exhaustive]`: a source added to the parser reads as an + // unnamed default here rather than stopping this from compiling. + _ => OriginRow::Default, + } +} + +/// The default a flag would supply, from wherever it is declared. +/// +/// Two places, and the parser prefers them in this order (`Parser::parse` binds `flag.default` +/// and only then `flag.arg.default`), so a report that read one of them called a shadowed +/// default no default at all — which is the question this table exists to answer. +fn declared_default(flag: &SpecFlag) -> Option { + if !flag.default.is_empty() { + return Some(flag.default.join(" ")); + } + flag.arg + .as_ref() + .filter(|arg| !arg.default.is_empty()) + .map(|arg| arg.default.join(" ")) +} + +/// How the flag of this name is spelled, for a table that has only the name to go on. +/// +/// `overridden_flags` is keyed by name because that is what the parser knows at the point it +/// records one. Falling back to `--{name}` instead would print `--v` for a short-only flag, +/// which is not a spelling anything answers to. +fn spelling_of(out: &ParseOutput, name: &str) -> String { + out.available_flags + .values() + .find(|flag| flag.name == name) + .map(|flag| flag_display(flag)) + .unwrap_or_else(|| name.to_string()) +} + +/// How a flag is spelled in a report: the long form if it has one, else the short. +/// +/// Not [`SpecFlag::usage`], which renders the whole declaration (`-j --jobs `) and is +/// right for help output and too wide for a column. +fn flag_display(flag: &SpecFlag) -> String { + if let Some(long) = flag.long.first() { + return format!("--{long}"); + } + if let Some(short) = flag.short.first() { + return format!("-{short}"); + } + flag.name.clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(words: &[&str]) -> Vec { + words.iter().map(|w| (*w).to_string()).collect() + } + + fn env(pairs: &[(&str, &str)]) -> Option> { + Some( + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + ) + } + + fn fixture() -> Spec { + std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/explain.usage.kdl" + )) + .unwrap() + .parse() + .unwrap() + } + + fn roles(explanation: &Explanation, index: usize) -> Vec { + explanation + .tokens + .iter() + .find(|token| token.index == index) + .unwrap_or_else(|| panic!("no token at {index}")) + .roles + .iter() + .map(render_role) + .collect() + } + + fn value(explanation: &Explanation, name: &str) -> String { + let row = explanation + .values + .iter() + .find(|row| row.name == name) + .unwrap_or_else(|| panic!("no value for {name}")); + format!("{} {}", row.value, render_origins(&row.origins)) + } + + #[test] + fn the_attached_form_binds_and_the_report_says_where() { + let spec = fixture(); + let explanation = explain( + &spec, + &argv(&["mycli", "--env=prod", "build", "a"]), + env(&[]), + ); + + // jdx/mise discussion #8883: mise's hand-written scanner ignored `--env=production` + // while `--env production` worked, and nothing would show the difference. + assert_eq!( + roles(&explanation, 1), + ["flag --env", "value of env = \"prod\", attached"] + ); + assert_eq!(value(&explanation, "env"), "prod argv [1]"); + } + + #[test] + fn a_short_bundle_reads_as_one_token() { + let spec = fixture(); + let explanation = explain(&spec, &argv(&["mycli", "-sj8", "build", "a"]), env(&[])); + + assert_eq!( + roles(&explanation, 1), + ["flag -s", "flag -j", "value of jobs = \"8\", attached"] + ); + // Not synthesized: `-sj8` is the word the caller wrote, and its tails are + // continuations of it. That flag is for a position where the parser read something + // else entirely, which is the multicall case. + assert!(!explanation.tokens[1].synthesized); + assert_eq!(explanation.tokens.len(), 4); + } + + #[test] + fn a_multicall_applet_name_is_marked_read_rather_than_written() { + let spec: Spec = "name \"mycli\"\nbin \"mycli\"\nmulticall #true\ncmd \"build\"\n" + .parse() + .unwrap(); + + // Invoked through a symlink named `build`, argv[0] is both the program and the word + // that selected the subcommand — and the caller wrote neither of those roles as a + // word of its own. + let explanation = explain(&spec, &argv(&["build"]), env(&[])); + + assert_eq!(roles(&explanation, 0), ["program", "subcommand build"]); + assert!(explanation.tokens[0].synthesized); + } + + #[test] + fn a_parse_the_binding_phase_gave_up_on_still_reports_its_tokens() { + let spec: Spec = "name \"mycli\"\nbin \"mycli\"\nflag \"--env \"\n" + .parse() + .unwrap(); + + // Nothing declared can take `boom`. The words read before it still bound, and a + // report that showed only "unexpected word" would be the message the caller already + // had — so the tokens survive the failure, the refused word says why, and what was + // still queued behind it says it was never read. + let explanation = explain( + &spec, + &argv(&["mycli", "--env=prod", "boom", "later"]), + env(&[]), + ); + + assert_eq!( + roles(&explanation, 1), + ["flag --env", "value of env = \"prod\", attached"] + ); + assert_eq!( + roles(&explanation, 2), + ["refused, no declaration takes this word"] + ); + assert_eq!(roles(&explanation, 3), ["not read"]); + assert!(!explanation.fallbacks_applied); + assert!(explanation.refused.is_some()); + } + + #[test] + fn a_help_request_is_a_role_rather_than_an_error() { + let spec: Spec = "name \"mycli\"\nbin \"mycli\"\nflag \"--jobs \"\n" + .parse() + .unwrap(); + + let explanation = explain(&spec, &argv(&["mycli", "--help"]), env(&[])); + + // usage-lib carries the help page in an error because that is how it stops the + // parse. It is not a failure of the command line, and printing a whole help page + // under `errors` says it was. + assert_eq!(roles(&explanation, 1), ["built-in --help"]); + assert!(explanation.errors.is_empty(), "{:?}", explanation.errors); + } + + #[test] + fn an_override_row_spells_the_flag_the_way_every_other_row_does() { + let spec: Spec = r#" +name "mycli" +bin "mycli" +flag "-q" help="quiet" default="true" +flag "-l" help="loud" overrides="-q" + "# + .parse() + .unwrap(); + + let explanation = explain(&spec, &argv(&["mycli", "-l"]), env(&[])); + + // `--q` is not a spelling anything answers to. The row names the flag the way the + // caller would type it, as the values and shadowed tables already do. + let row = &explanation.overridden[0]; + assert_eq!(row.display, "-q"); + assert_eq!(row.by_display, "-l"); + } + + #[test] + fn a_mount_is_never_run_to_answer_a_report() { + let spec: Spec = r#" +name "mycli" +bin "mycli" +flag "--jobs " default="1" +mount run="false --usage" + "# + .parse() + .unwrap(); + + // `false --usage` exits non-zero, so running it would end the report with the + // mount's failure rather than an explanation — and running whatever a spec file + // names is the thing this must not do at all. The line is explained on the + // declarations that are readable without it. + let explanation = explain(&spec, &argv(&["mycli", "--jobs", "8"]), env(&[])); + assert_eq!(value(&explanation, "jobs"), "8 argv [2]"); + + // A word nothing declares is what sends the parser looking for a mount, so this is + // the invocation that would have spawned. It comes back as a refusal about the word. + let discovery = explain(&spec, &argv(&["mycli", "tasks"]), env(&[])); + assert!( + discovery + .refused + .as_deref() + .is_some_and(|r| r.contains("tasks")), + "{:?}", + discovery.refused + ); + } + + #[test] + fn a_separator_is_not_a_value_and_what_follows_it_is() { + let spec = fixture(); + let explanation = explain( + &spec, + &argv(&["mycli", "build", "a", "--", "--raw"]), + env(&[]), + ); + + assert_eq!(roles(&explanation, 3), ["separator"]); + assert_eq!(roles(&explanation, 4), ["arg extra = \"--raw\""]); + } + + #[test] + fn an_env_value_names_the_variable() { + let spec = fixture(); + let explanation = explain( + &spec, + &argv(&["mycli", "build", "a"]), + env(&[("MYCLI_COLOR", "never")]), + ); + + assert_eq!(value(&explanation, "color"), "never env MYCLI_COLOR"); + } + + #[test] + fn a_shadowed_default_says_what_beat_it() { + let spec = fixture(); + let explanation = explain( + &spec, + &argv(&["mycli", "-j", "8", "build", "a"]), + env(&[("MYCLI_COLOR", "never")]), + ); + + let shadowed = explanation + .shadowed + .iter() + .map(|row| { + format!( + "{} {} {}", + row.display, + row.value, + render_origins(&row.lost_to) + ) + }) + .collect::>(); + assert!( + shadowed.contains(&"--jobs 1 argv [2]".to_string()), + "{shadowed:?}" + ); + assert!( + shadowed.contains(&"--color auto env MYCLI_COLOR".to_string()), + "{shadowed:?}" + ); + } + + #[test] + fn a_default_if_says_which_condition_fired() { + let spec = fixture(); + let explanation = explain( + &spec, + &argv(&["mycli", "--profile", "prod", "build", "a"]), + env(&[]), + ); + + assert_eq!( + value(&explanation, "strict"), + "true default_if --profile when=\"prod\"" + ); + } + + #[test] + fn an_unknown_flag_says_what_took_it() { + let spec = fixture(); + let explanation = explain(&spec, &argv(&["mycli", "build", "--wat"]), env(&[])); + + // Lax unknown flags are the default, so the word became data. Which is the useful + // thing to be told: the other reading is "you have a typo". + assert_eq!(roles(&explanation, 2), ["unknown flag, bound as target"]); + } + + #[test] + fn a_word_offered_to_an_argument_that_refused_it_says_so() { + let spec = fixture(); + let explanation = explain(&spec, &argv(&["mycli", "build", "a", "b"]), env(&[])); + + // `extra` only accepts words after `--`, so `b` was dropped. Reporting the token as + // having done nothing would hide the one thing that happened to it. + assert_eq!( + roles(&explanation, 3), + ["refused, extra only accepts words after `--`"] + ); + } + + #[test] + fn a_command_line_that_fails_still_gets_a_report() { + let spec = fixture(); + let explanation = explain(&spec, &argv(&["mycli", "--env=prod", "build"]), env(&[])); + + // The bindings that worked, then the complaint — rather than the complaint alone, + // which is the report the caller already had. + assert_eq!(value(&explanation, "env"), "prod argv [1]"); + assert!( + explanation.errors.iter().any(|e| e.contains("target")), + "{:?}", + explanation.errors + ); + assert!(explanation.refused.is_none()); + assert!(explanation.fallbacks_applied); + } + + #[test] + fn a_bare_invocation_explains_the_fallbacks_alone() { + let spec = fixture(); + let explanation = explain(&spec, &argv(&["mycli"]), env(&[("MYCLI_COLOR", "never")])); + + assert_eq!(roles(&explanation, 0), ["program"]); + assert_eq!(value(&explanation, "color"), "never env MYCLI_COLOR"); + assert_eq!(value(&explanation, "jobs"), "1 default"); + } + + #[test] + fn the_json_shape_carries_the_same_facts() { + let spec = fixture(); + let explanation = explain( + &spec, + &argv(&["mycli", "--env=prod", "build", "a"]), + env(&[]), + ); + let json = serde_json::to_value(&explanation).unwrap(); + + assert_eq!(json["tokens"][1]["roles"][0]["kind"], "flag"); + assert_eq!(json["tokens"][1]["roles"][1]["kind"], "value"); + assert_eq!(json["tokens"][1]["roles"][1]["attached"], true); + let env_row = json["values"] + .as_array() + .unwrap() + .iter() + .find(|row| row["name"] == "env") + .unwrap() + .clone(); + assert_eq!(env_row["origins"][0]["kind"], "argv"); + assert_eq!(env_row["origins"][0]["tokens"][0], 1); + } +} diff --git a/cli/src/cli/lint.rs b/cli/src/cli/lint.rs index d7aa62892..4992461a4 100644 --- a/cli/src/cli/lint.rs +++ b/cli/src/cli/lint.rs @@ -6,6 +6,7 @@ use usage::spec::cmd::SpecExample; use usage::{Parser, Spec, SpecArg, SpecCommand, SpecFlag, SpecFlagAction}; use crate::cli::generate::parse_file_or_stdin; +use crate::cli::{empty_mount_answers, OutputFormat}; /// Lint a usage spec file for common issues #[derive(usage_rs::Args)] @@ -38,25 +39,6 @@ pub struct LintOptions { pub sorted: bool, } -#[derive(Clone, Copy, Default, usage_rs::ValueEnum)] -enum OutputFormat { - #[default] - Text, - Json, -} - -impl std::str::FromStr for OutputFormat { - type Err = String; - - fn from_str(value: &str) -> Result { - match value { - "text" => Ok(Self::Text), - "json" => Ok(Self::Json), - _ => Err(format!("`{value}` is not one of: text, json")), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] pub enum Severity { @@ -750,28 +732,6 @@ fn parse_example(spec: &Spec, words: &[String]) -> Result<(), Unparsed> { } } -/// A spec that declares nothing, as the answer to every mount in the tree. -/// -/// Keyed by the exact `run` string, which is how injected answers are looked up. It is a -/// whole spec rather than an empty string because that is what a mount's stdout is. -fn empty_mount_answers(cmd: &SpecCommand) -> HashMap { - let mut answers = HashMap::new(); - collect_mount_answers(cmd, &mut answers); - answers -} - -fn collect_mount_answers(cmd: &SpecCommand, answers: &mut HashMap) { - for mount in &cmd.mounts { - answers.insert( - mount.run.clone(), - "name \"mounted\"\nbin \"mounted\"\n".to_string(), - ); - } - for sub in cmd.subcommands.values() { - collect_mount_answers(sub, answers); - } -} - /// Whether an invocation asks for help or a version rather than doing anything. /// /// The spellings are the ones the parser answers to: those of any declared flag whose diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index 272b0e1f9..6a519410f 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::ffi::OsStr; use miette::Result; @@ -5,6 +6,7 @@ use usage_rs::{Cli as DeriveCli, Subcommands}; pub mod complete_word; mod exec; +mod explain; pub(crate) mod generate; mod lint; mod mcp; @@ -101,6 +103,7 @@ enum Command { Bash(shell::Bash), CompleteWord(complete_word::CompleteWord), Exec(exec::Exec), + Explain(explain::Explain), Fish(shell::Fish), Generate(generate::Generate), Lint(lint::Lint), @@ -155,3 +158,57 @@ impl Cli { usage_rs::Run::run(cli.command) } } + +/// A spec that declares nothing, as the answer to every mount in the tree. +/// +/// Keyed by the exact `run` string, which is how injected answers are looked up. It is a +/// whole spec rather than an empty string because that is what a mount's stdout is. +/// +/// Shared by `lint` and `explain`, which want it for the same reason: usage-lib resolves a +/// command's mounts on the way *into* it, so a spec that mounts anything cannot be parsed +/// without either spawning the mounted program or being handed its answer — and a command +/// that reads a file and prints a report should not spawn whatever that file names. +pub(crate) fn empty_mount_answers(cmd: &usage::SpecCommand) -> HashMap { + let mut answers = HashMap::new(); + collect_mount_answers(cmd, &mut answers); + answers +} + +fn collect_mount_answers(cmd: &usage::SpecCommand, answers: &mut HashMap) { + for mount in &cmd.mounts { + answers.insert( + mount.run.clone(), + "name \"mounted\"\nbin \"mounted\"\n".to_string(), + ); + } + for sub in cmd.subcommands.values() { + collect_mount_answers(sub, answers); + } +} + +/// How a command that can print either prose or JSON was asked to print. +/// +/// Shared rather than declared twice: `lint` and `explain` both offer it, and a third +/// copy of the same four-line `FromStr` is how the two spellings drift apart. +#[derive(Debug, Clone, Copy, Default, usage_rs::ValueEnum)] +pub(crate) enum OutputFormat { + #[default] + Text, + Json, +} + +impl std::str::FromStr for OutputFormat { + type Err = String; + + fn from_str(value: &str) -> Result { + // Delegated rather than matched again: the derive already lists the words, and a + // second list beside it is one more thing that can fall out of step with the type. + use usage_rs::spec::ValueEnum; + Self::from_choice(value).ok_or_else(|| { + format!( + "`{value}` is not one of: {}", + Self::ACCEPTED_CHOICES.join(", ") + ) + }) + } +} diff --git a/cli/tests/explain.rs b/cli/tests/explain.rs new file mode 100644 index 000000000..2f7dda977 --- /dev/null +++ b/cli/tests/explain.rs @@ -0,0 +1,280 @@ +use assert_cmd::Command; +use predicates::str::contains; + +fn usage_cmd() -> Command { + Command::new(assert_cmd::cargo::cargo_bin!("usage")) +} + +fn example_path(name: &str) -> String { + format!("{}/../examples/{}", env!("CARGO_MANIFEST_DIR"), name) +} + +/// Stdout, once the run is known to have succeeded. +/// +/// Asserted here rather than at each call site: a failed run has empty stdout, and a test +/// comparing it reports a confusing string difference instead of the real failure. +fn stdout_of(cmd: &mut Command) -> String { + let output = cmd.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stdout).unwrap() +} + +/// The fixture, the whole environment, and whatever argv the test is about. +/// +/// The environment goes through `--env` rather than through the child process: `Parser` +/// reads the real environment when it is given no map, so a test that set `MYCLI_COLOR` on +/// the child would still be at the mercy of whatever else the machine exports. +fn explain(argv: &[&str]) -> String { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["-e", "MYCLI_COLOR=never", "-e", "MYCLI_PROFILE=prod"]); + cmd.arg("--"); + cmd.args(argv); + stdout_of(&mut cmd) +} + +#[test] +fn explains_the_worked_example() { + insta::assert_snapshot!(explain(&[ + "mycli", + "-j8", + "--env=prod", + "build", + "a", + "b", + "--", + "--raw" + ])); +} + +/// The example on the grammar page, so the page cannot drift from the tool. +/// +/// `docs/spec/argv.md` claims this output; a documented example nothing checks is doc rot +/// with a delay on it. +#[test] +fn explains_the_documented_example() { + insta::assert_snapshot!(explain(&[ + "mycli", + "-j8", + "--env=prod", + "build", + "a", + "--", + "--raw" + ])); +} + +#[test] +fn a_default_on_a_flags_argument_is_shadowed_like_any_other() { + let mut cmd = usage_cmd(); + cmd.args([ + "explain", + "-s", + "name \"ex\"\nbin \"ex\"\nflag \"--jobs \" {\n arg \"\" default=\"1\"\n}\n", + "--", + "ex", + "--jobs", + "8", + ]); + + // A flag can declare its default on itself or on its argument, and the parser prefers + // them in that order. Reading only the first called a shadowed default no default at all. + cmd.assert() + .success() + .stdout(contains("--jobs default 1 lost to argv [2]")); +} + +#[test] +fn env_wants_a_key() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["-e", "=never", "--", "mycli"]); + + // No variable can be named "", so accepting it would describe an environment nothing + // could produce. + cmd.assert().failure().stderr(contains("KEY=VALUE")); +} + +#[test] +fn env_wants_a_separator() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["-e", "MYCLI_COLOR", "--", "mycli"]); + + // The other half of the same contract as `env_wants_a_key`: a word with no `=` names no + // value, and guessing one would put a variable in the report the caller never set. + cmd.assert().failure().stderr(contains("KEY=VALUE")); +} + +#[test] +fn binds_an_attached_long_flag() { + // jdx/mise discussion #8883: a hand-written scanner ignored `--env=production` while + // `--env production` worked. Both forms bind here, and the report says which token did. + let attached = explain(&["mycli", "--env=production", "build", "a"]); + let detached = explain(&["mycli", "--env", "production", "build", "a"]); + + assert!( + attached.contains("value of env = \"production\", attached"), + "{attached}" + ); + assert!( + detached.contains("value of env = \"production\""), + "{detached}" + ); + for report in [&attached, &detached] { + assert!(report.contains("--env"), "{report}"); + assert!(report.contains("production"), "{report}"); + } +} + +#[test] +fn the_separator_is_optional_before_the_explained_line() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["-e", "MYCLI_COLOR=never", "-e", "MYCLI_PROFILE=prod"]); + // No `--`: `double_dash="automatic"` ends this command's own flag parsing at the + // program name, so a foreign `--env=prod` is data rather than a flag `usage` rejects. + cmd.args(["mycli", "-j8", "--env=prod", "build", "a"]); + let without = stdout_of(&mut cmd); + + assert_eq!( + without, + explain(&["mycli", "-j8", "--env=prod", "build", "a"]) + ); +} + +#[test] +fn a_line_of_its_own_needs_the_separator_to_keep_a_double_dash() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["mycli", "build", "a", "--", "--raw"]); + let without = stdout_of(&mut cmd); + + // `usage`'s own parse takes the first `--` as its separator — `automatic` ends flag + // parsing but does not stop a later separator being honoured, which is what + // a78564c0 settled. So an explained line carrying its own `--` needs the leading one, + // and the report shows the difference rather than hiding it. + let with = explain(&["mycli", "build", "a", "--", "--raw"]); + assert!(without.starts_with("mycli build a --raw\n"), "{without}"); + assert!(with.starts_with("mycli build a -- --raw\n"), "{with}"); + assert!(with.contains("-- separator"), "{with}"); +} + +#[test] +fn a_second_separator_stays_data() { + let report = explain(&["mycli", "build", "a", "--", "--raw", "--", "more"]); + + // The first `--` separates; every later one is data, which is what every parser worth + // comparing against does and what jdx/usage#229 was about. + assert!(report.contains("[3] -- separator"), "{report}"); + assert!( + report.contains("[5] -- arg extra = \"--\""), + "{report}" + ); +} + +#[test] +fn reports_a_command_line_that_does_not_parse_and_exits_zero() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["--", "mycli", "--env=prod", "build"]); + + // Exit 0: the report succeeded, and this is the case a report is wanted for. Anything + // else would make the tool useless under `set -e`. + cmd.assert() + .success() + .stdout(contains("value of env = \"prod\", attached")) + .stdout(contains("errors")) + .stdout(contains("target")); +} + +#[test] +fn reports_a_parse_that_could_not_continue() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["--", "mycli", "-j"]); + + cmd.assert() + .success() + .stdout(contains( + "the parse stopped before the environment and defaults", + )) + .stdout(contains("flag -j")) + .stdout(contains("requires an argument")); +} + +#[test] +fn json_carries_the_same_facts() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args([ + "--format", + "json", + "--", + "mycli", + "--env=prod", + "build", + "a", + ]); + let stdout = stdout_of(&mut cmd); + + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["command"], serde_json::json!(["mycli", "build"])); + assert_eq!(json["tokens"][1]["roles"][1]["kind"], "value"); + assert_eq!(json["tokens"][1]["roles"][1]["attached"], true); + assert_eq!(json["fallbacks_applied"], true); +} + +#[test] +fn reads_a_spec_from_stdin() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", "-", "--", "mycli", "--jobs", "8"]); + cmd.write_stdin("name \"mycli\"\nbin \"mycli\"\nflag \"--jobs \"\n"); + + cmd.assert().success().stdout(contains("value of jobs")); +} + +#[test] +fn reads_a_spec_from_an_argument() { + let mut cmd = usage_cmd(); + cmd.args([ + "explain", + "-s", + "name \"mycli\"\nbin \"mycli\"\nflag \"--jobs \" default=\"1\"\n", + "--", + "mycli", + ]); + + cmd.assert() + .success() + .stdout(contains("--jobs 1 default")); +} + +#[test] +fn a_bare_invocation_explains_the_fallbacks_alone() { + let mut cmd = usage_cmd(); + cmd.args(["explain", "-f", &example_path("explain.usage.kdl")]); + cmd.args(["-e", "MYCLI_COLOR=never"]); + + // No argv at all is a real question: which defaults and environment values fire when + // nothing is typed. + cmd.assert() + .success() + .stdout(contains("--color never env MYCLI_COLOR")) + .stdout(contains("--jobs 1 default")); +} + +#[test] +fn its_own_unknown_flags_are_still_refused() { + let mut cmd = usage_cmd(); + cmd.args([ + "explain", + "-f", + &example_path("explain.usage.kdl"), + "--nope", + ]); + + // Deliberately unlike `exec`: a typo in `usage explain`'s own flags is a mistake, not + // data, because the explained line starts at its program name. + cmd.assert().failure(); +} diff --git a/cli/tests/snapshots/explain__explains_the_documented_example.snap b/cli/tests/snapshots/explain__explains_the_documented_example.snap new file mode 100644 index 000000000..460a38315 --- /dev/null +++ b/cli/tests/snapshots/explain__explains_the_documented_example.snap @@ -0,0 +1,28 @@ +--- +source: cli/tests/explain.rs +expression: "explain(&[\"mycli\", \"-j8\", \"--env=prod\", \"build\", \"a\", \"--\", \"--raw\"])" +--- +mycli -j8 --env=prod build a -- --raw +command mycli build + +tokens + [0] mycli program + [1] -j8 flag -j, value of jobs = "8", attached + [2] --env=prod flag --env, value of env = "prod", attached + [3] build subcommand build + [4] a arg target = "a" + [5] -- separator + [6] --raw arg extra = "--raw" + +values + flag --jobs 8 argv [1] + flag --env prod argv [2] + flag --color never env MYCLI_COLOR + flag --profile prod env MYCLI_PROFILE + flag --strict true default_if --profile when="prod" + arg a argv [4] + arg [-- extra]… --raw argv [6] + +shadowed + flag --jobs default 1 lost to argv [1] + flag --color default auto lost to env MYCLI_COLOR diff --git a/cli/tests/snapshots/explain__explains_the_worked_example.snap b/cli/tests/snapshots/explain__explains_the_worked_example.snap new file mode 100644 index 000000000..434540ba6 --- /dev/null +++ b/cli/tests/snapshots/explain__explains_the_worked_example.snap @@ -0,0 +1,32 @@ +--- +source: cli/tests/explain.rs +expression: "explain(&[\"mycli\", \"-j8\", \"--env=prod\", \"build\", \"a\", \"b\", \"--\", \"--raw\"])" +--- +mycli -j8 --env=prod build a b -- --raw +command mycli build + +tokens + [0] mycli program + [1] -j8 flag -j, value of jobs = "8", attached + [2] --env=prod flag --env, value of env = "prod", attached + [3] build subcommand build + [4] a arg target = "a" + [5] b refused, extra only accepts words after `--` + [6] -- separator + [7] --raw arg extra = "--raw" + +values + flag --jobs 8 argv [1] + flag --env prod argv [2] + flag --color never env MYCLI_COLOR + flag --profile prod env MYCLI_PROFILE + flag --strict true default_if --profile when="prod" + arg a argv [4] + arg [-- extra]… --raw argv [7] + +shadowed + flag --jobs default 1 lost to argv [1] + flag --color default auto lost to env MYCLI_COLOR + +errors + Argument can only be set after a `--` separator diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 8522bcbb0..438bd0c68 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -49,6 +49,35 @@ cmd exec help="Execute a script, parsing args and exposing them as environment v arg help="path to script to execute" arg "[ARGS]..." help="arguments to pass to script" } +cmd explain help="Explain what a command line binds to" effect=read { + long_help "Explain what a command line binds to\n\nPrints a row per argv token saying what it became, then the values that came from\nsomewhere other than argv, then anything that went wrong. Exits 0 even when the explained\ncommand line does not parse: the report succeeded, and that is the case worth a report." + flag "-f --file" help="A usage spec file or script with a usage shebang, use \"-\" to read from stdin" { + arg + } + flag "-s --spec" help="Raw string spec input" overrides=--file required_unless=--file { + arg + } + flag --format help="Output format" default=text { + arg { + choices { + choice text + choice json + } + } + } + flag --view help="A spec-declared executable view to explain" { + arg + } + flag "-e --env" help="Environment to explain against, as KEY=VALUE, repeatable" var=#true { + long_help "Environment to explain against, as KEY=VALUE, repeatable\n\nGiven at all, these are the *whole* environment: an explanation pasted into a bug\nreport has to mean the same thing on the machine that reads it. Omitted, the process\nenvironment is used, which is what an execution would see." + arg + } + arg "[ARGV]..." help="The command line to explain, starting with the program name" double_dash=automatic { + long_help "The command line to explain, starting with the program name\n\n`usage`'s own flags come before it, and flag parsing ends at the program name, so\nboth `explain -f f.kdl mycli --env=prod` and `explain -f f.kdl -- mycli --env=prod`\nwork. Separate with `--` when the explained line carries its own: the first `--` is\nstill `usage`'s separator, so `explain -f f.kdl mycli a -- b` loses one." + } + complete argv type=command_args + complete file type=path +} cmd fish help="Execute a shell script using fish" unknown_flags=value { long_help "Execute a shell script with the specified shell\n\nTypically, this will be called by a script's shebang.\n\nIf using `var=#true` on args/flags, they will be joined with spaces using `shell_words::join()`\nto properly escape and quote values with spaces in them." flag -h help="Show help" diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index f937aa8fd..cfb2453d2 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -230,6 +230,148 @@ "hidden_aliases": [], "examples": [] }, + "explain": { + "full_cmd": ["explain"], + "usage": "explain [FLAGS] [ARGV]…", + "subcommands": {}, + "args": [ + { + "name": "ARGV", + "usage": "[ARGV]…", + "help": "The command line to explain, starting with the program name", + "help_long": "The command line to explain, starting with the program name\n\n`usage`'s own flags come before it, and flag parsing ends at the program name, so\nboth `explain -f f.kdl mycli --env=prod` and `explain -f f.kdl -- mycli --env=prod`\nwork. Separate with `--` when the explained line carries its own: the first `--` is\nstill `usage`'s separator, so `explain -f f.kdl mycli a -- b` loses one.", + "help_first_line": "The command line to explain, starting with the program name", + "required": false, + "double_dash": "Automatic", + "var": true, + "hide": false + } + ], + "flags": [ + { + "name": "file", + "usage": "-f --file ", + "help": "A usage spec file or script with a usage shebang, use \"-\" to read from stdin", + "help_first_line": "A usage spec file or script with a usage shebang, use \"-\" to read from stdin", + "short": ["f"], + "long": ["file"], + "hide": false, + "global": false, + "arg": { + "name": "FILE", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "spec", + "usage": "-s --spec ", + "help": "Raw string spec input", + "help_first_line": "Raw string spec input", + "short": ["s"], + "long": ["spec"], + "required_unless": ["--file"], + "hide": false, + "global": false, + "arg": { + "name": "SPEC", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + }, + "overrides": ["--file"] + }, + { + "name": "format", + "usage": "--format ", + "help": "Output format", + "help_first_line": "Output format", + "short": [], + "long": ["format"], + "hide": false, + "global": false, + "arg": { + "name": "FORMAT", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false, + "choices": { + "choices": ["text", "json"], + "details": [ + { + "value": "text" + }, + { + "value": "json" + } + ] + } + }, + "default": ["text"] + }, + { + "name": "view", + "usage": "--view ", + "help": "A spec-declared executable view to explain", + "help_first_line": "A spec-declared executable view to explain", + "short": [], + "long": ["view"], + "hide": false, + "global": false, + "arg": { + "name": "VIEW", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "env", + "usage": "-e --env… ", + "help": "Environment to explain against, as KEY=VALUE, repeatable", + "help_long": "Environment to explain against, as KEY=VALUE, repeatable\n\nGiven at all, these are the *whole* environment: an explanation pasted into a bug\nreport has to mean the same thing on the machine that reads it. Omitted, the process\nenvironment is used, which is what an execution would see.", + "help_first_line": "Environment to explain against, as KEY=VALUE, repeatable", + "short": ["e"], + "long": ["env"], + "var": true, + "hide": false, + "global": false, + "arg": { + "name": "ENV", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + } + ], + "mounts": [], + "effect": "read", + "unknown_flags": null, + "hide": false, + "args_override_self": true, + "help": "Explain what a command line binds to", + "help_long": "Explain what a command line binds to\n\nPrints a row per argv token saying what it became, then the values that came from\nsomewhere other than argv, then anything that went wrong. Exits 0 even when the explained\ncommand line does not parse: the report succeeded, and that is the case worth a report.", + "name": "explain", + "aliases": [], + "hidden_aliases": [], + "examples": [], + "complete": { + "argv": { + "name": "argv", + "type_": "command_args" + }, + "file": { + "name": "file", + "type_": "path" + } + } + }, "fish": { "full_cmd": ["fish"], "usage": "fish [-h] [--help]