diff --git a/PLAN.md b/PLAN.md index 066551533..7d9ba2b81 100644 --- a/PLAN.md +++ b/PLAN.md @@ -669,6 +669,56 @@ 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 color half is a bug fix.** `argv/src/help.rs` and + `argv/src/diagnostic.rs` each decided color 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. + **The perf gate went red, and the regression was accepted** as the cost of + the dogfood. `usage --help` rose 14.6% and the markdown benchmark 5.4%, + both measured against a build of the base on one machine. Attributed by + measuring the same binary two ways: the branch reading _main's_ spec is + +0.51% on markdown, and the branch with the flags declared but not compiled + in is +2.58% on startup — so 90% and 82% of the two numbers is usage's own + command line gaining six flags, which is what a dogfood is. The hot path is + untouched, which the shadow table reports independently at 8370 + instructions either way. What is left is the new code in the binary, and + `usage --help` is ~57% dynamic-linker relocation, so anything that grows it + shows up there. Worth writing down because the gate will keep firing until + the next baseline: it caught a declared cost, not an accidental one. + **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..63cfb6c2e 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 color. 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()) + } + + /// Color, 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 @@ -479,17 +492,31 @@ pub fn render_warnings(warnings: &[crate::warn::Warning<'_>], style: Style) -> S /// Render a parse failure through a spec-declared executable view. pub fn render_view<'a>( spec: &'a Spec<'a>, - argv: &[&std::ffi::OsStr], + argv: &[&'a std::ffi::OsStr], error: &Error<'_, '_>, style: Style, view: &'a ViewMeta<'a>, ) -> String { + render_inner(spec, &view_words(argv, view), error, style, Some(view)) +} + +/// The command line as the parse walked it, for a view invocation. +/// +/// argv0 named the view rather than the program, so the words it stands for are put back: +/// `scoped-run --bad` is `run --bad` to everything downstream. Shared rather than done at +/// each call site because the caller that renders and the caller that decides whether to +/// colour have to agree about which command the words reached — they did not, and a +/// `color=` flag on the rooted command painted a help page and not a failure. +pub(crate) fn view_words<'a>( + argv: &[&'a std::ffi::OsStr], + view: &ViewMeta<'a>, +) -> Vec<&'a std::ffi::OsStr> { let words = argv.get(1..).unwrap_or_default(); let mut rewritten = Vec::with_capacity(words.len() + view.root.split_ascii_whitespace().count()); rewritten.extend(view.root.split_ascii_whitespace().map(std::ffi::OsStr::new)); rewritten.extend_from_slice(words); - render_inner(spec, &rewritten, error, style, Some(view)) + rewritten } fn render_inner<'a>( @@ -676,11 +703,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..7c5911b32 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 color, + /// so `mycli --no-color --help` can turn off the color 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) + } + + /// Color, 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..a8cc545a2 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -171,6 +171,8 @@ macro_rules! __usage_needs_complete_feature { pub mod help; // Behind no feature: two traits and no code, so there is nothing here for a binary that // does not dispatch to pay for, and a hand-written CLI on the bare runtime can use them. +#[cfg(feature = "spec")] +pub mod policy; pub mod run; #[cfg(feature = "spec")] pub mod spec; @@ -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 color + // 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. @@ -1014,11 +1019,22 @@ pub fn render_failure_plain( #[cfg(feature = "diagnostics")] pub fn render_failure_view<'a>( spec: &'a spec::Spec<'a>, - argv: &[&OsStr], + argv: &[&'a OsStr], error: &Error<'_, '_>, view: &'a spec::ViewMeta<'a>, ) -> String { - diagnostic::render_view(spec, argv, error, diagnostic::Style::auto(), view) + // The words the parse walked, not the ones the caller typed: argv0 named the view, so + // the root command it stands for has to be put back before asking which command's + // `color=` flag applies. Dropping argv0 alone left the question answered at the root, + // where a view's flags are not declared. + let words = diagnostic::view_words(argv, view); + 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. @@ -1065,12 +1081,40 @@ pub fn render_warnings(warnings: &[warn::Warning<'_>]) -> String { diagnostic::render_warnings(warnings, diagnostic::Style::auto()) } -/// The same wording without the renderer that colours it. See the other half. +/// The same wording without the renderer that colors it. See the other half. #[cfg(all(feature = "spec", not(feature = "diagnostics")))] pub fn render_warnings(warnings: &[warn::Warning<'_>]) -> String { warn::render_warnings(warnings) } +/// The same, painted the way this command line asked for. +/// +/// [`render_warnings`] decides from the environment alone, which is all a caller holding +/// nothing but the warnings can do. A caller that still has the spec and the words can +/// honor a declared `color=` flag instead, and should: a warning is output like any other, +/// and `mycli --no-color` meaning "except the warnings" would be a strange rule to explain. +/// +/// `argv` is the command line without the program name, as [`render_failure`] takes it. +#[cfg(feature = "diagnostics")] +pub fn render_warnings_for( + spec: &spec::Spec<'_>, + argv: &[&OsStr], + warnings: &[warn::Warning<'_>], +) -> String { + diagnostic::render_warnings(warnings, diagnostic::Style::resolve(spec, argv)) +} + +/// The same wording without the renderer that colors it. See the other half. +#[cfg(all(feature = "spec", not(feature = "diagnostics")))] +pub fn render_warnings_for( + spec: &spec::Spec<'_>, + argv: &[&OsStr], + warnings: &[warn::Warning<'_>], +) -> String { + let _ = (spec, argv); + warn::render_warnings(warnings) +} + /// The word a tool sends to ask a binary for its own spec. /// /// Not a flag and not a command: a spec request is not something this CLI *does*, so it is diff --git a/argv/src/policy.rs b/argv/src/policy.rs new file mode 100644 index 000000000..0a6dcf038 --- /dev/null +++ b/argv/src/policy.rs @@ -0,0 +1,741 @@ +//! What a flag *means*: how much the CLI should say, and whether to color 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, as a spec spells it. + /// + /// This is the spelling the fleet uses and the one `verbosity=` takes, so it is what + /// help, documentation and an emitted spec say. It is *not* what a logger reads — + /// see [`Verbosity::log_filter`], which differs for exactly one level. + 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 word `log`, `tracing` and `env_logger` all read as a filter. + /// + /// This is the whole integration with them: a CLI writes + /// `.filter_level(level.log_filter().parse()?)` and usage needs no dependency on any + /// of them. + /// + /// Identical to [`Verbosity::as_str`] for five of the six. The bottom of the scale is + /// the exception, and the reason this is a second method rather than one: the fleet + /// spells silence `silent` — mise's and hk's `--silent`, aube's `--loglevel silent` — + /// while every logging crate spells it `off`, and `silent` is not a level to any of + /// them. `env_logger` would read it as the name of a module to filter on, so a CLI + /// handing it `as_str()` would answer `--silent` by logging *more*, not less. + pub const fn log_filter(self) -> &'static str { + match self { + Self::Silent => "off", + other => other.as_str(), + } + } + + /// 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; + // Saturating, so a step count large enough to wrap lands at the end of the scale + // rather than at the other end of it: `-v` repeated past `i32` is still "louder". + let there = here + .saturating_add(by) + .clamp(0, Self::SCALE.len() as i32 - 1); + Self::SCALE[there as usize] + } +} + +/// Whether output is colored. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ColorChoice { + /// Decide from the destination and the environment. + #[default] + Auto, + /// Color, whatever the destination. + Always, + /// No color, 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 color" 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 color 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 color 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 color. The spec's `color=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColorRole { + /// This switch forces color; its negation forbids it. + Always, + /// This switch forbids color; 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 color 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 = 0i64; + 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() { + // In `i64` and saturating: a count arrives as a `usize` from a field whose type + // the CLI chose, and `as i32` on a large one changes its sign — which would have + // `-v` given absurdly many times resolve toward silence. + let by = i64::try_from(input.count).unwrap_or(i64::MAX); + steps = steps.saturating_add(by.saturating_mul(i64::from(step))); + } + } + let base = from_value.or(pinned).unwrap_or(base); + base.step(steps.clamp(i32::MIN as i64, i32::MAX as i64) as i32) +} + +/// Resolve a color 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 color choice a CLI was asked for. See [`VerbosityPolicy`] on why it is a trait. +pub trait ColorPolicy { + /// The color choice this command line asked for. + fn color(&self) -> ColorChoice; +} + +/// The color 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 color, 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 color 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]; + // What each flag ended up saying, in the order the flags were first seen. Kept per + // flag rather than as one running answer, because that is the shape the bound struct + // has and the two must not disagree: a second occurrence of *one* flag replaces its + // value, which is `args_override_self`, while two *different* flags both hold theirs + // and are combined below, where a refusal beats a request. + let mut said: ::std::vec::Vec<(&Flag<'_>, ColorChoice)> = ::std::vec::Vec::new(); + 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 { + match said.iter_mut().find(|(seen, _)| same_flag(seen, flag)) { + Some((_, held)) => *held = asked, + None => said.push((flag, asked)), + } + } + } + _ => {} + } + } + // A flag nobody typed still speaks if the environment or a `default` answers for it: + // the struct would have held that value, so the two answers have to agree about it. + // Weaker than a typed token by construction — only flags missing from `said` are + // considered — and in the order the binder fills them, argv then environment then + // default. + for meta in scope.iter().flat_map(|cmd| cmd.flags.iter()) { + let Some(role) = meta.color else { + continue; + }; + if said.iter().any(|(seen, _)| same_flag(seen, meta.flag)) { + continue; + } + let from_env = meta + .env + .into_iter() + .chain(meta.env_fallback.iter().copied()) + .chain(meta.deprecated_env.iter().copied()) + .find_map(|name| ::std::env::var(name).ok()); + let word = match &from_env { + Some(value) => Some(value.as_str()), + None => meta.default.first().copied(), + }; + let Some(word) = word else { + continue; + }; + if let Some(asked) = asked_for(role, meta.flag, word, from_env.is_some()) { + said.push((meta.flag, asked)); + } + } + said.into_iter() + .map(|(_, asked)| asked) + .reduce(ColorChoice::combine) +} + +/// What a color flag says when the word came from somewhere other than the command line. +/// +/// The same reading the bound struct gives, because the two have to agree — which means +/// reading a `bool` the way the binder reads it, and the binder does not read the two +/// sources alike. An environment word is true for anything but the falsy words; a declared +/// `default` is true only for the words that spell it. So `default="yes"` leaves the field +/// `false`, and answering "colour was asked for" here would put help and the program back +/// in the disagreement this exists to close. Mirrored rather than tidied: whether those two +/// rules should be one rule is a question about the binder, not about colour. +/// +/// A `false` is the negated spelling's answer where there is a negation, and nothing at all +/// where there is not — a plain switch has no way to say "no". +fn asked_for(role: ColorRole, flag: &Flag<'_>, word: &str, from_env: bool) -> Option { + match role { + ColorRole::Choice => ColorChoice::parse(word), + role => match bool_word(word, from_env) { + true => role.asks_for(false), + false if flag.negate.is_some() => role.asks_for(true), + false => None, + }, + } +} + +/// Whether a word fills a `bool` field, as the binder fills it. See [`asked_for`]. +fn bool_word(word: &str, from_env: bool) -> bool { + match from_env { + true => !matches!(word, "" | "0" | "false" | "no" | "off"), + false => matches!(word, "1" | "true" | "True" | "TRUE"), + } +} + +/// The color 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 a_bool_word_is_read_the_way_its_source_is_read() { + // Two rules, because the binder has two. A word from the environment is true unless + // it is one of the falsy spellings; a declared `default` is true only if it spells + // true — which is why the derive refuses `default = "yes"` on a `bool` outright. + // Reading a default by the environment's rule reported colour for a field left + // `false`, which is the disagreement this module exists to prevent. + assert!(bool_word("yes", true)); + assert!(!bool_word("yes", false)); + // The words both rules agree on, which is all the derive can produce. + for (word, want) in [("true", true), ("false", false)] { + assert_eq!(bool_word(word, true), want, "{word} from env"); + assert_eq!(bool_word(word, false), want, "{word} from default"); + } + // And the falsy spellings, where the environment's rule is the wider one. + for word in ["", "0", "false", "no", "off"] { + assert!(!bool_word(word, true), "{word} from env"); + assert!(!bool_word(word, false), "{word} from default"); + } + } + + #[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 the_bottom_of_the_scale_has_two_spellings() { + // The fleet says `silent`; every logging crate says `off`. Both are this level, + // and which word comes out depends on who is being told. + assert_eq!(Verbosity::Silent.as_str(), "silent"); + assert_eq!(Verbosity::Silent.log_filter(), "off"); + assert_eq!(Verbosity::parse("off"), Some(Verbosity::Silent)); + // Everything else is the same word to both. + for level in Verbosity::SCALE.iter().skip(1) { + assert_eq!(level.as_str(), level.log_filter()); + } + } + + #[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); + // Including a step so large that adding it would wrap: louder is still louder. + assert_eq!(Verbosity::Info.step(i32::MAX), Verbosity::Trace); + assert_eq!(Verbosity::Info.step(i32::MIN), Verbosity::Silent); + } + + #[test] + fn an_absurd_count_is_still_louder_rather_than_quieter() { + // The count comes from a field whose integer type the CLI chose, so it arrives as a + // `usize`; narrowing a large one to `i32` would change its sign and resolve `-v` + // repeated past counting toward silence. + assert_eq!(resolve_verbosity([verbose(usize::MAX)]), Verbosity::Trace); + let down = [VerbosityInput { + role: VerbosityRole::Quiet, + count: usize::MAX, + value: None, + }]; + assert_eq!(resolve_verbosity(down), 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 color(role: ColorRole, negated: bool) -> ColorInput<'static> { + ColorInput { + role, + negated, + given: true, + value: None, + } + } + + #[test] + fn a_refusal_of_color_beats_a_request() { + let both = [ + color(ColorRole::Always, false), + color(ColorRole::Never, false), + ]; + assert_eq!(resolve_color(both), ColorChoice::Never); + } + + #[test] + fn a_negated_color_switch_means_the_other_answer() { + assert_eq!( + resolve_color([color(ColorRole::Always, true)]), + ColorChoice::Never + ); + assert_eq!( + resolve_color([color(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..bee4e69f0 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 color. 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..85307e980 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -857,6 +857,26 @@ 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: "Force colored output", + isRepeatable: false, + }, + { + name: "--no-color", + description: "Disable colored output", + isRepeatable: false, + }, ], }; diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 6f4fb325d..ccaa803bf 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -15,6 +15,18 @@ 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\-\-color\fR +Force colored output +.TP +\fB\-\-no\-color\fR +Disable colored output .SH COMMANDS .TP \fBbash\fR diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index 305a7101b..11e9c7ea6 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,100 @@ 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, + + /// Force colored output + // + // Two switches rather than one `--color `: three words for three answers, and + // no value to read, mis-type or leave off. It is also the only shape where "said + // nothing" is a state of its own — a `bool` holding a color has to mean either + // `always` or `never`, so a single negatable flag cannot express the `auto` a bare + // command line asks for, while two flags neither of which was given can. + #[usage(long, color = "always", conflicts = "--no-color")] + color: bool, + + /// Disable colored output + #[usage(long, color = "never")] + no_color: bool, +} + +/// 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. +/// +/// `log_filter` rather than `as_str`: `env_logger` spells silence `off`, and would read +/// the spec's spelling of it as the name of a module to filter on. +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.log_filter())) + .try_init(); } /// What `--version` and `-v` answer with. @@ -127,17 +223,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 +255,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/src/test.rs b/cli/src/test.rs index 646c865d7..616db7ca9 100644 --- a/cli/src/test.rs +++ b/cli/src/test.rs @@ -25,3 +25,33 @@ fn shell_commands_keep_their_verbatim_long_help() { ) ); } + +/// Every level has to be a word the logger actually takes. +/// +/// `log_filter` exists because `silent` — what the fleet calls the bottom of the scale, and +/// what a spec spells it — is not a level to `log` or `env_logger`; `off` is. Handing them +/// the spec's word would have `env_logger` read it as the name of a module to filter on, so +/// `usage --log-level silent` would answer by logging *more*. This is the CLI that would have +/// done it, so this is where the guarantee is held. +#[test] +fn every_level_names_a_filter_the_logger_understands() { + use std::str::FromStr as _; + use usage_rs::Verbosity; + + for level in Verbosity::SCALE { + let filter = log::LevelFilter::from_str(level.log_filter()) + .unwrap_or_else(|_| panic!("`{}` is not a log filter", level.log_filter())); + let expected = match level { + Verbosity::Silent => log::LevelFilter::Off, + Verbosity::Error => log::LevelFilter::Error, + Verbosity::Warn => log::LevelFilter::Warn, + Verbosity::Info => log::LevelFilter::Info, + Verbosity::Debug => log::LevelFilter::Debug, + Verbosity::Trace => log::LevelFilter::Trace, + }; + assert_eq!(filter, expected, "{}", level.as_str()); + } + + // And the spec's own spelling of silence is the one that would not have worked. + assert!(log::LevelFilter::from_str(Verbosity::Silent.as_str()).is_err()); +} diff --git a/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap b/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap index 157000376..1d4d827c5 100644 --- a/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap +++ b/cli/tests/snapshots/manpage__generate_manpage_with_flags.snap @@ -22,73 +22,31 @@ https://asdf\-vm.com/ \fB\-C, \-\-cd\fR \fI\fR Change directory before running command .TP -\fB\-c, \-\-continue\-on\-error\fR -Continue running tasks even if one fails -.TP -\fB\-n, \-\-dry\-run\fR -Dry run, don't actually do anything -.TP \fB\-E, \-\-env\fR \fI\fR Set the environment for loading `mise..toml` .TP -\fB\-f, \-\-force\fR -Force the operation -.TP -\fB\-i, \-\-interleave\fR -Set the log output verbosity -.TP \fB\-j, \-\-jobs\fR \fI\fR How many jobs to run in parallel [default: 8] .TP -\fB\-p, \-\-prefix\fR -.TP \fB\-\-output\fR \fI\fR .TP -\fB\-P, \-\-profile\fR \fI\fR -Set the profile (environment) -.TP -\fB\-s, \-\-shell\fR \fI\fR -.TP -\fB\-t, \-\-tool\fR \fI\fR -Tool(s) to run in addition to what is in mise.toml files e.g.: node@20 python@3.10 -.TP \fB\-\-raw\fR Read/write directly to stdin/stdout/stderr instead of by line .TP -\fB\-\-timings\fR -Shows elapsed time after each task completes - -Default to always show with `MISE_TASK_TIMINGS=1` -.TP \fB\-\-no\-config\fR Do not load any config files Can also use `MISE_NO_CONFIG=1` .TP -\fB\-\-no\-timings\fR -Hides elapsed time after each task completes - -Default to always hide with `MISE_TASK_TIMINGS=0` -.TP -\fB\-V, \-\-version\fR -.TP \fB\-y, \-\-yes\fR Answer yes to all confirmation prompts .TP -\fB\-\-debug\fR -Sets log level to debug -.TP -\fB\-\-log\-level\fR \fI\fR -.TP \fB\-q, \-\-quiet\fR Suppress non\-error messages .TP \fB\-\-silent\fR Suppress all task output and mise non\-error messages .TP -\fB\-\-trace\fR -Sets log level to trace -.TP \fB\-v, \-\-verbose\fR Show extra output (use \-vv for even more) .SH ARGUMENTS @@ -487,12 +445,6 @@ Customize status output with `status` settings. \fBOptions:\fR .PP .TP -\fB\-s, \-\-shell\fR \fI\fR -Shell type to generate the script for -.TP -\fB\-\-status\fR -Show "mise: @" message when changing directories -.TP \fB\-\-shims\fR Use shims instead of modifying PATH Effectively the same as: @@ -611,13 +563,8 @@ e.g.: ruby@3 .SH "MISE CACHE CLEAR" Deletes all cache files in mise .PP -\fBUsage:\fR mise cache clear [OPTIONS] [] ... -.PP -\fBOptions:\fR +\fBUsage:\fR mise cache clear [] ... .PP -.TP -\fB\-\-outdate\fR -Mark all cache files as old \fBArguments:\fR .PP .TP @@ -652,17 +599,6 @@ Generate shell completions \fBOptions:\fR .PP .TP -\fB\-s, \-\-shell\fR \fI\fR -Shell type to generate completions for -.TP -\fB\-\-usage\fR -Always use usage for completions. -Currently, usage is the default for fish and bash but not zsh since it has a few quirks -to work out first. - -This requires the `usage` CLI to be installed. -https://usage.jdx.dev -.TP \fB\-\-include\-bash\-completion\-lib\fR Include the bash completion library in the bash completion script @@ -1108,8 +1044,6 @@ It's a useful command to get the current state of your tools. \fBOptions:\fR .PP .TP -\fB\-p, \-\-plugin\fR \fI\fR -.TP \fB\-c, \-\-current\fR Only show tool versions currently specified in a mise.toml .TP @@ -1200,11 +1134,6 @@ Manage plugins \fBOptions:\fR .PP .TP -\fB\-a, \-\-all\fR -list all available remote plugins - -same as `mise plugins ls\-remote` -.TP \fB\-c, \-\-core\fR The built\-in plugins only Normally these are not shown @@ -1218,10 +1147,6 @@ to show core and user plugins \fB\-u, \-\-urls\fR Show the git url for each plugin e.g.: https://github.com/asdf\-vm/asdf\-nodejs.git -.TP -\fB\-\-refs\fR -Show the git refs for each plugin -e.g.: main 1234abc .SH "MISE PLUGINS INSTALL" Install a plugin @@ -1287,24 +1212,9 @@ Can also show remotely available plugins to install. \fBOptions:\fR .PP .TP -\fB\-a, \-\-all\fR -List all available remote plugins -Same as `mise plugins ls\-remote` -.TP -\fB\-c, \-\-core\fR -The built\-in plugins only -Normally these are not shown -.TP -\fB\-\-user\fR -List installed plugins -.TP \fB\-u, \-\-urls\fR Show the git url for each plugin e.g.: https://github.com/asdf\-vm/asdf\-nodejs.git -.TP -\fB\-\-refs\fR -Show the git refs for each plugin -e.g.: main 1234abc .SH "MISE PLUGINS LS-REMOTE" List all available remote plugins @@ -1402,9 +1312,6 @@ For example, `poetry` is shorthand for `asdf:mise\-plugins/mise\-poetry`. \fB\-b, \-\-backend\fR \fI\fR Show only tools for this backend .TP -\fB\-\-complete\fR -Print all tools with descriptions for shell completions -.TP \fB\-\-hide\-aliased\fR Hide aliased tools \fBArguments:\fR @@ -1483,16 +1390,6 @@ Don't actually run the tasks(s), just print them in order of execution \fB\-f, \-\-force\fR Force the tasks to run even if outputs are up to date .TP -\fB\-p, \-\-prefix\fR -Print stdout/stderr by line, prefixed with the task's label -Defaults to true if \-\-jobs > 1 -Configure with `task_output` config or `MISE_TASK_OUTPUT` env var -.TP -\fB\-i, \-\-interleave\fR -Print directly to stdout/stderr instead of by line -Defaults to true if \-\-jobs == 1 -Configure with `task_output` config or `MISE_TASK_OUTPUT` env var -.TP \fB\-s, \-\-shell\fR \fI\fR Shell to use to run toml tasks @@ -1512,11 +1409,6 @@ Configure with `jobs` config or `MISE_JOBS` env var Read/write directly to stdin/stdout/stderr instead of by line Configure with `raw` config or `MISE_RAW` env var .TP -\fB\-\-timings\fR -Shows elapsed time after each task completes - -Default to always show with `MISE_TASK_TIMINGS=1` -.TP \fB\-\-no\-timings\fR Hides elapsed time after each task completes @@ -1580,16 +1472,8 @@ The TOML file to update Defaults to MISE_DEFAULT_CONFIG_FILENAME environment variable, or `mise.toml`. .TP -\fB\-\-complete\fR -Render completions -.TP \fB\-g, \-\-global\fR Set the environment variable in the global config file -.TP -\fB\-\-remove, \-\-rm, \-\-unset\fR \fI\fR -Remove the environment variable from config file - -Can be used multiple times. \fBArguments:\fR .PP .TP @@ -1612,9 +1496,6 @@ but managed separately with `mise aliases` \fB\-a, \-\-all\fR List all settings .TP -\fB\-\-complete\fR -Print all settings with descriptions for shell completions -.TP \fB\-l, \-\-local\fR Use the local config file instead of the global one .TP @@ -1691,9 +1572,6 @@ but managed separately with `mise aliases` \fB\-a, \-\-all\fR List all settings .TP -\fB\-\-complete\fR -Print all settings with descriptions for shell completions -.TP \fB\-l, \-\-local\fR Use the local config file instead of the global one .TP @@ -1832,9 +1710,6 @@ Manage tasks \fB\-\-no\-header\fR Do not print table header .TP -\fB\-\-complete\fR -Display tasks for usage completion -.TP \fB\-x, \-\-extended\fR Show all columns .TP @@ -1849,8 +1724,6 @@ Sort order. Default is asc. .TP \fB\-J, \-\-json\fR Output in JSON format -.TP -\fB\-\-usage\fR \fBArguments:\fR .PP .TP @@ -1982,9 +1855,6 @@ tasks will override the global ones if they have the same name. \fB\-\-no\-header\fR Do not print table header .TP -\fB\-\-complete\fR -Display tasks for usage completion -.TP \fB\-x, \-\-extended\fR Show all columns .TP @@ -1999,8 +1869,6 @@ Sort order. Default is asc. .TP \fB\-J, \-\-json\fR Output in JSON format -.TP -\fB\-\-usage\fR .SH "MISE TASKS RUN" Run task(s) @@ -2045,16 +1913,6 @@ Don't actually run the tasks(s), just print them in order of execution \fB\-f, \-\-force\fR Force the tasks to run even if outputs are up to date .TP -\fB\-p, \-\-prefix\fR -Print stdout/stderr by line, prefixed with the task's label -Defaults to true if \-\-jobs > 1 -Configure with `task_output` config or `MISE_TASK_OUTPUT` env var -.TP -\fB\-i, \-\-interleave\fR -Print directly to stdout/stderr instead of by line -Defaults to true if \-\-jobs == 1 -Configure with `task_output` config or `MISE_TASK_OUTPUT` env var -.TP \fB\-s, \-\-shell\fR \fI\fR Shell to use to run toml tasks @@ -2074,11 +1932,6 @@ Configure with `jobs` config or `MISE_JOBS` env var Read/write directly to stdin/stdout/stderr instead of by line Configure with `raw` config or `MISE_RAW` env var .TP -\fB\-\-timings\fR -Shows elapsed time after each task completes - -Default to always show with `MISE_TASK_TIMINGS=1` -.TP \fB\-\-no\-timings\fR Hides elapsed time after each task completes @@ -2367,13 +2220,6 @@ It must be installed for this command to work, but you can install it with `mise \fBOptions:\fR .PP .TP -\fB\-t, \-\-task\-flag\fR \fI\fR -Tasks to run -.TP -\fB\-g, \-\-glob\fR \fI\fR -Files to watch -Defaults to sources from the tasks(s) -.TP \fB\-w, \-\-watch\fR \fI\fR Watch a specific file or directory @@ -2905,8 +2751,6 @@ Use this to figure out what version of a tool is currently active. \fBOptions:\fR .PP .TP -\fB\-\-complete\fR -.TP \fB\-\-plugin\fR Show the plugin name instead of the path .TP diff --git a/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap b/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap index a2ed24fe2..92e231b88 100644 --- a/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap +++ b/cli/tests/snapshots/manpage__manpage_output_first_50_lines.snap @@ -22,33 +22,33 @@ https://asdf\-vm.com/ \fB\-C, \-\-cd\fR \fI\fR Change directory before running command .TP -\fB\-c, \-\-continue\-on\-error\fR -Continue running tasks even if one fails -.TP -\fB\-n, \-\-dry\-run\fR -Dry run, don't actually do anything -.TP \fB\-E, \-\-env\fR \fI\fR Set the environment for loading `mise..toml` .TP -\fB\-f, \-\-force\fR -Force the operation -.TP -\fB\-i, \-\-interleave\fR -Set the log output verbosity -.TP \fB\-j, \-\-jobs\fR \fI\fR How many jobs to run in parallel [default: 8] .TP -\fB\-p, \-\-prefix\fR -.TP \fB\-\-output\fR \fI\fR .TP -\fB\-P, \-\-profile\fR \fI\fR -Set the profile (environment) +\fB\-\-raw\fR +Read/write directly to stdin/stdout/stderr instead of by line +.TP +\fB\-\-no\-config\fR +Do not load any config files + +Can also use `MISE_NO_CONFIG=1` +.TP +\fB\-y, \-\-yes\fR +Answer yes to all confirmation prompts +.TP +\fB\-q, \-\-quiet\fR +Suppress non\-error messages .TP -\fB\-s, \-\-shell\fR \fI\fR +\fB\-\-silent\fR +Suppress all task output and mise non\-error messages .TP -\fB\-t, \-\-tool\fR \fI\fR -Tool(s) to run in addition to what is in mise.toml files e.g.: node@20 python@3.10 +\fB\-v, \-\-verbose\fR +Show extra output (use \-vv for even more) +.SH ARGUMENTS .TP +\fB\fR diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 0221202c7..a57d77453 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -17,6 +17,26 @@ 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="Force colored output" color=always conflicts=--no-color +flag --no-color help="Disable colored output" color=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..219d852c1 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_color() { + 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..d842a5cb1 --- /dev/null +++ b/conformance/tests/verbosity.rs @@ -0,0 +1,527 @@ +//! How much a CLI says, and whether it colors 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 color 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 color 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 plain switch carrying an explicit `default`, which is where "absent" and "said no" +/// are easiest to confuse: the parse output holds the default for a flag nobody typed. +#[derive(Cli)] +#[usage(bin = "defaulted", name = "defaulted")] +struct Defaulted { + /// Disable colored output + #[usage(long, global, color = "never", default = "false")] + no_color: bool, +} + +/// The roles a command gets from somewhere else: a group it flattens, which the derive now +/// lowers into a `flagset`. hk's shape, written once and given to several commands. +#[derive(usage_derive::Args)] +struct Loudness { + /// Enables verbose output + #[usage(long, short = 'v', global, count, verbosity = "verbose")] + verbose: u8, + /// Disable colored output + #[usage(long, global, color = "never")] + no_color: bool, +} + +#[derive(Cli)] +#[usage(bin = "borrowed", name = "borrowed")] +struct Borrowed { + #[usage(flatten)] + loudness: Loudness, + #[usage(long, global)] + jobs: Option, +} + +/// 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") + } + } + + impl TypedSpec for $ty { + fn typed_spec() -> &'static usage_argv::spec::Spec<'static> { + <$ty>::spec() + } + } + }; +} + +typed_parse!(Mise); +typed_parse!(Aube); +typed_parse!(Hk); +typed_parse!(Fnox); +typed_parse!(Paired); +typed_parse!(Defaulted); +typed_parse!(Borrowed); +typed_parse!(Tak); + +/// Both implementations, held to the same answer. +/// The color a command line asks for, read from argv rather than from a bound struct. +/// +/// The third implementation, and the one with the least to go on: help and diagnostics are +/// rendered where no struct was built, so this walks argv instead. Every case below holds it +/// to the same answer as the other two — it has drifted twice already, once on which flag +/// wins when two disagree and once on whether a declared default counts. +fn from_argv(argv: &[&str]) -> ColorChoice { + let words: Vec<&OsStr> = argv.iter().map(OsStr::new).collect(); + match usage_argv::policy::color_from_argv(T::typed_spec(), &words).unwrap_or_default() { + usage_argv::policy::ColorChoice::Auto => ColorChoice::Auto, + usage_argv::policy::ColorChoice::Always => ColorChoice::Always, + usage_argv::policy::ColorChoice::Never => ColorChoice::Never, + } +} + +/// The static spec a derived CLI carries, without naming each of them at every call site. +trait TypedSpec { + fn typed_spec() -> &'static usage_argv::spec::Spec<'static>; +} + +macro_rules! agree { + ($ty:ty, $kdl:expr, $argv:expr, $level:expr, $color:expr) => {{ + let argv: &[&str] = &$argv; + let want = ($level, $color); + assert_eq!(compiled::<$ty>(argv), want, "compiled: {argv:?}"); + assert_eq!(interpreted(&$kdl, argv), want, "interpreted: {argv:?}"); + assert_eq!(from_argv::<$ty>(argv), want.1, "from argv: {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_color_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_default_is_not_the_same_as_an_answer() { + let kdl = Defaulted::to_kdl(); + // The flag was not given. A plain switch has no way to say "no" — that is what a + // negation is for — so its `false`, default or otherwise, says nothing at all. + agree!(Defaulted, kdl, [], Verbosity::Info, ColorChoice::Auto); + // And when it is given, it says the one thing it can. + agree!( + Defaulted, + kdl, + ["--no-color"], + Verbosity::Info, + ColorChoice::Never + ); +} + +#[test] +fn a_flattened_group_answers_for_the_command_that_holds_it() { + // The declarations belong to the command, so its answer is theirs — and the group is + // emitted as a `flagset` the command `use`s, so this also holds the roles to surviving + // that indirection on the way into the spec and back out. + let kdl = Borrowed::to_kdl(); + assert!(kdl.contains("verbosity=verbose"), "{kdl}"); + assert!(kdl.contains("color=never"), "{kdl}"); + agree!(Borrowed, kdl, [], Verbosity::Info, ColorChoice::Auto); + agree!(Borrowed, kdl, ["-vv"], Verbosity::Trace, ColorChoice::Auto); + agree!( + Borrowed, + kdl, + ["--no-color"], + Verbosity::Info, + ColorChoice::Never + ); + // And a flag of the command's own moves nothing, which is the other half of "the + // parent's roles and the group's are the same set". + agree!( + Borrowed, + kdl, + ["--jobs", "4", "-v"], + Verbosity::Debug, + ColorChoice::Auto + ); +} + +#[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"); + // Counts and names first: `zip` stops at the shorter list, so a serialization that + // dropped the last flag would otherwise agree with itself about the ones left. + assert_eq!(spec.cmd.flags.len(), reparsed.cmd.flags.len(), "{rendered}"); + for (before, after) in spec.cmd.flags.iter().zip(reparsed.cmd.flags.iter()) { + assert_eq!(before.name, after.name, "{rendered}"); + 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..3a1e5d902 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(|_| { @@ -609,7 +610,11 @@ pub fn emit(cli: &Cli) -> TokenStream { if !__usage_warnings.is_empty() { ::std::eprint!( "{}", - usage_argv::render_warnings(&__usage_warnings), + usage_argv::render_warnings_for( + Self::spec(), + &__usage_argv, + &__usage_warnings, + ), ); } __usage_parsed @@ -770,8 +775,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 color 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 +1077,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, @@ -1320,7 +1335,11 @@ pub fn emit(cli: &Cli) -> TokenStream { if !__usage_warnings.is_empty() { ::std::eprint!( "{}", - usage_argv::render_warnings(&__usage_warnings), + usage_argv::render_warnings_for( + Self::spec(), + &__usage_argv, + &__usage_warnings, + ), ); } parsed @@ -2314,10 +2333,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 +4007,216 @@ 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), + ); + } + }); + + // A type that declares nothing and holds nothing that could answers with what it was + // handed. Most types in a CLI are this one, and the difference is not only the `Vec` + // it does not build: it is a function the linker can see through, rather than two + // dozen of them carrying tables that never say anything. + let verbosity_body = if verbosity_inputs.is_empty() && flattened.is_empty() { + quote!(base) + } else { + quote! { + #[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) + } + }; + let color_body = if color_inputs.is_empty() && flattened.is_empty() { + quote!(usage_argv::policy::ColorChoice::Auto) + } else { + quote! { + #[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)) + } + }; + quote! { + impl usage_argv::policy::VerbosityPolicy for #ident { + fn verbosity_from( + &self, + base: usage_argv::policy::Verbosity, + ) -> usage_argv::policy::Verbosity { + #verbosity_body + } + } + + impl usage_argv::policy::ColorPolicy for #ident { + fn color(&self) -> usage_argv::policy::ColorChoice { + #color_body + } + } + } +} + +/// 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 +5238,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 +5649,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { } #settings_defs + #policy impl usage_argv::spec::CommandArgs for #ident { type Partial = Partial; @@ -8216,6 +8465,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 +8510,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..6928c5b57 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 color, 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,160 @@ 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 color. The spec's `color=`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorRoleDecl { + /// This switch forces color; its negation forbids it. + Always, + /// This switch forbids color; 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" + ), + )); + } + }) +} + +/// Whether a word names a point on the verbosity scale. +/// +/// A copy of `usage_argv::policy::Verbosity::parse`, alias families included, because this +/// crate deliberately does not depend on the runtime it emits code for — see the note in +/// `Cargo.toml`. Kept honest by the spec, which makes the same check on the emitted KDL: +/// a list they disagreed about would fail to parse rather than pass silently. +fn names_a_level(word: &str) -> bool { + matches!( + word.to_ascii_lowercase().as_str(), + "silent" | "off" | "none" | "error" | "warn" | "warning" | "info" | "debug" | "trace" + ) +} + +/// What a flag means for color. +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 +2008,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 +2143,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 +2274,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 +2399,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 +2658,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 +2768,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 +3466,124 @@ 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\"`", + )); + } + // A declared list the scale cannot read would resolve to the baseline however + // the flag was invoked. The spec refuses it too; catching it here is what makes + // that a message pointing at the field rather than one about emitted KDL. Only + // a literal list can be checked — a `value_enum`'s words belong to a type this + // expansion cannot see — and only a strict one, since `strict=#false` expects + // words the list does not have. + if role.takes_value() && !allow_unknown_choices { + if let Some(unknown) = choices.iter().find(|choice| !names_a_level(choice)) { + return Err(syn::Error::new( + span, + format!( + "`verbosity = \"level\"` accepts {unknown:?}, which names no \ + level: expected one of silent, error, warn, info, debug, trace" + ), + )); + } + } + 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 color; 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 color + // 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 +3800,8 @@ impl Field { help_heading, display_order, effect, + verbosity, + color, value_name, value_names, required_collection, @@ -6850,6 +7142,125 @@ 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 level flag's declared values have to be levels. + let err = rejection( + r#" + struct Ex { + #[usage(long, verbosity = "level", choices("info", "chatty"))] + log_level: Option, + } + "#, + ); + assert!(err.contains("names no level"), "unhelpful: {err}"); + assert!(err.contains("chatty"), "unhelpful: {err}"); + + // A negatable color 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..14809ebfb 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,103 @@ "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": "Force colored output", + "help_first_line": "Force colored output", + "short": [], + "long": ["color"], + "hide": false, + "global": false, + "conflicts": ["--no-color"], + "color": "always" + }, + { + "name": "no-color", + "usage": "--no-color", + "help": "Disable colored output", + "help_first_line": "Disable colored output", + "short": [], + "long": ["no-color"], + "hide": false, + "global": false, + "color": "never" } ], "mounts": [], diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index d230312e4..3f0b5ac92 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,30 @@ 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**: forces colored output + +Force colored output + +### `--no-color` + +**Color**: disables colored output + +Disable colored output + ## Subcommands - [`usage bash [-h] [--help]