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
11pub 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 if !current_line.is_empty() {
34 lines.push(current_line.join(" "));
35 current_line.clear();
36 }
37 if word.starts_with('@') && word.contains('(') {
39 current_line.push(word);
40 continue;
41 }
42 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(¤t_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
72pub 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(¤t_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: ®ex::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 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}