Skip to content

Commit c138b38

Browse files
karanabecakebaker
authored andcommitted
fmt: fix width wrapping for GNU compatibility
Align fmt line-breaking with GNU coreutils 9.10 for the tests/fmt/width case. The -w 7 input "aa bb cc dd ee" now breaks as: aa bb cc dd ee Update Knuth-Plass scoring to keep finite penalties, evaluate break-before candidates, and apply orphan penalties. Add a regression test for -w 7 and -w 8 behavior.
1 parent ade19bc commit c138b38

2 files changed

Lines changed: 100 additions & 43 deletions

File tree

src/uu/fmt/src/linebreak.rs

Lines changed: 85 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// spell-checker:ignore (ToDO) INFTY MULT accum breakwords linebreak linebreaking linebreaks linelen maxlength minlength nchars ostream overlen parasplit plass posn powf punct signum slen sstart tabwidth tlen underlen winfo wlen wordlen
77

88
use std::io::{BufWriter, Stdout, Write};
9-
use std::{cmp, mem};
9+
use std::mem;
1010

1111
use crate::FmtOptions;
1212
use crate::parasplit::{ParaWords, Paragraph, WordInfo};
@@ -215,7 +215,7 @@ struct LineBreak<'a> {
215215
}
216216

217217
#[allow(clippy::cognitive_complexity)]
218-
fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
218+
fn find_kp_breakpoints<'a, T: Clone + Iterator<Item = &'a WordInfo<'a>>>(
219219
iter: T,
220220
args: &BreakArgs<'a>,
221221
) -> Vec<(&'a WordInfo<'a>, bool)> {
@@ -226,7 +226,7 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
226226
linebreak: None,
227227
break_before: false,
228228
demerits: 0,
229-
prev_rat: 0.0,
229+
prev_rat: f32::NAN,
230230
length: args.init_len,
231231
fresh: false,
232232
}];
@@ -243,8 +243,8 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
243243
};
244244
let mut new_linebreaks = vec![];
245245
let mut is_sentence_start = false;
246-
let mut least_demerits = 0;
247246
while let Some(w) = iter.next() {
247+
let next_word_sentence_final = is_next_word_sentence_final(iter.clone());
248248
// if this is the last word, we don't add additional demerits for this break
249249
let (is_last_word, is_sentence_end) = match iter.peek() {
250250
None => (true, true),
@@ -258,23 +258,51 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
258258
// should we be adding extra space at the beginning of the next sentence?
259259
let slen = compute_slen(args.uniform, w.new_line, is_sentence_start, false);
260260

261-
let mut ld_new = i64::MAX;
262-
let mut ld_next = i64::MAX;
261+
let mut best_active_demerits = i64::MAX;
263262
let mut ld_idx = 0;
264263
new_linebreaks.clear();
264+
let mut best_break_before: Option<LineBreak<'_>> = None;
265+
let mut best_break_after: Option<LineBreak<'_>> = None;
265266
next_active_breaks.clear();
266267
// go through each active break, extending it and possibly adding a new active
267268
// break if we are above the minimum required length
268269
#[allow(clippy::explicit_iter_loop)]
269270
for &i in active_breaks.iter() {
270271
let active = &mut linebreaks[i];
271-
// normalize demerits to avoid overflow, and record if this is the least
272-
active.demerits -= least_demerits;
273-
if active.demerits < ld_next {
274-
ld_next = active.demerits;
272+
if active.demerits < best_active_demerits {
273+
best_active_demerits = active.demerits;
275274
ld_idx = i;
276275
}
277276

277+
// Also consider a break before this word, so the previous line can end at the
278+
// prior word when that yields a better global layout.
279+
if !active.fresh && active.length >= minlength {
280+
let (mut new_demerits, new_ratio) = compute_demerits(
281+
args.opts.goal as isize - active.length as isize,
282+
stretch,
283+
w.word_nchars,
284+
active.prev_rat,
285+
);
286+
if is_sentence_end {
287+
new_demerits = new_demerits.saturating_add(ORPHAN_BREAK_PENALTY);
288+
}
289+
let total_demerits = active.demerits.saturating_add(new_demerits);
290+
if best_break_before
291+
.as_ref()
292+
.is_none_or(|best| total_demerits < best.demerits)
293+
{
294+
best_break_before = Some(LineBreak {
295+
prev: i,
296+
linebreak: Some(w),
297+
break_before: true,
298+
demerits: total_demerits,
299+
prev_rat: new_ratio,
300+
length: args.indent_len + w.word_nchars,
301+
fresh: false,
302+
});
303+
}
304+
}
305+
278306
// get the new length
279307
let tlen = w.word_nchars
280308
+ args.compute_width(w, active.length, active.fresh)
@@ -292,7 +320,7 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
292320

293321
// if we're above the minlength, we can also consider breaking here
294322
if tlen >= minlength {
295-
let (new_demerits, new_ratio) = if is_last_word {
323+
let (mut new_demerits, new_ratio) = if is_last_word {
296324
// there is no penalty for the final line's length
297325
(0, 0.0)
298326
} else {
@@ -304,15 +332,16 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
304332
)
305333
};
306334

307-
// do not even consider adding a line that has too many demerits
308-
// also, try to detect overflow by checking signum
309-
let total_demerits = new_demerits + active.demerits;
310-
if new_demerits < BAD_INFTY_SQ
311-
&& total_demerits < ld_new
312-
&& active.demerits.signum() <= new_demerits.signum()
335+
if !is_last_word && next_word_sentence_final {
336+
new_demerits = new_demerits.saturating_add(ORPHAN_BREAK_PENALTY);
337+
}
338+
339+
let total_demerits = active.demerits.saturating_add(new_demerits);
340+
if best_break_after
341+
.as_ref()
342+
.is_none_or(|best| total_demerits < best.demerits)
313343
{
314-
ld_new = total_demerits;
315-
new_linebreaks.push(LineBreak {
344+
best_break_after = Some(LineBreak {
316345
prev: i,
317346
linebreak: Some(w),
318347
break_before: false,
@@ -326,15 +355,16 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
326355
}
327356
}
328357

329-
// if we generated any new linebreaks, add the last one to the list
330-
// the last one is always the best because we don't add to new_linebreaks unless
331-
// it's better than the best one so far
332-
match new_linebreaks.pop() {
333-
None => (),
334-
Some(lb) => {
335-
next_active_breaks.push(linebreaks.len());
336-
linebreaks.push(lb);
337-
}
358+
if let Some(lb) = best_break_before {
359+
new_linebreaks.push(lb);
360+
}
361+
if let Some(lb) = best_break_after {
362+
new_linebreaks.push(lb);
363+
}
364+
365+
for lb in new_linebreaks.drain(..) {
366+
next_active_breaks.push(linebreaks.len());
367+
linebreaks.push(lb);
338368
}
339369

340370
if next_active_breaks.is_empty() {
@@ -343,11 +373,6 @@ fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>(
343373
restart_active_breaks(args, &linebreaks[ld_idx], ld_idx, w, slen, minlength);
344374
next_active_breaks.push(linebreaks.len());
345375
linebreaks.push(new_break);
346-
least_demerits = 0;
347-
} else {
348-
// next time around, normalize out the demerits fields
349-
// on active linebreaks to make overflow less likely
350-
least_demerits = cmp::max(ld_next, 0);
351376
}
352377
// swap in new list of active breaks
353378
mem::swap(&mut active_breaks, &mut next_active_breaks);
@@ -382,15 +407,28 @@ fn build_best_path<'a>(paths: &[LineBreak<'a>], active: &[usize]) -> Vec<(&'a Wo
382407
.unwrap_or_default()
383408
}
384409

385-
// "infinite" badness is more like (1+BAD_INFTY)^2 because of how demerits are computed
386-
const BAD_INFTY: i64 = 10_000_000;
387-
const BAD_INFTY_SQ: i64 = BAD_INFTY * BAD_INFTY;
388410
// badness = BAD_MULT * abs(r) ^ 3
389411
const BAD_MULT: f32 = 200.0;
390412
// DR_MULT is multiplier for delta-R between lines
391413
const DR_MULT: f32 = 600.0;
392414
// DL_MULT is penalty multiplier for short words at end of line
393415
const DL_MULT: f32 = 10.0;
416+
// Penalize breaks that leave the first word on the next line as the sentence-final word.
417+
const ORPHAN_BREAK_PENALTY: i64 = 250_000_000;
418+
419+
fn is_word_sentence_final(current: &WordInfo, next: Option<&WordInfo>) -> bool {
420+
match next {
421+
None => true,
422+
Some(next_word) => next_word.sentence_start || (next_word.new_line && current.ends_punct),
423+
}
424+
}
425+
426+
fn is_next_word_sentence_final<'a, T: Iterator<Item = &'a WordInfo<'a>>>(mut iter: T) -> bool {
427+
let Some(next_word) = iter.next() else {
428+
return false;
429+
};
430+
is_word_sentence_final(next_word, iter.next())
431+
}
394432

395433
fn compute_demerits(delta_len: isize, stretch: usize, wlen: usize, prev_rat: f32) -> (i64, f32) {
396434
// how much stretch are we using?
@@ -401,11 +439,7 @@ fn compute_demerits(delta_len: isize, stretch: usize, wlen: usize, prev_rat: f32
401439
};
402440

403441
// compute badness given the stretch ratio
404-
let bad_linelen = if ratio.abs() > 1.0f32 {
405-
BAD_INFTY
406-
} else {
407-
(BAD_MULT * ratio.powi(3).abs()) as i64
408-
};
442+
let bad_linelen = (BAD_MULT * ratio.powi(3).abs()) as i64;
409443

410444
// we penalize lines ending in really short words
411445
let bad_wordlen = if wlen >= stretch {
@@ -418,9 +452,17 @@ fn compute_demerits(delta_len: isize, stretch: usize, wlen: usize, prev_rat: f32
418452
};
419453

420454
// we penalize lines that have very different ratios from previous lines
421-
let bad_delta_r = (DR_MULT * ((ratio - prev_rat) / 2.0).powi(3).abs()) as i64;
455+
let bad_delta_r = if prev_rat.is_nan() {
456+
0
457+
} else {
458+
(DR_MULT * ((ratio - prev_rat) / 2.0).powi(3).abs()) as i64
459+
};
422460

423-
let demerits = i64::pow(1 + bad_linelen + bad_wordlen + bad_delta_r, 2);
461+
let demerits_base = 1_i64
462+
.saturating_add(bad_linelen)
463+
.saturating_add(bad_wordlen)
464+
.saturating_add(bad_delta_r);
465+
let demerits = demerits_base.saturating_mul(demerits_base);
424466

425467
(demerits, ratio)
426468
}

tests/by-util/test_fmt.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ fn test_fmt_width() {
5050
.stdout_is("this is a\nfile with\none word\nper line\n");
5151
}
5252

53+
#[test]
54+
fn test_fmt_width_max_display_width() {
55+
let input = "aa bb cc dd ee";
56+
new_ucmd!()
57+
.args(&["-w", "8"])
58+
.pipe_in(input)
59+
.succeeds()
60+
.stdout_is("aa bb cc\ndd ee\n");
61+
new_ucmd!()
62+
.args(&["-w", "7"])
63+
.pipe_in(input)
64+
.succeeds()
65+
.stdout_is("aa\nbb cc\ndd ee\n");
66+
}
67+
5368
#[test]
5469
fn test_fmt_width_invalid() {
5570
new_ucmd!()

0 commit comments

Comments
 (0)