Skip to main content

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}