Skip to main content

headwater_check/
fill.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The one fill in this engine, and the width every report is laid out at.
3//!
4//! # Why it lives here
5//!
6//! `engine/crates/conformance/src/render.rs` carried the only fill in the tree
7//! and the instruction to move it: "when a later change fills the check report,
8//! `WIDTH` and `block` move down into `headwater-check` and this crate calls
9//! them there". This is that move. `headwater-conformance`, `headwater-adapter`
10//! and `headwater-cli` all depend on this crate and this crate depends on none
11//! of them, so one implementation is reachable from every report and a second
12//! copy is not needed.
13//!
14//! # The three shapes a caller wants, and the difference between them
15//!
16//! [`fold`] and [`fold_at`] fill a run of prose that carries no indent of its
17//! own. `headwater_cli::paint` folds a help string with them before `clap` sees
18//! it, and [`fold_at`] leaves room on the last line for the `[default: 0]` that
19//! `clap` appends after it.
20//!
21//! [`block`] writes one labelled block: `indent` spaces, a label, and the text
22//! filled under it, with a continuation hanging at the width of the label. A
23//! caller that is composing a report line by line reaches for this.
24//!
25//! [`filled`] lays out a report that is already composed. It reads each line's
26//! own leading spaces as that line's indent, fills the rest, and hangs a
27//! continuation two columns further in. A caller that is handed the finished
28//! text of a foreign renderer — which is what `headwater_adapter::text` is
29//! handed three times — reaches for this, because the alternative is a width
30//! parameter threaded through every renderer below it.
31//!
32//! # What no fill here ever does
33//!
34//! **Nothing breaks inside a word.** A word longer than the room it lands in is
35//! written past the width, whole. A digest, a path, a rule name and a flag name
36//! all arrive here as one word, and every one of them is a thing a caller
37//! retypes or a thing another program matches on: `headwater_adapter::census`
38//! audits every format by `artifact.contains(path)`, so a path broken across a
39//! fold point would make a clean run fail its own projection census.
40//!
41//! **The count is in `char`s.** `str::len()` is bytes, and a line carrying an em
42//! dash of three bytes would be broken two characters early by a byte count. A
43//! codepoint count claims no display width and no grapheme boundary, which is
44//! the right claim for a report of ASCII identifiers and English prose.
45//!
46//! **A line that already fits comes back byte-identical.** [`filled`] returns a
47//! short line untouched rather than re-joining its words, so a report whose
48//! lines all fit is the same bytes before and after this function runs.
49
50/// The width every report is laid out at, whatever the run is attached to.
51///
52/// It is a constant rather than the width of whatever terminal ran the verb. A
53/// recorded block compared against the running terminal's width compares
54/// nothing, and the engine names no `libc`, no `terminal_size` and no
55/// `unicode-width` in its lock. `headwater_cli::paint::width` is the one place
56/// a caller may state a different number, and [`WIDEST`] is the ceiling on it.
57pub const WIDTH: usize = 80;
58
59/// The widest a caller may ask for with `--wide`.
60pub const WIDEST: usize = 120;
61
62/// The continuation indent [`filled`] hangs a wrapped line at.
63const HANG: usize = 2;
64
65/// The text, folded to `width` columns, breaking only where there is a space.
66pub fn fold(text: &str, width: usize) -> String {
67    fold_at(text, width, 0)
68}
69
70/// The text folded to `width`, leaving room on the last line for what follows.
71///
72/// `tail` is the width of a run of text that something else will append after
73/// this one, on the same line and after a space. It is kept with the last word
74/// rather than added as a word of its own, so the last line either carries both
75/// or carries neither.
76///
77/// A newline in the source is a break the author asked for and survives. A word
78/// longer than `width` is written past it rather than cut: a fold that broke
79/// inside a word would break an identifier, a path or a flag name, and every one
80/// of those is a thing a caller retypes.
81pub fn fold_at(text: &str, width: usize, tail: usize) -> String {
82    let lines: Vec<&str> = text.split('\n').collect();
83    let last = lines.len().saturating_sub(1);
84    lines
85        .iter()
86        .enumerate()
87        .map(|(at, line)| one_line(line, width, if at == last { tail } else { 0 }))
88        .collect::<Vec<String>>()
89        .join("\n")
90}
91
92fn one_line(text: &str, width: usize, tail: usize) -> String {
93    let words: Vec<&str> = text.split_whitespace().collect();
94    if words.is_empty() {
95        return String::new();
96    }
97    let mut widths: Vec<usize> = words.iter().map(|word| word.chars().count()).collect();
98    if tail > 0 {
99        let end = widths.len() - 1;
100        widths[end] += 1 + tail;
101    }
102
103    let mut out = String::new();
104    let mut used = 0;
105    for (word, measure) in words.iter().zip(widths) {
106        if used == 0 {
107            out.push_str(word);
108            used = measure;
109        } else if used + 1 + measure <= width {
110            out.push(' ');
111            out.push_str(word);
112            used += 1 + measure;
113        } else {
114            out.push('\n');
115            out.push_str(word);
116            used = measure;
117        }
118    }
119    out
120}
121
122/// One block of a report: `indent` spaces, then `label`, then `text` filled to
123/// `width`.
124///
125/// A continuation line is indented to `indent + label.chars().count()`, so the
126/// label hangs the text under itself and no caller names a second indent. Where
127/// a line opens with an identifying token — `fix: `, a level name, a rule name —
128/// that token is the label, and the text under it lines up.
129///
130/// `text` is split on `'\n'` first and each piece is filled on its own, so a
131/// break the author wrote survives. The fill is greedy on whitespace runs, and a
132/// whitespace run becomes one space; that normalization is invisible in the
133/// YAML-folded strings a rule set ships.
134pub fn block(out: &mut String, indent: usize, label: &str, text: &str, width: usize) {
135    let cont = " ".repeat(indent + label.chars().count());
136    let mut opening = format!("{}{label}", " ".repeat(indent));
137    for paragraph in text.split('\n') {
138        let mut line = opening.clone();
139        let mut written = false;
140        for word in paragraph.split_whitespace() {
141            match written {
142                false => {
143                    line.push_str(word);
144                    written = true;
145                }
146                true => match line.chars().count() + 1 + word.chars().count() <= width {
147                    true => {
148                        line.push(' ');
149                        line.push_str(word);
150                    }
151                    false => {
152                        out.push_str(&line);
153                        out.push('\n');
154                        line = cont.clone();
155                        line.push_str(word);
156                    }
157                },
158            }
159        }
160        out.push_str(&line);
161        out.push('\n');
162        opening = cont.clone();
163    }
164}
165
166/// A composed report, every line laid out at its own leading indent.
167///
168/// A line's leading spaces are its indent and are kept. What follows them is
169/// filled greedily to `width`, and a continuation sits at the indent plus two,
170/// so a wrapped line is visibly a continuation of the line above rather than a
171/// new one. A blank line stays blank, with no indent written into it: a blank
172/// line carrying trailing whitespace would put it in an artifact a test compares
173/// byte for byte.
174///
175/// A line that already fits inside `width` is returned untouched. That is what
176/// makes this safe to run over text somebody else composed: a renderer that
177/// aligns a column with two spaces keeps its alignment, because the line was
178/// never taken apart.
179///
180/// A trailing newline on the input is a trailing newline on the output, and no
181/// newline is added to text that had none.
182pub fn filled(text: &str, width: usize) -> String {
183    if text.is_empty() {
184        return String::new();
185    }
186    let ends = text.ends_with('\n');
187    let body = match ends {
188        true => &text[..text.len() - 1],
189        false => text,
190    };
191    let mut out: String = body
192        .split('\n')
193        .map(|line| one_indented(line, width))
194        .collect::<Vec<String>>()
195        .join("\n");
196    if ends {
197        out.push('\n');
198    }
199    out
200}
201
202/// One line of a composed report, kept at its own indent and filled under it.
203fn one_indented(line: &str, width: usize) -> String {
204    // A line that fits is returned as it arrived, so this function is the
205    // identity over a report whose lines are all short enough.
206    if line.chars().count() <= width {
207        return line.to_string();
208    }
209    let indent = line.len() - line.trim_start_matches(' ').len();
210    let rest = &line[indent..];
211    // A line whose only content is whitespace stays blank rather than becoming
212    // an indent with nothing under it.
213    if rest.trim().is_empty() {
214        return String::new();
215    }
216    // A line whose opening word leaves no room for the word after it is left
217    // exactly as it arrived. See [`opens_past_the_room`] for why the test is
218    // about the first two words rather than about the first one alone.
219    if opens_past_the_room(line, width) {
220        return line.to_string();
221    }
222    let opening = " ".repeat(indent);
223    let cont = " ".repeat(indent + HANG);
224
225    let mut out = String::new();
226    let mut line_out = opening;
227    let mut written = false;
228    for word in rest.split_whitespace() {
229        match written {
230            false => {
231                line_out.push_str(word);
232                written = true;
233            }
234            true => match line_out.chars().count() + 1 + word.chars().count() <= width {
235                true => {
236                    line_out.push(' ');
237                    line_out.push_str(word);
238                }
239                false => {
240                    out.push_str(&line_out);
241                    out.push('\n');
242                    line_out = cont.clone();
243                    line_out.push_str(word);
244                }
245            },
246        }
247    }
248    out.push_str(&line_out);
249    out
250}
251
252/// The longest tail [`opens_past_the_room`] reads whole rather than by its
253/// first word.
254///
255/// Two, because the widest identity tail in this report is a severity marker
256/// under `ColorMode::Plain`: a glyph and a word. A read-set entry opens with
257/// `input`, so its tail is measured by the path and this bound never binds it.
258const TAIL: usize = 2;
259
260/// Whether the opening word of `line` leaves no room for what follows it.
261///
262/// This is the one predicate that decides whether [`filled`] narrows a line or
263/// hands it back untouched, and [`unfoldable`] is its public name. It reads
264/// past the opening word rather than stopping at it, and what follows is the
265/// whole point.
266///
267/// # Why one word is the wrong question
268///
269/// A greedy fill puts the first word on the opening line and wraps the next one
270/// where it does not fit. When the first word alone reaches the width, the
271/// opening line carries that word and nothing else, and **every** following word
272/// wraps. For a two-word line that means the second word lands alone on a
273/// continuation, which is the harm this guard exists to prevent rather than a
274/// narrowing worth having.
275///
276/// The report is full of two-word lines, and each one is an identity beside the
277/// token that classifies it: `<path>:<line>:<column> <severity>` is a finding's
278/// location, `input <path> <sha256>` is a read-set entry. Splitting the second
279/// word off one of those hands a reader half an identity. It also hands
280/// `.githooks/pre-commit` a line whose whole content is `error`, which that
281/// selector reads as the header of a new finding — so the rule line and the
282/// `fix:` line under the real header are dropped and a refused commit explains
283/// nothing.
284///
285/// A guard written as "the first word alone is past the width" is aimed one
286/// boundary short of that harm: it misses every line whose first word *reaches*
287/// the width without passing it. On this corpus that band held 38 of 334
288/// documents. `crates/cli/tests/width.rs::no_finding_states_its_severity_on_a_line_of_its_own`
289/// and the `a location line at the width boundary` case of `.githooks/fixtures.sh`
290/// are what hold this now, and neither is a width assertion: the broken output
291/// is two short lines, so counting columns cannot see it.
292fn opens_past_the_room(line: &str, width: usize) -> bool {
293    let indent = line.len() - line.trim_start_matches(' ').len();
294    let mut words = line.split_whitespace();
295    let Some(first) = words.next() else {
296        return false;
297    };
298    let opening = indent + first.chars().count();
299    let tail: Vec<&str> = words.collect();
300    match tail.len() {
301        // One word, so there is nothing to detach. It is left alone only when
302        // no fill could narrow it.
303        0 => opening > width,
304        // An identity and the token that classifies it. The whole tail is
305        // measured rather than its first word, because a severity marker is one
306        // token under `ColorMode::Ansi` and two under `ColorMode::Plain`, where
307        // `crate::paint::severity_word` writes a glyph, a space and the word. A
308        // guard that measured the first word of the tail let the glyph ride and
309        // wrapped the word alone, which is the harm above with an extra step in
310        // front of it.
311        1..=TAIL => {
312            opening
313                + tail
314                    .iter()
315                    .map(|word| 1 + word.chars().count())
316                    .sum::<usize>()
317                > width
318        }
319        // Prose. The opening word of a message line is short, so this arm is
320        // reached with room to spare and the fill narrows the line as it should.
321        _ => opening + 1 + tail[0].chars().count() > width,
322    }
323}
324
325/// Whether [`filled`] leaves this line exactly as it arrived.
326///
327/// A line wider than `width` is either one this function names, or a defect in
328/// whatever laid the text out. [`filled`] leaves none of the second kind behind,
329/// so a caller can partition a report into the wide lines that are unavoidable
330/// and the wide lines that are somebody's fault.
331///
332/// **Every line [`filled`] emits satisfies this or fits.** A line it built
333/// greedily carries a second word only when that word fitted, so an over-width
334/// output line holds exactly one word that no fill could narrow. A line it
335/// handed back untouched is one this predicate already named. That equivalence
336/// is what lets `engine/crates/cli/tests/width.rs` assert the avoidable count is
337/// zero without re-implementing the fill.
338pub fn unfoldable(line: &str, width: usize) -> bool {
339    opens_past_the_room(line, width)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::{block, filled, fold, fold_at, unfoldable, WIDEST, WIDTH};
345
346    #[test]
347    fn a_folded_line_is_never_wider_than_the_width() {
348        let text = "the manifest of the change this run is scoped to, and every line of it \
349                    names one document the change carries";
350        for width in [20, 40, 70, 80] {
351            for line in fold(text, width).lines() {
352                assert!(
353                    line.chars().count() <= width,
354                    "{width}: {line:?} is {} columns",
355                    line.chars().count()
356                );
357            }
358        }
359        assert_eq!(fold(text, 200), text);
360    }
361
362    /// A word wider than the fold is written past it rather than cut in half.
363    #[test]
364    fn a_word_longer_than_the_width_is_never_broken() {
365        let text = "see docs/spec/06-engine-architecture.md#the-command-line for it";
366        let folded = fold(text, 20);
367        assert!(folded.contains("docs/spec/06-engine-architecture.md#the-command-line"));
368        assert_eq!(folded.split('\n').next(), Some("see"));
369    }
370
371    #[test]
372    fn a_break_the_author_wrote_survives_the_fold() {
373        assert_eq!(fold("one two\nthree four", 40), "one two\nthree four");
374    }
375
376    /// The last word and the suffix `clap` appends are on one line or on none.
377    #[test]
378    fn the_suffix_that_follows_the_text_is_left_room_for() {
379        let width = 30;
380        let tail = "[default: 0]".chars().count();
381        let text = "the rotation seed, which is a member of the run identity";
382        let folded = fold_at(text, width, tail);
383        let last = folded.lines().last().expect("the fold wrote a line");
384        assert!(
385            last.chars().count() + 1 + tail <= width,
386            "{last:?} plus the suffix is past {width}"
387        );
388        // The same text with no suffix fills the line the suffix vacated.
389        assert_ne!(fold(text, width), folded);
390    }
391
392    /// The label hangs the text under itself, at the label's own width.
393    #[test]
394    fn a_block_hangs_its_text_under_its_label() {
395        let mut out = String::new();
396        block(
397            &mut out,
398            2,
399            "fix: ",
400            "rewrite the sentence so that it states one claim and no more",
401            40,
402        );
403        let lines: Vec<&str> = out.trim_end().lines().collect();
404        assert_eq!(lines[0], "  fix: rewrite the sentence so that it");
405        for line in &lines[1..] {
406            assert!(line.starts_with(&" ".repeat(7)), "{line:?}");
407        }
408        for line in &lines {
409            assert!(line.chars().count() <= 40, "{line:?}");
410        }
411        assert!(out.ends_with('\n'));
412    }
413
414    /// A report whose every line already fits is returned byte for byte.
415    #[test]
416    fn a_report_that_already_fits_comes_back_unchanged() {
417        let report = "census\n  36 documents\n\n  4 excluded\nchecks\n  0 findings\n";
418        assert_eq!(filled(report, WIDTH), report);
419    }
420
421    /// The indent a line arrived with is the indent it keeps, and a wrapped
422    /// line sits two columns further in.
423    #[test]
424    fn a_wrapped_line_keeps_its_indent_and_hangs_two_columns_in() {
425        let line = format!("    {}", "word ".repeat(30).trim_end());
426        let out = filled(&line, 40);
427        let lines: Vec<&str> = out.lines().collect();
428        assert!(lines.len() > 1, "the line wrapped");
429        assert!(lines[0].starts_with("    w"), "{:?}", lines[0]);
430        for one in &lines[1..] {
431            assert!(one.starts_with("      w"), "{one:?}");
432        }
433        for one in &lines {
434            assert!(one.chars().count() <= 40, "{one:?}");
435        }
436    }
437
438    /// A blank line stays blank, and a trailing newline survives.
439    #[test]
440    fn a_blank_line_stays_blank_and_the_closing_newline_survives() {
441        let text = "one\n\ntwo\n";
442        assert_eq!(filled(text, 10), text);
443        assert_eq!(filled("one\n\ntwo", 10), "one\n\ntwo");
444        assert_eq!(filled("", 10), "");
445    }
446
447    /// A word past the room is written past it, whole, and says so.
448    ///
449    /// The line itself opens with `fix:`, which leaves room for the word after
450    /// it, so the fill does narrow this line. What it cannot narrow is the
451    /// continuation the path lands on, and that is the line [`unfoldable`]
452    /// names — the predicate is about a line the fill emitted, not about the
453    /// line it was handed.
454    #[test]
455    fn a_line_whose_one_word_is_past_the_room_is_left_whole() {
456        let path = "docs/obligations/0146-the-stop-hook-reads-its-re-entry-guard.md";
457        let line = format!("  fix: add a heading to {path}");
458        let out = filled(&line, 40);
459        assert!(out.contains(path), "the path arrived intact: {out}");
460        let carrier = out
461            .lines()
462            .find(|one| one.contains(path))
463            .expect("a line carries the path");
464        assert_eq!(carrier.trim(), path, "the path is alone on its line");
465        assert!(unfoldable(carrier, 40), "that line names its own reason");
466        assert!(!unfoldable("  a short line", 40));
467    }
468
469    /// A line whose *first* word is past the room is left exactly as it came.
470    ///
471    /// `<path> <severity>` is the shape of every finding's location line, and
472    /// `<path> <digest>` is the shape of every read-set input. Wrapping the
473    /// second word of one of those detaches an identity from the thing it
474    /// identifies, and narrows nothing: the line was already over the width
475    /// when the first word landed.
476    #[test]
477    fn a_line_that_opens_past_the_room_is_not_wrapped_after_it() {
478        let path = "docs/obligations/0146-the-stop-hook-reads-its-re-entry-guard.md";
479        let line = format!("  {path}:12:3 error");
480        assert_eq!(filled(&line, 40), line);
481        assert_eq!(filled(&format!("  {path}"), 40), format!("  {path}"));
482    }
483
484    /// **The boundary, walked one column at a time.**
485    ///
486    /// A guard written as "the first word alone is past the width" is aimed one
487    /// column short: a first word that *reaches* the width leaves the opening
488    /// line full, so the second word wraps alone. This walks the opening width
489    /// from well inside the room to well past it and asserts that a two-word
490    /// line is either laid out with both words on the first line, or handed back
491    /// whole — and never split one-and-one.
492    #[test]
493    fn a_two_word_line_is_never_split_one_word_to_a_line() {
494        let width = 40;
495        for opening in 20..=48 {
496            // Two spaces of indent, a first word of `opening - 2`, then `warn`.
497            let first = "p".repeat(opening - 2);
498            let line = format!("  {first} warn");
499            let out = filled(&line, width);
500            let lines: Vec<&str> = out.lines().collect();
501            assert!(
502                lines.len() == 1,
503                "at an opening of {opening} the line was split into {} lines:\n{out}",
504                lines.len()
505            );
506            assert!(
507                lines[0].ends_with(" warn"),
508                "at an opening of {opening} the severity left its line: {out:?}"
509            );
510            // And the predicate the width tests read agrees with what happened.
511            assert_eq!(
512                unfoldable(&line, width),
513                line.chars().count() > width && out == line,
514                "at an opening of {opening} the predicate and the fill disagree"
515            );
516        }
517    }
518
519    /// The exact shape that broke `.githooks/pre-commit`, at 80 columns.
520    ///
521    /// `docs/decisions/0041-q41-whether-vale-becomes-a-declared-regime-backend.md`
522    /// with a `:32:1` suffix is 79 columns at indent 2. Its severity used to
523    /// wrap onto a line of its own, and the commit hook then read that bare
524    /// `error` as the header of a new finding and dropped the real message.
525    #[test]
526    fn a_finding_location_at_the_width_keeps_its_severity() {
527        let path = "docs/decisions/0041-q41-whether-vale-becomes-a-declared-regime-backend.md";
528        // The opening word reaches the width exactly, which is the boundary the
529        // old guard sat one column short of.
530        let opening = 2 + path.chars().count() + ":32:1".chars().count();
531        assert_eq!(opening, WIDTH, "this case is at the boundary it claims");
532        let line = format!("  {path}:32:1 error");
533        let out = filled(&line, WIDTH);
534        assert_eq!(out, line, "the severity left its location line:\n{out}");
535        assert_eq!(out.lines().count(), 1);
536    }
537
538    /// The same guard against a severity marker of two tokens.
539    ///
540    /// `crate::paint::severity_word` writes `warn` under `ColorMode::Ansi` and
541    /// `▲ warn` under `ColorMode::Plain`, so a finding's location line
542    /// holds three words in every recorded fixture and every piped run. The
543    /// band where the glyph fits and the word does not is one column wide per
544    /// opening, so a case that reads the corpus as it stands meets it only when
545    /// a path happens to land there. This walks the opening instead.
546    #[test]
547    fn a_glyph_never_rides_alone_when_its_severity_word_wraps() {
548        let width = 40;
549        for opening in 20..=48 {
550            let first = "p".repeat(opening - 2);
551            let line = format!("  {first} ▲ warn");
552            let out = filled(&line, width);
553            let lines: Vec<&str> = out.lines().collect();
554            assert!(
555                !lines.iter().any(|line| line.trim() == "warn"),
556                "at an opening of {opening} the severity word landed alone:\n{out}"
557            );
558            assert!(
559                !lines.iter().any(|line| line.trim() == "▲"),
560                "at an opening of {opening} the glyph landed alone:\n{out}"
561            );
562            assert_eq!(
563                unfoldable(&line, width),
564                line.chars().count() > width && out == line,
565                "at an opening of {opening} the predicate and the fill disagree"
566            );
567        }
568    }
569
570    /// Every wide line the fill emits is one the predicate names.
571    ///
572    /// This is the equivalence `crates/cli/tests/width.rs` relies on to assert
573    /// that the avoidable count is zero without re-implementing the fill.
574    #[test]
575    fn every_wide_line_the_fill_emits_names_itself() {
576        let report = "  a short line\n  \
577             docs/decisions/0041-q41-whether-vale-becomes-a-declared-regime-backend.md:32:1 error\n  \
578             fix (mechanical): write `behavior` into \
579             docs/obligations/0146-the-stop-hook-reads-its-re-entry-guard-with-an-interpreter.md\n  \
580             a much longer line of ordinary prose that will certainly need to be laid out at eighty columns\n";
581        for width in [40, WIDTH, WIDEST] {
582            let out = filled(report, width);
583            for line in out.lines() {
584                if line.chars().count() > width {
585                    assert!(
586                        unfoldable(line, width),
587                        "at {width} the fill emitted a wide line it could have narrowed: {line:?}"
588                    );
589                }
590            }
591            // And no emitted line is a bare severity word.
592            for line in out.lines() {
593                assert!(
594                    !matches!(line.trim(), "error" | "warn" | "info"),
595                    "at {width} a severity reached a line of its own:\n{out}"
596                );
597            }
598        }
599    }
600
601    /// The fill is idempotent: laying out a laid-out report changes nothing.
602    #[test]
603    fn laying_out_a_laid_out_report_changes_nothing() {
604        let report = "checks\n  a very long sentence that will certainly need to wrap at forty \
605                      columns and then some more\n  docs/one.md:1:1 warn\n";
606        let once = filled(report, 40);
607        assert_eq!(filled(&once, 40), once);
608    }
609}