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,
44}
45
46#[derive(Debug, Clone)]
48pub struct WrapOptions {
49 pub width: usize,
51 pub mode: WrapMode,
53 pub preserve_indent: bool,
55 pub trim_trailing: bool,
57}
58
59impl WrapOptions {
60 #[must_use]
62 pub fn new(width: usize) -> Self {
63 Self {
64 width,
65 mode: WrapMode::Word,
66 preserve_indent: false,
67 trim_trailing: true,
68 }
69 }
70
71 #[must_use]
73 pub fn mode(mut self, mode: WrapMode) -> Self {
74 self.mode = mode;
75 self
76 }
77
78 #[must_use]
80 pub fn preserve_indent(mut self, preserve: bool) -> Self {
81 self.preserve_indent = preserve;
82 self
83 }
84
85 #[must_use]
87 pub fn trim_trailing(mut self, trim: bool) -> Self {
88 self.trim_trailing = trim;
89 self
90 }
91}
92
93impl Default for WrapOptions {
94 fn default() -> Self {
95 Self::new(80)
96 }
97}
98
99#[must_use]
103pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
104 let preserve = mode == WrapMode::Char;
106 wrap_with_options(
107 text,
108 &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
109 )
110}
111
112#[must_use]
114pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
115 if options.width == 0 {
116 return vec![text.to_string()];
117 }
118
119 match options.mode {
120 WrapMode::None => vec![text.to_string()],
121 WrapMode::Char => wrap_chars(text, options),
122 WrapMode::Word => wrap_words(text, options, false),
123 WrapMode::WordChar => wrap_words(text, options, true),
124 WrapMode::Optimal => wrap_text_optimal(text, options.width),
125 }
126}
127
128fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
130 let mut lines = Vec::new();
131 let mut current_line = String::new();
132 let mut current_width = 0;
133
134 for grapheme in text.graphemes(true) {
135 if grapheme == "\n" || grapheme == "\r\n" {
137 lines.push(finalize_line(¤t_line, options));
138 current_line.clear();
139 current_width = 0;
140 continue;
141 }
142
143 let grapheme_width = crate::wrap::grapheme_width(grapheme);
144
145 if current_width + grapheme_width > options.width && !current_line.is_empty() {
147 lines.push(finalize_line(¤t_line, options));
148 current_line.clear();
149 current_width = 0;
150 }
151
152 current_line.push_str(grapheme);
154 current_width += grapheme_width;
155 }
156
157 lines.push(finalize_line(¤t_line, options));
160
161 lines
162}
163
164fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
166 let mut lines = Vec::new();
167
168 for raw_paragraph in text.split('\n') {
170 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
171 let mut current_line = String::new();
172 let mut current_width = 0;
173
174 let len_before = lines.len();
175
176 wrap_paragraph(
177 paragraph,
178 options,
179 char_fallback,
180 &mut lines,
181 &mut current_line,
182 &mut current_width,
183 );
184
185 if !current_line.is_empty() || lines.len() == len_before {
188 lines.push(finalize_line(¤t_line, options));
189 }
190 }
191
192 lines
193}
194
195fn wrap_paragraph(
197 text: &str,
198 options: &WrapOptions,
199 char_fallback: bool,
200 lines: &mut Vec<String>,
201 current_line: &mut String,
202 current_width: &mut usize,
203) {
204 for word in split_words(text) {
205 let is_whitespace_only = word.chars().all(is_breaking_whitespace);
206
207 if *current_width == 0 && is_whitespace_only && !options.preserve_indent {
209 continue;
210 }
211
212 let word_width = display_width(word);
213
214 if *current_width + word_width <= options.width {
216 current_line.push_str(word);
217 *current_width += word_width;
218 continue;
219 }
220
221 if !current_line.is_empty() {
223 lines.push(finalize_line(current_line, options));
224 current_line.clear();
225 *current_width = 0;
226
227 if is_whitespace_only && !options.preserve_indent {
231 continue;
232 }
233 }
234
235 if word_width > options.width {
237 if char_fallback {
238 wrap_long_word(word, options, lines, current_line, current_width);
240 } else {
241 lines.push(finalize_line(word, options));
243 }
244 } else {
245 if !word.is_empty() {
247 current_line.push_str(word);
248 }
249 *current_width = word_width;
250 }
251 }
252}
253
254fn wrap_long_word(
256 word: &str,
257 options: &WrapOptions,
258 lines: &mut Vec<String>,
259 current_line: &mut String,
260 current_width: &mut usize,
261) {
262 for grapheme in word.graphemes(true) {
263 let grapheme_width = crate::wrap::grapheme_width(grapheme);
264
265 if *current_width == 0
267 && grapheme.chars().all(is_breaking_whitespace)
268 && !options.preserve_indent
269 {
270 continue;
271 }
272
273 if *current_width + grapheme_width > options.width && !current_line.is_empty() {
274 lines.push(finalize_line(current_line, options));
275 current_line.clear();
276 *current_width = 0;
277
278 if grapheme.chars().all(is_breaking_whitespace) && !options.preserve_indent {
280 continue;
281 }
282 }
283
284 current_line.push_str(grapheme);
285 *current_width += grapheme_width;
286 }
287}
288
289fn split_words(text: &str) -> Vec<&str> {
294 let mut words = Vec::new();
295 let mut current_start = 0;
296 let mut current_end = 0;
297 let mut in_whitespace = false;
298 let mut byte_offset = 0;
299
300 for grapheme in text.graphemes(true) {
301 let is_ws = grapheme.chars().all(is_breaking_whitespace);
302
303 if is_ws != in_whitespace && current_end > current_start {
304 words.push(&text[current_start..current_end]);
305 current_start = byte_offset;
306 } else if current_end == current_start {
307 current_start = byte_offset;
308 }
309
310 current_end = byte_offset + grapheme.len();
311 in_whitespace = is_ws;
312 byte_offset += grapheme.len();
313 }
314
315 if current_end > current_start {
316 words.push(&text[current_start..current_end]);
317 }
318
319 words
320}
321
322fn finalize_line(line: &str, options: &WrapOptions) -> String {
324 if options.trim_trailing {
325 line.trim_end_matches(is_breaking_whitespace).to_string()
326 } else {
327 line.to_string()
328 }
329}
330
331#[must_use]
336pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
337 let text_width = display_width(text);
338
339 if text_width <= max_width {
340 return text.to_string();
341 }
342
343 let ellipsis_width = display_width(ellipsis);
344
345 if ellipsis_width >= max_width {
347 return truncate_to_width(text, max_width);
348 }
349
350 let target_width = max_width - ellipsis_width;
351 let mut result = truncate_to_width(text, target_width);
352 result.push_str(ellipsis);
353 result
354}
355
356#[must_use]
360pub fn truncate_to_width(text: &str, max_width: usize) -> String {
361 let mut result = String::new();
362 let mut current_width = 0;
363
364 for grapheme in text.graphemes(true) {
365 let grapheme_width = crate::wrap::grapheme_width(grapheme);
366
367 if current_width + grapheme_width > max_width {
368 break;
369 }
370
371 result.push_str(grapheme);
372 current_width += grapheme_width;
373 }
374
375 result
376}
377
378#[inline]
397#[must_use]
398pub fn ascii_width(text: &str) -> Option<usize> {
399 ftui_core::text_width::ascii_width(text)
400}
401
402#[inline]
410#[must_use]
411pub fn grapheme_width(grapheme: &str) -> usize {
412 ftui_core::text_width::grapheme_width(grapheme)
413}
414
415#[inline]
426#[must_use]
427pub fn display_width(text: &str) -> usize {
428 ftui_core::text_width::display_width(text)
429}
430
431#[must_use]
433pub fn has_wide_chars(text: &str) -> bool {
434 text.graphemes(true)
435 .any(|g| crate::wrap::grapheme_width(g) > 1)
436}
437
438#[must_use]
440pub fn is_ascii_only(text: &str) -> bool {
441 text.is_ascii()
442}
443
444#[inline]
462#[must_use]
463pub fn grapheme_count(text: &str) -> usize {
464 text.graphemes(true).count()
465}
466
467#[inline]
480pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
481 text.graphemes(true)
482}
483
484#[must_use]
507pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
508 let mut byte_end = 0;
509 let mut current_width = 0;
510
511 for grapheme in text.graphemes(true) {
512 let grapheme_width = crate::wrap::grapheme_width(grapheme);
513
514 if current_width + grapheme_width > max_width {
515 break;
516 }
517
518 current_width += grapheme_width;
519 byte_end += grapheme.len();
520 }
521
522 (&text[..byte_end], current_width)
523}
524
525pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
540 text.split_word_bound_indices().filter_map(|(idx, word)| {
541 if word.chars().all(is_breaking_whitespace) {
543 Some(idx + word.len())
544 } else {
545 None
546 }
547 })
548}
549
550pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
563 text.split_word_bounds()
564}
565
566const BADNESS_SCALE: u64 = 10_000;
606
607const BADNESS_INF: u64 = u64::MAX / 2;
609
610const PENALTY_FORCE_BREAK: u64 = 5000;
612
613const KP_MAX_LOOKAHEAD: usize = 1024;
617
618#[inline]
627fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
628 if slack < 0 {
629 return BADNESS_INF;
630 }
631 if is_last_line {
632 return 0;
633 }
634 if width == 0 {
635 return if slack == 0 { 0 } else { BADNESS_INF };
636 }
637
638 let ratio = slack as f64 / width as f64;
639 (ratio * ratio * ratio * BADNESS_SCALE as f64) as u64
640}
641
642pub(crate) fn is_breaking_whitespace(c: char) -> bool {
647 c.is_whitespace() && c != '\u{00A0}' && c != '\u{202F}'
648}
649
650#[derive(Debug, Clone)]
655struct KpWord<'a> {
656 content: Cow<'a, str>,
658 space: Cow<'a, str>,
660 content_width: usize,
662 space_width: usize,
664}
665
666fn kp_tokenize(text: &str) -> Vec<KpWord<'_>> {
673 let mut words = Vec::new();
674 let mut content_start = 0;
675 let mut content_end = 0;
676 let mut current_content_width = 0;
677 let mut byte_offset = 0;
678
679 for seg in text.split_word_bounds() {
680 let is_space = seg.chars().all(is_breaking_whitespace);
681 let width = display_width(seg);
682
683 if is_space {
684 if content_end > content_start {
685 let content = &text[content_start..content_end];
686 words.push(KpWord {
687 content: Cow::Borrowed(content),
688 space: Cow::Borrowed(seg),
689 content_width: current_content_width,
690 space_width: width,
691 });
692 content_start = byte_offset + seg.len();
693 content_end = content_start;
694 current_content_width = 0;
695 } else if let Some(last) = words.last_mut() {
696 if let Cow::Borrowed(s) = last.space {
698 let start = byte_offset - s.len();
699 let end = byte_offset + seg.len();
700 last.space = Cow::Borrowed(&text[start..end]);
701 }
702 last.space_width += width;
703 content_start = byte_offset + seg.len();
704 content_end = content_start;
705 } else {
706 words.push(KpWord {
707 content: Cow::Borrowed(""),
708 space: Cow::Borrowed(seg),
709 content_width: 0,
710 space_width: width,
711 });
712 content_start = byte_offset + seg.len();
713 content_end = content_start;
714 }
715 } else {
716 if content_start == content_end {
717 content_start = byte_offset;
718 }
719 content_end = byte_offset + seg.len();
720 current_content_width += width;
721 }
722
723 byte_offset += seg.len();
724 }
725
726 if content_end > content_start {
727 let content = &text[content_start..content_end];
728 words.push(KpWord {
729 content: Cow::Borrowed(content),
730 space: Cow::Borrowed(""),
731 content_width: current_content_width,
732 space_width: 0,
733 });
734 }
735
736 words
737}
738
739#[derive(Debug, Clone)]
741pub struct KpBreakResult {
742 pub lines: Vec<String>,
744 pub total_cost: u64,
746 pub line_badness: Vec<u64>,
748}
749
750pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
765 if width == 0 || text.is_empty() {
766 return KpBreakResult {
767 lines: vec![text.to_string()],
768 total_cost: 0,
769 line_badness: vec![0],
770 };
771 }
772
773 let words = kp_tokenize(text);
774 if words.is_empty() {
775 return KpBreakResult {
776 lines: vec![text.to_string()],
777 total_cost: 0,
778 line_badness: vec![0],
779 };
780 }
781
782 let n = words.len();
783
784 let mut cost = vec![BADNESS_INF; n + 1];
787 let mut from = vec![0usize; n + 1];
788 cost[0] = 0;
789
790 for j in 1..=n {
791 let mut line_width: usize = 0;
792 let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
795 for i in (earliest..j).rev() {
796 line_width += words[i].content_width;
798 if i < j - 1 {
799 line_width += words[i].space_width;
801 }
802
803 if line_width > width && i < j - 1 {
805 break;
807 }
808
809 let slack = width as i64 - line_width as i64;
810 let is_last = j == n;
811 let badness = if line_width > width {
812 PENALTY_FORCE_BREAK
814 } else {
815 knuth_plass_badness(slack, width, is_last)
816 };
817
818 let candidate = cost[i].saturating_add(badness);
819 if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
821 cost[j] = candidate;
822 from[j] = i;
823 }
824 }
825 }
826
827 let mut breaks = Vec::new();
829 let mut pos = n;
830 while pos > 0 {
831 breaks.push(from[pos]);
832 pos = from[pos];
833 }
834 breaks.reverse();
835
836 let mut lines = Vec::new();
838 let mut line_badness = Vec::new();
839 let break_count = breaks.len();
840
841 for (idx, &start) in breaks.iter().enumerate() {
842 let end = if idx + 1 < break_count {
843 breaks[idx + 1]
844 } else {
845 n
846 };
847
848 let mut line = String::new();
850 for (i, word) in words.iter().take(end).skip(start).enumerate() {
851 line.push_str(&word.content);
852 if i < (end - start) - 1 {
854 line.push_str(&word.space);
855 }
856 }
857
858 let trimmed = line.trim_end_matches(is_breaking_whitespace).to_string();
860
861 let line_w = display_width(trimmed.as_str());
863 let slack = width as i64 - line_w as i64;
864 let is_last = idx == break_count - 1;
865 let bad = if slack < 0 {
866 PENALTY_FORCE_BREAK
867 } else {
868 knuth_plass_badness(slack, width, is_last)
869 };
870
871 lines.push(trimmed);
872 line_badness.push(bad);
873 }
874
875 KpBreakResult {
876 lines,
877 total_cost: cost[n],
878 line_badness,
879 }
880}
881
882#[must_use]
886pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
887 let mut result = Vec::new();
888 for raw_paragraph in text.split('\n') {
889 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
890 if paragraph.is_empty() {
891 result.push(String::new());
892 continue;
893 }
894 let kp = wrap_optimal(paragraph, width);
895 result.extend(kp.lines);
896 }
897 result
898}
899
900#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
933#[repr(u8)]
934pub enum FitnessClass {
935 Tight = 0,
937 Normal = 1,
939 Loose = 2,
941 VeryLoose = 3,
943}
944
945impl FitnessClass {
946 #[must_use]
951 pub fn from_ratio(ratio: f64) -> Self {
952 if ratio < -0.5 {
953 FitnessClass::Tight
954 } else if ratio < 0.5 {
955 FitnessClass::Normal
956 } else if ratio < 1.0 {
957 FitnessClass::Loose
958 } else {
959 FitnessClass::VeryLoose
960 }
961 }
962
963 #[must_use]
966 pub const fn incompatible(self, other: Self) -> bool {
967 let a = self as i8;
968 let b = other as i8;
969 (a - b > 1) || (b - a > 1)
971 }
972}
973
974#[derive(Debug, Clone, Copy, PartialEq, Eq)]
976pub enum BreakKind {
977 Space,
979 Hyphen,
981 Forced,
983 Emergency,
985}
986
987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
994pub struct BreakPenalty {
995 pub value: i64,
997 pub flagged: bool,
1000}
1001
1002impl BreakPenalty {
1003 pub const SPACE: Self = Self {
1005 value: 0,
1006 flagged: false,
1007 };
1008
1009 pub const HYPHEN: Self = Self {
1011 value: 50,
1012 flagged: true,
1013 };
1014
1015 pub const FORCED: Self = Self {
1017 value: i64::MIN,
1018 flagged: false,
1019 };
1020
1021 pub const EMERGENCY: Self = Self {
1023 value: 5000,
1024 flagged: false,
1025 };
1026}
1027
1028#[derive(Debug, Clone, Copy, PartialEq)]
1033pub struct ParagraphObjective {
1034 pub line_penalty: u64,
1038
1039 pub fitness_demerit: u64,
1042
1043 pub double_hyphen_demerit: u64,
1046
1047 pub final_hyphen_demerit: u64,
1050
1051 pub max_adjustment_ratio: f64,
1055
1056 pub min_adjustment_ratio: f64,
1059
1060 pub widow_demerit: u64,
1064
1065 pub widow_threshold: usize,
1068
1069 pub orphan_demerit: u64,
1073
1074 pub orphan_threshold: usize,
1077
1078 pub badness_scale: u64,
1081}
1082
1083impl Default for ParagraphObjective {
1084 fn default() -> Self {
1085 Self {
1086 line_penalty: 10,
1087 fitness_demerit: 100,
1088 double_hyphen_demerit: 100,
1089 final_hyphen_demerit: 100,
1090 max_adjustment_ratio: 2.0,
1091 min_adjustment_ratio: -1.0,
1092 widow_demerit: 150,
1093 widow_threshold: 15,
1094 orphan_demerit: 150,
1095 orphan_threshold: 20,
1096 badness_scale: BADNESS_SCALE,
1097 }
1098 }
1099}
1100
1101impl ParagraphObjective {
1102 #[must_use]
1105 pub fn terminal() -> Self {
1106 Self {
1107 line_penalty: 20,
1109 fitness_demerit: 50,
1111 min_adjustment_ratio: 0.0,
1113 max_adjustment_ratio: 3.0,
1115 widow_demerit: 50,
1117 orphan_demerit: 50,
1118 ..Self::default()
1119 }
1120 }
1121
1122 #[must_use]
1124 pub fn typographic() -> Self {
1125 Self::default()
1126 }
1127
1128 #[must_use]
1133 pub fn badness(&self, slack: i64, width: usize) -> Option<u64> {
1134 if width == 0 {
1135 return if slack == 0 { Some(0) } else { None };
1136 }
1137
1138 let ratio = slack as f64 / width as f64;
1139
1140 if ratio < self.min_adjustment_ratio || ratio > self.max_adjustment_ratio {
1142 return None; }
1144
1145 let abs_ratio = ratio.abs();
1146 let badness = (abs_ratio * abs_ratio * abs_ratio * self.badness_scale as f64) as u64;
1147 Some(badness)
1148 }
1149
1150 #[must_use]
1152 pub fn adjustment_ratio(&self, slack: i64, width: usize) -> f64 {
1153 if width == 0 {
1154 return 0.0;
1155 }
1156 slack as f64 / width as f64
1157 }
1158
1159 #[must_use]
1169 pub fn demerits(&self, slack: i64, width: usize, penalty: &BreakPenalty) -> Option<u64> {
1170 let badness = self.badness(slack, width)?;
1171
1172 let base = self.line_penalty.saturating_add(badness);
1173 let base_sq = base.saturating_mul(base);
1174
1175 let pen_sq = (penalty.value.unsigned_abs()).saturating_mul(penalty.value.unsigned_abs());
1176
1177 if penalty.value >= 0 {
1178 Some(base_sq.saturating_add(pen_sq))
1179 } else if penalty.value > i64::MIN {
1180 Some(base_sq.saturating_sub(pen_sq))
1182 } else {
1183 Some(base_sq)
1185 }
1186 }
1187
1188 #[must_use]
1193 pub fn adjacency_demerits(
1194 &self,
1195 prev_fitness: FitnessClass,
1196 curr_fitness: FitnessClass,
1197 prev_flagged: bool,
1198 curr_flagged: bool,
1199 ) -> u64 {
1200 let mut extra = 0u64;
1201
1202 if prev_fitness.incompatible(curr_fitness) {
1204 extra = extra.saturating_add(self.fitness_demerit);
1205 }
1206
1207 if prev_flagged && curr_flagged {
1209 extra = extra.saturating_add(self.double_hyphen_demerit);
1210 }
1211
1212 extra
1213 }
1214
1215 #[must_use]
1220 pub fn widow_demerits(&self, last_line_chars: usize) -> u64 {
1221 if last_line_chars < self.widow_threshold {
1222 self.widow_demerit
1223 } else {
1224 0
1225 }
1226 }
1227
1228 #[must_use]
1232 pub fn orphan_demerits(&self, first_line_chars: usize) -> u64 {
1233 if first_line_chars < self.orphan_threshold {
1234 self.orphan_demerit
1235 } else {
1236 0
1237 }
1238 }
1239}
1240
1241#[cfg(test)]
1242trait TestWidth {
1243 fn width(&self) -> usize;
1244}
1245
1246#[cfg(test)]
1247impl TestWidth for str {
1248 fn width(&self) -> usize {
1249 display_width(self)
1250 }
1251}
1252
1253#[cfg(test)]
1254impl TestWidth for String {
1255 fn width(&self) -> usize {
1256 display_width(self)
1257 }
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262 use super::TestWidth;
1263 use super::*;
1264
1265 #[test]
1270 fn wrap_text_no_wrap_needed() {
1271 let lines = wrap_text("hello", 10, WrapMode::Word);
1272 assert_eq!(lines, vec!["hello"]);
1273 }
1274
1275 #[test]
1276 fn wrap_text_single_word_wrap() {
1277 let lines = wrap_text("hello world", 5, WrapMode::Word);
1278 assert_eq!(lines, vec!["hello", "world"]);
1279 }
1280
1281 #[test]
1282 fn wrap_text_multiple_words() {
1283 let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
1284 assert_eq!(lines, vec!["hello world", "foo bar"]);
1285 }
1286
1287 #[test]
1288 fn wrap_text_preserves_newlines() {
1289 let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
1290 assert_eq!(lines, vec!["line1", "line2"]);
1291 }
1292
1293 #[test]
1294 fn wrap_text_preserves_crlf_newlines() {
1295 let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
1296 assert_eq!(lines, vec!["line1", "line2", ""]);
1297 }
1298
1299 #[test]
1300 fn wrap_text_trailing_newlines() {
1301 let lines = wrap_text("line1\n", 20, WrapMode::Word);
1303 assert_eq!(lines, vec!["line1", ""]);
1304
1305 let lines = wrap_text("\n", 20, WrapMode::Word);
1307 assert_eq!(lines, vec!["", ""]);
1308
1309 let lines = wrap_text("line1\n", 20, WrapMode::Char);
1311 assert_eq!(lines, vec!["line1", ""]);
1312 }
1313
1314 #[test]
1315 fn wrap_text_empty_string() {
1316 let lines = wrap_text("", 10, WrapMode::Word);
1317 assert_eq!(lines, vec![""]);
1318 }
1319
1320 #[test]
1321 fn wrap_text_long_word_no_fallback() {
1322 let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
1323 assert_eq!(lines, vec!["supercalifragilistic"]);
1325 }
1326
1327 #[test]
1328 fn wrap_text_long_word_with_fallback() {
1329 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1330 assert!(lines.len() > 1);
1332 for line in &lines {
1333 assert!(line.width() <= 10);
1334 }
1335 }
1336
1337 #[test]
1338 fn wrap_char_mode() {
1339 let lines = wrap_text("hello world", 5, WrapMode::Char);
1340 assert_eq!(lines, vec!["hello", " worl", "d"]);
1341 }
1342
1343 #[test]
1344 fn wrap_none_mode() {
1345 let lines = wrap_text("hello world", 5, WrapMode::None);
1346 assert_eq!(lines, vec!["hello world"]);
1347 }
1348
1349 #[test]
1354 fn wrap_cjk_respects_width() {
1355 let lines = wrap_text("你好世界", 4, WrapMode::Char);
1357 assert_eq!(lines, vec!["你好", "世界"]);
1358 }
1359
1360 #[test]
1361 fn wrap_cjk_odd_width() {
1362 let lines = wrap_text("你好世", 5, WrapMode::Char);
1364 assert_eq!(lines, vec!["你好", "世"]);
1365 }
1366
1367 #[test]
1368 fn wrap_mixed_ascii_cjk() {
1369 let lines = wrap_text("hi你好", 4, WrapMode::Char);
1370 assert_eq!(lines, vec!["hi你", "好"]);
1371 }
1372
1373 #[test]
1378 fn wrap_emoji_as_unit() {
1379 let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
1381 assert_eq!(lines.len(), 2);
1383 for line in &lines {
1384 assert!(!line.contains("\\u"));
1386 }
1387 }
1388
1389 #[test]
1390 fn wrap_zwj_sequence_as_unit() {
1391 let text = "👨👩👧";
1393 let lines = wrap_text(text, 2, WrapMode::Char);
1394 assert!(lines.iter().any(|l| l.contains("👨👩👧")));
1397 }
1398
1399 #[test]
1400 fn wrap_mixed_ascii_and_emoji_respects_width() {
1401 let lines = wrap_text("a😀b", 3, WrapMode::Char);
1402 assert_eq!(lines, vec!["a😀", "b"]);
1403 }
1404
1405 #[test]
1410 fn truncate_no_change_if_fits() {
1411 let result = truncate_with_ellipsis("hello", 10, "...");
1412 assert_eq!(result, "hello");
1413 }
1414
1415 #[test]
1416 fn truncate_with_ellipsis_ascii() {
1417 let result = truncate_with_ellipsis("hello world", 8, "...");
1418 assert_eq!(result, "hello...");
1419 }
1420
1421 #[test]
1422 fn truncate_cjk() {
1423 let result = truncate_with_ellipsis("你好世界", 6, "...");
1424 assert_eq!(result, "你...");
1427 }
1428
1429 #[test]
1430 fn truncate_to_width_basic() {
1431 let result = truncate_to_width("hello world", 5);
1432 assert_eq!(result, "hello");
1433 }
1434
1435 #[test]
1436 fn truncate_to_width_cjk() {
1437 let result = truncate_to_width("你好世界", 4);
1438 assert_eq!(result, "你好");
1439 }
1440
1441 #[test]
1442 fn truncate_to_width_odd_boundary() {
1443 let result = truncate_to_width("你好", 3);
1445 assert_eq!(result, "你");
1446 }
1447
1448 #[test]
1449 fn truncate_combining_chars() {
1450 let text = "e\u{0301}test";
1452 let result = truncate_to_width(text, 2);
1453 assert_eq!(result.chars().count(), 3); }
1456
1457 #[test]
1462 fn display_width_ascii() {
1463 assert_eq!(display_width("hello"), 5);
1464 }
1465
1466 #[test]
1467 fn display_width_cjk() {
1468 assert_eq!(display_width("你好"), 4);
1469 }
1470
1471 #[test]
1472 fn display_width_emoji_sequences() {
1473 assert_eq!(display_width("👩🔬"), 2);
1474 assert_eq!(display_width("👨👩👧👦"), 2);
1475 assert_eq!(display_width("👩🚀x"), 3);
1476 }
1477
1478 #[test]
1479 fn display_width_misc_symbol_emoji() {
1480 assert_eq!(display_width("⏳"), 2);
1481 assert_eq!(display_width("⌛"), 2);
1482 }
1483
1484 #[test]
1485 fn display_width_emoji_presentation_selector() {
1486 assert_eq!(display_width("❤️"), 1);
1488 assert_eq!(display_width("⌨️"), 1);
1489 assert_eq!(display_width("⚠️"), 1);
1490 }
1491
1492 #[test]
1493 fn display_width_misc_symbol_ranges() {
1494 assert_eq!(display_width("⌚"), 2); assert_eq!(display_width("⭐"), 2); let airplane_width = display_width("✈"); let arrow_width = display_width("⬆"); assert!(
1502 [1, 2].contains(&airplane_width),
1503 "airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
1504 );
1505 assert_eq!(
1506 airplane_width, arrow_width,
1507 "both Neutral-width chars should have same width in any mode"
1508 );
1509 }
1510
1511 #[test]
1512 fn display_width_flags() {
1513 assert_eq!(display_width("🇺🇸"), 2);
1514 assert_eq!(display_width("🇯🇵"), 2);
1515 assert_eq!(display_width("🇺🇸🇯🇵"), 4);
1516 }
1517
1518 #[test]
1519 fn display_width_skin_tone_modifiers() {
1520 assert_eq!(display_width("👍🏻"), 2);
1521 assert_eq!(display_width("👍🏽"), 2);
1522 }
1523
1524 #[test]
1525 fn display_width_zwj_sequences() {
1526 assert_eq!(display_width("👩💻"), 2);
1527 assert_eq!(display_width("👨👩👧👦"), 2);
1528 }
1529
1530 #[test]
1531 fn display_width_mixed_ascii_and_emoji() {
1532 assert_eq!(display_width("A😀B"), 4);
1533 assert_eq!(display_width("A👩💻B"), 4);
1534 assert_eq!(display_width("ok ✅"), 5);
1535 }
1536
1537 #[test]
1538 fn display_width_file_icons() {
1539 let wide_icons = ["📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄"];
1542 for icon in wide_icons {
1543 assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
1544 }
1545 let narrow_icons = ["⚙️", "🖼️"];
1547 for icon in narrow_icons {
1548 assert_eq!(display_width(icon), 1, "VS16 icon width mismatch: {icon}");
1549 }
1550 }
1551
1552 #[test]
1553 fn grapheme_width_emoji_sequence() {
1554 assert_eq!(grapheme_width("👩🔬"), 2);
1555 }
1556
1557 #[test]
1558 fn grapheme_width_flags_and_modifiers() {
1559 assert_eq!(grapheme_width("🇺🇸"), 2);
1560 assert_eq!(grapheme_width("👍🏽"), 2);
1561 }
1562
1563 #[test]
1564 fn display_width_empty() {
1565 assert_eq!(display_width(""), 0);
1566 }
1567
1568 #[test]
1573 fn ascii_width_pure_ascii() {
1574 assert_eq!(ascii_width("hello"), Some(5));
1575 assert_eq!(ascii_width("hello world 123"), Some(15));
1576 }
1577
1578 #[test]
1579 fn ascii_width_empty() {
1580 assert_eq!(ascii_width(""), Some(0));
1581 }
1582
1583 #[test]
1584 fn ascii_width_non_ascii_returns_none() {
1585 assert_eq!(ascii_width("你好"), None);
1586 assert_eq!(ascii_width("héllo"), None);
1587 assert_eq!(ascii_width("hello😀"), None);
1588 }
1589
1590 #[test]
1591 fn ascii_width_mixed_returns_none() {
1592 assert_eq!(ascii_width("hi你好"), None);
1593 assert_eq!(ascii_width("caf\u{00e9}"), None); }
1595
1596 #[test]
1597 fn ascii_width_control_chars_returns_none() {
1598 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); }
1607
1608 #[test]
1609 fn display_width_uses_ascii_fast_path() {
1610 assert_eq!(display_width("test"), 4);
1612 assert_eq!(display_width("你"), 2);
1614 }
1615
1616 #[test]
1617 fn has_wide_chars_true() {
1618 assert!(has_wide_chars("hi你好"));
1619 }
1620
1621 #[test]
1622 fn has_wide_chars_false() {
1623 assert!(!has_wide_chars("hello"));
1624 }
1625
1626 #[test]
1627 fn is_ascii_only_true() {
1628 assert!(is_ascii_only("hello world 123"));
1629 }
1630
1631 #[test]
1632 fn is_ascii_only_false() {
1633 assert!(!is_ascii_only("héllo"));
1634 }
1635
1636 #[test]
1641 fn grapheme_count_ascii() {
1642 assert_eq!(grapheme_count("hello"), 5);
1643 assert_eq!(grapheme_count(""), 0);
1644 }
1645
1646 #[test]
1647 fn grapheme_count_combining() {
1648 assert_eq!(grapheme_count("e\u{0301}"), 1);
1650 assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
1652 }
1653
1654 #[test]
1655 fn grapheme_count_cjk() {
1656 assert_eq!(grapheme_count("你好"), 2);
1657 }
1658
1659 #[test]
1660 fn grapheme_count_emoji() {
1661 assert_eq!(grapheme_count("😀"), 1);
1662 assert_eq!(grapheme_count("👍🏻"), 1);
1664 }
1665
1666 #[test]
1667 fn grapheme_count_zwj() {
1668 assert_eq!(grapheme_count("👨👩👧"), 1);
1670 }
1671
1672 #[test]
1673 fn graphemes_iteration() {
1674 let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
1675 assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
1676 }
1677
1678 #[test]
1679 fn graphemes_empty() {
1680 let gs: Vec<&str> = graphemes("").collect();
1681 assert!(gs.is_empty());
1682 }
1683
1684 #[test]
1685 fn graphemes_cjk() {
1686 let gs: Vec<&str> = graphemes("你好").collect();
1687 assert_eq!(gs, vec!["你", "好"]);
1688 }
1689
1690 #[test]
1691 fn truncate_to_width_with_info_basic() {
1692 let (text, width) = truncate_to_width_with_info("hello world", 5);
1693 assert_eq!(text, "hello");
1694 assert_eq!(width, 5);
1695 }
1696
1697 #[test]
1698 fn truncate_to_width_with_info_cjk() {
1699 let (text, width) = truncate_to_width_with_info("你好世界", 3);
1700 assert_eq!(text, "你");
1701 assert_eq!(width, 2);
1702 }
1703
1704 #[test]
1705 fn truncate_to_width_with_info_combining() {
1706 let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
1707 assert_eq!(text, "e\u{0301}b");
1708 assert_eq!(width, 2);
1709 }
1710
1711 #[test]
1712 fn truncate_to_width_with_info_fits() {
1713 let (text, width) = truncate_to_width_with_info("hi", 10);
1714 assert_eq!(text, "hi");
1715 assert_eq!(width, 2);
1716 }
1717
1718 #[test]
1719 fn word_boundaries_basic() {
1720 let breaks: Vec<usize> = word_boundaries("hello world").collect();
1721 assert!(breaks.contains(&6)); }
1723
1724 #[test]
1725 fn word_boundaries_multiple_spaces() {
1726 let breaks: Vec<usize> = word_boundaries("a b").collect();
1727 assert!(breaks.contains(&3)); }
1729
1730 #[test]
1731 fn word_segments_basic() {
1732 let segs: Vec<&str> = word_segments("hello world").collect();
1733 assert!(segs.contains(&"hello"));
1735 assert!(segs.contains(&"world"));
1736 }
1737
1738 #[test]
1743 fn wrap_options_builder() {
1744 let opts = WrapOptions::new(40)
1745 .mode(WrapMode::Char)
1746 .preserve_indent(true)
1747 .trim_trailing(false);
1748
1749 assert_eq!(opts.width, 40);
1750 assert_eq!(opts.mode, WrapMode::Char);
1751 assert!(opts.preserve_indent);
1752 assert!(!opts.trim_trailing);
1753 }
1754
1755 #[test]
1756 fn wrap_options_trim_trailing() {
1757 let opts = WrapOptions::new(10).trim_trailing(true);
1758 let lines = wrap_with_options("hello world", &opts);
1759 assert!(!lines.iter().any(|l| l.ends_with(' ')));
1761 }
1762
1763 #[test]
1764 fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
1765 let opts = WrapOptions::new(7)
1766 .mode(WrapMode::Word)
1767 .preserve_indent(true);
1768 let lines = wrap_with_options("word12 abcde", &opts);
1769 assert_eq!(lines, vec!["word12", " abcde"]);
1770 }
1771
1772 #[test]
1773 fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
1774 let opts = WrapOptions::new(7)
1775 .mode(WrapMode::Word)
1776 .preserve_indent(false);
1777 let lines = wrap_with_options("word12 abcde", &opts);
1778 assert_eq!(lines, vec!["word12", "abcde"]);
1779 }
1780
1781 #[test]
1782 fn wrap_zero_width() {
1783 let lines = wrap_text("hello", 0, WrapMode::Word);
1784 assert_eq!(lines, vec!["hello"]);
1786 }
1787
1788 #[test]
1793 fn wrap_mode_default() {
1794 let mode = WrapMode::default();
1795 assert_eq!(mode, WrapMode::Word);
1796 }
1797
1798 #[test]
1799 fn wrap_options_default() {
1800 let opts = WrapOptions::default();
1801 assert_eq!(opts.width, 80);
1802 assert_eq!(opts.mode, WrapMode::Word);
1803 assert!(!opts.preserve_indent);
1804 assert!(opts.trim_trailing);
1805 }
1806
1807 #[test]
1808 fn display_width_emoji_skin_tone() {
1809 let width = display_width("👍🏻");
1810 assert_eq!(width, 2);
1811 }
1812
1813 #[test]
1814 fn display_width_flag_emoji() {
1815 let width = display_width("🇺🇸");
1816 assert_eq!(width, 2);
1817 }
1818
1819 #[test]
1820 fn display_width_zwj_family() {
1821 let width = display_width("👨👩👧");
1822 assert_eq!(width, 2);
1823 }
1824
1825 #[test]
1826 fn display_width_multiple_combining() {
1827 let width = display_width("e\u{0301}\u{0308}");
1829 assert_eq!(width, 1);
1830 }
1831
1832 #[test]
1833 fn ascii_width_printable_range() {
1834 let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
1836 assert_eq!(ascii_width(&printable), Some(printable.len()));
1837 }
1838
1839 #[test]
1840 fn ascii_width_newline_returns_none() {
1841 assert!(ascii_width("hello\nworld").is_none());
1843 }
1844
1845 #[test]
1846 fn ascii_width_tab_returns_none() {
1847 assert!(ascii_width("hello\tworld").is_none());
1849 }
1850
1851 #[test]
1852 fn ascii_width_del_returns_none() {
1853 assert!(ascii_width("hello\x7Fworld").is_none());
1855 }
1856
1857 #[test]
1858 fn has_wide_chars_cjk_mixed() {
1859 assert!(has_wide_chars("abc你def"));
1860 assert!(has_wide_chars("你"));
1861 assert!(!has_wide_chars("abc"));
1862 }
1863
1864 #[test]
1865 fn has_wide_chars_emoji() {
1866 assert!(has_wide_chars("😀"));
1867 assert!(has_wide_chars("hello😀"));
1868 }
1869
1870 #[test]
1871 fn grapheme_count_empty() {
1872 assert_eq!(grapheme_count(""), 0);
1873 }
1874
1875 #[test]
1876 fn grapheme_count_regional_indicators() {
1877 assert_eq!(grapheme_count("🇺🇸"), 1);
1879 }
1880
1881 #[test]
1882 fn word_boundaries_no_spaces() {
1883 let breaks: Vec<usize> = word_boundaries("helloworld").collect();
1884 assert!(breaks.is_empty());
1885 }
1886
1887 #[test]
1888 fn word_boundaries_only_spaces() {
1889 let breaks: Vec<usize> = word_boundaries(" ").collect();
1890 assert!(!breaks.is_empty());
1891 }
1892
1893 #[test]
1894 fn word_segments_empty() {
1895 let segs: Vec<&str> = word_segments("").collect();
1896 assert!(segs.is_empty());
1897 }
1898
1899 #[test]
1900 fn word_segments_single_word() {
1901 let segs: Vec<&str> = word_segments("hello").collect();
1902 assert_eq!(segs.len(), 1);
1903 assert_eq!(segs[0], "hello");
1904 }
1905
1906 #[test]
1907 fn truncate_to_width_empty() {
1908 let result = truncate_to_width("", 10);
1909 assert_eq!(result, "");
1910 }
1911
1912 #[test]
1913 fn truncate_to_width_zero_width() {
1914 let result = truncate_to_width("hello", 0);
1915 assert_eq!(result, "");
1916 }
1917
1918 #[test]
1919 fn truncate_with_ellipsis_exact_fit() {
1920 let result = truncate_with_ellipsis("hello", 5, "...");
1922 assert_eq!(result, "hello");
1923 }
1924
1925 #[test]
1926 fn truncate_with_ellipsis_empty_ellipsis() {
1927 let result = truncate_with_ellipsis("hello world", 5, "");
1928 assert_eq!(result, "hello");
1929 }
1930
1931 #[test]
1932 fn truncate_to_width_with_info_empty() {
1933 let (text, width) = truncate_to_width_with_info("", 10);
1934 assert_eq!(text, "");
1935 assert_eq!(width, 0);
1936 }
1937
1938 #[test]
1939 fn truncate_to_width_with_info_zero_width() {
1940 let (text, width) = truncate_to_width_with_info("hello", 0);
1941 assert_eq!(text, "");
1942 assert_eq!(width, 0);
1943 }
1944
1945 #[test]
1946 fn truncate_to_width_wide_char_boundary() {
1947 let (text, width) = truncate_to_width_with_info("a你好", 2);
1949 assert_eq!(text, "a");
1951 assert_eq!(width, 1);
1952 }
1953
1954 #[test]
1955 fn wrap_mode_none() {
1956 let lines = wrap_text("hello world", 5, WrapMode::None);
1957 assert_eq!(lines, vec!["hello world"]);
1958 }
1959
1960 #[test]
1961 fn wrap_long_word_no_char_fallback() {
1962 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1964 for line in &lines {
1966 assert!(line.width() <= 10);
1967 }
1968 }
1969
1970 #[test]
1975 fn unit_badness_monotone() {
1976 let width = 80;
1978 let mut prev = knuth_plass_badness(0, width, false);
1979 for slack in 1..=80i64 {
1980 let bad = knuth_plass_badness(slack, width, false);
1981 assert!(
1982 bad >= prev,
1983 "badness must be monotonically non-decreasing: \
1984 badness({slack}) = {bad} < badness({}) = {prev}",
1985 slack - 1
1986 );
1987 prev = bad;
1988 }
1989 }
1990
1991 #[test]
1992 fn unit_badness_zero_slack() {
1993 assert_eq!(knuth_plass_badness(0, 80, false), 0);
1995 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1996 }
1997
1998 #[test]
1999 fn unit_badness_overflow_is_inf() {
2000 assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
2002 assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
2003 }
2004
2005 #[test]
2006 fn unit_badness_last_line_always_zero() {
2007 assert_eq!(knuth_plass_badness(0, 80, true), 0);
2009 assert_eq!(knuth_plass_badness(40, 80, true), 0);
2010 assert_eq!(knuth_plass_badness(79, 80, true), 0);
2011 }
2012
2013 #[test]
2014 fn unit_badness_cubic_growth() {
2015 let width = 100;
2016 let b10 = knuth_plass_badness(10, width, false);
2017 let b20 = knuth_plass_badness(20, width, false);
2018 let b40 = knuth_plass_badness(40, width, false);
2019
2020 assert!(
2023 b20 >= b10 * 6,
2024 "doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
2025 b20.checked_div(b10).unwrap_or(0)
2026 );
2027 assert!(
2028 b40 >= b20 * 6,
2029 "doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
2030 b40.checked_div(b20).unwrap_or(0)
2031 );
2032 }
2033
2034 #[test]
2035 fn unit_penalty_applied() {
2036 let result = wrap_optimal("superlongwordthatcannotfit", 10);
2038 assert!(
2040 result.total_cost >= PENALTY_FORCE_BREAK,
2041 "force-break penalty should be applied: cost={}",
2042 result.total_cost
2043 );
2044 }
2045
2046 #[test]
2047 fn kp_simple_wrap() {
2048 let result = wrap_optimal("Hello world foo bar", 10);
2049 for line in &result.lines {
2051 assert!(
2052 line.width() <= 10,
2053 "line '{line}' exceeds width 10 (width={})",
2054 line.width()
2055 );
2056 }
2057 assert!(result.lines.len() >= 2);
2059 }
2060
2061 #[test]
2062 fn kp_perfect_fit() {
2063 let result = wrap_optimal("aaaa bbbb", 9);
2065 assert_eq!(result.lines.len(), 1);
2067 assert_eq!(result.total_cost, 0);
2068 }
2069
2070 #[test]
2071 fn kp_optimal_vs_greedy() {
2072 let result = wrap_optimal("aaa bb cc ddddd", 6);
2077
2078 for line in &result.lines {
2080 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2081 }
2082
2083 assert!(result.lines.len() >= 2);
2087 }
2088
2089 #[test]
2090 fn kp_empty_text() {
2091 let result = wrap_optimal("", 80);
2092 assert_eq!(result.lines, vec![""]);
2093 assert_eq!(result.total_cost, 0);
2094 }
2095
2096 #[test]
2097 fn kp_single_word() {
2098 let result = wrap_optimal("hello", 80);
2099 assert_eq!(result.lines, vec!["hello"]);
2100 assert_eq!(result.total_cost, 0); }
2102
2103 #[test]
2104 fn kp_multiline_preserves_newlines() {
2105 let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
2106 assert!(lines.len() >= 2);
2108 assert!(lines[0].width() <= 10);
2110 }
2111
2112 #[test]
2113 fn kp_tokenize_basic() {
2114 let words = kp_tokenize("hello world foo");
2115 assert_eq!(words.len(), 3);
2116 assert_eq!(words[0].content_width, 5);
2117 assert_eq!(words[0].space_width, 1);
2118 assert_eq!(words[1].content_width, 5);
2119 assert_eq!(words[1].space_width, 1);
2120 assert_eq!(words[2].content_width, 3);
2121 assert_eq!(words[2].space_width, 0);
2122 }
2123
2124 #[test]
2125 fn kp_diagnostics_line_badness() {
2126 let result = wrap_optimal("short text here for testing the dp", 15);
2127 assert_eq!(result.line_badness.len(), result.lines.len());
2129 assert_eq!(
2131 *result.line_badness.last().unwrap(),
2132 0,
2133 "last line should have zero badness"
2134 );
2135 }
2136
2137 #[test]
2138 fn kp_deterministic() {
2139 let text = "The quick brown fox jumps over the lazy dog near a riverbank";
2140 let r1 = wrap_optimal(text, 20);
2141 let r2 = wrap_optimal(text, 20);
2142 assert_eq!(r1.lines, r2.lines);
2143 assert_eq!(r1.total_cost, r2.total_cost);
2144 }
2145
2146 #[test]
2151 fn unit_dp_matches_known() {
2152 let result = wrap_optimal("aaa bb cc ddddd", 6);
2157
2158 for line in &result.lines {
2160 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2161 }
2162
2163 assert_eq!(
2165 result.lines.len(),
2166 3,
2167 "expected 3 lines, got {:?}",
2168 result.lines
2169 );
2170 assert_eq!(result.lines[0], "aaa");
2171 assert_eq!(result.lines[1], "bb cc");
2172 assert_eq!(result.lines[2], "ddddd");
2173
2174 assert_eq!(*result.line_badness.last().unwrap(), 0);
2176 }
2177
2178 #[test]
2179 fn unit_dp_known_two_line() {
2180 let r1 = wrap_optimal("hello world", 11);
2182 assert_eq!(r1.lines, vec!["hello world"]);
2183 assert_eq!(r1.total_cost, 0);
2184
2185 let r2 = wrap_optimal("hello world", 7);
2187 assert_eq!(r2.lines.len(), 2);
2188 assert_eq!(r2.lines[0], "hello");
2189 assert_eq!(r2.lines[1], "world");
2190 assert!(
2193 r2.total_cost > 0 && r2.total_cost < 300,
2194 "expected cost ~233, got {}",
2195 r2.total_cost
2196 );
2197 }
2198
2199 #[test]
2200 fn unit_dp_optimal_beats_greedy() {
2201 let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
2222 let optimal = wrap_optimal("the quick brown fox", 10);
2223
2224 for line in &greedy {
2226 assert!(line.width() <= 10);
2227 }
2228 for line in &optimal.lines {
2229 assert!(line.width() <= 10);
2230 }
2231
2232 let mut greedy_cost: u64 = 0;
2235 for (i, line) in greedy.iter().enumerate() {
2236 let slack = 10i64 - line.width() as i64;
2237 let is_last = i == greedy.len() - 1;
2238 greedy_cost += knuth_plass_badness(slack, 10, is_last);
2239 }
2240 assert!(
2241 optimal.total_cost <= greedy_cost,
2242 "optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
2243 optimal.total_cost,
2244 greedy_cost
2245 );
2246 }
2247
2248 #[test]
2249 fn perf_wrap_large() {
2250 use std::time::Instant;
2251
2252 let words: Vec<&str> = [
2254 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2255 "back", "to", "its", "den", "in",
2256 ]
2257 .to_vec();
2258
2259 let mut paragraph = String::new();
2260 for i in 0..1000 {
2261 if i > 0 {
2262 paragraph.push(' ');
2263 }
2264 paragraph.push_str(words[i % words.len()]);
2265 }
2266
2267 let iterations = 20;
2268 let start = Instant::now();
2269 for _ in 0..iterations {
2270 let result = wrap_optimal(¶graph, 80);
2271 assert!(!result.lines.is_empty());
2272 }
2273 let elapsed = start.elapsed();
2274
2275 eprintln!(
2276 "{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
2277 iterations,
2278 elapsed.as_millis(),
2279 elapsed.as_micros() / iterations as u128
2280 );
2281
2282 assert!(
2284 elapsed.as_secs() < 2,
2285 "Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
2286 );
2287 }
2288
2289 #[test]
2290 fn kp_pruning_lookahead_bound() {
2291 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";
2293 let result = wrap_optimal(text, 10);
2294 for line in &result.lines {
2295 assert!(line.width() <= 10, "line '{line}' exceeds width");
2296 }
2297 let joined: String = result.lines.join(" ");
2299 for ch in 'a'..='z' {
2300 assert!(joined.contains(ch), "missing letter '{ch}' in output");
2301 }
2302 }
2303
2304 #[test]
2305 fn kp_very_narrow_width() {
2306 let result = wrap_optimal("ab cd ef", 2);
2308 assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
2309 }
2310
2311 #[test]
2312 fn kp_wide_width_single_line() {
2313 let result = wrap_optimal("hello world", 1000);
2315 assert_eq!(result.lines, vec!["hello world"]);
2316 assert_eq!(result.total_cost, 0);
2317 }
2318
2319 fn fnv1a_lines(lines: &[String]) -> u64 {
2325 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2326 for (i, line) in lines.iter().enumerate() {
2327 for byte in (i as u32)
2328 .to_le_bytes()
2329 .iter()
2330 .chain(line.as_bytes().iter())
2331 {
2332 hash ^= *byte as u64;
2333 hash = hash.wrapping_mul(0x0100_0000_01b3);
2334 }
2335 }
2336 hash
2337 }
2338
2339 #[test]
2340 fn snapshot_wrap_quality() {
2341 let paragraphs = [
2343 "The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
2344 "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",
2345 "aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
2346 ];
2347
2348 let widths = [20, 40, 60, 80];
2349
2350 for paragraph in ¶graphs {
2351 for &width in &widths {
2352 let result = wrap_optimal(paragraph, width);
2353
2354 let result2 = wrap_optimal(paragraph, width);
2356 assert_eq!(
2357 fnv1a_lines(&result.lines),
2358 fnv1a_lines(&result2.lines),
2359 "non-deterministic wrap at width {width}"
2360 );
2361
2362 for line in &result.lines {
2364 assert!(line.width() <= width, "line '{line}' exceeds width {width}");
2365 }
2366
2367 if !paragraph.is_empty() {
2369 for line in &result.lines {
2370 assert!(!line.is_empty(), "empty line in output at width {width}");
2371 }
2372 }
2373
2374 let original_words: Vec<&str> = paragraph.split_whitespace().collect();
2376 let result_words: Vec<&str> = result
2377 .lines
2378 .iter()
2379 .flat_map(|l| l.split_whitespace())
2380 .collect();
2381 assert_eq!(
2382 original_words, result_words,
2383 "content lost at width {width}"
2384 );
2385
2386 assert_eq!(
2388 *result.line_badness.last().unwrap(),
2389 0,
2390 "last line should have zero badness at width {width}"
2391 );
2392 }
2393 }
2394 }
2395
2396 #[test]
2401 fn perf_wrap_bench() {
2402 use std::time::Instant;
2403
2404 let sample_words = [
2405 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2406 "back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
2407 "near",
2408 ];
2409
2410 let scenarios: &[(usize, usize, &str)] = &[
2411 (50, 40, "short_40"),
2412 (50, 80, "short_80"),
2413 (200, 40, "medium_40"),
2414 (200, 80, "medium_80"),
2415 (500, 40, "long_40"),
2416 (500, 80, "long_80"),
2417 ];
2418
2419 for &(word_count, width, label) in scenarios {
2420 let mut paragraph = String::new();
2422 for i in 0..word_count {
2423 if i > 0 {
2424 paragraph.push(' ');
2425 }
2426 paragraph.push_str(sample_words[i % sample_words.len()]);
2427 }
2428
2429 let iterations = 30u32;
2430 let mut times_us = Vec::with_capacity(iterations as usize);
2431 let mut last_lines = 0usize;
2432 let mut last_cost = 0u64;
2433 let mut last_checksum = 0u64;
2434
2435 for _ in 0..iterations {
2436 let start = Instant::now();
2437 let result = wrap_optimal(¶graph, width);
2438 let elapsed = start.elapsed();
2439
2440 last_lines = result.lines.len();
2441 last_cost = result.total_cost;
2442 last_checksum = fnv1a_lines(&result.lines);
2443 times_us.push(elapsed.as_micros() as u64);
2444 }
2445
2446 times_us.sort();
2447 let len = times_us.len();
2448 let p50 = times_us[len / 2];
2449 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2450
2451 eprintln!(
2453 "{{\"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}\"}}"
2454 );
2455
2456 let verify = wrap_optimal(¶graph, width);
2458 assert_eq!(
2459 fnv1a_lines(&verify.lines),
2460 last_checksum,
2461 "non-deterministic: {label}"
2462 );
2463
2464 if word_count >= 500 && p95 > 5000 {
2466 eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
2467 }
2468 }
2469 }
2470}
2471
2472#[cfg(test)]
2473mod proptests {
2474 use super::TestWidth;
2475 use super::*;
2476 use proptest::prelude::*;
2477
2478 proptest! {
2479 #[test]
2480 fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
2481 let lines = wrap_text(&s, width, WrapMode::Char);
2482 for line in &lines {
2483 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2484 }
2485 }
2486
2487 #[test]
2488 fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
2489 let lines = wrap_text(&s, width, WrapMode::Char);
2490 let rejoined: String = lines.join("");
2491 prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
2493 }
2494
2495 #[test]
2496 fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
2497 let result = truncate_with_ellipsis(&s, width, "...");
2498 prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
2499 }
2500
2501 #[test]
2502 fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
2503 let result = truncate_to_width(&s, width);
2504 prop_assert!(result.width() <= width);
2505 if s.width() > width {
2507 prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
2509 }
2510 }
2511
2512 #[test]
2513 fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
2514 let lines = wrap_text(&s, width, WrapMode::WordChar);
2515 for line in &lines {
2516 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2517 }
2518 }
2519
2520 #[test]
2526 fn property_dp_vs_greedy(
2527 text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
2528 width in 8usize..40,
2529 ) {
2530 let greedy = wrap_text(&text, width, WrapMode::Word);
2531 let optimal = wrap_optimal(&text, width);
2532
2533 let mut greedy_cost: u64 = 0;
2535 for (i, line) in greedy.iter().enumerate() {
2536 let lw = line.width();
2537 let slack = width as i64 - lw as i64;
2538 let is_last = i == greedy.len() - 1;
2539 if slack >= 0 {
2540 greedy_cost = greedy_cost.saturating_add(
2541 knuth_plass_badness(slack, width, is_last)
2542 );
2543 } else {
2544 greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
2545 }
2546 }
2547
2548 prop_assert!(
2549 optimal.total_cost <= greedy_cost,
2550 "DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
2551 optimal.total_cost, greedy_cost, width, optimal.lines, greedy
2552 );
2553 }
2554
2555 #[test]
2557 fn property_dp_respects_width(
2558 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
2559 width in 6usize..30,
2560 ) {
2561 let result = wrap_optimal(&text, width);
2562 for line in &result.lines {
2563 prop_assert!(
2564 line.width() <= width,
2565 "DP line '{}' (width {}) exceeds target {}",
2566 line, line.width(), width
2567 );
2568 }
2569 }
2570
2571 #[test]
2573 fn property_dp_preserves_content(
2574 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
2575 width in 8usize..30,
2576 ) {
2577 let result = wrap_optimal(&text, width);
2578 let original_words: Vec<&str> = text.split_whitespace().collect();
2579 let result_words: Vec<&str> = result.lines.iter()
2580 .flat_map(|l| l.split_whitespace())
2581 .collect();
2582 prop_assert_eq!(
2583 original_words, result_words,
2584 "DP should preserve all words"
2585 );
2586 }
2587 }
2588
2589 #[test]
2594 fn fitness_class_from_ratio() {
2595 assert_eq!(FitnessClass::from_ratio(-0.8), FitnessClass::Tight);
2596 assert_eq!(FitnessClass::from_ratio(-0.5), FitnessClass::Normal);
2597 assert_eq!(FitnessClass::from_ratio(0.0), FitnessClass::Normal);
2598 assert_eq!(FitnessClass::from_ratio(0.49), FitnessClass::Normal);
2599 assert_eq!(FitnessClass::from_ratio(0.5), FitnessClass::Loose);
2600 assert_eq!(FitnessClass::from_ratio(0.99), FitnessClass::Loose);
2601 assert_eq!(FitnessClass::from_ratio(1.0), FitnessClass::VeryLoose);
2602 assert_eq!(FitnessClass::from_ratio(2.0), FitnessClass::VeryLoose);
2603 }
2604
2605 #[test]
2606 fn fitness_class_incompatible() {
2607 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Tight));
2608 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Normal));
2609 assert!(FitnessClass::Tight.incompatible(FitnessClass::Loose));
2610 assert!(FitnessClass::Tight.incompatible(FitnessClass::VeryLoose));
2611 assert!(!FitnessClass::Normal.incompatible(FitnessClass::Loose));
2612 assert!(FitnessClass::Normal.incompatible(FitnessClass::VeryLoose));
2613 }
2614
2615 #[test]
2616 fn objective_default_is_tex_standard() {
2617 let obj = ParagraphObjective::default();
2618 assert_eq!(obj.line_penalty, 10);
2619 assert_eq!(obj.fitness_demerit, 100);
2620 assert_eq!(obj.double_hyphen_demerit, 100);
2621 assert_eq!(obj.badness_scale, BADNESS_SCALE);
2622 }
2623
2624 #[test]
2625 fn objective_terminal_preset() {
2626 let obj = ParagraphObjective::terminal();
2627 assert_eq!(obj.line_penalty, 20);
2628 assert_eq!(obj.min_adjustment_ratio, 0.0);
2629 assert!(obj.max_adjustment_ratio > 2.0);
2630 }
2631
2632 #[test]
2633 fn badness_zero_slack_is_zero() {
2634 let obj = ParagraphObjective::default();
2635 assert_eq!(obj.badness(0, 80), Some(0));
2636 }
2637
2638 #[test]
2639 fn badness_moderate_slack() {
2640 let obj = ParagraphObjective::default();
2641 let b = obj.badness(10, 80).unwrap();
2644 assert!(b > 0 && b < 100, "badness = {b}");
2645 }
2646
2647 #[test]
2648 fn badness_excessive_slack_infeasible() {
2649 let obj = ParagraphObjective::default();
2650 assert!(obj.badness(240, 80).is_none());
2652 }
2653
2654 #[test]
2655 fn badness_negative_slack_within_bounds() {
2656 let obj = ParagraphObjective::default();
2657 let b = obj.badness(-40, 80);
2659 assert!(b.is_some());
2660 }
2661
2662 #[test]
2663 fn badness_negative_slack_beyond_bounds() {
2664 let obj = ParagraphObjective::default();
2665 assert!(obj.badness(-100, 80).is_none());
2667 }
2668
2669 #[test]
2670 fn badness_terminal_no_compression() {
2671 let obj = ParagraphObjective::terminal();
2672 assert!(obj.badness(-1, 80).is_none());
2674 }
2675
2676 #[test]
2677 fn demerits_space_break() {
2678 let obj = ParagraphObjective::default();
2679 let d = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2680 let badness = obj.badness(10, 80).unwrap();
2682 let expected = (obj.line_penalty + badness).pow(2);
2683 assert_eq!(d, expected);
2684 }
2685
2686 #[test]
2687 fn demerits_hyphen_break() {
2688 let obj = ParagraphObjective::default();
2689 let d_space = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2690 let d_hyphen = obj.demerits(10, 80, &BreakPenalty::HYPHEN).unwrap();
2691 assert!(d_hyphen > d_space);
2693 }
2694
2695 #[test]
2696 fn demerits_forced_break() {
2697 let obj = ParagraphObjective::default();
2698 let d = obj.demerits(0, 80, &BreakPenalty::FORCED).unwrap();
2699 assert_eq!(d, obj.line_penalty.pow(2));
2701 }
2702
2703 #[test]
2704 fn demerits_infeasible_returns_none() {
2705 let obj = ParagraphObjective::default();
2706 assert!(obj.demerits(300, 80, &BreakPenalty::SPACE).is_none());
2708 }
2709
2710 #[test]
2711 fn adjacency_fitness_incompatible() {
2712 let obj = ParagraphObjective::default();
2713 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::Loose, false, false);
2714 assert_eq!(d, obj.fitness_demerit);
2715 }
2716
2717 #[test]
2718 fn adjacency_fitness_compatible() {
2719 let obj = ParagraphObjective::default();
2720 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Loose, false, false);
2721 assert_eq!(d, 0);
2722 }
2723
2724 #[test]
2725 fn adjacency_double_hyphen() {
2726 let obj = ParagraphObjective::default();
2727 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Normal, true, true);
2728 assert_eq!(d, obj.double_hyphen_demerit);
2729 }
2730
2731 #[test]
2732 fn adjacency_double_hyphen_plus_fitness() {
2733 let obj = ParagraphObjective::default();
2734 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::VeryLoose, true, true);
2735 assert_eq!(d, obj.fitness_demerit + obj.double_hyphen_demerit);
2736 }
2737
2738 #[test]
2739 fn widow_penalty_short_last_line() {
2740 let obj = ParagraphObjective::default();
2741 assert_eq!(obj.widow_demerits(5), obj.widow_demerit);
2742 assert_eq!(obj.widow_demerits(14), obj.widow_demerit);
2743 assert_eq!(obj.widow_demerits(15), 0);
2744 assert_eq!(obj.widow_demerits(80), 0);
2745 }
2746
2747 #[test]
2748 fn orphan_penalty_short_first_line() {
2749 let obj = ParagraphObjective::default();
2750 assert_eq!(obj.orphan_demerits(10), obj.orphan_demerit);
2751 assert_eq!(obj.orphan_demerits(19), obj.orphan_demerit);
2752 assert_eq!(obj.orphan_demerits(20), 0);
2753 assert_eq!(obj.orphan_demerits(80), 0);
2754 }
2755
2756 #[test]
2757 fn adjustment_ratio_computation() {
2758 let obj = ParagraphObjective::default();
2759 let r = obj.adjustment_ratio(10, 80);
2760 assert!((r - 0.125).abs() < 1e-10);
2761 }
2762
2763 #[test]
2764 fn adjustment_ratio_zero_width() {
2765 let obj = ParagraphObjective::default();
2766 assert_eq!(obj.adjustment_ratio(5, 0), 0.0);
2767 }
2768
2769 #[test]
2770 fn badness_zero_width_zero_slack() {
2771 let obj = ParagraphObjective::default();
2772 assert_eq!(obj.badness(0, 0), Some(0));
2773 }
2774
2775 #[test]
2776 fn badness_zero_width_nonzero_slack() {
2777 let obj = ParagraphObjective::default();
2778 assert!(obj.badness(5, 0).is_none());
2779 }
2780}