Skip to main content

supercode_frontend_tui/foundation/
wrapping.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/wrapping.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Word-wrapping with URL-aware heuristics.
8//!
9//! The TUI renders text that frequently contains URLs — command output,
10//! markdown, agent messages, tool-call results. Standard `textwrap`
11//! hyphenation treats `/` and `-` as split points, which breaks URLs
12//! across lines and makes them unclickable in terminal emulators.
13//!
14//! This module provides two wrapping paths:
15//!
16//! - **Standard** (`word_wrap_line`, `word_wrap_lines`): delegates to
17//!   `textwrap` with the caller's options unchanged. Used when the
18//!   content is known to be plain prose.
19//! - **Adaptive** (`adaptive_wrap_line`, `adaptive_wrap_lines`):
20//!   inspects the line for URL-like tokens; if any are found, the
21//!   wrapping keeps URL tokens intact. Mixed URL/prose lines still wrap
22//!   ordinary prose at word boundaries, only splitting a non-URL token
23//!   when that token is itself wider than the available row width.
24//!
25//! Callers that *might* encounter URLs should use the `adaptive_*`
26//! functions. Callers that definitely will not (code blocks, pure
27//! numeric output) can use the standard path for speed.
28//!
29//! URL detection is heuristic — see [`text_contains_url_like`] for the
30//! rules. False positives suppress hyphenation for that line; false
31//! negatives let a URL get split. The heuristic is intentionally
32//! conservative: file paths like `src/main.rs` are not matched.
33
34use 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
57/// Returns byte-ranges into `text` for each wrapped line, including
58/// trailing whitespace and a +1 sentinel byte. Used by the textarea
59/// cursor-position logic.
60pub 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
100/// Like `wrap_ranges` but returns ranges without trailing whitespace and
101/// without the sentinel extra byte. Suitable for general wrapping where
102/// trailing spaces should not be preserved.
103pub 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
152/// Maps an owned (materialized) wrapped line back to a byte range in `text`.
153///
154/// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty
155/// character (typically `-`) that does not exist in the source. This
156/// function walks the owned string character-by-character against the
157/// source, skipping trailing penalty chars, and returns the
158/// corresponding source byte range starting from `cursor`.
159fn 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        // textwrap can materialize owned lines when penalties are inserted.
198        // The default penalty is a trailing '-'; it does not correspond to
199        // source bytes, so we skip it while keeping byte ranges in source text.
200        if ch == '-' && chars.peek().is_none() {
201            continue;
202        }
203
204        // Non-source chars can be synthesized by textwrap in owned output
205        // (e.g. non-space indent prefixes). Keep going and map the source bytes
206        // we can confidently match instead of crashing the app.
207        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
223/// Returns `true` if any whitespace-delimited token in `line` looks like a URL.
224///
225/// Concatenates all span contents and delegates to [`text_contains_url_like`].
226pub 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
235/// Returns `true` if `line` contains both a URL-like token and at least one
236/// substantive non-URL token.
237///
238/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
239/// `│`) are ignored for the non-URL side of this check.
240pub 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
249/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
250///
251/// Recognized patterns:
252/// - Absolute URLs with a scheme (`https://…`, `ftp://…`, custom `myapp://…`).
253/// - Bare domain URLs (`example.com/path`, `www.example.com`, `localhost:3000/api`).
254/// - IPv4 hosts with a path (`192.168.1.1:8080/health`).
255///
256/// Surrounding punctuation (`()[]{}< >,.;:!'"`) is stripped before
257/// checking. Tokens that look like file paths (`src/main.rs`, `foo/bar`)
258/// are intentionally rejected — the host portion must be a valid domain
259/// name (with a recognized TLD), an IPv4 address, or `localhost`.
260pub fn text_contains_url_like(text: &str) -> bool {
261    text.split_ascii_whitespace().any(is_url_like_token)
262}
263
264/// Returns `true` if `text` contains at least one URL-like token and at least
265/// one substantive non-URL token.
266fn 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
285/// Decides whether a single whitespace-delimited token is URL-like.
286///
287/// Strips surrounding punctuation, then checks for an absolute URL
288/// (with `://`) or a bare domain URL (recognized host + path/query/fragment).
289fn 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
352/// Checks for `scheme://host` patterns. Uses `url::Url::parse` for
353/// well-known schemes; falls back to `has_valid_scheme_prefix` for
354/// custom schemes that the `url` crate rejects.
355fn 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
390/// Checks for bare-domain URLs without a scheme: `host[:port]/path`,
391/// `host[:port]?query`, or `host[:port]#fragment`.
392///
393/// Requires that the host is `localhost`, an IPv4 address, or a valid
394/// domain name. Bare `host.tld` without a path/query/fragment is only
395/// accepted when the host starts with `www.`.
396///
397/// IPv6 bracket notation (`[::1]:8080`) is intentionally not handled.
398fn 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    // Require URL-ish trailer for bare hosts unless token starts with www.
405    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    // We intentionally do not treat bracketed IPv6 as URL-like in this first pass.
432    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
503/// Reconfigures wrapping options so that URL-like tokens are never split.
504///
505/// Sets `AsciiSpace` word separation (so `/` and `-` inside URLs are
506/// not treated as break points), disables `break_words`, and prevents
507/// per-word hyphenation. Mixed URL/prose lines use a dedicated wrapper
508/// so normal prose can still wrap cleanly around the preserved URL token.
509pub 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(/*break_words*/ false)
513}
514
515/// Wraps a single ratatui `Line`, automatically switching to
516/// URL-preserving options when the line contains a URL-like token.
517///
518/// When no URL is detected, wrapping behavior is identical to
519/// [`word_wrap_line`]. URL-only lines use [`url_preserving_wrap_options`]
520/// so terminal link detection keeps seeing one intact token. Mixed URL/prose
521/// lines use a token-aware wrapper so ordinary prose still moves as whole words
522/// while a genuinely overlong non-URL token can still split if needed.
523#[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/// Wraps multiple input lines with URL-aware heuristics, applying
537/// `initial_indent` to the first line and `subsequent_indent` to the
538/// rest. Each line is independently checked for URLs; URL detection on
539/// one line does not affect wrapping of the others.
540///
541/// This is the multi-line counterpart to [`adaptive_wrap_line`] and is
542/// the primary wrapping entry point for most history-cell rendering.
543#[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    /// The width in columns at which the text will be wrapped.
575    pub width: usize,
576    /// Line ending used for breaking lines.
577    pub line_ending: textwrap::LineEnding,
578    /// Indentation used for the first line of output. See the
579    /// [`Options::initial_indent`] method.
580    pub initial_indent: Line<'a>,
581    /// Indentation used for subsequent lines of output. See the
582    /// [`Options::subsequent_indent`] method.
583    pub subsequent_indent: Line<'a>,
584    /// Allow long words to be broken if they cannot fit on a line.
585    /// When set to `false`, some lines may be longer than
586    /// `self.width`. See the [`Options::break_words`] method.
587    pub break_words: bool,
588    /// Wrapping algorithm to use, see the implementations of the
589    /// [`WrapAlgorithm`] trait for details.
590    pub wrap_algorithm: textwrap::WrapAlgorithm,
591    /// The line breaking algorithm to use, see the [`WordSeparator`]
592    /// trait for an overview and possible implementations.
593    pub word_separator: textwrap::WordSeparator,
594    /// The method for splitting words. This can be used to prohibit
595    /// splitting words on hyphens, or it can be used to implement
596    /// language-aware machine hyphenation.
597    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    // Compute first line range with reduced width due to initial indent.
692    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    // Build first wrapped line with initial indent.
702    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    // Wrap the remainder using subsequent indent width and map back to original indices.
718    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/// Utilities to allow wrapping either borrowed or owned lines.
929#[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
944/// This trait makes it easier to pass whatever we need into word_wrap_lines.
945trait 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/// Wrap a sequence of lines, applying the initial indent only to the very first
998/// output line, and using the subsequent indent for all later wrapped pieces.
999#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be.
1000pub 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, /*width_or_options*/ 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, /*width_or_options*/ 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, /*width_or_options*/ 6);
1126        assert_eq!(out.len(), 2);
1127        // First line should carry the red style
1128        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        // Second line is unstyled
1132        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(/*width*/ 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        // Expect three lines with proper prefixes
1145        assert!(concat_line(&out[0]).starts_with("- "));
1146        assert!(concat_line(&out[1]).starts_with("  "));
1147        assert!(concat_line(&out[2]).starts_with("  "));
1148        // And content roughly segmented
1149        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(/*width*/ 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, /*width_or_options*/ 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, /*width_or_options*/ 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, /*width_or_options*/ 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(/*width*/ 5).break_words(/*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, /*width_or_options*/ 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(/*width*/ 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, /*width_or_options*/ 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, /*width_or_options*/ 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(/*width*/ 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        // Expect: first line prefixed with "- ", subsequent wrapped pieces with "  "
1256        // and for the second input line, there should be no "- " prefix on its first piece
1257        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, /*width_or_options*/ 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(/*width*/ 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(), /*width_or_options*/ 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, /*width_or_options*/ 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, /*width_or_options*/ 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(); // each emoji ~ width 2
1315        assert_eq!(word_wrap_line(&line, /*width_or_options*/ 4).len(), 2);
1316        assert_eq!(word_wrap_line(&line, /*width_or_options*/ 2).len(), 3);
1317        assert_eq!(word_wrap_line(&line, /*width_or_options*/ 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        // Force small width to exercise wrapping at spaces.
1326        let wrapped = word_wrap_lines_borrowed(&lines, /*width_or_options*/ 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(/*width*/ 24)
1345            .word_separator(textwrap::WordSeparator::AsciiSpace)
1346            .word_splitter(textwrap::WordSplitter::NoHyphenation)
1347            .break_words(/*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", // no path/query/fragment and no www
1386        ];
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(/*width*/ 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(/*width*/ 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(/*width*/ 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(/*width*/ 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(/*width*/ 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(/*width*/ 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        // Match source chars first, then introduce a non-penalty mismatch.
1526        // The function should recover and return the mapped prefix range.
1527        let range = map_owned_wrapped_line_to_range("hello world", /*cursor*/ 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, /*cursor*/ 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        // When the synthetic indent prefix starts with a character that also
1545        // appears at the current source position, the mapper must not confuse
1546        // the indent char for a source match.  Here the indent is "- " and the
1547        // source text also starts with "-", so a naive char-by-char match would
1548        // consume the source "-" for the indent "-", set saw_source_char too
1549        // early, then break on the space — returning 0..1 instead of the full
1550        // first word.
1551        let text = "- item one and some more words";
1552        // Simulate what textwrap would produce for the first continuation line
1553        // when subsequent_indent = "- ": it prepends "- " to the source slice.
1554        let range = map_owned_wrapped_line_to_range(text, /*cursor*/ 0, "- - item one", "- ");
1555        // The mapper should skip the synthetic "- " prefix and map "- item one"
1556        // back to source bytes 0..10.
1557        assert_eq!(range, 0..10);
1558    }
1559
1560    #[test]
1561    fn wrap_ranges_indent_prefix_coincides_with_source_char() {
1562        // End-to-end: source text starts with the same character as the indent
1563        // prefix.  wrap_ranges must still reconstruct the full source.
1564        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, /*cursor*/ 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        // wrap_ranges returns cursor-oriented ranges that may overlap by one byte;
1637        // rebuild with cursor progression to validate full source coverage.
1638        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}