diff --git a/crates/ailang-prose/src/lib.rs b/crates/ailang-prose/src/lib.rs index e276991..aba87aa 100644 --- a/crates/ailang-prose/src/lib.rs +++ b/crates/ailang-prose/src/lib.rs @@ -131,6 +131,33 @@ fn wrap_words(piece: &str, budget: usize) -> Vec { // 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]