diff --git a/argv/src/embedded.rs b/argv/src/embedded.rs index 8296934fd..9ac07b20a 100644 --- a/argv/src/embedded.rs +++ b/argv/src/embedded.rs @@ -9,7 +9,7 @@ use std::ffi::OsStr; -use crate::help::{self, Page, Style}; +use crate::help::{self, Page, Palette, Style}; use crate::spec::Spec; use crate::{Command, Error}; @@ -92,6 +92,26 @@ pub fn outcome<'v, T>( ) } +/// [`outcome`] with a remapped semantic colour map. +/// +/// Colour still follows the destination stream. The same palette is applied to both. +pub fn outcome_paletted<'v, T>( + spec: &Spec<'_>, + root: &Command<'_>, + argv: &[&'v OsStr], + parse_from: impl FnOnce(&[&'v OsStr]) -> Result>, + palette: Palette, +) -> Outcome { + outcome_with_styles( + spec, + root, + argv, + parse_from, + Style::auto().palette(palette), + Style::auto_stderr().palette(palette), + ) +} + fn outcome_with_styles<'v, T>( spec: &Spec<'_>, root: &Command<'_>, diff --git a/argv/src/help.rs b/argv/src/help.rs index e11fba3a5..ef536a7a9 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -29,6 +29,57 @@ use crate::DoubleDash; mod template; pub use template::STYLES; +/// Semantic colours for a help page, named in the same vocabulary as `{$…}` tags. +/// +/// Each field is a style specification (`"cyan+bold"`, `"heading"`, `"bright-cyan"`). +/// Role names expand once: mapping `metavar` to `"heading"` uses the built-in +/// heading colour, not a remapped heading. The renderer still writes SGR directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Palette { + /// Headings such as `Usage:` and `Options:`. Default `"heading"` (yellow + bold). + pub heading: &'static str, + /// Flag and option literals. Default `"option"` (green + bold). + pub option: &'static str, + /// Metavariables such as ``. Default `"metavar"` (magenta + bold). + pub metavar: &'static str, + /// Subcommand names. Default `"command"` (green + bold). + pub command: &'static str, +} + +impl Palette { + /// The built-in mapping: each role paints as itself. + pub const DEFAULT: Palette = Palette { + heading: "heading", + option: "option", + metavar: "metavar", + command: "command", + }; + + /// Remap headings. + pub const fn heading(mut self, spec: &'static str) -> Self { + self.heading = spec; + self + } + + /// Remap option literals. + pub const fn option(mut self, spec: &'static str) -> Self { + self.option = spec; + self + } + + /// Remap metavariables. + pub const fn metavar(mut self, spec: &'static str) -> Self { + self.metavar = spec; + self + } + + /// Remap subcommand names. + pub const fn command(mut self, spec: &'static str) -> Self { + self.command = spec; + self + } +} + /// The indent a page uses where it cannot align to its column. const BLOCK_INDENT: usize = 4; const MIN_INLINE_HELP_WIDTH: usize = 30; @@ -186,7 +237,7 @@ impl Sections { /// A section that came out empty leaves no gap behind, which is what lets one template /// serve a whole CLI: see `usage::help_template::collapse_blank_runs`, whose rule this is. fn substituted(&self, template: &str, style: Style) -> String { - template::substitute(template, style.coloured, |name| self.named(name)) + template::substitute(template, style, |name| self.named(name)) } } @@ -258,22 +309,34 @@ fn strip_ansi_sequences(text: String) -> String { plain } -/// Whether help output is coloured. +/// Whether help output is coloured, and which colours the four semantic roles use. /// /// Plain rendering remains available for generated documents and snapshots; -/// process-facing help uses [`Style::auto`]. +/// process-facing help uses [`Style::auto`]. Remap roles with [`Style::palette`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Style { coloured: bool, + palette: Palette, } impl Style { /// Plain text, suitable for a pipe or a generated artifact. /// /// ANSI CSI escapes already present in authored help are removed as well. - pub const PLAIN: Style = Style { coloured: false }; + pub const PLAIN: Style = Style { + coloured: false, + palette: Palette::DEFAULT, + }; /// ANSI-coloured text, regardless of the output destination. - pub const COLOURED: Style = Style { coloured: true }; + pub const COLOURED: Style = Style { + coloured: true, + palette: Palette::DEFAULT, + }; + + /// Remap the four semantic roles. Plain rendering ignores the palette. + pub const fn palette(self, palette: Palette) -> Self { + Style { palette, ..self } + } /// Colour when stdout is a terminal and the environment permits it. pub fn auto() -> Style { @@ -300,19 +363,19 @@ impl Style { } fn heading(self, text: &str) -> String { - template::semantic("heading", text, self.coloured) + template::semantic("heading", text, self) } fn literal(self, text: &str) -> String { - template::semantic("option", text, self.coloured) + template::semantic("option", text, self) } fn metavar(self, text: &str) -> String { - template::semantic("metavar", text, self.coloured) + template::semantic("metavar", text, self) } fn command(self, text: &str) -> String { - template::semantic("command", text, self.coloured) + template::semantic("command", text, self) } /// Render the small Markdown vocabulary accepted in help prose. @@ -886,7 +949,7 @@ fn assembled_help( .help_template .filter(|template| !template.trim().is_empty()) { - Some(template) => template::substitute(template, style.coloured, |name| { + Some(template) => template::substitute(template, style, |name| { sections.named(name).map(|part| { styled_help( &part, @@ -4065,7 +4128,7 @@ mod style_tests { use super::{ commands_section, display_usage_masked, flag_notes, flag_usage, flat_commands_short, inline_environment_notes, long_help, render, render_styled, render_view_at_styled, - styled_flag_usage, styled_help, styled_inline, usage_line, wrap, Shown, Style, + styled_flag_usage, styled_help, styled_inline, usage_line, wrap, Palette, Shown, Style, }; use crate::spec::{ArgMeta, ClauseMeta, CommandMeta, Example, FlagMeta, Spec, ViewMeta}; use crate::{Arg, ArgAction, Clause, Command, Flag}; @@ -4826,6 +4889,55 @@ mod style_tests { ); } + #[test] + fn a_palette_remaps_metavar_colour_without_changing_plain_text() { + let cyan = Style::COLOURED.palette(Palette::DEFAULT.metavar("cyan+bold")); + assert_eq!( + styled_flag_usage("--output=", cyan), + "\u{1b}[1;32m--output\u{1b}[0m=\u{1b}[1;36m\u{1b}[0m" + ); + assert_eq!( + styled_flag_usage( + "--output=", + Style::PLAIN.palette(Palette::DEFAULT.metavar("cyan+bold")) + ), + "--output=" + ); + } + + #[test] + fn a_palette_remaps_template_role_tags() { + let command = Command { + name: "ex", + ..Command::EMPTY + }; + let root = CommandMeta { + cmd: &command, + ..CommandMeta::EMPTY + }; + let spec = Spec { + name: "ex", + help_template: Some("{$heading}CUSTOM HELP{/$}\n\n{{usage}}"), + root: &root, + ..Spec::EMPTY + }; + let cyan = Style::COLOURED.palette(Palette::DEFAULT.heading("cyan+bold")); + let coloured = render_styled(&spec, &command, false, cyan).expect("root page"); + assert!( + coloured.starts_with("\u{1b}[1;36mCUSTOM HELP\u{1b}[0m"), + "{coloured:?}" + ); + } + + #[test] + fn a_palette_expands_role_names_once() { + let palette = Palette::DEFAULT.heading("cyan+bold").metavar("heading"); + assert_eq!( + styled_flag_usage("", Style::COLOURED.palette(palette)), + "\u{1b}[1;33m\u{1b}[0m" + ); + } + #[test] fn metavar_scanning_handles_lowercase_capitalized_and_unicode_words() { assert_eq!( diff --git a/argv/src/help/template.rs b/argv/src/help/template.rs index e4edbb3b1..a72e452dd 100644 --- a/argv/src/help/template.rs +++ b/argv/src/help/template.rs @@ -4,6 +4,10 @@ //! come from KDL rather than a Rust string literal. The parser touches only the template itself; //! substituted help sections are opaque, so prose that happens to contain `{$red}` stays prose. +use std::borrow::Cow; + +use super::{Palette, Style}; + const MARK: char = '\u{2}'; const END: char = '\u{3}'; @@ -120,13 +124,41 @@ impl AnsiStyle { } } -pub(super) fn semantic(specification: &str, text: &str, coloured: bool) -> String { - if !coloured { +fn mapped_fragment(fragment: &str, palette: Palette) -> &str { + match fragment { + "heading" => palette.heading, + "option" => palette.option, + "metavar" => palette.metavar, + "command" => palette.command, + other => other, + } +} + +fn expand_spec(specification: &str, palette: Palette) -> Cow<'_, str> { + if specification + .split('+') + .all(|fragment| mapped_fragment(fragment, palette) == fragment) + { + return Cow::Borrowed(specification); + } + let mut out = String::with_capacity(specification.len()); + for (i, fragment) in specification.split('+').enumerate() { + if i > 0 { + out.push('+'); + } + out.push_str(mapped_fragment(fragment, palette)); + } + Cow::Owned(out) +} + +pub(super) fn semantic(specification: &str, text: &str, style: Style) -> String { + if !style.coloured { return text.to_string(); } + let specification = expand_spec(specification, style.palette); let mut out = String::with_capacity(text.len() + 16); AnsiStyle::default() - .apply(specification) + .apply(specification.as_ref()) .unwrap_or_default() .write(&mut out); out.push_str(text); @@ -177,7 +209,7 @@ pub(super) fn check(template: &str) -> Result<(), &'static str> { /// Substitute sections and render template-authored colour markup. pub(super) fn substitute( template: &str, - coloured: bool, + style: Style, mut section: impl FnMut(&str) -> Option, ) -> String { if check(template).is_err() { @@ -187,8 +219,8 @@ pub(super) fn substitute( let mut rest = template; loop { let placeholder = rest.find("{{").map(|at| (at, Event::Placeholder)); - let style = next_style_event(rest); - let Some((at, event)) = earliest(placeholder, style) else { + let tag = next_style_event(rest); + let Some((at, event)) = earliest(placeholder, tag) else { push_escaped(&mut marked, rest); break; }; @@ -234,7 +266,7 @@ pub(super) fn substitute( } } } - render_marked(&collapse_blank_runs(&marked), coloured) + render_marked(&collapse_blank_runs(&marked), style) } #[derive(Clone, Copy)] @@ -333,7 +365,7 @@ fn push_markers(out: &mut String, line: &str) { } } -fn render_marked(marked: &str, coloured: bool) -> String { +fn render_marked(marked: &str, style: Style) -> String { let mut out = String::with_capacity(marked.len()); let mut stack = vec![AnsiStyle::default()]; let mut rest = marked; @@ -341,7 +373,7 @@ fn render_marked(marked: &str, coloured: bool) -> String { push_content( &mut out, &rest[..at], - coloured, + style.coloured, stack.last().copied().unwrap_or_default(), ); let after = &rest[at + MARK.len_utf8()..]; @@ -357,21 +389,22 @@ fn render_marked(marked: &str, coloured: bool) -> String { }; let marker = &after[..end]; if let Some(specification) = marker.strip_prefix('+') { + let specification = expand_spec(specification, style.palette); let next = stack .last() .copied() .unwrap_or_default() - .apply(specification) + .apply(specification.as_ref()) .unwrap_or_default(); stack.push(next); - if coloured { + if style.coloured { next.write(&mut out); } } else { if stack.len() > 1 { stack.pop(); } - if coloured { + if style.coloured { AnsiStyle::default().write(&mut out); let parent = stack.last().copied().unwrap_or_default(); if parent.foreground.is_some() @@ -389,7 +422,7 @@ fn render_marked(marked: &str, coloured: bool) -> String { push_content( &mut out, rest, - coloured, + style.coloured, stack.last().copied().unwrap_or_default(), ); out @@ -476,15 +509,28 @@ fn push_content(out: &mut String, text: &str, coloured: bool, active: AnsiStyle) mod tests { use super::*; + fn paint( + template: &str, + coloured: bool, + section: impl FnMut(&str) -> Option, + ) -> String { + let style = if coloured { + Style::COLOURED + } else { + Style::PLAIN + }; + substitute(template, style, section) + } + #[test] fn nested_styles_restore_the_parent_and_plain_output_strips_tags() { let template = "{$red}before {$bold}strong{/$} after{/$}: {{usage}}"; assert_eq!( - substitute(template, false, |_| Some("Usage: ex".to_string())), + paint(template, false, |_| Some("Usage: ex".to_string())), "before strong after: Usage: ex" ); assert_eq!( - substitute(template, true, |_| Some("Usage: ex".to_string())), + paint(template, true, |_| Some("Usage: ex".to_string())), "\u{1b}[31mbefore \u{1b}[1;31mstrong\u{1b}[0m\u{1b}[31m after\u{1b}[0m: Usage: ex" ); } @@ -492,7 +538,7 @@ mod tests { #[test] fn markup_inside_a_substituted_section_is_opaque() { assert_eq!( - substitute("{$heading}Title{/$}\n{{about}}", false, |_| { + paint("{$heading}Title{/$}\n{{about}}", false, |_| { Some("The literal {$red} word".to_string()) }), "Title\nThe literal {$red} word" @@ -502,7 +548,7 @@ mod tests { #[test] fn an_inner_style_close_restores_the_template_style() { assert_eq!( - substitute("{$red}{{about}}{/$}", true, |_| { + paint("{$red}{{about}}{/$}", true, |_| { Some("before \u{1b}[36mcode\u{1b}[39m after".to_string()) }), "\u{1b}[31mbefore \u{1b}[36mcode\u{1b}[39m\u{1b}[31m after\u{1b}[0m" @@ -512,7 +558,7 @@ mod tests { #[test] fn style_only_lines_do_not_keep_an_empty_section_gap_open() { assert_eq!( - substitute( + paint( "{{usage}}\n\n{$red}{{args}}{/$}\n\n{{flags}}", false, |name| { @@ -542,20 +588,20 @@ mod tests { fn tags_on_lines_of_their_own_keep_a_balanced_style_stack() { let template = "{$heading}\nMY TOOL\n{/$}\n\n{{usage}}"; assert_eq!( - substitute(template, false, |_| Some("Usage: ex".to_string())), + paint(template, false, |_| Some("Usage: ex".to_string())), "MY TOOL\n\nUsage: ex" ); assert_eq!( - substitute(template, true, |_| Some("Usage: ex".to_string())), + paint(template, true, |_| Some("Usage: ex".to_string())), "\u{1b}[1;33mMY TOOL\u{1b}[0m\n\nUsage: ex" ); assert_eq!( - substitute("{$dim}fine print\n{/$}", true, |_| None), + paint("{$dim}fine print\n{/$}", true, |_| None), "\u{1b}[2mfine print\u{1b}[0m" ); assert_eq!( - substitute("before\n{$red}\nafter\n{/$}", false, |_| None), + paint("before\n{$red}\nafter\n{/$}", false, |_| None), "before\n\nafter" ); } @@ -563,15 +609,24 @@ mod tests { #[test] fn malformed_markup_is_literal_and_escaped_tags_can_be_documented() { assert_eq!( - substitute("before {$red and {{usage}}", true, |_| { + paint("before {$red and {{usage}}", true, |_| { Some("Usage: ex".to_string()) }), "before {$red and Usage: ex" ); assert_eq!( - substitute("{$$heading}literal{/$$}", true, |_| None), + paint("{$$heading}literal{/$$}", true, |_| None), "{$heading}literal{/$}" ); assert!(check("{$$heading}literal{/$$}").is_ok()); } + + #[test] + fn a_palette_remaps_role_tags_in_a_template() { + let style = Style::COLOURED.palette(Palette::DEFAULT.heading("cyan+bold")); + assert_eq!( + substitute("{$heading}Title{/$}", style, |_| None), + "\u{1b}[1;36mTitle\u{1b}[0m" + ); + } } diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 803840681..ff115ca16 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -1002,6 +1002,25 @@ pub fn emit(cli: &Cli) -> TokenStream { Self::parse_into_from, ) } + + /// [`Self::embedded_outcome_into`] with a remapped semantic colour map. + pub fn embedded_outcome_into_paletted( + argv: &[::std::ffi::OsString], + palette: usage_argv::help::Palette, + ) -> usage_argv::embedded::Outcome<#target> { + let __usage_refs: ::std::vec::Vec<&::std::ffi::OsStr> = + argv.iter().map(|arg| arg.as_os_str()).collect(); + #embedded_spec_request + #embedded_completion_request + #effective_spec + usage_argv::embedded::outcome_paletted( + __usage_spec, + Self::command(), + &__usage_refs, + Self::parse_into_from, + palette, + ) + } } }); @@ -1470,6 +1489,25 @@ pub fn emit(cli: &Cli) -> TokenStream { ) } + /// [`Self::embedded_outcome`] with a remapped semantic colour map. + pub fn embedded_outcome_paletted( + argv: &[::std::ffi::OsString], + palette: usage_argv::help::Palette, + ) -> usage_argv::embedded::Outcome { + let __usage_refs: ::std::vec::Vec<&::std::ffi::OsStr> = + argv.iter().map(|arg| arg.as_os_str()).collect(); + #embedded_spec_request + #embedded_completion_request + #effective_spec + usage_argv::embedded::outcome_paletted( + __usage_spec, + Self::command(), + &__usage_refs, + Self::parse_from, + palette, + ) + } + #embedded_outcome_into #settings_binding_forward diff --git a/docs/rust/help.md b/docs/rust/help.md index baf8e1298..80c1b3099 100644 --- a/docs/rust/help.md +++ b/docs/rust/help.md @@ -80,6 +80,21 @@ a CLI declaring [`try_into`](/rust/validation#cross-field-validation-and-typed-f rendered as the same failure response a parse error produces. A host that finalizes some other way can convert a parsed value in place with `Outcome::map`. +`embedded_outcome_paletted` / `embedded_outcome_into_paletted` remap the four semantic +roles with a `Palette` — still the `{$…}` tag vocabulary, still SGR. Colour follows each +destination stream the way `embedded_outcome` already does: + +```rust +let palette = usage::help::Palette::DEFAULT.metavar("cyan+bold"); +match Ex::embedded_outcome_paletted(&argv, palette) { + usage::embedded::Outcome::Parsed(cli) => run(cli), + usage::embedded::Outcome::Exit(exit) => host.respond(exit), +} +``` + +Role names in a palette spec expand once, so mapping `metavar` to `"heading"` uses the built-in +heading colour rather than a remapped heading. `parse()` is unchanged. + ### Deprecation warnings A `deprecated` flag or command, or a value that arrived through a `deprecated_env` alias, is @@ -217,7 +232,9 @@ Template-authored text and whole sections may be styled with runtime, bunt-like An opening `{$…}` tag applies until its matching `{/$}` and tags may nest. Join styles with `+`, as in `{$bold+bright-blue}`. `heading`, `option`, `metavar`, and `command` use usage's semantic palette. Headings are bold yellow by default, options and commands are bold green, and -metavariables are bold magenta. The physical vocabulary contains `black`, `red`, `green`, `yellow`, +metavariables are bold magenta. A host that owns the exit path can remap those four roles with +a `Palette`; see [Embedding without exiting](#embedding-without-exiting). +The physical vocabulary contains `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, and `white`; each `bright-` variant; and `bold`, `dim`, `italic`, and `underline`. Terminal output renders the ANSI styles. Plain output and generated Go pages remove the tags while retaining their contents. Double the dollar sign to write either delimiter