1use std::ops::Range;
7
8use itertools::Itertools;
9use memchr::memchr2_iter;
10
11use crate::{
12 splitter::{SemanticLevel, Splitter},
13 ChunkConfig, ChunkSizer, TryChunkSizer,
14};
15
16use super::{fallback::GRAPHEME_SEGMENTER, ChunkCharIndex};
17
18#[derive(Debug)]
22pub struct TextSplitter<Sizer>
23where
24 Sizer: TryChunkSizer,
25{
26 chunk_config: ChunkConfig<Sizer>,
28}
29
30impl<Sizer> TextSplitter<Sizer>
31where
32 Sizer: TryChunkSizer,
33{
34 #[must_use]
43 pub fn new(chunk_config: impl Into<ChunkConfig<Sizer>>) -> Self {
44 Self {
45 chunk_config: chunk_config.into(),
46 }
47 }
48}
49
50impl<Sizer: ChunkSizer> TextSplitter<Sizer> {
51 pub fn chunks<'splitter, 'text: 'splitter>(
83 &'splitter self,
84 text: &'text str,
85 ) -> impl Iterator<Item = &'text str> + 'splitter {
86 Splitter::<_>::try_chunks(self, text).map(super::unwrap_infallible)
87 }
88
89 pub fn chunk_indices<'splitter, 'text: 'splitter>(
104 &'splitter self,
105 text: &'text str,
106 ) -> impl Iterator<Item = (usize, &'text str)> + 'splitter {
107 Splitter::<_>::try_chunk_indices(self, text).map(super::unwrap_infallible)
108 }
109
110 pub fn chunk_char_indices<'splitter, 'text: 'splitter>(
129 &'splitter self,
130 text: &'text str,
131 ) -> impl Iterator<Item = ChunkCharIndex<'text>> + 'splitter {
132 Splitter::<_>::try_chunk_char_indices(self, text).map(super::unwrap_infallible)
133 }
134}
135
136impl<Sizer: TryChunkSizer> TextSplitter<Sizer> {
137 pub fn try_chunks<'splitter, 'text: 'splitter>(
143 &'splitter self,
144 text: &'text str,
145 ) -> impl Iterator<Item = Result<&'text str, Sizer::Error>> + 'splitter {
146 Splitter::<_>::try_chunks(self, text)
147 }
148
149 pub fn try_chunk_indices<'splitter, 'text: 'splitter>(
153 &'splitter self,
154 text: &'text str,
155 ) -> impl Iterator<Item = Result<(usize, &'text str), Sizer::Error>> + 'splitter {
156 Splitter::<_>::try_chunk_indices(self, text)
157 }
158
159 pub fn try_chunk_char_indices<'splitter, 'text: 'splitter>(
164 &'splitter self,
165 text: &'text str,
166 ) -> impl Iterator<Item = Result<ChunkCharIndex<'text>, Sizer::Error>> + 'splitter {
167 Splitter::<_>::try_chunk_char_indices(self, text)
168 }
169}
170
171impl<Sizer> Splitter<Sizer> for TextSplitter<Sizer>
172where
173 Sizer: TryChunkSizer,
174{
175 type Level = LineBreaks;
176
177 fn chunk_config(&self) -> &ChunkConfig<Sizer> {
178 &self.chunk_config
179 }
180
181 fn parse(&self, text: &str) -> Vec<(Self::Level, Range<usize>)> {
182 memchr2_iter(b'\n', b'\r', text.as_bytes())
183 .map(|i| i..i + 1)
184 .coalesce(|a, b| {
185 if a.end == b.start {
186 Ok(a.start..b.end)
187 } else {
188 Err((a, b))
189 }
190 })
191 .map(|range| {
192 let level = GRAPHEME_SEGMENTER
193 .segment_str(text.get(range.start..range.end).unwrap())
194 .tuple_windows::<(usize, usize)>()
195 .count();
196 (
197 match level {
198 0 => unreachable!("regex should always match at least one newline"),
199 n => LineBreaks(n),
200 },
201 range,
202 )
203 })
204 .collect()
205 }
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
214pub struct LineBreaks(usize);
215
216impl SemanticLevel for LineBreaks {}
217
218#[cfg(test)]
219mod tests {
220 use std::cmp::min;
221
222 use fake::{Fake, Faker};
223
224 use crate::{splitter::SemanticSplitRanges, ChunkCharIndex};
225
226 use super::*;
227
228 #[test]
229 fn returns_one_chunk_if_text_is_shorter_than_max_chunk_size() {
230 let text = Faker.fake::<String>();
231 let chunks = TextSplitter::new(ChunkConfig::new(text.chars().count()).with_trim(false))
232 .chunks(&text)
233 .collect::<Vec<_>>();
234
235 assert_eq!(vec![&text], chunks);
236 }
237
238 #[test]
239 fn returns_two_chunks_if_text_is_longer_than_max_chunk_size() {
240 let text1 = Faker.fake::<String>();
241 let text2 = Faker.fake::<String>();
242 let text = format!("{text1}{text2}");
243 let max_chunk_size = text.chars().count() / 2 + 1;
245 let chunks = TextSplitter::new(ChunkConfig::new(max_chunk_size).with_trim(false))
246 .chunks(&text)
247 .collect::<Vec<_>>();
248
249 assert!(chunks.iter().all(|c| c.chars().count() <= max_chunk_size));
250
251 let len = min(text1.len(), chunks[0].len());
253 assert_eq!(text1[..len], chunks[0][..len]);
254 let len = min(text2.len(), chunks[1].len());
256 assert_eq!(
257 text2[(text2.len() - len)..],
258 chunks[1][chunks[1].len() - len..]
259 );
260
261 assert_eq!(chunks.join(""), text);
262 }
263
264 #[test]
265 fn empty_string() {
266 let text = "";
267 let chunks = TextSplitter::new(ChunkConfig::new(100).with_trim(false))
268 .chunks(text)
269 .collect::<Vec<_>>();
270
271 assert!(chunks.is_empty());
272 }
273
274 #[test]
275 fn can_handle_unicode_characters() {
276 let text = "éé"; let chunks = TextSplitter::new(ChunkConfig::new(1).with_trim(false))
278 .chunks(text)
279 .collect::<Vec<_>>();
280 assert_eq!(vec!["é", "é"], chunks);
281 }
282
283 struct Str;
285
286 impl ChunkSizer for Str {
287 fn size(&self, chunk: &str) -> usize {
288 chunk.len()
289 }
290 }
291
292 #[test]
293 fn custom_len_function() {
294 let text = "éé"; let chunks = TextSplitter::new(ChunkConfig::new(2).with_sizer(Str).with_trim(false))
296 .chunks(text)
297 .collect::<Vec<_>>();
298
299 assert_eq!(vec!["é", "é"], chunks);
300 }
301
302 #[test]
303 fn handles_char_bigger_than_len() {
304 let text = "éé"; let chunks = TextSplitter::new(ChunkConfig::new(1).with_sizer(Str).with_trim(false))
306 .chunks(text)
307 .collect::<Vec<_>>();
308
309 assert_eq!(vec!["é", "é"], chunks);
311 }
312
313 #[test]
314 fn chunk_by_graphemes() {
315 let text = "a̐éö̲\r\n";
316 let chunks = TextSplitter::new(ChunkConfig::new(3).with_trim(false))
317 .chunks(text)
318 .collect::<Vec<_>>();
319
320 assert_eq!(vec!["a̐é", "ö̲", "\r\n"], chunks);
322 }
323
324 #[test]
325 fn trim_char_indices() {
326 let text = " a b ";
327 let chunks = TextSplitter::new(1).chunk_indices(text).collect::<Vec<_>>();
328
329 assert_eq!(vec![(1, "a"), (3, "b")], chunks);
330 }
331
332 #[test]
333 fn chunk_char_indices() {
334 let text = " a b ";
335 let chunks = TextSplitter::new(1)
336 .chunk_char_indices(text)
337 .collect::<Vec<_>>();
338
339 assert_eq!(
340 vec![
341 ChunkCharIndex {
342 chunk: "a",
343 byte_offset: 1,
344 char_offset: 1
345 },
346 ChunkCharIndex {
347 chunk: "b",
348 byte_offset: 3,
349 char_offset: 3,
350 },
351 ],
352 chunks
353 );
354 }
355
356 #[test]
357 fn graphemes_fallback_to_chars() {
358 let text = "a̐éö̲\r\n";
359 let chunks = TextSplitter::new(ChunkConfig::new(1).with_trim(false))
360 .chunks(text)
361 .collect::<Vec<_>>();
362 assert_eq!(
363 vec!["a", "\u{310}", "é", "ö", "\u{332}", "\r", "\n"],
364 chunks
365 );
366 }
367
368 #[test]
369 fn trim_grapheme_indices() {
370 let text = "\r\na̐éö̲\r\n";
371 let chunks = TextSplitter::new(3).chunk_indices(text).collect::<Vec<_>>();
372
373 assert_eq!(vec![(2, "a̐é"), (7, "ö̲")], chunks);
374 }
375
376 #[test]
377 fn grapheme_char_indices() {
378 let text = "\r\na̐éö̲\r\n";
379 let chunks = TextSplitter::new(3)
380 .chunk_char_indices(text)
381 .collect::<Vec<_>>();
382
383 assert_eq!(
384 vec![
385 ChunkCharIndex {
386 chunk: "a̐é",
387 byte_offset: 2,
388 char_offset: 2
389 },
390 ChunkCharIndex {
391 chunk: "ö̲",
392 byte_offset: 7,
393 char_offset: 5
394 }
395 ],
396 chunks
397 );
398 }
399
400 #[test]
401 fn chunk_by_words() {
402 let text = "The quick (\"brown\") fox can't jump 32.3 feet, right?";
403 let chunks = TextSplitter::new(ChunkConfig::new(10).with_trim(false))
404 .chunks(text)
405 .collect::<Vec<_>>();
406
407 assert_eq!(
408 vec![
409 "The quick ",
410 "(\"brown\") ",
411 "fox can't ",
412 "jump 32.3 ",
413 "feet, ",
414 "right?"
415 ],
416 chunks
417 );
418 }
419
420 #[test]
421 fn words_fallback_to_graphemes() {
422 let text = "Thé quick\r\n";
423 let chunks = TextSplitter::new(ChunkConfig::new(2).with_trim(false))
424 .chunks(text)
425 .collect::<Vec<_>>();
426 assert_eq!(vec!["Th", "é ", "qu", "ic", "k", "\r\n"], chunks);
427 }
428
429 #[test]
430 fn trim_word_indices() {
431 let text = "Some text from a document";
432 let chunks = TextSplitter::new(10)
433 .chunk_indices(text)
434 .collect::<Vec<_>>();
435 assert_eq!(
436 vec![(0, "Some text"), (10, "from a"), (17, "document")],
437 chunks
438 );
439 }
440
441 #[test]
442 fn chunk_by_sentences() {
443 let text = "Mr. Fox jumped. [...] The dog was too lazy.";
444 let chunks = TextSplitter::new(ChunkConfig::new(21).with_trim(false))
445 .chunks(text)
446 .collect::<Vec<_>>();
447 assert_eq!(
448 vec!["Mr. Fox jumped. ", "[...] ", "The dog was too lazy."],
449 chunks
450 );
451 }
452
453 #[test]
454 fn sentences_falls_back_to_words() {
455 let text = "Mr. Fox jumped. [...] The dog was too lazy.";
456 let chunks = TextSplitter::new(ChunkConfig::new(16).with_trim(false))
457 .chunks(text)
458 .collect::<Vec<_>>();
459 assert_eq!(
460 vec!["Mr. Fox jumped. ", "[...] ", "The dog was too ", "lazy."],
461 chunks
462 );
463 }
464
465 #[test]
466 fn trim_sentence_indices() {
467 let text = "Some text. From a document.";
468 let chunks = TextSplitter::new(10)
469 .chunk_indices(text)
470 .collect::<Vec<_>>();
471 assert_eq!(
472 vec![(0, "Some text."), (11, "From a"), (18, "document.")],
473 chunks
474 );
475 }
476
477 #[test]
478 fn trim_paragraph_indices() {
479 let text = "Some text\n\nfrom a\ndocument";
480 let chunks = TextSplitter::new(10)
481 .chunk_indices(text)
482 .collect::<Vec<_>>();
483 assert_eq!(
484 vec![(0, "Some text"), (11, "from a"), (18, "document")],
485 chunks
486 );
487 }
488
489 #[test]
490 fn correctly_determines_newlines() {
491 let text = "\r\n\r\ntext\n\n\ntext2";
492 let splitter = TextSplitter::new(10);
493 let linebreaks = SemanticSplitRanges::new(splitter.parse(text));
494 assert_eq!(
495 vec![(LineBreaks(2), 0..4), (LineBreaks(3), 8..11)],
496 linebreaks.ranges
497 );
498 }
499}