headwater_cli/paint.rs
1// SPDX-License-Identifier: Apache-2.0
2//! How wide the help is, and who lays it out.
3//!
4//! # `clap` cannot wrap in this workspace, and `term_width` will not make it
5//!
6//! `StyledStr::wrap` is `pub(crate) fn wrap(&mut self, _hard_width: usize) {}`
7//! under `#[cfg(not(feature = "wrap_help"))]`, and this workspace takes `clap`
8//! with `derive` alone. So `Command::term_width` sets a number every renderer
9//! reads and nothing acts on, and every help string reached a caller on one
10//! line however long it was — 1,126 columns at the widest.
11//!
12//! Taking the `wrap_help` feature is the route the crate offers and it is the
13//! wrong one here. It pulls `terminal_size`, which measures the terminal the
14//! process is attached to, and a width that depends on the terminal makes a
15//! piped run and a run under a terminal write different bytes. Every recorded
16//! fixture and every test that reads this help would then be reading the
17//! terminal of whoever ran it.
18//!
19//! So the strings are folded here, before `clap` sees them, at a width this
20//! module decides. Nothing in the path reads a terminal: `dimensions()` is
21//! `(None, None)` without `wrap_help`, so `clap` asks no question about the
22//! stream it is writing to, and neither does this.
23//!
24//! # The one indent, and how it is known
25//!
26//! [`painted`] declares `next_line_help` on every command of the tree, which
27//! puts an argument's help on the line under the argument rather than in a
28//! column whose width is a function of the longest argument at that node. The
29//! indent is then `TAB` plus `NEXT_LINE_INDENT` — two spaces and eight — at
30//! every node, so [`INDENT`] is a constant rather than a computation, and one
31//! folded string is right wherever `clap` decides to print it.
32//!
33//! # What `clap` appends after a help string, and why folding has to know
34//!
35//! `HelpTemplate::help` writes the string this module folded and then appends
36//! the spec values — `[default: 0]`, `[possible values: …]`, `[aliases: …]` —
37//! on the same line after a space. A fold that did not account for them would
38//! be right about the text and wrong about the line. [`reserved`] measures what
39//! is coming and [`fold_at`] keeps the last word of the text and that suffix on
40//! one line together.
41//!
42//! # Color reads the terminal on purpose, and the masthead is why it must
43//!
44//! [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
45//! departs from the rule two sections up, deliberately: an escape sequence
46//! leaked into a pipe or a log file actively harms whoever reads it, where a
47//! column-wrap choice never did, and every fixture this corpus pins already
48//! runs headless. So [`color_of`] is a pure function in exactly the shape
49//! [`width_of`] already is — unit-testable with a table and no real terminal —
50//! and its one live caller, [`stdout_color`] or [`stderr_color`], reads a
51//! stream's own terminal state, which nothing above this line ever does.
52//!
53//! `--no-color`, `--no-banner` and their environment variables are read the
54//! way `--wide` already is: scanned raw, before `clap` builds the tree,
55//! because [`banner`] runs inside `first_screen`, which is built before
56//! parsing runs.
57
58use clap::{Arg, ArgAction, Command};
59
60/// The width and the fill, which live in `headwater_check::fill` and are named
61/// here so that a caller of this module keeps writing `paint::WIDTH`.
62///
63/// They moved down when the check report gained a layout: the report and the
64/// help are laid out by one implementation, and `headwater-check` is the crate
65/// every report composer can reach. Nothing is re-implemented here.
66pub use headwater_check::fill::{fold, fold_at, WIDEST, WIDTH};
67
68/// The column an argument's help starts at, at every node of the tree.
69///
70/// `clap`'s `TAB` is two spaces and its `NEXT_LINE_INDENT` is eight, and
71/// [`painted`] declares `next_line_help` everywhere so that the pair is the
72/// whole indent at every node.
73pub const INDENT: usize = 10;
74
75/// The width this process lays the help out at.
76///
77/// `COLUMNS` is read here and nowhere else in this binary, and only when the
78/// command line carries `--wide`. The scan is over the raw arguments because
79/// the answer is needed to build the tree that parses them: `clap` renders help
80/// inside the parse, out of strings that were folded before it started.
81pub fn width() -> usize {
82 // The variable is read inside the `true` arm rather than beside the scan,
83 // so that a run with no `--wide` on its command line makes no call at all.
84 // Two interface contracts say what reaches this binary out of the
85 // environment, and the honest sentence is shorter for it.
86 match std::env::args_os().any(|one| one == "--wide") {
87 false => WIDTH,
88 true => width_of(true, std::env::var("COLUMNS").ok().as_deref()),
89 }
90}
91
92/// The width a `--wide` and a `COLUMNS` reading come to.
93///
94/// Without `--wide` the answer is [`WIDTH`] and the reading is not consulted, so
95/// a run in a 40-column terminal and a run piped into a file write the same
96/// bytes. With it the reading is held to `[WIDTH, WIDEST]`: a narrower terminal
97/// gets 80 because the text was written to be read at 80, and a wider one gets
98/// 120 because a line of prose past that is harder to read rather than easier.
99/// A `COLUMNS` that is absent or is not a number is the same answer as no
100/// `--wide` at all.
101pub fn width_of(wide: bool, columns: Option<&str>) -> usize {
102 if !wide {
103 return WIDTH;
104 }
105 match columns.and_then(|text| text.trim().parse::<usize>().ok()) {
106 Some(number) => number.clamp(WIDTH, WIDEST),
107 None => WIDTH,
108 }
109}
110
111/// The tree, with every string folded and every node laid out the same way.
112///
113/// It walks the whole tree rather than the verbs, so a second word and an
114/// argument `clap` propagated are folded by the same code as a verb, and an
115/// argument added later is folded without anybody remembering to.
116pub fn painted(command: Command, width: usize) -> Command {
117 let mut one = command.next_line_help(true);
118 if let Some(about) = one.get_about().map(ToString::to_string) {
119 one = one.about(fold(&about, width));
120 }
121 one = one.mut_args(|arg| {
122 let Some(help) = arg.get_help().map(ToString::to_string) else {
123 return arg;
124 };
125 let room = width.saturating_sub(INDENT);
126 let tail = reserved(&arg);
127 let folded = fold_at(&help, room, tail);
128 arg.help(folded)
129 });
130 let names: Vec<String> = one
131 .get_subcommands()
132 .map(|inner| inner.get_name().to_string())
133 .collect();
134 for name in names {
135 one = one.mut_subcommand(name, |inner| painted(inner, width));
136 }
137 one
138}
139
140/// The same tree with every string put back on one line.
141///
142/// # What this undoes, and for whom
143///
144/// [`painted`] folds every `about` and every `help` in the tree, and a help
145/// screen is what that is for. A completion script is the other reader of the
146/// same tree and it wants the opposite: a shell shows a description in a
147/// listing it lays out itself, so a fold this engine chose arrives as a break
148/// at a width the shell did not pick. `main`'s `completions` runs this over the
149/// painted tree before handing it to `clap_complete`.
150///
151/// Folding and then flattening returns the source string, because [`fold_at`]
152/// breaks only at a space and rejoins words with one space. So the flag and
153/// subcommand descriptions do not move a byte, and only the positionals change
154/// — which is where the whole defect was.
155///
156/// # Why here rather than a tree built at no width
157///
158/// `command_at(usize::MAX)` folds nothing and produces the same bytes today,
159/// and it is the wrong answer. [`fold_at`]'s own contract is that "a newline in
160/// the source is a break the author asked for and survives", so that route
161/// leaves a completion script one authored newline in one help string away
162/// from the defect returning. This one removes the newline whatever put it
163/// there.
164///
165/// # Why the tree rather than the `zsh` writer
166///
167/// `clap_complete` flattens a flag and a subcommand description and does not
168/// flatten a positional one: its `zsh` generator escapes a positional by hand
169/// rather than through the `escape_help` its other two paths use, and that
170/// hand-written chain omits the newline. Its `bash`, `fish` and PowerShell
171/// generators write no positional description at all, so those three are clean
172/// for a reason this repository does not control. Flattening the tree is
173/// therefore the fix that survives a dependency bump, and
174/// `engine/crates/cli/tests/completions.rs` reads all four scripts for the same
175/// reason.
176pub fn flattened(command: Command) -> Command {
177 let mut one = command;
178 if let Some(about) = one.get_about().map(ToString::to_string) {
179 one = one.about(one_line(&about));
180 }
181 one = one.mut_args(|arg| {
182 let Some(help) = arg.get_help().map(ToString::to_string) else {
183 return arg;
184 };
185 arg.help(one_line(&help))
186 });
187 let names: Vec<String> = one
188 .get_subcommands()
189 .map(|inner| inner.get_name().to_string())
190 .collect();
191 for name in names {
192 one = one.mut_subcommand(name, flattened);
193 }
194 one
195}
196
197/// The words of a string, on one line, separated by one space each.
198///
199/// It splits on whitespace rather than replacing the newline, so a fold that
200/// left a space before its break cannot leave two spaces behind. Every help
201/// string of this binary is written as one logical line of single-spaced words,
202/// so over a folded string this returns the source exactly.
203fn one_line(text: &str) -> String {
204 text.split_whitespace().collect::<Vec<&str>>().join(" ")
205}
206
207/// The width of what `clap` appends after an argument's help, with its space.
208///
209/// It is the `spec_vals` of `HelpTemplate`, measured rather than rendered. The
210/// `env` feature is not compiled in, so the environment-variable clause of that
211/// function cannot occur here and is not measured. Every other clause is, and
212/// the whole surface is held to [`WIDTH`] by `tests/width.rs`, so a clause
213/// measured wrong is reported as a wide line rather than passing quietly.
214fn reserved(arg: &Arg) -> usize {
215 let mut parts: Vec<usize> = Vec::new();
216
217 // `clap` prints a default and a possible-value set for an argument that
218 // takes a value and for no other. A flag declared `SetTrue` carries
219 // `true`/`false` on its value parser and `false` as its default, and
220 // neither reaches a caller. `get_num_args` is `None` until
221 // `Command::build` and this runs before that, so the action is what
222 // answers here. An alias is printed for a flag as well and is not gated.
223 let takes_a_value = matches!(arg.get_action(), ArgAction::Set | ArgAction::Append);
224
225 let defaults = arg.get_default_values();
226 if takes_a_value && !defaults.is_empty() && !arg.is_hide_default_value_set() {
227 let written: Vec<String> = defaults
228 .iter()
229 .map(|value| value.to_string_lossy().into_owned())
230 .collect();
231 parts.push("[default: ]".chars().count() + written.join(" ").chars().count());
232 }
233
234 let mut aliases: Vec<usize> = Vec::new();
235 aliases.extend(
236 arg.get_visible_short_aliases()
237 .unwrap_or_default()
238 .iter()
239 .map(|_| 2),
240 );
241 aliases.extend(
242 arg.get_visible_aliases()
243 .unwrap_or_default()
244 .iter()
245 .map(|name| 2 + name.chars().count()),
246 );
247 if !aliases.is_empty() {
248 let plural = if aliases.len() == 1 { 0 } else { 2 };
249 let separators = 2 * (aliases.len() - 1);
250 parts.push(
251 "[alias: ]".chars().count() + plural + separators + aliases.iter().sum::<usize>(),
252 );
253 }
254
255 if takes_a_value && !arg.is_hide_possible_values_set() {
256 // `get_visible_quoted_name` is `clap`'s and is private, so its two
257 // rules are read off it here: a hidden value is not printed, and a name
258 // holding a space is printed in quotes.
259 let possible: Vec<usize> = arg
260 .get_possible_values()
261 .iter()
262 .filter(|value| !value.is_hide_set())
263 .map(|value| {
264 let name = value.get_name();
265 let quotes = usize::from(name.contains(char::is_whitespace)) * 2;
266 name.chars().count() + quotes
267 })
268 .collect();
269 if !possible.is_empty() {
270 let separators = 2 * (possible.len() - 1);
271 parts.push(
272 "[possible values: ]".chars().count() + separators + possible.iter().sum::<usize>(),
273 );
274 }
275 }
276
277 match parts.is_empty() {
278 true => 0,
279 // `spec_vals` joins its parts with one space, and one more space
280 // separates the whole of it from the help text before it.
281 false => parts.iter().sum::<usize>() + parts.len(),
282 }
283}
284
285/// A block of text folded to `width` and indented, with its closing newline.
286///
287/// The whole block is indented, first line included, which is what separates it
288/// from [`row`]: a row hangs under a name and this stands under a heading.
289pub fn fold_indented(text: &str, width: usize, at: usize) -> String {
290 let indent = " ".repeat(at);
291 let folded = fold(text, width.saturating_sub(at));
292 let body = folded.replace('\n', &format!("\n{indent}"));
293 format!("{indent}{body}\n")
294}
295
296/// One row of a two-column list, folded so that no line passes `width`.
297///
298/// `at` is the column the second field starts at. A row whose text does not fit
299/// is continued under itself rather than under the name, which is what keeps a
300/// list of verbs readable when one summary is long.
301pub fn row(name: &str, text: &str, at: usize, width: usize) -> String {
302 let pad = at.saturating_sub(2 + name.chars().count()).max(1);
303 let folded = fold(text, width.saturating_sub(at));
304 let indent = " ".repeat(at);
305 let body = folded.replace('\n', &format!("\n{indent}"));
306 format!(" {name}{}{body}\n", " ".repeat(pad))
307}
308
309/// [`ColorMode`], [`Role`], [`color_of`], [`paint`] and [`dim`] moved to
310/// `headwater_check::paint`, and are re-exported here unchanged.
311///
312/// `Finding::render`, `Run::render`, `explain`'s renderer and the two sweep
313/// renderers all need them and none of the three crates that declare those
314/// functions may depend on `headwater-cli`, so the types live where every
315/// caller can reach them — see the module comment of
316/// `engine/crates/check/src/paint.rs` for the full reasoning. What stays here
317/// is [`stdout_color`] and [`stderr_color`]: the one fact only this crate
318/// holds, which stream this process is attached to.
319pub use headwater_check::paint::{color_of, dim, glyph, paint, severity_role, severity_word};
320pub use headwater_check::paint::{ColorMode, Role};
321
322/// Whether `--no-color` is on the raw command line, scanned the way
323/// [`width`] scans for `--wide`.
324fn no_color_flag() -> bool {
325 std::env::args_os().any(|one| one == "--no-color")
326}
327
328/// `NO_COLOR`'s convention: any value at all, including an empty one, turns
329/// color off. `tests/width.rs` asserts this over `NO_COLOR=1`, `NO_COLOR=` and
330/// `NO_COLOR=0` alike.
331fn no_color_env() -> bool {
332 std::env::var_os("NO_COLOR").is_some()
333}
334
335/// The mode standard output renders in, for this run of the binary.
336#[must_use]
337pub fn stdout_color() -> ColorMode {
338 color_of(
339 no_color_flag(),
340 no_color_env(),
341 std::io::IsTerminal::is_terminal(&std::io::stdout()),
342 )
343}
344
345/// The mode standard error renders in, for this run of the binary.
346#[must_use]
347pub fn stderr_color() -> ColorMode {
348 color_of(
349 no_color_flag(),
350 no_color_env(),
351 std::io::IsTerminal::is_terminal(&std::io::stderr()),
352 )
353}
354
355/// Whether `--no-banner` or `HEADWATER_NO_BANNER` suppress the masthead,
356/// scanned the way [`no_color_flag`] and `NO_COLOR` are.
357#[must_use]
358pub fn banner_suppressed() -> bool {
359 std::env::args_os().any(|one| one == "--no-banner")
360 || std::env::var_os("HEADWATER_NO_BANNER").is_some()
361}
362
363/// Whether the raw command line asks for the root help screen: `-h` or
364/// `--help` present, and no token that names a verb.
365///
366/// Read the way [`no_color_flag`] is, before `clap` decides anything, because
367/// the masthead is printed by plain I/O ahead of `clap`'s own help writer
368/// rather than inside the template it renders — see `first_screen`'s doc
369/// comment for why a template cannot carry it. A `--root <path>` whose value
370/// happens to equal a verb's name is the one case this reads wrong, and it
371/// costs a missing masthead rather than a wrong screen: `clap` still resolves
372/// the command line the same way regardless of what this function returns.
373#[must_use]
374pub fn wants_root_help() -> bool {
375 let mut has_help = false;
376 let mut has_verb = false;
377 for one in std::env::args_os().skip(1) {
378 if one == "-h" || one == "--help" {
379 has_help = true;
380 }
381 if one
382 .to_str()
383 .is_some_and(|text| headwater_verbs::VERBS.iter().any(|verb| verb.name == text))
384 {
385 has_verb = true;
386 }
387 }
388 has_help && !has_verb
389}
390
391/// The masthead `HW-DR-0045` rules on, or today's plain name line where
392/// [`banner_suppressed`] holds.
393///
394/// `version` is `headwater_resolve::release::ENGINE`, the same value
395/// `--version` prints, so a caller never reads two numbers for one binary.
396/// The blank line closing the string is the one `first_screen` used to open
397/// with, folded in here so the root screen keeps the same shape either way.
398#[must_use]
399pub fn banner(version: &str, mode: ColorMode) -> String {
400 let tagline = "a documentation corpus, governed and checked like code";
401 if banner_suppressed() {
402 return format!("headwater — {tagline}\n\n");
403 }
404 let name = paint(Role::Verb, &format!("headwater {version}"), mode);
405 let rule = dim(&"─".repeat(WIDTH), mode);
406 format!("{name} — {}\n{rule}\n\n", dim(tagline, mode))
407}
408
409#[cfg(test)]
410mod tests {
411 use super::{
412 banner, color_of, fold, fold_at, fold_indented, row, width_of, ColorMode, INDENT, WIDEST,
413 WIDTH,
414 };
415
416 #[test]
417 fn color_is_plain_off_a_terminal_and_ansi_on_one_unless_overridden() {
418 assert_eq!(color_of(false, false, false), ColorMode::Plain);
419 assert_eq!(color_of(false, false, true), ColorMode::Ansi);
420 assert_eq!(
421 color_of(true, false, true),
422 ColorMode::Plain,
423 "--no-color wins"
424 );
425 assert_eq!(
426 color_of(false, true, true),
427 ColorMode::Plain,
428 "NO_COLOR wins"
429 );
430 assert_eq!(color_of(true, true, false), ColorMode::Plain);
431 }
432
433 #[test]
434 fn the_masthead_names_the_version_once_above_a_rule_of_the_help_width() {
435 let text = banner("9.9.9", ColorMode::Plain);
436 let mut lines = text.lines();
437 assert_eq!(
438 lines.next(),
439 Some("headwater 9.9.9 — a documentation corpus, governed and checked like code")
440 );
441 let rule = lines.next().expect("a rule line follows");
442 assert_eq!(rule.chars().count(), WIDTH);
443 assert!(rule.chars().all(|c| c == '─'));
444 }
445
446 #[test]
447 fn nothing_reads_columns_until_a_caller_asks_for_it() {
448 assert_eq!(width_of(false, Some("500")), WIDTH);
449 assert_eq!(width_of(false, Some("40")), WIDTH);
450 assert_eq!(width_of(false, None), WIDTH);
451 }
452
453 #[test]
454 fn a_width_a_caller_asks_for_is_held_to_the_band() {
455 assert_eq!(width_of(true, Some("40")), WIDTH);
456 assert_eq!(width_of(true, Some("100")), 100);
457 assert_eq!(width_of(true, Some("500")), WIDEST);
458 assert_eq!(width_of(true, Some("80")), WIDTH);
459 assert_eq!(width_of(true, Some("120")), WIDEST);
460 }
461
462 /// A reading that is not a number is the width every other run takes.
463 #[test]
464 fn a_columns_that_is_not_a_number_is_the_default_width() {
465 assert_eq!(width_of(true, None), WIDTH);
466 assert_eq!(width_of(true, Some("")), WIDTH);
467 assert_eq!(width_of(true, Some("wide")), WIDTH);
468 assert_eq!(width_of(true, Some("-1")), WIDTH);
469 }
470
471 /// The fill this module re-exports is the one in `headwater-check`.
472 ///
473 /// Its own cases live beside it, in `crates/check/src/fill.rs`. This one
474 /// holds the re-export: a second implementation appearing here would pass
475 /// every case there and lay the help out differently.
476 #[test]
477 fn the_fold_this_module_names_is_the_one_the_check_layer_owns() {
478 let text = "see docs/spec/06-engine-architecture.md#the-command-line for it";
479 assert_eq!(fold(text, 20), headwater_check::fill::fold(text, 20));
480 assert_eq!(
481 fold_at(text, 20, 6),
482 headwater_check::fill::fold_at(text, 20, 6)
483 );
484 assert_eq!(WIDTH, headwater_check::fill::WIDTH);
485 assert_eq!(WIDEST, headwater_check::fill::WIDEST);
486 }
487
488 #[test]
489 fn a_row_that_does_not_fit_is_continued_under_itself() {
490 let written = row(
491 "check",
492 "run the pipeline over the corpus, against the lock",
493 15,
494 40,
495 );
496 let lines: Vec<&str> = written.trim_end().lines().collect();
497 assert_eq!(lines[0], " check run the pipeline over the");
498 for line in &lines[1..] {
499 assert!(line.starts_with(&" ".repeat(15)), "{line:?}");
500 }
501 for line in &lines {
502 assert!(line.chars().count() <= 40, "{line:?}");
503 }
504 }
505
506 #[test]
507 fn an_indented_block_holds_every_line_inside_the_width() {
508 let written = fold_indented("run the checks, and fail on an error", 20, 6);
509 assert!(written.ends_with('\n'));
510 for line in written.lines() {
511 assert!(line.starts_with(" "), "{line:?}");
512 assert!(line.chars().count() <= 20, "{line:?}");
513 }
514 }
515
516 /// The indent is `clap`'s two-space `TAB` and its eight-space next-line
517 /// indent, and the whole point of declaring `next_line_help` is that it is
518 /// the same number at every node.
519 #[test]
520 fn the_indent_is_the_pair_clap_writes() {
521 assert_eq!(INDENT, " ".len() + " ".len());
522 }
523}