1use ratatui::text::Line;
35use ratatui::text::Span;
36use std::borrow::Cow;
37use std::ops::Range;
38use textwrap::core::display_width;
39use textwrap::core::Word;
40use textwrap::Options;
41use textwrap::WordSeparator;
42
43fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
44 out.extend(src.iter().map(|line| {
45 Line {
46 style: line.style,
47 alignment: line.alignment,
48 spans: line
49 .spans
50 .iter()
51 .map(|span| Span::styled(span.content.to_string(), span.style))
52 .collect(),
53 }
54 }));
55}
56
57pub fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
61where
62 O: Into<Options<'a>>,
63{
64 let opts = width_or_options.into();
65 let mut lines: Vec<Range<usize>> = Vec::new();
66 let mut cursor = 0usize;
67 for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
68 match line {
69 std::borrow::Cow::Borrowed(slice) => {
70 let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
71 let synthetic_prefix = if line_index == 0 {
72 opts.initial_indent
73 } else {
74 opts.subsequent_indent
75 };
76 map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
77 });
78 let start = range.start;
79 let end = range.end;
80 let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
81 lines.push(start..end + trailing_spaces + 1);
82 cursor = end + trailing_spaces;
83 }
84 std::borrow::Cow::Owned(slice) => {
85 let synthetic_prefix = if line_index == 0 {
86 opts.initial_indent
87 } else {
88 opts.subsequent_indent
89 };
90 let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
91 let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count();
92 lines.push(mapped.start..mapped.end + trailing_spaces + 1);
93 cursor = mapped.end + trailing_spaces;
94 }
95 }
96 }
97 lines
98}
99
100pub fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
104where
105 O: Into<Options<'a>>,
106{
107 let opts = width_or_options.into();
108 let mut lines: Vec<Range<usize>> = Vec::new();
109 let mut cursor = 0usize;
110 for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() {
111 match line {
112 std::borrow::Cow::Borrowed(slice) => {
113 let range = borrowed_slice_range(text, slice).unwrap_or_else(|| {
114 let synthetic_prefix = if line_index == 0 {
115 opts.initial_indent
116 } else {
117 opts.subsequent_indent
118 };
119 map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix)
120 });
121 cursor = range.end;
122 lines.push(range);
123 }
124 std::borrow::Cow::Owned(slice) => {
125 let synthetic_prefix = if line_index == 0 {
126 opts.initial_indent
127 } else {
128 opts.subsequent_indent
129 };
130 let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix);
131 lines.push(mapped.clone());
132 cursor = mapped.end;
133 }
134 }
135 }
136 lines
137}
138
139fn borrowed_slice_range(text: &str, slice: &str) -> Option<Range<usize>> {
140 let text_start = text.as_ptr() as usize;
141 let text_end = text_start.checked_add(text.len())?;
142 let slice_start = slice.as_ptr() as usize;
143 let slice_end = slice_start.checked_add(slice.len())?;
144
145 if slice_start < text_start || slice_end > text_end {
146 return None;
147 }
148
149 Some((slice_start - text_start)..(slice_end - text_start))
150}
151
152fn map_owned_wrapped_line_to_range(
160 text: &str,
161 cursor: usize,
162 wrapped: &str,
163 synthetic_prefix: &str,
164) -> Range<usize> {
165 let wrapped = if synthetic_prefix.is_empty() {
166 wrapped
167 } else {
168 wrapped.strip_prefix(synthetic_prefix).unwrap_or(wrapped)
169 };
170
171 let mut start = cursor;
172 while start < text.len() && !wrapped.starts_with(' ') {
173 let Some(ch) = text[start..].chars().next() else {
174 break;
175 };
176 if ch != ' ' {
177 break;
178 }
179 start += ch.len_utf8();
180 }
181
182 let mut end = start;
183 let mut saw_source_char = false;
184 let mut chars = wrapped.chars().peekable();
185 while let Some(ch) = chars.next() {
186 if end < text.len() {
187 let Some(src) = text[end..].chars().next() else {
188 unreachable!("checked end < text.len()");
189 };
190 if ch == src {
191 end += src.len_utf8();
192 saw_source_char = true;
193 continue;
194 }
195 }
196
197 if ch == '-' && chars.peek().is_none() {
201 continue;
202 }
203
204 if !saw_source_char {
208 continue;
209 }
210
211 tracing::warn!(
212 wrapped = %wrapped,
213 cursor,
214 end,
215 "wrap_ranges: could not fully map owned line; returning partial source range"
216 );
217 break;
218 }
219
220 start..end
221}
222
223pub fn line_contains_url_like(line: &Line<'_>) -> bool {
227 let text: String = line
228 .spans
229 .iter()
230 .map(|span| span.content.as_ref())
231 .collect();
232 text_contains_url_like(&text)
233}
234
235pub fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
241 let text: String = line
242 .spans
243 .iter()
244 .map(|span| span.content.as_ref())
245 .collect();
246 text_has_mixed_url_and_non_url_tokens(&text)
247}
248
249pub fn text_contains_url_like(text: &str) -> bool {
261 text.split_ascii_whitespace().any(is_url_like_token)
262}
263
264fn text_has_mixed_url_and_non_url_tokens(text: &str) -> bool {
267 let mut saw_url = false;
268 let mut saw_non_url = false;
269
270 for raw_token in text.split_ascii_whitespace() {
271 if is_url_like_token(raw_token) {
272 saw_url = true;
273 } else if is_substantive_non_url_token(raw_token) {
274 saw_non_url = true;
275 }
276
277 if saw_url && saw_non_url {
278 return true;
279 }
280 }
281
282 false
283}
284
285fn is_url_like_token(raw_token: &str) -> bool {
290 let token = trim_url_token(raw_token);
291 !token.is_empty() && (is_absolute_url_like(token) || is_bare_url_like(token))
292}
293
294fn is_substantive_non_url_token(raw_token: &str) -> bool {
295 let token = trim_url_token(raw_token);
296 if token.is_empty() || is_decorative_marker_token(raw_token, token) {
297 return false;
298 }
299
300 token.chars().any(char::is_alphanumeric)
301}
302
303fn is_decorative_marker_token(raw_token: &str, token: &str) -> bool {
304 let raw = raw_token.trim();
305 matches!(
306 raw,
307 "-" | "*"
308 | "+"
309 | "•"
310 | "◦"
311 | "▪"
312 | ">"
313 | "|"
314 | "│"
315 | "┆"
316 | "└"
317 | "├"
318 | "┌"
319 | "┐"
320 | "┘"
321 | "┼"
322 ) || is_ordered_list_marker(raw, token)
323}
324
325fn is_ordered_list_marker(raw_token: &str, token: &str) -> bool {
326 token.chars().all(|c| c.is_ascii_digit())
327 && (raw_token.ends_with('.') || raw_token.ends_with(')'))
328}
329
330fn trim_url_token(token: &str) -> &str {
331 token.trim_matches(|c: char| {
332 matches!(
333 c,
334 '(' | ')'
335 | '['
336 | ']'
337 | '{'
338 | '}'
339 | '<'
340 | '>'
341 | ','
342 | '.'
343 | ';'
344 | ':'
345 | '!'
346 | '\''
347 | '"'
348 )
349 })
350}
351
352fn is_absolute_url_like(token: &str) -> bool {
356 if !token.contains("://") {
357 return false;
358 }
359
360 if let Ok(url) = url::Url::parse(token) {
361 let scheme = url.scheme().to_ascii_lowercase();
362 if matches!(
363 scheme.as_str(),
364 "http" | "https" | "ftp" | "ftps" | "ws" | "wss"
365 ) {
366 return url.host_str().is_some();
367 }
368 return true;
369 }
370
371 has_valid_scheme_prefix(token)
372}
373
374fn has_valid_scheme_prefix(token: &str) -> bool {
375 let Some((scheme, rest)) = token.split_once("://") else {
376 return false;
377 };
378 if scheme.is_empty() || rest.is_empty() {
379 return false;
380 }
381
382 let mut chars = scheme.chars();
383 let Some(first) = chars.next() else {
384 return false;
385 };
386 first.is_ascii_alphabetic()
387 && chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
388}
389
390fn is_bare_url_like(token: &str) -> bool {
399 let (host_port, has_trailer) = split_host_port_and_trailer(token);
400 if host_port.is_empty() {
401 return false;
402 }
403
404 if !has_trailer && !host_port.to_ascii_lowercase().starts_with("www.") {
406 return false;
407 }
408
409 let (host, port) = split_host_and_port(host_port);
410 if host.is_empty() {
411 return false;
412 }
413 if let Some(port) = port {
414 if !is_valid_port(port) {
415 return false;
416 }
417 }
418
419 host.eq_ignore_ascii_case("localhost") || is_ipv4(host) || is_domain_name(host)
420}
421
422fn split_host_port_and_trailer(token: &str) -> (&str, bool) {
423 if let Some(idx) = token.find(['/', '?', '#']) {
424 (&token[..idx], true)
425 } else {
426 (token, false)
427 }
428}
429
430fn split_host_and_port(host_port: &str) -> (&str, Option<&str>) {
431 if host_port.starts_with('[') {
433 return (host_port, None);
434 }
435
436 if let Some((host, port)) = host_port.rsplit_once(':') {
437 if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
438 return (host, Some(port));
439 }
440 }
441
442 (host_port, None)
443}
444
445fn is_valid_port(port: &str) -> bool {
446 if port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()) {
447 return false;
448 }
449
450 port.parse::<u16>().is_ok()
451}
452
453fn is_ipv4(host: &str) -> bool {
454 let parts: Vec<&str> = host.split('.').collect();
455 if parts.len() != 4 {
456 return false;
457 }
458
459 parts
460 .iter()
461 .all(|part| !part.is_empty() && part.parse::<u8>().is_ok())
462}
463
464fn is_domain_name(host: &str) -> bool {
465 let host = host.to_ascii_lowercase();
466 if !host.contains('.') {
467 return false;
468 }
469
470 let mut labels = host.split('.');
471 let Some(tld) = labels.next_back() else {
472 return false;
473 };
474 if !is_tld(tld) {
475 return false;
476 }
477
478 labels.all(is_domain_label)
479}
480
481fn is_tld(label: &str) -> bool {
482 (2..=63).contains(&label.len()) && label.chars().all(|c| c.is_ascii_alphabetic())
483}
484
485fn is_domain_label(label: &str) -> bool {
486 if label.is_empty() || label.len() > 63 {
487 return false;
488 }
489
490 let mut chars = label.chars();
491 let Some(first) = chars.next() else {
492 return false;
493 };
494 let Some(last) = label.chars().next_back() else {
495 return false;
496 };
497
498 first.is_ascii_alphanumeric()
499 && last.is_ascii_alphanumeric()
500 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
501}
502
503pub fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> {
510 opts.word_separator(textwrap::WordSeparator::AsciiSpace)
511 .word_splitter(textwrap::WordSplitter::NoHyphenation)
512 .break_words(false)
513}
514
515#[must_use]
524pub fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec<Line<'a>> {
525 if !line_contains_url_like(line) {
526 return word_wrap_line(line, base);
527 }
528
529 if line_has_mixed_url_and_non_url_tokens(line) {
530 mixed_url_wrap_line(line, base)
531 } else {
532 word_wrap_line(line, url_preserving_wrap_options(base))
533 }
534}
535
536#[allow(private_bounds)]
544pub fn adaptive_wrap_lines<'a, I, L>(
545 lines: I,
546 width_or_options: RtOptions<'a>,
547) -> Vec<Line<'static>>
548where
549 I: IntoIterator<Item = L>,
550 L: IntoLineInput<'a>,
551{
552 let base_opts = width_or_options;
553 let mut out: Vec<Line<'static>> = Vec::new();
554
555 for (idx, line) in lines.into_iter().enumerate() {
556 let line_input = line.into_line_input();
557 let opts = if idx == 0 {
558 base_opts.clone()
559 } else {
560 base_opts
561 .clone()
562 .initial_indent(base_opts.subsequent_indent.clone())
563 };
564
565 let wrapped = adaptive_wrap_line(line_input.as_ref(), opts);
566 push_owned_lines(&wrapped, &mut out);
567 }
568
569 out
570}
571
572#[derive(Debug, Clone)]
573pub struct RtOptions<'a> {
574 pub width: usize,
576 pub line_ending: textwrap::LineEnding,
578 pub initial_indent: Line<'a>,
581 pub subsequent_indent: Line<'a>,
584 pub break_words: bool,
588 pub wrap_algorithm: textwrap::WrapAlgorithm,
591 pub word_separator: textwrap::WordSeparator,
594 pub word_splitter: textwrap::WordSplitter,
598}
599impl From<usize> for RtOptions<'_> {
600 fn from(width: usize) -> Self {
601 RtOptions::new(width)
602 }
603}
604
605#[allow(dead_code)]
606impl<'a> RtOptions<'a> {
607 pub fn new(width: usize) -> Self {
608 RtOptions {
609 width,
610 line_ending: textwrap::LineEnding::LF,
611 initial_indent: Line::default(),
612 subsequent_indent: Line::default(),
613 break_words: true,
614 word_separator: textwrap::WordSeparator::new(),
615 wrap_algorithm: textwrap::WrapAlgorithm::FirstFit,
616 word_splitter: textwrap::WordSplitter::HyphenSplitter,
617 }
618 }
619
620 pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
621 RtOptions {
622 line_ending,
623 ..self
624 }
625 }
626
627 pub fn width(self, width: usize) -> Self {
628 RtOptions { width, ..self }
629 }
630
631 pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
632 RtOptions {
633 initial_indent,
634 ..self
635 }
636 }
637
638 pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
639 RtOptions {
640 subsequent_indent,
641 ..self
642 }
643 }
644
645 pub fn break_words(self, break_words: bool) -> Self {
646 RtOptions {
647 break_words,
648 ..self
649 }
650 }
651
652 pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
653 RtOptions {
654 word_separator,
655 ..self
656 }
657 }
658
659 pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
660 RtOptions {
661 wrap_algorithm,
662 ..self
663 }
664 }
665
666 pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
667 RtOptions {
668 word_splitter,
669 ..self
670 }
671 }
672}
673
674#[must_use]
675pub fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
676where
677 O: Into<RtOptions<'a>>,
678{
679 let (flat, span_bounds) = flatten_line(line);
680
681 let rt_opts: RtOptions<'a> = width_or_options.into();
682 let opts = Options::new(rt_opts.width)
683 .line_ending(rt_opts.line_ending)
684 .break_words(rt_opts.break_words)
685 .wrap_algorithm(rt_opts.wrap_algorithm)
686 .word_separator(rt_opts.word_separator)
687 .word_splitter(rt_opts.word_splitter);
688
689 let mut out: Vec<Line<'a>> = Vec::new();
690
691 let initial_width_available = opts
693 .width
694 .saturating_sub(rt_opts.initial_indent.width())
695 .max(1);
696 let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
697 let Some(first_line_range) = initial_wrapped.first() else {
698 return vec![rt_opts.initial_indent.clone()];
699 };
700
701 let mut first_line = rt_opts.initial_indent.clone().style(line.style);
703 {
704 let sliced = slice_line_spans(line, &span_bounds, first_line_range);
705 let mut spans = first_line.spans;
706 spans.append(
707 &mut sliced
708 .spans
709 .into_iter()
710 .map(|s| s.patch_style(line.style))
711 .collect(),
712 );
713 first_line.spans = spans;
714 out.push(first_line);
715 }
716
717 let base = first_line_range.end;
719 let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
720 let base = base + skip_leading_spaces;
721 let subsequent_width_available = opts
722 .width
723 .saturating_sub(rt_opts.subsequent_indent.width())
724 .max(1);
725 let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
726 for r in &remaining_wrapped {
727 if r.is_empty() {
728 continue;
729 }
730 let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
731 let offset_range = (r.start + base)..(r.end + base);
732 let sliced = slice_line_spans(line, &span_bounds, &offset_range);
733 let mut spans = subsequent_line.spans;
734 spans.append(
735 &mut sliced
736 .spans
737 .into_iter()
738 .map(|s| s.patch_style(line.style))
739 .collect(),
740 );
741 subsequent_line.spans = spans;
742 out.push(subsequent_line);
743 }
744
745 out
746}
747
748#[derive(Clone, Debug)]
749struct MixedUrlWord {
750 range: Range<usize>,
751 is_url: bool,
752}
753
754impl MixedUrlWord {
755 fn width(&self, text: &str) -> usize {
756 display_width(&text[self.range.clone()])
757 }
758}
759
760fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Line<'a>> {
761 let (flat, span_bounds) = flatten_line(line);
762 let initial_width_available = rt_opts
763 .width
764 .saturating_sub(rt_opts.initial_indent.width())
765 .max(1);
766 let subsequent_width_available = rt_opts
767 .width
768 .saturating_sub(rt_opts.subsequent_indent.width())
769 .max(1);
770 let ranges = mixed_url_wrap_ranges(&flat, initial_width_available, subsequent_width_available);
771
772 let mut out = Vec::new();
773 for (idx, range) in ranges.iter().enumerate() {
774 let mut wrapped_line = if idx == 0 {
775 rt_opts.initial_indent.clone()
776 } else {
777 rt_opts.subsequent_indent.clone()
778 }
779 .style(line.style);
780 let sliced = slice_line_spans(line, &span_bounds, range);
781 let mut spans = wrapped_line.spans;
782 spans.extend(
783 sliced
784 .spans
785 .into_iter()
786 .map(|span| span.patch_style(line.style)),
787 );
788 wrapped_line.spans = spans;
789 out.push(wrapped_line);
790 }
791
792 if out.is_empty() {
793 vec![rt_opts.initial_indent.clone()]
794 } else {
795 out
796 }
797}
798
799fn mixed_url_wrap_ranges(
800 text: &str,
801 initial_width: usize,
802 subsequent_width: usize,
803) -> Vec<Range<usize>> {
804 let leading_space_width = text.chars().take_while(|ch| *ch == ' ').count();
805 let mut words = Vec::new();
806 let mut cursor = 0usize;
807 for word in WordSeparator::AsciiSpace.find_words(text) {
808 let word_start = cursor;
809 let word_end = word_start + word.word.len();
810 let trailing_space_end = word_end + word.whitespace.len();
811 if !word.word.is_empty() {
812 words.push(MixedUrlWord {
813 range: word_start..word_end,
814 is_url: is_url_like_token(word.word),
815 });
816 }
817 cursor = trailing_space_end;
818 }
819
820 let mut lines = Vec::new();
821 let mut line_start = None;
822 let mut line_end = 0usize;
823 let mut line_width = 0usize;
824 let mut line_limit = initial_width.max(1);
825
826 for word in words {
827 let mut pending = split_mixed_url_word(text, word, line_limit);
828 let mut pending_idx = 0usize;
829
830 while let Some(piece) = pending.get(pending_idx).cloned() {
831 let empty_line_prefix_width = if line_start.is_none() && lines.is_empty() {
832 leading_space_width
833 } else {
834 0
835 };
836 let empty_line_piece_limit = line_limit.saturating_sub(empty_line_prefix_width).max(1);
837 if line_start.is_none() && !piece.is_url && piece.width(text) > empty_line_piece_limit {
838 pending.splice(
839 pending_idx..=pending_idx,
840 split_mixed_url_word(text, piece, empty_line_piece_limit),
841 );
842 continue;
843 }
844
845 let piece_width = piece.width(text);
846 let inter_word_space = line_start
847 .map(|_| text[line_end..piece.range.start].len())
848 .unwrap_or(0);
849 let fits = if line_start.is_none() {
850 piece.is_url
851 || empty_line_prefix_width + piece_width <= line_limit
852 || empty_line_prefix_width >= line_limit
853 } else {
854 line_width + inter_word_space + piece_width <= line_limit
855 };
856
857 if fits {
858 if line_start.is_none() {
859 let is_first_output_line = lines.is_empty();
860 let start = if is_first_output_line {
861 0
862 } else {
863 piece.range.start
864 };
865 line_start = Some(start);
866 line_width = if is_first_output_line {
867 leading_space_width + piece_width
868 } else {
869 piece_width
870 };
871 } else {
872 line_width += inter_word_space + piece_width;
873 }
874 line_end = piece.range.end;
875 pending_idx += 1;
876 continue;
877 }
878
879 if let Some(start) = line_start.take() {
880 lines.push(start..line_end);
881 }
882 line_end = 0;
883 line_width = 0;
884 line_limit = subsequent_width.max(1);
885 }
886 }
887
888 if let Some(start) = line_start {
889 lines.push(start..line_end);
890 }
891
892 lines
893}
894
895fn split_mixed_url_word(text: &str, word: MixedUrlWord, line_limit: usize) -> Vec<MixedUrlWord> {
896 if word.is_url || word.width(text) <= line_limit {
897 return vec![word];
898 }
899
900 let source = Word::from(&text[word.range.clone()]);
901 let mut offset = word.range.start;
902 let mut pieces = Vec::new();
903 for piece in source.break_apart(line_limit.max(1)) {
904 let end = offset + piece.word.len();
905 pieces.push(MixedUrlWord {
906 range: offset..end,
907 is_url: false,
908 });
909 offset = end;
910 }
911 pieces
912}
913
914fn flatten_line(line: &Line<'_>) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
915 let mut flat = String::new();
916 let mut span_bounds = Vec::new();
917 let mut acc = 0usize;
918 for span in &line.spans {
919 let text = span.content.as_ref();
920 let start = acc;
921 flat.push_str(text);
922 acc += text.len();
923 span_bounds.push((start..acc, span.style));
924 }
925 (flat, span_bounds)
926}
927
928#[derive(Debug)]
930enum LineInput<'a> {
931 Borrowed(&'a Line<'a>),
932 Owned(Line<'a>),
933}
934
935impl<'a> LineInput<'a> {
936 fn as_ref(&self) -> &Line<'a> {
937 match self {
938 LineInput::Borrowed(line) => line,
939 LineInput::Owned(line) => line,
940 }
941 }
942}
943
944trait IntoLineInput<'a> {
946 fn into_line_input(self) -> LineInput<'a>;
947}
948
949impl<'a> IntoLineInput<'a> for &'a Line<'a> {
950 fn into_line_input(self) -> LineInput<'a> {
951 LineInput::Borrowed(self)
952 }
953}
954
955impl<'a> IntoLineInput<'a> for &'a mut Line<'a> {
956 fn into_line_input(self) -> LineInput<'a> {
957 LineInput::Borrowed(self)
958 }
959}
960
961impl<'a> IntoLineInput<'a> for Line<'a> {
962 fn into_line_input(self) -> LineInput<'a> {
963 LineInput::Owned(self)
964 }
965}
966
967impl<'a> IntoLineInput<'a> for String {
968 fn into_line_input(self) -> LineInput<'a> {
969 LineInput::Owned(Line::from(self))
970 }
971}
972
973impl<'a> IntoLineInput<'a> for &'a str {
974 fn into_line_input(self) -> LineInput<'a> {
975 LineInput::Owned(Line::from(self))
976 }
977}
978
979impl<'a> IntoLineInput<'a> for Cow<'a, str> {
980 fn into_line_input(self) -> LineInput<'a> {
981 LineInput::Owned(Line::from(self))
982 }
983}
984
985impl<'a> IntoLineInput<'a> for Span<'a> {
986 fn into_line_input(self) -> LineInput<'a> {
987 LineInput::Owned(Line::from(self))
988 }
989}
990
991impl<'a> IntoLineInput<'a> for Vec<Span<'a>> {
992 fn into_line_input(self) -> LineInput<'a> {
993 LineInput::Owned(Line::from(self))
994 }
995}
996
997#[allow(private_bounds)] pub fn word_wrap_lines<'a, I, O, L>(lines: I, width_or_options: O) -> Vec<Line<'static>>
1001where
1002 I: IntoIterator<Item = L>,
1003 L: IntoLineInput<'a>,
1004 O: Into<RtOptions<'a>>,
1005{
1006 let base_opts: RtOptions<'a> = width_or_options.into();
1007 let mut out: Vec<Line<'static>> = Vec::new();
1008
1009 for (idx, line) in lines.into_iter().enumerate() {
1010 let line_input = line.into_line_input();
1011 let opts = if idx == 0 {
1012 base_opts.clone()
1013 } else {
1014 let mut o = base_opts.clone();
1015 let sub = o.subsequent_indent.clone();
1016 o = o.initial_indent(sub);
1017 o
1018 };
1019 let wrapped = word_wrap_line(line_input.as_ref(), opts);
1020 push_owned_lines(&wrapped, &mut out);
1021 }
1022
1023 out
1024}
1025
1026#[allow(dead_code)]
1027pub fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
1028where
1029 I: IntoIterator<Item = &'a Line<'a>>,
1030 O: Into<RtOptions<'a>>,
1031{
1032 let base_opts: RtOptions<'a> = width_or_options.into();
1033 let mut out: Vec<Line<'a>> = Vec::new();
1034 let mut first = true;
1035 for line in lines.into_iter() {
1036 let opts = if first {
1037 base_opts.clone()
1038 } else {
1039 base_opts
1040 .clone()
1041 .initial_indent(base_opts.subsequent_indent.clone())
1042 };
1043 out.extend(word_wrap_line(line, opts));
1044 first = false;
1045 }
1046 out
1047}
1048
1049fn slice_line_spans<'a>(
1050 original: &'a Line<'a>,
1051 span_bounds: &[(Range<usize>, ratatui::style::Style)],
1052 range: &Range<usize>,
1053) -> Line<'a> {
1054 let start_byte = range.start;
1055 let end_byte = range.end;
1056 let mut acc: Vec<Span<'a>> = Vec::new();
1057 for (i, (range, style)) in span_bounds.iter().enumerate() {
1058 let s = range.start;
1059 let e = range.end;
1060 if e <= start_byte {
1061 continue;
1062 }
1063 if s >= end_byte {
1064 break;
1065 }
1066 let seg_start = start_byte.max(s);
1067 let seg_end = end_byte.min(e);
1068 if seg_end > seg_start {
1069 let local_start = seg_start - s;
1070 let local_end = seg_end - s;
1071 let content = original.spans[i].content.as_ref();
1072 let slice = &content[local_start..local_end];
1073 acc.push(Span {
1074 style: *style,
1075 content: std::borrow::Cow::Borrowed(slice),
1076 });
1077 }
1078 if e >= end_byte {
1079 break;
1080 }
1081 }
1082 Line {
1083 style: original.style,
1084 alignment: original.alignment,
1085 spans: acc,
1086 }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092 use itertools::Itertools as _;
1093 use pretty_assertions::assert_eq;
1094 use ratatui::style::Color;
1095 use ratatui::style::Stylize;
1096 use std::string::ToString;
1097
1098 fn concat_line(line: &Line) -> String {
1099 line.spans
1100 .iter()
1101 .map(|s| s.content.as_ref())
1102 .collect::<String>()
1103 }
1104
1105 #[test]
1106 fn trivial_unstyled_no_indents_wide_width() {
1107 let line = Line::from("hello");
1108 let out = word_wrap_line(&line, 10);
1109 assert_eq!(out.len(), 1);
1110 assert_eq!(concat_line(&out[0]), "hello");
1111 }
1112
1113 #[test]
1114 fn simple_unstyled_wrap_narrow_width() {
1115 let line = Line::from("hello world");
1116 let out = word_wrap_line(&line, 5);
1117 assert_eq!(out.len(), 2);
1118 assert_eq!(concat_line(&out[0]), "hello");
1119 assert_eq!(concat_line(&out[1]), "world");
1120 }
1121
1122 #[test]
1123 fn simple_styled_wrap_preserves_styles() {
1124 let line = Line::from(vec!["hello ".red(), "world".into()]);
1125 let out = word_wrap_line(&line, 6);
1126 assert_eq!(out.len(), 2);
1127 assert_eq!(concat_line(&out[0]), "hello");
1129 assert_eq!(out[0].spans.len(), 1);
1130 assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
1131 assert_eq!(concat_line(&out[1]), "world");
1133 assert_eq!(out[1].spans.len(), 1);
1134 assert_eq!(out[1].spans[0].style.fg, None);
1135 }
1136
1137 #[test]
1138 fn with_initial_and_subsequent_indents() {
1139 let opts = RtOptions::new(8)
1140 .initial_indent(Line::from("- "))
1141 .subsequent_indent(Line::from(" "));
1142 let line = Line::from("hello world foo");
1143 let out = word_wrap_line(&line, opts);
1144 assert!(concat_line(&out[0]).starts_with("- "));
1146 assert!(concat_line(&out[1]).starts_with(" "));
1147 assert!(concat_line(&out[2]).starts_with(" "));
1148 assert_eq!(concat_line(&out[0]), "- hello");
1150 assert_eq!(concat_line(&out[1]), " world");
1151 assert_eq!(concat_line(&out[2]), " foo");
1152 }
1153
1154 #[test]
1155 fn empty_initial_indent_subsequent_spaces() {
1156 let opts = RtOptions::new(8)
1157 .initial_indent(Line::from(""))
1158 .subsequent_indent(Line::from(" "));
1159 let line = Line::from("hello world foobar");
1160 let out = word_wrap_line(&line, opts);
1161 assert!(concat_line(&out[0]).starts_with("hello"));
1162 for l in &out[1..] {
1163 assert!(concat_line(l).starts_with(" "));
1164 }
1165 }
1166
1167 #[test]
1168 fn empty_input_yields_single_empty_line() {
1169 let line = Line::from("");
1170 let out = word_wrap_line(&line, 10);
1171 assert_eq!(out.len(), 1);
1172 assert_eq!(concat_line(&out[0]), "");
1173 }
1174
1175 #[test]
1176 fn leading_spaces_preserved_on_first_line() {
1177 let line = Line::from(" hello");
1178 let out = word_wrap_line(&line, 8);
1179 assert_eq!(out.len(), 1);
1180 assert_eq!(concat_line(&out[0]), " hello");
1181 }
1182
1183 #[test]
1184 fn multiple_spaces_between_words_dont_start_next_line_with_spaces() {
1185 let line = Line::from("hello world");
1186 let out = word_wrap_line(&line, 8);
1187 assert_eq!(out.len(), 2);
1188 assert_eq!(concat_line(&out[0]), "hello");
1189 assert_eq!(concat_line(&out[1]), "world");
1190 }
1191
1192 #[test]
1193 fn break_words_false_allows_overflow_for_long_word() {
1194 let opts = RtOptions::new(5).break_words(false);
1195 let line = Line::from("supercalifragilistic");
1196 let out = word_wrap_line(&line, opts);
1197 assert_eq!(out.len(), 1);
1198 assert_eq!(concat_line(&out[0]), "supercalifragilistic");
1199 }
1200
1201 #[test]
1202 fn hyphen_splitter_breaks_at_hyphen() {
1203 let line = Line::from("hello-world");
1204 let out = word_wrap_line(&line, 7);
1205 assert_eq!(out.len(), 2);
1206 assert_eq!(concat_line(&out[0]), "hello-");
1207 assert_eq!(concat_line(&out[1]), "world");
1208 }
1209
1210 #[test]
1211 fn indent_consumes_width_leaving_one_char_space() {
1212 let opts = RtOptions::new(4)
1213 .initial_indent(Line::from(">>>>"))
1214 .subsequent_indent(Line::from("--"));
1215 let line = Line::from("hello");
1216 let out = word_wrap_line(&line, opts);
1217 assert_eq!(out.len(), 3);
1218 assert_eq!(concat_line(&out[0]), ">>>>h");
1219 assert_eq!(concat_line(&out[1]), "--el");
1220 assert_eq!(concat_line(&out[2]), "--lo");
1221 }
1222
1223 #[test]
1224 fn wide_unicode_wraps_by_display_width() {
1225 let line = Line::from("😀😀😀");
1226 let out = word_wrap_line(&line, 4);
1227 assert_eq!(out.len(), 2);
1228 assert_eq!(concat_line(&out[0]), "😀😀");
1229 assert_eq!(concat_line(&out[1]), "😀");
1230 }
1231
1232 #[test]
1233 fn styled_split_within_span_preserves_style() {
1234 use ratatui::style::Stylize;
1235 let line = Line::from(vec!["abcd".red()]);
1236 let out = word_wrap_line(&line, 2);
1237 assert_eq!(out.len(), 2);
1238 assert_eq!(out[0].spans.len(), 1);
1239 assert_eq!(out[1].spans.len(), 1);
1240 assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
1241 assert_eq!(out[1].spans[0].style.fg, Some(Color::Red));
1242 assert_eq!(concat_line(&out[0]), "ab");
1243 assert_eq!(concat_line(&out[1]), "cd");
1244 }
1245
1246 #[test]
1247 fn wrap_lines_applies_initial_indent_only_once() {
1248 let opts = RtOptions::new(8)
1249 .initial_indent(Line::from("- "))
1250 .subsequent_indent(Line::from(" "));
1251
1252 let lines = vec![Line::from("hello world"), Line::from("foo bar baz")];
1253 let out = word_wrap_lines(lines, opts);
1254
1255 let rendered: Vec<String> = out.iter().map(concat_line).collect();
1258 assert!(rendered[0].starts_with("- "));
1259 for r in rendered.iter().skip(1) {
1260 assert!(r.starts_with(" "));
1261 }
1262 }
1263
1264 #[test]
1265 fn wrap_lines_without_indents_is_concat_of_single_wraps() {
1266 let lines = vec![Line::from("hello"), Line::from("world!")];
1267 let out = word_wrap_lines(lines, 10);
1268 let rendered: Vec<String> = out.iter().map(concat_line).collect();
1269 assert_eq!(rendered, vec!["hello", "world!"]);
1270 }
1271
1272 #[test]
1273 fn wrap_lines_borrowed_applies_initial_indent_only_once() {
1274 let opts = RtOptions::new(8)
1275 .initial_indent(Line::from("- "))
1276 .subsequent_indent(Line::from(" "));
1277
1278 let lines = [Line::from("hello world"), Line::from("foo bar baz")];
1279 let out = word_wrap_lines_borrowed(lines.iter(), opts);
1280
1281 let rendered: Vec<String> = out.iter().map(concat_line).collect();
1282 assert!(rendered.first().unwrap().starts_with("- "));
1283 for r in rendered.iter().skip(1) {
1284 assert!(r.starts_with(" "));
1285 }
1286 }
1287
1288 #[test]
1289 fn wrap_lines_borrowed_without_indents_is_concat_of_single_wraps() {
1290 let lines = [Line::from("hello"), Line::from("world!")];
1291 let out = word_wrap_lines_borrowed(lines.iter(), 10);
1292 let rendered: Vec<String> = out.iter().map(concat_line).collect();
1293 assert_eq!(rendered, vec!["hello", "world!"]);
1294 }
1295
1296 #[test]
1297 fn wrap_lines_accepts_borrowed_iterators() {
1298 let lines = [Line::from("hello world"), Line::from("foo bar baz")];
1299 let out = word_wrap_lines(lines, 10);
1300 let rendered: Vec<String> = out.iter().map(concat_line).collect();
1301 assert_eq!(rendered, vec!["hello", "world", "foo bar", "baz"]);
1302 }
1303
1304 #[test]
1305 fn wrap_lines_accepts_str_slices() {
1306 let lines = ["hello world", "goodnight moon"];
1307 let out = word_wrap_lines(lines, 12);
1308 let rendered: Vec<String> = out.iter().map(concat_line).collect();
1309 assert_eq!(rendered, vec!["hello world", "goodnight", "moon"]);
1310 }
1311
1312 #[test]
1313 fn line_height_counts_double_width_emoji() {
1314 let line = "😀😀😀".into(); assert_eq!(word_wrap_line(&line, 4).len(), 2);
1316 assert_eq!(word_wrap_line(&line, 2).len(), 3);
1317 assert_eq!(word_wrap_line(&line, 6).len(), 1);
1318 }
1319
1320 #[test]
1321 fn word_wrap_does_not_split_words_simple_english() {
1322 let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
1323 let line = Line::from(sample);
1324 let lines = [line];
1325 let wrapped = word_wrap_lines_borrowed(&lines, 40);
1327 let joined: String = wrapped.iter().map(ToString::to_string).join("\n");
1328 assert_eq!(
1329 joined,
1330 r#"Years passed, and Willowmere thrived in
1331peace and friendship. Mira’s herb garden
1332flourished with both ordinary and
1333enchanted plants, and travelers spoke of
1334the kindness of the woman who tended
1335them."#
1336 );
1337 }
1338
1339 #[test]
1340 fn ascii_space_separator_with_no_hyphenation_keeps_url_intact() {
1341 let line = Line::from(
1342 "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text",
1343 );
1344 let opts = RtOptions::new(24)
1345 .word_separator(textwrap::WordSeparator::AsciiSpace)
1346 .word_splitter(textwrap::WordSplitter::NoHyphenation)
1347 .break_words(false);
1348
1349 let out = word_wrap_line(&line, opts);
1350
1351 assert_eq!(out.len(), 1);
1352 assert_eq!(
1353 concat_line(&out[0]),
1354 "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text"
1355 );
1356 }
1357
1358 #[test]
1359 fn text_contains_url_like_matches_expected_tokens() {
1360 let positives = [
1361 "https://example.com/a/b",
1362 "ftp://host/path",
1363 "www.example.com/path?x=1",
1364 "example.test/path#frag",
1365 "localhost:3000/api",
1366 "127.0.0.1:8080/health",
1367 "(https://example.com/wrapped-in-parens)",
1368 ];
1369
1370 for text in positives {
1371 assert!(
1372 text_contains_url_like(text),
1373 "expected URL-like match for {text:?}"
1374 );
1375 }
1376 }
1377
1378 #[test]
1379 fn text_contains_url_like_rejects_non_urls() {
1380 let negatives = [
1381 "src/main.rs",
1382 "foo/bar",
1383 "key:value",
1384 "just-some-text-with-dashes",
1385 "hello.world", ];
1387
1388 for text in negatives {
1389 assert!(
1390 !text_contains_url_like(text),
1391 "did not expect URL-like match for {text:?}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn line_contains_url_like_checks_across_spans() {
1398 let line = Line::from(vec![
1399 "see ".into(),
1400 "https://example.com/a/very/long/path".cyan(),
1401 " for details".into(),
1402 ]);
1403
1404 assert!(line_contains_url_like(&line));
1405 }
1406
1407 #[test]
1408 fn line_has_mixed_url_and_non_url_tokens_detects_prose_plus_url() {
1409 let line = Line::from("see https://example.com/path for details");
1410 assert!(line_has_mixed_url_and_non_url_tokens(&line));
1411 }
1412
1413 #[test]
1414 fn line_has_mixed_url_and_non_url_tokens_ignores_pipe_prefix() {
1415 let line = Line::from(vec![" │ ".into(), "https://example.com/path".into()]);
1416 assert!(!line_has_mixed_url_and_non_url_tokens(&line));
1417 }
1418
1419 #[test]
1420 fn line_has_mixed_url_and_non_url_tokens_ignores_ordered_list_marker() {
1421 let line = Line::from("1. https://example.com/path");
1422 assert!(!line_has_mixed_url_and_non_url_tokens(&line));
1423 }
1424
1425 #[test]
1426 fn text_contains_url_like_accepts_custom_scheme_with_separator() {
1427 assert!(text_contains_url_like("myapp://open/some/path"));
1428 }
1429
1430 #[test]
1431 fn text_contains_url_like_rejects_invalid_ports() {
1432 assert!(!text_contains_url_like("localhost:99999/path"));
1433 assert!(!text_contains_url_like("example.com:abc/path"));
1434 }
1435
1436 #[test]
1437 fn adaptive_wrap_line_keeps_long_url_like_token_intact() {
1438 let line = Line::from("example.test/a-very-long-path-with-many-segments-and-query?x=1&y=2");
1439 let out = adaptive_wrap_line(&line, RtOptions::new(20));
1440 assert_eq!(out.len(), 1);
1441 assert_eq!(
1442 concat_line(&out[0]),
1443 "example.test/a-very-long-path-with-many-segments-and-query?x=1&y=2"
1444 );
1445 }
1446
1447 #[test]
1448 fn adaptive_wrap_line_preserves_default_behavior_for_non_url_tokens() {
1449 let line = Line::from("a_very_long_token_without_spaces_to_force_wrapping");
1450 let out = adaptive_wrap_line(&line, RtOptions::new(20));
1451 assert!(
1452 out.len() > 1,
1453 "expected non-url token to wrap with default options"
1454 );
1455 }
1456
1457 #[test]
1458 fn adaptive_wrap_line_mixed_line_keeps_regular_words_intact() {
1459 let line = Line::from(
1460 "see https://example.com/path and keep strikethrough intact while wrapping prose",
1461 );
1462 let out = adaptive_wrap_line(&line, RtOptions::new(36));
1463 let joined = out.iter().map(concat_line).join("\n");
1464
1465 assert_eq!(
1466 joined,
1467 "see https://example.com/path and\nkeep strikethrough intact while\nwrapping prose"
1468 );
1469 }
1470
1471 #[test]
1472 fn adaptive_wrap_line_mixed_line_wraps_long_non_url_token() {
1473 let long_non_url = "a_very_long_token_without_spaces_to_force_wrapping";
1474 let line = Line::from(format!("see https://ex.com {long_non_url}"));
1475 let out = adaptive_wrap_line(&line, RtOptions::new(24));
1476
1477 assert!(
1478 out.iter()
1479 .any(|line| concat_line(line).contains("https://ex.com")),
1480 "expected URL token to remain present, got: {out:?}"
1481 );
1482 assert!(
1483 !out.iter()
1484 .any(|line| concat_line(line).contains(long_non_url)),
1485 "expected long non-url token to wrap on mixed lines, got: {out:?}"
1486 );
1487 }
1488
1489 #[test]
1490 fn adaptive_wrap_line_mixed_line_counts_leading_spaces_before_first_word() {
1491 let line = Line::from(" abcdefgh https://x.co");
1492 let out = adaptive_wrap_line(
1493 &line,
1494 RtOptions::new(10).subsequent_indent(" ".into()),
1495 );
1496 let rendered = out.iter().map(concat_line).collect_vec();
1497
1498 assert_eq!(
1499 rendered[..2],
1500 [" abcd".to_string(), " efgh".to_string()]
1501 );
1502 }
1503
1504 #[test]
1505 fn adaptive_wrap_line_mixed_line_resplits_long_token_for_continuation_width() {
1506 let line = Line::from("abcdefghijklmnopqrst https://x.co");
1507 let out = adaptive_wrap_line(
1508 &line,
1509 RtOptions::new(10).subsequent_indent(" ".into()),
1510 );
1511 let rendered = out.iter().map(concat_line).collect_vec();
1512
1513 assert_eq!(
1514 rendered[..3],
1515 [
1516 "abcdefghij".to_string(),
1517 " klmnop".to_string(),
1518 " qrst".to_string(),
1519 ]
1520 );
1521 }
1522
1523 #[test]
1524 fn map_owned_wrapped_line_to_range_recovers_on_non_prefix_mismatch() {
1525 let range = map_owned_wrapped_line_to_range("hello world", 0, "helloX", "");
1528 assert_eq!(range, 0..5);
1529 }
1530
1531 #[test]
1532 fn borrowed_slice_range_rejects_slices_outside_source_text() {
1533 let text = "test message";
1534 let external = String::from("test");
1535
1536 assert_eq!(borrowed_slice_range(text, &external), None);
1537
1538 let fallback = map_owned_wrapped_line_to_range(text, 0, &external, "");
1539 assert_eq!(fallback, 0..4);
1540 }
1541
1542 #[test]
1543 fn map_owned_wrapped_line_to_range_indent_coincides_with_source() {
1544 let text = "- item one and some more words";
1552 let range = map_owned_wrapped_line_to_range(text, 0, "- - item one", "- ");
1555 assert_eq!(range, 0..10);
1558 }
1559
1560 #[test]
1561 fn wrap_ranges_indent_prefix_coincides_with_source_char() {
1562 let text = "- first item is long enough to wrap around";
1565 let opts = || {
1566 textwrap::Options::new(16)
1567 .initial_indent("- ")
1568 .subsequent_indent("- ")
1569 };
1570 let ranges = wrap_ranges(text, opts());
1571 assert!(!ranges.is_empty());
1572
1573 let mut rebuilt = String::new();
1574 let mut cursor = 0usize;
1575 for range in ranges {
1576 let start = range.start.max(cursor).min(text.len());
1577 let end = range.end.min(text.len());
1578 if start < end {
1579 rebuilt.push_str(&text[start..end]);
1580 }
1581 cursor = cursor.max(end);
1582 }
1583 assert_eq!(rebuilt, text);
1584 }
1585
1586 #[test]
1587 fn map_owned_wrapped_line_to_range_repro_overconsumes_repeated_prefix_patterns() {
1588 let text = "- - foo";
1589 let opts = textwrap::Options::new(3)
1590 .initial_indent("- ")
1591 .subsequent_indent("- ")
1592 .word_separator(textwrap::WordSeparator::AsciiSpace)
1593 .break_words(false);
1594 let wrapped = textwrap::wrap(text, opts);
1595 let Some(line) = wrapped.first() else {
1596 panic!("expected at least one wrapped line");
1597 };
1598
1599 let mapped = map_owned_wrapped_line_to_range(text, 0, line.as_ref(), "- ");
1600 let expected_len = line
1601 .as_ref()
1602 .strip_prefix("- ")
1603 .unwrap_or(line.as_ref())
1604 .len();
1605 let mapped_len = mapped.end.saturating_sub(mapped.start);
1606 assert!(
1607 mapped_len <= expected_len,
1608 "overconsumed source: text={text:?} line={line:?} mapped={mapped:?} expected_len={expected_len}"
1609 );
1610 }
1611
1612 #[test]
1613 fn wrap_ranges_recovers_with_non_space_indents() {
1614 let text = "The quick brown fox jumps over the lazy dog";
1615 let wrapped = textwrap::wrap(
1616 text,
1617 textwrap::Options::new(12)
1618 .initial_indent("* ")
1619 .subsequent_indent(" "),
1620 );
1621 assert!(
1622 wrapped
1623 .iter()
1624 .any(|line| matches!(line, std::borrow::Cow::Owned(_))),
1625 "expected textwrap to produce owned lines with synthetic indent prefixes"
1626 );
1627
1628 let ranges = wrap_ranges(
1629 text,
1630 textwrap::Options::new(12)
1631 .initial_indent("* ")
1632 .subsequent_indent(" "),
1633 );
1634 assert!(!ranges.is_empty());
1635
1636 let mut rebuilt = String::new();
1639 let mut cursor = 0usize;
1640 for range in ranges {
1641 let start = range.start.max(cursor).min(text.len());
1642 let end = range.end.min(text.len());
1643 if start < end {
1644 rebuilt.push_str(&text[start..end]);
1645 }
1646 cursor = cursor.max(end);
1647 }
1648
1649 assert_eq!(rebuilt, text);
1650 }
1651
1652 #[test]
1653 fn wrap_ranges_trim_handles_owned_lines_with_penalty_char() {
1654 fn split_every_char(word: &str) -> Vec<usize> {
1655 word.char_indices().skip(1).map(|(idx, _)| idx).collect()
1656 }
1657
1658 let text = "a_very_long_token_without_spaces";
1659 let opts = Options::new(8)
1660 .word_separator(textwrap::WordSeparator::AsciiSpace)
1661 .word_splitter(textwrap::WordSplitter::Custom(split_every_char))
1662 .break_words(false);
1663
1664 let ranges = wrap_ranges_trim(text, opts);
1665 let rebuilt = ranges
1666 .iter()
1667 .map(|range| &text[range.clone()])
1668 .collect::<String>();
1669
1670 assert_eq!(rebuilt, text);
1671 assert!(ranges.len() > 1, "expected wrapped ranges, got: {ranges:?}");
1672 }
1673}