Skip to main content

textwrap/
word_splitters.rs

1//! Word splitting functionality.
2//!
3//! To wrap text into lines, long words sometimes need to be split
4//! across lines. The [`WordSplitter`] enum defines this
5//! functionality.
6
7use crate::core::{Word, display_width};
8use alloc::vec::Vec;
9
10/// The `WordSplitter` enum describes where words can be split.
11///
12/// If the textwrap crate has been compiled with the `hyphenation`
13/// Cargo feature enabled, you will find a
14/// [`WordSplitter::Hyphenation`] variant. Use this variant for
15/// language-aware hyphenation:
16///
17/// ```
18/// #[cfg(feature = "hyphenation")] {
19///     use hyphenation::{Language, Load, Standard};
20///     use textwrap::{wrap, Options, WordSplitter};
21///
22///     let text = "Oxidation is the loss of electrons.";
23///     let dictionary = Standard::from_embedded(Language::EnglishUS).unwrap();
24///     let options = Options::new(8).word_splitter(WordSplitter::Hyphenation(dictionary));
25///     assert_eq!(wrap(text, &options), vec!["Oxida-",
26///                                           "tion is",
27///                                           "the loss",
28///                                           "of elec-",
29///                                           "trons."]);
30/// }
31/// ```
32///
33/// Please see the documentation for the [hyphenation] crate for more
34/// details.
35///
36/// [hyphenation]: https://docs.rs/hyphenation/
37#[derive(Debug, Clone)]
38pub enum WordSplitter {
39    /// Use this as an [`Options.word_splitter`] to avoid any kind of
40    /// hyphenation:
41    ///
42    /// ```
43    /// use textwrap::{wrap, Options, WordSplitter};
44    ///
45    /// let options = Options::new(8).word_splitter(WordSplitter::NoHyphenation);
46    /// assert_eq!(wrap("foo bar-baz", &options),
47    ///            vec!["foo", "bar-baz"]);
48    /// ```
49    ///
50    /// [`Options.word_splitter`]: super::Options::word_splitter
51    NoHyphenation,
52
53    /// `HyphenSplitter` is the default `WordSplitter` used by
54    /// [`Options::new`](super::Options::new). It will split words on
55    /// existing hyphens in the word.
56    ///
57    /// It will only use hyphens that are surrounded by alphanumeric
58    /// characters, which prevents a word like `"--foo-bar"` from
59    /// being split into `"--"` and `"foo-bar"`.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use textwrap::WordSplitter;
65    ///
66    /// assert_eq!(WordSplitter::HyphenSplitter.split_points("--foo-bar"),
67    ///            vec![6]);
68    /// ```
69    HyphenSplitter,
70
71    /// Use a custom function as the word splitter.
72    ///
73    /// This variant lets you implement a custom word splitter using
74    /// your own function.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use textwrap::WordSplitter;
80    ///
81    /// fn split_at_underscore(word: &str) -> Vec<usize> {
82    ///     word.match_indices('_').map(|(idx, _)| idx + 1).collect()
83    /// }
84    ///
85    /// let word_splitter = WordSplitter::Custom(split_at_underscore);
86    /// assert_eq!(word_splitter.split_points("a_long_identifier"),
87    ///            vec![2, 7]);
88    /// ```
89    Custom(fn(word: &str) -> Vec<usize>),
90
91    /// A hyphenation dictionary can be used to do language-specific
92    /// hyphenation using patterns from the [hyphenation] crate.
93    ///
94    /// **Note:** Only available when the `hyphenation` Cargo feature is
95    /// enabled.
96    ///
97    /// [hyphenation]: https://docs.rs/hyphenation/
98    #[cfg(feature = "hyphenation")]
99    Hyphenation(hyphenation::Standard),
100}
101
102impl PartialEq<WordSplitter> for WordSplitter {
103    fn eq(&self, other: &WordSplitter) -> bool {
104        match (self, other) {
105            (WordSplitter::NoHyphenation, WordSplitter::NoHyphenation) => true,
106            (WordSplitter::HyphenSplitter, WordSplitter::HyphenSplitter) => true,
107            #[cfg(feature = "hyphenation")]
108            (WordSplitter::Hyphenation(this_dict), WordSplitter::Hyphenation(other_dict)) => {
109                this_dict.language() == other_dict.language()
110            }
111            (_, _) => false,
112        }
113    }
114}
115
116impl WordSplitter {
117    /// Return all possible indices where `word` can be split.
118    ///
119    /// The indices are in the range `0..word.len()`. They point to
120    /// the index _after_ the split point, i.e., after `-` if
121    /// splitting on hyphens. This way, `word.split_at(idx)` will
122    /// break the word into two well-formed pieces.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use textwrap::WordSplitter;
128    /// assert_eq!(WordSplitter::NoHyphenation.split_points("cannot-be-split"), vec![]);
129    /// assert_eq!(WordSplitter::HyphenSplitter.split_points("can-be-split"), vec![4, 7]);
130    /// assert_eq!(WordSplitter::Custom(|word| vec![word.len()/2]).split_points("middle"), vec![3]);
131    /// ```
132    pub fn split_points(&self, word: &str) -> Vec<usize> {
133        match self {
134            WordSplitter::NoHyphenation => Vec::new(),
135            WordSplitter::HyphenSplitter => {
136                let mut splits = Vec::new();
137
138                for (idx, _) in word.match_indices('-') {
139                    // We only use hyphens that are surrounded by alphanumeric
140                    // characters. This is to avoid splitting on repeated hyphens,
141                    // such as those found in --foo-bar.
142                    let prev = word[..idx].chars().next_back();
143                    let next = word[idx + 1..].chars().next();
144
145                    if prev.filter(|ch| ch.is_alphanumeric()).is_some()
146                        && next.filter(|ch| ch.is_alphanumeric()).is_some()
147                    {
148                        splits.push(idx + 1); // +1 due to width of '-'.
149                    }
150                }
151
152                splits
153            }
154            WordSplitter::Custom(splitter_func) => splitter_func(word),
155            #[cfg(feature = "hyphenation")]
156            WordSplitter::Hyphenation(dictionary) => {
157                use hyphenation::Hyphenator;
158                dictionary.hyphenate(word).breaks
159            }
160        }
161    }
162}
163
164/// Split words into smaller words according to the split points given
165/// by `word_splitter`.
166///
167/// Note that we split all words, regardless of their length. This is
168/// to more cleanly separate the business of splitting (including
169/// automatic hyphenation) from the business of word wrapping.
170pub fn split_words<'a, I>(
171    words: I,
172    word_splitter: &'a WordSplitter,
173) -> impl Iterator<Item = Word<'a>>
174where
175    I: IntoIterator<Item = Word<'a>>,
176{
177    words.into_iter().flat_map(move |word| {
178        let mut prev = 0;
179        let mut split_points = word_splitter.split_points(&word).into_iter();
180        core::iter::from_fn(move || {
181            if let Some(idx) = split_points.next() {
182                let need_hyphen = !word[..idx].ends_with('-');
183                let w = Word {
184                    word: &word.word[prev..idx],
185                    width: display_width(&word[prev..idx]),
186                    whitespace: "",
187                    penalty: if need_hyphen { "-" } else { "" },
188                };
189                prev = idx;
190                return Some(w);
191            }
192
193            if prev < word.word.len() || prev == 0 {
194                let w = Word {
195                    word: &word.word[prev..],
196                    width: display_width(&word[prev..]),
197                    whitespace: word.whitespace,
198                    penalty: word.penalty,
199                };
200                prev = word.word.len() + 1;
201                return Some(w);
202            }
203
204            None
205        })
206    })
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use alloc::vec;
213
214    // Like assert_eq!, but the left expression is an iterator.
215    macro_rules! assert_iter_eq {
216        ($left:expr, $right:expr) => {
217            assert_eq!($left.collect::<Vec<_>>(), $right);
218        };
219    }
220
221    #[test]
222    fn split_words_no_words() {
223        assert_iter_eq!(split_words(vec![], &WordSplitter::HyphenSplitter), vec![]);
224    }
225
226    #[test]
227    fn split_words_empty_word() {
228        assert_iter_eq!(
229            split_words(vec![Word::from("   ")], &WordSplitter::HyphenSplitter),
230            vec![Word::from("   ")]
231        );
232    }
233
234    #[test]
235    fn split_words_single_word() {
236        assert_iter_eq!(
237            split_words(vec![Word::from("foobar")], &WordSplitter::HyphenSplitter),
238            vec![Word::from("foobar")]
239        );
240    }
241
242    #[test]
243    fn split_words_hyphen_splitter() {
244        assert_iter_eq!(
245            split_words(vec![Word::from("foo-bar")], &WordSplitter::HyphenSplitter),
246            vec![Word::from("foo-"), Word::from("bar")]
247        );
248    }
249
250    #[test]
251    fn split_words_no_hyphenation() {
252        assert_iter_eq!(
253            split_words(vec![Word::from("foo-bar")], &WordSplitter::NoHyphenation),
254            vec![Word::from("foo-bar")]
255        );
256    }
257
258    #[test]
259    fn split_words_adds_penalty() {
260        let fixed_split_point = |_: &str| vec![3];
261
262        assert_iter_eq!(
263            split_words(
264                vec![Word::from("foobar")].into_iter(),
265                &WordSplitter::Custom(fixed_split_point)
266            ),
267            vec![
268                Word {
269                    word: "foo",
270                    width: 3,
271                    whitespace: "",
272                    penalty: "-"
273                },
274                Word {
275                    word: "bar",
276                    width: 3,
277                    whitespace: "",
278                    penalty: ""
279                }
280            ]
281        );
282
283        assert_iter_eq!(
284            split_words(
285                vec![Word::from("fo-bar")].into_iter(),
286                &WordSplitter::Custom(fixed_split_point)
287            ),
288            vec![
289                Word {
290                    word: "fo-",
291                    width: 3,
292                    whitespace: "",
293                    penalty: ""
294                },
295                Word {
296                    word: "bar",
297                    width: 3,
298                    whitespace: "",
299                    penalty: ""
300                }
301            ]
302        );
303    }
304}