1#![forbid(unsafe_code)]
2
3use unicode_segmentation::UnicodeSegmentation;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum WrapMode {
28 None,
30 #[default]
32 Word,
33 Char,
35 WordChar,
37}
38
39#[derive(Debug, Clone)]
41pub struct WrapOptions {
42 pub width: usize,
44 pub mode: WrapMode,
46 pub preserve_indent: bool,
48 pub trim_trailing: bool,
50}
51
52impl WrapOptions {
53 #[must_use]
55 pub fn new(width: usize) -> Self {
56 Self {
57 width,
58 mode: WrapMode::Word,
59 preserve_indent: false,
60 trim_trailing: true,
61 }
62 }
63
64 #[must_use]
66 pub fn mode(mut self, mode: WrapMode) -> Self {
67 self.mode = mode;
68 self
69 }
70
71 #[must_use]
73 pub fn preserve_indent(mut self, preserve: bool) -> Self {
74 self.preserve_indent = preserve;
75 self
76 }
77
78 #[must_use]
80 pub fn trim_trailing(mut self, trim: bool) -> Self {
81 self.trim_trailing = trim;
82 self
83 }
84}
85
86impl Default for WrapOptions {
87 fn default() -> Self {
88 Self::new(80)
89 }
90}
91
92#[must_use]
96pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
97 let preserve = mode == WrapMode::Char;
99 wrap_with_options(
100 text,
101 &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
102 )
103}
104
105#[must_use]
107pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
108 if options.width == 0 {
109 return vec![text.to_string()];
110 }
111
112 match options.mode {
113 WrapMode::None => vec![text.to_string()],
114 WrapMode::Char => wrap_chars(text, options),
115 WrapMode::Word => wrap_words(text, options, false),
116 WrapMode::WordChar => wrap_words(text, options, true),
117 }
118}
119
120fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
122 let mut lines = Vec::new();
123 let mut current_line = String::new();
124 let mut current_width = 0;
125
126 for grapheme in text.graphemes(true) {
127 if grapheme == "\n" || grapheme == "\r\n" {
129 lines.push(finalize_line(¤t_line, options));
130 current_line.clear();
131 current_width = 0;
132 continue;
133 }
134
135 let grapheme_width = crate::wrap::grapheme_width(grapheme);
136
137 if current_width + grapheme_width > options.width && !current_line.is_empty() {
139 lines.push(finalize_line(¤t_line, options));
140 current_line.clear();
141 current_width = 0;
142 }
143
144 current_line.push_str(grapheme);
146 current_width += grapheme_width;
147 }
148
149 lines.push(finalize_line(¤t_line, options));
152
153 lines
154}
155
156fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
158 let mut lines = Vec::new();
159
160 for raw_paragraph in text.split('\n') {
162 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
163 let mut current_line = String::new();
164 let mut current_width = 0;
165
166 let len_before = lines.len();
167
168 wrap_paragraph(
169 paragraph,
170 options,
171 char_fallback,
172 &mut lines,
173 &mut current_line,
174 &mut current_width,
175 );
176
177 if !current_line.is_empty() || lines.len() == len_before {
180 lines.push(finalize_line(¤t_line, options));
181 }
182 }
183
184 lines
185}
186
187fn wrap_paragraph(
189 text: &str,
190 options: &WrapOptions,
191 char_fallback: bool,
192 lines: &mut Vec<String>,
193 current_line: &mut String,
194 current_width: &mut usize,
195) {
196 for word in split_words(text) {
197 let word_width = display_width(&word);
198
199 if *current_width + word_width <= options.width {
201 current_line.push_str(&word);
202 *current_width += word_width;
203 continue;
204 }
205
206 if !current_line.is_empty() {
208 lines.push(finalize_line(current_line, options));
209 current_line.clear();
210 *current_width = 0;
211 }
212
213 if word_width > options.width {
215 if char_fallback {
216 wrap_long_word(&word, options, lines, current_line, current_width);
218 } else {
219 lines.push(finalize_line(&word, options));
221 }
222 } else {
223 let (fragment, fragment_width) = if options.preserve_indent {
225 (word.as_str(), word_width)
226 } else {
227 let trimmed = word.trim_start();
228 (trimmed, display_width(trimmed))
229 };
230 if !fragment.is_empty() {
231 current_line.push_str(fragment);
232 }
233 *current_width = fragment_width;
234 }
235 }
236}
237
238fn wrap_long_word(
240 word: &str,
241 options: &WrapOptions,
242 lines: &mut Vec<String>,
243 current_line: &mut String,
244 current_width: &mut usize,
245) {
246 for grapheme in word.graphemes(true) {
247 let grapheme_width = crate::wrap::grapheme_width(grapheme);
248
249 if *current_width == 0 && grapheme.trim().is_empty() && !options.preserve_indent {
251 continue;
252 }
253
254 if *current_width + grapheme_width > options.width && !current_line.is_empty() {
255 lines.push(finalize_line(current_line, options));
256 current_line.clear();
257 *current_width = 0;
258
259 if grapheme.trim().is_empty() && !options.preserve_indent {
261 continue;
262 }
263 }
264
265 current_line.push_str(grapheme);
266 *current_width += grapheme_width;
267 }
268}
269
270fn split_words(text: &str) -> Vec<String> {
275 let mut words = Vec::new();
276 let mut current = String::new();
277 let mut in_whitespace = false;
278
279 for grapheme in text.graphemes(true) {
280 let is_ws = grapheme.chars().all(|c| c.is_whitespace());
281
282 if is_ws != in_whitespace && !current.is_empty() {
283 words.push(std::mem::take(&mut current));
284 }
285
286 current.push_str(grapheme);
287 in_whitespace = is_ws;
288 }
289
290 if !current.is_empty() {
291 words.push(current);
292 }
293
294 words
295}
296
297fn finalize_line(line: &str, options: &WrapOptions) -> String {
299 let mut result = if options.trim_trailing {
300 line.trim_end().to_string()
301 } else {
302 line.to_string()
303 };
304
305 if !options.preserve_indent {
306 let trimmed = result.trim_start();
326 if trimmed.len() != result.len() {
327 result = trimmed.to_string();
328 }
329 }
330
331 result
332}
333
334#[must_use]
339pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
340 let text_width = display_width(text);
341
342 if text_width <= max_width {
343 return text.to_string();
344 }
345
346 let ellipsis_width = display_width(ellipsis);
347
348 if ellipsis_width >= max_width {
350 return truncate_to_width(text, max_width);
351 }
352
353 let target_width = max_width - ellipsis_width;
354 let mut result = truncate_to_width(text, target_width);
355 result.push_str(ellipsis);
356 result
357}
358
359#[must_use]
363pub fn truncate_to_width(text: &str, max_width: usize) -> String {
364 let mut result = String::new();
365 let mut current_width = 0;
366
367 for grapheme in text.graphemes(true) {
368 let grapheme_width = crate::wrap::grapheme_width(grapheme);
369
370 if current_width + grapheme_width > max_width {
371 break;
372 }
373
374 result.push_str(grapheme);
375 current_width += grapheme_width;
376 }
377
378 result
379}
380
381#[inline]
400#[must_use]
401pub fn ascii_width(text: &str) -> Option<usize> {
402 ftui_core::text_width::ascii_width(text)
403}
404
405#[inline]
413#[must_use]
414pub fn grapheme_width(grapheme: &str) -> usize {
415 ftui_core::text_width::grapheme_width(grapheme)
416}
417
418#[inline]
429#[must_use]
430pub fn display_width(text: &str) -> usize {
431 ftui_core::text_width::display_width(text)
432}
433
434#[must_use]
436pub fn has_wide_chars(text: &str) -> bool {
437 text.graphemes(true)
438 .any(|g| crate::wrap::grapheme_width(g) > 1)
439}
440
441#[must_use]
443pub fn is_ascii_only(text: &str) -> bool {
444 text.is_ascii()
445}
446
447#[inline]
465#[must_use]
466pub fn grapheme_count(text: &str) -> usize {
467 text.graphemes(true).count()
468}
469
470#[inline]
483pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
484 text.graphemes(true)
485}
486
487#[must_use]
510pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
511 let mut byte_end = 0;
512 let mut current_width = 0;
513
514 for grapheme in text.graphemes(true) {
515 let grapheme_width = crate::wrap::grapheme_width(grapheme);
516
517 if current_width + grapheme_width > max_width {
518 break;
519 }
520
521 current_width += grapheme_width;
522 byte_end += grapheme.len();
523 }
524
525 (&text[..byte_end], current_width)
526}
527
528pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
543 text.split_word_bound_indices().filter_map(|(idx, word)| {
544 if word.chars().all(|c| c.is_whitespace()) {
546 Some(idx + word.len())
547 } else {
548 None
549 }
550 })
551}
552
553pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
566 text.split_word_bounds()
567}
568
569const BADNESS_SCALE: u64 = 10_000;
609
610const BADNESS_INF: u64 = u64::MAX / 2;
612
613const PENALTY_FORCE_BREAK: u64 = 5000;
615
616const KP_MAX_LOOKAHEAD: usize = 64;
620
621#[inline]
630fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
631 if slack < 0 {
632 return BADNESS_INF;
633 }
634 if is_last_line {
635 return 0;
636 }
637 if width == 0 {
638 return if slack == 0 { 0 } else { BADNESS_INF };
639 }
640 let s = slack as u64;
644 let w = width as u64;
645 let s3 = s.saturating_mul(s).saturating_mul(s);
647 let w3 = w.saturating_mul(w).saturating_mul(w);
648 if w3 == 0 {
649 return BADNESS_INF;
650 }
651 s3.saturating_mul(BADNESS_SCALE) / w3
652}
653
654#[derive(Debug, Clone)]
656struct KpWord {
657 text: String,
659 content_width: usize,
661 space_width: usize,
663}
664
665fn kp_tokenize(text: &str) -> Vec<KpWord> {
667 let mut words = Vec::new();
668 let raw_segments: Vec<&str> = text.split_word_bounds().collect();
669
670 let mut i = 0;
671 while i < raw_segments.len() {
672 let seg = raw_segments[i];
673 if seg.chars().all(|c| c.is_whitespace()) {
674 if let Some(last) = words.last_mut() {
676 let w: &mut KpWord = last;
677 w.text.push_str(seg);
678 w.space_width += display_width(seg);
679 } else {
680 words.push(KpWord {
682 text: seg.to_string(),
683 content_width: 0,
684 space_width: display_width(seg),
685 });
686 }
687 i += 1;
688 } else {
689 let content_width = display_width(seg);
690 words.push(KpWord {
691 text: seg.to_string(),
692 content_width,
693 space_width: 0,
694 });
695 i += 1;
696 }
697 }
698
699 words
700}
701
702#[derive(Debug, Clone)]
704pub struct KpBreakResult {
705 pub lines: Vec<String>,
707 pub total_cost: u64,
709 pub line_badness: Vec<u64>,
711}
712
713pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
728 if width == 0 || text.is_empty() {
729 return KpBreakResult {
730 lines: vec![text.to_string()],
731 total_cost: 0,
732 line_badness: vec![0],
733 };
734 }
735
736 let words = kp_tokenize(text);
737 if words.is_empty() {
738 return KpBreakResult {
739 lines: vec![text.to_string()],
740 total_cost: 0,
741 line_badness: vec![0],
742 };
743 }
744
745 let n = words.len();
746
747 let mut cost = vec![BADNESS_INF; n + 1];
750 let mut from = vec![0usize; n + 1];
751 cost[0] = 0;
752
753 for j in 1..=n {
754 let mut line_width: usize = 0;
755 let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
758 for i in (earliest..j).rev() {
759 line_width += words[i].content_width;
761 if i < j - 1 {
762 line_width += words[i].space_width;
764 }
765
766 if line_width > width && i < j - 1 {
768 break;
770 }
771
772 let slack = width as i64 - line_width as i64;
773 let is_last = j == n;
774 let badness = if line_width > width {
775 PENALTY_FORCE_BREAK
777 } else {
778 knuth_plass_badness(slack, width, is_last)
779 };
780
781 let candidate = cost[i].saturating_add(badness);
782 if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
784 cost[j] = candidate;
785 from[j] = i;
786 }
787 }
788 }
789
790 let mut breaks = Vec::new();
792 let mut pos = n;
793 while pos > 0 {
794 breaks.push(from[pos]);
795 pos = from[pos];
796 }
797 breaks.reverse();
798
799 let mut lines = Vec::new();
801 let mut line_badness = Vec::new();
802 let break_count = breaks.len();
803
804 for (idx, &start) in breaks.iter().enumerate() {
805 let end = if idx + 1 < break_count {
806 breaks[idx + 1]
807 } else {
808 n
809 };
810
811 let mut line = String::new();
813 for word in words.iter().take(end).skip(start) {
814 line.push_str(&word.text);
815 }
816
817 let trimmed = line.trim_end().to_string();
819
820 let line_w = display_width(trimmed.as_str());
822 let slack = width as i64 - line_w as i64;
823 let is_last = idx == break_count - 1;
824 let bad = if slack < 0 {
825 PENALTY_FORCE_BREAK
826 } else {
827 knuth_plass_badness(slack, width, is_last)
828 };
829
830 lines.push(trimmed);
831 line_badness.push(bad);
832 }
833
834 KpBreakResult {
835 lines,
836 total_cost: cost[n],
837 line_badness,
838 }
839}
840
841#[must_use]
845pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
846 let mut result = Vec::new();
847 for raw_paragraph in text.split('\n') {
848 let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
849 if paragraph.is_empty() {
850 result.push(String::new());
851 continue;
852 }
853 let kp = wrap_optimal(paragraph, width);
854 result.extend(kp.lines);
855 }
856 result
857}
858
859#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
892#[repr(u8)]
893pub enum FitnessClass {
894 Tight = 0,
896 Normal = 1,
898 Loose = 2,
900 VeryLoose = 3,
902}
903
904impl FitnessClass {
905 #[must_use]
910 pub fn from_ratio(ratio: f64) -> Self {
911 if ratio < -0.5 {
912 FitnessClass::Tight
913 } else if ratio < 0.5 {
914 FitnessClass::Normal
915 } else if ratio < 1.0 {
916 FitnessClass::Loose
917 } else {
918 FitnessClass::VeryLoose
919 }
920 }
921
922 #[must_use]
925 pub const fn incompatible(self, other: Self) -> bool {
926 let a = self as i8;
927 let b = other as i8;
928 (a - b > 1) || (b - a > 1)
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935pub enum BreakKind {
936 Space,
938 Hyphen,
940 Forced,
942 Emergency,
944}
945
946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953pub struct BreakPenalty {
954 pub value: i64,
956 pub flagged: bool,
959}
960
961impl BreakPenalty {
962 pub const SPACE: Self = Self {
964 value: 0,
965 flagged: false,
966 };
967
968 pub const HYPHEN: Self = Self {
970 value: 50,
971 flagged: true,
972 };
973
974 pub const FORCED: Self = Self {
976 value: i64::MIN,
977 flagged: false,
978 };
979
980 pub const EMERGENCY: Self = Self {
982 value: 5000,
983 flagged: false,
984 };
985}
986
987#[derive(Debug, Clone, Copy, PartialEq)]
992pub struct ParagraphObjective {
993 pub line_penalty: u64,
997
998 pub fitness_demerit: u64,
1001
1002 pub double_hyphen_demerit: u64,
1005
1006 pub final_hyphen_demerit: u64,
1009
1010 pub max_adjustment_ratio: f64,
1014
1015 pub min_adjustment_ratio: f64,
1018
1019 pub widow_demerit: u64,
1023
1024 pub widow_threshold: usize,
1027
1028 pub orphan_demerit: u64,
1032
1033 pub orphan_threshold: usize,
1036
1037 pub badness_scale: u64,
1040}
1041
1042impl Default for ParagraphObjective {
1043 fn default() -> Self {
1044 Self {
1045 line_penalty: 10,
1046 fitness_demerit: 100,
1047 double_hyphen_demerit: 100,
1048 final_hyphen_demerit: 100,
1049 max_adjustment_ratio: 2.0,
1050 min_adjustment_ratio: -1.0,
1051 widow_demerit: 150,
1052 widow_threshold: 15,
1053 orphan_demerit: 150,
1054 orphan_threshold: 20,
1055 badness_scale: BADNESS_SCALE,
1056 }
1057 }
1058}
1059
1060impl ParagraphObjective {
1061 #[must_use]
1064 pub fn terminal() -> Self {
1065 Self {
1066 line_penalty: 20,
1068 fitness_demerit: 50,
1070 min_adjustment_ratio: 0.0,
1072 max_adjustment_ratio: 3.0,
1074 widow_demerit: 50,
1076 orphan_demerit: 50,
1077 ..Self::default()
1078 }
1079 }
1080
1081 #[must_use]
1083 pub fn typographic() -> Self {
1084 Self::default()
1085 }
1086
1087 #[must_use]
1092 pub fn badness(&self, slack: i64, width: usize) -> Option<u64> {
1093 if width == 0 {
1094 return if slack == 0 { Some(0) } else { None };
1095 }
1096
1097 let ratio = slack as f64 / width as f64;
1098
1099 if ratio < self.min_adjustment_ratio || ratio > self.max_adjustment_ratio {
1101 return None; }
1103
1104 let abs_ratio = ratio.abs();
1105 let badness = (abs_ratio * abs_ratio * abs_ratio * self.badness_scale as f64) as u64;
1106 Some(badness)
1107 }
1108
1109 #[must_use]
1111 pub fn adjustment_ratio(&self, slack: i64, width: usize) -> f64 {
1112 if width == 0 {
1113 return 0.0;
1114 }
1115 slack as f64 / width as f64
1116 }
1117
1118 #[must_use]
1128 pub fn demerits(&self, slack: i64, width: usize, penalty: &BreakPenalty) -> Option<u64> {
1129 let badness = self.badness(slack, width)?;
1130
1131 let base = self.line_penalty.saturating_add(badness);
1132 let base_sq = base.saturating_mul(base);
1133
1134 let pen_sq = (penalty.value.unsigned_abs()).saturating_mul(penalty.value.unsigned_abs());
1135
1136 if penalty.value >= 0 {
1137 Some(base_sq.saturating_add(pen_sq))
1138 } else if penalty.value > i64::MIN {
1139 Some(base_sq.saturating_sub(pen_sq))
1141 } else {
1142 Some(base_sq)
1144 }
1145 }
1146
1147 #[must_use]
1152 pub fn adjacency_demerits(
1153 &self,
1154 prev_fitness: FitnessClass,
1155 curr_fitness: FitnessClass,
1156 prev_flagged: bool,
1157 curr_flagged: bool,
1158 ) -> u64 {
1159 let mut extra = 0u64;
1160
1161 if prev_fitness.incompatible(curr_fitness) {
1163 extra = extra.saturating_add(self.fitness_demerit);
1164 }
1165
1166 if prev_flagged && curr_flagged {
1168 extra = extra.saturating_add(self.double_hyphen_demerit);
1169 }
1170
1171 extra
1172 }
1173
1174 #[must_use]
1179 pub fn widow_demerits(&self, last_line_chars: usize) -> u64 {
1180 if last_line_chars < self.widow_threshold {
1181 self.widow_demerit
1182 } else {
1183 0
1184 }
1185 }
1186
1187 #[must_use]
1191 pub fn orphan_demerits(&self, first_line_chars: usize) -> u64 {
1192 if first_line_chars < self.orphan_threshold {
1193 self.orphan_demerit
1194 } else {
1195 0
1196 }
1197 }
1198}
1199
1200#[cfg(test)]
1201trait TestWidth {
1202 fn width(&self) -> usize;
1203}
1204
1205#[cfg(test)]
1206impl TestWidth for str {
1207 fn width(&self) -> usize {
1208 display_width(self)
1209 }
1210}
1211
1212#[cfg(test)]
1213impl TestWidth for String {
1214 fn width(&self) -> usize {
1215 display_width(self)
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::TestWidth;
1222 use super::*;
1223
1224 #[test]
1229 fn wrap_text_no_wrap_needed() {
1230 let lines = wrap_text("hello", 10, WrapMode::Word);
1231 assert_eq!(lines, vec!["hello"]);
1232 }
1233
1234 #[test]
1235 fn wrap_text_single_word_wrap() {
1236 let lines = wrap_text("hello world", 5, WrapMode::Word);
1237 assert_eq!(lines, vec!["hello", "world"]);
1238 }
1239
1240 #[test]
1241 fn wrap_text_multiple_words() {
1242 let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
1243 assert_eq!(lines, vec!["hello world", "foo bar"]);
1244 }
1245
1246 #[test]
1247 fn wrap_text_preserves_newlines() {
1248 let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
1249 assert_eq!(lines, vec!["line1", "line2"]);
1250 }
1251
1252 #[test]
1253 fn wrap_text_preserves_crlf_newlines() {
1254 let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
1255 assert_eq!(lines, vec!["line1", "line2", ""]);
1256 }
1257
1258 #[test]
1259 fn wrap_text_trailing_newlines() {
1260 let lines = wrap_text("line1\n", 20, WrapMode::Word);
1262 assert_eq!(lines, vec!["line1", ""]);
1263
1264 let lines = wrap_text("\n", 20, WrapMode::Word);
1266 assert_eq!(lines, vec!["", ""]);
1267
1268 let lines = wrap_text("line1\n", 20, WrapMode::Char);
1270 assert_eq!(lines, vec!["line1", ""]);
1271 }
1272
1273 #[test]
1274 fn wrap_text_empty_string() {
1275 let lines = wrap_text("", 10, WrapMode::Word);
1276 assert_eq!(lines, vec![""]);
1277 }
1278
1279 #[test]
1280 fn wrap_text_long_word_no_fallback() {
1281 let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
1282 assert_eq!(lines, vec!["supercalifragilistic"]);
1284 }
1285
1286 #[test]
1287 fn wrap_text_long_word_with_fallback() {
1288 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1289 assert!(lines.len() > 1);
1291 for line in &lines {
1292 assert!(line.width() <= 10);
1293 }
1294 }
1295
1296 #[test]
1297 fn wrap_char_mode() {
1298 let lines = wrap_text("hello world", 5, WrapMode::Char);
1299 assert_eq!(lines, vec!["hello", " worl", "d"]);
1300 }
1301
1302 #[test]
1303 fn wrap_none_mode() {
1304 let lines = wrap_text("hello world", 5, WrapMode::None);
1305 assert_eq!(lines, vec!["hello world"]);
1306 }
1307
1308 #[test]
1313 fn wrap_cjk_respects_width() {
1314 let lines = wrap_text("你好世界", 4, WrapMode::Char);
1316 assert_eq!(lines, vec!["你好", "世界"]);
1317 }
1318
1319 #[test]
1320 fn wrap_cjk_odd_width() {
1321 let lines = wrap_text("你好世", 5, WrapMode::Char);
1323 assert_eq!(lines, vec!["你好", "世"]);
1324 }
1325
1326 #[test]
1327 fn wrap_mixed_ascii_cjk() {
1328 let lines = wrap_text("hi你好", 4, WrapMode::Char);
1329 assert_eq!(lines, vec!["hi你", "好"]);
1330 }
1331
1332 #[test]
1337 fn wrap_emoji_as_unit() {
1338 let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
1340 assert_eq!(lines.len(), 2);
1342 for line in &lines {
1343 assert!(!line.contains("\\u"));
1345 }
1346 }
1347
1348 #[test]
1349 fn wrap_zwj_sequence_as_unit() {
1350 let text = "👨👩👧";
1352 let lines = wrap_text(text, 2, WrapMode::Char);
1353 assert!(lines.iter().any(|l| l.contains("👨👩👧")));
1356 }
1357
1358 #[test]
1359 fn wrap_mixed_ascii_and_emoji_respects_width() {
1360 let lines = wrap_text("a😀b", 3, WrapMode::Char);
1361 assert_eq!(lines, vec!["a😀", "b"]);
1362 }
1363
1364 #[test]
1369 fn truncate_no_change_if_fits() {
1370 let result = truncate_with_ellipsis("hello", 10, "...");
1371 assert_eq!(result, "hello");
1372 }
1373
1374 #[test]
1375 fn truncate_with_ellipsis_ascii() {
1376 let result = truncate_with_ellipsis("hello world", 8, "...");
1377 assert_eq!(result, "hello...");
1378 }
1379
1380 #[test]
1381 fn truncate_cjk() {
1382 let result = truncate_with_ellipsis("你好世界", 6, "...");
1383 assert_eq!(result, "你...");
1386 }
1387
1388 #[test]
1389 fn truncate_to_width_basic() {
1390 let result = truncate_to_width("hello world", 5);
1391 assert_eq!(result, "hello");
1392 }
1393
1394 #[test]
1395 fn truncate_to_width_cjk() {
1396 let result = truncate_to_width("你好世界", 4);
1397 assert_eq!(result, "你好");
1398 }
1399
1400 #[test]
1401 fn truncate_to_width_odd_boundary() {
1402 let result = truncate_to_width("你好", 3);
1404 assert_eq!(result, "你");
1405 }
1406
1407 #[test]
1408 fn truncate_combining_chars() {
1409 let text = "e\u{0301}test";
1411 let result = truncate_to_width(text, 2);
1412 assert_eq!(result.chars().count(), 3); }
1415
1416 #[test]
1421 fn display_width_ascii() {
1422 assert_eq!(display_width("hello"), 5);
1423 }
1424
1425 #[test]
1426 fn display_width_cjk() {
1427 assert_eq!(display_width("你好"), 4);
1428 }
1429
1430 #[test]
1431 fn display_width_emoji_sequences() {
1432 assert_eq!(display_width("👩🔬"), 2);
1433 assert_eq!(display_width("👨👩👧👦"), 2);
1434 assert_eq!(display_width("👩🚀x"), 3);
1435 }
1436
1437 #[test]
1438 fn display_width_misc_symbol_emoji() {
1439 assert_eq!(display_width("⏳"), 2);
1440 assert_eq!(display_width("⌛"), 2);
1441 }
1442
1443 #[test]
1444 fn display_width_emoji_presentation_selector() {
1445 assert_eq!(display_width("❤️"), 1);
1447 assert_eq!(display_width("⌨️"), 1);
1448 assert_eq!(display_width("⚠️"), 1);
1449 }
1450
1451 #[test]
1452 fn display_width_misc_symbol_ranges() {
1453 assert_eq!(display_width("⌚"), 2); assert_eq!(display_width("⭐"), 2); let airplane_width = display_width("✈"); let arrow_width = display_width("⬆"); assert!(
1461 [1, 2].contains(&airplane_width),
1462 "airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
1463 );
1464 assert_eq!(
1465 airplane_width, arrow_width,
1466 "both Neutral-width chars should have same width in any mode"
1467 );
1468 }
1469
1470 #[test]
1471 fn display_width_flags() {
1472 assert_eq!(display_width("🇺🇸"), 2);
1473 assert_eq!(display_width("🇯🇵"), 2);
1474 assert_eq!(display_width("🇺🇸🇯🇵"), 4);
1475 }
1476
1477 #[test]
1478 fn display_width_skin_tone_modifiers() {
1479 assert_eq!(display_width("👍🏻"), 2);
1480 assert_eq!(display_width("👍🏽"), 2);
1481 }
1482
1483 #[test]
1484 fn display_width_zwj_sequences() {
1485 assert_eq!(display_width("👩💻"), 2);
1486 assert_eq!(display_width("👨👩👧👦"), 2);
1487 }
1488
1489 #[test]
1490 fn display_width_mixed_ascii_and_emoji() {
1491 assert_eq!(display_width("A😀B"), 4);
1492 assert_eq!(display_width("A👩💻B"), 4);
1493 assert_eq!(display_width("ok ✅"), 5);
1494 }
1495
1496 #[test]
1497 fn display_width_file_icons() {
1498 let wide_icons = ["📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄"];
1501 for icon in wide_icons {
1502 assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
1503 }
1504 let narrow_icons = ["⚙️", "🖼️"];
1506 for icon in narrow_icons {
1507 assert_eq!(display_width(icon), 1, "VS16 icon width mismatch: {icon}");
1508 }
1509 }
1510
1511 #[test]
1512 fn grapheme_width_emoji_sequence() {
1513 assert_eq!(grapheme_width("👩🔬"), 2);
1514 }
1515
1516 #[test]
1517 fn grapheme_width_flags_and_modifiers() {
1518 assert_eq!(grapheme_width("🇺🇸"), 2);
1519 assert_eq!(grapheme_width("👍🏽"), 2);
1520 }
1521
1522 #[test]
1523 fn display_width_empty() {
1524 assert_eq!(display_width(""), 0);
1525 }
1526
1527 #[test]
1532 fn ascii_width_pure_ascii() {
1533 assert_eq!(ascii_width("hello"), Some(5));
1534 assert_eq!(ascii_width("hello world 123"), Some(15));
1535 }
1536
1537 #[test]
1538 fn ascii_width_empty() {
1539 assert_eq!(ascii_width(""), Some(0));
1540 }
1541
1542 #[test]
1543 fn ascii_width_non_ascii_returns_none() {
1544 assert_eq!(ascii_width("你好"), None);
1545 assert_eq!(ascii_width("héllo"), None);
1546 assert_eq!(ascii_width("hello😀"), None);
1547 }
1548
1549 #[test]
1550 fn ascii_width_mixed_returns_none() {
1551 assert_eq!(ascii_width("hi你好"), None);
1552 assert_eq!(ascii_width("caf\u{00e9}"), None); }
1554
1555 #[test]
1556 fn ascii_width_control_chars_returns_none() {
1557 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); }
1566
1567 #[test]
1568 fn display_width_uses_ascii_fast_path() {
1569 assert_eq!(display_width("test"), 4);
1571 assert_eq!(display_width("你"), 2);
1573 }
1574
1575 #[test]
1576 fn has_wide_chars_true() {
1577 assert!(has_wide_chars("hi你好"));
1578 }
1579
1580 #[test]
1581 fn has_wide_chars_false() {
1582 assert!(!has_wide_chars("hello"));
1583 }
1584
1585 #[test]
1586 fn is_ascii_only_true() {
1587 assert!(is_ascii_only("hello world 123"));
1588 }
1589
1590 #[test]
1591 fn is_ascii_only_false() {
1592 assert!(!is_ascii_only("héllo"));
1593 }
1594
1595 #[test]
1600 fn grapheme_count_ascii() {
1601 assert_eq!(grapheme_count("hello"), 5);
1602 assert_eq!(grapheme_count(""), 0);
1603 }
1604
1605 #[test]
1606 fn grapheme_count_combining() {
1607 assert_eq!(grapheme_count("e\u{0301}"), 1);
1609 assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
1611 }
1612
1613 #[test]
1614 fn grapheme_count_cjk() {
1615 assert_eq!(grapheme_count("你好"), 2);
1616 }
1617
1618 #[test]
1619 fn grapheme_count_emoji() {
1620 assert_eq!(grapheme_count("😀"), 1);
1621 assert_eq!(grapheme_count("👍🏻"), 1);
1623 }
1624
1625 #[test]
1626 fn grapheme_count_zwj() {
1627 assert_eq!(grapheme_count("👨👩👧"), 1);
1629 }
1630
1631 #[test]
1632 fn graphemes_iteration() {
1633 let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
1634 assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
1635 }
1636
1637 #[test]
1638 fn graphemes_empty() {
1639 let gs: Vec<&str> = graphemes("").collect();
1640 assert!(gs.is_empty());
1641 }
1642
1643 #[test]
1644 fn graphemes_cjk() {
1645 let gs: Vec<&str> = graphemes("你好").collect();
1646 assert_eq!(gs, vec!["你", "好"]);
1647 }
1648
1649 #[test]
1650 fn truncate_to_width_with_info_basic() {
1651 let (text, width) = truncate_to_width_with_info("hello world", 5);
1652 assert_eq!(text, "hello");
1653 assert_eq!(width, 5);
1654 }
1655
1656 #[test]
1657 fn truncate_to_width_with_info_cjk() {
1658 let (text, width) = truncate_to_width_with_info("你好世界", 3);
1659 assert_eq!(text, "你");
1660 assert_eq!(width, 2);
1661 }
1662
1663 #[test]
1664 fn truncate_to_width_with_info_combining() {
1665 let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
1666 assert_eq!(text, "e\u{0301}b");
1667 assert_eq!(width, 2);
1668 }
1669
1670 #[test]
1671 fn truncate_to_width_with_info_fits() {
1672 let (text, width) = truncate_to_width_with_info("hi", 10);
1673 assert_eq!(text, "hi");
1674 assert_eq!(width, 2);
1675 }
1676
1677 #[test]
1678 fn word_boundaries_basic() {
1679 let breaks: Vec<usize> = word_boundaries("hello world").collect();
1680 assert!(breaks.contains(&6)); }
1682
1683 #[test]
1684 fn word_boundaries_multiple_spaces() {
1685 let breaks: Vec<usize> = word_boundaries("a b").collect();
1686 assert!(breaks.contains(&3)); }
1688
1689 #[test]
1690 fn word_segments_basic() {
1691 let segs: Vec<&str> = word_segments("hello world").collect();
1692 assert!(segs.contains(&"hello"));
1694 assert!(segs.contains(&"world"));
1695 }
1696
1697 #[test]
1702 fn wrap_options_builder() {
1703 let opts = WrapOptions::new(40)
1704 .mode(WrapMode::Char)
1705 .preserve_indent(true)
1706 .trim_trailing(false);
1707
1708 assert_eq!(opts.width, 40);
1709 assert_eq!(opts.mode, WrapMode::Char);
1710 assert!(opts.preserve_indent);
1711 assert!(!opts.trim_trailing);
1712 }
1713
1714 #[test]
1715 fn wrap_options_trim_trailing() {
1716 let opts = WrapOptions::new(10).trim_trailing(true);
1717 let lines = wrap_with_options("hello world", &opts);
1718 assert!(!lines.iter().any(|l| l.ends_with(' ')));
1720 }
1721
1722 #[test]
1723 fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
1724 let opts = WrapOptions::new(7)
1725 .mode(WrapMode::Word)
1726 .preserve_indent(true);
1727 let lines = wrap_with_options("word12 abcde", &opts);
1728 assert_eq!(lines, vec!["word12", " abcde"]);
1729 }
1730
1731 #[test]
1732 fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
1733 let opts = WrapOptions::new(7)
1734 .mode(WrapMode::Word)
1735 .preserve_indent(false);
1736 let lines = wrap_with_options("word12 abcde", &opts);
1737 assert_eq!(lines, vec!["word12", "abcde"]);
1738 }
1739
1740 #[test]
1741 fn wrap_zero_width() {
1742 let lines = wrap_text("hello", 0, WrapMode::Word);
1743 assert_eq!(lines, vec!["hello"]);
1745 }
1746
1747 #[test]
1752 fn wrap_mode_default() {
1753 let mode = WrapMode::default();
1754 assert_eq!(mode, WrapMode::Word);
1755 }
1756
1757 #[test]
1758 fn wrap_options_default() {
1759 let opts = WrapOptions::default();
1760 assert_eq!(opts.width, 80);
1761 assert_eq!(opts.mode, WrapMode::Word);
1762 assert!(!opts.preserve_indent);
1763 assert!(opts.trim_trailing);
1764 }
1765
1766 #[test]
1767 fn display_width_emoji_skin_tone() {
1768 let width = display_width("👍🏻");
1769 assert_eq!(width, 2);
1770 }
1771
1772 #[test]
1773 fn display_width_flag_emoji() {
1774 let width = display_width("🇺🇸");
1775 assert_eq!(width, 2);
1776 }
1777
1778 #[test]
1779 fn display_width_zwj_family() {
1780 let width = display_width("👨👩👧");
1781 assert_eq!(width, 2);
1782 }
1783
1784 #[test]
1785 fn display_width_multiple_combining() {
1786 let width = display_width("e\u{0301}\u{0308}");
1788 assert_eq!(width, 1);
1789 }
1790
1791 #[test]
1792 fn ascii_width_printable_range() {
1793 let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
1795 assert_eq!(ascii_width(&printable), Some(printable.len()));
1796 }
1797
1798 #[test]
1799 fn ascii_width_newline_returns_none() {
1800 assert!(ascii_width("hello\nworld").is_none());
1802 }
1803
1804 #[test]
1805 fn ascii_width_tab_returns_none() {
1806 assert!(ascii_width("hello\tworld").is_none());
1808 }
1809
1810 #[test]
1811 fn ascii_width_del_returns_none() {
1812 assert!(ascii_width("hello\x7Fworld").is_none());
1814 }
1815
1816 #[test]
1817 fn has_wide_chars_cjk_mixed() {
1818 assert!(has_wide_chars("abc你def"));
1819 assert!(has_wide_chars("你"));
1820 assert!(!has_wide_chars("abc"));
1821 }
1822
1823 #[test]
1824 fn has_wide_chars_emoji() {
1825 assert!(has_wide_chars("😀"));
1826 assert!(has_wide_chars("hello😀"));
1827 }
1828
1829 #[test]
1830 fn grapheme_count_empty() {
1831 assert_eq!(grapheme_count(""), 0);
1832 }
1833
1834 #[test]
1835 fn grapheme_count_regional_indicators() {
1836 assert_eq!(grapheme_count("🇺🇸"), 1);
1838 }
1839
1840 #[test]
1841 fn word_boundaries_no_spaces() {
1842 let breaks: Vec<usize> = word_boundaries("helloworld").collect();
1843 assert!(breaks.is_empty());
1844 }
1845
1846 #[test]
1847 fn word_boundaries_only_spaces() {
1848 let breaks: Vec<usize> = word_boundaries(" ").collect();
1849 assert!(!breaks.is_empty());
1850 }
1851
1852 #[test]
1853 fn word_segments_empty() {
1854 let segs: Vec<&str> = word_segments("").collect();
1855 assert!(segs.is_empty());
1856 }
1857
1858 #[test]
1859 fn word_segments_single_word() {
1860 let segs: Vec<&str> = word_segments("hello").collect();
1861 assert_eq!(segs.len(), 1);
1862 assert_eq!(segs[0], "hello");
1863 }
1864
1865 #[test]
1866 fn truncate_to_width_empty() {
1867 let result = truncate_to_width("", 10);
1868 assert_eq!(result, "");
1869 }
1870
1871 #[test]
1872 fn truncate_to_width_zero_width() {
1873 let result = truncate_to_width("hello", 0);
1874 assert_eq!(result, "");
1875 }
1876
1877 #[test]
1878 fn truncate_with_ellipsis_exact_fit() {
1879 let result = truncate_with_ellipsis("hello", 5, "...");
1881 assert_eq!(result, "hello");
1882 }
1883
1884 #[test]
1885 fn truncate_with_ellipsis_empty_ellipsis() {
1886 let result = truncate_with_ellipsis("hello world", 5, "");
1887 assert_eq!(result, "hello");
1888 }
1889
1890 #[test]
1891 fn truncate_to_width_with_info_empty() {
1892 let (text, width) = truncate_to_width_with_info("", 10);
1893 assert_eq!(text, "");
1894 assert_eq!(width, 0);
1895 }
1896
1897 #[test]
1898 fn truncate_to_width_with_info_zero_width() {
1899 let (text, width) = truncate_to_width_with_info("hello", 0);
1900 assert_eq!(text, "");
1901 assert_eq!(width, 0);
1902 }
1903
1904 #[test]
1905 fn truncate_to_width_wide_char_boundary() {
1906 let (text, width) = truncate_to_width_with_info("a你好", 2);
1908 assert_eq!(text, "a");
1910 assert_eq!(width, 1);
1911 }
1912
1913 #[test]
1914 fn wrap_mode_none() {
1915 let lines = wrap_text("hello world", 5, WrapMode::None);
1916 assert_eq!(lines, vec!["hello world"]);
1917 }
1918
1919 #[test]
1920 fn wrap_long_word_no_char_fallback() {
1921 let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1923 for line in &lines {
1925 assert!(line.width() <= 10);
1926 }
1927 }
1928
1929 #[test]
1934 fn unit_badness_monotone() {
1935 let width = 80;
1937 let mut prev = knuth_plass_badness(0, width, false);
1938 for slack in 1..=80i64 {
1939 let bad = knuth_plass_badness(slack, width, false);
1940 assert!(
1941 bad >= prev,
1942 "badness must be monotonically non-decreasing: \
1943 badness({slack}) = {bad} < badness({}) = {prev}",
1944 slack - 1
1945 );
1946 prev = bad;
1947 }
1948 }
1949
1950 #[test]
1951 fn unit_badness_zero_slack() {
1952 assert_eq!(knuth_plass_badness(0, 80, false), 0);
1954 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1955 }
1956
1957 #[test]
1958 fn unit_badness_overflow_is_inf() {
1959 assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
1961 assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
1962 }
1963
1964 #[test]
1965 fn unit_badness_last_line_always_zero() {
1966 assert_eq!(knuth_plass_badness(0, 80, true), 0);
1968 assert_eq!(knuth_plass_badness(40, 80, true), 0);
1969 assert_eq!(knuth_plass_badness(79, 80, true), 0);
1970 }
1971
1972 #[test]
1973 fn unit_badness_cubic_growth() {
1974 let width = 100;
1975 let b10 = knuth_plass_badness(10, width, false);
1976 let b20 = knuth_plass_badness(20, width, false);
1977 let b40 = knuth_plass_badness(40, width, false);
1978
1979 assert!(
1982 b20 >= b10 * 6,
1983 "doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
1984 b20.checked_div(b10).unwrap_or(0)
1985 );
1986 assert!(
1987 b40 >= b20 * 6,
1988 "doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
1989 b40.checked_div(b20).unwrap_or(0)
1990 );
1991 }
1992
1993 #[test]
1994 fn unit_penalty_applied() {
1995 let result = wrap_optimal("superlongwordthatcannotfit", 10);
1997 assert!(
1999 result.total_cost >= PENALTY_FORCE_BREAK,
2000 "force-break penalty should be applied: cost={}",
2001 result.total_cost
2002 );
2003 }
2004
2005 #[test]
2006 fn kp_simple_wrap() {
2007 let result = wrap_optimal("Hello world foo bar", 10);
2008 for line in &result.lines {
2010 assert!(
2011 line.width() <= 10,
2012 "line '{line}' exceeds width 10 (width={})",
2013 line.width()
2014 );
2015 }
2016 assert!(result.lines.len() >= 2);
2018 }
2019
2020 #[test]
2021 fn kp_perfect_fit() {
2022 let result = wrap_optimal("aaaa bbbb", 9);
2024 assert_eq!(result.lines.len(), 1);
2026 assert_eq!(result.total_cost, 0);
2027 }
2028
2029 #[test]
2030 fn kp_optimal_vs_greedy() {
2031 let result = wrap_optimal("aaa bb cc ddddd", 6);
2036
2037 for line in &result.lines {
2039 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2040 }
2041
2042 assert!(result.lines.len() >= 2);
2046 }
2047
2048 #[test]
2049 fn kp_empty_text() {
2050 let result = wrap_optimal("", 80);
2051 assert_eq!(result.lines, vec![""]);
2052 assert_eq!(result.total_cost, 0);
2053 }
2054
2055 #[test]
2056 fn kp_single_word() {
2057 let result = wrap_optimal("hello", 80);
2058 assert_eq!(result.lines, vec!["hello"]);
2059 assert_eq!(result.total_cost, 0); }
2061
2062 #[test]
2063 fn kp_multiline_preserves_newlines() {
2064 let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
2065 assert!(lines.len() >= 2);
2067 assert!(lines[0].width() <= 10);
2069 }
2070
2071 #[test]
2072 fn kp_tokenize_basic() {
2073 let words = kp_tokenize("hello world foo");
2074 assert_eq!(words.len(), 3);
2075 assert_eq!(words[0].content_width, 5);
2076 assert_eq!(words[0].space_width, 1);
2077 assert_eq!(words[1].content_width, 5);
2078 assert_eq!(words[1].space_width, 1);
2079 assert_eq!(words[2].content_width, 3);
2080 assert_eq!(words[2].space_width, 0);
2081 }
2082
2083 #[test]
2084 fn kp_diagnostics_line_badness() {
2085 let result = wrap_optimal("short text here for testing the dp", 15);
2086 assert_eq!(result.line_badness.len(), result.lines.len());
2088 assert_eq!(
2090 *result.line_badness.last().unwrap(),
2091 0,
2092 "last line should have zero badness"
2093 );
2094 }
2095
2096 #[test]
2097 fn kp_deterministic() {
2098 let text = "The quick brown fox jumps over the lazy dog near a riverbank";
2099 let r1 = wrap_optimal(text, 20);
2100 let r2 = wrap_optimal(text, 20);
2101 assert_eq!(r1.lines, r2.lines);
2102 assert_eq!(r1.total_cost, r2.total_cost);
2103 }
2104
2105 #[test]
2110 fn unit_dp_matches_known() {
2111 let result = wrap_optimal("aaa bb cc ddddd", 6);
2116
2117 for line in &result.lines {
2119 assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2120 }
2121
2122 assert_eq!(
2124 result.lines.len(),
2125 3,
2126 "expected 3 lines, got {:?}",
2127 result.lines
2128 );
2129 assert_eq!(result.lines[0], "aaa");
2130 assert_eq!(result.lines[1], "bb cc");
2131 assert_eq!(result.lines[2], "ddddd");
2132
2133 assert_eq!(*result.line_badness.last().unwrap(), 0);
2135 }
2136
2137 #[test]
2138 fn unit_dp_known_two_line() {
2139 let r1 = wrap_optimal("hello world", 11);
2141 assert_eq!(r1.lines, vec!["hello world"]);
2142 assert_eq!(r1.total_cost, 0);
2143
2144 let r2 = wrap_optimal("hello world", 7);
2146 assert_eq!(r2.lines.len(), 2);
2147 assert_eq!(r2.lines[0], "hello");
2148 assert_eq!(r2.lines[1], "world");
2149 assert!(
2152 r2.total_cost > 0 && r2.total_cost < 300,
2153 "expected cost ~233, got {}",
2154 r2.total_cost
2155 );
2156 }
2157
2158 #[test]
2159 fn unit_dp_optimal_beats_greedy() {
2160 let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
2181 let optimal = wrap_optimal("the quick brown fox", 10);
2182
2183 for line in &greedy {
2185 assert!(line.width() <= 10);
2186 }
2187 for line in &optimal.lines {
2188 assert!(line.width() <= 10);
2189 }
2190
2191 let mut greedy_cost: u64 = 0;
2194 for (i, line) in greedy.iter().enumerate() {
2195 let slack = 10i64 - line.width() as i64;
2196 let is_last = i == greedy.len() - 1;
2197 greedy_cost += knuth_plass_badness(slack, 10, is_last);
2198 }
2199 assert!(
2200 optimal.total_cost <= greedy_cost,
2201 "optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
2202 optimal.total_cost,
2203 greedy_cost
2204 );
2205 }
2206
2207 #[test]
2208 fn perf_wrap_large() {
2209 use std::time::Instant;
2210
2211 let words: Vec<&str> = [
2213 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2214 "back", "to", "its", "den", "in",
2215 ]
2216 .to_vec();
2217
2218 let mut paragraph = String::new();
2219 for i in 0..1000 {
2220 if i > 0 {
2221 paragraph.push(' ');
2222 }
2223 paragraph.push_str(words[i % words.len()]);
2224 }
2225
2226 let iterations = 20;
2227 let start = Instant::now();
2228 for _ in 0..iterations {
2229 let result = wrap_optimal(¶graph, 80);
2230 assert!(!result.lines.is_empty());
2231 }
2232 let elapsed = start.elapsed();
2233
2234 eprintln!(
2235 "{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
2236 iterations,
2237 elapsed.as_millis(),
2238 elapsed.as_micros() / iterations as u128
2239 );
2240
2241 assert!(
2243 elapsed.as_secs() < 2,
2244 "Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
2245 );
2246 }
2247
2248 #[test]
2249 fn kp_pruning_lookahead_bound() {
2250 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";
2252 let result = wrap_optimal(text, 10);
2253 for line in &result.lines {
2254 assert!(line.width() <= 10, "line '{line}' exceeds width");
2255 }
2256 let joined: String = result.lines.join(" ");
2258 for ch in 'a'..='z' {
2259 assert!(joined.contains(ch), "missing letter '{ch}' in output");
2260 }
2261 }
2262
2263 #[test]
2264 fn kp_very_narrow_width() {
2265 let result = wrap_optimal("ab cd ef", 2);
2267 assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
2268 }
2269
2270 #[test]
2271 fn kp_wide_width_single_line() {
2272 let result = wrap_optimal("hello world", 1000);
2274 assert_eq!(result.lines, vec!["hello world"]);
2275 assert_eq!(result.total_cost, 0);
2276 }
2277
2278 fn fnv1a_lines(lines: &[String]) -> u64 {
2284 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2285 for (i, line) in lines.iter().enumerate() {
2286 for byte in (i as u32)
2287 .to_le_bytes()
2288 .iter()
2289 .chain(line.as_bytes().iter())
2290 {
2291 hash ^= *byte as u64;
2292 hash = hash.wrapping_mul(0x0100_0000_01b3);
2293 }
2294 }
2295 hash
2296 }
2297
2298 #[test]
2299 fn snapshot_wrap_quality() {
2300 let paragraphs = [
2302 "The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
2303 "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",
2304 "aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
2305 ];
2306
2307 let widths = [20, 40, 60, 80];
2308
2309 for paragraph in ¶graphs {
2310 for &width in &widths {
2311 let result = wrap_optimal(paragraph, width);
2312
2313 let result2 = wrap_optimal(paragraph, width);
2315 assert_eq!(
2316 fnv1a_lines(&result.lines),
2317 fnv1a_lines(&result2.lines),
2318 "non-deterministic wrap at width {width}"
2319 );
2320
2321 for line in &result.lines {
2323 assert!(line.width() <= width, "line '{line}' exceeds width {width}");
2324 }
2325
2326 if !paragraph.is_empty() {
2328 for line in &result.lines {
2329 assert!(!line.is_empty(), "empty line in output at width {width}");
2330 }
2331 }
2332
2333 let original_words: Vec<&str> = paragraph.split_whitespace().collect();
2335 let result_words: Vec<&str> = result
2336 .lines
2337 .iter()
2338 .flat_map(|l| l.split_whitespace())
2339 .collect();
2340 assert_eq!(
2341 original_words, result_words,
2342 "content lost at width {width}"
2343 );
2344
2345 assert_eq!(
2347 *result.line_badness.last().unwrap(),
2348 0,
2349 "last line should have zero badness at width {width}"
2350 );
2351 }
2352 }
2353 }
2354
2355 #[test]
2360 fn perf_wrap_bench() {
2361 use std::time::Instant;
2362
2363 let sample_words = [
2364 "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2365 "back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
2366 "near",
2367 ];
2368
2369 let scenarios: &[(usize, usize, &str)] = &[
2370 (50, 40, "short_40"),
2371 (50, 80, "short_80"),
2372 (200, 40, "medium_40"),
2373 (200, 80, "medium_80"),
2374 (500, 40, "long_40"),
2375 (500, 80, "long_80"),
2376 ];
2377
2378 for &(word_count, width, label) in scenarios {
2379 let mut paragraph = String::new();
2381 for i in 0..word_count {
2382 if i > 0 {
2383 paragraph.push(' ');
2384 }
2385 paragraph.push_str(sample_words[i % sample_words.len()]);
2386 }
2387
2388 let iterations = 30u32;
2389 let mut times_us = Vec::with_capacity(iterations as usize);
2390 let mut last_lines = 0usize;
2391 let mut last_cost = 0u64;
2392 let mut last_checksum = 0u64;
2393
2394 for _ in 0..iterations {
2395 let start = Instant::now();
2396 let result = wrap_optimal(¶graph, width);
2397 let elapsed = start.elapsed();
2398
2399 last_lines = result.lines.len();
2400 last_cost = result.total_cost;
2401 last_checksum = fnv1a_lines(&result.lines);
2402 times_us.push(elapsed.as_micros() as u64);
2403 }
2404
2405 times_us.sort();
2406 let len = times_us.len();
2407 let p50 = times_us[len / 2];
2408 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2409
2410 eprintln!(
2412 "{{\"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}\"}}"
2413 );
2414
2415 let verify = wrap_optimal(¶graph, width);
2417 assert_eq!(
2418 fnv1a_lines(&verify.lines),
2419 last_checksum,
2420 "non-deterministic: {label}"
2421 );
2422
2423 if word_count >= 500 && p95 > 5000 {
2425 eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
2426 }
2427 }
2428 }
2429}
2430
2431#[cfg(test)]
2432mod proptests {
2433 use super::TestWidth;
2434 use super::*;
2435 use proptest::prelude::*;
2436
2437 proptest! {
2438 #[test]
2439 fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
2440 let lines = wrap_text(&s, width, WrapMode::Char);
2441 for line in &lines {
2442 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2443 }
2444 }
2445
2446 #[test]
2447 fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
2448 let lines = wrap_text(&s, width, WrapMode::Char);
2449 let rejoined: String = lines.join("");
2450 prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
2452 }
2453
2454 #[test]
2455 fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
2456 let result = truncate_with_ellipsis(&s, width, "...");
2457 prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
2458 }
2459
2460 #[test]
2461 fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
2462 let result = truncate_to_width(&s, width);
2463 prop_assert!(result.width() <= width);
2464 if s.width() > width {
2466 prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
2468 }
2469 }
2470
2471 #[test]
2472 fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
2473 let lines = wrap_text(&s, width, WrapMode::WordChar);
2474 for line in &lines {
2475 prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2476 }
2477 }
2478
2479 #[test]
2485 fn property_dp_vs_greedy(
2486 text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
2487 width in 8usize..40,
2488 ) {
2489 let greedy = wrap_text(&text, width, WrapMode::Word);
2490 let optimal = wrap_optimal(&text, width);
2491
2492 let mut greedy_cost: u64 = 0;
2494 for (i, line) in greedy.iter().enumerate() {
2495 let lw = line.width();
2496 let slack = width as i64 - lw as i64;
2497 let is_last = i == greedy.len() - 1;
2498 if slack >= 0 {
2499 greedy_cost = greedy_cost.saturating_add(
2500 knuth_plass_badness(slack, width, is_last)
2501 );
2502 } else {
2503 greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
2504 }
2505 }
2506
2507 prop_assert!(
2508 optimal.total_cost <= greedy_cost,
2509 "DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
2510 optimal.total_cost, greedy_cost, width, optimal.lines, greedy
2511 );
2512 }
2513
2514 #[test]
2516 fn property_dp_respects_width(
2517 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
2518 width in 6usize..30,
2519 ) {
2520 let result = wrap_optimal(&text, width);
2521 for line in &result.lines {
2522 prop_assert!(
2523 line.width() <= width,
2524 "DP line '{}' (width {}) exceeds target {}",
2525 line, line.width(), width
2526 );
2527 }
2528 }
2529
2530 #[test]
2532 fn property_dp_preserves_content(
2533 text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
2534 width in 8usize..30,
2535 ) {
2536 let result = wrap_optimal(&text, width);
2537 let original_words: Vec<&str> = text.split_whitespace().collect();
2538 let result_words: Vec<&str> = result.lines.iter()
2539 .flat_map(|l| l.split_whitespace())
2540 .collect();
2541 prop_assert_eq!(
2542 original_words, result_words,
2543 "DP should preserve all words"
2544 );
2545 }
2546 }
2547
2548 #[test]
2553 fn fitness_class_from_ratio() {
2554 assert_eq!(FitnessClass::from_ratio(-0.8), FitnessClass::Tight);
2555 assert_eq!(FitnessClass::from_ratio(-0.5), FitnessClass::Normal);
2556 assert_eq!(FitnessClass::from_ratio(0.0), FitnessClass::Normal);
2557 assert_eq!(FitnessClass::from_ratio(0.49), FitnessClass::Normal);
2558 assert_eq!(FitnessClass::from_ratio(0.5), FitnessClass::Loose);
2559 assert_eq!(FitnessClass::from_ratio(0.99), FitnessClass::Loose);
2560 assert_eq!(FitnessClass::from_ratio(1.0), FitnessClass::VeryLoose);
2561 assert_eq!(FitnessClass::from_ratio(2.0), FitnessClass::VeryLoose);
2562 }
2563
2564 #[test]
2565 fn fitness_class_incompatible() {
2566 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Tight));
2567 assert!(!FitnessClass::Tight.incompatible(FitnessClass::Normal));
2568 assert!(FitnessClass::Tight.incompatible(FitnessClass::Loose));
2569 assert!(FitnessClass::Tight.incompatible(FitnessClass::VeryLoose));
2570 assert!(!FitnessClass::Normal.incompatible(FitnessClass::Loose));
2571 assert!(FitnessClass::Normal.incompatible(FitnessClass::VeryLoose));
2572 }
2573
2574 #[test]
2575 fn objective_default_is_tex_standard() {
2576 let obj = ParagraphObjective::default();
2577 assert_eq!(obj.line_penalty, 10);
2578 assert_eq!(obj.fitness_demerit, 100);
2579 assert_eq!(obj.double_hyphen_demerit, 100);
2580 assert_eq!(obj.badness_scale, BADNESS_SCALE);
2581 }
2582
2583 #[test]
2584 fn objective_terminal_preset() {
2585 let obj = ParagraphObjective::terminal();
2586 assert_eq!(obj.line_penalty, 20);
2587 assert_eq!(obj.min_adjustment_ratio, 0.0);
2588 assert!(obj.max_adjustment_ratio > 2.0);
2589 }
2590
2591 #[test]
2592 fn badness_zero_slack_is_zero() {
2593 let obj = ParagraphObjective::default();
2594 assert_eq!(obj.badness(0, 80), Some(0));
2595 }
2596
2597 #[test]
2598 fn badness_moderate_slack() {
2599 let obj = ParagraphObjective::default();
2600 let b = obj.badness(10, 80).unwrap();
2603 assert!(b > 0 && b < 100, "badness = {b}");
2604 }
2605
2606 #[test]
2607 fn badness_excessive_slack_infeasible() {
2608 let obj = ParagraphObjective::default();
2609 assert!(obj.badness(240, 80).is_none());
2611 }
2612
2613 #[test]
2614 fn badness_negative_slack_within_bounds() {
2615 let obj = ParagraphObjective::default();
2616 let b = obj.badness(-40, 80);
2618 assert!(b.is_some());
2619 }
2620
2621 #[test]
2622 fn badness_negative_slack_beyond_bounds() {
2623 let obj = ParagraphObjective::default();
2624 assert!(obj.badness(-100, 80).is_none());
2626 }
2627
2628 #[test]
2629 fn badness_terminal_no_compression() {
2630 let obj = ParagraphObjective::terminal();
2631 assert!(obj.badness(-1, 80).is_none());
2633 }
2634
2635 #[test]
2636 fn demerits_space_break() {
2637 let obj = ParagraphObjective::default();
2638 let d = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2639 let badness = obj.badness(10, 80).unwrap();
2641 let expected = (obj.line_penalty + badness).pow(2);
2642 assert_eq!(d, expected);
2643 }
2644
2645 #[test]
2646 fn demerits_hyphen_break() {
2647 let obj = ParagraphObjective::default();
2648 let d_space = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2649 let d_hyphen = obj.demerits(10, 80, &BreakPenalty::HYPHEN).unwrap();
2650 assert!(d_hyphen > d_space);
2652 }
2653
2654 #[test]
2655 fn demerits_forced_break() {
2656 let obj = ParagraphObjective::default();
2657 let d = obj.demerits(0, 80, &BreakPenalty::FORCED).unwrap();
2658 assert_eq!(d, obj.line_penalty.pow(2));
2660 }
2661
2662 #[test]
2663 fn demerits_infeasible_returns_none() {
2664 let obj = ParagraphObjective::default();
2665 assert!(obj.demerits(300, 80, &BreakPenalty::SPACE).is_none());
2667 }
2668
2669 #[test]
2670 fn adjacency_fitness_incompatible() {
2671 let obj = ParagraphObjective::default();
2672 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::Loose, false, false);
2673 assert_eq!(d, obj.fitness_demerit);
2674 }
2675
2676 #[test]
2677 fn adjacency_fitness_compatible() {
2678 let obj = ParagraphObjective::default();
2679 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Loose, false, false);
2680 assert_eq!(d, 0);
2681 }
2682
2683 #[test]
2684 fn adjacency_double_hyphen() {
2685 let obj = ParagraphObjective::default();
2686 let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Normal, true, true);
2687 assert_eq!(d, obj.double_hyphen_demerit);
2688 }
2689
2690 #[test]
2691 fn adjacency_double_hyphen_plus_fitness() {
2692 let obj = ParagraphObjective::default();
2693 let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::VeryLoose, true, true);
2694 assert_eq!(d, obj.fitness_demerit + obj.double_hyphen_demerit);
2695 }
2696
2697 #[test]
2698 fn widow_penalty_short_last_line() {
2699 let obj = ParagraphObjective::default();
2700 assert_eq!(obj.widow_demerits(5), obj.widow_demerit);
2701 assert_eq!(obj.widow_demerits(14), obj.widow_demerit);
2702 assert_eq!(obj.widow_demerits(15), 0);
2703 assert_eq!(obj.widow_demerits(80), 0);
2704 }
2705
2706 #[test]
2707 fn orphan_penalty_short_first_line() {
2708 let obj = ParagraphObjective::default();
2709 assert_eq!(obj.orphan_demerits(10), obj.orphan_demerit);
2710 assert_eq!(obj.orphan_demerits(19), obj.orphan_demerit);
2711 assert_eq!(obj.orphan_demerits(20), 0);
2712 assert_eq!(obj.orphan_demerits(80), 0);
2713 }
2714
2715 #[test]
2716 fn adjustment_ratio_computation() {
2717 let obj = ParagraphObjective::default();
2718 let r = obj.adjustment_ratio(10, 80);
2719 assert!((r - 0.125).abs() < 1e-10);
2720 }
2721
2722 #[test]
2723 fn adjustment_ratio_zero_width() {
2724 let obj = ParagraphObjective::default();
2725 assert_eq!(obj.adjustment_ratio(5, 0), 0.0);
2726 }
2727
2728 #[test]
2729 fn badness_zero_width_zero_slack() {
2730 let obj = ParagraphObjective::default();
2731 assert_eq!(obj.badness(0, 0), Some(0));
2732 }
2733
2734 #[test]
2735 fn badness_zero_width_nonzero_slack() {
2736 let obj = ParagraphObjective::default();
2737 assert!(obj.badness(5, 0).is_none());
2738 }
2739}