textwrap/word_separators.rs
1//! Functionality for finding words.
2//!
3//! In order to wrap text, we need to know where the legal break
4//! points are, i.e., where the words of the text are. This means that
5//! we need to define what a "word" is.
6//!
7//! A simple approach is to simply split the text on whitespace, but
8//! this does not work for East Asian languages such as Chinese or
9//! Japanese where there are no spaces between words. Breaking a long
10//! sequence of emojis is another example where line breaks might be
11//! wanted even if there is no whitespace to be found.
12//!
13//! The [`WordSeparator`] enum is responsible for determining where
14//! the words are in a line of text. Please refer to the enum and
15//! its variants for more information.
16
17use alloc::boxed::Box;
18#[cfg(any(feature = "unicode-linebreak", test))]
19use alloc::vec::Vec;
20
21use crate::core::Word;
22#[cfg(feature = "unicode-linebreak")]
23use crate::core::skip_ansi_escape_sequence;
24
25#[cfg(feature = "unicode-linebreak")]
26thread_local! {
27 static LINE_SEGMENTER: icu_segmenter::LineSegmenterBorrowed<'static> = {
28 use icu_segmenter::options::LineBreakOptions;
29 use icu_segmenter::LineSegmenter;
30
31 let options = LineBreakOptions::default();
32 #[cfg(feature = "unicode-linebreak-complex")]
33 {
34 LineSegmenter::new_auto(options)
35 }
36 #[cfg(not(feature = "unicode-linebreak-complex"))]
37 {
38 LineSegmenter::new_for_non_complex_scripts(options)
39 }
40 };
41}
42
43/// Describes where words occur in a line of text.
44///
45/// The simplest approach is to say that words are separated by one or
46/// more ASCII spaces (`' '`). This works for Western languages
47/// without emojis. A more complex approach is to use the Unicode line
48/// breaking algorithm, which finds break points in non-ASCII text.
49///
50/// The line breaks occur between words, please see
51/// [`WordSplitter`](crate::WordSplitter) for options of how to handle
52/// hyphenation of individual words.
53///
54/// # Examples
55///
56/// ```
57/// use textwrap::core::Word;
58/// use textwrap::WordSeparator::AsciiSpace;
59///
60/// let words = AsciiSpace.find_words("Hello World!").collect::<Vec<_>>();
61/// assert_eq!(words, vec![Word::from("Hello "), Word::from("World!")]);
62/// ```
63#[derive(Debug, Clone, Copy)]
64pub enum WordSeparator {
65 /// Find words by splitting on runs of `' '` characters.
66 ///
67 /// # Examples
68 ///
69 /// ```
70 /// use textwrap::core::Word;
71 /// use textwrap::WordSeparator::AsciiSpace;
72 ///
73 /// let words = AsciiSpace.find_words("Hello World!").collect::<Vec<_>>();
74 /// assert_eq!(words, vec![Word::from("Hello "),
75 /// Word::from("World!")]);
76 /// ```
77 AsciiSpace,
78
79 /// Split `line` into words using Unicode break properties.
80 ///
81 /// This word separator uses the Unicode line breaking algorithm
82 /// described in [Unicode Standard Annex
83 /// #14](https://www.unicode.org/reports/tr14/) to find legal places
84 /// to break lines. There is a small difference in that the U+002D
85 /// (Hyphen-Minus) and U+00AD (Soft Hyphen) don’t create a line break:
86 /// to allow a line break at a hyphen, use
87 /// [`WordSplitter::HyphenSplitter`](crate::WordSplitter::HyphenSplitter).
88 /// Soft hyphens are not currently supported.
89 ///
90 /// # Examples
91 ///
92 /// Unlike [`WordSeparator::AsciiSpace`], the Unicode line
93 /// breaking algorithm will find line break opportunities between
94 /// some characters with no intervening whitespace:
95 ///
96 /// ```
97 /// #[cfg(feature = "unicode-linebreak")] {
98 /// use textwrap::core::Word;
99 /// use textwrap::WordSeparator::UnicodeBreakProperties;
100 ///
101 /// assert_eq!(UnicodeBreakProperties.find_words("Emojis: 😂😍").collect::<Vec<_>>(),
102 /// vec![Word::from("Emojis: "),
103 /// Word::from("😂"),
104 /// Word::from("😍")]);
105 ///
106 /// assert_eq!(UnicodeBreakProperties.find_words("CJK: 你好").collect::<Vec<_>>(),
107 /// vec![Word::from("CJK: "),
108 /// Word::from("你"),
109 /// Word::from("好")]);
110 /// }
111 /// ```
112 ///
113 /// A U+2060 (Word Joiner) character can be inserted if you want to
114 /// manually override the defaults and keep the characters together:
115 ///
116 /// ```
117 /// #[cfg(feature = "unicode-linebreak")] {
118 /// use textwrap::core::Word;
119 /// use textwrap::WordSeparator::UnicodeBreakProperties;
120 ///
121 /// assert_eq!(UnicodeBreakProperties.find_words("Emojis: 😂\u{2060}😍").collect::<Vec<_>>(),
122 /// vec![Word::from("Emojis: "),
123 /// Word::from("😂\u{2060}😍")]);
124 /// }
125 /// ```
126 ///
127 /// The Unicode line breaking algorithm will also automatically
128 /// suppress line breaks around certain punctuation characters:
129 ///
130 /// ```
131 /// #[cfg(feature = "unicode-linebreak")] {
132 /// use textwrap::core::Word;
133 /// use textwrap::WordSeparator::UnicodeBreakProperties;
134 ///
135 /// assert_eq!(UnicodeBreakProperties.find_words("[ foo ] bar !").collect::<Vec<_>>(),
136 /// vec![Word::from("[ foo ] "),
137 /// Word::from("bar !")]);
138 /// }
139 /// ```
140 #[cfg(feature = "unicode-linebreak")]
141 UnicodeBreakProperties,
142
143 /// Find words using a custom word separator.
144 Custom(fn(line: &str) -> Box<dyn Iterator<Item = Word<'_>> + '_>),
145}
146
147impl PartialEq for WordSeparator {
148 /// Compare two word separators.
149 ///
150 /// ```
151 /// use textwrap::WordSeparator;
152 ///
153 /// assert_eq!(WordSeparator::AsciiSpace, WordSeparator::AsciiSpace);
154 /// #[cfg(feature = "unicode-linebreak")] {
155 /// assert_eq!(WordSeparator::UnicodeBreakProperties,
156 /// WordSeparator::UnicodeBreakProperties);
157 /// }
158 /// ```
159 ///
160 /// Note that `WordSeparator::Custom` values never compare equal:
161 ///
162 /// ```
163 /// use textwrap::WordSeparator;
164 /// use textwrap::core::Word;
165 /// fn word_separator(line: &str) -> Box<dyn Iterator<Item = Word<'_>> + '_> {
166 /// Box::new(line.split_inclusive(' ').map(Word::from))
167 /// }
168 /// assert_ne!(WordSeparator::Custom(word_separator),
169 /// WordSeparator::Custom(word_separator));
170 /// ```
171 fn eq(&self, other: &Self) -> bool {
172 match (self, other) {
173 (WordSeparator::AsciiSpace, WordSeparator::AsciiSpace) => true,
174 #[cfg(feature = "unicode-linebreak")]
175 (WordSeparator::UnicodeBreakProperties, WordSeparator::UnicodeBreakProperties) => true,
176 (_, _) => false,
177 }
178 }
179}
180
181impl WordSeparator {
182 /// Create a new word separator.
183 ///
184 /// The best available algorithm is used by default, i.e.,
185 /// [`WordSeparator::UnicodeBreakProperties`] if available,
186 /// otherwise [`WordSeparator::AsciiSpace`].
187 pub const fn new() -> Self {
188 #[cfg(feature = "unicode-linebreak")]
189 {
190 WordSeparator::UnicodeBreakProperties
191 }
192
193 #[cfg(not(feature = "unicode-linebreak"))]
194 {
195 WordSeparator::AsciiSpace
196 }
197 }
198
199 // This function should really return impl Iterator<Item = Word>, but
200 // this isn't possible until Rust supports higher-kinded types:
201 // https://github.com/rust-lang/rfcs/blob/master/text/1522-conservative-impl-trait.md
202 /// Find all words in `line`.
203 pub fn find_words<'a>(&self, line: &'a str) -> Box<dyn Iterator<Item = Word<'a>> + 'a> {
204 match self {
205 WordSeparator::AsciiSpace => find_words_ascii_space(line),
206 #[cfg(feature = "unicode-linebreak")]
207 WordSeparator::UnicodeBreakProperties => find_words_unicode_break_properties(line),
208 WordSeparator::Custom(func) => func(line),
209 }
210 }
211}
212
213fn find_words_ascii_space<'a>(line: &'a str) -> Box<dyn Iterator<Item = Word<'a>> + 'a> {
214 let mut start = 0;
215 let mut in_whitespace = false;
216 let mut char_indices = line.char_indices();
217
218 Box::new(core::iter::from_fn(move || {
219 for (idx, ch) in char_indices.by_ref() {
220 if in_whitespace && ch != ' ' {
221 let word = Word::from(&line[start..idx]);
222 start = idx;
223 in_whitespace = ch == ' ';
224 return Some(word);
225 }
226
227 in_whitespace = ch == ' ';
228 }
229
230 if start < line.len() {
231 let word = Word::from(&line[start..]);
232 start = line.len();
233 return Some(word);
234 }
235
236 None
237 }))
238}
239
240// Strip all ANSI escape sequences from `text`.
241#[cfg(feature = "unicode-linebreak")]
242fn strip_ansi_escape_sequences(text: &str) -> String {
243 let mut result = String::with_capacity(text.len());
244
245 let mut chars = text.chars();
246 while let Some(ch) = chars.next() {
247 if skip_ansi_escape_sequence(ch, &mut chars) {
248 continue;
249 }
250 result.push(ch);
251 }
252
253 result
254}
255
256/// Soft hyphen, also knows as a “shy hyphen”. Should show up as ‘-’
257/// if a line is broken at this point, and otherwise be invisible.
258/// Textwrap does not currently support breaking words at soft
259/// hyphens.
260#[cfg(feature = "unicode-linebreak")]
261const SHY: char = '\u{00ad}';
262
263/// Find words in line. ANSI escape sequences are ignored in `line`.
264#[cfg(feature = "unicode-linebreak")]
265fn find_words_unicode_break_properties<'a>(
266 line: &'a str,
267) -> Box<dyn Iterator<Item = Word<'a>> + 'a> {
268 // Construct an iterator over (original index, stripped index)
269 // tuples. We find the Unicode linebreaks on a stripped string,
270 // but we need the original indices so we can form words based on
271 // the original string.
272 let mut last_stripped_idx = 0;
273 let mut char_indices = line.char_indices();
274 let mut idx_map = core::iter::from_fn(move || match char_indices.next() {
275 Some((orig_idx, ch)) => {
276 let stripped_idx = last_stripped_idx;
277 if !skip_ansi_escape_sequence(ch, &mut char_indices.by_ref().map(|(_, ch)| ch)) {
278 last_stripped_idx += ch.len_utf8();
279 }
280 Some((orig_idx, stripped_idx))
281 }
282 None => None,
283 });
284
285 let stripped = strip_ansi_escape_sequences(line);
286 let opportunities_vec = LINE_SEGMENTER.with(|segmenter| {
287 segmenter
288 .segment_str(&stripped)
289 .filter(|&idx| idx > 0)
290 .filter(|&idx| {
291 #[allow(clippy::match_like_matches_macro)]
292 match &stripped[..idx].chars().next_back() {
293 // We suppress breaks at ‘-’ since we want to control
294 // this via the WordSplitter.
295 Some('-') => false,
296 // Soft hyphens are currently not supported since we
297 // require all `Word` fragments to be continuous in
298 // the input string.
299 Some(SHY) => false,
300 // Other breaks should be fine!
301 _ => true,
302 }
303 })
304 .collect::<Vec<_>>()
305 });
306 let mut opportunities = opportunities_vec.into_iter();
307
308 // Remove final break opportunity, we will add it below using
309 // &line[start..]; This ensures that we correctly include a
310 // trailing ANSI escape sequence.
311 opportunities.next_back();
312
313 let mut start = 0;
314 Box::new(core::iter::from_fn(move || {
315 for idx in opportunities.by_ref() {
316 if let Some((orig_idx, _)) = idx_map.find(|&(_, stripped_idx)| stripped_idx == idx) {
317 let word = Word::from(&line[start..orig_idx]);
318 start = orig_idx;
319 return Some(word);
320 }
321 }
322
323 if start < line.len() {
324 let word = Word::from(&line[start..]);
325 start = line.len();
326 return Some(word);
327 }
328
329 None
330 }))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::WordSeparator::*;
336 use super::*;
337 use alloc::vec;
338
339 // Like assert_eq!, but the left expression is an iterator.
340 macro_rules! assert_iter_eq {
341 ($left:expr, $right:expr) => {
342 assert_eq!($left.collect::<Vec<_>>(), $right);
343 };
344 }
345
346 fn to_words(words: Vec<&str>) -> Vec<Word<'_>> {
347 words.into_iter().map(Word::from).collect()
348 }
349
350 macro_rules! test_find_words {
351 ($ascii_name:ident,
352 $unicode_name:ident,
353 $([ $line:expr, $ascii_words:expr, $unicode_words:expr ]),+) => {
354 #[test]
355 fn $ascii_name() {
356 $(
357 let expected_words = to_words($ascii_words.to_vec());
358 let actual_words = WordSeparator::AsciiSpace
359 .find_words($line)
360 .collect::<Vec<_>>();
361 assert_eq!(actual_words, expected_words, "Line: {:?}", $line);
362 )+
363 }
364
365 #[test]
366 #[cfg(feature = "unicode-linebreak")]
367 fn $unicode_name() {
368 $(
369 let expected_words = to_words($unicode_words.to_vec());
370 let actual_words = WordSeparator::UnicodeBreakProperties
371 .find_words($line)
372 .collect::<Vec<_>>();
373 assert_eq!(actual_words, expected_words, "Line: {:?}", $line);
374 )+
375 }
376 };
377 }
378
379 test_find_words!(ascii_space_empty, unicode_empty, ["", [], []]);
380
381 test_find_words!(
382 ascii_single_word,
383 unicode_single_word,
384 ["foo", ["foo"], ["foo"]]
385 );
386
387 test_find_words!(
388 ascii_two_words,
389 unicode_two_words,
390 ["foo bar", ["foo ", "bar"], ["foo ", "bar"]]
391 );
392
393 test_find_words!(
394 ascii_multiple_words,
395 unicode_multiple_words,
396 ["foo bar", ["foo ", "bar"], ["foo ", "bar"]],
397 ["x y z", ["x ", "y ", "z"], ["x ", "y ", "z"]]
398 );
399
400 test_find_words!(
401 ascii_only_whitespace,
402 unicode_only_whitespace,
403 [" ", [" "], [" "]],
404 [" ", [" "], [" "]]
405 );
406
407 test_find_words!(
408 ascii_inter_word_whitespace,
409 unicode_inter_word_whitespace,
410 ["foo bar", ["foo ", "bar"], ["foo ", "bar"]]
411 );
412
413 test_find_words!(
414 ascii_trailing_whitespace,
415 unicode_trailing_whitespace,
416 ["foo ", ["foo "], ["foo "]]
417 );
418
419 test_find_words!(
420 ascii_leading_whitespace,
421 unicode_leading_whitespace,
422 [" foo", [" ", "foo"], [" ", "foo"]]
423 );
424
425 test_find_words!(
426 ascii_multi_column_char,
427 unicode_multi_column_char,
428 ["\u{1f920}", ["\u{1f920}"], ["\u{1f920}"]] // cowboy emoji 🤠
429 );
430
431 test_find_words!(
432 ascii_hyphens,
433 unicode_hyphens,
434 ["foo-bar", ["foo-bar"], ["foo-bar"]],
435 ["foo- bar", ["foo- ", "bar"], ["foo- ", "bar"]],
436 ["foo - bar", ["foo ", "- ", "bar"], ["foo ", "- ", "bar"]],
437 ["foo -bar", ["foo ", "-bar"], ["foo ", "-bar"]]
438 );
439
440 test_find_words!(
441 ascii_newline,
442 unicode_newline,
443 ["foo\nbar", ["foo\nbar"], ["foo\n", "bar"]]
444 );
445
446 test_find_words!(
447 ascii_tab,
448 unicode_tab,
449 ["foo\tbar", ["foo\tbar"], ["foo\t", "bar"]]
450 );
451
452 test_find_words!(
453 ascii_non_breaking_space,
454 unicode_non_breaking_space,
455 ["foo\u{00A0}bar", ["foo\u{00A0}bar"], ["foo\u{00A0}bar"]]
456 );
457
458 #[test]
459 #[cfg(all(unix, feature = "std"))]
460 fn find_words_colored_text() {
461 use termion::color::{Blue, Fg, Green, Reset};
462
463 let green_hello = format!("{}Hello{} ", Fg(Green), Fg(Reset));
464 let blue_world = format!("{}World!{}", Fg(Blue), Fg(Reset));
465 assert_iter_eq!(
466 AsciiSpace.find_words(&format!("{}{}", green_hello, blue_world)),
467 vec![Word::from(&green_hello), Word::from(&blue_world)]
468 );
469
470 #[cfg(feature = "unicode-linebreak")]
471 assert_iter_eq!(
472 UnicodeBreakProperties.find_words(&format!("{}{}", green_hello, blue_world)),
473 vec![Word::from(&green_hello), Word::from(&blue_world)]
474 );
475 }
476
477 #[test]
478 fn find_words_color_inside_word() {
479 let text = "foo\u{1b}[0m\u{1b}[32mbar\u{1b}[0mbaz";
480 assert_iter_eq!(AsciiSpace.find_words(text), vec![Word::from(text)]);
481
482 #[cfg(feature = "unicode-linebreak")]
483 assert_iter_eq!(
484 UnicodeBreakProperties.find_words(text),
485 vec![Word::from(text)]
486 );
487 }
488
489 #[test]
490 #[cfg(feature = "unicode-linebreak")]
491 fn find_words_complex_scripts() {
492 let text = "ภาษาไทย"; // English: "Thai language"
493
494 #[cfg(feature = "unicode-linebreak-complex")]
495 assert_iter_eq!(
496 UnicodeBreakProperties.find_words(text),
497 vec![Word::from("ภาษา"), Word::from("ไทย")]
498 );
499
500 #[cfg(not(feature = "unicode-linebreak-complex"))]
501 assert_iter_eq!(
502 UnicodeBreakProperties.find_words(text),
503 vec![Word::from(text)]
504 );
505 }
506
507 #[test]
508 fn word_separator_new() {
509 #[cfg(feature = "unicode-linebreak")]
510 assert!(matches!(WordSeparator::new(), UnicodeBreakProperties));
511
512 #[cfg(not(feature = "unicode-linebreak"))]
513 assert!(matches!(WordSeparator::new(), AsciiSpace));
514 }
515}