1#![forbid(unsafe_code)]
2
3use std::borrow::Cow;
24use unicode_segmentation::UnicodeSegmentation;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
28pub enum WrapMode {
29 None,
31 #[default]
33 Word,
34 Char,
36 WordChar,
38 Optimal,
48}
49
50#[derive(Debug, Clone)]
52pub struct WrapOptions {
53 pub width: usize,
55 pub mode: WrapMode,
57 pub preserve_indent: bool,
62 pub trim_trailing: bool,
67}
68
69impl WrapOptions {
70 #[must_use]
72 pub fn new(width: usize) -> Self {
73 Self {
74 width,
75 mode: WrapMode::Word,
76 preserve_indent: false,
77 trim_trailing: true,
78 }
79 }
80
81 #[must_use]
83 pub fn mode(mut self, mode: WrapMode) -> Self {
84 self.mode = mode;
85 self
86 }
87
88 #[must_use]
90 pub fn preserve_indent(mut self, preserve: bool) -> Self {
91 self.preserve_indent = preserve;
92 self
93 }
94
95 #[must_use]
97 pub fn trim_trailing(mut self, trim: bool) -> Self {
98 self.trim_trailing = trim;
99 self
100 }
101}
102
103impl Default for WrapOptions {
104 fn default() -> Self {
105 Self::new(80)
106 }
107}
108
109#[must_use]
113pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
114 let preserve = mode == WrapMode::Char;
116 wrap_with_options(
117 text,
118 &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
119 )
120}
121
122#[must_use]
128pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
129 if options.width == 0 {
130 return split_lines_unwrapped(text, options);
131 }
132
133 match options.mode {
134 WrapMode::None => split_lines_unwrapped(text, options),
135 WrapMode::Char => wrap_chars(text, options),
136 WrapMode::Word => wrap_words(text, options, false),
137 WrapMode::WordChar => wrap_words(text, options, true),
138 WrapMode::Optimal => wrap_text_optimal(text, options.width),
139 }
140}
141
142fn split_lines_unwrapped(text: &str, options: &WrapOptions) -> Vec<String> {
148 text.split('\n')
149 .map(|raw| {
150 let line = raw.strip_suffix('\r').unwrap_or(raw);
151 finalize_line(line, options)
152 })
153 .collect()
154}
155
156fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
158 let mut lines = Vec::new();
159 let mut current_line = String::new();
160 let mut current_width = 0;
161
162 for grapheme in text.graphemes(true) {
163 if grapheme == "\n" || grapheme == "\r\n" {
165 lines.push(finalize_line(¤t_line, options));
166 current_line.clear();
167 current_width = 0;
168 continue;
169 }
170
171 let grapheme_width = crate::wrap::grapheme_width(grapheme);
172
173 if current_width + grapheme_width > options.width && !current_line.is_empty() {
175 lines.push(finalize_line(¤t_line, options));
176 current_line.clear();
177 current_width = 0;
178 }
179
180 current_line.push_str(grapheme);
182 current_width += grapheme_width;
183 }
184
185 lines.push(finalize_line(¤t_line, options));
188
189 lines
190}
191
192fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
194 let mut lines = Vec::new();
195
196 for raw_paragraph in text.split('\n') {
198 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
199 let mut current_line = String::new();
200 let mut current_width = 0;
201
202 let len_before = lines.len();
203
204 wrap_paragraph(
205 paragraph,
206 options,
207 char_fallback,
208 &mut lines,
209 &mut current_line,
210 &mut current_width,
211 );
212
213 if !current_line.is_empty() || lines.len() == len_before {
216 lines.push(finalize_line(¤t_line, options));
217 }
218 }
219
220 lines
221}
222
223fn wrap_paragraph(
225 text: &str,
226 options: &WrapOptions,
227 char_fallback: bool,
228 lines: &mut Vec<String>,
229 current_line: &mut String,
230 current_width: &mut usize,
231) {
232 for word in split_words(text) {
233 let is_whitespace_only = word.chars().all(is_breaking_whitespace);
234
235 if *current_width == 0 && is_whitespace_only && !options.preserve_indent {
237 continue;
238 }
239
240 let word_width = display_width(word);
241
242 if *current_width + word_width <= options.width {
244 current_line.push_str(word);
245 *current_width += word_width;
246 continue;
247 }
248
249 if !current_line.is_empty() {
251 if current_line.chars().all(is_breaking_whitespace) {
257 current_line.clear();
258 *current_width = 0;
259 } else {
260 lines.push(finalize_line(current_line, options));
261 current_line.clear();
262 *current_width = 0;
263 }
264
265 if is_whitespace_only && !options.preserve_indent {
269 continue;
270 }
271 }
272
273 if word_width > options.width {
275 if char_fallback {
276 wrap_long_word(word, options, lines, current_line, current_width);
278 } else {
279 lines.push(finalize_line(word, options));
281 }
282 } else {
283 if !word.is_empty() {
285 current_line.push_str(word);
286 }
287 *current_width = word_width;
288 }
289 }
290}
291
292fn wrap_long_word(
294 word: &str,
295 options: &WrapOptions,
296 lines: &mut Vec<String>,
297 current_line: &mut String,
298 current_width: &mut usize,
299) {
300 for grapheme in word.graphemes(true) {
301 let grapheme_width = crate::wrap::grapheme_width(grapheme);
302
303 if *current_width == 0
305 && grapheme.chars().all(is_breaking_whitespace)
306 && !options.preserve_indent
307 {
308 continue;
309 }
310
311 if *current_width + grapheme_width > options.width && !current_line.is_empty() {
312 lines.push(finalize_line(current_line, options));
313 current_line.clear();
314 *current_width = 0;
315
316 if grapheme.chars().all(is_breaking_whitespace) && !options.preserve_indent {
318 continue;
319 }
320 }
321
322 current_line.push_str(grapheme);
323 *current_width += grapheme_width;
324 }
325}
326
327fn split_words(text: &str) -> Vec<&str> {
332 let mut words = Vec::new();
333 let mut current_start = 0;
334 let mut current_end = 0;
335 let mut in_whitespace = false;
336 let mut byte_offset = 0;
337
338 for grapheme in text.graphemes(true) {
339 let is_ws = grapheme.chars().all(is_breaking_whitespace);
340
341 if is_ws != in_whitespace && current_end > current_start {
342 words.push(&text[current_start..current_end]);
343 current_start = byte_offset;
344 } else if current_end == current_start {
345 current_start = byte_offset;
346 }
347
348 current_end = byte_offset + grapheme.len();
349 in_whitespace = is_ws;
350 byte_offset += grapheme.len();
351 }
352
353 if current_end > current_start {
354 words.push(&text[current_start..current_end]);
355 }
356
357 words
358}
359
360fn finalize_line(line: &str, options: &WrapOptions) -> String {
362 if options.trim_trailing {
363 line.trim_end_matches(is_breaking_whitespace).to_string()
364 } else {
365 line.to_string()
366 }
367}
368
369#[must_use]
374pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
375 let text_width = display_width(text);
376
377 if text_width <= max_width {
378 return text.to_string();
379 }
380
381 let ellipsis_width = display_width(ellipsis);
382
383 if ellipsis_width >= max_width {
385 return truncate_to_width(text, max_width);
386 }
387
388 let target_width = max_width - ellipsis_width;
389 let mut result = truncate_to_width(text, target_width);
390 result.push_str(ellipsis);
391 result
392}
393
394#[must_use]
398pub fn truncate_to_width(text: &str, max_width: usize) -> String {
399 let mut result = String::new();
400 let mut current_width = 0;
401
402 for grapheme in text.graphemes(true) {
403 let grapheme_width = crate::wrap::grapheme_width(grapheme);
404
405 if current_width + grapheme_width > max_width {
406 break;
407 }
408
409 result.push_str(grapheme);
410 current_width += grapheme_width;
411 }
412
413 result
414}
415
416#[inline]
437#[must_use]
438pub fn ascii_width(text: &str) -> Option<usize> {
439 ftui_core::text_width::ascii_width(text)
440}
441
442#[inline]
450#[must_use]
451pub fn grapheme_width(grapheme: &str) -> usize {
452 ftui_core::text_width::grapheme_width(grapheme)
453}
454
455#[inline]
466#[must_use]
467pub fn display_width(text: &str) -> usize {
468 ftui_core::text_width::display_width(text)
469}
470
471#[must_use]
473pub fn has_wide_chars(text: &str) -> bool {
474 text.graphemes(true)
475 .any(|g| crate::wrap::grapheme_width(g) > 1)
476}
477
478#[must_use]
480pub fn is_ascii_only(text: &str) -> bool {
481 text.is_ascii()
482}
483
484#[inline]
502#[must_use]
503pub fn grapheme_count(text: &str) -> usize {
504 text.graphemes(true).count()
505}
506
507#[inline]
520pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
521 text.graphemes(true)
522}
523
524#[must_use]
547pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
548 let mut byte_end = 0;
549 let mut current_width = 0;
550
551 for grapheme in text.graphemes(true) {
552 let grapheme_width = crate::wrap::grapheme_width(grapheme);
553
554 if current_width + grapheme_width > max_width {
555 break;
556 }
557
558 current_width += grapheme_width;
559 byte_end += grapheme.len();
560 }
561
562 (&text[..byte_end], current_width)
563}
564
565pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
580 text.split_word_bound_indices().filter_map(|(idx, word)| {
581 if word.chars().all(is_breaking_whitespace) {
583 Some(idx + word.len())
584 } else {
585 None
586 }
587 })
588}
589
590pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
603 text.split_word_bounds()
604}
605
606const BADNESS_SCALE: u64 = 10_000;
646
647const BADNESS_INF: u64 = u64::MAX / 2;
649
650const PENALTY_FORCE_BREAK: u64 = 5000;
653
654const KP_MAX_LOOKAHEAD: usize = 1024;
658
659#[inline]
668fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
669 if slack < 0 {
670 return BADNESS_INF;
671 }
672 if is_last_line {
673 return 0;
674 }
675 if width == 0 {
676 return if slack == 0 { 0 } else { BADNESS_INF };
677 }
678
679 let ratio = slack as f64 / width as f64;
680 (ratio * ratio * ratio * BADNESS_SCALE as f64) as u64
681}
682
683pub(crate) fn is_breaking_whitespace(c: char) -> bool {
688 c.is_whitespace() && c != '\u{00A0}' && c != '\u{202F}'
689}
690
691#[derive(Debug, Clone)]
696struct KpWord<'a> {
697 content: Cow<'a, str>,
699 space: Cow<'a, str>,
701 content_width: usize,
703 space_width: usize,
705}
706
707fn kp_tokenize(text: &str) -> Vec<KpWord<'_>> {
714 let mut words = Vec::new();
715 let mut content_start = 0;
716 let mut content_end = 0;
717 let mut current_content_width = 0;
718 let mut byte_offset = 0;
719
720 for seg in text.split_word_bounds() {
721 let is_space = seg.chars().all(is_breaking_whitespace);
722 let width = display_width(seg);
723
724 if is_space {
725 if content_end > content_start {
726 let content = &text[content_start..content_end];
727 words.push(KpWord {
728 content: Cow::Borrowed(content),
729 space: Cow::Borrowed(seg),
730 content_width: current_content_width,
731 space_width: width,
732 });
733 content_start = byte_offset + seg.len();
734 content_end = content_start;
735 current_content_width = 0;
736 } else if let Some(last) = words.last_mut() {
737 if let Cow::Borrowed(s) = last.space {
739 let start = byte_offset - s.len();
740 let end = byte_offset + seg.len();
741 last.space = Cow::Borrowed(&text[start..end]);
742 }
743 last.space_width += width;
744 content_start = byte_offset + seg.len();
745 content_end = content_start;
746 } else {
747 content_start = byte_offset + seg.len();
752 content_end = content_start;
753 }
754 } else {
755 if content_start == content_end {
756 content_start = byte_offset;
757 }
758 content_end = byte_offset + seg.len();
759 current_content_width += width;
760 }
761
762 byte_offset += seg.len();
763 }
764
765 if content_end > content_start {
766 let content = &text[content_start..content_end];
767 words.push(KpWord {
768 content: Cow::Borrowed(content),
769 space: Cow::Borrowed(""),
770 content_width: current_content_width,
771 space_width: 0,
772 });
773 }
774
775 words
776}
777
778#[derive(Debug, Clone)]
780pub struct KpBreakResult {
781 pub lines: Vec<String>,
783 pub total_cost: u64,
785 pub line_badness: Vec<u64>,
787}
788
789pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
810 if width == 0 || text.is_empty() {
811 return KpBreakResult {
812 lines: vec![text.to_string()],
813 total_cost: 0,
814 line_badness: vec![0],
815 };
816 }
817
818 let words = kp_tokenize(text);
819 if words.is_empty() {
820 return KpBreakResult {
823 lines: vec![String::new()],
824 total_cost: 0,
825 line_badness: vec![0],
826 };
827 }
828
829 let n = words.len();
830
831 let mut cost = vec![BADNESS_INF; n + 1];
834 let mut from = vec![0usize; n + 1];
835 cost[0] = 0;
836
837 for j in 1..=n {
838 let mut line_width: usize = 0;
839 let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
842 for i in (earliest..j).rev() {
843 line_width += words[i].content_width;
845 if i < j - 1 {
846 line_width += words[i].space_width;
848 }
849
850 if line_width > width && i < j - 1 {
852 break;
854 }
855
856 let slack = width as i64 - line_width as i64;
857 let is_last = j == n;
858 let badness = if line_width > width {
859 PENALTY_FORCE_BREAK
861 } else {
862 knuth_plass_badness(slack, width, is_last)
863 };
864
865 let candidate = cost[i].saturating_add(badness);
866 if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
868 cost[j] = candidate;
869 from[j] = i;
870 }
871 }
872 }
873
874 let mut breaks = Vec::new();
876 let mut pos = n;
877 while pos > 0 {
878 breaks.push(from[pos]);
879 pos = from[pos];
880 }
881 breaks.reverse();
882
883 let mut lines = Vec::new();
885 let mut line_badness = Vec::new();
886 let break_count = breaks.len();
887
888 for (idx, &start) in breaks.iter().enumerate() {
889 let end = if idx + 1 < break_count {
890 breaks[idx + 1]
891 } else {
892 n
893 };
894
895 let mut line = String::new();
897 for (i, word) in words.iter().take(end).skip(start).enumerate() {
898 line.push_str(&word.content);
899 if i < (end - start) - 1 {
901 line.push_str(&word.space);
902 }
903 }
904
905 let trimmed = line.trim_end_matches(is_breaking_whitespace).to_string();
907
908 let line_w = display_width(trimmed.as_str());
910 let slack = width as i64 - line_w as i64;
911 let is_last = idx == break_count - 1;
912 let bad = if slack < 0 {
913 PENALTY_FORCE_BREAK
914 } else {
915 knuth_plass_badness(slack, width, is_last)
916 };
917
918 lines.push(trimmed);
919 line_badness.push(bad);
920 }
921
922 KpBreakResult {
923 lines,
924 total_cost: cost[n],
925 line_badness,
926 }
927}
928
929#[must_use]
933pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
934 let mut result = Vec::new();
935 for raw_paragraph in text.split('\n') {
936 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
937 if paragraph.is_empty() {
938 result.push(String::new());
939 continue;
940 }
941 let kp = wrap_optimal(paragraph, width);
942 result.extend(kp.lines);
943 }
944 result
945}
946
947#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
980#[repr(u8)]
981pub enum FitnessClass {
982 Tight = 0,
984 Normal = 1,
986 Loose = 2,
988 VeryLoose = 3,
990}
991
992impl FitnessClass {
993 #[must_use]
998 pub fn from_ratio(ratio: f64) -> Self {
999 if ratio < -0.5 {
1000 FitnessClass::Tight
1001 } else if ratio < 0.5 {
1002 FitnessClass::Normal
1003 } else if ratio < 1.0 {
1004 FitnessClass::Loose
1005 } else {
1006 FitnessClass::VeryLoose
1007 }
1008 }
1009
1010 #[must_use]
1013 pub const fn incompatible(self, other: Self) -> bool {
1014 let a = self as i8;
1015 let b = other as i8;
1016 (a - b > 1) || (b - a > 1)
1018 }
1019}
1020
1021#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1023pub enum BreakKind {
1024 Space,
1026 Hyphen,
1028 Forced,
1030 Emergency,
1032}
1033
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1041pub struct BreakPenalty {
1042 pub value: i64,
1044 pub flagged: bool,
1047}
1048
1049impl BreakPenalty {
1050 pub const SPACE: Self = Self {
1052 value: 0,
1053 flagged: false,
1054 };
1055
1056 pub const HYPHEN: Self = Self {
1058 value: 50,
1059 flagged: true,
1060 };
1061
1062 pub const FORCED: Self = Self {
1064 value: i64::MIN,
1065 flagged: false,
1066 };
1067
1068 pub const EMERGENCY: Self = Self {
1070 value: 5000,
1071 flagged: false,
1072 };
1073}
1074
1075#[derive(Debug, Clone, Copy, PartialEq)]
1080pub struct ParagraphObjective {
1081 pub line_penalty: u64,
1085
1086 pub fitness_demerit: u64,
1089
1090 pub double_hyphen_demerit: u64,
1093
1094 pub final_hyphen_demerit: u64,
1097
1098 pub max_adjustment_ratio: f64,
1102
1103 pub min_adjustment_ratio: f64,
1106
1107 pub widow_demerit: u64,
1111
1112 pub widow_threshold: usize,
1115
1116 pub orphan_demerit: u64,
1120
1121 pub orphan_threshold: usize,
1124
1125 pub badness_scale: u64,
1128}
1129
1130impl Default for ParagraphObjective {
1131 fn default() -> Self {
1132 Self {
1133 line_penalty: 10,
1134 fitness_demerit: 100,
1135 double_hyphen_demerit: 100,
1136 final_hyphen_demerit: 100,
1137 max_adjustment_ratio: 2.0,
1138 min_adjustment_ratio: -1.0,
1139 widow_demerit: 150,
1140 widow_threshold: 15,
1141 orphan_demerit: 150,
1142 orphan_threshold: 20,
1143 badness_scale: BADNESS_SCALE,
1144 }
1145 }
1146}
1147
1148impl ParagraphObjective {
1149 #[must_use]
1152 pub fn terminal() -> Self {
1153 Self {
1154 line_penalty: 20,
1156 fitness_demerit: 50,
1158 min_adjustment_ratio: 0.0,
1160 max_adjustment_ratio: 3.0,
1162 widow_demerit: 50,
1164 orphan_demerit: 50,
1165 ..Self::default()
1166 }
1167 }
1168
1169 #[must_use]
1171 pub fn typographic() -> Self {
1172 Self::default()
1173 }
1174
1175 #[must_use]
1180 pub fn badness(&self, slack: i64, width: usize) -> Option<u64> {
1181 if width == 0 {
1182 return if slack == 0 { Some(0) } else { None };
1183 }
1184
1185 let ratio = slack as f64 / width as f64;
1186
1187 if ratio < self.min_adjustment_ratio || ratio > self.max_adjustment_ratio {
1189 return None; }
1191
1192 let abs_ratio = ratio.abs();
1193 let badness = (abs_ratio * abs_ratio * abs_ratio * self.badness_scale as f64) as u64;
1194 Some(badness)
1195 }
1196
1197 #[must_use]
1199 pub fn adjustment_ratio(&self, slack: i64, width: usize) -> f64 {
1200 if width == 0 {
1201 return 0.0;
1202 }
1203 slack as f64 / width as f64
1204 }
1205
1206 #[must_use]
1216 pub fn demerits(&self, slack: i64, width: usize, penalty: &BreakPenalty) -> Option<u64> {
1217 let badness = self.badness(slack, width)?;
1218
1219 let base = self.line_penalty.saturating_add(badness);
1220 let base_sq = base.saturating_mul(base);
1221
1222 let pen_sq = (penalty.value.unsigned_abs()).saturating_mul(penalty.value.unsigned_abs());
1223
1224 if penalty.value >= 0 {
1225 Some(base_sq.saturating_add(pen_sq))
1226 } else if penalty.value > i64::MIN {
1227 Some(base_sq.saturating_sub(pen_sq))
1229 } else {
1230 Some(base_sq)
1232 }
1233 }
1234
1235 #[must_use]
1240 pub fn adjacency_demerits(
1241 &self,
1242 prev_fitness: FitnessClass,
1243 curr_fitness: FitnessClass,
1244 prev_flagged: bool,
1245 curr_flagged: bool,
1246 ) -> u64 {
1247 let mut extra = 0u64;
1248
1249 if prev_fitness.incompatible(curr_fitness) {
1251 extra = extra.saturating_add(self.fitness_demerit);
1252 }
1253
1254 if prev_flagged && curr_flagged {
1256 extra = extra.saturating_add(self.double_hyphen_demerit);
1257 }
1258
1259 extra
1260 }
1261
1262 #[must_use]
1267 pub fn widow_demerits(&self, last_line_chars: usize) -> u64 {
1268 if last_line_chars < self.widow_threshold {
1269 self.widow_demerit
1270 } else {
1271 0
1272 }
1273 }
1274
1275 #[must_use]
1279 pub fn orphan_demerits(&self, first_line_chars: usize) -> u64 {
1280 if first_line_chars < self.orphan_threshold {
1281 self.orphan_demerit
1282 } else {
1283 0
1284 }
1285 }
1286}
1287
1288#[cfg(test)]
1289trait TestWidth {
1290 fn width(&self) -> usize;
1291}
1292
1293#[cfg(test)]
1294impl TestWidth for str {
1295 fn width(&self) -> usize {
1296 display_width(self)
1297 }
1298}
1299
1300#[cfg(test)]
1301impl TestWidth for String {
1302 fn width(&self) -> usize {
1303 display_width(self)
1304 }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::TestWidth;
1310 use super::*;
1311
1312 #[test]
1317 fn wrap_text_no_wrap_needed() {
1318 let lines = wrap_text("hello", 10, WrapMode::Word);
1319 assert_eq!(lines, vec!["hello"]);
1320 }
1321
1322 #[test]
1323 fn wrap_text_single_word_wrap() {
1324 let lines = wrap_text("hello world", 5, WrapMode::Word);
1325 assert_eq!(lines, vec!["hello", "world"]);
1326 }
1327
1328 #[test]
1329 fn wrap_text_multiple_words() {
1330 let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
1331 assert_eq!(lines, vec!["hello world", "foo bar"]);
1332 }
1333
1334 #[test]
1335 fn wrap_text_preserves_newlines() {
1336 let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
1337 assert_eq!(lines, vec!["line1", "line2"]);
1338 }
1339
1340 #[test]
1341 fn wrap_text_preserves_crlf_newlines() {
1342 let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
1343 assert_eq!(lines, vec!["line1", "line2", ""]);
1344 }
1345
1346 #[test]
1347 fn wrap_text_trailing_newlines() {
1348 let lines = wrap_text("line1\n", 20, WrapMode::Word);
1350 assert_eq!(lines, vec!["line1", ""]);
1351
1352 let lines = wrap_text("\n", 20, WrapMode::Word);
1354 assert_eq!(lines, vec!["", ""]);
1355
1356 let lines = wrap_text("line1\n", 20, WrapMode::Char);
1358 assert_eq!(lines, vec!["line1", ""]);
1359 }
1360
1361 #[test]
1362 fn wrap_text_empty_string() {
1363 let lines = wrap_text("", 10, WrapMode::Word);
1364 assert_eq!(lines, vec![""]);
1365 }
1366
1367 #[test]
1368 fn wrap_text_long_word_no_fallback() {
1369 let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
1370 assert_eq!(lines, vec!["supercalifragilistic"]);
1372 }
1373
1374 #[test]
1375 fn wrap_text_long_word_with_fallback() {
1376 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1377 assert!(lines.len() > 1);
1379 for line in &lines {
1380 assert!(line.width() <= 10);
1381 }
1382 }
1383
1384 #[test]
1385 fn wrap_char_mode() {
1386 let lines = wrap_text("hello world", 5, WrapMode::Char);
1387 assert_eq!(lines, vec!["hello", " worl", "d"]);
1388 }
1389
1390 #[test]
1391 fn wrap_none_mode() {
1392 let lines = wrap_text("hello world", 5, WrapMode::None);
1393 assert_eq!(lines, vec!["hello world"]);
1394 }
1395
1396 #[test]
1397 fn wrap_none_mode_splits_embedded_newlines() {
1398 assert_eq!(wrap_text("a\nb", 5, WrapMode::None), vec!["a", "b"]);
1401 assert_eq!(wrap_text("a\r\nb", 5, WrapMode::None), vec!["a", "b"]);
1402 assert_eq!(wrap_text("a\nb", 0, WrapMode::Word), vec!["a", "b"]);
1404 }
1405
1406 #[test]
1407 fn wrap_optimal_drops_leading_whitespace_like_greedy() {
1408 let result = wrap_optimal(" foo", 4);
1412 assert_eq!(result.lines, vec!["foo"]);
1413 assert_eq!(result.total_cost, 0);
1414
1415 assert_eq!(
1418 wrap_text_optimal(" foo bar baz", 10),
1419 wrap_text(" foo bar baz", 10, WrapMode::Word)
1420 );
1421 }
1422
1423 #[test]
1424 fn wrap_optimal_whitespace_only_is_single_empty_line() {
1425 assert_eq!(wrap_text_optimal(" ", 4), vec![""]);
1427 assert_eq!(wrap_text(" ", 4, WrapMode::Word), vec![""]);
1428 }
1429
1430 #[test]
1431 fn wrap_preserve_indent_never_emits_phantom_blank_line() {
1432 let lines = wrap_with_options(
1437 "aaaa bb",
1438 &WrapOptions::new(4)
1439 .mode(WrapMode::Word)
1440 .preserve_indent(true),
1441 );
1442 assert_eq!(lines, vec!["aaaa", "bb"]);
1443
1444 let lines = wrap_with_options(
1447 "aaaa bb",
1448 &WrapOptions::new(4)
1449 .mode(WrapMode::Word)
1450 .preserve_indent(true)
1451 .trim_trailing(false),
1452 );
1453 assert_eq!(lines, vec!["aaaa", "bb"]);
1454 }
1455
1456 #[test]
1461 fn wrap_cjk_respects_width() {
1462 let lines = wrap_text("你好世界", 4, WrapMode::Char);
1464 assert_eq!(lines, vec!["你好", "世界"]);
1465 }
1466
1467 #[test]
1468 fn wrap_cjk_odd_width() {
1469 let lines = wrap_text("你好世", 5, WrapMode::Char);
1471 assert_eq!(lines, vec!["你好", "世"]);
1472 }
1473
1474 #[test]
1475 fn wrap_mixed_ascii_cjk() {
1476 let lines = wrap_text("hi你好", 4, WrapMode::Char);
1477 assert_eq!(lines, vec!["hi你", "好"]);
1478 }
1479
1480 #[test]
1485 fn wrap_emoji_as_unit() {
1486 let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
1488 assert_eq!(lines.len(), 2);
1490 for line in &lines {
1491 assert!(!line.contains("\\u"));
1493 }
1494 }
1495
1496 #[test]
1497 fn wrap_zwj_sequence_as_unit() {
1498 let text = "👨👩👧";
1500 let lines = wrap_text(text, 2, WrapMode::Char);
1501 assert!(lines.iter().any(|l| l.contains("👨👩👧")));
1504 }
1505
1506 #[test]
1507 fn wrap_mixed_ascii_and_emoji_respects_width() {
1508 let lines = wrap_text("a😀b", 3, WrapMode::Char);
1509 assert_eq!(lines, vec!["a😀", "b"]);
1510 }
1511
1512 #[test]
1517 fn truncate_no_change_if_fits() {
1518 let result = truncate_with_ellipsis("hello", 10, "...");
1519 assert_eq!(result, "hello");
1520 }
1521
1522 #[test]
1523 fn truncate_with_ellipsis_ascii() {
1524 let result = truncate_with_ellipsis("hello world", 8, "...");
1525 assert_eq!(result, "hello...");
1526 }
1527
1528 #[test]
1529 fn truncate_cjk() {
1530 let result = truncate_with_ellipsis("你好世界", 6, "...");
1531 assert_eq!(result, "你...");
1534 }
1535
1536 #[test]
1537 fn truncate_to_width_basic() {
1538 let result = truncate_to_width("hello world", 5);
1539 assert_eq!(result, "hello");
1540 }
1541
1542 #[test]
1543 fn truncate_to_width_cjk() {
1544 let result = truncate_to_width("你好世界", 4);
1545 assert_eq!(result, "你好");
1546 }
1547
1548 #[test]
1549 fn truncate_to_width_odd_boundary() {
1550 let result = truncate_to_width("你好", 3);
1552 assert_eq!(result, "你");
1553 }
1554
1555 #[test]
1556 fn truncate_combining_chars() {
1557 let text = "e\u{0301}test";
1559 let result = truncate_to_width(text, 2);
1560 assert_eq!(result.chars().count(), 3); }
1563
1564 #[test]
1569 fn display_width_ascii() {
1570 assert_eq!(display_width("hello"), 5);
1571 }
1572
1573 #[test]
1574 fn display_width_cjk() {
1575 assert_eq!(display_width("你好"), 4);
1576 }
1577
1578 #[test]
1579 fn display_width_emoji_sequences() {
1580 assert_eq!(display_width("👩🔬"), 2);
1581 assert_eq!(display_width("👨👩👧👦"), 2);
1582 assert_eq!(display_width("👩🚀x"), 3);
1583 }
1584
1585 #[test]
1586 fn display_width_misc_symbol_emoji() {
1587 assert_eq!(display_width("⏳"), 2);
1588 assert_eq!(display_width("⌛"), 2);
1589 }
1590
1591 #[test]
1592 fn display_width_emoji_presentation_selector() {
1593 assert_eq!(display_width("❤️"), 1);
1595 assert_eq!(display_width("⌨️"), 1);
1596 assert_eq!(display_width("⚠️"), 1);
1597 }
1598
1599 #[test]
1600 fn display_width_misc_symbol_ranges() {
1601 assert_eq!(display_width("⌚"), 2); assert_eq!(display_width("⭐"), 2); let airplane_width = display_width("✈"); let arrow_width = display_width("⬆"); assert!(
1609 [1, 2].contains(&airplane_width),
1610 "airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
1611 );
1612 assert_eq!(
1613 airplane_width, arrow_width,
1614 "both Neutral-width chars should have same width in any mode"
1615 );
1616 }
1617
1618 #[test]
1619 fn display_width_flags() {
1620 assert_eq!(display_width("🇺🇸"), 2);
1621 assert_eq!(display_width("🇯🇵"), 2);
1622 assert_eq!(display_width("🇺🇸🇯🇵"), 4);
1623 }
1624
1625 #[test]
1626 fn display_width_skin_tone_modifiers() {
1627 assert_eq!(display_width("👍🏻"), 2);
1628 assert_eq!(display_width("👍🏽"), 2);
1629 }
1630
1631 #[test]
1632 fn display_width_zwj_sequences() {
1633 assert_eq!(display_width("👩💻"), 2);
1634 assert_eq!(display_width("👨👩👧👦"), 2);
1635 }
1636
1637 #[test]
1638 fn display_width_mixed_ascii_and_emoji() {
1639 assert_eq!(display_width("A😀B"), 4);
1640 assert_eq!(display_width("A👩💻B"), 4);
1641 assert_eq!(display_width("ok ✅"), 5);
1642 }
1643
1644 #[test]
1645 fn display_width_file_icons() {
1646 let wide_icons = ["📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄"];
1649 for icon in wide_icons {
1650 assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
1651 }
1652 let narrow_icons = ["⚙️", "🖼️"];
1654 for icon in narrow_icons {
1655 assert_eq!(display_width(icon), 1, "VS16 icon width mismatch: {icon}");
1656 }
1657 }
1658
1659 #[test]
1660 fn grapheme_width_emoji_sequence() {
1661 assert_eq!(grapheme_width("👩🔬"), 2);
1662 }
1663
1664 #[test]
1665 fn grapheme_width_flags_and_modifiers() {
1666 assert_eq!(grapheme_width("🇺🇸"), 2);
1667 assert_eq!(grapheme_width("👍🏽"), 2);
1668 }
1669
1670 #[test]
1671 fn display_width_empty() {
1672 assert_eq!(display_width(""), 0);
1673 }
1674
1675 #[test]
1680 fn ascii_width_pure_ascii() {
1681 assert_eq!(ascii_width("hello"), Some(5));
1682 assert_eq!(ascii_width("hello world 123"), Some(15));
1683 }
1684
1685 #[test]
1686 fn ascii_width_empty() {
1687 assert_eq!(ascii_width(""), Some(0));
1688 }
1689
1690 #[test]
1691 fn ascii_width_non_ascii_returns_none() {
1692 assert_eq!(ascii_width("你好"), None);
1693 assert_eq!(ascii_width("héllo"), None);
1694 assert_eq!(ascii_width("hello😀"), None);
1695 }
1696
1697 #[test]
1698 fn ascii_width_mixed_returns_none() {
1699 assert_eq!(ascii_width("hi你好"), None);
1700 assert_eq!(ascii_width("caf\u{00e9}"), None); }
1702
1703 #[test]
1704 fn ascii_width_control_chars_returns_none() {
1705 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); }
1714
1715 #[test]
1716 fn display_width_uses_ascii_fast_path() {
1717 assert_eq!(display_width("test"), 4);
1719 assert_eq!(display_width("你"), 2);
1721 }
1722
1723 #[test]
1724 fn has_wide_chars_true() {
1725 assert!(has_wide_chars("hi你好"));
1726 }
1727
1728 #[test]
1729 fn has_wide_chars_false() {
1730 assert!(!has_wide_chars("hello"));
1731 }
1732
1733 #[test]
1734 fn is_ascii_only_true() {
1735 assert!(is_ascii_only("hello world 123"));
1736 }
1737
1738 #[test]
1739 fn is_ascii_only_false() {
1740 assert!(!is_ascii_only("héllo"));
1741 }
1742
1743 #[test]
1748 fn grapheme_count_ascii() {
1749 assert_eq!(grapheme_count("hello"), 5);
1750 assert_eq!(grapheme_count(""), 0);
1751 }
1752
1753 #[test]
1754 fn grapheme_count_combining() {
1755 assert_eq!(grapheme_count("e\u{0301}"), 1);
1757 assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
1759 }
1760
1761 #[test]
1762 fn grapheme_count_cjk() {
1763 assert_eq!(grapheme_count("你好"), 2);
1764 }
1765
1766 #[test]
1767 fn grapheme_count_emoji() {
1768 assert_eq!(grapheme_count("😀"), 1);
1769 assert_eq!(grapheme_count("👍🏻"), 1);
1771 }
1772
1773 #[test]
1774 fn grapheme_count_zwj() {
1775 assert_eq!(grapheme_count("👨👩👧"), 1);
1777 }
1778
1779 #[test]
1780 fn graphemes_iteration() {
1781 let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
1782 assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
1783 }
1784
1785 #[test]
1786 fn graphemes_empty() {
1787 let gs: Vec<&str> = graphemes("").collect();
1788 assert!(gs.is_empty());
1789 }
1790
1791 #[test]
1792 fn graphemes_cjk() {
1793 let gs: Vec<&str> = graphemes("你好").collect();
1794 assert_eq!(gs, vec!["你", "好"]);
1795 }
1796
1797 #[test]
1798 fn truncate_to_width_with_info_basic() {
1799 let (text, width) = truncate_to_width_with_info("hello world", 5);
1800 assert_eq!(text, "hello");
1801 assert_eq!(width, 5);
1802 }
1803
1804 #[test]
1805 fn truncate_to_width_with_info_cjk() {
1806 let (text, width) = truncate_to_width_with_info("你好世界", 3);
1807 assert_eq!(text, "你");
1808 assert_eq!(width, 2);
1809 }
1810
1811 #[test]
1812 fn truncate_to_width_with_info_combining() {
1813 let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
1814 assert_eq!(text, "e\u{0301}b");
1815 assert_eq!(width, 2);
1816 }
1817
1818 #[test]
1819 fn truncate_to_width_with_info_fits() {
1820 let (text, width) = truncate_to_width_with_info("hi", 10);
1821 assert_eq!(text, "hi");
1822 assert_eq!(width, 2);
1823 }
1824
1825 #[test]
1826 fn word_boundaries_basic() {
1827 let breaks: Vec<usize> = word_boundaries("hello world").collect();
1828 assert!(breaks.contains(&6)); }
1830
1831 #[test]
1832 fn word_boundaries_multiple_spaces() {
1833 let breaks: Vec<usize> = word_boundaries("a b").collect();
1834 assert!(breaks.contains(&3)); }
1836
1837 #[test]
1838 fn word_segments_basic() {
1839 let segs: Vec<&str> = word_segments("hello world").collect();
1840 assert!(segs.contains(&"hello"));
1842 assert!(segs.contains(&"world"));
1843 }
1844
1845 #[test]
1850 fn wrap_options_builder() {
1851 let opts = WrapOptions::new(40)
1852 .mode(WrapMode::Char)
1853 .preserve_indent(true)
1854 .trim_trailing(false);
1855
1856 assert_eq!(opts.width, 40);
1857 assert_eq!(opts.mode, WrapMode::Char);
1858 assert!(opts.preserve_indent);
1859 assert!(!opts.trim_trailing);
1860 }
1861
1862 #[test]
1863 fn wrap_options_trim_trailing() {
1864 let opts = WrapOptions::new(10).trim_trailing(true);
1865 let lines = wrap_with_options("hello world", &opts);
1866 assert!(!lines.iter().any(|l| l.ends_with(' ')));
1868 }
1869
1870 #[test]
1871 fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
1872 let opts = WrapOptions::new(7)
1873 .mode(WrapMode::Word)
1874 .preserve_indent(true);
1875 let lines = wrap_with_options("word12 abcde", &opts);
1876 assert_eq!(lines, vec!["word12", " abcde"]);
1877 }
1878
1879 #[test]
1880 fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
1881 let opts = WrapOptions::new(7)
1882 .mode(WrapMode::Word)
1883 .preserve_indent(false);
1884 let lines = wrap_with_options("word12 abcde", &opts);
1885 assert_eq!(lines, vec!["word12", "abcde"]);
1886 }
1887
1888 #[test]
1889 fn wrap_zero_width() {
1890 let lines = wrap_text("hello", 0, WrapMode::Word);
1891 assert_eq!(lines, vec!["hello"]);
1893 }
1894
1895 #[test]
1900 fn wrap_mode_default() {
1901 let mode = WrapMode::default();
1902 assert_eq!(mode, WrapMode::Word);
1903 }
1904
1905 #[test]
1906 fn wrap_options_default() {
1907 let opts = WrapOptions::default();
1908 assert_eq!(opts.width, 80);
1909 assert_eq!(opts.mode, WrapMode::Word);
1910 assert!(!opts.preserve_indent);
1911 assert!(opts.trim_trailing);
1912 }
1913
1914 #[test]
1915 fn display_width_emoji_skin_tone() {
1916 let width = display_width("👍🏻");
1917 assert_eq!(width, 2);
1918 }
1919
1920 #[test]
1921 fn display_width_flag_emoji() {
1922 let width = display_width("🇺🇸");
1923 assert_eq!(width, 2);
1924 }
1925
1926 #[test]
1927 fn display_width_zwj_family() {
1928 let width = display_width("👨👩👧");
1929 assert_eq!(width, 2);
1930 }
1931
1932 #[test]
1933 fn display_width_multiple_combining() {
1934 let width = display_width("e\u{0301}\u{0308}");
1936 assert_eq!(width, 1);
1937 }
1938
1939 #[test]
1940 fn ascii_width_printable_range() {
1941 let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
1943 assert_eq!(ascii_width(&printable), Some(printable.len()));
1944 }
1945
1946 #[test]
1947 fn ascii_width_newline_returns_none() {
1948 assert!(ascii_width("hello\nworld").is_none());
1950 }
1951
1952 #[test]
1953 fn ascii_width_tab_returns_none() {
1954 assert!(ascii_width("hello\tworld").is_none());
1956 }
1957
1958 #[test]
1959 fn ascii_width_del_returns_none() {
1960 assert!(ascii_width("hello\x7Fworld").is_none());
1962 }
1963
1964 #[test]
1965 fn has_wide_chars_cjk_mixed() {
1966 assert!(has_wide_chars("abc你def"));
1967 assert!(has_wide_chars("你"));
1968 assert!(!has_wide_chars("abc"));
1969 }
1970
1971 #[test]
1972 fn has_wide_chars_emoji() {
1973 assert!(has_wide_chars("😀"));
1974 assert!(has_wide_chars("hello😀"));
1975 }
1976
1977 #[test]
1978 fn grapheme_count_empty() {
1979 assert_eq!(grapheme_count(""), 0);
1980 }
1981
1982 #[test]
1983 fn grapheme_count_regional_indicators() {
1984 assert_eq!(grapheme_count("🇺🇸"), 1);
1986 }
1987
1988 #[test]
1989 fn word_boundaries_no_spaces() {
1990 let breaks: Vec<usize> = word_boundaries("helloworld").collect();
1991 assert!(breaks.is_empty());
1992 }
1993
1994 #[test]
1995 fn word_boundaries_only_spaces() {
1996 let breaks: Vec<usize> = word_boundaries(" ").collect();
1997 assert!(!breaks.is_empty());
1998 }
1999
2000 #[test]
2001 fn word_segments_empty() {
2002 let segs: Vec<&str> = word_segments("").collect();
2003 assert!(segs.is_empty());
2004 }
2005
2006 #[test]
2007 fn word_segments_single_word() {
2008 let segs: Vec<&str> = word_segments("hello").collect();
2009 assert_eq!(segs.len(), 1);
2010 assert_eq!(segs[0], "hello");
2011 }
2012
2013 #[test]
2014 fn truncate_to_width_empty() {
2015 let result = truncate_to_width("", 10);
2016 assert_eq!(result, "");
2017 }
2018
2019 #[test]
2020 fn truncate_to_width_zero_width() {
2021 let result = truncate_to_width("hello", 0);
2022 assert_eq!(result, "");
2023 }
2024
2025 #[test]
2026 fn truncate_with_ellipsis_exact_fit() {
2027 let result = truncate_with_ellipsis("hello", 5, "...");
2029 assert_eq!(result, "hello");
2030 }
2031
2032 #[test]
2033 fn truncate_with_ellipsis_empty_ellipsis() {
2034 let result = truncate_with_ellipsis("hello world", 5, "");
2035 assert_eq!(result, "hello");
2036 }
2037
2038 #[test]
2039 fn truncate_to_width_with_info_empty() {
2040 let (text, width) = truncate_to_width_with_info("", 10);
2041 assert_eq!(text, "");
2042 assert_eq!(width, 0);
2043 }
2044
2045 #[test]
2046 fn truncate_to_width_with_info_zero_width() {
2047 let (text, width) = truncate_to_width_with_info("hello", 0);
2048 assert_eq!(text, "");
2049 assert_eq!(width, 0);
2050 }
2051
2052 #[test]
2053 fn truncate_to_width_wide_char_boundary() {
2054 let (text, width) = truncate_to_width_with_info("a你好", 2);
2056 assert_eq!(text, "a");
2058 assert_eq!(width, 1);
2059 }
2060
2061 #[test]
2062 fn wrap_mode_none() {
2063 let lines = wrap_text("hello world", 5, WrapMode::None);
2064 assert_eq!(lines, vec!["hello world"]);
2065 }
2066
2067 #[test]
2068 fn wrap_long_word_no_char_fallback() {
2069 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
2071 for line in &lines {
2073 assert!(line.width() <= 10);
2074 }
2075 }
2076
2077 #[test]
2082 fn unit_badness_monotone() {
2083 let width = 80;
2085 let mut prev = knuth_plass_badness(0, width, false);
2086 for slack in 1..=80i64 {
2087 let bad = knuth_plass_badness(slack, width, false);
2088 assert!(
2089 bad >= prev,
2090 "badness must be monotonically non-decreasing: \
2091 badness({slack}) = {bad} < badness({}) = {prev}",
2092 slack - 1
2093 );
2094 prev = bad;
2095 }
2096 }
2097
2098 #[test]
2099 fn unit_badness_zero_slack() {
2100 assert_eq!(knuth_plass_badness(0, 80, false), 0);
2102 assert_eq!(knuth_plass_badness(0, 80, true), 0);
2103 }
2104
2105 #[test]
2106 fn unit_badness_overflow_is_inf() {
2107 assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
2109 assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
2110 }
2111
2112 #[test]
2113 fn unit_badness_last_line_always_zero() {
2114 assert_eq!(knuth_plass_badness(0, 80, true), 0);
2116 assert_eq!(knuth_plass_badness(40, 80, true), 0);
2117 assert_eq!(knuth_plass_badness(79, 80, true), 0);
2118 }
2119
2120 #[test]
2121 fn unit_badness_cubic_growth() {
2122 let width = 100;
2123 let b10 = knuth_plass_badness(10, width, false);
2124 let b20 = knuth_plass_badness(20, width, false);
2125 let b40 = knuth_plass_badness(40, width, false);
2126
2127 assert!(
2130 b20 >= b10 * 6,
2131 "doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
2132 b20.checked_div(b10).unwrap_or(0)
2133 );
2134 assert!(
2135 b40 >= b20 * 6,
2136 "doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
2137 b40.checked_div(b20).unwrap_or(0)
2138 );
2139 }
2140
2141 #[test]
2142 fn unit_penalty_applied() {
2143 let result = wrap_optimal("superlongwordthatcannotfit", 10);
2145 assert!(
2147 result.total_cost >= PENALTY_FORCE_BREAK,
2148 "force-break penalty should be applied: cost={}",
2149 result.total_cost
2150 );
2151 }
2152
2153 #[test]
2154 fn kp_simple_wrap() {
2155 let result = wrap_optimal("Hello world foo bar", 10);
2156 for line in &result.lines {
2158 assert!(
2159 line.width() <= 10,
2160 "line '{line}' exceeds width 10 (width={})",
2161 line.width()
2162 );
2163 }
2164 assert!(result.lines.len() >= 2);
2166 }
2167
2168 #[test]
2169 fn kp_perfect_fit() {
2170 let result = wrap_optimal("aaaa bbbb", 9);
2172 assert_eq!(result.lines.len(), 1);
2174 assert_eq!(result.total_cost, 0);
2175 }
2176
2177 #[test]
2178 fn kp_optimal_vs_greedy() {
2179 let result = wrap_optimal("aaa bb cc ddddd", 6);
2184
2185 for line in &result.lines {
2187 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2188 }
2189
2190 assert!(result.lines.len() >= 2);
2194 }
2195
2196 #[test]
2197 fn kp_empty_text() {
2198 let result = wrap_optimal("", 80);
2199 assert_eq!(result.lines, vec![""]);
2200 assert_eq!(result.total_cost, 0);
2201 }
2202
2203 #[test]
2204 fn kp_single_word() {
2205 let result = wrap_optimal("hello", 80);
2206 assert_eq!(result.lines, vec!["hello"]);
2207 assert_eq!(result.total_cost, 0); }
2209
2210 #[test]
2211 fn kp_multiline_preserves_newlines() {
2212 let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
2213 assert!(lines.len() >= 2);
2215 assert!(lines[0].width() <= 10);
2217 }
2218
2219 #[test]
2220 fn kp_tokenize_basic() {
2221 let words = kp_tokenize("hello world foo");
2222 assert_eq!(words.len(), 3);
2223 assert_eq!(words[0].content_width, 5);
2224 assert_eq!(words[0].space_width, 1);
2225 assert_eq!(words[1].content_width, 5);
2226 assert_eq!(words[1].space_width, 1);
2227 assert_eq!(words[2].content_width, 3);
2228 assert_eq!(words[2].space_width, 0);
2229 }
2230
2231 #[test]
2232 fn kp_diagnostics_line_badness() {
2233 let result = wrap_optimal("short text here for testing the dp", 15);
2234 assert_eq!(result.line_badness.len(), result.lines.len());
2236 assert_eq!(
2238 *result.line_badness.last().unwrap(),
2239 0,
2240 "last line should have zero badness"
2241 );
2242 }
2243
2244 #[test]
2245 fn kp_deterministic() {
2246 let text = "The quick brown fox jumps over the lazy dog near a riverbank";
2247 let r1 = wrap_optimal(text, 20);
2248 let r2 = wrap_optimal(text, 20);
2249 assert_eq!(r1.lines, r2.lines);
2250 assert_eq!(r1.total_cost, r2.total_cost);
2251 }
2252
2253 #[test]
2258 fn unit_dp_matches_known() {
2259 let result = wrap_optimal("aaa bb cc ddddd", 6);
2264
2265 for line in &result.lines {
2267 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2268 }
2269
2270 assert_eq!(
2272 result.lines.len(),
2273 3,
2274 "expected 3 lines, got {:?}",
2275 result.lines
2276 );
2277 assert_eq!(result.lines[0], "aaa");
2278 assert_eq!(result.lines[1], "bb cc");
2279 assert_eq!(result.lines[2], "ddddd");
2280
2281 assert_eq!(*result.line_badness.last().unwrap(), 0);
2283 }
2284
2285 #[test]
2286 fn unit_dp_known_two_line() {
2287 let r1 = wrap_optimal("hello world", 11);
2289 assert_eq!(r1.lines, vec!["hello world"]);
2290 assert_eq!(r1.total_cost, 0);
2291
2292 let r2 = wrap_optimal("hello world", 7);
2294 assert_eq!(r2.lines.len(), 2);
2295 assert_eq!(r2.lines[0], "hello");
2296 assert_eq!(r2.lines[1], "world");
2297 assert!(
2300 r2.total_cost > 0 && r2.total_cost < 300,
2301 "expected cost ~233, got {}",
2302 r2.total_cost
2303 );
2304 }
2305
2306 #[test]
2307 fn unit_dp_optimal_beats_greedy() {
2308 let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
2329 let optimal = wrap_optimal("the quick brown fox", 10);
2330
2331 for line in &greedy {
2333 assert!(line.width() <= 10);
2334 }
2335 for line in &optimal.lines {
2336 assert!(line.width() <= 10);
2337 }
2338
2339 let mut greedy_cost: u64 = 0;
2342 for (i, line) in greedy.iter().enumerate() {
2343 let slack = 10i64 - line.width() as i64;
2344 let is_last = i == greedy.len() - 1;
2345 greedy_cost += knuth_plass_badness(slack, 10, is_last);
2346 }
2347 assert!(
2348 optimal.total_cost <= greedy_cost,
2349 "optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
2350 optimal.total_cost,
2351 greedy_cost
2352 );
2353 }
2354
2355 #[test]
2356 fn perf_wrap_large() {
2357 use std::time::Instant;
2358
2359 let words: Vec<&str> = [
2361 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2362 "back", "to", "its", "den", "in",
2363 ]
2364 .to_vec();
2365
2366 let mut paragraph = String::new();
2367 for i in 0..1000 {
2368 if i > 0 {
2369 paragraph.push(' ');
2370 }
2371 paragraph.push_str(words[i % words.len()]);
2372 }
2373
2374 let iterations = 20;
2375 let start = Instant::now();
2376 for _ in 0..iterations {
2377 let result = wrap_optimal(¶graph, 80);
2378 assert!(!result.lines.is_empty());
2379 }
2380 let elapsed = start.elapsed();
2381
2382 eprintln!(
2383 "{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
2384 iterations,
2385 elapsed.as_millis(),
2386 elapsed.as_micros() / iterations as u128
2387 );
2388
2389 assert!(
2391 elapsed.as_secs() < 2,
2392 "Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
2393 );
2394 }
2395
2396 #[test]
2397 fn kp_pruning_lookahead_bound() {
2398 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";
2400 let result = wrap_optimal(text, 10);
2401 for line in &result.lines {
2402 assert!(line.width() <= 10, "line '{line}' exceeds width");
2403 }
2404 let joined: String = result.lines.join(" ");
2406 for ch in 'a'..='z' {
2407 assert!(joined.contains(ch), "missing letter '{ch}' in output");
2408 }
2409 }
2410
2411 #[test]
2412 fn kp_very_narrow_width() {
2413 let result = wrap_optimal("ab cd ef", 2);
2415 assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
2416 }
2417
2418 #[test]
2419 fn kp_wide_width_single_line() {
2420 let result = wrap_optimal("hello world", 1000);
2422 assert_eq!(result.lines, vec!["hello world"]);
2423 assert_eq!(result.total_cost, 0);
2424 }
2425
2426 fn fnv1a_lines(lines: &[String]) -> u64 {
2432 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2433 for (i, line) in lines.iter().enumerate() {
2434 for byte in (i as u32)
2435 .to_le_bytes()
2436 .iter()
2437 .chain(line.as_bytes().iter())
2438 {
2439 hash ^= *byte as u64;
2440 hash = hash.wrapping_mul(0x0100_0000_01b3);
2441 }
2442 }
2443 hash
2444 }
2445
2446 #[test]
2447 fn snapshot_wrap_quality() {
2448 let paragraphs = [
2450 "The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
2451 "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",
2452 "aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
2453 ];
2454
2455 let widths = [20, 40, 60, 80];
2456
2457 for paragraph in ¶graphs {
2458 for &width in &widths {
2459 let result = wrap_optimal(paragraph, width);
2460
2461 let result2 = wrap_optimal(paragraph, width);
2463 assert_eq!(
2464 fnv1a_lines(&result.lines),
2465 fnv1a_lines(&result2.lines),
2466 "non-deterministic wrap at width {width}"
2467 );
2468
2469 for line in &result.lines {
2471 assert!(line.width() <= width, "line '{line}' exceeds width {width}");
2472 }
2473
2474 if !paragraph.is_empty() {
2476 for line in &result.lines {
2477 assert!(!line.is_empty(), "empty line in output at width {width}");
2478 }
2479 }
2480
2481 let original_words: Vec<&str> = paragraph.split_whitespace().collect();
2483 let result_words: Vec<&str> = result
2484 .lines
2485 .iter()
2486 .flat_map(|l| l.split_whitespace())
2487 .collect();
2488 assert_eq!(
2489 original_words, result_words,
2490 "content lost at width {width}"
2491 );
2492
2493 assert_eq!(
2495 *result.line_badness.last().unwrap(),
2496 0,
2497 "last line should have zero badness at width {width}"
2498 );
2499 }
2500 }
2501 }
2502
2503 #[test]
2508 fn perf_wrap_bench() {
2509 use std::time::Instant;
2510
2511 let sample_words = [
2512 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2513 "back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
2514 "near",
2515 ];
2516
2517 let scenarios: &[(usize, usize, &str)] = &[
2518 (50, 40, "short_40"),
2519 (50, 80, "short_80"),
2520 (200, 40, "medium_40"),
2521 (200, 80, "medium_80"),
2522 (500, 40, "long_40"),
2523 (500, 80, "long_80"),
2524 ];
2525
2526 for &(word_count, width, label) in scenarios {
2527 let mut paragraph = String::new();
2529 for i in 0..word_count {
2530 if i > 0 {
2531 paragraph.push(' ');
2532 }
2533 paragraph.push_str(sample_words[i % sample_words.len()]);
2534 }
2535
2536 let iterations = 30u32;
2537 let mut times_us = Vec::with_capacity(iterations as usize);
2538 let mut last_lines = 0usize;
2539 let mut last_cost = 0u64;
2540 let mut last_checksum = 0u64;
2541
2542 for _ in 0..iterations {
2543 let start = Instant::now();
2544 let result = wrap_optimal(¶graph, width);
2545 let elapsed = start.elapsed();
2546
2547 last_lines = result.lines.len();
2548 last_cost = result.total_cost;
2549 last_checksum = fnv1a_lines(&result.lines);
2550 times_us.push(elapsed.as_micros() as u64);
2551 }
2552
2553 times_us.sort();
2554 let len = times_us.len();
2555 let p50 = times_us[len / 2];
2556 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2557
2558 eprintln!(
2560 "{{\"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}\"}}"
2561 );
2562
2563 let verify = wrap_optimal(¶graph, width);
2565 assert_eq!(
2566 fnv1a_lines(&verify.lines),
2567 last_checksum,
2568 "non-deterministic: {label}"
2569 );
2570
2571 if word_count >= 500 && p95 > 5000 {
2573 eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
2574 }
2575 }
2576 }
2577}
2578
2579#[cfg(test)]
2580mod proptests {
2581 use super::TestWidth;
2582 use super::*;
2583 use proptest::prelude::*;
2584
2585 proptest! {
2586 #[test]
2587 fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
2588 let lines = wrap_text(&s, width, WrapMode::Char);
2589 for line in &lines {
2590 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2591 }
2592 }
2593
2594 #[test]
2595 fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
2596 let lines = wrap_text(&s, width, WrapMode::Char);
2597 let rejoined: String = lines.join("");
2598 prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
2600 }
2601
2602 #[test]
2603 fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
2604 let result = truncate_with_ellipsis(&s, width, "...");
2605 prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
2606 }
2607
2608 #[test]
2609 fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
2610 let result = truncate_to_width(&s, width);
2611 prop_assert!(result.width() <= width);
2612 if s.width() > width {
2614 prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
2616 }
2617 }
2618
2619 #[test]
2620 fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
2621 let lines = wrap_text(&s, width, WrapMode::WordChar);
2622 for line in &lines {
2623 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2624 }
2625 }
2626
2627 #[test]
2633 fn property_dp_vs_greedy(
2634 text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
2635 width in 8usize..40,
2636 ) {
2637 let greedy = wrap_text(&text, width, WrapMode::Word);
2638 let optimal = wrap_optimal(&text, width);
2639
2640 let mut greedy_cost: u64 = 0;
2642 for (i, line) in greedy.iter().enumerate() {
2643 let lw = line.width();
2644 let slack = width as i64 - lw as i64;
2645 let is_last = i == greedy.len() - 1;
2646 if slack >= 0 {
2647 greedy_cost = greedy_cost.saturating_add(
2648 knuth_plass_badness(slack, width, is_last)
2649 );
2650 } else {
2651 greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
2652 }
2653 }
2654
2655 prop_assert!(
2656 optimal.total_cost <= greedy_cost,
2657 "DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
2658 optimal.total_cost, greedy_cost, width, optimal.lines, greedy
2659 );
2660 }
2661
2662 #[test]
2664 fn property_dp_respects_width(
2665 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
2666 width in 6usize..30,
2667 ) {
2668 let result = wrap_optimal(&text, width);
2669 for line in &result.lines {
2670 prop_assert!(
2671 line.width() <= width,
2672 "DP line '{}' (width {}) exceeds target {}",
2673 line, line.width(), width
2674 );
2675 }
2676 }
2677
2678 #[test]
2680 fn property_dp_preserves_content(
2681 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
2682 width in 8usize..30,
2683 ) {
2684 let result = wrap_optimal(&text, width);
2685 let original_words: Vec<&str> = text.split_whitespace().collect();
2686 let result_words: Vec<&str> = result.lines.iter()
2687 .flat_map(|l| l.split_whitespace())
2688 .collect();
2689 prop_assert_eq!(
2690 original_words, result_words,
2691 "DP should preserve all words"
2692 );
2693 }
2694 }
2695
2696 #[test]
2701 fn fitness_class_from_ratio() {
2702 assert_eq!(FitnessClass::from_ratio(-0.8), FitnessClass::Tight);
2703 assert_eq!(FitnessClass::from_ratio(-0.5), FitnessClass::Normal);
2704 assert_eq!(FitnessClass::from_ratio(0.0), FitnessClass::Normal);
2705 assert_eq!(FitnessClass::from_ratio(0.49), FitnessClass::Normal);
2706 assert_eq!(FitnessClass::from_ratio(0.5), FitnessClass::Loose);
2707 assert_eq!(FitnessClass::from_ratio(0.99), FitnessClass::Loose);
2708 assert_eq!(FitnessClass::from_ratio(1.0), FitnessClass::VeryLoose);
2709 assert_eq!(FitnessClass::from_ratio(2.0), FitnessClass::VeryLoose);
2710 }
2711
2712 #[test]
2713 fn fitness_class_incompatible() {
2714 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Tight));
2715 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Normal));
2716 assert!(FitnessClass::Tight.incompatible(FitnessClass::Loose));
2717 assert!(FitnessClass::Tight.incompatible(FitnessClass::VeryLoose));
2718 assert!(!FitnessClass::Normal.incompatible(FitnessClass::Loose));
2719 assert!(FitnessClass::Normal.incompatible(FitnessClass::VeryLoose));
2720 }
2721
2722 #[test]
2723 fn objective_default_is_tex_standard() {
2724 let obj = ParagraphObjective::default();
2725 assert_eq!(obj.line_penalty, 10);
2726 assert_eq!(obj.fitness_demerit, 100);
2727 assert_eq!(obj.double_hyphen_demerit, 100);
2728 assert_eq!(obj.badness_scale, BADNESS_SCALE);
2729 }
2730
2731 #[test]
2732 fn objective_terminal_preset() {
2733 let obj = ParagraphObjective::terminal();
2734 assert_eq!(obj.line_penalty, 20);
2735 assert_eq!(obj.min_adjustment_ratio, 0.0);
2736 assert!(obj.max_adjustment_ratio > 2.0);
2737 }
2738
2739 #[test]
2740 fn badness_zero_slack_is_zero() {
2741 let obj = ParagraphObjective::default();
2742 assert_eq!(obj.badness(0, 80), Some(0));
2743 }
2744
2745 #[test]
2746 fn badness_moderate_slack() {
2747 let obj = ParagraphObjective::default();
2748 let b = obj.badness(10, 80).unwrap();
2751 assert!(b > 0 && b < 100, "badness = {b}");
2752 }
2753
2754 #[test]
2755 fn badness_excessive_slack_infeasible() {
2756 let obj = ParagraphObjective::default();
2757 assert!(obj.badness(240, 80).is_none());
2759 }
2760
2761 #[test]
2762 fn badness_negative_slack_within_bounds() {
2763 let obj = ParagraphObjective::default();
2764 let b = obj.badness(-40, 80);
2766 assert!(b.is_some());
2767 }
2768
2769 #[test]
2770 fn badness_negative_slack_beyond_bounds() {
2771 let obj = ParagraphObjective::default();
2772 assert!(obj.badness(-100, 80).is_none());
2774 }
2775
2776 #[test]
2777 fn badness_terminal_no_compression() {
2778 let obj = ParagraphObjective::terminal();
2779 assert!(obj.badness(-1, 80).is_none());
2781 }
2782
2783 #[test]
2784 fn demerits_space_break() {
2785 let obj = ParagraphObjective::default();
2786 let d = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2787 let badness = obj.badness(10, 80).unwrap();
2789 let expected = (obj.line_penalty + badness).pow(2);
2790 assert_eq!(d, expected);
2791 }
2792
2793 #[test]
2794 fn demerits_hyphen_break() {
2795 let obj = ParagraphObjective::default();
2796 let d_space = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2797 let d_hyphen = obj.demerits(10, 80, &BreakPenalty::HYPHEN).unwrap();
2798 assert!(d_hyphen > d_space);
2800 }
2801
2802 #[test]
2803 fn demerits_forced_break() {
2804 let obj = ParagraphObjective::default();
2805 let d = obj.demerits(0, 80, &BreakPenalty::FORCED).unwrap();
2806 assert_eq!(d, obj.line_penalty.pow(2));
2808 }
2809
2810 #[test]
2811 fn demerits_infeasible_returns_none() {
2812 let obj = ParagraphObjective::default();
2813 assert!(obj.demerits(300, 80, &BreakPenalty::SPACE).is_none());
2815 }
2816
2817 #[test]
2818 fn adjacency_fitness_incompatible() {
2819 let obj = ParagraphObjective::default();
2820 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::Loose, false, false);
2821 assert_eq!(d, obj.fitness_demerit);
2822 }
2823
2824 #[test]
2825 fn adjacency_fitness_compatible() {
2826 let obj = ParagraphObjective::default();
2827 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Loose, false, false);
2828 assert_eq!(d, 0);
2829 }
2830
2831 #[test]
2832 fn adjacency_double_hyphen() {
2833 let obj = ParagraphObjective::default();
2834 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Normal, true, true);
2835 assert_eq!(d, obj.double_hyphen_demerit);
2836 }
2837
2838 #[test]
2839 fn adjacency_double_hyphen_plus_fitness() {
2840 let obj = ParagraphObjective::default();
2841 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::VeryLoose, true, true);
2842 assert_eq!(d, obj.fitness_demerit + obj.double_hyphen_demerit);
2843 }
2844
2845 #[test]
2846 fn widow_penalty_short_last_line() {
2847 let obj = ParagraphObjective::default();
2848 assert_eq!(obj.widow_demerits(5), obj.widow_demerit);
2849 assert_eq!(obj.widow_demerits(14), obj.widow_demerit);
2850 assert_eq!(obj.widow_demerits(15), 0);
2851 assert_eq!(obj.widow_demerits(80), 0);
2852 }
2853
2854 #[test]
2855 fn orphan_penalty_short_first_line() {
2856 let obj = ParagraphObjective::default();
2857 assert_eq!(obj.orphan_demerits(10), obj.orphan_demerit);
2858 assert_eq!(obj.orphan_demerits(19), obj.orphan_demerit);
2859 assert_eq!(obj.orphan_demerits(20), 0);
2860 assert_eq!(obj.orphan_demerits(80), 0);
2861 }
2862
2863 #[test]
2864 fn adjustment_ratio_computation() {
2865 let obj = ParagraphObjective::default();
2866 let r = obj.adjustment_ratio(10, 80);
2867 assert!((r - 0.125).abs() < 1e-10);
2868 }
2869
2870 #[test]
2871 fn adjustment_ratio_zero_width() {
2872 let obj = ParagraphObjective::default();
2873 assert_eq!(obj.adjustment_ratio(5, 0), 0.0);
2874 }
2875
2876 #[test]
2877 fn badness_zero_width_zero_slack() {
2878 let obj = ParagraphObjective::default();
2879 assert_eq!(obj.badness(0, 0), Some(0));
2880 }
2881
2882 #[test]
2883 fn badness_zero_width_nonzero_slack() {
2884 let obj = ParagraphObjective::default();
2885 assert!(obj.badness(5, 0).is_none());
2886 }
2887}