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}