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