headwater_paint/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The palette [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
3//! rules on, and the pure functions every renderer applies it through.
4//!
5//! # Why this is a leaf crate and not a module of `headwater-check`
6//!
7//! These primitives lived in `headwater-check` until #479, beside
8//! `headwater_check::fill`, because the four crates that needed them then —
9//! `headwater-check` itself, `headwater-query`, `headwater-sweep` and
10//! `headwater-cli` — all sit above that crate and reach it without a cycle.
11//! That placement held for as long as every renderer that wanted color sat
12//! above `headwater-check`, and it stopped holding the moment one did not.
13//!
14//! `headwater-check` depends on `headwater-census`, `headwater-resolve` and
15//! `headwater-lock`. A renderer inside any of those three cannot name
16//! `headwater_check::paint` at all: cargo refuses the cycle before a line
17//! compiles. Six of the eleven command lines whose interface contract still
18//! promises terminal sensing render inside exactly those three crates —
19//! `headwater derived` in `headwater-census`, and `taxonomy validate`,
20//! `resolve`, `publish`, `vendor` and `migrate` in `headwater-resolve` and
21//! `headwater-lock`. Wiring any of them was not expensive, it was impossible,
22//! and nothing recorded that until the dependency graph was read against
23//! [HW-OBL-0180](../../../../docs/obligations/0180-a-renderer-s-color-mode-is-wired-at-a-call-site-that-no-type-forbids-from-being-wrong.md).
24//!
25//! So the palette moved to the bottom of the graph, where every renderer of
26//! this engine reaches it. This crate depends on nothing, for the reason
27//! `headwater-hash` and `headwater-mark` depend on nothing: a rule that two
28//! components on opposite sides of the engine both read must have one
29//! implementation, and a second copy of a palette is two palettes.
30//!
31//! # What stayed in `headwater-check`, and why
32//!
33//! `Severity` is a type of the check layer, so `glyph`, `severity_role` and
34//! `severity_word` stayed with it rather than dragging `Severity` down here
35//! behind them. `headwater_check::paint` re-exports everything below
36//! unchanged, so every caller that wrote `headwater_check::paint::Role` before
37//! the move still compiles — the same re-export chain
38//! `engine/crates/cli/src/paint.rs` already ran one level up.
39//!
40//! Deciding *whether* a stream renders color stays in `headwater-cli`:
41//! `stdout_color` and its `stderr` twin read the terminal a process is
42//! attached to, which is a fact about the binary rather than about a corpus.
43//!
44//! # `ColorMode` is a parameter, never a read
45//!
46//! Every function here is pure: given the same [`Role`] and the same
47//! [`ColorMode`], it returns the same bytes. Nothing here opens a stream or
48//! reads an environment variable, which is what makes every renderer that
49//! takes a mode unit-testable with a case table and no real terminal.
50//!
51//! That purity is also the defect [HW-OBL-0180](../../../../docs/obligations/0180-a-renderer-s-color-mode-is-wired-at-a-call-site-that-no-type-forbids-from-being-wrong.md)
52//! records: a renderer threaded with a mode and a call site that hands it
53//! [`ColorMode::Plain`] forever passes every headless test there is. What
54//! catches that is `tools/engine/color-fixtures.sh`, which attaches a
55//! pseudo-terminal and counts escape bytes, and nothing else in this
56//! repository can.
57//!
58//! # `Plain` writes no escape sequence, ever
59//!
60//! [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
61//! is explicit that the fallback is "no escape sequence at all", and
62//! `NO_COLOR_TEXT` (`engine/crates/cli/src/lib.rs`) promises the same thing to
63//! every caller of `--no-color`. So [`paint`] and [`dim`] write `text` back
64//! unchanged under [`ColorMode::Plain`].
65
66/// Whether a stream renders the palette below, or its plain-text fallback.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ColorMode {
69 Ansi,
70 Plain,
71}
72
73/// The mode `--no-color`, `NO_COLOR` and a stream's own terminal state come
74/// to, decided once so every caller reads the same answer the same way.
75///
76/// `--no-color` or a set `NO_COLOR` forces [`ColorMode::Plain`] regardless of
77/// `is_terminal`. There is no third state: a caller who wants color forced
78/// into a pipe has no lever here.
79#[must_use]
80pub fn color_of(no_color_flag: bool, no_color_env: bool, is_terminal: bool) -> ColorMode {
81 if no_color_flag || no_color_env {
82 return ColorMode::Plain;
83 }
84 match is_terminal {
85 true => ColorMode::Ansi,
86 false => ColorMode::Plain,
87 }
88}
89
90/// One semantic role [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)'s
91/// palette names.
92///
93/// `Verb` carries two rows of the decision's table at once — "a verb name or a
94/// `fix:` label" is one role, green and bold, so a second variant for the
95/// second noun would be a distinction the palette itself does not draw.
96/// `Heading` and structural or already-stated text are the two rows with no
97/// hue at all — a heading is bold in the default color and the other is dim in
98/// it — so the second reaches no variant here: [`dim`] is a sibling function
99/// rather than a seventh role.
100#[derive(Debug, Clone, Copy)]
101pub enum Role {
102 Error,
103 Warn,
104 Info,
105 /// A file path or a flag name.
106 Path,
107 /// A verb name or a `fix:` label.
108 Verb,
109 /// An obligation or adoption-task identifier (`OB-…`, `AD-…`).
110 Obligation,
111 /// A section heading.
112 Heading,
113}
114
115/// Every [`Role`], for a case table that would otherwise hand-keep its own copy
116/// of the variants.
117///
118/// [HW-OBL-0172](../../../../docs/obligations/0172-nine-hand-kept-constants-enumerate-an-enum-and-nothing-holds-one-against-the-variants.md)
119/// records the shape this is: a hand-typed array whose length is part of its
120/// type, so a new variant leaves it short and compiles. It ships discharged
121/// under that record's own Discharge clause — `roles_carries_every_variant_once`
122/// maps this array through an exhaustive `match` on [`Role`], so a variant
123/// added to the enum and left out of here stops the crate compiling. One held
124/// list replaces the two unheld ones the tests below used to type by hand, and
125/// a renderer's color case table reads this rather than growing a third.
126pub const ROLES: [Role; 7] = [
127 Role::Error,
128 Role::Warn,
129 Role::Info,
130 Role::Path,
131 Role::Verb,
132 Role::Obligation,
133 Role::Heading,
134];
135
136/// `text`, painted for `role` under `mode`.
137///
138/// `Ansi` writes the standard SGR codes the decision names, which are
139/// remapped by whatever theme the caller's terminal already runs — the reason
140/// the decision refuses a truecolor hex. `Plain` writes `text` back unchanged:
141/// no escape sequence, on the rule the module comment states.
142#[must_use]
143pub fn paint(role: Role, text: &str, mode: ColorMode) -> String {
144 let (open, close) = match (role, mode) {
145 (Role::Error, ColorMode::Ansi) => ("\x1b[1;31m", "\x1b[0m"),
146 (Role::Warn, ColorMode::Ansi) => ("\x1b[1;33m", "\x1b[0m"),
147 (Role::Info, ColorMode::Ansi) => ("\x1b[34m", "\x1b[0m"),
148 (Role::Path, ColorMode::Ansi) => ("\x1b[36m", "\x1b[0m"),
149 (Role::Verb, ColorMode::Ansi) => ("\x1b[1;32m", "\x1b[0m"),
150 (Role::Obligation, ColorMode::Ansi) => ("\x1b[35m", "\x1b[0m"),
151 (Role::Heading, ColorMode::Ansi) => ("\x1b[1m", "\x1b[0m"),
152 (_, ColorMode::Plain) => ("", ""),
153 };
154 format!("{open}{text}{close}")
155}
156
157/// Dim weight, the one part of the `Plain` fallback that is not color.
158#[must_use]
159pub fn dim(text: &str, mode: ColorMode) -> String {
160 match mode {
161 ColorMode::Ansi => format!("\x1b[2m{text}\x1b[0m"),
162 ColorMode::Plain => text.to_string(),
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::{color_of, dim, paint, ColorMode, Role, ROLES};
169
170 #[test]
171 fn color_is_plain_off_a_terminal_and_ansi_on_one_unless_overridden() {
172 assert_eq!(color_of(false, false, false), ColorMode::Plain);
173 assert_eq!(color_of(false, false, true), ColorMode::Ansi);
174 assert_eq!(
175 color_of(true, false, true),
176 ColorMode::Plain,
177 "--no-color wins"
178 );
179 assert_eq!(
180 color_of(false, true, true),
181 ColorMode::Plain,
182 "NO_COLOR wins"
183 );
184 assert_eq!(color_of(true, true, false), ColorMode::Plain);
185 }
186
187 /// `Plain` never writes an escape sequence, for every role.
188 #[test]
189 fn plain_writes_no_escape_sequence_for_any_role() {
190 for role in ROLES {
191 assert_eq!(paint(role, "text", ColorMode::Plain), "text");
192 }
193 assert_eq!(dim("text", ColorMode::Plain), "text");
194 }
195
196 /// `Ansi` wraps the text in an SGR pair that closes with a reset, and
197 /// changes no byte of the text itself.
198 #[test]
199 fn ansi_wraps_every_role_in_an_opening_and_a_reset() {
200 for role in ROLES {
201 let written = paint(role, "text", ColorMode::Ansi);
202 assert!(written.starts_with("\x1b["), "{written:?}");
203 assert!(written.ends_with("\x1b[0m"), "{written:?}");
204 assert!(written.contains("text"), "{written:?}");
205 }
206 let dimmed = dim("text", ColorMode::Ansi);
207 assert_eq!(dimmed, "\x1b[2mtext\x1b[0m");
208 }
209
210 /// [`ROLES`] carries every variant of [`Role`], once each.
211 ///
212 /// The `match` below is the discharge HW-OBL-0172 names, and it works at
213 /// compile time rather than here: a variant added to [`Role`] leaves this
214 /// `match` non-exhaustive and the crate stops building. What this case
215 /// itself adds is the other half — that no variant is written twice and
216 /// none is silently dropped for a duplicate.
217 #[test]
218 fn roles_carries_every_variant_once() {
219 let mut seen = [false; ROLES.len()];
220 for role in ROLES {
221 let index = match role {
222 Role::Error => 0,
223 Role::Warn => 1,
224 Role::Info => 2,
225 Role::Path => 3,
226 Role::Verb => 4,
227 Role::Obligation => 5,
228 Role::Heading => 6,
229 };
230 assert!(!seen[index], "{role:?} appears twice in ROLES");
231 seen[index] = true;
232 }
233 assert!(
234 seen.iter().all(|one| *one),
235 "ROLES misses a variant of Role: {seen:?}"
236 );
237 }
238}