prose: doc-wrap widow control for 1-word orphans

When greedy word-wrap leaves a 1-word last line, pull the
penultimate line's tail word down so the orphan has at least
2 words. Skipped if the combined length would exceed budget
(invariant takes priority over cosmetics).

Visible on nested_let_rec.prose.txt:
  before: '...captures outer's param\n/// i.'
  after:  '...captures outer's\n/// param i.'

Two new unit tests; existing 4 snapshots unaffected (none had
a 1-word orphan).
This commit is contained in:
2026-05-08 19:18:37 +02:00
parent 2852326dd3
commit b9e942b99d
+81
View File
@@ -131,6 +131,33 @@ fn wrap_words(piece: &str, budget: usize) -> Vec<String> {
// empty line rather than dropping it.
out.push(String::new());
}
// Widow control: a 1-word orphan as the last line reads as a
// typographic blemish (e.g. "...captures outer's param\ni."). If
// the second-to-last line has ≥2 words and the merge fits in
// budget, pull its tail word down so the last line has 2 words
// and the penultimate line gives up one. Greedy fill never
// produces a line that overflows budget when shortened from the
// tail, so this never breaks the wrap invariant.
if out.len() >= 2 {
let last_word_count = out.last().unwrap().split_whitespace().count();
if last_word_count == 1 {
let prev_idx = out.len() - 2;
let prev_words: Vec<&str> = out[prev_idx].split_whitespace().collect();
if prev_words.len() >= 2 {
let pulled = prev_words.last().unwrap();
let last_line = out.last().unwrap();
if pulled.len() + 1 + last_line.len() <= budget {
let pulled_owned = pulled.to_string();
let prev_new = prev_words[..prev_words.len() - 1].join(" ");
let last_line_owned = last_line.clone();
let combined = format!("{pulled_owned} {last_line_owned}");
out[prev_idx] = prev_new;
let last_idx = out.len() - 1;
out[last_idx] = combined;
}
}
}
}
out
}
@@ -1326,6 +1353,60 @@ mod tests {
assert!(doc_lines.len() >= 3, "expected at least 3 doc lines, got:\n{out}");
}
#[test]
fn doc_wrap_widow_control_pulls_one_word_back() {
// A doc string whose greedy wrap leaves a 1-word orphan as
// the last line should pull the previous line's tail word
// down so the orphan has at least 2 words. This is exactly
// the `nested_let_rec` case where "outer's param i." used
// to wrap to "outer's param" / "i.".
let doc = "Sum 1 + 2 + ... + n by nested LetRec helpers; inner captures \
outer's param i.";
let td = TypeDef {
name: "Foo".into(),
vars: vec![],
ctors: vec![Ctor { name: "MkFoo".into(), fields: vec![] }],
doc: Some(doc.into()),
drop_iterative: false,
};
let mut out = String::new();
write_type_def(&mut out, &td, 0);
let doc_lines: Vec<&str> = out.lines().take_while(|l| l.starts_with("///")).collect();
// The last doc line MUST have ≥ 2 words.
let last = doc_lines.last().unwrap();
let last_word_count = last
.trim_start_matches("///")
.trim()
.split_whitespace()
.count();
assert!(
last_word_count >= 2,
"expected widow control to leave ≥ 2 words on the last line; got {last:?}"
);
// No line over the 80-col budget.
for line in &doc_lines {
assert!(line.len() <= 80, "line over 80 cols: {line:?}");
}
}
#[test]
fn doc_wrap_widow_control_skips_when_combined_too_long() {
// If the combined length of (pulled + " " + orphan) would
// exceed budget, widow control must NOT fire — preserving
// the wrap budget invariant takes priority over cosmetics.
// Construct a case: prev-tail = a long word, orphan = a long
// word — combined > 80.
let pieces = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \
cccccccccccccccccccccccccccccccccccccccccc";
let lines = wrap_words(pieces, 80);
// Sanity: the wrap produced multiple lines.
assert!(lines.len() >= 2);
// No line exceeds budget.
for l in &lines {
assert!(l.len() <= 80, "line over budget: {l:?}");
}
}
// ---- Polish 5: nested match across lines ----
#[test]