Skip to main content

doing_template/
wrap.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::colors;
6
7static TAG_VALUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"@\S+\(.*?\)").unwrap());
8
9const TAG_VALUE_SENTINEL: &str = "\u{E001}";
10
11/// Wrap text at word boundaries, respecting tag values.
12///
13/// Tag values like `@tag(value with spaces)` are treated as single units
14/// and will not be broken across lines. Width is measured by visible
15/// characters (ANSI escapes are excluded from the count).
16///
17/// Returns the original text unchanged if `width` is 0.
18pub fn wrap(text: &str, width: usize) -> String {
19  if width == 0 || text.is_empty() {
20    return text.to_string();
21  }
22
23  let words = protected_words(text);
24
25  let mut lines: Vec<String> = Vec::new();
26  let mut current_line: Vec<String> = Vec::new();
27
28  for word in words {
29    let word_len = colors::visible_len(&word);
30
31    if word_len >= width {
32      // Flush current line
33      if !current_line.is_empty() {
34        lines.push(current_line.join(" "));
35        current_line.clear();
36      }
37      // Keep tag values atomic (don't break @tag(value) across lines)
38      if word.starts_with('@') && word.contains('(') {
39        current_line.push(word);
40        continue;
41      }
42      // Break other long words into chunks
43      let visible: String = colors::strip_ansi(&word);
44      let mut chars = visible.chars().peekable();
45      while chars.peek().is_some() {
46        let chunk: String = chars.by_ref().take(width).collect();
47        if chars.peek().is_some() {
48          lines.push(chunk);
49        } else {
50          current_line.push(chunk);
51        }
52      }
53      continue;
54    }
55
56    let current_len = colors::visible_len(&current_line.join(" "));
57    if !current_line.is_empty() && current_len + word_len + 1 > width {
58      lines.push(current_line.join(" "));
59      current_line.clear();
60    }
61
62    current_line.push(word);
63  }
64
65  if !current_line.is_empty() {
66    lines.push(current_line.join(" "));
67  }
68
69  lines.join("\n")
70}
71
72/// Wrap text with indentation applied to continuation lines.
73///
74/// The first line wraps at `width`. Subsequent lines are prefixed with
75/// `indent` characters of whitespace and wrap at `width - indent` to
76/// stay within the total width.
77///
78/// Returns the original text unchanged if `width` is 0.
79pub fn wrap_with_indent(text: &str, width: usize, indent: usize) -> String {
80  if width == 0 || text.is_empty() {
81    return text.to_string();
82  }
83
84  let continuation_width = width.saturating_sub(indent);
85  if continuation_width == 0 {
86    return wrap(text, width);
87  }
88
89  let words = protected_words(text);
90
91  let indent_str: String = " ".repeat(indent);
92  let mut lines: Vec<String> = Vec::new();
93  let mut current_line: Vec<String> = Vec::new();
94  let mut is_first_line = true;
95
96  for word in words {
97    let word_len = colors::visible_len(&word);
98    let effective_width = if is_first_line { width } else { continuation_width };
99
100    let current_len = colors::visible_len(&current_line.join(" "));
101    if !current_line.is_empty() && current_len + word_len + 1 > effective_width {
102      let line = current_line.join(" ");
103      if is_first_line {
104        lines.push(line);
105        is_first_line = false;
106      } else {
107        lines.push(format!("{indent_str}{line}"));
108      }
109      current_line.clear();
110    }
111
112    current_line.push(word);
113  }
114
115  if !current_line.is_empty() {
116    let line = current_line.join(" ");
117    if is_first_line {
118      lines.push(line);
119    } else {
120      lines.push(format!("{indent_str}{line}"));
121    }
122  }
123
124  lines.join("\n")
125}
126
127fn protected_words(text: &str) -> Vec<String> {
128  let protected = TAG_VALUE_RE.replace_all(text, |caps: &regex::Captures| caps[0].replace(' ', TAG_VALUE_SENTINEL));
129  let normalized = protected.replace('\n', " ");
130  normalized
131    .split(' ')
132    .map(|w| w.replace(TAG_VALUE_SENTINEL, " "))
133    .collect()
134}
135
136#[cfg(test)]
137mod test {
138  mod wrap {
139    use pretty_assertions::assert_eq;
140
141    use super::super::wrap;
142
143    #[test]
144    fn it_breaks_long_words() {
145      let result = wrap("abcdefghij", 4);
146
147      assert_eq!(result, "abcd\nefgh\nij");
148    }
149
150    #[test]
151    fn it_preserves_tag_values() {
152      let result = wrap("hello @tag(value with spaces) world", 20);
153
154      assert_eq!(result, "hello\n@tag(value with spaces)\nworld");
155    }
156
157    #[test]
158    fn it_returns_empty_for_empty_input() {
159      assert_eq!(wrap("", 40), "");
160    }
161
162    #[test]
163    fn it_returns_unchanged_when_width_is_zero() {
164      assert_eq!(wrap("hello world", 0), "hello world");
165    }
166
167    #[test]
168    fn it_returns_unchanged_when_within_width() {
169      assert_eq!(wrap("hello world", 40), "hello world");
170    }
171
172    #[test]
173    fn it_handles_control_characters_in_input() {
174      // Entries containing old sentinel characters (\x02) should
175      // wrap correctly now that we use PUA codepoints.
176      let result = wrap("hello \x02 world", 40);
177
178      assert_eq!(result, "hello \x02 world");
179    }
180
181    #[test]
182    fn it_wraps_at_word_boundaries() {
183      let result = wrap("the quick brown fox jumps over", 16);
184
185      assert_eq!(result, "the quick brown\nfox jumps over");
186    }
187
188    #[test]
189    fn it_wraps_multiple_lines() {
190      let result = wrap("one two three four five six", 10);
191
192      assert_eq!(result, "one two\nthree four\nfive six");
193    }
194  }
195
196  mod wrap_with_indent {
197    use pretty_assertions::assert_eq;
198
199    use super::super::wrap_with_indent;
200
201    #[test]
202    fn it_does_not_indent_first_line() {
203      let result = wrap_with_indent("hello world foo bar", 12, 4);
204
205      assert_eq!(result, "hello world\n    foo bar");
206    }
207
208    #[test]
209    fn it_indents_continuation_lines() {
210      let result = wrap_with_indent("one two three four", 10, 2);
211
212      assert_eq!(result, "one two\n  three\n  four");
213    }
214
215    #[test]
216    fn it_returns_unchanged_when_width_is_zero() {
217      assert_eq!(wrap_with_indent("hello world", 0, 4), "hello world");
218    }
219  }
220}