From 439a28a95387e7ca22a3ed9d87b4ce359c3b5352 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:16:58 +0000 Subject: [PATCH 01/19] feat(spec): declare verbosity and colour roles on flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CLI in the fleet declares how loud it is and whether it colours its output, and none of them could say so in a spec: mise turns six flags into a level in a forty-nine-line function, hk has the same shape with three, aube spells quiet as a value of `--loglevel`, fnox has a lone `--no-color`. Help, documentation, an agent reading the spec and the CLI's own logger each had to guess from a spelling. Two closed vocabularies on `flag`, written on the flags a CLI already has: flag "-v --verbose" count=#true verbosity=verbose flag "-q --quiet" verbosity=error flag --log-level verbosity=level { arg { choices … } } flag "--color " color=choice flag --no-color color=never `verbosity=` takes `verbose`/`quiet` for a switch that moves along the scale, `level` for a flag whose value names one, and the six points on the scale — `silent < error < warn < info < debug < trace`, baseline `info` — for a switch that pins one. `color=` takes `always`/`never` on a switch or `choice` on a value. Roles add no relationship and change no parsing, so mise's override lattice keeps working exactly as written, and they are opt-in per flag, so hk's `--trace` — spans, not a level — stays what it is. Cold, beside `effect`: the hot `Flag` and `Flag::BOOL` are untouched and the role stays out of `binding_hash`, since two declarations differing only in role bind identically. The colour half is a bug fix. `argv/src/help.rs` and `argv/src/diagnostic.rs` each decided colour from the environment and neither could be overridden, so a CLI's own `--no-color` turned off its output and not the help page usage rendered for it. Both now answer from one `ColorChoice`, taken from argv by a real parse — `--message --no-color` is a value, and a token after `--` is somebody's argument — and an explicit choice outranks `NO_COLOR` and `CLICOLOR_FORCE`, which were set once for every program. usage-cli dogfoods it: `--verbose`, `-q`, hidden `--debug`/`--trace` carrying `USAGE_DEBUG`/`USAGE_TRACE` as `env` rather than rewriting `USAGE_LOG` behind the user's back, `--log-level`, and `--color`, with `env_logger` started from the resolved level. Two findings came out of that: `-v` cannot be taken, since `crate::run` answers it with the version before a parse happens; and these flags must not be `global` on a CLI that forwards argv, because `usage bash script.sh --debug` hands `--debug` to the script precisely because usage does not know it. The completion suite caught the second as a real regression. Not carried into Go, following `effect`, the other cold semantic property, which never crossed either: Go's `encoding/json` ignores the unknown key and a generated Go front door has no logger to configure. Recorded in PLAN.md as a known gap. Co-Authored-By: Claude Opus 5 --- PLAN.md | 38 ++ argv/src/diagnostic.rs | 42 +- argv/src/help.rs | 40 +- argv/src/lib.rs | 18 +- argv/src/policy.rs | 596 ++++++++++++++++++ argv/src/spec.rs | 23 + cli/assets/fig.ts | 19 + cli/assets/usage.1 | 27 + cli/src/cli/mod.rs | 111 +++- cli/src/main.rs | 20 +- cli/usage.usage.kdl | 25 +- conformance/src/tables.rs | 26 + ...roundtrip__the_emitted_spec_is_stable.snap | 4 +- conformance/tests/spec_roundtrip.rs | 25 + conformance/tests/verbosity.rs | 419 ++++++++++++ derive/src/codegen.rs | 249 +++++++- derive/src/model.rs | 371 ++++++++++- docs/cli/reference/commands.json | 100 ++- docs/cli/reference/index.md | 30 +- docs/rust/args-and-flags.md | 53 ++ docs/rust/clap-compatibility.md | 13 +- docs/rust/help.md | 18 + docs/spec/reference/flag.md | 73 +++ .../markdown/templates/flag_template.md.tera | 8 + lib/src/docs/models.rs | 6 + lib/src/lib.rs | 1 + lib/src/parse.rs | 50 ++ lib/src/spec/builder.rs | 13 + lib/src/spec/flag.rs | 257 ++++++++ lib/src/spec/mod.rs | 1 + lib/src/spec/policy.rs | 515 +++++++++++++++ usage-rs/src/lib.rs | 5 + usage-rs/tests/facade.rs | 244 +++++++ xtask/src/shadow.rs | 24 + 34 files changed, 3408 insertions(+), 56 deletions(-) create mode 100644 argv/src/policy.rs create mode 100644 conformance/tests/verbosity.rs create mode 100644 lib/src/spec/policy.rs diff --git a/PLAN.md b/PLAN.md index 066551533..b7aa22ea0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -669,6 +669,44 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and names automatically, honoring `NO_COLOR` and `CLICOLOR_FORCE`; explicit plain/coloured rendering stays available for tests and generated artifacts. clap's arbitrary `Command::styles` palette is intentionally not reproduced. + **A declared `color=` flag now outranks all of it** — see the entry below. + The two `Style::auto` implementations that each decided this separately now + answer from one `ColorChoice`, so the rule is stated once. +- [x] **`verbosity=` and `color=` on a flag** — what a flag _means_, as opposed to + what it binds. Every CLI in the fleet declares both and none of them could + say so: mise turns six flags into a level in a forty-nine-line function, hk + has the same shape with three, aube spells quiet as a value of `--loglevel`, + fnox has a lone `--no-color`. A spec saw six ordinary booleans. + Two closed vocabularies on `flag`, written on the flags a CLI already has: + `verbosity="verbose"|"quiet"|"level"` plus the six points on the scale + (`silent < error < warn < info < debug < trace`, baseline `info`), and + `color="always"|"never"|"choice"`. A role adds no relationship and changes + no parsing, so mise's override lattice keeps working as written; it is + opt-in per flag, so hk's `--trace` — spans, not a level — stays what it is. + Cold, beside `effect`: the hot `Flag` and `Flag::BOOL` are untouched and the + role is out of `binding_hash`, since two declarations differing only in role + bind identically. + **The colour half is a bug fix.** `argv/src/help.rs` and + `argv/src/diagnostic.rs` each decided colour from the environment and + neither could be overridden, so a CLI's own `--no-color` turned off its + output and not the help page usage rendered for it. The choice now comes + from argv — a real parse, so `--message --no-color` is a value and a token + after `--` is somebody's argument — and beats `NO_COLOR`/`CLICOLOR_FORCE`, + which were set once for every program. + usage-cli dogfoods it: `--verbose`, `-q`, hidden `--debug`/`--trace` + (carrying `USAGE_DEBUG`/`USAGE_TRACE` as `env` rather than rewriting + `USAGE_LOG` behind the user's back), `--log-level` and `--color`, with + `env_logger` started from the resolved level. Two findings came out of that: + `-v` could not be taken, because `crate::run` answers it with the version + before a parse happens; and these flags must **not** be `global` on a CLI + that forwards argv, since `usage bash script.sh --debug` gives `--debug` to + the script precisely because usage does not know it — a global one it _did_ + know would be eaten. The completion suite caught that as a real regression. + **Not carried into Go**, following `effect`, the other cold semantic + property, which never crossed either: Go's `encoding/json` ignores the + unknown key, so a spec carrying `verbosity=` still works there, and a + generated Go front door has no logger to configure. A known gap, not a + design. - [x] **Visible aliases in generated references.** Markdown and JSON reference models list every visible short and long spelling while interactive help retains its compact aligned first-pair layout; hidden aliases stay hidden. diff --git a/argv/src/diagnostic.rs b/argv/src/diagnostic.rs index 1ed8b6f11..d26c90bad 100644 --- a/argv/src/diagnostic.rs +++ b/argv/src/diagnostic.rs @@ -23,6 +23,7 @@ use core::fmt::Write as _; +use crate::policy::ColorChoice; use crate::spec::{CommandMeta, FlagMeta, Spec, ViewMeta}; use crate::{Command, Error}; @@ -46,15 +47,27 @@ impl Style { /// /// `NO_COLOR` wins over everything, per the convention: a user who sets it has said once, for /// every program, that they do not want this. `CLICOLOR_FORCE` is the other direction, for a - /// pipe that ends up somewhere that does render colour. + /// pipe that ends up somewhere that does render colour. Both are the + /// [`ColorChoice::Auto`] arm, so this rule is stated once and read here and by + /// the help renderer alike. pub fn auto() -> Style { use std::io::IsTerminal as _; - let forced = std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0"); - let refused = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()); - if refused { - return Style::PLAIN; - } - if forced || std::io::stderr().is_terminal() { + Self::for_choice(ColorChoice::Auto, std::io::stderr().is_terminal()) + } + + /// Honour what this command line asked for, falling back to [`Style::auto`]. + /// + /// A flag the CLI declared with `color=` outranks the environment: it was + /// typed now, and `NO_COLOR` was set once for every program. + pub fn resolve(spec: &Spec<'_>, argv: &[&std::ffi::OsStr]) -> Style { + use std::io::IsTerminal as _; + let choice = crate::policy::color_from_argv(spec, argv).unwrap_or_default(); + Self::for_choice(choice, std::io::stderr().is_terminal()) + } + + /// Colour, or not, for an already-decided choice. + pub fn for_choice(choice: ColorChoice, is_terminal: bool) -> Style { + if choice.enabled_for(is_terminal) { Style::COLOURED } else { Style::PLAIN @@ -676,11 +689,16 @@ fn render_inner<'a>( // clap prints the command's help page here, including the available subcommands, // while keeping exit 2; an error plus only `` tells the reader what is // missing and withholds the list they need to fix it. - let help_style = if style == Style::COLOURED { - crate::help::Style::COLOURED - } else { - crate::help::Style::PLAIN - }; + // Both styles come from one choice; this hands the decision already + // made here to the help renderer rather than deciding again. + let help_style = crate::help::Style::for_choice( + if style == Style::COLOURED { + ColorChoice::Always + } else { + ColorChoice::Never + }, + false, + ); // `spec`, `chain`, and this route have already been projected above. Feeding the // canonical host route through the view renderer a second time makes it look for // host-only ancestors under the promoted root and loses the useful full help page. diff --git a/argv/src/help.rs b/argv/src/help.rs index 03c225079..a35a1ba4d 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -19,6 +19,7 @@ use core::fmt::Write as _; +use crate::policy::ColorChoice; use crate::spec::{ArgMeta, CommandMeta, Example, FlagMeta, Spec, ViewMeta}; use crate::Command; use crate::DoubleDash; @@ -56,18 +57,45 @@ impl Style { Self::auto_for(std::io::stderr().is_terminal()) } - fn auto_for(is_terminal: bool) -> Style { - let forced = std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0"); - let refused = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()); - if refused { - Style::PLAIN - } else if forced || is_terminal { + /// Honour what this command line asked for, falling back to [`Style::auto`]. + /// + /// A CLI that declares `color=` on a flag has said which flag means colour, + /// so `mycli --no-color --help` can turn off the colour in the help page + /// usage renders for it. Reached from argv rather than from a bound struct + /// because help is rendered on a path where no struct was ever built. + pub fn resolve(spec: &Spec<'_>, argv: &[&std::ffi::OsStr]) -> Style { + use std::io::IsTerminal as _; + Self::resolve_for(spec, argv, std::io::stdout().is_terminal()) + } + + /// The same, for the stderr side. + pub fn resolve_stderr(spec: &Spec<'_>, argv: &[&std::ffi::OsStr]) -> Style { + use std::io::IsTerminal as _; + Self::resolve_for(spec, argv, std::io::stderr().is_terminal()) + } + + fn resolve_for(spec: &Spec<'_>, argv: &[&std::ffi::OsStr], is_terminal: bool) -> Style { + let choice = crate::policy::color_from_argv(spec, argv).unwrap_or_default(); + Self::for_choice(choice, is_terminal) + } + + /// Colour, or not, for an already-decided choice. + /// + /// [`ColorChoice::Auto`] is the rule this has always applied — `NO_COLOR` + /// refuses, `CLICOLOR_FORCE` insists, otherwise the destination decides — + /// and an explicit choice skips it. + pub fn for_choice(choice: ColorChoice, is_terminal: bool) -> Style { + if choice.enabled_for(is_terminal) { Style::COLOURED } else { Style::PLAIN } } + fn auto_for(is_terminal: bool) -> Style { + Self::for_choice(ColorChoice::Auto, is_terminal) + } + fn wrap(self, code: &str, text: &str) -> String { if self.coloured { format!("\u{1b}[{code}m{text}\u{1b}[0m") diff --git a/argv/src/lib.rs b/argv/src/lib.rs index 5e0f1d091..f5bf019fa 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -173,6 +173,8 @@ pub mod help; // does not dispatch to pay for, and a hand-written CLI on the bare runtime can use them. pub mod run; #[cfg(feature = "spec")] +pub mod policy; +#[cfg(feature = "spec")] pub mod spec; #[cfg(feature = "spec")] pub mod warn; @@ -990,7 +992,10 @@ pub(crate) fn find_named<'t>(cmd: &'t Command<'t>, name: &[u8]) -> Option<&'t Co /// [`Error::Help`] and [`Error::Version`] are not failures and must be handled before this. #[cfg(feature = "diagnostics")] pub fn render_failure(spec: &spec::Spec<'_>, argv: &[&OsStr], error: &Error<'_, '_>) -> String { - diagnostic::render(spec, argv, error, diagnostic::Style::auto()) + // `resolve` rather than `auto`: a CLI that declared which flag means colour + // gets its own answer honoured, even here, where the struct that would have + // held it was never built. + diagnostic::render(spec, argv, error, diagnostic::Style::resolve(spec, argv)) } /// A parse failure, never coloured. @@ -1018,7 +1023,16 @@ pub fn render_failure_view<'a>( error: &Error<'_, '_>, view: &'a spec::ViewMeta<'a>, ) -> String { - diagnostic::render_view(spec, argv, error, diagnostic::Style::auto(), view) + // A view's argv still carries argv0, which the parser behind `resolve` does + // not expect: it takes the words after the program name. + let words = argv.get(1..).unwrap_or(&[]); + diagnostic::render_view( + spec, + argv, + error, + diagnostic::Style::resolve(spec, words), + view, + ) } /// What a caller should print for a parse failure, without the renderer that makes it readable. diff --git a/argv/src/policy.rs b/argv/src/policy.rs new file mode 100644 index 000000000..d369b176d --- /dev/null +++ b/argv/src/policy.rs @@ -0,0 +1,596 @@ +//! What a flag *means*: how much the CLI should say, and whether to colour it. +//! +//! Two declarations, both cold. A role changes nothing about where a token +//! lands — `-v` binds the same `u8` and `--no-color` the same `bool` whether or +//! not its meaning is declared — so nothing here is reachable from the hot +//! binding path, and [`crate::Flag`] does not grow a field. +//! +//! What the declaration buys is that the meaning stops being folklore. Help, +//! docs, an agent reading the emitted spec and the CLI's own logger all read one +//! statement instead of inferring from a spelling, and usage can finally honour +//! a CLI's own `--no-color` when it renders that CLI's help page. +//! +//! These types mirror `usage_lib::spec::policy`, the same way [`crate::spec::Effect`] +//! mirrors `SpecCommandEffect`: this crate has no dependencies, and that is worth +//! one small duplication that `conformance` holds to agreement. + +use crate::spec::{FlagMeta, Spec}; +use crate::{Command, Event, Flag, Parser}; +use ::core::ffi::c_void; +use ::std::ffi::OsStr; + +/// How much a CLI should say. +/// +/// Ordered least to most, so where two flags pin a level the more restrictive +/// one wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum Verbosity { + /// Say nothing at all. + Silent, + /// Only what went wrong. + Error, + /// What went wrong, and what might. + Warn, + /// The default: what a user asked for and nothing else. + #[default] + Info, + /// Enough to follow what the program decided. + Debug, + /// Everything, including what only a maintainer wants. + Trace, +} + +impl Verbosity { + /// Where a CLI sits when nothing on the command line says otherwise. + pub const BASELINE: Verbosity = Verbosity::Info; + + /// Least to most, which is the order steps move along. + pub const SCALE: &'static [Verbosity] = &[ + Verbosity::Silent, + Verbosity::Error, + Verbosity::Warn, + Verbosity::Info, + Verbosity::Debug, + Verbosity::Trace, + ]; + + /// The word for this level. + /// + /// This is the whole integration with `log`, `tracing` and `env_logger`: + /// all three read exactly these six words as a filter, so a CLI writes + /// `.filter_level(level.as_str().parse()?)` and usage needs no dependency + /// on any of them. + pub const fn as_str(self) -> &'static str { + match self { + Self::Silent => "silent", + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Trace => "trace", + } + } + + /// The level a word names, or `None` if it names none. + /// + /// `warning` and `off`/`none` are accepted because mise and the fleet's + /// config files already spell them that way. Case is ignored, since an + /// environment variable carrying a level is as likely to shout. + pub fn parse(word: &str) -> Option { + Some(match () { + _ if word.eq_ignore_ascii_case("silent") + || word.eq_ignore_ascii_case("off") + || word.eq_ignore_ascii_case("none") => + { + Self::Silent + } + _ if word.eq_ignore_ascii_case("error") => Self::Error, + _ if word.eq_ignore_ascii_case("warn") || word.eq_ignore_ascii_case("warning") => { + Self::Warn + } + _ if word.eq_ignore_ascii_case("info") => Self::Info, + _ if word.eq_ignore_ascii_case("debug") => Self::Debug, + _ if word.eq_ignore_ascii_case("trace") => Self::Trace, + _ => return None, + }) + } + + /// Move `by` steps along the scale, saturating at both ends. + pub fn step(self, by: i32) -> Verbosity { + let here = Self::SCALE.iter().position(|l| *l == self).unwrap_or(0) as i32; + let there = (here + by).clamp(0, Self::SCALE.len() as i32 - 1); + Self::SCALE[there as usize] + } +} + +/// Whether output is coloured. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ColorChoice { + /// Decide from the destination and the environment. + #[default] + Auto, + /// Colour, whatever the destination. + Always, + /// No colour, whatever the destination. + Never, +} + +impl ColorChoice { + pub const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } + + /// The choice a word names, or `None` if it names none. + pub fn parse(word: &str) -> Option { + Some(match () { + _ if word.eq_ignore_ascii_case("auto") => Self::Auto, + _ if word.eq_ignore_ascii_case("always") => Self::Always, + _ if word.eq_ignore_ascii_case("never") => Self::Never, + _ => return None, + }) + } + + /// Combine two choices. A refusal beats a request, which is the convention + /// `NO_COLOR` sets: saying "no colour" once should be enough. + pub const fn combine(self, other: ColorChoice) -> ColorChoice { + match (self, other) { + (Self::Never, _) | (_, Self::Never) => Self::Never, + (Self::Always, _) | (_, Self::Always) => Self::Always, + _ => Self::Auto, + } + } + + /// Whether to colour output going to a destination that is, or is not, a terminal. + /// + /// `Auto` consults the environment first: `NO_COLOR` refuses, `CLICOLOR_FORCE` + /// insists, and otherwise a terminal gets colour and a pipe does not. An explicit + /// choice skips all of that, which is the whole point of typing one. + pub fn enabled_for(self, is_terminal: bool) -> bool { + match self { + Self::Always => true, + Self::Never => false, + Self::Auto => { + let forced = ::std::env::var_os("CLICOLOR_FORCE").is_some_and(|v| v != "0"); + let refused = ::std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()); + !refused && (forced || is_terminal) + } + } + } +} + +/// What a flag means for verbosity. The spec's `verbosity=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VerbosityRole { + /// Each occurrence raises the level one step. + Verbose, + /// Each occurrence lowers the level one step. + Quiet, + /// The flag's value names the level. + Level, + /// This switch pins the level. + Pin(Verbosity), +} + +impl VerbosityRole { + /// The spelling used in a spec. + pub const fn as_str(self) -> &'static str { + match self { + Self::Verbose => "verbose", + Self::Quiet => "quiet", + Self::Level => "level", + Self::Pin(level) => level.as_str(), + } + } + + /// The level this role pins, for the roles that name one. + pub const fn pinned(self) -> Option { + match self { + Self::Pin(level) => Some(level), + _ => None, + } + } + + /// How far one occurrence moves the level, for the roles that move it. + pub const fn step(self) -> Option { + match self { + Self::Verbose => Some(1), + Self::Quiet => Some(-1), + _ => None, + } + } + + /// Whether the role reads the flag's own value. + pub const fn takes_value(self) -> bool { + matches!(self, Self::Level) + } +} + +/// What a flag means for colour. The spec's `color=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColorRole { + /// This switch forces colour; its negation forbids it. + Always, + /// This switch forbids colour; its negation forces it. + Never, + /// The flag's value is `auto`, `always` or `never`. + Choice, +} + +impl ColorRole { + /// The spelling used in a spec. + pub const fn as_str(self) -> &'static str { + match self { + Self::Always => "always", + Self::Never => "never", + Self::Choice => "choice", + } + } + + /// What supplying this flag asks for, in its positive and negated spellings. + pub const fn asks_for(self, negated: bool) -> Option { + match (self, negated) { + (Self::Always, false) | (Self::Never, true) => Some(ColorChoice::Always), + (Self::Never, false) | (Self::Always, true) => Some(ColorChoice::Never), + (Self::Choice, _) => None, + } + } + + /// Whether the role reads the flag's own value. + pub const fn takes_value(self) -> bool { + matches!(self, Self::Choice) + } +} + +/// One flag's contribution to the level, as the bound struct holds it. +/// +/// `count` is how many times the flag was given — one for a switch, the count +/// for a counted flag, zero for a flag that was not given at all — and `value` +/// is the word a [`VerbosityRole::Level`] flag carried. +#[derive(Debug, Clone, Copy)] +pub struct VerbosityInput<'a> { + pub role: VerbosityRole, + pub count: usize, + pub value: Option<&'a str>, +} + +/// One flag's contribution to the colour choice. +#[derive(Debug, Clone, Copy)] +pub struct ColorInput<'a> { + pub role: ColorRole, + /// Whether the flag arrived through its negated spelling. + pub negated: bool, + /// Whether the flag was given at all. A switch that was not given says nothing. + pub given: bool, + /// The word a [`ColorRole::Choice`] flag carried. + pub value: Option<&'a str>, +} + +/// Resolve a level from what a command line supplied. +/// +/// An explicit level value pins; otherwise the most restrictive pinning switch +/// wins; otherwise the baseline stands. Then the stepping roles apply, +/// saturating at both ends. +/// +/// This never sees argv order, and does not need to: `overrides` has usually +/// settled the question already — mise and hk declare their verbosity flags as +/// a mutual override lattice, so at most one of them survives the parse — and +/// where it has not, an order-independent rule is the only kind whose answer a +/// reader can predict. +pub fn resolve_verbosity<'a>(supplied: impl IntoIterator>) -> Verbosity { + resolve_verbosity_from(Verbosity::BASELINE, supplied) +} + +/// The same, from a baseline the caller chose. +/// +/// A flattened group resolves first and hands its answer along as the base, so a +/// parent's own `-v` moves whatever the group settled on. +pub fn resolve_verbosity_from<'a>( + base: Verbosity, + supplied: impl IntoIterator>, +) -> Verbosity { + let mut from_value: Option = None; + let mut pinned: Option = None; + let mut steps = 0i32; + for input in supplied { + if input.count == 0 { + continue; + } + if input.role.takes_value() { + if let Some(level) = input.value.and_then(Verbosity::parse) { + from_value = Some(match from_value { + Some(held) if held < level => held, + _ => level, + }); + } + continue; + } + if let Some(level) = input.role.pinned() { + pinned = Some(match pinned { + Some(held) if held < level => held, + _ => level, + }); + } + if let Some(step) = input.role.step() { + steps += step * input.count as i32; + } + } + let base = from_value.or(pinned).unwrap_or(base); + base.step(steps) +} + +/// Resolve a colour choice from what a command line supplied. +pub fn resolve_color<'a>(supplied: impl IntoIterator>) -> ColorChoice { + let mut choice = ColorChoice::Auto; + for input in supplied { + if !input.given { + continue; + } + let asked = match input.role { + ColorRole::Choice => input.value.and_then(ColorChoice::parse), + role => role.asks_for(input.negated), + }; + if let Some(asked) = asked { + choice = choice.combine(asked); + } + } + choice +} + +/// The level a CLI was asked for, for a type whose declaration says which flags mean it. +/// +/// A trait rather than an inherent method, deliberately: a CLI adopting this may +/// well already have its own `fn verbosity`, and an inherent method would win +/// over ours and change what its own code does. Here the CLI keeps its method +/// and reaches this one as `VerbosityPolicy::verbosity(&cli)`. +pub trait VerbosityPolicy { + /// The level this command line asked for, from `base` when it asked for nothing. + fn verbosity_from(&self, base: Verbosity) -> Verbosity; + + /// The level this command line asked for. + fn verbosity(&self) -> Verbosity { + self.verbosity_from(Verbosity::BASELINE) + } +} + +/// The colour choice a CLI was asked for. See [`VerbosityPolicy`] on why it is a trait. +pub trait ColorPolicy { + /// The colour choice this command line asked for. + fn color(&self) -> ColorChoice; +} + +/// The colour choice a command line asks for, found without binding a struct. +/// +/// Help and errors are rendered on paths where the struct was never built — a +/// `--help` request and a parse failure both come back as an `Err` — so the +/// answer has to come from argv. It comes from a real parse rather than a scan +/// for the word: `mycli --message --no-color` gives `--message` its value, and +/// a `--no-color` after `--` is somebody's argument. +/// +/// `None` means the command line said nothing about colour, which is not the +/// same as asking for `Auto`: a caller can tell "no opinion" from "decide from +/// the terminal" and fall back accordingly. +/// +/// Parsing stops at the first thing that does not parse, since after that the +/// tokens no longer mean what they appear to. A colour flag before the mistake +/// is honoured; one after it is not, and the environment decides instead. That +/// is the conservative direction: the cost is a help page painted the way it +/// would have been painted anyway. +pub fn color_from_argv(spec: &Spec<'_>, argv: &[&OsStr]) -> Option { + let root = spec.root; + // The commands argv descended through, outermost first. A flag in scope was + // declared by one of them. + let mut scope: ::std::vec::Vec<&crate::spec::CommandMeta<'_>> = ::std::vec![root]; + let mut choice: Option = None; + let mut parser = Parser::new(root.cmd, argv); + while let Some(Ok(event)) = parser.next_event() { + match event { + Event::Command(cmd) => { + if let Some(meta) = child_meta(scope.last().copied(), cmd) { + scope.push(meta); + } + } + Event::Flag { + flag, + value, + negated, + } => { + let Some(role) = role_of(&scope, flag) else { + continue; + }; + let asked = match role { + ColorRole::Choice => value + .and_then(|bytes| ::core::str::from_utf8(bytes).ok()) + .and_then(ColorChoice::parse), + role => role.asks_for(negated), + }; + if let Some(asked) = asked { + // Last one wins, which is what `args_override_self` says a + // repeated scalar flag means. + choice = Some(asked); + } + } + _ => {} + } + } + choice +} + +/// The colour role declared for `flag`, looked for only among the commands on the +/// route argv took. A flag in scope was declared by one of them — a global by an +/// ancestor, anything else by the command that owns it. +fn role_of(scope: &[&crate::spec::CommandMeta<'_>], flag: &Flag<'_>) -> Option { + scope.iter().rev().find_map(|meta| { + meta.flags + .iter() + .find(|candidate: &&FlagMeta<'_>| same_flag(candidate.flag, flag)) + .and_then(|meta| meta.color) + }) +} + +/// Metadata for a child the parser descended into. +fn child_meta<'a>( + parent: Option<&'a crate::spec::CommandMeta<'a>>, + cmd: &Command<'_>, +) -> Option<&'a crate::spec::CommandMeta<'a>> { + let parent = parent?; + let index = parent + .cmd + .subcommands + .iter() + .position(|candidate| same_command(candidate, cmd))?; + parent.subcommands.get(index).copied() +} + +/// Whether two references name the same table entry. +/// +/// Metadata borrows the parse-table entry it describes, so identity is the +/// address rather than anything compared field by field — two flags may share +/// every field and still be different flags. +fn same_flag(a: &Flag<'_>, b: &Flag<'_>) -> bool { + ::core::ptr::eq( + a as *const _ as *const c_void, + b as *const _ as *const c_void, + ) +} + +fn same_command(a: &Command<'_>, b: &Command<'_>) -> bool { + ::core::ptr::eq( + a as *const _ as *const c_void, + b as *const _ as *const c_void, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_scale_runs_least_to_most() { + assert!(Verbosity::Silent < Verbosity::Error); + assert!(Verbosity::Info < Verbosity::Debug); + assert!(Verbosity::Debug < Verbosity::Trace); + assert_eq!(Verbosity::default(), Verbosity::Info); + } + + #[test] + fn a_word_names_a_level() { + assert_eq!(Verbosity::parse("warning"), Some(Verbosity::Warn)); + assert_eq!(Verbosity::parse("off"), Some(Verbosity::Silent)); + assert_eq!(Verbosity::parse("DEBUG"), Some(Verbosity::Debug)); + assert_eq!(Verbosity::parse("fatal"), None); + } + + #[test] + fn steps_saturate_at_both_ends() { + assert_eq!(Verbosity::Info.step(1), Verbosity::Debug); + assert_eq!(Verbosity::Trace.step(4), Verbosity::Trace); + assert_eq!(Verbosity::Silent.step(-4), Verbosity::Silent); + } + + fn verbose(count: usize) -> VerbosityInput<'static> { + VerbosityInput { + role: VerbosityRole::Verbose, + count, + value: None, + } + } + + fn pin(level: Verbosity, given: bool) -> VerbosityInput<'static> { + VerbosityInput { + role: VerbosityRole::Pin(level), + count: usize::from(given), + value: None, + } + } + + #[test] + fn nothing_supplied_is_the_baseline() { + assert_eq!(resolve_verbosity([]), Verbosity::Info); + assert_eq!(resolve_color([]), ColorChoice::Auto); + } + + #[test] + fn a_count_steps_once_per_occurrence() { + assert_eq!(resolve_verbosity([verbose(0)]), Verbosity::Info); + assert_eq!(resolve_verbosity([verbose(1)]), Verbosity::Debug); + assert_eq!(resolve_verbosity([verbose(3)]), Verbosity::Trace); + } + + #[test] + fn the_most_restrictive_switch_wins_whatever_the_order() { + let forward = [pin(Verbosity::Trace, true), pin(Verbosity::Silent, true)]; + let backward = [pin(Verbosity::Silent, true), pin(Verbosity::Trace, true)]; + assert_eq!(resolve_verbosity(forward), Verbosity::Silent); + assert_eq!(resolve_verbosity(backward), Verbosity::Silent); + } + + #[test] + fn an_explicit_value_pins_over_a_switch() { + let supplied = [ + pin(Verbosity::Debug, true), + VerbosityInput { + role: VerbosityRole::Level, + count: 1, + value: Some("trace"), + }, + ]; + assert_eq!(resolve_verbosity(supplied), Verbosity::Trace); + } + + #[test] + fn steps_apply_to_whatever_was_pinned() { + let supplied = [pin(Verbosity::Error, true), verbose(2)]; + assert_eq!(resolve_verbosity(supplied), Verbosity::Info); + } + + fn colour(role: ColorRole, negated: bool) -> ColorInput<'static> { + ColorInput { + role, + negated, + given: true, + value: None, + } + } + + #[test] + fn a_refusal_of_colour_beats_a_request() { + let both = [ + colour(ColorRole::Always, false), + colour(ColorRole::Never, false), + ]; + assert_eq!(resolve_color(both), ColorChoice::Never); + } + + #[test] + fn a_negated_colour_switch_means_the_other_answer() { + assert_eq!( + resolve_color([colour(ColorRole::Always, true)]), + ColorChoice::Never + ); + assert_eq!( + resolve_color([colour(ColorRole::Never, true)]), + ColorChoice::Always + ); + } + + #[test] + fn a_flag_that_was_not_given_says_nothing() { + let absent = [ColorInput { + role: ColorRole::Never, + negated: false, + given: false, + value: None, + }]; + assert_eq!(resolve_color(absent), ColorChoice::Auto); + } + + #[test] + fn an_explicit_choice_ignores_the_environment() { + assert!(ColorChoice::Always.enabled_for(false)); + assert!(!ColorChoice::Never.enabled_for(true)); + } +} diff --git a/argv/src/spec.rs b/argv/src/spec.rs index c90482f52..e9d697a7f 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -23,6 +23,7 @@ //! use core::fmt::Write as _; +use crate::policy::{ColorRole, VerbosityRole}; use crate::UnknownFlags; use crate::{Arg, Command, DoubleDash, Flag}; @@ -1030,6 +1031,10 @@ pub struct FlagMeta<'a> { /// a long flag list into sections and changes nothing about parsing. pub help_heading: Option<&'a str>, pub effect: Option, + /// What this flag means for how much the CLI says. See [`VerbosityRole`]. + pub verbosity: Option, + /// What this flag means for colour. See [`ColorRole`]. + pub color: Option, } impl FlagMeta<'_> { @@ -1089,6 +1094,8 @@ impl FlagMeta<'_> { required_unless_all: &[], help_heading: None, effect: None, + verbosity: None, + color: None, }; } @@ -2063,6 +2070,12 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: if let Some(effect) = meta.effect { write!(out, " effect={}", quoted(effect.as_str()))?; } + if let Some(role) = meta.verbosity { + write!(out, " verbosity={}", quoted(role.as_str()))?; + } + if let Some(role) = meta.color { + write!(out, " color={}", quoted(role.as_str()))?; + } if let Some(env) = meta.env { write!(out, " env={}", quoted(env))?; } @@ -2861,6 +2874,16 @@ pub trait ValueEnum: Sized { const IGNORE_CASE: bool = false; /// Convert one canonical word or alias into its enum variant. fn from_choice(value: &str) -> Option; + + /// The canonical word for this variant. + /// + /// The other direction, for the places that hold a variant and need the word + /// back: a `verbosity="level"` field is an `Option`, and the level a + /// command line asked for is a word on the scale. Defaulted to `None` so an + /// implementation written by hand keeps compiling; the derive overrides it. + fn to_choice(&self) -> Option<&'static str> { + None + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index 54214dc2b..a1cb9500d 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -857,6 +857,25 @@ const completionSpec: Fig.Spec = { description: "Outputs a `usage.kdl` spec for this CLI itself", isRepeatable: false, }, + { + name: "--verbose", + description: "Show more of what `usage` is doing", + isRepeatable: true, + }, + { + name: ["-q", "--quiet"], + description: "Say nothing that is not a failure", + isRepeatable: false, + }, + { + name: "--color", + description: "When to color output", + isRepeatable: false, + args: { + name: "when", + suggestions: ["auto", "always", "never"], + }, + }, ], }; diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 6f4fb325d..6e06370d3 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -15,6 +15,33 @@ Outputs completions for the specified shell for completing the `usage` CLI itsel .TP \fB\-\-usage\-spec\fR Outputs a `usage.kdl` spec for this CLI itself +.TP +\fB\-\-verbose\fR +Show more of what `usage` is doing +.TP +\fB\-q, \-\-quiet\fR +Say nothing that is not a failure +.TP +\fB\-\-debug\fR +Sets the log level to debug +.RS +\fIEnvironment: \fR\fBUSAGE_DEBUG\fR +.RE +.TP +\fB\-\-trace\fR +Sets the log level to trace +.RS +\fIEnvironment: \fR\fBUSAGE_TRACE\fR +.RE +.TP +\fB\-\-log\-level\fR \fI\fR +How much to say +.TP +\fB\-\-color\fR \fI\fR +When to color output +.RS +\fIDefault: \fRauto +.RE .SH COMMANDS .TP \fBbash\fR diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index 305a7101b..023f102ba 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -24,10 +24,12 @@ mod sponsors; // outright with "unsupported cmd prop effect", so this moves in lockstep with the fields the // spec actually carries. // -// Owed a bump: the four shell commands flatten `Shell`, which now emits a `flagset`, and no -// 4.0 can read that node. The floor is whichever release carries flagsets, so the number waits -// for that release rather than being guessed at here — and this crate warning about its own -// spec until then is worse than the stale claim. +// Owed a bump, now for two reasons: the four shell commands flatten `Shell`, which emits a +// `flagset`, and the root's verbosity and color flags carry `verbosity=` and `color=`. No 4.0 +// can read either — the parser stops at "unsupported flag key verbosity". The floor is +// whichever release carries them, so the number waits for that release rather than being +// guessed at here, and this crate warning about its own spec until then is worse than the +// stale claim. #[derive(DeriveCli)] #[usage( bin = "usage", @@ -81,6 +83,93 @@ pub struct Cli { /// Outputs a `usage.kdl` spec for this CLI itself #[usage(long)] usage_spec: bool, + + /// Show more of what `usage` is doing + // + // Long-only: `crate::run` answers a bare `-v` with the version string before a parse + // happens, and taking the letter here would change what an existing invocation means. + // The role never claims a spelling, so saying so is all this costs. + // + // And *not* global, which is the other spelling this CLI cannot have. `usage bash`, + // the other three shells and `usage exec` hand the words after the script to somebody + // else's program; `unknown_flags = "value"` forwards a flag usage does not know, but a + // global one it *does* know would be recognised there and eaten. A shebang script + // taking `--debug` would silently lose it. So these belong to `usage` itself and are + // written before the subcommand: `usage --verbose lint f.kdl`. + #[usage( + long, + count, + verbosity = "verbose", + overrides("--quiet", "--debug", "--trace", "--log-level") + )] + verbose: u8, + + /// Say nothing that is not a failure + #[usage( + long, + short = 'q', + verbosity = "error", + overrides("--verbose", "--debug", "--trace", "--log-level") + )] + quiet: bool, + + /// Sets the log level to debug + // `USAGE_DEBUG` and `USAGE_TRACE` were read by hand in `main` and turned into a + // `USAGE_LOG` nobody typed. As an `env` on the flag they are one declaration, and the + // help page and the spec can both say they exist. + #[usage( + long, + hide, + env = "USAGE_DEBUG", + verbosity = "debug", + overrides("--verbose", "--quiet", "--trace", "--log-level") + )] + debug: bool, + + /// Sets the log level to trace + #[usage( + long, + hide, + env = "USAGE_TRACE", + verbosity = "trace", + overrides("--verbose", "--quiet", "--debug", "--log-level") + )] + trace: bool, + + /// How much to say + #[usage( + long, + hide, + value_name = "LEVEL", + choices("silent", "error", "warn", "info", "debug", "trace"), + verbosity = "level", + overrides("--verbose", "--quiet", "--debug", "--trace") + )] + log_level: Option, + + /// When to color output + #[usage( + long, + value_name = "WHEN", + choices("auto", "always", "never"), + default = "auto", + color = "choice" + )] + color: Option, +} + +/// Start logging at the level this command line asked for. +/// +/// The default filter is the resolved level rather than a hard-coded `info`, and +/// `USAGE_LOG` still wins: it is an `env_logger` filter string, which can name a module, +/// and a level cannot say that. `try_init` because a test process may run this twice. +fn init_logging(cli: &Cli) { + use usage_rs::VerbosityPolicy as _; + let level = cli.verbosity(); + let _ = env_logger::builder() + .format_timestamp(None) + .parse_env(env_logger::Env::default().filter_or("USAGE_LOG", level.as_str())) + .try_init(); } /// What `--version` and `-v` answer with. @@ -127,17 +216,23 @@ impl Cli { // went wrong instead of ending the process — which is what lets the error come out // through the same path as every other failure here. let words: Vec<&OsStr> = argv.iter().skip(1).map(OsStr::new).collect(); + // What `--color` asked for, read from argv rather than from the struct: a help + // request and a parse failure both come back as an `Err`, and neither has built one. + // Asked for only where something is about to be printed — reading it walks the + // command line a second time, and a successful parse has no reason to pay for that. + let style = || usage_rs::help::Style::resolve(Self::spec(), &words); let cli = match Self::parse_from(&words) { Ok(cli) => cli, // Not failures: someone asked a question, and the answer goes to stdout. Err(usage_rs::Error::Help { cmd, long }) => { - if let Some(page) = usage_rs::help::render(Self::spec(), cmd, long) { + if let Some(page) = usage_rs::help::render_styled(Self::spec(), cmd, long, style()) + { print!("{page}"); } return Ok(()); } Err(usage_rs::Error::HelpAll { cmd }) => { - if let Some(page) = usage_rs::help::render_all(Self::spec(), cmd) { + if let Some(page) = usage_rs::help::render_all_styled(Self::spec(), cmd, style()) { print!("{page}"); } return Ok(()); @@ -153,6 +248,10 @@ impl Cli { std::process::exit(2); } }; + // Logging starts once the command line has been read, since the command line is + // what says how much of it there should be. `USAGE_LOG` still overrides, because it + // is a filter — `usage_cli=trace` — and a level is not. + init_logging(&cli); if let Some(shell) = cli.completions.as_deref() { return crate::usage_spec::complete(shell); } diff --git a/cli/src/main.rs b/cli/src/main.rs index 168da4f69..d65c791ae 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,22 +1,10 @@ -use env_logger::Env; use usage_cli::env; fn main() -> miette::Result<()> { - set_log_env_vars(); - env_logger::builder() - .format_timestamp(None) - .parse_env(Env::default().filter_or("USAGE_LOG", "info")) - .init(); - + // Logging is set up after the parse, in `Cli::run`: `--verbose`, `--quiet` and the rest + // are declarations on the CLI itself now, so the command line is what says how much to + // say. `USAGE_DEBUG` and `USAGE_TRACE` are `env` fallbacks on those flags rather than + // two lines here that rewrote `USAGE_LOG` behind the user's back. let args: Vec<_> = env::args().collect(); usage_cli::run(&args) } - -fn set_log_env_vars() { - if env::var_true("USAGE_DEBUG") { - env::set_var("USAGE_LOG", "debug"); - } - if env::var_true("USAGE_TRACE") { - env::set_var("USAGE_LOG", "trace"); - } -} diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 0221202c7..4ab6b0de1 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -1,5 +1,5 @@ // @generated by usage-cli from its own parse tables -min_usage_version "4.0" +min_usage_version "5.2" name usage bin usage version "5.1.0" @@ -17,6 +17,29 @@ flag --completions help="Outputs completions for the specified shell for complet arg } flag --usage-spec help="Outputs a `usage.kdl` spec for this CLI itself" +flag --verbose help="Show more of what `usage` is doing" count=#true var=#true verbosity=verbose { + overrides --quiet --debug --trace --log-level +} +flag "-q --quiet" help="Say nothing that is not a failure" verbosity=error { + overrides --verbose --debug --trace --log-level +} +flag --debug help="Sets the log level to debug" hide=#true verbosity=debug env=USAGE_DEBUG { + overrides --verbose --quiet --trace --log-level +} +flag --trace help="Sets the log level to trace" hide=#true verbosity=trace env=USAGE_TRACE { + overrides --verbose --quiet --debug --log-level +} +flag --log-level help="How much to say" hide=#true verbosity=level { + overrides --verbose --quiet --debug --trace + arg { + choices silent error warn info debug trace + } +} +flag --color help="When to color output" color=choice default=auto { + arg { + choices auto always never + } +} cmd bash help="Execute a shell script using bash" 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." use shell diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 09b9c3912..7c34ff57e 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -25,6 +25,7 @@ use usage::spec::cmd::SpecExample; use usage::{Spec, SpecArg, SpecChoices, SpecCommand, SpecComplete, SpecFlag, SpecGroup}; +use usage_argv::policy::{ColorRole, Verbosity, VerbosityRole}; use usage_argv::spec::{ ArgMeta, ChoiceAliasMeta, ChoiceMeta, CommandMeta, DefaultIf, Effect, Example, FlagMeta, GroupMeta, RequiredIfEq, RequiresIf, @@ -454,6 +455,8 @@ fn flag_meta( help_heading: opt(&f.help_heading), display_order: f.display_order, effect: f.effect.map(effect), + verbosity: f.verbosity.map(verbosity_role), + color: f.color.map(color_role), complete_type: complete_type(completers, &f.name, arg.map(|a| a.name.as_str())), complete: NO_COMPLETER, } @@ -580,6 +583,29 @@ fn effect(effect: usage::SpecCommandEffect) -> Effect { } } +fn verbosity_role(role: usage::SpecVerbosityRole) -> VerbosityRole { + use usage::SpecVerbosityRole as Declared; + match role { + Declared::Verbose => VerbosityRole::Verbose, + Declared::Quiet => VerbosityRole::Quiet, + Declared::Level => VerbosityRole::Level, + Declared::Silent => VerbosityRole::Pin(Verbosity::Silent), + Declared::Error => VerbosityRole::Pin(Verbosity::Error), + Declared::Warn => VerbosityRole::Pin(Verbosity::Warn), + Declared::Info => VerbosityRole::Pin(Verbosity::Info), + Declared::Debug => VerbosityRole::Pin(Verbosity::Debug), + Declared::Trace => VerbosityRole::Pin(Verbosity::Trace), + } +} + +fn color_role(role: usage::SpecColorRole) -> ColorRole { + match role { + usage::SpecColorRole::Always => ColorRole::Always, + usage::SpecColorRole::Never => ColorRole::Never, + usage::SpecColorRole::Choice => ColorRole::Choice, + } +} + fn opt(s: &Option) -> Option<&'static str> { s.as_deref().map(leak) } diff --git a/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap b/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap index 50c62d3e1..02f1841cd 100644 --- a/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap +++ b/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap @@ -16,8 +16,8 @@ flag "-j --jobs" help="how many jobs, and a quote: \"" global=#true help_heading long_help "More about jobs.\nOn two lines." arg } -flag --color help="colorize output" negate=--no-color default="true" -flag "-v --verbose" hide=#true count=#true +flag --color help="colorize output" negate=--no-color color=always default="true" +flag "-v --verbose" hide=#true count=#true verbosity=verbose flag --include help="patterns to include" var=#true var_min=1 var_max=5 overrides=--exclude required_if=--verbose { arg ... } diff --git a/conformance/tests/spec_roundtrip.rs b/conformance/tests/spec_roundtrip.rs index f27cc1a80..218e60c58 100644 --- a/conformance/tests/spec_roundtrip.rs +++ b/conformance/tests/spec_roundtrip.rs @@ -12,6 +12,7 @@ //! than as a diff nobody reads. use usage::Spec as LibSpec; +use usage_argv::policy::{ColorRole, VerbosityRole}; use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta, Spec}; use usage_argv::{Arg, Command, DoubleDash, Flag}; @@ -248,12 +249,16 @@ static ROOT_META: CommandMeta = CommandMeta { flag: &COLOR, help: Some("colorize output"), default: &["true"], + // A negatable switch that says which way it means: `--no-color` is the + // other answer rather than a second flag. + color: Some(ColorRole::Always), ..FlagMeta::EMPTY }, FlagMeta { flag: &VERBOSE, count: true, hide: true, + verbosity: Some(VerbosityRole::Verbose), ..FlagMeta::EMPTY }, FlagMeta { @@ -616,6 +621,26 @@ fn a_flag_can_carry_an_effect() { assert!(matches!(prune.effect, Some(Eff::Destructive))); } +#[test] +fn a_flag_can_say_what_it_means_for_verbosity_and_colour() { + let spec = parsed(); + let flag = |name: &str| { + spec.cmd + .flags + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("--{name} should be in the spec")) + }; + assert_eq!( + flag("verbose").verbosity, + Some(usage::SpecVerbosityRole::Verbose) + ); + assert_eq!(flag("color").color, Some(usage::SpecColorRole::Always)); + // And a flag that says nothing about either stays silent about both. + assert_eq!(flag("jobs").verbosity, None); + assert_eq!(flag("jobs").color, None); +} + #[test] fn several_defaults_all_survive() { // KDL properties are unique per node, so writing these as `default="a" diff --git a/conformance/tests/verbosity.rs b/conformance/tests/verbosity.rs new file mode 100644 index 000000000..9ba27801e --- /dev/null +++ b/conformance/tests/verbosity.rs @@ -0,0 +1,419 @@ +//! How much a CLI says, and whether it colours it, declared on the flags it already has. +//! +//! Every CLI in the fleet hand-rolls this: mise turns six flags into a level in a +//! forty-nine-line function, hk has the same shape with three, aube spells quiet as a value +//! of `--loglevel`, fnox has a lone `--no-color`. None of them could say so in a spec, so +//! help, documentation and anything else reading the spec saw six ordinary booleans. +//! +//! Two implementations answer the question — usage-lib interpreting the emitted spec, and the +//! compiled parser reading its own tables — so the point of this file is that they agree. The +//! shapes exercised are the fleet's, unchanged: nobody had to respell a flag to declare what +//! it already meant. + +use std::ffi::OsStr; + +use usage::parse::parse; +use usage::{ColorChoice, Spec as LibSpec, Verbosity}; +use usage_argv::policy::{ColorPolicy, VerbosityPolicy}; +use usage_derive::{Cli, ValueEnum}; + +/// mise's root, with the six-flag override lattice it really declares. +#[derive(Cli)] +#[usage(bin = "mise", name = "mise")] +struct Mise { + /// Show extra output (use -vv for even more) + #[usage( + long, + short = 'v', + global, + count, + verbosity = "verbose", + overrides("--quiet", "--silent", "--trace", "--debug", "--log-level") + )] + verbose: u8, + /// Suppress non-error messages + #[usage( + long, + short = 'q', + global, + verbosity = "error", + overrides("--verbose", "--silent", "--trace", "--debug", "--log-level") + )] + quiet: bool, + /// Suppress all task output and mise non-error messages + #[usage( + long, + global, + verbosity = "silent", + overrides("--verbose", "--quiet", "--trace", "--debug", "--log-level") + )] + silent: bool, + /// Sets log level to debug + #[usage( + long, + global, + hide, + verbosity = "debug", + overrides("--verbose", "--quiet", "--silent", "--trace", "--log-level") + )] + debug: bool, + /// Sets log level to trace + #[usage( + long, + global, + hide, + verbosity = "trace", + overrides("--verbose", "--quiet", "--silent", "--debug", "--log-level") + )] + trace: bool, + #[usage( + long, + global, + hide, + value_name = "LEVEL", + // mise's own list, `warning` and all. + choices("trace", "debug", "info", "warning", "error"), + verbosity = "level", + overrides("--verbose", "--quiet", "--silent", "--debug", "--trace") + )] + log_level: Option, +} + +/// aube's, where quiet is a *value* of the level flag and colour is two switches. +#[derive(Debug, PartialEq, Eq, ValueEnum)] +enum Loglevel { + Trace, + Debug, + Info, + Warn, + Error, + Silent, +} + +#[derive(Cli)] +#[usage(bin = "aube", name = "aube")] +struct Aube { + /// Enable verbose/debug logging (shortcut for `--loglevel debug`) + #[usage(long, short = 'v', global, verbosity = "debug")] + verbose: bool, + /// Set the log level. Logs at or above this level are shown. + #[usage(long, global, value_enum, value_name = "LEVEL", verbosity = "level")] + loglevel: Option, + /// Suppress all non-error output (alias for `--loglevel silent`) + #[usage(long, global, verbosity = "silent")] + silent: bool, + /// Force colored output even when stderr is not a TTY + #[usage(long, global, conflicts = "--no-color", color = "always")] + color: bool, + /// Disable colored output + #[usage(long, global, color = "never")] + no_color: bool, +} + +/// hk's triangle: a counted `-v` and two switches, all mutually overriding. +#[derive(Cli)] +#[usage(bin = "hk", name = "hk")] +struct Hk { + /// Enables verbose output + #[usage( + long, + short = 'v', + global, + count, + verbosity = "verbose", + overrides("--quiet", "--silent") + )] + verbose: u8, + /// Suppresses non-essential output + #[usage( + long, + short = 'q', + global, + verbosity = "quiet", + overrides("--verbose", "--silent") + )] + quiet: bool, + /// Suppresses all output including warnings + #[usage(long, global, verbosity = "silent", overrides("--quiet", "--verbose"))] + silent: bool, + /// Enable tracing spans and performance diagnostics + // + // Deliberately unannotated: hk's `--trace` turns on spans, not a level. A role is opt-in + // per flag and never claims a spelling, which is what lets this stay what it is. + #[usage(long, global)] + trace: bool, +} + +/// fnox: one word, and it is the only change fnox makes. +#[derive(Cli)] +#[usage(bin = "fnox", name = "fnox")] +struct Fnox { + /// Enable verbose logging + #[usage(long, short = 'v', global, verbosity = "debug")] + verbose: bool, + /// Disable colored output + #[usage(long, global, color = "never")] + no_color: bool, +} + +/// The other colour shape: one negatable switch rather than two flags. +#[derive(Cli)] +#[usage(bin = "paired", name = "paired")] +struct Paired { + /// Colorize output + // + // The default is what an absent flag means, and a negatable switch has to declare it: + // a `bool` holds the answer rather than whether one was given, so without it `false` + // would read as `--no-color` rather than as silence. The spec and the derive both + // refuse the flag without one. + #[usage(long, global, negate = "no-color", default = "true", color = "always")] + color: bool, +} + +/// A CLI that declares no roles at all, which most of the fleet is. +#[derive(Cli)] +#[usage(bin = "tak", name = "tak")] +struct Tak { + #[usage(long, global)] + runner: Option, +} + +/// The level usage-lib resolves, interpreting the spec the derive emitted. +fn interpreted(kdl: &str, argv: &[&str]) -> (Verbosity, ColorChoice) { + let spec: LibSpec = kdl.parse().expect("usage-lib should read the emitted spec"); + let words: Vec = std::iter::once(spec.bin.clone()) + .chain(argv.iter().map(|w| (*w).to_string())) + .collect(); + let parsed = parse(&spec, &words).expect("valid command line"); + (parsed.verbosity(), parsed.color()) +} + +/// The same question, put to the compiled parser through the built struct. +fn compiled(argv: &[&str]) -> (Verbosity, ColorChoice) +where + T: VerbosityPolicy + ColorPolicy, + T: for<'v> TypedParse<'v>, +{ + let words: Vec<&OsStr> = argv.iter().map(OsStr::new).collect(); + let cli = T::parse_words(&words); + ( + match VerbosityPolicy::verbosity(&cli) { + usage_argv::policy::Verbosity::Silent => Verbosity::Silent, + usage_argv::policy::Verbosity::Error => Verbosity::Error, + usage_argv::policy::Verbosity::Warn => Verbosity::Warn, + usage_argv::policy::Verbosity::Info => Verbosity::Info, + usage_argv::policy::Verbosity::Debug => Verbosity::Debug, + usage_argv::policy::Verbosity::Trace => Verbosity::Trace, + }, + match ColorPolicy::color(&cli) { + usage_argv::policy::ColorChoice::Auto => ColorChoice::Auto, + usage_argv::policy::ColorChoice::Always => ColorChoice::Always, + usage_argv::policy::ColorChoice::Never => ColorChoice::Never, + }, + ) +} + +/// Parsing one of the CLIs above, without naming each of them at every call site. +trait TypedParse<'v>: Sized { + fn parse_words(argv: &'v [&'v OsStr]) -> Self; +} + +macro_rules! typed_parse { + ($ty:ty) => { + impl<'v> TypedParse<'v> for $ty { + fn parse_words(argv: &'v [&'v OsStr]) -> Self { + <$ty>::parse_from(argv).expect("valid command line") + } + } + }; +} + +typed_parse!(Mise); +typed_parse!(Aube); +typed_parse!(Hk); +typed_parse!(Fnox); +typed_parse!(Paired); +typed_parse!(Tak); + +/// Both implementations, held to the same answer. +macro_rules! agree { + ($ty:ty, $kdl:expr, $argv:expr, $level:expr, $colour:expr) => {{ + let argv: &[&str] = &$argv; + let want = ($level, $colour); + assert_eq!(compiled::<$ty>(argv), want, "compiled: {argv:?}"); + assert_eq!(interpreted(&$kdl, argv), want, "interpreted: {argv:?}"); + }}; +} + +#[test] +fn mise_six_flag_lattice() { + let kdl = Mise::to_kdl(); + agree!(Mise, kdl, [], Verbosity::Info, ColorChoice::Auto); + agree!(Mise, kdl, ["-v"], Verbosity::Debug, ColorChoice::Auto); + agree!(Mise, kdl, ["-vv"], Verbosity::Trace, ColorChoice::Auto); + // Past the end of the scale is still the end of the scale. + agree!(Mise, kdl, ["-vvvvv"], Verbosity::Trace, ColorChoice::Auto); + agree!(Mise, kdl, ["-q"], Verbosity::Error, ColorChoice::Auto); + agree!( + Mise, + kdl, + ["--silent"], + Verbosity::Silent, + ColorChoice::Auto + ); + agree!(Mise, kdl, ["--debug"], Verbosity::Debug, ColorChoice::Auto); + agree!(Mise, kdl, ["--trace"], Verbosity::Trace, ColorChoice::Auto); + agree!( + Mise, + kdl, + ["--log-level", "warning"], + Verbosity::Warn, + ColorChoice::Auto + ); + + // The lattice, not the resolver, settles a contradiction: `overrides` removes the + // displaced flag during the parse, so only one of these ever reaches the question. + agree!( + Mise, + kdl, + ["-vv", "--quiet"], + Verbosity::Error, + ColorChoice::Auto + ); + agree!( + Mise, + kdl, + ["--quiet", "-vv"], + Verbosity::Trace, + ColorChoice::Auto + ); + agree!( + Mise, + kdl, + ["--silent", "--log-level", "trace"], + Verbosity::Trace, + ColorChoice::Auto + ); +} + +#[test] +fn aube_level_as_a_value_and_colour_as_a_pair() { + let kdl = Aube::to_kdl(); + agree!(Aube, kdl, [], Verbosity::Info, ColorChoice::Auto); + agree!(Aube, kdl, ["-v"], Verbosity::Debug, ColorChoice::Auto); + // `silent` is a point on the scale rather than a separate concept, which is exactly why + // `--loglevel silent` and `--silent` resolve the same way with no special case. + agree!( + Aube, + kdl, + ["--loglevel", "silent"], + Verbosity::Silent, + ColorChoice::Auto + ); + agree!( + Aube, + kdl, + ["--silent"], + Verbosity::Silent, + ColorChoice::Auto + ); + // No lattice here, so both arrive: the value pins over the switch. + agree!( + Aube, + kdl, + ["-v", "--loglevel", "trace"], + Verbosity::Trace, + ColorChoice::Auto + ); + // And where two switches disagree, the more restrictive one wins. + agree!( + Aube, + kdl, + ["-v", "--silent"], + Verbosity::Silent, + ColorChoice::Auto + ); + + agree!(Aube, kdl, ["--color"], Verbosity::Info, ColorChoice::Always); + agree!( + Aube, + kdl, + ["--no-color"], + Verbosity::Info, + ColorChoice::Never + ); +} + +#[test] +fn hk_counted_triangle() { + let kdl = Hk::to_kdl(); + agree!(Hk, kdl, [], Verbosity::Info, ColorChoice::Auto); + agree!(Hk, kdl, ["-vv"], Verbosity::Trace, ColorChoice::Auto); + // hk's `-q` is a step rather than a level, which is what its help says it is. + agree!(Hk, kdl, ["-q"], Verbosity::Warn, ColorChoice::Auto); + agree!(Hk, kdl, ["--silent"], Verbosity::Silent, ColorChoice::Auto); + // `--trace` declares nothing, so it moves nothing. + agree!(Hk, kdl, ["--trace"], Verbosity::Info, ColorChoice::Auto); +} + +#[test] +fn fnox_one_word() { + let kdl = Fnox::to_kdl(); + agree!(Fnox, kdl, [], Verbosity::Info, ColorChoice::Auto); + agree!(Fnox, kdl, ["-v"], Verbosity::Debug, ColorChoice::Auto); + agree!( + Fnox, + kdl, + ["--no-color"], + Verbosity::Info, + ColorChoice::Never + ); +} + +#[test] +fn a_negatable_switch_says_both_answers() { + let kdl = Paired::to_kdl(); + // The declared default is the third statement: what the command line means when it + // mentions neither spelling. + agree!(Paired, kdl, [], Verbosity::Info, ColorChoice::Always); + agree!( + Paired, + kdl, + ["--color"], + Verbosity::Info, + ColorChoice::Always + ); + agree!( + Paired, + kdl, + ["--no-color"], + Verbosity::Info, + ColorChoice::Never + ); +} + +#[test] +fn a_cli_that_declares_nothing_says_nothing() { + let kdl = Tak::to_kdl(); + agree!(Tak, kdl, [], Verbosity::Info, ColorChoice::Auto); + agree!( + Tak, + kdl, + ["--runner", "local"], + Verbosity::Info, + ColorChoice::Auto + ); +} + +#[test] +fn the_roles_survive_the_round_trip_through_kdl() { + for kdl in [Mise::to_kdl(), Aube::to_kdl(), Hk::to_kdl(), Fnox::to_kdl()] { + let spec: LibSpec = kdl.parse().expect("usage-lib should read the emitted spec"); + let rendered = spec.to_string(); + let reparsed: LibSpec = rendered.parse().expect("and read what it wrote"); + for (before, after) in spec.cmd.flags.iter().zip(reparsed.cmd.flags.iter()) { + assert_eq!(before.verbosity, after.verbosity, "{}", before.name); + assert_eq!(before.color, after.color, "{}", before.name); + } + } +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 9b68a0cda..274960658 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -361,6 +361,7 @@ pub fn emit(cli: &Cli) -> TokenStream { .map(|s| s.bindings.clone()); let settings_layer = resolves.then(|| settings_layer(&config)); let settings_guard = (!resolves).then(|| settings_guard(cli)).flatten(); + let policy = policy_impls(cli, ident); // The name an adopter uses, forwarding to the one inside the const block, which is where // the table that reads it lives. let settings_binding_forward = settings_bindings.as_ref().map(|_| { @@ -770,8 +771,17 @@ pub fn emit(cli: &Cli) -> TokenStream { }; } }; - let render_page = page_of(quote!(usage_argv::help::Style::auto())); - let render_page_stderr = page_of(quote!(usage_argv::help::Style::auto_stderr())); + // `resolve` rather than `auto`: a CLI that declared which of its flags means colour gets + // its own answer honoured here too, on the one path where the struct that would have held + // it was never built. + let render_page = page_of(quote!(usage_argv::help::Style::resolve( + __usage_spec, + &__usage_argv + ))); + let render_page_stderr = page_of(quote!(usage_argv::help::Style::resolve_stderr( + __usage_spec, + &__usage_argv + ))); let runtime_program = cli .runtime_bin .as_ref() @@ -1063,6 +1073,7 @@ pub fn emit(cli: &Cli) -> TokenStream { #settings_bindings #settings_layer #settings_guard + #policy pub static SPEC: usage_argv::spec::Spec = usage_argv::spec::Spec { name: #name, @@ -2314,10 +2325,28 @@ fn flag_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStr .effect .clone() .unwrap_or_else(|| quote!(::core::option::Option::None)); + // Cold, like the effect above: a role says what the flag means, and means it + // after the parse rather than during one. + let verbosity = match field.verbosity { + Some(role) => { + let role = role.tokens(); + quote!(::core::option::Option::Some(#role)) + } + None => quote!(::core::option::Option::None), + }; + let color = match field.color { + Some(role) => { + let role = role.tokens(); + quote!(::core::option::Option::Some(#role)) + } + None => quote!(::core::option::Option::None), + }; quote! { #completer_decl pub static #name: usage_argv::spec::FlagMeta = usage_argv::spec::FlagMeta { effect: #effect, + verbosity: #verbosity, + color: #color, complete: #completer, complete_type: #complete_type, flag: &#table, @@ -3970,6 +3999,198 @@ fn joined_bindings(own: &[TokenStream], children: &[TokenStream]) -> TokenStream /// /// A group with no bindings of its own still has something to say when it flattens one that does, /// which is why this is not simply "does any field declare `setting`". +/// The two policy impls, for a type that declares a role or holds a group that might. +/// +/// Generated for every `Cli` and `Args` type rather than only for the ones that declare +/// something, so that `VerbosityPolicy::verbosity(&cli)` is always there to call and a +/// flattened group composes without the parent having to know whether the child declared +/// anything. A type that declares nothing answers with the baseline it was handed, which +/// costs nothing at run time. +/// +/// Traits rather than inherent methods, deliberately: a CLI adopting this may already have +/// its own `fn verbosity` or `fn color`, and an inherent method generated here would win +/// over it and quietly change what its own code does. +fn policy_impls(cli: &Cli, ident: &syn::Ident) -> TokenStream { + let verbosity_inputs: Vec = cli + .fields + .iter() + .filter_map(|field| { + let role = field.verbosity?; + let role_tokens = role.tokens(); + Some(if role.takes_value() { + let word = value_word(field); + quote! { + __usage_inputs.push(usage_argv::policy::VerbosityInput { + role: #role_tokens, + count: usize::from(#word.is_some()), + value: #word, + }); + } + } else { + let count = occurrences(field); + quote! { + __usage_inputs.push(usage_argv::policy::VerbosityInput { + role: #role_tokens, + count: #count, + value: ::core::option::Option::None, + }); + } + }) + }) + .collect(); + let color_inputs: Vec = cli + .fields + .iter() + .filter_map(|field| { + let role = field.color?; + let role_tokens = role.tokens(); + let name = &field.ident; + Some(if role.takes_value() { + let word = value_word(field); + quote! { + __usage_inputs.push(usage_argv::policy::ColorInput { + role: #role_tokens, + negated: false, + given: #word.is_some(), + value: #word, + }); + } + } else if matches!( + field.kind, + Kind::Flag { + negate: Some(_), + .. + } + ) { + // A switch with a negation says both answers itself, and its default is + // what "absent" means — which the model insists it declares, so there is + // no state here that means nothing. + quote! { + __usage_inputs.push(usage_argv::policy::ColorInput { + role: #role_tokens, + negated: !self.#name, + given: true, + value: ::core::option::Option::None, + }); + } + } else { + // A plain switch can only say its one answer. `false` is absence, since a + // `bool` has nowhere to put the difference. + quote! { + __usage_inputs.push(usage_argv::policy::ColorInput { + role: #role_tokens, + negated: false, + given: self.#name, + value: ::core::option::Option::None, + }); + } + }) + }) + .collect(); + + // A flattened group's declarations belong to this command, so its answer is part of + // this one's: the level it resolves is the base the parent's own roles then move. + let flattened: Vec<&Field> = cli + .fields + .iter() + .filter(|f| matches!(f.kind, Kind::Flatten { .. })) + .collect(); + let verbosity_from_children = flattened.iter().map(|field| { + let name = &field.ident; + quote! { + __usage_base = usage_argv::policy::VerbosityPolicy::verbosity_from( + &self.#name, + __usage_base, + ); + } + }); + let color_from_children = flattened.iter().map(|field| { + let name = &field.ident; + quote! { + __usage_choice = __usage_choice.combine( + usage_argv::policy::ColorPolicy::color(&self.#name), + ); + } + }); + + quote! { + impl usage_argv::policy::VerbosityPolicy for #ident { + fn verbosity_from( + &self, + base: usage_argv::policy::Verbosity, + ) -> usage_argv::policy::Verbosity { + #[allow(unused_mut)] + let mut __usage_base = base; + #(#verbosity_from_children)* + #[allow(unused_mut)] + let mut __usage_inputs: ::std::vec::Vec = + ::std::vec::Vec::new(); + #(#verbosity_inputs)* + usage_argv::policy::resolve_verbosity_from(__usage_base, __usage_inputs) + } + } + + impl usage_argv::policy::ColorPolicy for #ident { + fn color(&self) -> usage_argv::policy::ColorChoice { + #[allow(unused_mut)] + let mut __usage_choice = usage_argv::policy::ColorChoice::Auto; + #(#color_from_children)* + #[allow(unused_mut)] + let mut __usage_inputs: ::std::vec::Vec = + ::std::vec::Vec::new(); + #(#color_inputs)* + __usage_choice.combine(usage_argv::policy::resolve_color(__usage_inputs)) + } + } + } +} + +/// How many times a switch or a counted flag was given, read off the built struct. +fn occurrences(field: &Field) -> TokenStream { + let name = &field.ident; + match field.shape { + Shape::Count => quote! { + ::std::convert::TryFrom::try_from(self.#name) + .unwrap_or(::std::primitive::usize::MAX) + }, + // A `bool` says whether, not how many, and absence and `false` are the same value. + _ => quote!(::std::primitive::usize::from(self.#name)), + } +} + +/// The word a value-carrying role's field holds. +/// +/// A `ValueEnum` gives it back through the type, which is where the list of words is +/// declared. Anything else has to be something a `&str` can be borrowed from, which in +/// practice means a `String`: the alternative is guessing at a conversion for a type this +/// macro cannot see. +fn value_word(field: &Field) -> TokenStream { + let name = &field.ident; + let borrowed = if field.value_enum { + quote!(usage_argv::spec::ValueEnum::to_choice(__usage_value)) + } else { + quote!(::core::option::Option::Some(::std::convert::AsRef::< + ::std::primitive::str, + >::as_ref( + __usage_value + ))) + }; + match field.shape { + Shape::Optional => quote! { + match self.#name.as_ref() { + ::core::option::Option::Some(__usage_value) => #borrowed, + ::core::option::Option::None => ::core::option::Option::None, + } + }, + _ => quote! { + { + let __usage_value = &self.#name; + #borrowed + } + }, + } +} + fn settings(cli: &Cli) -> Option { let bound: Vec<&Field> = cli.fields.iter().filter(|f| f.setting.is_some()).collect(); let children = children(cli); @@ -4991,6 +5212,7 @@ fn subcommand_parts(cli: &Cli) -> Option { /// parent reach them. pub fn emit_args(cli: &Cli) -> TokenStream { let ident = &cli.ident; + let policy = policy_impls(cli, ident); let runtime = runtime_path(); let dispatch = emit_command_dispatch(cli, &runtime); let validation = validation_path(); @@ -5401,6 +5623,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { } #settings_defs + #policy impl usage_argv::spec::CommandArgs for #ident { type Partial = Partial; @@ -8216,6 +8439,21 @@ pub fn emit_value_enum(value_enum: &ValueEnum) -> TokenStream { ) }); let ignore_case = value_enum.ignore_case; + // The canonical word back out of a variant. A `cfg`-gated variant may leave the + // match without an arm for some build, so an unreachable fallback keeps it total. + let word_arms = value_enum.variants.iter().map(|value| { + let ident = &value.ident; + let cfg = &value.cfg_attrs; + let word = &value.name; + quote! { + #(#cfg)* + Self::#ident => ::std::option::Option::Some(#word), + } + }); + let unreachable_arm = quote! { + #[allow(unreachable_patterns)] + _ => ::std::option::Option::None, + }; let parse_arms = value_enum.variants.iter().map(|value| { let ident = &value.ident; let cfg = &value.cfg_attrs; @@ -8246,6 +8484,13 @@ pub fn emit_value_enum(value_enum: &ValueEnum) -> TokenStream { #(#parse_arms)* ::std::option::Option::None } + + fn to_choice(&self) -> ::std::option::Option<&'static str> { + match self { + #(#word_arms)* + #unreachable_arm + } + } } }; } diff --git a/derive/src/model.rs b/derive/src/model.rs index 9e30eaa86..1f0af0fbd 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -296,6 +296,13 @@ pub struct Field { /// A flag can only *raise* what its command does — `--dry-run` does not make a writing /// command read-only — which is the spec's rule and not this crate's to enforce. pub effect: Option, + /// What this flag means for how much the CLI says, when it says. + /// + /// Cold: it changes nothing about how a token binds, and is read once, after + /// the parse, by whatever configures the CLI's logging. + pub verbosity: Option, + /// What this flag means for colour, when it says. + pub color: Option, /// Whether a collecting argument needs at least one value. /// /// Required-ness is normally the type's to say: a bare `String` has nowhere to put @@ -524,6 +531,147 @@ fn effect_value(meta: &Meta) -> syn::Result { ))) } +/// A point on the verbosity scale, as a field declares it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LevelDecl { + Silent, + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LevelDecl { + fn tokens(self) -> proc_macro2::TokenStream { + let variant = match self { + Self::Silent => quote::quote!(Silent), + Self::Error => quote::quote!(Error), + Self::Warn => quote::quote!(Warn), + Self::Info => quote::quote!(Info), + Self::Debug => quote::quote!(Debug), + Self::Trace => quote::quote!(Trace), + }; + quote::quote!(usage_argv::policy::Verbosity::#variant) + } +} + +/// What a field declares itself to mean for verbosity. The spec's `verbosity=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerbosityRoleDecl { + /// Each occurrence raises the level one step. + Verbose, + /// Each occurrence lowers the level one step. + Quiet, + /// The field's value names the level. + Level, + /// This switch pins the level. + Pin(LevelDecl), +} + +impl VerbosityRoleDecl { + pub fn takes_value(self) -> bool { + matches!(self, Self::Level) + } + + /// The level this role pins, for the roles that name one. + pub fn pinned_level(self) -> Option { + match self { + Self::Pin(level) => Some(level), + _ => None, + } + } + + pub fn tokens(self) -> proc_macro2::TokenStream { + match self { + Self::Verbose => quote::quote!(usage_argv::policy::VerbosityRole::Verbose), + Self::Quiet => quote::quote!(usage_argv::policy::VerbosityRole::Quiet), + Self::Level => quote::quote!(usage_argv::policy::VerbosityRole::Level), + Self::Pin(level) => { + let level = level.tokens(); + quote::quote!(usage_argv::policy::VerbosityRole::Pin(#level)) + } + } + } +} + +/// What a field declares itself to mean for colour. The spec's `color=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorRoleDecl { + /// This switch forces colour; its negation forbids it. + Always, + /// This switch forbids colour; its negation forces it. + Never, + /// The field's value is `auto`, `always` or `never`. + Choice, +} + +impl ColorRoleDecl { + pub fn takes_value(self) -> bool { + matches!(self, Self::Choice) + } + + pub fn tokens(self) -> proc_macro2::TokenStream { + match self { + Self::Always => quote::quote!(usage_argv::policy::ColorRole::Always), + Self::Never => quote::quote!(usage_argv::policy::ColorRole::Never), + Self::Choice => quote::quote!(usage_argv::policy::ColorRole::Choice), + } + } +} + +/// What a flag means for how much the CLI says. +/// +/// Every CLI in the fleet has one of these and none of them could say so: mise +/// hand-copies six flags into a level in a forty-nine-line function. Declaring +/// the meaning changes no parsing — it is read after the last token, by the CLI +/// deciding how loud to be, and by help, docs and anything else reading the spec. +fn verbosity_value(meta: &Meta) -> syn::Result { + let value = string_value(meta)?; + Ok(match value.as_str() { + "verbose" => VerbosityRoleDecl::Verbose, + "quiet" => VerbosityRoleDecl::Quiet, + "level" => VerbosityRoleDecl::Level, + "silent" => VerbosityRoleDecl::Pin(LevelDecl::Silent), + "error" => VerbosityRoleDecl::Pin(LevelDecl::Error), + "warn" => VerbosityRoleDecl::Pin(LevelDecl::Warn), + "info" => VerbosityRoleDecl::Pin(LevelDecl::Info), + "debug" => VerbosityRoleDecl::Pin(LevelDecl::Debug), + "trace" => VerbosityRoleDecl::Pin(LevelDecl::Trace), + other => { + return Err(syn::Error::new_spanned( + meta, + format!( + "`verbosity = \"{other}\"` is not one the spec has; it takes \"verbose\" \ + or \"quiet\" for a switch that moves along the scale, \"level\" for a \ + flag whose value names a level, and \"silent\", \"error\", \"warn\", \ + \"info\", \"debug\" or \"trace\" for a switch that pins one" + ), + )); + } + }) +} + +/// What a flag means for colour. +fn color_value(meta: &Meta) -> syn::Result { + let value = string_value(meta)?; + Ok(match value.as_str() { + "always" => ColorRoleDecl::Always, + "never" => ColorRoleDecl::Never, + "choice" => ColorRoleDecl::Choice, + other => { + return Err(syn::Error::new_spanned( + meta, + format!( + "`color = \"{other}\"` is not one the spec has; a switch takes \"always\" \ + or \"never\", and a flag whose value is `auto`, `always` or `never` takes \ + \"choice\"" + ), + )); + } + }) +} + /// usage's shell-native `ValueHint`s lowered into the completion types the spec has. fn value_hint(meta: &Meta) -> syn::Result { let value = &meta.require_name_value()?.value; @@ -1847,6 +1995,8 @@ impl Field { value_optional: false, kind: Kind::Skip, effect: None, + verbosity: None, + color: None, complete: None, complete_type: None, shape: Shape::Bool, @@ -1980,6 +2130,8 @@ impl Field { // flattened group's flags carry their own, and a subcommand field is a command's // place rather than a command. effect: None, + verbosity: None, + color: None, complete: None, complete_type: None, // A flattened field holds declarations, not a value, so none of what describes a @@ -2109,6 +2261,8 @@ impl Field { value_optional: false, kind: Kind::Subcommand { ty, optional }, effect: None, + verbosity: None, + color: None, complete: None, complete_type: None, // A subcommand field holds a command, not a value, so none of what @@ -2232,6 +2386,8 @@ impl Field { let mut help_heading = None; let mut display_order = None; let mut effect = None; + let mut verbosity = None; + let mut color = None; let mut value_name = None; let mut value_names: Vec = Vec::new(); let mut num_args: Option<(usize, Option)> = None; @@ -2489,6 +2645,8 @@ impl Field { "help_heading" => help_heading = Some(string_value(&meta)?), "display_order" => display_order = Some(int_value(&meta)?), "effect" => effect = Some(effect_value(&meta)?), + "verbosity" => verbosity = Some(verbosity_value(&meta)?), + "color" => color = Some(color_value(&meta)?), "value_name" => value_name = Some(string_value(&meta)?), "value_names" => value_names = selectors(&meta)?, "num_args" => { @@ -2597,7 +2755,8 @@ impl Field { `conflicts`, `requires`, `group`, `exclusive`, \ `delimiter`, `allow_hyphen_values`, `allow_negative_numbers`, \ `value_terminator`, `require_equals`, `bool_value`, \ - `default_missing`, `default_if`, \ + `default_missing`, `default_if`, `effect`, \ + `verbosity`, `color`, \ `required_if`, \ `required_unless`, `help_heading`, `display_order`, `value_name`, `value_names`, `num_args`, \ `verbatim_doc_comment`, \ @@ -3294,6 +3453,107 @@ impl Field { )); } + // A role says what a flag *means*, so it has to agree with the shape the field + // already has. The spec makes the same checks; making them here as well is what + // turns a spec error at emission time into a message pointing at the field. + if verbosity.is_some() && color.is_some() { + return Err(syn::Error::new( + span, + "a flag means one thing: `verbosity` or `color`, not both", + )); + } + if let Some(role) = verbosity { + if !matches!(kind, Kind::Flag { .. }) { + return Err(syn::Error::new( + span, + "`verbosity` describes what supplying a flag means for how much the CLI \ + says; add `long` or `short` to make this field a flag", + )); + } + if role.takes_value() && !matches!(shape, Shape::Optional | Shape::Required) { + return Err(syn::Error::new( + span, + "`verbosity = \"level\"` reads the flag's value, so the field holds one: \ + an `Option` or a `T`, where `T` is a `ValueEnum` or a `String`", + )); + } + if !role.takes_value() && !matches!(shape, Shape::Bool | Shape::Count) { + return Err(syn::Error::new( + span, + "this `verbosity` is a switch, and the field takes a value; a flag whose \ + value names the level says `verbosity = \"level\"`", + )); + } + if matches!(shape, Shape::Count) && role.pinned_level().is_some() { + return Err(syn::Error::new( + span, + "a counted flag says how far to move, not where to land: use \ + `verbosity = \"verbose\"` or `verbosity = \"quiet\"`", + )); + } + if let Kind::Flag { + negate: Some(_), .. + } = &kind + { + return Err(syn::Error::new( + span, + "a level cannot be negated, so `verbosity` and `negate` do not go together", + )); + } + } + if let Some(role) = color { + if !matches!(kind, Kind::Flag { .. }) { + return Err(syn::Error::new( + span, + "`color` describes what supplying a flag means for colour; add `long` or \ + `short` to make this field a flag", + )); + } + if role.takes_value() && !matches!(shape, Shape::Optional | Shape::Required) { + return Err(syn::Error::new( + span, + "`color = \"choice\"` reads the flag's value, so the field holds one: an \ + `Option` or a `T`, where `T` is a `ValueEnum` or a `String`", + )); + } + if !role.takes_value() && !matches!(shape, Shape::Bool) { + return Err(syn::Error::new( + span, + "`color = \"always\"` and `color = \"never\"` describe a switch; a flag \ + whose value is `auto`, `always` or `never` says `color = \"choice\"`", + )); + } + if role.takes_value() { + if let Kind::Flag { + negate: Some(_), .. + } = &kind + { + return Err(syn::Error::new( + span, + "`color = \"choice\"` already spells every answer, so it cannot also \ + be negated", + )); + } + } + // A `bool` holds the answer, not whether one was given, so a negatable colour + // switch has to say what an absent flag means: without a default, `false` would + // read as the negation rather than as silence. + if matches!( + kind, + Kind::Flag { + negate: Some(_), + .. + } + ) && default.is_empty() + { + return Err(syn::Error::new( + span, + "a negatable `color` flag says both answers, so it needs a `default` for \ + the command line that says neither", + )); + } + } + // `exclusive` is represented by flag metadata and enforced for a flag occurrence. // Accepting it on a positional would make the derive enforce a rule that its emitted // spec and documentation silently omit. @@ -3510,6 +3770,8 @@ impl Field { help_heading, display_order, effect, + verbosity, + color, value_name, value_names, required_collection, @@ -6850,6 +7112,113 @@ mod tests { assert!(err.contains("destructive"), "unhelpful: {err}"); } + #[test] + fn a_role_the_spec_does_not_have_is_refused() { + let err = rejection( + r#" + struct Ex { + #[usage(long, verbosity = "loud")] + verbose: bool, + } + "#, + ); + assert!(err.contains("is not one the spec has"), "unhelpful: {err}"); + // The message lists the vocabulary, which nine words are not guessable from. + assert!(err.contains("level"), "unhelpful: {err}"); + + let err = rejection( + r#" + struct Ex { + #[usage(long, color = "auto")] + color: bool, + } + "#, + ); + // `auto` is an answer a *value* can carry, not a thing a switch can mean. + assert!(err.contains("is not one the spec has"), "unhelpful: {err}"); + assert!(err.contains("choice"), "unhelpful: {err}"); + } + + #[test] + fn a_role_has_to_agree_with_the_field_it_is_written_on() { + // A switch that pins a level, on a field holding a value. + let err = rejection( + r#" + struct Ex { + #[usage(long, verbosity = "debug")] + log_level: Option, + } + "#, + ); + assert!(err.contains("verbosity = \"level\""), "unhelpful: {err}"); + + // And the other way about. + let err = rejection( + r#" + struct Ex { + #[usage(long, verbosity = "level")] + verbose: bool, + } + "#, + ); + assert!(err.contains("the field holds one"), "unhelpful: {err}"); + + // A count says how far to move, not where to land. + let err = rejection( + r#" + struct Ex { + #[usage(long, count, verbosity = "debug")] + verbose: u8, + } + "#, + ); + assert!(err.contains("how far to move"), "unhelpful: {err}"); + + // A level cannot be negated. + let err = rejection( + r#" + struct Ex { + #[usage(long, negate = "quiet", verbosity = "verbose")] + verbose: bool, + } + "#, + ); + assert!(err.contains("cannot be negated"), "unhelpful: {err}"); + + // A flag means one thing. + let err = rejection( + r#" + struct Ex { + #[usage(long, verbosity = "verbose", color = "always")] + verbose: bool, + } + "#, + ); + assert!(err.contains("not both"), "unhelpful: {err}"); + + // A negatable colour switch has to say what an absent flag means. + let err = rejection( + r#" + struct Ex { + #[usage(long, negate = "no-color", color = "always")] + color: bool, + } + "#, + ); + assert!(err.contains("says both answers"), "unhelpful: {err}"); + + // And a positional is not something supplied to say how loud to be. + let err = rejection( + r#" + struct Ex { + #[usage(verbosity = "verbose")] + level: Option, + } + "#, + ); + assert!(err.contains("make this field a flag"), "unhelpful: {err}"); + } + #[test] fn one_spec_makes_one_claim_about_which_usage_can_read_it() { // Only the root emits a spec, so only the root can say this. Accepted on an `Args` it diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index 8fe8c989e..f8d62f363 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -3,7 +3,7 @@ "bin": "usage", "cmd": { "full_cmd": [], - "usage": "[--completions ] [--usage-spec] ", + "usage": "[FLAGS] ", "subcommands": { "bash": { "full_cmd": ["bash"], @@ -1740,6 +1740,102 @@ "long": ["usage-spec"], "hide": false, "global": false + }, + { + "name": "verbose", + "usage": "--verbose…", + "help": "Show more of what `usage` is doing", + "help_first_line": "Show more of what `usage` is doing", + "short": [], + "long": ["verbose"], + "var": true, + "hide": false, + "global": false, + "count": true, + "overrides": ["--quiet", "--debug", "--trace", "--log-level"], + "verbosity": "verbose" + }, + { + "name": "quiet", + "usage": "-q --quiet", + "help": "Say nothing that is not a failure", + "help_first_line": "Say nothing that is not a failure", + "short": ["q"], + "long": ["quiet"], + "hide": false, + "global": false, + "overrides": ["--verbose", "--debug", "--trace", "--log-level"], + "verbosity": "error" + }, + { + "name": "debug", + "usage": "--debug", + "help": "Sets the log level to debug", + "help_first_line": "Sets the log level to debug", + "short": [], + "long": ["debug"], + "hide": true, + "global": false, + "overrides": ["--verbose", "--quiet", "--trace", "--log-level"], + "verbosity": "debug", + "env": "USAGE_DEBUG" + }, + { + "name": "trace", + "usage": "--trace", + "help": "Sets the log level to trace", + "help_first_line": "Sets the log level to trace", + "short": [], + "long": ["trace"], + "hide": true, + "global": false, + "overrides": ["--verbose", "--quiet", "--debug", "--log-level"], + "verbosity": "trace", + "env": "USAGE_TRACE" + }, + { + "name": "log-level", + "usage": "--log-level ", + "help": "How much to say", + "help_first_line": "How much to say", + "short": [], + "long": ["log-level"], + "hide": true, + "global": false, + "arg": { + "name": "LEVEL", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false, + "choices": { + "choices": ["silent", "error", "warn", "info", "debug", "trace"] + } + }, + "overrides": ["--verbose", "--quiet", "--debug", "--trace"], + "verbosity": "level" + }, + { + "name": "color", + "usage": "--color ", + "help": "When to color output", + "help_first_line": "When to color output", + "short": [], + "long": ["color"], + "hide": false, + "global": false, + "arg": { + "name": "WHEN", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false, + "choices": { + "choices": ["auto", "always", "never"] + } + }, + "default": ["auto"], + "color": "choice" } ], "mounts": [], @@ -1763,6 +1859,6 @@ "source_code_link_template": "{%- set path = path | replace(from='-', to='_') -%}\n{%- if cmd.subcommands | length > 0 -%}\n{%- set path = path ~ \"/mod.rs\" -%}\n{%- elif path in [\"bash\", \"fish\", \"powershell\", \"zsh\"] -%}\n{%- set path = \"shell.rs\" -%}\n{%- else -%}\n{%- set path = path ~ \".rs\" -%}\n{%- endif -%}\nhttps://github.com/jdx/usage/blob/main/cli/src/cli/{{path}}", "repository": "https://github.com/jdx/usage", "about": "CLI for working with usage-based CLIs", - "min_usage_version": "4.0", + "min_usage_version": "5.2", "unknown_flags": "error" } diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index d230312e4..8d99197c8 100644 --- a/docs/cli/reference/index.md +++ b/docs/cli/reference/index.md @@ -2,13 +2,13 @@ # `usage` -**Usage**: `usage [--completions ] [--usage-spec] ` +**Usage**: `usage [FLAGS] ` **Version**: 5.1.0 **Repository**: https://github.com/jdx/usage -- **Usage**: `usage [--completions ] [--usage-spec] ` +- **Usage**: `usage [FLAGS] ` ## Flags @@ -20,6 +20,32 @@ Outputs completions for the specified shell for completing the `usage` CLI itsel Outputs a `usage.kdl` spec for this CLI itself +### `--verbose…` + +**Verbosity**: raises the log level one step per occurrence + +Show more of what `usage` is doing + +### `-q --quiet` + +**Verbosity**: sets the log level to error + +Say nothing that is not a failure + +### `--color ` + +**Color**: chooses whether output is colored + +When to color output + +**Choices:** + +- `auto` +- `always` +- `never` + +**Default:** `auto` + ## Subcommands - [`usage bash [-h] [--help]