Skip to main content

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}