Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
439a28a
feat(spec): declare verbosity and colour roles on flags
jdx Aug 21, 2026
0336d24
fix(spec): give the logger a word it takes for the silent level
jdx Aug 21, 2026
7555ba2
docs(spec): spell color American, and keep it off optional values
jdx Aug 21, 2026
89aab09
fix(manpage): stop publishing hidden flags in the manual
jdx Aug 21, 2026
8eab2e5
fix(spec): make the two color answers one answer
jdx Aug 21, 2026
c3e7e0a
style(derive): spell the lifetimes in generated policy locals
jdx Aug 21, 2026
a965295
perf(docs): skip the roles a flag does not carry
jdx Aug 21, 2026
e3f0ce8
perf(derive): no table for a type that declares no policy
jdx Aug 21, 2026
081b700
chore: regenerate usage's own spec for the version floor it now claims
jdx Aug 21, 2026
c17bfb8
test(spec): hold the roles a command borrows from a flagset
jdx Aug 21, 2026
d98cbb5
fix(manpage): stop advertising options a page will not list
jdx Aug 21, 2026
f4ab04c
fix(spec): let a declared default reach the color a page is painted in
jdx Aug 21, 2026
ce53829
docs(spec): say that a role needs a version floor, and that nothing a…
jdx Aug 21, 2026
880aefb
feat(cli): make usage's own color flag a pair of switches
jdx Aug 21, 2026
162cd29
docs(spec): record the perf cost this dogfood was accepted at
jdx Aug 21, 2026
baf0071
fix(spec): paint warnings, and read the environment, the same way
jdx Aug 21, 2026
24d611f
fix(spec): keep a step from reversing itself, and show the shape the …
jdx Aug 21, 2026
2adb089
fix(spec): paint a report from the words the parse walked, and read a…
jdx Aug 21, 2026
443e457
fix(spec): paint a view's failure from the command the view roots at
jdx Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 46 additions & 14 deletions argv/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

use core::fmt::Write as _;

use crate::policy::ColorChoice;
use crate::spec::{CommandMeta, FlagMeta, Spec, ViewMeta};
use crate::{Command, Error};

Expand All @@ -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
Expand Down Expand Up @@ -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>(
Expand Down Expand Up @@ -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 `<SUBCOMMAND>` 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.
Expand Down
40 changes: 34 additions & 6 deletions argv/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down
52 changes: 48 additions & 4 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pub mod run;
#[cfg(feature = "spec")]
pub mod spec;
Expand Down Expand Up @@ -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.
Expand All @@ -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,
)
Comment thread
cursor[bot] marked this conversation as resolved.
}

/// What a caller should print for a parse failure, without the renderer that makes it readable.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading