1#![forbid(unsafe_code)]
2
3use unicode_segmentation::UnicodeSegmentation;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum WrapMode {
28 None,
30 #[default]
32 Word,
33 Char,
35 WordChar,
37}
38
39#[derive(Debug, Clone)]
41pub struct WrapOptions {
42 pub width: usize,
44 pub mode: WrapMode,
46 pub preserve_indent: bool,
48 pub trim_trailing: bool,
50}
51
52impl WrapOptions {
53 #[must_use]
55 pub fn new(width: usize) -> Self {
56 Self {
57 width,
58 mode: WrapMode::Word,
59 preserve_indent: false,
60 trim_trailing: true,
61 }
62 }
63
64 #[must_use]
66 pub fn mode(mut self, mode: WrapMode) -> Self {
67 self.mode = mode;
68 self
69 }
70
71 #[must_use]
73 pub fn preserve_indent(mut self, preserve: bool) -> Self {
74 self.preserve_indent = preserve;
75 self
76 }
77
78 #[must_use]
80 pub fn trim_trailing(mut self, trim: bool) -> Self {
81 self.trim_trailing = trim;
82 self
83 }
84}
85
86impl Default for WrapOptions {
87 fn default() -> Self {
88 Self::new(80)
89 }
90}
91
92#[must_use]
96pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
97 let preserve = mode == WrapMode::Char;
99 wrap_with_options(
100 text,
101 &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
102 )
103}
104
105#[must_use]
107pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
108 if options.width == 0 {
109 return vec![text.to_string()];
110 }
111
112 match options.mode {
113 WrapMode::None => vec![text.to_string()],
114 WrapMode::Char => wrap_chars(text, options),
115 WrapMode::Word => wrap_words(text, options, false),
116 WrapMode::WordChar => wrap_words(text, options, true),
117 }
118}
119
120fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
122 let mut lines = Vec::new();
123 let mut current_line = String::new();
124 let mut current_width = 0;
125
126 for grapheme in text.graphemes(true) {
127 if grapheme == "\n" || grapheme == "\r\n" {
129 lines.push(finalize_line(¤t_line, options));
130 current_line.clear();
131 current_width = 0;
132 continue;
133 }
134
135 let grapheme_width = crate::wrap::grapheme_width(grapheme);
136
137 if current_width + grapheme_width > options.width && !current_line.is_empty() {
139 lines.push(finalize_line(¤t_line, options));
140 current_line.clear();
141 current_width = 0;
142 }
143
144 current_line.push_str(grapheme);
146 current_width += grapheme_width;
147 }
148
149 lines.push(finalize_line(¤t_line, options));
152
153 lines
154}
155
156fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
158 let mut lines = Vec::new();
159
160 for raw_paragraph in text.split('\n') {
162 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
163 let mut current_line = String::new();
164 let mut current_width = 0;
165
166 let len_before = lines.len();
167
168 wrap_paragraph(
169 paragraph,
170 options,
171 char_fallback,
172 &mut lines,
173 &mut current_line,
174 &mut current_width,
175 );
176
177 if !current_line.is_empty() || lines.len() == len_before {
180 lines.push(finalize_line(¤t_line, options));
181 }
182 }
183
184 lines
185}
186
187fn wrap_paragraph(
189 text: &str,
190 options: &WrapOptions,
191 char_fallback: bool,
192 lines: &mut Vec<String>,
193 current_line: &mut String,
194 current_width: &mut usize,
195) {
196 for word in split_words(text) {
197 let word_width = display_width(&word);
198
199 if *current_width + word_width <= options.width {
201 current_line.push_str(&word);
202 *current_width += word_width;
203 continue;
204 }
205
206 if !current_line.is_empty() {
208 lines.push(finalize_line(current_line, options));
209 current_line.clear();
210 *current_width = 0;
211
212 if word.trim().is_empty() {
218 continue;
219 }
220 }
221
222 if word_width > options.width {
224 if char_fallback {
225 wrap_long_word(&word, options, lines, current_line, current_width);
227 } else {
228 lines.push(finalize_line(&word, options));
230 }
231 } else {
232 let (fragment, fragment_width) = if options.preserve_indent {
234 (word.as_str(), word_width)
235 } else {
236 let trimmed = word.trim_start();
237 (trimmed, display_width(trimmed))
238 };
239 if !fragment.is_empty() {
240 current_line.push_str(fragment);
241 }
242 *current_width = fragment_width;
243 }
244 }
245}
246
247fn wrap_long_word(
249 word: &str,
250 options: &WrapOptions,
251 lines: &mut Vec<String>,
252 current_line: &mut String,
253 current_width: &mut usize,
254) {
255 for grapheme in word.graphemes(true) {
256 let grapheme_width = crate::wrap::grapheme_width(grapheme);
257
258 if *current_width == 0 && grapheme.trim().is_empty() && !options.preserve_indent {
260 continue;
261 }
262
263 if *current_width + grapheme_width > options.width && !current_line.is_empty() {
264 lines.push(finalize_line(current_line, options));
265 current_line.clear();
266 *current_width = 0;
267
268 if grapheme.trim().is_empty() && !options.preserve_indent {
270 continue;
271 }
272 }
273
274 current_line.push_str(grapheme);
275 *current_width += grapheme_width;
276 }
277}
278
279fn split_words(text: &str) -> Vec<String> {
284 let mut words = Vec::new();
285 let mut current = String::new();
286 let mut in_whitespace = false;
287
288 for grapheme in text.graphemes(true) {
289 let is_ws = grapheme.chars().all(|c| c.is_whitespace());
290
291 if is_ws != in_whitespace && !current.is_empty() {
292 words.push(std::mem::take(&mut current));
293 }
294
295 current.push_str(grapheme);
296 in_whitespace = is_ws;
297 }
298
299 if !current.is_empty() {
300 words.push(current);
301 }
302
303 words
304}
305
306fn finalize_line(line: &str, options: &WrapOptions) -> String {
308 let mut result = if options.trim_trailing {
309 line.trim_end().to_string()
310 } else {
311 line.to_string()
312 };
313
314 if !options.preserve_indent {
315 let trimmed = result.trim_start();
335 if trimmed.len() != result.len() {
336 result = trimmed.to_string();
337 }
338 }
339
340 result
341}
342
343#[must_use]
348pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
349 let text_width = display_width(text);
350
351 if text_width <= max_width {
352 return text.to_string();
353 }
354
355 let ellipsis_width = display_width(ellipsis);
356
357 if ellipsis_width >= max_width {
359 return truncate_to_width(text, max_width);
360 }
361
362 let target_width = max_width - ellipsis_width;
363 let mut result = truncate_to_width(text, target_width);
364 result.push_str(ellipsis);
365 result
366}
367
368#[must_use]
372pub fn truncate_to_width(text: &str, max_width: usize) -> String {
373 let mut result = String::new();
374 let mut current_width = 0;
375
376 for grapheme in text.graphemes(true) {
377 let grapheme_width = crate::wrap::grapheme_width(grapheme);
378
379 if current_width + grapheme_width > max_width {
380 break;
381 }
382
383 result.push_str(grapheme);
384 current_width += grapheme_width;
385 }
386
387 result
388}
389
390#[inline]
409#[must_use]
410pub fn ascii_width(text: &str) -> Option<usize> {
411 ftui_core::text_width::ascii_width(text)
412}
413
414#[inline]
422#[must_use]
423pub fn grapheme_width(grapheme: &str) -> usize {
424 ftui_core::text_width::grapheme_width(grapheme)
425}
426
427#[inline]
438#[must_use]
439pub fn display_width(text: &str) -> usize {
440 ftui_core::text_width::display_width(text)
441}
442
443#[must_use]
445pub fn has_wide_chars(text: &str) -> bool {
446 text.graphemes(true)
447 .any(|g| crate::wrap::grapheme_width(g) > 1)
448}
449
450#[must_use]
452pub fn is_ascii_only(text: &str) -> bool {
453 text.is_ascii()
454}
455
456#[inline]
474#[must_use]
475pub fn grapheme_count(text: &str) -> usize {
476 text.graphemes(true).count()
477}
478
479#[inline]
492pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
493 text.graphemes(true)
494}
495
496#[must_use]
519pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
520 let mut byte_end = 0;
521 let mut current_width = 0;
522
523 for grapheme in text.graphemes(true) {
524 let grapheme_width = crate::wrap::grapheme_width(grapheme);
525
526 if current_width + grapheme_width > max_width {
527 break;
528 }
529
530 current_width += grapheme_width;
531 byte_end += grapheme.len();
532 }
533
534 (&text[..byte_end], current_width)
535}
536
537pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
552 text.split_word_bound_indices().filter_map(|(idx, word)| {
553 if word.chars().all(|c| c.is_whitespace()) {
555 Some(idx + word.len())
556 } else {
557 None
558 }
559 })
560}
561
562pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
575 text.split_word_bounds()
576}
577
578const BADNESS_SCALE: u64 = 10_000;
618
619const BADNESS_INF: u64 = u64::MAX / 2;
621
622const PENALTY_FORCE_BREAK: u64 = 5000;
624
625const KP_MAX_LOOKAHEAD: usize = 64;
629
630#[inline]
639fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
640 if slack < 0 {
641 return BADNESS_INF;
642 }
643 if is_last_line {
644 return 0;
645 }
646 if width == 0 {
647 return if slack == 0 { 0 } else { BADNESS_INF };
648 }
649 let s = slack as u64;
653 let w = width as u64;
654 let s3 = s.saturating_mul(s).saturating_mul(s);
656 let w3 = w.saturating_mul(w).saturating_mul(w);
657 if w3 == 0 {
658 return BADNESS_INF;
659 }
660 s3.saturating_mul(BADNESS_SCALE) / w3
661}
662
663#[derive(Debug, Clone)]
665struct KpWord {
666 text: String,
668 content_width: usize,
670 space_width: usize,
672}
673
674fn kp_tokenize(text: &str) -> Vec<KpWord> {
676 let mut words = Vec::new();
677 let raw_segments: Vec<&str> = text.split_word_bounds().collect();
678
679 let mut i = 0;
680 while i < raw_segments.len() {
681 let seg = raw_segments[i];
682 if seg.chars().all(|c| c.is_whitespace()) {
683 if let Some(last) = words.last_mut() {
685 let w: &mut KpWord = last;
686 w.text.push_str(seg);
687 w.space_width += display_width(seg);
688 } else {
689 words.push(KpWord {
691 text: seg.to_string(),
692 content_width: 0,
693 space_width: display_width(seg),
694 });
695 }
696 i += 1;
697 } else {
698 let content_width = display_width(seg);
699 words.push(KpWord {
700 text: seg.to_string(),
701 content_width,
702 space_width: 0,
703 });
704 i += 1;
705 }
706 }
707
708 words
709}
710
711#[derive(Debug, Clone)]
713pub struct KpBreakResult {
714 pub lines: Vec<String>,
716 pub total_cost: u64,
718 pub line_badness: Vec<u64>,
720}
721
722pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
737 if width == 0 || text.is_empty() {
738 return KpBreakResult {
739 lines: vec![text.to_string()],
740 total_cost: 0,
741 line_badness: vec![0],
742 };
743 }
744
745 let words = kp_tokenize(text);
746 if words.is_empty() {
747 return KpBreakResult {
748 lines: vec![text.to_string()],
749 total_cost: 0,
750 line_badness: vec![0],
751 };
752 }
753
754 let n = words.len();
755
756 let mut cost = vec![BADNESS_INF; n + 1];
759 let mut from = vec![0usize; n + 1];
760 cost[0] = 0;
761
762 for j in 1..=n {
763 let mut line_width: usize = 0;
764 let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
767 for i in (earliest..j).rev() {
768 line_width += words[i].content_width;
770 if i < j - 1 {
771 line_width += words[i].space_width;
773 }
774
775 if line_width > width && i < j - 1 {
777 break;
779 }
780
781 let slack = width as i64 - line_width as i64;
782 let is_last = j == n;
783 let badness = if line_width > width {
784 PENALTY_FORCE_BREAK
786 } else {
787 knuth_plass_badness(slack, width, is_last)
788 };
789
790 let candidate = cost[i].saturating_add(badness);
791 if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
793 cost[j] = candidate;
794 from[j] = i;
795 }
796 }
797 }
798
799 let mut breaks = Vec::new();
801 let mut pos = n;
802 while pos > 0 {
803 breaks.push(from[pos]);
804 pos = from[pos];
805 }
806 breaks.reverse();
807
808 let mut lines = Vec::new();
810 let mut line_badness = Vec::new();
811 let break_count = breaks.len();
812
813 for (idx, &start) in breaks.iter().enumerate() {
814 let end = if idx + 1 < break_count {
815 breaks[idx + 1]
816 } else {
817 n
818 };
819
820 let mut line = String::new();
822 for word in words.iter().take(end).skip(start) {
823 line.push_str(&word.text);
824 }
825
826 let trimmed = line.trim_end().to_string();
828
829 let line_w = display_width(trimmed.as_str());
831 let slack = width as i64 - line_w as i64;
832 let is_last = idx == break_count - 1;
833 let bad = if slack < 0 {
834 PENALTY_FORCE_BREAK
835 } else {
836 knuth_plass_badness(slack, width, is_last)
837 };
838
839 lines.push(trimmed);
840 line_badness.push(bad);
841 }
842
843 KpBreakResult {
844 lines,
845 total_cost: cost[n],
846 line_badness,
847 }
848}
849
850#[must_use]
854pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
855 let mut result = Vec::new();
856 for raw_paragraph in text.split('\n') {
857 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
858 if paragraph.is_empty() {
859 result.push(String::new());
860 continue;
861 }
862 let kp = wrap_optimal(paragraph, width);
863 result.extend(kp.lines);
864 }
865 result
866}
867
868#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
901#[repr(u8)]
902pub enum FitnessClass {
903 Tight = 0,
905 Normal = 1,
907 Loose = 2,
909 VeryLoose = 3,
911}
912
913impl FitnessClass {
914 #[must_use]
919 pub fn from_ratio(ratio: f64) -> Self {
920 if ratio < -0.5 {
921 FitnessClass::Tight
922 } else if ratio < 0.5 {
923 FitnessClass::Normal
924 } else if ratio < 1.0 {
925 FitnessClass::Loose
926 } else {
927 FitnessClass::VeryLoose
928 }
929 }
930
931 #[must_use]
934 pub const fn incompatible(self, other: Self) -> bool {
935 let a = self as i8;
936 let b = other as i8;
937 (a - b > 1) || (b - a > 1)
939 }
940}
941
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum BreakKind {
945 Space,
947 Hyphen,
949 Forced,
951 Emergency,
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub struct BreakPenalty {
963 pub value: i64,
965 pub flagged: bool,
968}
969
970impl BreakPenalty {
971 pub const SPACE: Self = Self {
973 value: 0,
974 flagged: false,
975 };
976
977 pub const HYPHEN: Self = Self {
979 value: 50,
980 flagged: true,
981 };
982
983 pub const FORCED: Self = Self {
985 value: i64::MIN,
986 flagged: false,
987 };
988
989 pub const EMERGENCY: Self = Self {
991 value: 5000,
992 flagged: false,
993 };
994}
995
996#[derive(Debug, Clone, Copy, PartialEq)]
1001pub struct ParagraphObjective {
1002 pub line_penalty: u64,
1006
1007 pub fitness_demerit: u64,
1010
1011 pub double_hyphen_demerit: u64,
1014
1015 pub final_hyphen_demerit: u64,
1018
1019 pub max_adjustment_ratio: f64,
1023
1024 pub min_adjustment_ratio: f64,
1027
1028 pub widow_demerit: u64,
1032
1033 pub widow_threshold: usize,
1036
1037 pub orphan_demerit: u64,
1041
1042 pub orphan_threshold: usize,
1045
1046 pub badness_scale: u64,
1049}
1050
1051impl Default for ParagraphObjective {
1052 fn default() -> Self {
1053 Self {
1054 line_penalty: 10,
1055 fitness_demerit: 100,
1056 double_hyphen_demerit: 100,
1057 final_hyphen_demerit: 100,
1058 max_adjustment_ratio: 2.0,
1059 min_adjustment_ratio: -1.0,
1060 widow_demerit: 150,
1061 widow_threshold: 15,
1062 orphan_demerit: 150,
1063 orphan_threshold: 20,
1064 badness_scale: BADNESS_SCALE,
1065 }
1066 }
1067}
1068
1069impl ParagraphObjective {
1070 #[must_use]
1073 pub fn terminal() -> Self {
1074 Self {
1075 line_penalty: 20,
1077 fitness_demerit: 50,
1079 min_adjustment_ratio: 0.0,
1081 max_adjustment_ratio: 3.0,
1083 widow_demerit: 50,
1085 orphan_demerit: 50,
1086 ..Self::default()
1087 }
1088 }
1089
1090 #[must_use]
1092 pub fn typographic() -> Self {
1093 Self::default()
1094 }
1095
1096 #[must_use]
1101 pub fn badness(&self, slack: i64, width: usize) -> Option<u64> {
1102 if width == 0 {
1103 return if slack == 0 { Some(0) } else { None };
1104 }
1105
1106 let ratio = slack as f64 / width as f64;
1107
1108 if ratio < self.min_adjustment_ratio || ratio > self.max_adjustment_ratio {
1110 return None; }
1112
1113 let abs_ratio = ratio.abs();
1114 let badness = (abs_ratio * abs_ratio * abs_ratio * self.badness_scale as f64) as u64;
1115 Some(badness)
1116 }
1117
1118 #[must_use]
1120 pub fn adjustment_ratio(&self, slack: i64, width: usize) -> f64 {
1121 if width == 0 {
1122 return 0.0;
1123 }
1124 slack as f64 / width as f64
1125 }
1126
1127 #[must_use]
1137 pub fn demerits(&self, slack: i64, width: usize, penalty: &BreakPenalty) -> Option<u64> {
1138 let badness = self.badness(slack, width)?;
1139
1140 let base = self.line_penalty.saturating_add(badness);
1141 let base_sq = base.saturating_mul(base);
1142
1143 let pen_sq = (penalty.value.unsigned_abs()).saturating_mul(penalty.value.unsigned_abs());
1144
1145 if penalty.value >= 0 {
1146 Some(base_sq.saturating_add(pen_sq))
1147 } else if penalty.value > i64::MIN {
1148 Some(base_sq.saturating_sub(pen_sq))
1150 } else {
1151 Some(base_sq)
1153 }
1154 }
1155
1156 #[must_use]
1161 pub fn adjacency_demerits(
1162 &self,
1163 prev_fitness: FitnessClass,
1164 curr_fitness: FitnessClass,
1165 prev_flagged: bool,
1166 curr_flagged: bool,
1167 ) -> u64 {
1168 let mut extra = 0u64;
1169
1170 if prev_fitness.incompatible(curr_fitness) {
1172 extra = extra.saturating_add(self.fitness_demerit);
1173 }
1174
1175 if prev_flagged && curr_flagged {
1177 extra = extra.saturating_add(self.double_hyphen_demerit);
1178 }
1179
1180 extra
1181 }
1182
1183 #[must_use]
1188 pub fn widow_demerits(&self, last_line_chars: usize) -> u64 {
1189 if last_line_chars < self.widow_threshold {
1190 self.widow_demerit
1191 } else {
1192 0
1193 }
1194 }
1195
1196 #[must_use]
1200 pub fn orphan_demerits(&self, first_line_chars: usize) -> u64 {
1201 if first_line_chars < self.orphan_threshold {
1202 self.orphan_demerit
1203 } else {
1204 0
1205 }
1206 }
1207}
1208
1209#[cfg(test)]
1210trait TestWidth {
1211 fn width(&self) -> usize;
1212}
1213
1214#[cfg(test)]
1215impl TestWidth for str {
1216 fn width(&self) -> usize {
1217 display_width(self)
1218 }
1219}
1220
1221#[cfg(test)]
1222impl TestWidth for String {
1223 fn width(&self) -> usize {
1224 display_width(self)
1225 }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::TestWidth;
1231 use super::*;
1232
1233 #[test]
1238 fn wrap_text_no_wrap_needed() {
1239 let lines = wrap_text("hello", 10, WrapMode::Word);
1240 assert_eq!(lines, vec!["hello"]);
1241 }
1242
1243 #[test]
1244 fn wrap_text_single_word_wrap() {
1245 let lines = wrap_text("hello world", 5, WrapMode::Word);
1246 assert_eq!(lines, vec!["hello", "world"]);
1247 }
1248
1249 #[test]
1250 fn wrap_text_multiple_words() {
1251 let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
1252 assert_eq!(lines, vec!["hello world", "foo bar"]);
1253 }
1254
1255 #[test]
1256 fn wrap_text_preserves_newlines() {
1257 let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
1258 assert_eq!(lines, vec!["line1", "line2"]);
1259 }
1260
1261 #[test]
1262 fn wrap_text_preserves_crlf_newlines() {
1263 let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
1264 assert_eq!(lines, vec!["line1", "line2", ""]);
1265 }
1266
1267 #[test]
1268 fn wrap_text_trailing_newlines() {
1269 let lines = wrap_text("line1\n", 20, WrapMode::Word);
1271 assert_eq!(lines, vec!["line1", ""]);
1272
1273 let lines = wrap_text("\n", 20, WrapMode::Word);
1275 assert_eq!(lines, vec!["", ""]);
1276
1277 let lines = wrap_text("line1\n", 20, WrapMode::Char);
1279 assert_eq!(lines, vec!["line1", ""]);
1280 }
1281
1282 #[test]
1283 fn wrap_text_empty_string() {
1284 let lines = wrap_text("", 10, WrapMode::Word);
1285 assert_eq!(lines, vec![""]);
1286 }
1287
1288 #[test]
1289 fn wrap_text_long_word_no_fallback() {
1290 let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
1291 assert_eq!(lines, vec!["supercalifragilistic"]);
1293 }
1294
1295 #[test]
1296 fn wrap_text_long_word_with_fallback() {
1297 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1298 assert!(lines.len() > 1);
1300 for line in &lines {
1301 assert!(line.width() <= 10);
1302 }
1303 }
1304
1305 #[test]
1306 fn wrap_char_mode() {
1307 let lines = wrap_text("hello world", 5, WrapMode::Char);
1308 assert_eq!(lines, vec!["hello", " worl", "d"]);
1309 }
1310
1311 #[test]
1312 fn wrap_none_mode() {
1313 let lines = wrap_text("hello world", 5, WrapMode::None);
1314 assert_eq!(lines, vec!["hello world"]);
1315 }
1316
1317 #[test]
1322 fn wrap_cjk_respects_width() {
1323 let lines = wrap_text("你好世界", 4, WrapMode::Char);
1325 assert_eq!(lines, vec!["你好", "世界"]);
1326 }
1327
1328 #[test]
1329 fn wrap_cjk_odd_width() {
1330 let lines = wrap_text("你好世", 5, WrapMode::Char);
1332 assert_eq!(lines, vec!["你好", "世"]);
1333 }
1334
1335 #[test]
1336 fn wrap_mixed_ascii_cjk() {
1337 let lines = wrap_text("hi你好", 4, WrapMode::Char);
1338 assert_eq!(lines, vec!["hi你", "好"]);
1339 }
1340
1341 #[test]
1346 fn wrap_emoji_as_unit() {
1347 let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
1349 assert_eq!(lines.len(), 2);
1351 for line in &lines {
1352 assert!(!line.contains("\\u"));
1354 }
1355 }
1356
1357 #[test]
1358 fn wrap_zwj_sequence_as_unit() {
1359 let text = "👨👩👧";
1361 let lines = wrap_text(text, 2, WrapMode::Char);
1362 assert!(lines.iter().any(|l| l.contains("👨👩👧")));
1365 }
1366
1367 #[test]
1368 fn wrap_mixed_ascii_and_emoji_respects_width() {
1369 let lines = wrap_text("a😀b", 3, WrapMode::Char);
1370 assert_eq!(lines, vec!["a😀", "b"]);
1371 }
1372
1373 #[test]
1378 fn truncate_no_change_if_fits() {
1379 let result = truncate_with_ellipsis("hello", 10, "...");
1380 assert_eq!(result, "hello");
1381 }
1382
1383 #[test]
1384 fn truncate_with_ellipsis_ascii() {
1385 let result = truncate_with_ellipsis("hello world", 8, "...");
1386 assert_eq!(result, "hello...");
1387 }
1388
1389 #[test]
1390 fn truncate_cjk() {
1391 let result = truncate_with_ellipsis("你好世界", 6, "...");
1392 assert_eq!(result, "你...");
1395 }
1396
1397 #[test]
1398 fn truncate_to_width_basic() {
1399 let result = truncate_to_width("hello world", 5);
1400 assert_eq!(result, "hello");
1401 }
1402
1403 #[test]
1404 fn truncate_to_width_cjk() {
1405 let result = truncate_to_width("你好世界", 4);
1406 assert_eq!(result, "你好");
1407 }
1408
1409 #[test]
1410 fn truncate_to_width_odd_boundary() {
1411 let result = truncate_to_width("你好", 3);
1413 assert_eq!(result, "你");
1414 }
1415
1416 #[test]
1417 fn truncate_combining_chars() {
1418 let text = "e\u{0301}test";
1420 let result = truncate_to_width(text, 2);
1421 assert_eq!(result.chars().count(), 3); }
1424
1425 #[test]
1430 fn display_width_ascii() {
1431 assert_eq!(display_width("hello"), 5);
1432 }
1433
1434 #[test]
1435 fn display_width_cjk() {
1436 assert_eq!(display_width("你好"), 4);
1437 }
1438
1439 #[test]
1440 fn display_width_emoji_sequences() {
1441 assert_eq!(display_width("👩🔬"), 2);
1442 assert_eq!(display_width("👨👩👧👦"), 2);
1443 assert_eq!(display_width("👩🚀x"), 3);
1444 }
1445
1446 #[test]
1447 fn display_width_misc_symbol_emoji() {
1448 assert_eq!(display_width("⏳"), 2);
1449 assert_eq!(display_width("⌛"), 2);
1450 }
1451
1452 #[test]
1453 fn display_width_emoji_presentation_selector() {
1454 assert_eq!(display_width("❤️"), 1);
1456 assert_eq!(display_width("⌨️"), 1);
1457 assert_eq!(display_width("⚠️"), 1);
1458 }
1459
1460 #[test]
1461 fn display_width_misc_symbol_ranges() {
1462 assert_eq!(display_width("⌚"), 2); assert_eq!(display_width("⭐"), 2); let airplane_width = display_width("✈"); let arrow_width = display_width("⬆"); assert!(
1470 [1, 2].contains(&airplane_width),
1471 "airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
1472 );
1473 assert_eq!(
1474 airplane_width, arrow_width,
1475 "both Neutral-width chars should have same width in any mode"
1476 );
1477 }
1478
1479 #[test]
1480 fn display_width_flags() {
1481 assert_eq!(display_width("🇺🇸"), 2);
1482 assert_eq!(display_width("🇯🇵"), 2);
1483 assert_eq!(display_width("🇺🇸🇯🇵"), 4);
1484 }
1485
1486 #[test]
1487 fn display_width_skin_tone_modifiers() {
1488 assert_eq!(display_width("👍🏻"), 2);
1489 assert_eq!(display_width("👍🏽"), 2);
1490 }
1491
1492 #[test]
1493 fn display_width_zwj_sequences() {
1494 assert_eq!(display_width("👩💻"), 2);
1495 assert_eq!(display_width("👨👩👧👦"), 2);
1496 }
1497
1498 #[test]
1499 fn display_width_mixed_ascii_and_emoji() {
1500 assert_eq!(display_width("A😀B"), 4);
1501 assert_eq!(display_width("A👩💻B"), 4);
1502 assert_eq!(display_width("ok ✅"), 5);
1503 }
1504
1505 #[test]
1506 fn display_width_file_icons() {
1507 let wide_icons = ["📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄"];
1510 for icon in wide_icons {
1511 assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
1512 }
1513 let narrow_icons = ["⚙️", "🖼️"];
1515 for icon in narrow_icons {
1516 assert_eq!(display_width(icon), 1, "VS16 icon width mismatch: {icon}");
1517 }
1518 }
1519
1520 #[test]
1521 fn grapheme_width_emoji_sequence() {
1522 assert_eq!(grapheme_width("👩🔬"), 2);
1523 }
1524
1525 #[test]
1526 fn grapheme_width_flags_and_modifiers() {
1527 assert_eq!(grapheme_width("🇺🇸"), 2);
1528 assert_eq!(grapheme_width("👍🏽"), 2);
1529 }
1530
1531 #[test]
1532 fn display_width_empty() {
1533 assert_eq!(display_width(""), 0);
1534 }
1535
1536 #[test]
1541 fn ascii_width_pure_ascii() {
1542 assert_eq!(ascii_width("hello"), Some(5));
1543 assert_eq!(ascii_width("hello world 123"), Some(15));
1544 }
1545
1546 #[test]
1547 fn ascii_width_empty() {
1548 assert_eq!(ascii_width(""), Some(0));
1549 }
1550
1551 #[test]
1552 fn ascii_width_non_ascii_returns_none() {
1553 assert_eq!(ascii_width("你好"), None);
1554 assert_eq!(ascii_width("héllo"), None);
1555 assert_eq!(ascii_width("hello😀"), None);
1556 }
1557
1558 #[test]
1559 fn ascii_width_mixed_returns_none() {
1560 assert_eq!(ascii_width("hi你好"), None);
1561 assert_eq!(ascii_width("caf\u{00e9}"), None); }
1563
1564 #[test]
1565 fn ascii_width_control_chars_returns_none() {
1566 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); }
1575
1576 #[test]
1577 fn display_width_uses_ascii_fast_path() {
1578 assert_eq!(display_width("test"), 4);
1580 assert_eq!(display_width("你"), 2);
1582 }
1583
1584 #[test]
1585 fn has_wide_chars_true() {
1586 assert!(has_wide_chars("hi你好"));
1587 }
1588
1589 #[test]
1590 fn has_wide_chars_false() {
1591 assert!(!has_wide_chars("hello"));
1592 }
1593
1594 #[test]
1595 fn is_ascii_only_true() {
1596 assert!(is_ascii_only("hello world 123"));
1597 }
1598
1599 #[test]
1600 fn is_ascii_only_false() {
1601 assert!(!is_ascii_only("héllo"));
1602 }
1603
1604 #[test]
1609 fn grapheme_count_ascii() {
1610 assert_eq!(grapheme_count("hello"), 5);
1611 assert_eq!(grapheme_count(""), 0);
1612 }
1613
1614 #[test]
1615 fn grapheme_count_combining() {
1616 assert_eq!(grapheme_count("e\u{0301}"), 1);
1618 assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
1620 }
1621
1622 #[test]
1623 fn grapheme_count_cjk() {
1624 assert_eq!(grapheme_count("你好"), 2);
1625 }
1626
1627 #[test]
1628 fn grapheme_count_emoji() {
1629 assert_eq!(grapheme_count("😀"), 1);
1630 assert_eq!(grapheme_count("👍🏻"), 1);
1632 }
1633
1634 #[test]
1635 fn grapheme_count_zwj() {
1636 assert_eq!(grapheme_count("👨👩👧"), 1);
1638 }
1639
1640 #[test]
1641 fn graphemes_iteration() {
1642 let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
1643 assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
1644 }
1645
1646 #[test]
1647 fn graphemes_empty() {
1648 let gs: Vec<&str> = graphemes("").collect();
1649 assert!(gs.is_empty());
1650 }
1651
1652 #[test]
1653 fn graphemes_cjk() {
1654 let gs: Vec<&str> = graphemes("你好").collect();
1655 assert_eq!(gs, vec!["你", "好"]);
1656 }
1657
1658 #[test]
1659 fn truncate_to_width_with_info_basic() {
1660 let (text, width) = truncate_to_width_with_info("hello world", 5);
1661 assert_eq!(text, "hello");
1662 assert_eq!(width, 5);
1663 }
1664
1665 #[test]
1666 fn truncate_to_width_with_info_cjk() {
1667 let (text, width) = truncate_to_width_with_info("你好世界", 3);
1668 assert_eq!(text, "你");
1669 assert_eq!(width, 2);
1670 }
1671
1672 #[test]
1673 fn truncate_to_width_with_info_combining() {
1674 let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
1675 assert_eq!(text, "e\u{0301}b");
1676 assert_eq!(width, 2);
1677 }
1678
1679 #[test]
1680 fn truncate_to_width_with_info_fits() {
1681 let (text, width) = truncate_to_width_with_info("hi", 10);
1682 assert_eq!(text, "hi");
1683 assert_eq!(width, 2);
1684 }
1685
1686 #[test]
1687 fn word_boundaries_basic() {
1688 let breaks: Vec<usize> = word_boundaries("hello world").collect();
1689 assert!(breaks.contains(&6)); }
1691
1692 #[test]
1693 fn word_boundaries_multiple_spaces() {
1694 let breaks: Vec<usize> = word_boundaries("a b").collect();
1695 assert!(breaks.contains(&3)); }
1697
1698 #[test]
1699 fn word_segments_basic() {
1700 let segs: Vec<&str> = word_segments("hello world").collect();
1701 assert!(segs.contains(&"hello"));
1703 assert!(segs.contains(&"world"));
1704 }
1705
1706 #[test]
1711 fn wrap_options_builder() {
1712 let opts = WrapOptions::new(40)
1713 .mode(WrapMode::Char)
1714 .preserve_indent(true)
1715 .trim_trailing(false);
1716
1717 assert_eq!(opts.width, 40);
1718 assert_eq!(opts.mode, WrapMode::Char);
1719 assert!(opts.preserve_indent);
1720 assert!(!opts.trim_trailing);
1721 }
1722
1723 #[test]
1724 fn wrap_options_trim_trailing() {
1725 let opts = WrapOptions::new(10).trim_trailing(true);
1726 let lines = wrap_with_options("hello world", &opts);
1727 assert!(!lines.iter().any(|l| l.ends_with(' ')));
1729 }
1730
1731 #[test]
1732 fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
1733 let opts = WrapOptions::new(7)
1734 .mode(WrapMode::Word)
1735 .preserve_indent(true);
1736 let lines = wrap_with_options("word12 abcde", &opts);
1737 assert_eq!(lines, vec!["word12", " abcde"]);
1738 }
1739
1740 #[test]
1741 fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
1742 let opts = WrapOptions::new(7)
1743 .mode(WrapMode::Word)
1744 .preserve_indent(false);
1745 let lines = wrap_with_options("word12 abcde", &opts);
1746 assert_eq!(lines, vec!["word12", "abcde"]);
1747 }
1748
1749 #[test]
1750 fn wrap_zero_width() {
1751 let lines = wrap_text("hello", 0, WrapMode::Word);
1752 assert_eq!(lines, vec!["hello"]);
1754 }
1755
1756 #[test]
1761 fn wrap_mode_default() {
1762 let mode = WrapMode::default();
1763 assert_eq!(mode, WrapMode::Word);
1764 }
1765
1766 #[test]
1767 fn wrap_options_default() {
1768 let opts = WrapOptions::default();
1769 assert_eq!(opts.width, 80);
1770 assert_eq!(opts.mode, WrapMode::Word);
1771 assert!(!opts.preserve_indent);
1772 assert!(opts.trim_trailing);
1773 }
1774
1775 #[test]
1776 fn display_width_emoji_skin_tone() {
1777 let width = display_width("👍🏻");
1778 assert_eq!(width, 2);
1779 }
1780
1781 #[test]
1782 fn display_width_flag_emoji() {
1783 let width = display_width("🇺🇸");
1784 assert_eq!(width, 2);
1785 }
1786
1787 #[test]
1788 fn display_width_zwj_family() {
1789 let width = display_width("👨👩👧");
1790 assert_eq!(width, 2);
1791 }
1792
1793 #[test]
1794 fn display_width_multiple_combining() {
1795 let width = display_width("e\u{0301}\u{0308}");
1797 assert_eq!(width, 1);
1798 }
1799
1800 #[test]
1801 fn ascii_width_printable_range() {
1802 let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
1804 assert_eq!(ascii_width(&printable), Some(printable.len()));
1805 }
1806
1807 #[test]
1808 fn ascii_width_newline_returns_none() {
1809 assert!(ascii_width("hello\nworld").is_none());
1811 }
1812
1813 #[test]
1814 fn ascii_width_tab_returns_none() {
1815 assert!(ascii_width("hello\tworld").is_none());
1817 }
1818
1819 #[test]
1820 fn ascii_width_del_returns_none() {
1821 assert!(ascii_width("hello\x7Fworld").is_none());
1823 }
1824
1825 #[test]
1826 fn has_wide_chars_cjk_mixed() {
1827 assert!(has_wide_chars("abc你def"));
1828 assert!(has_wide_chars("你"));
1829 assert!(!has_wide_chars("abc"));
1830 }
1831
1832 #[test]
1833 fn has_wide_chars_emoji() {
1834 assert!(has_wide_chars("😀"));
1835 assert!(has_wide_chars("hello😀"));
1836 }
1837
1838 #[test]
1839 fn grapheme_count_empty() {
1840 assert_eq!(grapheme_count(""), 0);
1841 }
1842
1843 #[test]
1844 fn grapheme_count_regional_indicators() {
1845 assert_eq!(grapheme_count("🇺🇸"), 1);
1847 }
1848
1849 #[test]
1850 fn word_boundaries_no_spaces() {
1851 let breaks: Vec<usize> = word_boundaries("helloworld").collect();
1852 assert!(breaks.is_empty());
1853 }
1854
1855 #[test]
1856 fn word_boundaries_only_spaces() {
1857 let breaks: Vec<usize> = word_boundaries(" ").collect();
1858 assert!(!breaks.is_empty());
1859 }
1860
1861 #[test]
1862 fn word_segments_empty() {
1863 let segs: Vec<&str> = word_segments("").collect();
1864 assert!(segs.is_empty());
1865 }
1866
1867 #[test]
1868 fn word_segments_single_word() {
1869 let segs: Vec<&str> = word_segments("hello").collect();
1870 assert_eq!(segs.len(), 1);
1871 assert_eq!(segs[0], "hello");
1872 }
1873
1874 #[test]
1875 fn truncate_to_width_empty() {
1876 let result = truncate_to_width("", 10);
1877 assert_eq!(result, "");
1878 }
1879
1880 #[test]
1881 fn truncate_to_width_zero_width() {
1882 let result = truncate_to_width("hello", 0);
1883 assert_eq!(result, "");
1884 }
1885
1886 #[test]
1887 fn truncate_with_ellipsis_exact_fit() {
1888 let result = truncate_with_ellipsis("hello", 5, "...");
1890 assert_eq!(result, "hello");
1891 }
1892
1893 #[test]
1894 fn truncate_with_ellipsis_empty_ellipsis() {
1895 let result = truncate_with_ellipsis("hello world", 5, "");
1896 assert_eq!(result, "hello");
1897 }
1898
1899 #[test]
1900 fn truncate_to_width_with_info_empty() {
1901 let (text, width) = truncate_to_width_with_info("", 10);
1902 assert_eq!(text, "");
1903 assert_eq!(width, 0);
1904 }
1905
1906 #[test]
1907 fn truncate_to_width_with_info_zero_width() {
1908 let (text, width) = truncate_to_width_with_info("hello", 0);
1909 assert_eq!(text, "");
1910 assert_eq!(width, 0);
1911 }
1912
1913 #[test]
1914 fn truncate_to_width_wide_char_boundary() {
1915 let (text, width) = truncate_to_width_with_info("a你好", 2);
1917 assert_eq!(text, "a");
1919 assert_eq!(width, 1);
1920 }
1921
1922 #[test]
1923 fn wrap_mode_none() {
1924 let lines = wrap_text("hello world", 5, WrapMode::None);
1925 assert_eq!(lines, vec!["hello world"]);
1926 }
1927
1928 #[test]
1929 fn wrap_long_word_no_char_fallback() {
1930 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1932 for line in &lines {
1934 assert!(line.width() <= 10);
1935 }
1936 }
1937
1938 #[test]
1943 fn unit_badness_monotone() {
1944 let width = 80;
1946 let mut prev = knuth_plass_badness(0, width, false);
1947 for slack in 1..=80i64 {
1948 let bad = knuth_plass_badness(slack, width, false);
1949 assert!(
1950 bad >= prev,
1951 "badness must be monotonically non-decreasing: \
1952 badness({slack}) = {bad} < badness({}) = {prev}",
1953 slack - 1
1954 );
1955 prev = bad;
1956 }
1957 }
1958
1959 #[test]
1960 fn unit_badness_zero_slack() {
1961 assert_eq!(knuth_plass_badness(0, 80, false), 0);
1963 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1964 }
1965
1966 #[test]
1967 fn unit_badness_overflow_is_inf() {
1968 assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
1970 assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
1971 }
1972
1973 #[test]
1974 fn unit_badness_last_line_always_zero() {
1975 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1977 assert_eq!(knuth_plass_badness(40, 80, true), 0);
1978 assert_eq!(knuth_plass_badness(79, 80, true), 0);
1979 }
1980
1981 #[test]
1982 fn unit_badness_cubic_growth() {
1983 let width = 100;
1984 let b10 = knuth_plass_badness(10, width, false);
1985 let b20 = knuth_plass_badness(20, width, false);
1986 let b40 = knuth_plass_badness(40, width, false);
1987
1988 assert!(
1991 b20 >= b10 * 6,
1992 "doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
1993 b20.checked_div(b10).unwrap_or(0)
1994 );
1995 assert!(
1996 b40 >= b20 * 6,
1997 "doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
1998 b40.checked_div(b20).unwrap_or(0)
1999 );
2000 }
2001
2002 #[test]
2003 fn unit_penalty_applied() {
2004 let result = wrap_optimal("superlongwordthatcannotfit", 10);
2006 assert!(
2008 result.total_cost >= PENALTY_FORCE_BREAK,
2009 "force-break penalty should be applied: cost={}",
2010 result.total_cost
2011 );
2012 }
2013
2014 #[test]
2015 fn kp_simple_wrap() {
2016 let result = wrap_optimal("Hello world foo bar", 10);
2017 for line in &result.lines {
2019 assert!(
2020 line.width() <= 10,
2021 "line '{line}' exceeds width 10 (width={})",
2022 line.width()
2023 );
2024 }
2025 assert!(result.lines.len() >= 2);
2027 }
2028
2029 #[test]
2030 fn kp_perfect_fit() {
2031 let result = wrap_optimal("aaaa bbbb", 9);
2033 assert_eq!(result.lines.len(), 1);
2035 assert_eq!(result.total_cost, 0);
2036 }
2037
2038 #[test]
2039 fn kp_optimal_vs_greedy() {
2040 let result = wrap_optimal("aaa bb cc ddddd", 6);
2045
2046 for line in &result.lines {
2048 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2049 }
2050
2051 assert!(result.lines.len() >= 2);
2055 }
2056
2057 #[test]
2058 fn kp_empty_text() {
2059 let result = wrap_optimal("", 80);
2060 assert_eq!(result.lines, vec![""]);
2061 assert_eq!(result.total_cost, 0);
2062 }
2063
2064 #[test]
2065 fn kp_single_word() {
2066 let result = wrap_optimal("hello", 80);
2067 assert_eq!(result.lines, vec!["hello"]);
2068 assert_eq!(result.total_cost, 0); }
2070
2071 #[test]
2072 fn kp_multiline_preserves_newlines() {
2073 let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
2074 assert!(lines.len() >= 2);
2076 assert!(lines[0].width() <= 10);
2078 }
2079
2080 #[test]
2081 fn kp_tokenize_basic() {
2082 let words = kp_tokenize("hello world foo");
2083 assert_eq!(words.len(), 3);
2084 assert_eq!(words[0].content_width, 5);
2085 assert_eq!(words[0].space_width, 1);
2086 assert_eq!(words[1].content_width, 5);
2087 assert_eq!(words[1].space_width, 1);
2088 assert_eq!(words[2].content_width, 3);
2089 assert_eq!(words[2].space_width, 0);
2090 }
2091
2092 #[test]
2093 fn kp_diagnostics_line_badness() {
2094 let result = wrap_optimal("short text here for testing the dp", 15);
2095 assert_eq!(result.line_badness.len(), result.lines.len());
2097 assert_eq!(
2099 *result.line_badness.last().unwrap(),
2100 0,
2101 "last line should have zero badness"
2102 );
2103 }
2104
2105 #[test]
2106 fn kp_deterministic() {
2107 let text = "The quick brown fox jumps over the lazy dog near a riverbank";
2108 let r1 = wrap_optimal(text, 20);
2109 let r2 = wrap_optimal(text, 20);
2110 assert_eq!(r1.lines, r2.lines);
2111 assert_eq!(r1.total_cost, r2.total_cost);
2112 }
2113
2114 #[test]
2119 fn unit_dp_matches_known() {
2120 let result = wrap_optimal("aaa bb cc ddddd", 6);
2125
2126 for line in &result.lines {
2128 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2129 }
2130
2131 assert_eq!(
2133 result.lines.len(),
2134 3,
2135 "expected 3 lines, got {:?}",
2136 result.lines
2137 );
2138 assert_eq!(result.lines[0], "aaa");
2139 assert_eq!(result.lines[1], "bb cc");
2140 assert_eq!(result.lines[2], "ddddd");
2141
2142 assert_eq!(*result.line_badness.last().unwrap(), 0);
2144 }
2145
2146 #[test]
2147 fn unit_dp_known_two_line() {
2148 let r1 = wrap_optimal("hello world", 11);
2150 assert_eq!(r1.lines, vec!["hello world"]);
2151 assert_eq!(r1.total_cost, 0);
2152
2153 let r2 = wrap_optimal("hello world", 7);
2155 assert_eq!(r2.lines.len(), 2);
2156 assert_eq!(r2.lines[0], "hello");
2157 assert_eq!(r2.lines[1], "world");
2158 assert!(
2161 r2.total_cost > 0 && r2.total_cost < 300,
2162 "expected cost ~233, got {}",
2163 r2.total_cost
2164 );
2165 }
2166
2167 #[test]
2168 fn unit_dp_optimal_beats_greedy() {
2169 let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
2190 let optimal = wrap_optimal("the quick brown fox", 10);
2191
2192 for line in &greedy {
2194 assert!(line.width() <= 10);
2195 }
2196 for line in &optimal.lines {
2197 assert!(line.width() <= 10);
2198 }
2199
2200 let mut greedy_cost: u64 = 0;
2203 for (i, line) in greedy.iter().enumerate() {
2204 let slack = 10i64 - line.width() as i64;
2205 let is_last = i == greedy.len() - 1;
2206 greedy_cost += knuth_plass_badness(slack, 10, is_last);
2207 }
2208 assert!(
2209 optimal.total_cost <= greedy_cost,
2210 "optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
2211 optimal.total_cost,
2212 greedy_cost
2213 );
2214 }
2215
2216 #[test]
2217 fn perf_wrap_large() {
2218 use std::time::Instant;
2219
2220 let words: Vec<&str> = [
2222 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2223 "back", "to", "its", "den", "in",
2224 ]
2225 .to_vec();
2226
2227 let mut paragraph = String::new();
2228 for i in 0..1000 {
2229 if i > 0 {
2230 paragraph.push(' ');
2231 }
2232 paragraph.push_str(words[i % words.len()]);
2233 }
2234
2235 let iterations = 20;
2236 let start = Instant::now();
2237 for _ in 0..iterations {
2238 let result = wrap_optimal(¶graph, 80);
2239 assert!(!result.lines.is_empty());
2240 }
2241 let elapsed = start.elapsed();
2242
2243 eprintln!(
2244 "{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
2245 iterations,
2246 elapsed.as_millis(),
2247 elapsed.as_micros() / iterations as u128
2248 );
2249
2250 assert!(
2252 elapsed.as_secs() < 2,
2253 "Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
2254 );
2255 }
2256
2257 #[test]
2258 fn kp_pruning_lookahead_bound() {
2259 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";
2261 let result = wrap_optimal(text, 10);
2262 for line in &result.lines {
2263 assert!(line.width() <= 10, "line '{line}' exceeds width");
2264 }
2265 let joined: String = result.lines.join(" ");
2267 for ch in 'a'..='z' {
2268 assert!(joined.contains(ch), "missing letter '{ch}' in output");
2269 }
2270 }
2271
2272 #[test]
2273 fn kp_very_narrow_width() {
2274 let result = wrap_optimal("ab cd ef", 2);
2276 assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
2277 }
2278
2279 #[test]
2280 fn kp_wide_width_single_line() {
2281 let result = wrap_optimal("hello world", 1000);
2283 assert_eq!(result.lines, vec!["hello world"]);
2284 assert_eq!(result.total_cost, 0);
2285 }
2286
2287 fn fnv1a_lines(lines: &[String]) -> u64 {
2293 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2294 for (i, line) in lines.iter().enumerate() {
2295 for byte in (i as u32)
2296 .to_le_bytes()
2297 .iter()
2298 .chain(line.as_bytes().iter())
2299 {
2300 hash ^= *byte as u64;
2301 hash = hash.wrapping_mul(0x0100_0000_01b3);
2302 }
2303 }
2304 hash
2305 }
2306
2307 #[test]
2308 fn snapshot_wrap_quality() {
2309 let paragraphs = [
2311 "The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
2312 "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",
2313 "aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
2314 ];
2315
2316 let widths = [20, 40, 60, 80];
2317
2318 for paragraph in ¶graphs {
2319 for &width in &widths {
2320 let result = wrap_optimal(paragraph, width);
2321
2322 let result2 = wrap_optimal(paragraph, width);
2324 assert_eq!(
2325 fnv1a_lines(&result.lines),
2326 fnv1a_lines(&result2.lines),
2327 "non-deterministic wrap at width {width}"
2328 );
2329
2330 for line in &result.lines {
2332 assert!(line.width() <= width, "line '{line}' exceeds width {width}");
2333 }
2334
2335 if !paragraph.is_empty() {
2337 for line in &result.lines {
2338 assert!(!line.is_empty(), "empty line in output at width {width}");
2339 }
2340 }
2341
2342 let original_words: Vec<&str> = paragraph.split_whitespace().collect();
2344 let result_words: Vec<&str> = result
2345 .lines
2346 .iter()
2347 .flat_map(|l| l.split_whitespace())
2348 .collect();
2349 assert_eq!(
2350 original_words, result_words,
2351 "content lost at width {width}"
2352 );
2353
2354 assert_eq!(
2356 *result.line_badness.last().unwrap(),
2357 0,
2358 "last line should have zero badness at width {width}"
2359 );
2360 }
2361 }
2362 }
2363
2364 #[test]
2369 fn perf_wrap_bench() {
2370 use std::time::Instant;
2371
2372 let sample_words = [
2373 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2374 "back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
2375 "near",
2376 ];
2377
2378 let scenarios: &[(usize, usize, &str)] = &[
2379 (50, 40, "short_40"),
2380 (50, 80, "short_80"),
2381 (200, 40, "medium_40"),
2382 (200, 80, "medium_80"),
2383 (500, 40, "long_40"),
2384 (500, 80, "long_80"),
2385 ];
2386
2387 for &(word_count, width, label) in scenarios {
2388 let mut paragraph = String::new();
2390 for i in 0..word_count {
2391 if i > 0 {
2392 paragraph.push(' ');
2393 }
2394 paragraph.push_str(sample_words[i % sample_words.len()]);
2395 }
2396
2397 let iterations = 30u32;
2398 let mut times_us = Vec::with_capacity(iterations as usize);
2399 let mut last_lines = 0usize;
2400 let mut last_cost = 0u64;
2401 let mut last_checksum = 0u64;
2402
2403 for _ in 0..iterations {
2404 let start = Instant::now();
2405 let result = wrap_optimal(¶graph, width);
2406 let elapsed = start.elapsed();
2407
2408 last_lines = result.lines.len();
2409 last_cost = result.total_cost;
2410 last_checksum = fnv1a_lines(&result.lines);
2411 times_us.push(elapsed.as_micros() as u64);
2412 }
2413
2414 times_us.sort();
2415 let len = times_us.len();
2416 let p50 = times_us[len / 2];
2417 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2418
2419 eprintln!(
2421 "{{\"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}\"}}"
2422 );
2423
2424 let verify = wrap_optimal(¶graph, width);
2426 assert_eq!(
2427 fnv1a_lines(&verify.lines),
2428 last_checksum,
2429 "non-deterministic: {label}"
2430 );
2431
2432 if word_count >= 500 && p95 > 5000 {
2434 eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
2435 }
2436 }
2437 }
2438}
2439
2440#[cfg(test)]
2441mod proptests {
2442 use super::TestWidth;
2443 use super::*;
2444 use proptest::prelude::*;
2445
2446 proptest! {
2447 #[test]
2448 fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
2449 let lines = wrap_text(&s, width, WrapMode::Char);
2450 for line in &lines {
2451 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2452 }
2453 }
2454
2455 #[test]
2456 fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
2457 let lines = wrap_text(&s, width, WrapMode::Char);
2458 let rejoined: String = lines.join("");
2459 prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
2461 }
2462
2463 #[test]
2464 fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
2465 let result = truncate_with_ellipsis(&s, width, "...");
2466 prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
2467 }
2468
2469 #[test]
2470 fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
2471 let result = truncate_to_width(&s, width);
2472 prop_assert!(result.width() <= width);
2473 if s.width() > width {
2475 prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
2477 }
2478 }
2479
2480 #[test]
2481 fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
2482 let lines = wrap_text(&s, width, WrapMode::WordChar);
2483 for line in &lines {
2484 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2485 }
2486 }
2487
2488 #[test]
2494 fn property_dp_vs_greedy(
2495 text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
2496 width in 8usize..40,
2497 ) {
2498 let greedy = wrap_text(&text, width, WrapMode::Word);
2499 let optimal = wrap_optimal(&text, width);
2500
2501 let mut greedy_cost: u64 = 0;
2503 for (i, line) in greedy.iter().enumerate() {
2504 let lw = line.width();
2505 let slack = width as i64 - lw as i64;
2506 let is_last = i == greedy.len() - 1;
2507 if slack >= 0 {
2508 greedy_cost = greedy_cost.saturating_add(
2509 knuth_plass_badness(slack, width, is_last)
2510 );
2511 } else {
2512 greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
2513 }
2514 }
2515
2516 prop_assert!(
2517 optimal.total_cost <= greedy_cost,
2518 "DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
2519 optimal.total_cost, greedy_cost, width, optimal.lines, greedy
2520 );
2521 }
2522
2523 #[test]
2525 fn property_dp_respects_width(
2526 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
2527 width in 6usize..30,
2528 ) {
2529 let result = wrap_optimal(&text, width);
2530 for line in &result.lines {
2531 prop_assert!(
2532 line.width() <= width,
2533 "DP line '{}' (width {}) exceeds target {}",
2534 line, line.width(), width
2535 );
2536 }
2537 }
2538
2539 #[test]
2541 fn property_dp_preserves_content(
2542 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
2543 width in 8usize..30,
2544 ) {
2545 let result = wrap_optimal(&text, width);
2546 let original_words: Vec<&str> = text.split_whitespace().collect();
2547 let result_words: Vec<&str> = result.lines.iter()
2548 .flat_map(|l| l.split_whitespace())
2549 .collect();
2550 prop_assert_eq!(
2551 original_words, result_words,
2552 "DP should preserve all words"
2553 );
2554 }
2555 }
2556
2557 #[test]
2562 fn fitness_class_from_ratio() {
2563 assert_eq!(FitnessClass::from_ratio(-0.8), FitnessClass::Tight);
2564 assert_eq!(FitnessClass::from_ratio(-0.5), FitnessClass::Normal);
2565 assert_eq!(FitnessClass::from_ratio(0.0), FitnessClass::Normal);
2566 assert_eq!(FitnessClass::from_ratio(0.49), FitnessClass::Normal);
2567 assert_eq!(FitnessClass::from_ratio(0.5), FitnessClass::Loose);
2568 assert_eq!(FitnessClass::from_ratio(0.99), FitnessClass::Loose);
2569 assert_eq!(FitnessClass::from_ratio(1.0), FitnessClass::VeryLoose);
2570 assert_eq!(FitnessClass::from_ratio(2.0), FitnessClass::VeryLoose);
2571 }
2572
2573 #[test]
2574 fn fitness_class_incompatible() {
2575 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Tight));
2576 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Normal));
2577 assert!(FitnessClass::Tight.incompatible(FitnessClass::Loose));
2578 assert!(FitnessClass::Tight.incompatible(FitnessClass::VeryLoose));
2579 assert!(!FitnessClass::Normal.incompatible(FitnessClass::Loose));
2580 assert!(FitnessClass::Normal.incompatible(FitnessClass::VeryLoose));
2581 }
2582
2583 #[test]
2584 fn objective_default_is_tex_standard() {
2585 let obj = ParagraphObjective::default();
2586 assert_eq!(obj.line_penalty, 10);
2587 assert_eq!(obj.fitness_demerit, 100);
2588 assert_eq!(obj.double_hyphen_demerit, 100);
2589 assert_eq!(obj.badness_scale, BADNESS_SCALE);
2590 }
2591
2592 #[test]
2593 fn objective_terminal_preset() {
2594 let obj = ParagraphObjective::terminal();
2595 assert_eq!(obj.line_penalty, 20);
2596 assert_eq!(obj.min_adjustment_ratio, 0.0);
2597 assert!(obj.max_adjustment_ratio > 2.0);
2598 }
2599
2600 #[test]
2601 fn badness_zero_slack_is_zero() {
2602 let obj = ParagraphObjective::default();
2603 assert_eq!(obj.badness(0, 80), Some(0));
2604 }
2605
2606 #[test]
2607 fn badness_moderate_slack() {
2608 let obj = ParagraphObjective::default();
2609 let b = obj.badness(10, 80).unwrap();
2612 assert!(b > 0 && b < 100, "badness = {b}");
2613 }
2614
2615 #[test]
2616 fn badness_excessive_slack_infeasible() {
2617 let obj = ParagraphObjective::default();
2618 assert!(obj.badness(240, 80).is_none());
2620 }
2621
2622 #[test]
2623 fn badness_negative_slack_within_bounds() {
2624 let obj = ParagraphObjective::default();
2625 let b = obj.badness(-40, 80);
2627 assert!(b.is_some());
2628 }
2629
2630 #[test]
2631 fn badness_negative_slack_beyond_bounds() {
2632 let obj = ParagraphObjective::default();
2633 assert!(obj.badness(-100, 80).is_none());
2635 }
2636
2637 #[test]
2638 fn badness_terminal_no_compression() {
2639 let obj = ParagraphObjective::terminal();
2640 assert!(obj.badness(-1, 80).is_none());
2642 }
2643
2644 #[test]
2645 fn demerits_space_break() {
2646 let obj = ParagraphObjective::default();
2647 let d = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2648 let badness = obj.badness(10, 80).unwrap();
2650 let expected = (obj.line_penalty + badness).pow(2);
2651 assert_eq!(d, expected);
2652 }
2653
2654 #[test]
2655 fn demerits_hyphen_break() {
2656 let obj = ParagraphObjective::default();
2657 let d_space = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2658 let d_hyphen = obj.demerits(10, 80, &BreakPenalty::HYPHEN).unwrap();
2659 assert!(d_hyphen > d_space);
2661 }
2662
2663 #[test]
2664 fn demerits_forced_break() {
2665 let obj = ParagraphObjective::default();
2666 let d = obj.demerits(0, 80, &BreakPenalty::FORCED).unwrap();
2667 assert_eq!(d, obj.line_penalty.pow(2));
2669 }
2670
2671 #[test]
2672 fn demerits_infeasible_returns_none() {
2673 let obj = ParagraphObjective::default();
2674 assert!(obj.demerits(300, 80, &BreakPenalty::SPACE).is_none());
2676 }
2677
2678 #[test]
2679 fn adjacency_fitness_incompatible() {
2680 let obj = ParagraphObjective::default();
2681 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::Loose, false, false);
2682 assert_eq!(d, obj.fitness_demerit);
2683 }
2684
2685 #[test]
2686 fn adjacency_fitness_compatible() {
2687 let obj = ParagraphObjective::default();
2688 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Loose, false, false);
2689 assert_eq!(d, 0);
2690 }
2691
2692 #[test]
2693 fn adjacency_double_hyphen() {
2694 let obj = ParagraphObjective::default();
2695 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Normal, true, true);
2696 assert_eq!(d, obj.double_hyphen_demerit);
2697 }
2698
2699 #[test]
2700 fn adjacency_double_hyphen_plus_fitness() {
2701 let obj = ParagraphObjective::default();
2702 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::VeryLoose, true, true);
2703 assert_eq!(d, obj.fitness_demerit + obj.double_hyphen_demerit);
2704 }
2705
2706 #[test]
2707 fn widow_penalty_short_last_line() {
2708 let obj = ParagraphObjective::default();
2709 assert_eq!(obj.widow_demerits(5), obj.widow_demerit);
2710 assert_eq!(obj.widow_demerits(14), obj.widow_demerit);
2711 assert_eq!(obj.widow_demerits(15), 0);
2712 assert_eq!(obj.widow_demerits(80), 0);
2713 }
2714
2715 #[test]
2716 fn orphan_penalty_short_first_line() {
2717 let obj = ParagraphObjective::default();
2718 assert_eq!(obj.orphan_demerits(10), obj.orphan_demerit);
2719 assert_eq!(obj.orphan_demerits(19), obj.orphan_demerit);
2720 assert_eq!(obj.orphan_demerits(20), 0);
2721 assert_eq!(obj.orphan_demerits(80), 0);
2722 }
2723
2724 #[test]
2725 fn adjustment_ratio_computation() {
2726 let obj = ParagraphObjective::default();
2727 let r = obj.adjustment_ratio(10, 80);
2728 assert!((r - 0.125).abs() < 1e-10);
2729 }
2730
2731 #[test]
2732 fn adjustment_ratio_zero_width() {
2733 let obj = ParagraphObjective::default();
2734 assert_eq!(obj.adjustment_ratio(5, 0), 0.0);
2735 }
2736
2737 #[test]
2738 fn badness_zero_width_zero_slack() {
2739 let obj = ParagraphObjective::default();
2740 assert_eq!(obj.badness(0, 0), Some(0));
2741 }
2742
2743 #[test]
2744 fn badness_zero_width_nonzero_slack() {
2745 let obj = ParagraphObjective::default();
2746 assert!(obj.badness(5, 0).is_none());
2747 }
2748}