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}