#![forbid(unsafe_code)]
use std::borrow::Cow;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum WrapMode {
None,
#[default]
Word,
Char,
WordChar,
Optimal,
}
#[derive(Debug, Clone)]
pub struct WrapOptions {
pub width: usize,
pub mode: WrapMode,
pub preserve_indent: bool,
pub trim_trailing: bool,
}
impl WrapOptions {
#[must_use]
pub fn new(width: usize) -> Self {
Self {
width,
mode: WrapMode::Word,
preserve_indent: false,
trim_trailing: true,
}
}
#[must_use]
pub fn mode(mut self, mode: WrapMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn preserve_indent(mut self, preserve: bool) -> Self {
self.preserve_indent = preserve;
self
}
#[must_use]
pub fn trim_trailing(mut self, trim: bool) -> Self {
self.trim_trailing = trim;
self
}
}
impl Default for WrapOptions {
fn default() -> Self {
Self::new(80)
}
}
#[must_use]
pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
let preserve = mode == WrapMode::Char;
wrap_with_options(
text,
&WrapOptions::new(width).mode(mode).preserve_indent(preserve),
)
}
#[must_use]
pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
if options.width == 0 {
return vec![text.to_string()];
}
match options.mode {
WrapMode::None => vec![text.to_string()],
WrapMode::Char => wrap_chars(text, options),
WrapMode::Word => wrap_words(text, options, false),
WrapMode::WordChar => wrap_words(text, options, true),
WrapMode::Optimal => wrap_text_optimal(text, options.width),
}
}
fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for grapheme in text.graphemes(true) {
if grapheme == "\n" || grapheme == "\r\n" {
lines.push(finalize_line(¤t_line, options));
current_line.clear();
current_width = 0;
continue;
}
let grapheme_width = crate::wrap::grapheme_width(grapheme);
if current_width + grapheme_width > options.width && !current_line.is_empty() {
lines.push(finalize_line(¤t_line, options));
current_line.clear();
current_width = 0;
}
current_line.push_str(grapheme);
current_width += grapheme_width;
}
lines.push(finalize_line(¤t_line, options));
lines
}
fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
let mut lines = Vec::new();
for raw_paragraph in text.split('\n') {
let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
let mut current_line = String::new();
let mut current_width = 0;
let len_before = lines.len();
wrap_paragraph(
paragraph,
options,
char_fallback,
&mut lines,
&mut current_line,
&mut current_width,
);
if !current_line.is_empty() || lines.len() == len_before {
lines.push(finalize_line(¤t_line, options));
}
}
lines
}
fn wrap_paragraph(
text: &str,
options: &WrapOptions,
char_fallback: bool,
lines: &mut Vec<String>,
current_line: &mut String,
current_width: &mut usize,
) {
for word in split_words(text) {
let is_whitespace_only = word.chars().all(is_breaking_whitespace);
if *current_width == 0 && is_whitespace_only && !options.preserve_indent {
continue;
}
let word_width = display_width(word);
if *current_width + word_width <= options.width {
current_line.push_str(word);
*current_width += word_width;
continue;
}
if !current_line.is_empty() {
lines.push(finalize_line(current_line, options));
current_line.clear();
*current_width = 0;
if is_whitespace_only && !options.preserve_indent {
continue;
}
}
if word_width > options.width {
if char_fallback {
wrap_long_word(word, options, lines, current_line, current_width);
} else {
lines.push(finalize_line(word, options));
}
} else {
if !word.is_empty() {
current_line.push_str(word);
}
*current_width = word_width;
}
}
}
fn wrap_long_word(
word: &str,
options: &WrapOptions,
lines: &mut Vec<String>,
current_line: &mut String,
current_width: &mut usize,
) {
for grapheme in word.graphemes(true) {
let grapheme_width = crate::wrap::grapheme_width(grapheme);
if *current_width == 0
&& grapheme.chars().all(is_breaking_whitespace)
&& !options.preserve_indent
{
continue;
}
if *current_width + grapheme_width > options.width && !current_line.is_empty() {
lines.push(finalize_line(current_line, options));
current_line.clear();
*current_width = 0;
if grapheme.chars().all(is_breaking_whitespace) && !options.preserve_indent {
continue;
}
}
current_line.push_str(grapheme);
*current_width += grapheme_width;
}
}
fn split_words(text: &str) -> Vec<&str> {
let mut words = Vec::new();
let mut current_start = 0;
let mut current_end = 0;
let mut in_whitespace = false;
let mut byte_offset = 0;
for grapheme in text.graphemes(true) {
let is_ws = grapheme.chars().all(is_breaking_whitespace);
if is_ws != in_whitespace && current_end > current_start {
words.push(&text[current_start..current_end]);
current_start = byte_offset;
} else if current_end == current_start {
current_start = byte_offset;
}
current_end = byte_offset + grapheme.len();
in_whitespace = is_ws;
byte_offset += grapheme.len();
}
if current_end > current_start {
words.push(&text[current_start..current_end]);
}
words
}
fn finalize_line(line: &str, options: &WrapOptions) -> String {
if options.trim_trailing {
line.trim_end_matches(is_breaking_whitespace).to_string()
} else {
line.to_string()
}
}
#[must_use]
pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
let text_width = display_width(text);
if text_width <= max_width {
return text.to_string();
}
let ellipsis_width = display_width(ellipsis);
if ellipsis_width >= max_width {
return truncate_to_width(text, max_width);
}
let target_width = max_width - ellipsis_width;
let mut result = truncate_to_width(text, target_width);
result.push_str(ellipsis);
result
}
#[must_use]
pub fn truncate_to_width(text: &str, max_width: usize) -> String {
let mut result = String::new();
let mut current_width = 0;
for grapheme in text.graphemes(true) {
let grapheme_width = crate::wrap::grapheme_width(grapheme);
if current_width + grapheme_width > max_width {
break;
}
result.push_str(grapheme);
current_width += grapheme_width;
}
result
}
#[inline]
#[must_use]
pub fn ascii_width(text: &str) -> Option<usize> {
ftui_core::text_width::ascii_width(text)
}
#[inline]
#[must_use]
pub fn grapheme_width(grapheme: &str) -> usize {
ftui_core::text_width::grapheme_width(grapheme)
}
#[inline]
#[must_use]
pub fn display_width(text: &str) -> usize {
ftui_core::text_width::display_width(text)
}
#[must_use]
pub fn has_wide_chars(text: &str) -> bool {
text.graphemes(true)
.any(|g| crate::wrap::grapheme_width(g) > 1)
}
#[must_use]
pub fn is_ascii_only(text: &str) -> bool {
text.is_ascii()
}
#[inline]
#[must_use]
pub fn grapheme_count(text: &str) -> usize {
text.graphemes(true).count()
}
#[inline]
pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
text.graphemes(true)
}
#[must_use]
pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
let mut byte_end = 0;
let mut current_width = 0;
for grapheme in text.graphemes(true) {
let grapheme_width = crate::wrap::grapheme_width(grapheme);
if current_width + grapheme_width > max_width {
break;
}
current_width += grapheme_width;
byte_end += grapheme.len();
}
(&text[..byte_end], current_width)
}
pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
text.split_word_bound_indices().filter_map(|(idx, word)| {
if word.chars().all(is_breaking_whitespace) {
Some(idx + word.len())
} else {
None
}
})
}
pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
text.split_word_bounds()
}
const BADNESS_SCALE: u64 = 10_000;
const BADNESS_INF: u64 = u64::MAX / 2;
const PENALTY_FORCE_BREAK: u64 = 5000;
const KP_MAX_LOOKAHEAD: usize = 1024;
#[inline]
fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
if slack < 0 {
return BADNESS_INF;
}
if is_last_line {
return 0;
}
if width == 0 {
return if slack == 0 { 0 } else { BADNESS_INF };
}
let ratio = slack as f64 / width as f64;
(ratio * ratio * ratio * BADNESS_SCALE as f64) as u64
}
pub(crate) fn is_breaking_whitespace(c: char) -> bool {
c.is_whitespace() && c != '\u{00A0}' && c != '\u{202F}'
}
#[derive(Debug, Clone)]
struct KpWord<'a> {
content: Cow<'a, str>,
space: Cow<'a, str>,
content_width: usize,
space_width: usize,
}
fn kp_tokenize(text: &str) -> Vec<KpWord<'_>> {
let mut words = Vec::new();
let mut content_start = 0;
let mut content_end = 0;
let mut current_content_width = 0;
let mut byte_offset = 0;
for seg in text.split_word_bounds() {
let is_space = seg.chars().all(is_breaking_whitespace);
let width = display_width(seg);
if is_space {
if content_end > content_start {
let content = &text[content_start..content_end];
words.push(KpWord {
content: Cow::Borrowed(content),
space: Cow::Borrowed(seg),
content_width: current_content_width,
space_width: width,
});
content_start = byte_offset + seg.len();
content_end = content_start;
current_content_width = 0;
} else if let Some(last) = words.last_mut() {
if let Cow::Borrowed(s) = last.space {
let start = byte_offset - s.len();
let end = byte_offset + seg.len();
last.space = Cow::Borrowed(&text[start..end]);
}
last.space_width += width;
content_start = byte_offset + seg.len();
content_end = content_start;
} else {
words.push(KpWord {
content: Cow::Borrowed(""),
space: Cow::Borrowed(seg),
content_width: 0,
space_width: width,
});
content_start = byte_offset + seg.len();
content_end = content_start;
}
} else {
if content_start == content_end {
content_start = byte_offset;
}
content_end = byte_offset + seg.len();
current_content_width += width;
}
byte_offset += seg.len();
}
if content_end > content_start {
let content = &text[content_start..content_end];
words.push(KpWord {
content: Cow::Borrowed(content),
space: Cow::Borrowed(""),
content_width: current_content_width,
space_width: 0,
});
}
words
}
#[derive(Debug, Clone)]
pub struct KpBreakResult {
pub lines: Vec<String>,
pub total_cost: u64,
pub line_badness: Vec<u64>,
}
pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
if width == 0 || text.is_empty() {
return KpBreakResult {
lines: vec![text.to_string()],
total_cost: 0,
line_badness: vec![0],
};
}
let words = kp_tokenize(text);
if words.is_empty() {
return KpBreakResult {
lines: vec![text.to_string()],
total_cost: 0,
line_badness: vec![0],
};
}
let n = words.len();
let mut cost = vec![BADNESS_INF; n + 1];
let mut from = vec![0usize; n + 1];
cost[0] = 0;
for j in 1..=n {
let mut line_width: usize = 0;
let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
for i in (earliest..j).rev() {
line_width += words[i].content_width;
if i < j - 1 {
line_width += words[i].space_width;
}
if line_width > width && i < j - 1 {
break;
}
let slack = width as i64 - line_width as i64;
let is_last = j == n;
let badness = if line_width > width {
PENALTY_FORCE_BREAK
} else {
knuth_plass_badness(slack, width, is_last)
};
let candidate = cost[i].saturating_add(badness);
if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
cost[j] = candidate;
from[j] = i;
}
}
}
let mut breaks = Vec::new();
let mut pos = n;
while pos > 0 {
breaks.push(from[pos]);
pos = from[pos];
}
breaks.reverse();
let mut lines = Vec::new();
let mut line_badness = Vec::new();
let break_count = breaks.len();
for (idx, &start) in breaks.iter().enumerate() {
let end = if idx + 1 < break_count {
breaks[idx + 1]
} else {
n
};
let mut line = String::new();
for (i, word) in words.iter().take(end).skip(start).enumerate() {
line.push_str(&word.content);
if i < (end - start) - 1 {
line.push_str(&word.space);
}
}
let trimmed = line.trim_end_matches(is_breaking_whitespace).to_string();
let line_w = display_width(trimmed.as_str());
let slack = width as i64 - line_w as i64;
let is_last = idx == break_count - 1;
let bad = if slack < 0 {
PENALTY_FORCE_BREAK
} else {
knuth_plass_badness(slack, width, is_last)
};
lines.push(trimmed);
line_badness.push(bad);
}
KpBreakResult {
lines,
total_cost: cost[n],
line_badness,
}
}
#[must_use]
pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
let mut result = Vec::new();
for raw_paragraph in text.split('\n') {
let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
if paragraph.is_empty() {
result.push(String::new());
continue;
}
let kp = wrap_optimal(paragraph, width);
result.extend(kp.lines);
}
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum FitnessClass {
Tight = 0,
Normal = 1,
Loose = 2,
VeryLoose = 3,
}
impl FitnessClass {
#[must_use]
pub fn from_ratio(ratio: f64) -> Self {
if ratio < -0.5 {
FitnessClass::Tight
} else if ratio < 0.5 {
FitnessClass::Normal
} else if ratio < 1.0 {
FitnessClass::Loose
} else {
FitnessClass::VeryLoose
}
}
#[must_use]
pub const fn incompatible(self, other: Self) -> bool {
let a = self as i8;
let b = other as i8;
(a - b > 1) || (b - a > 1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BreakKind {
Space,
Hyphen,
Forced,
Emergency,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BreakPenalty {
pub value: i64,
pub flagged: bool,
}
impl BreakPenalty {
pub const SPACE: Self = Self {
value: 0,
flagged: false,
};
pub const HYPHEN: Self = Self {
value: 50,
flagged: true,
};
pub const FORCED: Self = Self {
value: i64::MIN,
flagged: false,
};
pub const EMERGENCY: Self = Self {
value: 5000,
flagged: false,
};
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ParagraphObjective {
pub line_penalty: u64,
pub fitness_demerit: u64,
pub double_hyphen_demerit: u64,
pub final_hyphen_demerit: u64,
pub max_adjustment_ratio: f64,
pub min_adjustment_ratio: f64,
pub widow_demerit: u64,
pub widow_threshold: usize,
pub orphan_demerit: u64,
pub orphan_threshold: usize,
pub badness_scale: u64,
}
impl Default for ParagraphObjective {
fn default() -> Self {
Self {
line_penalty: 10,
fitness_demerit: 100,
double_hyphen_demerit: 100,
final_hyphen_demerit: 100,
max_adjustment_ratio: 2.0,
min_adjustment_ratio: -1.0,
widow_demerit: 150,
widow_threshold: 15,
orphan_demerit: 150,
orphan_threshold: 20,
badness_scale: BADNESS_SCALE,
}
}
}
impl ParagraphObjective {
#[must_use]
pub fn terminal() -> Self {
Self {
line_penalty: 20,
fitness_demerit: 50,
min_adjustment_ratio: 0.0,
max_adjustment_ratio: 3.0,
widow_demerit: 50,
orphan_demerit: 50,
..Self::default()
}
}
#[must_use]
pub fn typographic() -> Self {
Self::default()
}
#[must_use]
pub fn badness(&self, slack: i64, width: usize) -> Option<u64> {
if width == 0 {
return if slack == 0 { Some(0) } else { None };
}
let ratio = slack as f64 / width as f64;
if ratio < self.min_adjustment_ratio || ratio > self.max_adjustment_ratio {
return None; }
let abs_ratio = ratio.abs();
let badness = (abs_ratio * abs_ratio * abs_ratio * self.badness_scale as f64) as u64;
Some(badness)
}
#[must_use]
pub fn adjustment_ratio(&self, slack: i64, width: usize) -> f64 {
if width == 0 {
return 0.0;
}
slack as f64 / width as f64
}
#[must_use]
pub fn demerits(&self, slack: i64, width: usize, penalty: &BreakPenalty) -> Option<u64> {
let badness = self.badness(slack, width)?;
let base = self.line_penalty.saturating_add(badness);
let base_sq = base.saturating_mul(base);
let pen_sq = (penalty.value.unsigned_abs()).saturating_mul(penalty.value.unsigned_abs());
if penalty.value >= 0 {
Some(base_sq.saturating_add(pen_sq))
} else if penalty.value > i64::MIN {
Some(base_sq.saturating_sub(pen_sq))
} else {
Some(base_sq)
}
}
#[must_use]
pub fn adjacency_demerits(
&self,
prev_fitness: FitnessClass,
curr_fitness: FitnessClass,
prev_flagged: bool,
curr_flagged: bool,
) -> u64 {
let mut extra = 0u64;
if prev_fitness.incompatible(curr_fitness) {
extra = extra.saturating_add(self.fitness_demerit);
}
if prev_flagged && curr_flagged {
extra = extra.saturating_add(self.double_hyphen_demerit);
}
extra
}
#[must_use]
pub fn widow_demerits(&self, last_line_chars: usize) -> u64 {
if last_line_chars < self.widow_threshold {
self.widow_demerit
} else {
0
}
}
#[must_use]
pub fn orphan_demerits(&self, first_line_chars: usize) -> u64 {
if first_line_chars < self.orphan_threshold {
self.orphan_demerit
} else {
0
}
}
}
#[cfg(test)]
trait TestWidth {
fn width(&self) -> usize;
}
#[cfg(test)]
impl TestWidth for str {
fn width(&self) -> usize {
display_width(self)
}
}
#[cfg(test)]
impl TestWidth for String {
fn width(&self) -> usize {
display_width(self)
}
}
#[cfg(test)]
mod tests {
use super::TestWidth;
use super::*;
#[test]
fn wrap_text_no_wrap_needed() {
let lines = wrap_text("hello", 10, WrapMode::Word);
assert_eq!(lines, vec!["hello"]);
}
#[test]
fn wrap_text_single_word_wrap() {
let lines = wrap_text("hello world", 5, WrapMode::Word);
assert_eq!(lines, vec!["hello", "world"]);
}
#[test]
fn wrap_text_multiple_words() {
let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
assert_eq!(lines, vec!["hello world", "foo bar"]);
}
#[test]
fn wrap_text_preserves_newlines() {
let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
assert_eq!(lines, vec!["line1", "line2"]);
}
#[test]
fn wrap_text_preserves_crlf_newlines() {
let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
assert_eq!(lines, vec!["line1", "line2", ""]);
}
#[test]
fn wrap_text_trailing_newlines() {
let lines = wrap_text("line1\n", 20, WrapMode::Word);
assert_eq!(lines, vec!["line1", ""]);
let lines = wrap_text("\n", 20, WrapMode::Word);
assert_eq!(lines, vec!["", ""]);
let lines = wrap_text("line1\n", 20, WrapMode::Char);
assert_eq!(lines, vec!["line1", ""]);
}
#[test]
fn wrap_text_empty_string() {
let lines = wrap_text("", 10, WrapMode::Word);
assert_eq!(lines, vec![""]);
}
#[test]
fn wrap_text_long_word_no_fallback() {
let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
assert_eq!(lines, vec!["supercalifragilistic"]);
}
#[test]
fn wrap_text_long_word_with_fallback() {
let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
assert!(lines.len() > 1);
for line in &lines {
assert!(line.width() <= 10);
}
}
#[test]
fn wrap_char_mode() {
let lines = wrap_text("hello world", 5, WrapMode::Char);
assert_eq!(lines, vec!["hello", " worl", "d"]);
}
#[test]
fn wrap_none_mode() {
let lines = wrap_text("hello world", 5, WrapMode::None);
assert_eq!(lines, vec!["hello world"]);
}
#[test]
fn wrap_cjk_respects_width() {
let lines = wrap_text("你好世界", 4, WrapMode::Char);
assert_eq!(lines, vec!["你好", "世界"]);
}
#[test]
fn wrap_cjk_odd_width() {
let lines = wrap_text("你好世", 5, WrapMode::Char);
assert_eq!(lines, vec!["你好", "世"]);
}
#[test]
fn wrap_mixed_ascii_cjk() {
let lines = wrap_text("hi你好", 4, WrapMode::Char);
assert_eq!(lines, vec!["hi你", "好"]);
}
#[test]
fn wrap_emoji_as_unit() {
let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
assert_eq!(lines.len(), 2);
for line in &lines {
assert!(!line.contains("\\u"));
}
}
#[test]
fn wrap_zwj_sequence_as_unit() {
let text = "👨👩👧";
let lines = wrap_text(text, 2, WrapMode::Char);
assert!(lines.iter().any(|l| l.contains("👨👩👧")));
}
#[test]
fn wrap_mixed_ascii_and_emoji_respects_width() {
let lines = wrap_text("a😀b", 3, WrapMode::Char);
assert_eq!(lines, vec!["a😀", "b"]);
}
#[test]
fn truncate_no_change_if_fits() {
let result = truncate_with_ellipsis("hello", 10, "...");
assert_eq!(result, "hello");
}
#[test]
fn truncate_with_ellipsis_ascii() {
let result = truncate_with_ellipsis("hello world", 8, "...");
assert_eq!(result, "hello...");
}
#[test]
fn truncate_cjk() {
let result = truncate_with_ellipsis("你好世界", 6, "...");
assert_eq!(result, "你...");
}
#[test]
fn truncate_to_width_basic() {
let result = truncate_to_width("hello world", 5);
assert_eq!(result, "hello");
}
#[test]
fn truncate_to_width_cjk() {
let result = truncate_to_width("你好世界", 4);
assert_eq!(result, "你好");
}
#[test]
fn truncate_to_width_odd_boundary() {
let result = truncate_to_width("你好", 3);
assert_eq!(result, "你");
}
#[test]
fn truncate_combining_chars() {
let text = "e\u{0301}test";
let result = truncate_to_width(text, 2);
assert_eq!(result.chars().count(), 3); }
#[test]
fn display_width_ascii() {
assert_eq!(display_width("hello"), 5);
}
#[test]
fn display_width_cjk() {
assert_eq!(display_width("你好"), 4);
}
#[test]
fn display_width_emoji_sequences() {
assert_eq!(display_width("👩🔬"), 2);
assert_eq!(display_width("👨👩👧👦"), 2);
assert_eq!(display_width("👩🚀x"), 3);
}
#[test]
fn display_width_misc_symbol_emoji() {
assert_eq!(display_width("⏳"), 2);
assert_eq!(display_width("⌛"), 2);
}
#[test]
fn display_width_emoji_presentation_selector() {
assert_eq!(display_width("❤️"), 1);
assert_eq!(display_width("⌨️"), 1);
assert_eq!(display_width("⚠️"), 1);
}
#[test]
fn display_width_misc_symbol_ranges() {
assert_eq!(display_width("⌚"), 2); assert_eq!(display_width("⭐"), 2);
let airplane_width = display_width("✈"); let arrow_width = display_width("⬆"); assert!(
[1, 2].contains(&airplane_width),
"airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
);
assert_eq!(
airplane_width, arrow_width,
"both Neutral-width chars should have same width in any mode"
);
}
#[test]
fn display_width_flags() {
assert_eq!(display_width("🇺🇸"), 2);
assert_eq!(display_width("🇯🇵"), 2);
assert_eq!(display_width("🇺🇸🇯🇵"), 4);
}
#[test]
fn display_width_skin_tone_modifiers() {
assert_eq!(display_width("👍🏻"), 2);
assert_eq!(display_width("👍🏽"), 2);
}
#[test]
fn display_width_zwj_sequences() {
assert_eq!(display_width("👩💻"), 2);
assert_eq!(display_width("👨👩👧👦"), 2);
}
#[test]
fn display_width_mixed_ascii_and_emoji() {
assert_eq!(display_width("A😀B"), 4);
assert_eq!(display_width("A👩💻B"), 4);
assert_eq!(display_width("ok ✅"), 5);
}
#[test]
fn display_width_file_icons() {
let wide_icons = ["📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄"];
for icon in wide_icons {
assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
}
let narrow_icons = ["⚙️", "🖼️"];
for icon in narrow_icons {
assert_eq!(display_width(icon), 1, "VS16 icon width mismatch: {icon}");
}
}
#[test]
fn grapheme_width_emoji_sequence() {
assert_eq!(grapheme_width("👩🔬"), 2);
}
#[test]
fn grapheme_width_flags_and_modifiers() {
assert_eq!(grapheme_width("🇺🇸"), 2);
assert_eq!(grapheme_width("👍🏽"), 2);
}
#[test]
fn display_width_empty() {
assert_eq!(display_width(""), 0);
}
#[test]
fn ascii_width_pure_ascii() {
assert_eq!(ascii_width("hello"), Some(5));
assert_eq!(ascii_width("hello world 123"), Some(15));
}
#[test]
fn ascii_width_empty() {
assert_eq!(ascii_width(""), Some(0));
}
#[test]
fn ascii_width_non_ascii_returns_none() {
assert_eq!(ascii_width("你好"), None);
assert_eq!(ascii_width("héllo"), None);
assert_eq!(ascii_width("hello😀"), None);
}
#[test]
fn ascii_width_mixed_returns_none() {
assert_eq!(ascii_width("hi你好"), None);
assert_eq!(ascii_width("caf\u{00e9}"), None); }
#[test]
fn ascii_width_control_chars_returns_none() {
assert_eq!(ascii_width("\t"), None); assert_eq!(ascii_width("\n"), None); assert_eq!(ascii_width("\r"), None); assert_eq!(ascii_width("\0"), None); assert_eq!(ascii_width("\x7F"), None); assert_eq!(ascii_width("hello\tworld"), None); assert_eq!(ascii_width("line1\nline2"), None); }
#[test]
fn display_width_uses_ascii_fast_path() {
assert_eq!(display_width("test"), 4);
assert_eq!(display_width("你"), 2);
}
#[test]
fn has_wide_chars_true() {
assert!(has_wide_chars("hi你好"));
}
#[test]
fn has_wide_chars_false() {
assert!(!has_wide_chars("hello"));
}
#[test]
fn is_ascii_only_true() {
assert!(is_ascii_only("hello world 123"));
}
#[test]
fn is_ascii_only_false() {
assert!(!is_ascii_only("héllo"));
}
#[test]
fn grapheme_count_ascii() {
assert_eq!(grapheme_count("hello"), 5);
assert_eq!(grapheme_count(""), 0);
}
#[test]
fn grapheme_count_combining() {
assert_eq!(grapheme_count("e\u{0301}"), 1);
assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
}
#[test]
fn grapheme_count_cjk() {
assert_eq!(grapheme_count("你好"), 2);
}
#[test]
fn grapheme_count_emoji() {
assert_eq!(grapheme_count("😀"), 1);
assert_eq!(grapheme_count("👍🏻"), 1);
}
#[test]
fn grapheme_count_zwj() {
assert_eq!(grapheme_count("👨👩👧"), 1);
}
#[test]
fn graphemes_iteration() {
let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
}
#[test]
fn graphemes_empty() {
let gs: Vec<&str> = graphemes("").collect();
assert!(gs.is_empty());
}
#[test]
fn graphemes_cjk() {
let gs: Vec<&str> = graphemes("你好").collect();
assert_eq!(gs, vec!["你", "好"]);
}
#[test]
fn truncate_to_width_with_info_basic() {
let (text, width) = truncate_to_width_with_info("hello world", 5);
assert_eq!(text, "hello");
assert_eq!(width, 5);
}
#[test]
fn truncate_to_width_with_info_cjk() {
let (text, width) = truncate_to_width_with_info("你好世界", 3);
assert_eq!(text, "你");
assert_eq!(width, 2);
}
#[test]
fn truncate_to_width_with_info_combining() {
let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
assert_eq!(text, "e\u{0301}b");
assert_eq!(width, 2);
}
#[test]
fn truncate_to_width_with_info_fits() {
let (text, width) = truncate_to_width_with_info("hi", 10);
assert_eq!(text, "hi");
assert_eq!(width, 2);
}
#[test]
fn word_boundaries_basic() {
let breaks: Vec<usize> = word_boundaries("hello world").collect();
assert!(breaks.contains(&6)); }
#[test]
fn word_boundaries_multiple_spaces() {
let breaks: Vec<usize> = word_boundaries("a b").collect();
assert!(breaks.contains(&3)); }
#[test]
fn word_segments_basic() {
let segs: Vec<&str> = word_segments("hello world").collect();
assert!(segs.contains(&"hello"));
assert!(segs.contains(&"world"));
}
#[test]
fn wrap_options_builder() {
let opts = WrapOptions::new(40)
.mode(WrapMode::Char)
.preserve_indent(true)
.trim_trailing(false);
assert_eq!(opts.width, 40);
assert_eq!(opts.mode, WrapMode::Char);
assert!(opts.preserve_indent);
assert!(!opts.trim_trailing);
}
#[test]
fn wrap_options_trim_trailing() {
let opts = WrapOptions::new(10).trim_trailing(true);
let lines = wrap_with_options("hello world", &opts);
assert!(!lines.iter().any(|l| l.ends_with(' ')));
}
#[test]
fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
let opts = WrapOptions::new(7)
.mode(WrapMode::Word)
.preserve_indent(true);
let lines = wrap_with_options("word12 abcde", &opts);
assert_eq!(lines, vec!["word12", " abcde"]);
}
#[test]
fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
let opts = WrapOptions::new(7)
.mode(WrapMode::Word)
.preserve_indent(false);
let lines = wrap_with_options("word12 abcde", &opts);
assert_eq!(lines, vec!["word12", "abcde"]);
}
#[test]
fn wrap_zero_width() {
let lines = wrap_text("hello", 0, WrapMode::Word);
assert_eq!(lines, vec!["hello"]);
}
#[test]
fn wrap_mode_default() {
let mode = WrapMode::default();
assert_eq!(mode, WrapMode::Word);
}
#[test]
fn wrap_options_default() {
let opts = WrapOptions::default();
assert_eq!(opts.width, 80);
assert_eq!(opts.mode, WrapMode::Word);
assert!(!opts.preserve_indent);
assert!(opts.trim_trailing);
}
#[test]
fn display_width_emoji_skin_tone() {
let width = display_width("👍🏻");
assert_eq!(width, 2);
}
#[test]
fn display_width_flag_emoji() {
let width = display_width("🇺🇸");
assert_eq!(width, 2);
}
#[test]
fn display_width_zwj_family() {
let width = display_width("👨👩👧");
assert_eq!(width, 2);
}
#[test]
fn display_width_multiple_combining() {
let width = display_width("e\u{0301}\u{0308}");
assert_eq!(width, 1);
}
#[test]
fn ascii_width_printable_range() {
let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
assert_eq!(ascii_width(&printable), Some(printable.len()));
}
#[test]
fn ascii_width_newline_returns_none() {
assert!(ascii_width("hello\nworld").is_none());
}
#[test]
fn ascii_width_tab_returns_none() {
assert!(ascii_width("hello\tworld").is_none());
}
#[test]
fn ascii_width_del_returns_none() {
assert!(ascii_width("hello\x7Fworld").is_none());
}
#[test]
fn has_wide_chars_cjk_mixed() {
assert!(has_wide_chars("abc你def"));
assert!(has_wide_chars("你"));
assert!(!has_wide_chars("abc"));
}
#[test]
fn has_wide_chars_emoji() {
assert!(has_wide_chars("😀"));
assert!(has_wide_chars("hello😀"));
}
#[test]
fn grapheme_count_empty() {
assert_eq!(grapheme_count(""), 0);
}
#[test]
fn grapheme_count_regional_indicators() {
assert_eq!(grapheme_count("🇺🇸"), 1);
}
#[test]
fn word_boundaries_no_spaces() {
let breaks: Vec<usize> = word_boundaries("helloworld").collect();
assert!(breaks.is_empty());
}
#[test]
fn word_boundaries_only_spaces() {
let breaks: Vec<usize> = word_boundaries(" ").collect();
assert!(!breaks.is_empty());
}
#[test]
fn word_segments_empty() {
let segs: Vec<&str> = word_segments("").collect();
assert!(segs.is_empty());
}
#[test]
fn word_segments_single_word() {
let segs: Vec<&str> = word_segments("hello").collect();
assert_eq!(segs.len(), 1);
assert_eq!(segs[0], "hello");
}
#[test]
fn truncate_to_width_empty() {
let result = truncate_to_width("", 10);
assert_eq!(result, "");
}
#[test]
fn truncate_to_width_zero_width() {
let result = truncate_to_width("hello", 0);
assert_eq!(result, "");
}
#[test]
fn truncate_with_ellipsis_exact_fit() {
let result = truncate_with_ellipsis("hello", 5, "...");
assert_eq!(result, "hello");
}
#[test]
fn truncate_with_ellipsis_empty_ellipsis() {
let result = truncate_with_ellipsis("hello world", 5, "");
assert_eq!(result, "hello");
}
#[test]
fn truncate_to_width_with_info_empty() {
let (text, width) = truncate_to_width_with_info("", 10);
assert_eq!(text, "");
assert_eq!(width, 0);
}
#[test]
fn truncate_to_width_with_info_zero_width() {
let (text, width) = truncate_to_width_with_info("hello", 0);
assert_eq!(text, "");
assert_eq!(width, 0);
}
#[test]
fn truncate_to_width_wide_char_boundary() {
let (text, width) = truncate_to_width_with_info("a你好", 2);
assert_eq!(text, "a");
assert_eq!(width, 1);
}
#[test]
fn wrap_mode_none() {
let lines = wrap_text("hello world", 5, WrapMode::None);
assert_eq!(lines, vec!["hello world"]);
}
#[test]
fn wrap_long_word_no_char_fallback() {
let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
for line in &lines {
assert!(line.width() <= 10);
}
}
#[test]
fn unit_badness_monotone() {
let width = 80;
let mut prev = knuth_plass_badness(0, width, false);
for slack in 1..=80i64 {
let bad = knuth_plass_badness(slack, width, false);
assert!(
bad >= prev,
"badness must be monotonically non-decreasing: \
badness({slack}) = {bad} < badness({}) = {prev}",
slack - 1
);
prev = bad;
}
}
#[test]
fn unit_badness_zero_slack() {
assert_eq!(knuth_plass_badness(0, 80, false), 0);
assert_eq!(knuth_plass_badness(0, 80, true), 0);
}
#[test]
fn unit_badness_overflow_is_inf() {
assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
}
#[test]
fn unit_badness_last_line_always_zero() {
assert_eq!(knuth_plass_badness(0, 80, true), 0);
assert_eq!(knuth_plass_badness(40, 80, true), 0);
assert_eq!(knuth_plass_badness(79, 80, true), 0);
}
#[test]
fn unit_badness_cubic_growth() {
let width = 100;
let b10 = knuth_plass_badness(10, width, false);
let b20 = knuth_plass_badness(20, width, false);
let b40 = knuth_plass_badness(40, width, false);
assert!(
b20 >= b10 * 6,
"doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
b20.checked_div(b10).unwrap_or(0)
);
assert!(
b40 >= b20 * 6,
"doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
b40.checked_div(b20).unwrap_or(0)
);
}
#[test]
fn unit_penalty_applied() {
let result = wrap_optimal("superlongwordthatcannotfit", 10);
assert!(
result.total_cost >= PENALTY_FORCE_BREAK,
"force-break penalty should be applied: cost={}",
result.total_cost
);
}
#[test]
fn kp_simple_wrap() {
let result = wrap_optimal("Hello world foo bar", 10);
for line in &result.lines {
assert!(
line.width() <= 10,
"line '{line}' exceeds width 10 (width={})",
line.width()
);
}
assert!(result.lines.len() >= 2);
}
#[test]
fn kp_perfect_fit() {
let result = wrap_optimal("aaaa bbbb", 9);
assert_eq!(result.lines.len(), 1);
assert_eq!(result.total_cost, 0);
}
#[test]
fn kp_optimal_vs_greedy() {
let result = wrap_optimal("aaa bb cc ddddd", 6);
for line in &result.lines {
assert!(line.width() <= 6, "line '{line}' exceeds width 6");
}
assert!(result.lines.len() >= 2);
}
#[test]
fn kp_empty_text() {
let result = wrap_optimal("", 80);
assert_eq!(result.lines, vec![""]);
assert_eq!(result.total_cost, 0);
}
#[test]
fn kp_single_word() {
let result = wrap_optimal("hello", 80);
assert_eq!(result.lines, vec!["hello"]);
assert_eq!(result.total_cost, 0); }
#[test]
fn kp_multiline_preserves_newlines() {
let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
assert!(lines.len() >= 2);
assert!(lines[0].width() <= 10);
}
#[test]
fn kp_tokenize_basic() {
let words = kp_tokenize("hello world foo");
assert_eq!(words.len(), 3);
assert_eq!(words[0].content_width, 5);
assert_eq!(words[0].space_width, 1);
assert_eq!(words[1].content_width, 5);
assert_eq!(words[1].space_width, 1);
assert_eq!(words[2].content_width, 3);
assert_eq!(words[2].space_width, 0);
}
#[test]
fn kp_diagnostics_line_badness() {
let result = wrap_optimal("short text here for testing the dp", 15);
assert_eq!(result.line_badness.len(), result.lines.len());
assert_eq!(
*result.line_badness.last().unwrap(),
0,
"last line should have zero badness"
);
}
#[test]
fn kp_deterministic() {
let text = "The quick brown fox jumps over the lazy dog near a riverbank";
let r1 = wrap_optimal(text, 20);
let r2 = wrap_optimal(text, 20);
assert_eq!(r1.lines, r2.lines);
assert_eq!(r1.total_cost, r2.total_cost);
}
#[test]
fn unit_dp_matches_known() {
let result = wrap_optimal("aaa bb cc ddddd", 6);
for line in &result.lines {
assert!(line.width() <= 6, "line '{line}' exceeds width 6");
}
assert_eq!(
result.lines.len(),
3,
"expected 3 lines, got {:?}",
result.lines
);
assert_eq!(result.lines[0], "aaa");
assert_eq!(result.lines[1], "bb cc");
assert_eq!(result.lines[2], "ddddd");
assert_eq!(*result.line_badness.last().unwrap(), 0);
}
#[test]
fn unit_dp_known_two_line() {
let r1 = wrap_optimal("hello world", 11);
assert_eq!(r1.lines, vec!["hello world"]);
assert_eq!(r1.total_cost, 0);
let r2 = wrap_optimal("hello world", 7);
assert_eq!(r2.lines.len(), 2);
assert_eq!(r2.lines[0], "hello");
assert_eq!(r2.lines[1], "world");
assert!(
r2.total_cost > 0 && r2.total_cost < 300,
"expected cost ~233, got {}",
r2.total_cost
);
}
#[test]
fn unit_dp_optimal_beats_greedy() {
let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
let optimal = wrap_optimal("the quick brown fox", 10);
for line in &greedy {
assert!(line.width() <= 10);
}
for line in &optimal.lines {
assert!(line.width() <= 10);
}
let mut greedy_cost: u64 = 0;
for (i, line) in greedy.iter().enumerate() {
let slack = 10i64 - line.width() as i64;
let is_last = i == greedy.len() - 1;
greedy_cost += knuth_plass_badness(slack, 10, is_last);
}
assert!(
optimal.total_cost <= greedy_cost,
"optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
optimal.total_cost,
greedy_cost
);
}
#[test]
fn perf_wrap_large() {
use std::time::Instant;
let words: Vec<&str> = [
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
"back", "to", "its", "den", "in",
]
.to_vec();
let mut paragraph = String::new();
for i in 0..1000 {
if i > 0 {
paragraph.push(' ');
}
paragraph.push_str(words[i % words.len()]);
}
let iterations = 20;
let start = Instant::now();
for _ in 0..iterations {
let result = wrap_optimal(¶graph, 80);
assert!(!result.lines.is_empty());
}
let elapsed = start.elapsed();
eprintln!(
"{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
iterations,
elapsed.as_millis(),
elapsed.as_micros() / iterations as u128
);
assert!(
elapsed.as_secs() < 2,
"Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
);
}
#[test]
fn kp_pruning_lookahead_bound() {
let text = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
let result = wrap_optimal(text, 10);
for line in &result.lines {
assert!(line.width() <= 10, "line '{line}' exceeds width");
}
let joined: String = result.lines.join(" ");
for ch in 'a'..='z' {
assert!(joined.contains(ch), "missing letter '{ch}' in output");
}
}
#[test]
fn kp_very_narrow_width() {
let result = wrap_optimal("ab cd ef", 2);
assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
}
#[test]
fn kp_wide_width_single_line() {
let result = wrap_optimal("hello world", 1000);
assert_eq!(result.lines, vec!["hello world"]);
assert_eq!(result.total_cost, 0);
}
fn fnv1a_lines(lines: &[String]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for (i, line) in lines.iter().enumerate() {
for byte in (i as u32)
.to_le_bytes()
.iter()
.chain(line.as_bytes().iter())
{
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
}
hash
}
#[test]
fn snapshot_wrap_quality() {
let paragraphs = [
"The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
"To be or not to be that is the question whether tis nobler in the mind to suffer the slings and arrows of outrageous fortune",
"aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
];
let widths = [20, 40, 60, 80];
for paragraph in ¶graphs {
for &width in &widths {
let result = wrap_optimal(paragraph, width);
let result2 = wrap_optimal(paragraph, width);
assert_eq!(
fnv1a_lines(&result.lines),
fnv1a_lines(&result2.lines),
"non-deterministic wrap at width {width}"
);
for line in &result.lines {
assert!(line.width() <= width, "line '{line}' exceeds width {width}");
}
if !paragraph.is_empty() {
for line in &result.lines {
assert!(!line.is_empty(), "empty line in output at width {width}");
}
}
let original_words: Vec<&str> = paragraph.split_whitespace().collect();
let result_words: Vec<&str> = result
.lines
.iter()
.flat_map(|l| l.split_whitespace())
.collect();
assert_eq!(
original_words, result_words,
"content lost at width {width}"
);
assert_eq!(
*result.line_badness.last().unwrap(),
0,
"last line should have zero badness at width {width}"
);
}
}
}
#[test]
fn perf_wrap_bench() {
use std::time::Instant;
let sample_words = [
"the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
"back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
"near",
];
let scenarios: &[(usize, usize, &str)] = &[
(50, 40, "short_40"),
(50, 80, "short_80"),
(200, 40, "medium_40"),
(200, 80, "medium_80"),
(500, 40, "long_40"),
(500, 80, "long_80"),
];
for &(word_count, width, label) in scenarios {
let mut paragraph = String::new();
for i in 0..word_count {
if i > 0 {
paragraph.push(' ');
}
paragraph.push_str(sample_words[i % sample_words.len()]);
}
let iterations = 30u32;
let mut times_us = Vec::with_capacity(iterations as usize);
let mut last_lines = 0usize;
let mut last_cost = 0u64;
let mut last_checksum = 0u64;
for _ in 0..iterations {
let start = Instant::now();
let result = wrap_optimal(¶graph, width);
let elapsed = start.elapsed();
last_lines = result.lines.len();
last_cost = result.total_cost;
last_checksum = fnv1a_lines(&result.lines);
times_us.push(elapsed.as_micros() as u64);
}
times_us.sort();
let len = times_us.len();
let p50 = times_us[len / 2];
let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
eprintln!(
"{{\"ts\":\"2026-02-03T00:00:00Z\",\"test\":\"perf_wrap_bench\",\"scenario\":\"{label}\",\"words\":{word_count},\"width\":{width},\"lines\":{last_lines},\"badness_total\":{last_cost},\"algorithm\":\"dp\",\"p50_us\":{p50},\"p95_us\":{p95},\"breaks_checksum\":\"0x{last_checksum:016x}\"}}"
);
let verify = wrap_optimal(¶graph, width);
assert_eq!(
fnv1a_lines(&verify.lines),
last_checksum,
"non-deterministic: {label}"
);
if word_count >= 500 && p95 > 5000 {
eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
}
}
}
}
#[cfg(test)]
mod proptests {
use super::TestWidth;
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
let lines = wrap_text(&s, width, WrapMode::Char);
for line in &lines {
prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
}
}
#[test]
fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
let lines = wrap_text(&s, width, WrapMode::Char);
let rejoined: String = lines.join("");
prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
}
#[test]
fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
let result = truncate_with_ellipsis(&s, width, "...");
prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
}
#[test]
fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
let result = truncate_to_width(&s, width);
prop_assert!(result.width() <= width);
if s.width() > width {
prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
}
}
#[test]
fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
let lines = wrap_text(&s, width, WrapMode::WordChar);
for line in &lines {
prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
}
}
#[test]
fn property_dp_vs_greedy(
text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
width in 8usize..40,
) {
let greedy = wrap_text(&text, width, WrapMode::Word);
let optimal = wrap_optimal(&text, width);
let mut greedy_cost: u64 = 0;
for (i, line) in greedy.iter().enumerate() {
let lw = line.width();
let slack = width as i64 - lw as i64;
let is_last = i == greedy.len() - 1;
if slack >= 0 {
greedy_cost = greedy_cost.saturating_add(
knuth_plass_badness(slack, width, is_last)
);
} else {
greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
}
}
prop_assert!(
optimal.total_cost <= greedy_cost,
"DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
optimal.total_cost, greedy_cost, width, optimal.lines, greedy
);
}
#[test]
fn property_dp_respects_width(
text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
width in 6usize..30,
) {
let result = wrap_optimal(&text, width);
for line in &result.lines {
prop_assert!(
line.width() <= width,
"DP line '{}' (width {}) exceeds target {}",
line, line.width(), width
);
}
}
#[test]
fn property_dp_preserves_content(
text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
width in 8usize..30,
) {
let result = wrap_optimal(&text, width);
let original_words: Vec<&str> = text.split_whitespace().collect();
let result_words: Vec<&str> = result.lines.iter()
.flat_map(|l| l.split_whitespace())
.collect();
prop_assert_eq!(
original_words, result_words,
"DP should preserve all words"
);
}
}
#[test]
fn fitness_class_from_ratio() {
assert_eq!(FitnessClass::from_ratio(-0.8), FitnessClass::Tight);
assert_eq!(FitnessClass::from_ratio(-0.5), FitnessClass::Normal);
assert_eq!(FitnessClass::from_ratio(0.0), FitnessClass::Normal);
assert_eq!(FitnessClass::from_ratio(0.49), FitnessClass::Normal);
assert_eq!(FitnessClass::from_ratio(0.5), FitnessClass::Loose);
assert_eq!(FitnessClass::from_ratio(0.99), FitnessClass::Loose);
assert_eq!(FitnessClass::from_ratio(1.0), FitnessClass::VeryLoose);
assert_eq!(FitnessClass::from_ratio(2.0), FitnessClass::VeryLoose);
}
#[test]
fn fitness_class_incompatible() {
assert!(!FitnessClass::Tight.incompatible(FitnessClass::Tight));
assert!(!FitnessClass::Tight.incompatible(FitnessClass::Normal));
assert!(FitnessClass::Tight.incompatible(FitnessClass::Loose));
assert!(FitnessClass::Tight.incompatible(FitnessClass::VeryLoose));
assert!(!FitnessClass::Normal.incompatible(FitnessClass::Loose));
assert!(FitnessClass::Normal.incompatible(FitnessClass::VeryLoose));
}
#[test]
fn objective_default_is_tex_standard() {
let obj = ParagraphObjective::default();
assert_eq!(obj.line_penalty, 10);
assert_eq!(obj.fitness_demerit, 100);
assert_eq!(obj.double_hyphen_demerit, 100);
assert_eq!(obj.badness_scale, BADNESS_SCALE);
}
#[test]
fn objective_terminal_preset() {
let obj = ParagraphObjective::terminal();
assert_eq!(obj.line_penalty, 20);
assert_eq!(obj.min_adjustment_ratio, 0.0);
assert!(obj.max_adjustment_ratio > 2.0);
}
#[test]
fn badness_zero_slack_is_zero() {
let obj = ParagraphObjective::default();
assert_eq!(obj.badness(0, 80), Some(0));
}
#[test]
fn badness_moderate_slack() {
let obj = ParagraphObjective::default();
let b = obj.badness(10, 80).unwrap();
assert!(b > 0 && b < 100, "badness = {b}");
}
#[test]
fn badness_excessive_slack_infeasible() {
let obj = ParagraphObjective::default();
assert!(obj.badness(240, 80).is_none());
}
#[test]
fn badness_negative_slack_within_bounds() {
let obj = ParagraphObjective::default();
let b = obj.badness(-40, 80);
assert!(b.is_some());
}
#[test]
fn badness_negative_slack_beyond_bounds() {
let obj = ParagraphObjective::default();
assert!(obj.badness(-100, 80).is_none());
}
#[test]
fn badness_terminal_no_compression() {
let obj = ParagraphObjective::terminal();
assert!(obj.badness(-1, 80).is_none());
}
#[test]
fn demerits_space_break() {
let obj = ParagraphObjective::default();
let d = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
let badness = obj.badness(10, 80).unwrap();
let expected = (obj.line_penalty + badness).pow(2);
assert_eq!(d, expected);
}
#[test]
fn demerits_hyphen_break() {
let obj = ParagraphObjective::default();
let d_space = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
let d_hyphen = obj.demerits(10, 80, &BreakPenalty::HYPHEN).unwrap();
assert!(d_hyphen > d_space);
}
#[test]
fn demerits_forced_break() {
let obj = ParagraphObjective::default();
let d = obj.demerits(0, 80, &BreakPenalty::FORCED).unwrap();
assert_eq!(d, obj.line_penalty.pow(2));
}
#[test]
fn demerits_infeasible_returns_none() {
let obj = ParagraphObjective::default();
assert!(obj.demerits(300, 80, &BreakPenalty::SPACE).is_none());
}
#[test]
fn adjacency_fitness_incompatible() {
let obj = ParagraphObjective::default();
let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::Loose, false, false);
assert_eq!(d, obj.fitness_demerit);
}
#[test]
fn adjacency_fitness_compatible() {
let obj = ParagraphObjective::default();
let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Loose, false, false);
assert_eq!(d, 0);
}
#[test]
fn adjacency_double_hyphen() {
let obj = ParagraphObjective::default();
let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Normal, true, true);
assert_eq!(d, obj.double_hyphen_demerit);
}
#[test]
fn adjacency_double_hyphen_plus_fitness() {
let obj = ParagraphObjective::default();
let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::VeryLoose, true, true);
assert_eq!(d, obj.fitness_demerit + obj.double_hyphen_demerit);
}
#[test]
fn widow_penalty_short_last_line() {
let obj = ParagraphObjective::default();
assert_eq!(obj.widow_demerits(5), obj.widow_demerit);
assert_eq!(obj.widow_demerits(14), obj.widow_demerit);
assert_eq!(obj.widow_demerits(15), 0);
assert_eq!(obj.widow_demerits(80), 0);
}
#[test]
fn orphan_penalty_short_first_line() {
let obj = ParagraphObjective::default();
assert_eq!(obj.orphan_demerits(10), obj.orphan_demerit);
assert_eq!(obj.orphan_demerits(19), obj.orphan_demerit);
assert_eq!(obj.orphan_demerits(20), 0);
assert_eq!(obj.orphan_demerits(80), 0);
}
#[test]
fn adjustment_ratio_computation() {
let obj = ParagraphObjective::default();
let r = obj.adjustment_ratio(10, 80);
assert!((r - 0.125).abs() < 1e-10);
}
#[test]
fn adjustment_ratio_zero_width() {
let obj = ParagraphObjective::default();
assert_eq!(obj.adjustment_ratio(5, 0), 0.0);
}
#[test]
fn badness_zero_width_zero_slack() {
let obj = ParagraphObjective::default();
assert_eq!(obj.badness(0, 0), Some(0));
}
#[test]
fn badness_zero_width_nonzero_slack() {
let obj = ParagraphObjective::default();
assert!(obj.badness(5, 0).is_none());
}
}