1use std::ops::Range;
8
9use serde::{Deserialize, Serialize};
10
11const TEXT_ALGORITHM_VERSION: u32 = 2;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum TextFormat {
18 Markdown,
19 Plain,
20 Code,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct TextSegmenterConfig {
36 pub min_chars: usize,
38 pub target_chars: usize,
40 pub max_chars: usize,
43 pub overlap_chars: usize,
47}
48
49impl Default for TextSegmenterConfig {
50 fn default() -> Self {
51 Self {
52 min_chars: 60,
53 target_chars: 1000,
54 max_chars: 1800,
55 overlap_chars: 300,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct TextSegment {
63 pub byte_range: Range<usize>,
65 pub line_start: u64,
67 pub line_end: u64,
68 pub heading: Option<String>,
70 pub text: String,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct TextSegmenter {
75 pub config: TextSegmenterConfig,
76}
77
78#[derive(Debug, Clone)]
79struct Block {
80 range: Range<usize>,
81 is_heading: bool,
82 heading_path: Option<String>,
83}
84
85struct Pending {
87 range: Range<usize>,
88 heading: Option<String>,
90 body_len: usize,
92}
93
94impl TextSegmenter {
95 pub fn new(config: TextSegmenterConfig) -> Self {
96 Self { config }
97 }
98
99 pub fn version(&self) -> String {
101 let c = &self.config;
102 format!(
103 "text-v{TEXT_ALGORITHM_VERSION}:n{}:t{}:m{}:o{}",
104 c.min_chars, c.target_chars, c.max_chars, c.overlap_chars
105 )
106 }
107
108 pub fn segment(&self, source: &str, format: TextFormat) -> Vec<TextSegment> {
109 let blocks = match format {
110 TextFormat::Markdown => markdown_blocks(source),
111 TextFormat::Plain | TextFormat::Code => paragraph_blocks(source),
112 };
113 let blocks = self.split_oversized(source, blocks);
114 let lines = LineIndex::new(source);
115
116 let mut out = Vec::new();
117 let mut current: Option<Pending> = None;
118
119 for block in blocks {
120 let len = block.range.len();
121 let body_len = current.as_ref().map_or(0, |c| c.body_len);
123 let only_headings = body_len == 0;
124 let min = self.config.min_chars;
125
126 let new_section = block.is_heading && !only_headings;
128 let both_substantial = !block.is_heading && body_len >= min && len >= min;
130 let too_big = !only_headings && body_len + len > self.config.target_chars;
131 if new_section || both_substantial || too_big {
132 self.flush(source, &lines, current.take(), &mut out);
133 }
134 let added = if block.is_heading { 0 } else { len };
135 match &mut current {
136 Some(c) => {
137 c.range.end = block.range.end;
138 c.heading = block.heading_path;
139 c.body_len += added;
140 }
141 None => {
142 current = Some(Pending {
143 range: block.range,
144 heading: block.heading_path,
145 body_len: added,
146 })
147 }
148 }
149 }
150 self.flush(source, &lines, current, &mut out);
151 out
152 }
153
154 fn flush(
155 &self,
156 source: &str,
157 lines: &LineIndex,
158 pending: Option<Pending>,
159 out: &mut Vec<TextSegment>,
160 ) {
161 let Some(Pending {
162 range: Range { start, end },
163 heading,
164 ..
165 }) = pending
166 else {
167 return;
168 };
169 let text = &source[start..end];
170 if text.trim().is_empty() {
171 return;
172 }
173 out.push(TextSegment {
174 byte_range: start..end,
175 line_start: lines.line_of(start),
176 line_end: lines.line_of(end.saturating_sub(1).max(start)),
177 heading,
178 text: text.to_string(),
179 });
180 }
181
182 fn split_oversized(&self, source: &str, blocks: Vec<Block>) -> Vec<Block> {
184 let max = self.config.max_chars;
185 let mut out = Vec::with_capacity(blocks.len());
186 for block in blocks {
187 if block.range.len() <= max {
188 out.push(block);
189 continue;
190 }
191 let mut start = block.range.start;
192 while block.range.end - start > max {
193 let (piece_end, next) = self.split_point(&source[..block.range.end], start);
195 out.push(trimmed_block(source, start..piece_end, &block));
196 start = next;
197 }
198 if start < block.range.end {
199 out.push(trimmed_block(source, start..block.range.end, &block));
200 }
201 }
202 out.retain(|b| !b.range.is_empty());
203 out
204 }
205}
206
207impl TextSegmenter {
208 fn split_point(&self, source: &str, start: usize) -> (usize, usize) {
214 let mut window_end = floor_char_boundary(source, start + self.config.max_chars);
215 if window_end == start {
216 window_end += source[start..].chars().next().map_or(1, char::len_utf8);
217 }
218 let window = &source[start..window_end];
219 let half = window.len() / 2;
221 let cut = rfind_sentence_end(window, half)
222 .or_else(|| {
223 window
224 .char_indices()
225 .rev()
226 .take_while(|&(i, _)| i >= half)
227 .find(|&(_, c)| c.is_whitespace())
228 .map(|(i, c)| i + c.len_utf8())
229 })
230 .unwrap_or(window.len());
231 let piece_end = start + cut;
232 let piece = source[start..piece_end].trim_end();
236 let next = rfind_sentence_end(piece, 0)
237 .map(|i| skip_whitespace(source, start + i))
238 .filter(|&s| s > start && piece_end - s <= self.config.overlap_chars)
239 .unwrap_or_else(|| skip_whitespace(source, piece_end));
240 (piece_end, next)
241 }
242}
243
244fn trimmed_block(source: &str, range: Range<usize>, like: &Block) -> Block {
245 Block {
246 range: trim_range(source, range),
247 is_heading: false,
248 heading_path: like.heading_path.clone(),
249 }
250}
251
252fn rfind_sentence_end(window: &str, min: usize) -> Option<usize> {
255 let bytes = window.as_bytes();
256 (min..bytes.len().saturating_sub(1))
257 .rev()
258 .find(|&i| {
259 matches!(bytes[i], b'.' | b'!' | b'?' | b'\n') && bytes[i + 1].is_ascii_whitespace()
260 })
261 .map(|i| i + 1)
262}
263
264fn floor_char_boundary(s: &str, mut i: usize) -> usize {
265 i = i.min(s.len());
266 while !s.is_char_boundary(i) {
267 i -= 1;
268 }
269 i
270}
271
272fn skip_whitespace(s: &str, mut i: usize) -> usize {
273 while i < s.len() {
274 let c = s[i..].chars().next().unwrap();
275 if !c.is_whitespace() {
276 break;
277 }
278 i += c.len_utf8();
279 }
280 i
281}
282
283fn trim_range(s: &str, r: Range<usize>) -> Range<usize> {
284 let slice = &s[r.clone()];
285 let lead = slice.len() - slice.trim_start().len();
286 let trail = slice.len() - slice.trim_end().len();
287 if lead == slice.len() {
288 return r.start..r.start;
289 }
290 (r.start + lead)..(r.end - trail)
291}
292
293fn lines_with_ranges(source: &str) -> impl Iterator<Item = (Range<usize>, &str)> {
295 let mut pos = 0;
296 source.split_inclusive('\n').map(move |raw| {
297 let start = pos;
298 pos += raw.len();
299 let line = raw.trim_end_matches(['\n', '\r']);
300 (start..start + line.len(), line)
301 })
302}
303
304fn paragraph_blocks(source: &str) -> Vec<Block> {
306 let mut blocks = Vec::new();
307 let mut para: Option<Range<usize>> = None;
308 for (range, line) in lines_with_ranges(source) {
309 if line.trim().is_empty() {
310 if let Some(p) = para.take() {
311 blocks.push(Block {
312 range: p,
313 is_heading: false,
314 heading_path: None,
315 });
316 }
317 } else {
318 para = Some(match para {
319 Some(p) => p.start..range.end,
320 None => range,
321 });
322 }
323 }
324 if let Some(p) = para {
325 blocks.push(Block {
326 range: p,
327 is_heading: false,
328 heading_path: None,
329 });
330 }
331 for b in &mut blocks {
332 b.range = trim_range(source, b.range.clone());
333 }
334 blocks
335}
336
337fn markdown_blocks(source: &str) -> Vec<Block> {
339 let mut blocks = Vec::new();
340 let mut headings: Vec<(usize, String)> = Vec::new();
341 let mut para: Option<Range<usize>> = None;
342 let mut fence: Option<(String, Range<usize>)> = None;
343
344 let path = |headings: &[(usize, String)]| {
345 (!headings.is_empty()).then(|| {
346 headings
347 .iter()
348 .map(|(_, t)| t.as_str())
349 .collect::<Vec<_>>()
350 .join(" > ")
351 })
352 };
353
354 for (range, line) in lines_with_ranges(source) {
355 let trimmed = line.trim_start();
356
357 if let Some((marker, frange)) = &mut fence {
358 frange.end = range.end;
359 if trimmed.starts_with(marker.as_str()) {
360 blocks.push(Block {
361 range: frange.clone(),
362 is_heading: false,
363 heading_path: path(&headings),
364 });
365 fence = None;
366 }
367 continue;
368 }
369
370 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
371 if let Some(p) = para.take() {
372 blocks.push(Block {
373 range: p,
374 is_heading: false,
375 heading_path: path(&headings),
376 });
377 }
378 fence = Some((trimmed[..3].to_string(), range));
379 continue;
380 }
381
382 if let Some((level, title)) = atx_heading(line) {
383 if let Some(p) = para.take() {
384 blocks.push(Block {
385 range: p,
386 is_heading: false,
387 heading_path: path(&headings),
388 });
389 }
390 headings.retain(|(l, _)| *l < level);
391 headings.push((level, title));
392 blocks.push(Block {
393 range,
394 is_heading: true,
395 heading_path: path(&headings),
396 });
397 continue;
398 }
399
400 if line.trim().is_empty() {
401 if let Some(p) = para.take() {
402 blocks.push(Block {
403 range: p,
404 is_heading: false,
405 heading_path: path(&headings),
406 });
407 }
408 } else {
409 para = Some(match para {
410 Some(p) => p.start..range.end,
411 None => range,
412 });
413 }
414 }
415 if let Some((_, frange)) = fence {
416 blocks.push(Block {
417 range: frange,
418 is_heading: false,
419 heading_path: path(&headings),
420 });
421 }
422 if let Some(p) = para {
423 blocks.push(Block {
424 range: p,
425 is_heading: false,
426 heading_path: path(&headings),
427 });
428 }
429 for b in &mut blocks {
430 b.range = trim_range(source, b.range.clone());
431 }
432 blocks.retain(|b| !b.range.is_empty());
433 blocks
434}
435
436fn atx_heading(line: &str) -> Option<(usize, String)> {
437 let indent = line.len() - line.trim_start_matches(' ').len();
438 if indent > 3 {
439 return None;
440 }
441 let rest = &line[indent..];
442 let level = rest.bytes().take_while(|&b| b == b'#').count();
443 if !(1..=6).contains(&level) {
444 return None;
445 }
446 let after = &rest[level..];
447 if !after.is_empty() && !after.starts_with([' ', '\t']) {
448 return None;
449 }
450 let title = after.trim().trim_end_matches('#').trim().to_string();
451 Some((level, title))
452}
453
454pub(crate) struct LineIndex {
456 starts: Vec<usize>,
457}
458
459impl LineIndex {
460 pub(crate) fn new(source: &str) -> Self {
461 let mut starts = vec![0];
462 starts.extend(source.match_indices('\n').map(|(i, _)| i + 1));
463 Self { starts }
464 }
465
466 pub(crate) fn line_of(&self, byte: usize) -> u64 {
467 (self.starts.partition_point(|&s| s <= byte)) as u64
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 fn seg(src: &str, fmt: TextFormat) -> Vec<TextSegment> {
476 TextSegmenter::default().segment(src, fmt)
477 }
478
479 #[test]
480 fn locators_map_back_to_source() {
481 let src = "# Title\n\nFirst para line one.\nline two.\n\n## Sub\n\nSecond para.\n";
482 for s in seg(src, TextFormat::Markdown) {
483 assert_eq!(&src[s.byte_range.clone()], s.text);
484 let lines: Vec<&str> = src.lines().collect();
485 assert!(!lines[(s.line_start - 1) as usize].is_empty());
486 assert!(s.text.ends_with(lines[(s.line_end - 1) as usize].trim()));
487 }
488 }
489
490 #[test]
491 fn headings_stay_with_their_content() {
492 let src = "# Book\n\n## Garden\n\nShe waters the beds at dawn.\n\n## Soup\n\nTomato soup needs basil.\n";
493 let s = seg(src, TextFormat::Markdown);
494 assert_eq!(s.len(), 2);
495 assert!(
496 s[0].text.starts_with("# Book"),
497 "heading-only section merges forward"
498 );
499 assert!(s[0].text.contains("dawn"));
500 assert_eq!(s[0].heading.as_deref(), Some("Book > Garden"));
501 assert!(s[1].text.starts_with("## Soup"));
502 assert_eq!(s[1].heading.as_deref(), Some("Book > Soup"));
503 assert_eq!(s[1].line_start, 7);
504 assert_eq!(s[1].line_end, 9);
505 }
506
507 #[test]
508 fn boundaries_are_stable() {
509 let para = "This sentence is about memory and how it fades over years. ".repeat(8);
510 let src = format!("# A\n\n{para}\n\n{para}\n\n{para}\n\n# B\n\n{para}\n");
511 let a = seg(&src, TextFormat::Markdown);
512 let b = seg(&src, TextFormat::Markdown);
513 assert_eq!(a, b);
514 assert!(a.len() >= 3);
515 for s in &a {
516 assert!(
517 s.text.len() <= TextSegmenterConfig::default().max_chars,
518 "{}",
519 s.text.len()
520 );
521 }
522 let edited = src.replace("# B\n\n", "# B\n\nA new opening line.\n\n");
524 let c = seg(&edited, TextFormat::Markdown);
525 let a_only: Vec<_> = a
526 .iter()
527 .filter(|s| s.heading.as_deref() == Some("A"))
528 .collect();
529 let c_only: Vec<_> = c
530 .iter()
531 .filter(|s| s.heading.as_deref() == Some("A"))
532 .collect();
533 assert_eq!(a_only, c_only);
534 }
535
536 #[test]
537 fn long_paragraph_is_split_at_sentences() {
538 let src = "One sentence about grief as a weight in the chest. ".repeat(80);
539 let s = seg(&src, TextFormat::Plain);
540 assert!(s.len() > 1);
541 for piece in &s {
542 assert!(piece.text.len() <= 1800);
543 assert!(
544 piece.text.ends_with('.'),
545 "split at sentence end: {:?}",
546 &piece.text[piece.text.len() - 10..]
547 );
548 assert_eq!(&src[piece.byte_range.clone()], piece.text);
549 }
550 }
551
552 #[test]
553 fn substantial_paragraphs_stay_separate_and_fragments_merge() {
554 let src = "Grief: after the funeral her chest felt packed with wet sand.\n\n\
555 Memory: she is certain the argument happened in winter, not summer.\n\n\
556 TODO\n\n\
557 Inheritance: a temper, a way of folding shirts, the silence at dinner.\n";
558 let s = seg(src, TextFormat::Plain);
559 let texts: Vec<&str> = s.iter().map(|x| x.text.as_str()).collect();
560 assert_eq!(texts.len(), 3, "{texts:#?}");
561 assert!(texts[0].starts_with("Grief"));
562 assert!(
563 texts[1].starts_with("Memory") && texts[1].ends_with("TODO"),
564 "fragment joins its neighbour"
565 );
566 assert!(texts[2].starts_with("Inheritance"));
567 assert_eq!((s[1].line_start, s[1].line_end), (3, 5));
568 }
569
570 #[test]
571 fn split_paragraphs_overlap_by_one_sentence() {
572 let src = (0..60)
573 .map(|i| format!("Sentence number {i} is about the long walk home."))
574 .collect::<Vec<_>>()
575 .join(" ");
576 let s = seg(&src, TextFormat::Plain);
577 assert!(s.len() > 1);
578 for pair in s.windows(2) {
579 let last_sentence = pair[0].text.rsplit(". ").next().unwrap();
580 assert!(
581 pair[1]
582 .text
583 .starts_with(last_sentence.trim_end_matches('.')),
584 "{:?}",
585 &pair[1].text[..40]
586 );
587 assert!(pair[1].byte_range.start < pair[0].byte_range.end);
588 }
589 }
590
591 #[test]
592 fn fenced_code_is_not_parsed_as_headings() {
593 let src = "# Real\n\n```\n# not a heading\n```\n";
594 let s = seg(src, TextFormat::Markdown);
595 assert_eq!(s.len(), 1);
596 assert_eq!(s[0].heading.as_deref(), Some("Real"));
597 }
598
599 #[test]
600 fn multibyte_text_is_safe() {
601 let src = "Ünïcödé — „Zitat“ ✓. ".repeat(200);
602 for s in seg(&src, TextFormat::Plain) {
603 assert_eq!(&src[s.byte_range.clone()], s.text);
604 }
605 }
606
607 fn assert_well_formed(src: &str, segs: &[TextSegment]) {
610 assert!(!segs.is_empty() || src.trim().is_empty());
611 let mut last_start = None;
612 for s in segs {
613 assert!(!s.text.is_empty());
614 assert_eq!(&src[s.byte_range.clone()], s.text);
615 assert!(last_start < Some(s.byte_range.start), "pieces must advance");
616 last_start = Some(s.byte_range.start);
617 }
618 }
619
620 #[test]
621 fn long_runs_without_punctuation_split_on_char_boundaries() {
622 for unit in ["é", "日本語", "─", "🙂"] {
625 let src = unit.repeat(3000 / unit.len() + 1);
626 assert_well_formed(&src, &seg(&src, TextFormat::Plain));
627 }
628 for ws in ['\u{a0}', '\u{2003}'] {
630 let src = format!("wörd{ws}").repeat(800);
631 assert_well_formed(&src, &seg(&src, TextFormat::Plain));
632 }
633 }
634
635 #[test]
636 fn tiny_budgets_still_make_progress() {
637 let src = "Ab é 日. Cd ─ 🙂! Ef gh.";
638 for max_chars in 0..6 {
639 let s = TextSegmenter::new(TextSegmenterConfig {
640 min_chars: 0,
641 target_chars: max_chars,
642 max_chars,
643 overlap_chars: 2,
644 })
645 .segment(src, TextFormat::Plain);
646 assert_well_formed(src, &s);
647 }
648 }
649
650 #[test]
651 fn tiny_budget_does_not_skip_past_paragraph_end() {
652 let segmenter = TextSegmenter::new(TextSegmenterConfig {
653 min_chars: 0,
654 target_chars: 1,
655 max_chars: 1,
656 overlap_chars: 0,
657 });
658 let segments = segmenter.segment("é\n\nx", TextFormat::Plain);
659 assert_eq!(segments.len(), 2);
660 assert_eq!(segments[0].byte_range, 0..2);
661 assert_eq!(segments[0].text, "é");
662 assert_eq!(segments[1].byte_range, 4..5);
663 assert_eq!(segments[1].text, "x");
664 assert_eq!((segments[0].line_start, segments[1].line_start), (1, 3));
665 }
666
667 fn blocks_for(source: &str, format: TextFormat) -> Vec<Block> {
668 match format {
669 TextFormat::Markdown => markdown_blocks(source),
670 TextFormat::Plain | TextFormat::Code => paragraph_blocks(source),
671 }
672 }
673
674 fn assert_ranges_cover(source: &str, ranges: &[Range<usize>]) {
675 for range in ranges {
676 assert!(range.start < range.end);
677 assert!(range.end <= source.len());
678 assert!(source.is_char_boundary(range.start));
679 assert!(source.is_char_boundary(range.end));
680 }
681 for pair in ranges.windows(2) {
682 assert!(pair[0].start < pair[1].start, "starts must advance");
683 assert!(pair[0].end <= pair[1].end, "ends must not retreat");
684 }
685 for (offset, ch) in source.char_indices() {
688 assert!(ch.is_whitespace() || ranges.iter().any(|range| range.contains(&offset)));
689 }
690 }
691
692 #[test]
693 fn tiny_budgets_stay_in_blocks_and_cover_multibyte_text() {
694 let sources = [
695 "é\n\nx",
696 "日\n\n🙂\n\né\t \n\n",
697 " \té\u{2003}\n\n日\u{a0}\r\n\r\n🙂 \t",
698 "Ab é 日. Cd ─ 🙂! Ef gh.\n\n é \n\n",
699 "a. b. c. d. é. 日. 🙂.\n\nx\n",
700 "# é\n\n日\n\n## 🙂\n\né\t\n",
701 "# 日\n\n```\né\n🙂\n```\n\n日\t\n",
702 "\n\t\u{2003}\n",
703 "",
704 ];
705 for source in sources {
706 for format in [TextFormat::Plain, TextFormat::Markdown] {
707 for max_chars in 0..=5 {
708 for overlap_chars in [0, 2, 5] {
709 let segmenter = TextSegmenter::new(TextSegmenterConfig {
710 min_chars: 0,
711 target_chars: max_chars,
712 max_chars,
713 overlap_chars,
714 });
715 for block in blocks_for(source, format) {
718 let pieces = segmenter.split_oversized(source, vec![block.clone()]);
719 for piece in &pieces {
720 assert!(piece.range.start >= block.range.start);
721 assert!(piece.range.end <= block.range.end);
722 }
723 let relative_ranges: Vec<_> = pieces
724 .iter()
725 .map(|piece| {
726 piece.range.start - block.range.start
727 ..piece.range.end - block.range.start
728 })
729 .collect();
730 assert_ranges_cover(&source[block.range], &relative_ranges);
731 }
732 let segments = segmenter.segment(source, format);
733 assert_well_formed(source, &segments);
734 let ranges: Vec<_> =
735 segments.iter().map(|s| s.byte_range.clone()).collect();
736 assert_ranges_cover(source, &ranges);
737 }
738 }
739 }
740 }
741 }
742
743 fn old_split_oversized(
747 segmenter: &TextSegmenter,
748 source: &str,
749 blocks: Vec<Block>,
750 ) -> Vec<Block> {
751 let max = segmenter.config.max_chars;
752 let mut out = Vec::with_capacity(blocks.len());
753 for block in blocks {
754 if block.range.len() <= max {
755 out.push(block);
756 continue;
757 }
758 let mut start = block.range.start;
759 while block.range.end - start > max {
760 let (piece_end, next) = segmenter.split_point(source, start);
761 out.push(trimmed_block(source, start..piece_end, &block));
762 start = next;
763 }
764 if start < block.range.end {
765 out.push(trimmed_block(source, start..block.range.end, &block));
766 }
767 }
768 out.retain(|b| !b.range.is_empty());
769 out
770 }
771
772 #[test]
773 fn successful_splits_match_the_previous_algorithm() {
774 let paragraphs = [
775 "é",
776 "日",
777 "🙂",
778 "a. b. é! 日? 🙂.",
779 "# Héading",
780 "```\né\n```",
781 ];
782 let mut compared = 0;
783 let mut formerly_panicked = 0;
784 let signature = |blocks: Vec<Block>| {
785 blocks
786 .into_iter()
787 .map(|block| (block.range, block.is_heading, block.heading_path))
788 .collect::<Vec<_>>()
789 };
790 for first in paragraphs {
791 for second in paragraphs {
792 for separator in ["\n\n", "\r\n\r\n", "\n \t\n", "\n\u{2003}\n"] {
793 let source = format!("{first}{separator}{second}\t \n\n");
794 for format in [TextFormat::Plain, TextFormat::Markdown, TextFormat::Code] {
795 for max_chars in [0, 1, 2, 3, 4, 5, 6, 12, 64, 1800] {
796 for overlap_chars in [0, 2, 300] {
797 let segmenter = TextSegmenter::new(TextSegmenterConfig {
798 max_chars,
799 overlap_chars,
800 ..TextSegmenterConfig::default()
801 });
802 let blocks = blocks_for(&source, format);
803 let new =
804 signature(segmenter.split_oversized(&source, blocks.clone()));
805 match std::panic::catch_unwind(|| {
806 old_split_oversized(&segmenter, &source, blocks)
807 }) {
808 Ok(old) => {
809 assert_eq!(
810 new,
811 signature(old),
812 "source={source:?}, format={format:?}, config={:?}",
813 segmenter.config
814 );
815 compared += 1;
816 }
817 Err(_) => formerly_panicked += 1,
818 }
819 }
820 }
821 }
822 }
823 }
824 }
825 assert!(compared > 0);
826 assert!(formerly_panicked > 0, "the table must exercise the old bug");
827 assert_eq!(compared + formerly_panicked, 12_960);
828 println!("{compared} identical successful splits; {formerly_panicked} former panics fixed");
829 }
830
831 #[test]
832 fn crlf_line_numbers() {
833 let src = "\r\na\r\n\r\nb\r\n";
834 let s = seg(src, TextFormat::Plain);
835 assert_eq!(s.len(), 1, "small paragraphs merge");
836 assert_eq!((s[0].line_start, s[0].line_end), (2, 4));
837 assert_eq!(s[0].text, "a\r\n\r\nb");
838 }
839}