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 protected = TAG_VALUE_RE.replace_all(text, |caps: ®ex::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 if !current_line.is_empty() {
41 lines.push(current_line.join(" "));
42 current_line.clear();
43 }
44 if word.starts_with('@') && word.contains('(') {
46 current_line.push(word);
47 continue;
48 }
49 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(¤t_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
79pub 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: ®ex::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(¤t_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 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}