1use regex::Regex;
2use std::sync::LazyLock;
3
4static QUOTED_PUNCT_END_RE: LazyLock<Regex> =
7 LazyLock::new(|| Regex::new(r##"[.!?]["')\]]+\s*$"##).expect("valid quoted-punct regex"));
8
9use crate::abbreviations;
10use crate::sentence::SentenceSplitter;
11
12static INLINE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(
16 &[
17 r"\[\[[^\]]*\]\]", r"\[\[[^\]]*\]\[[^\]]*\]\]", r"\[[^\]]+\]\([^)]+\)", r"!\[[^\]]*\]\([^)]+\)", r"\$[^$]+\$", r"\\([a-zA-Z]+)\{[^}]*\}", r"\*[^*\s\n](?:[^*\n]*[^*\s\n])?\*", r"/[^/\s\n](?:[^/\n]*[^/\s\n])?/", r"_[^_\s\n](?:[^_\n]*[^_\s\n])?_", r"\+[^\+\s\n](?:[^\+\n]*[^\+\s\n])?\+", r"~[^~\n]+~", r"=[^=\n]+=", r"`[^`\n]+`", r#"https?://\S+[^.\s!?,;:)\]'""]"#, r"file:\S+", r"@@[a-zA-Z]+:[^@]*@@", ]
39 .join("|"),
40 )
41 .expect("valid inline token regex")
42});
43
44pub struct UnicodeSentenceSplitter {
48 extra_pattern: Option<Regex>,
50 lang_abbrev_pattern: Regex,
52 lang_multi_pattern: Regex,
54}
55
56impl UnicodeSentenceSplitter {
57 pub fn new() -> Self {
59 Self::for_lang("en", &[])
60 }
61
62 pub fn with_extra_abbreviations(extras: &[String]) -> Self {
64 Self::for_lang("en", extras)
65 }
66
67 pub fn for_lang(lang: &str, extras: &[String]) -> Self {
69 let abbrevs = abbreviations::abbreviations_for_lang(lang);
70 let multi = abbreviations::multi_abbrevs_for_lang(lang);
71
72 let alts: Vec<&str> = abbrevs.to_vec();
73 let pattern = format!(r#"(?:^|[\s"'`(\[])(?:{})$"#, alts.join("|"));
74 let lang_abbrev_pattern = Regex::new(&pattern).expect("valid abbreviation regex");
75
76 let multi_alts: Vec<String> = multi.iter().map(|a| regex::escape(a)).collect();
77 let multi_pattern = format!(r"(?:^|\s)(?:{})$", multi_alts.join("|"));
78 let lang_multi_pattern =
79 Regex::new(&multi_pattern).expect("valid multi-abbreviation regex");
80
81 let extra_pattern = if extras.is_empty() {
82 None
83 } else {
84 let alts: Vec<String> = extras.iter().map(|a| regex::escape(a)).collect();
85 let pattern = format!(r"(?:^|\s)(?:{})$", alts.join("|"));
86 Some(Regex::new(&pattern).expect("valid extra abbreviation regex"))
87 };
88
89 Self {
90 extra_pattern,
91 lang_abbrev_pattern,
92 lang_multi_pattern,
93 }
94 }
95}
96
97impl Default for UnicodeSentenceSplitter {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103pub fn protect_inline_tokens(text: &str) -> (String, Vec<String>) {
106 let mut placeholders: Vec<String> = Vec::new();
107 let protected = INLINE_TOKEN_RE.replace_all(text, |caps: ®ex::Captures| {
108 let idx = placeholders.len();
109 placeholders.push(caps[0].to_string());
110 format!("\x00PH{idx}\x00")
111 });
112 (protected.into_owned(), placeholders)
113}
114
115pub fn restore_inline_tokens(segments: Vec<String>, placeholders: &[String]) -> Vec<String> {
117 segments
118 .into_iter()
119 .map(|s| {
120 let mut restored = s.trim().to_string();
121 for (i, original) in placeholders.iter().enumerate() {
122 let ph = format!("\x00PH{i}\x00");
123 restored = restored.replace(&ph, original);
124 }
125 restored
126 })
127 .filter(|s| !s.is_empty())
128 .collect()
129}
130
131impl SentenceSplitter for UnicodeSentenceSplitter {
132 fn split(&self, text: &str) -> Vec<String> {
133 let text = text.trim();
134 if text.is_empty() {
135 return vec![];
136 }
137
138 let (protected, placeholders) = protect_inline_tokens(text);
139
140 let raw_segments: Vec<&str> = merge_tail_punctuation(&protected);
147
148 if raw_segments.is_empty() {
149 return vec![text.to_string()];
150 }
151
152 let merged = self.refine_segments_from_strs(&raw_segments);
153 restore_inline_tokens(merged, &placeholders)
154 }
155}
156
157impl UnicodeSentenceSplitter {
158 pub fn refine_segments(&self, segments: Vec<String>) -> Vec<String> {
163 if segments.is_empty() {
164 return segments;
165 }
166 let refs: Vec<&str> = segments.iter().map(String::as_str).collect();
167 self.refine_segments_from_strs(&refs)
168 }
169
170 fn refine_segments_from_strs(&self, raw_segments: &[&str]) -> Vec<String> {
171 let merged = merge_abbreviation_splits(
172 raw_segments,
173 &self.lang_abbrev_pattern,
174 &self.lang_multi_pattern,
175 self.extra_pattern.as_ref(),
176 );
177 let merged = merge_quoted_punct_splits(merged);
178 merge_splits_inside_delimiters(merged)
179 }
180}
181
182fn merge_tail_punctuation(text: &str) -> Vec<&str> {
195 use unicode_segmentation::UnicodeSegmentation;
196
197 fn has_content(s: &str) -> bool {
198 s.chars().any(|c| c.is_alphanumeric())
199 }
200
201 let bounds: Vec<&str> = text.split_sentence_bounds().collect();
202 if bounds.is_empty() {
203 return Vec::new();
204 }
205
206 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(bounds.len());
210 let mut cursor: usize = 0;
211 for seg in &bounds {
212 let start = cursor;
213 let end = cursor + seg.len();
214 if has_content(seg) {
215 merged.push((start, end));
216 } else if let Some(last) = merged.last_mut() {
217 last.1 = end;
219 } else {
220 merged.push((start, end));
223 }
224 cursor = end;
225 }
226
227 merged.into_iter().map(|(s, e)| &text[s..e]).collect()
228}
229
230fn merge_abbreviation_splits(
231 segments: &[&str],
232 abbrev_re: &Regex,
233 multi_re: &Regex,
234 extra: Option<&Regex>,
235) -> Vec<String> {
236 let mut result: Vec<String> = Vec::with_capacity(segments.len());
237
238 for &segment in segments {
239 let should_merge = if let Some(prev) = result.last() {
240 is_abbreviation_ending(prev, abbrev_re, multi_re, extra)
241 } else {
242 false
243 };
244
245 if should_merge {
246 let prev = result.last_mut().unwrap();
247 push_segment_preserving_space(prev, segment);
248 } else {
249 result.push(segment.to_string());
250 }
251 }
252
253 result
254}
255
256fn push_segment_preserving_space(dest: &mut String, piece: &str) {
259 if piece.is_empty() {
260 return;
261 }
262 let need_space = dest.chars().last().is_some_and(|c| !c.is_whitespace())
263 && !piece.chars().next().is_some_and(|c| c.is_whitespace());
264 if need_space {
265 dest.push(' ');
266 }
267 dest.push_str(piece);
268}
269
270fn merge_quoted_punct_splits(segments: Vec<String>) -> Vec<String> {
274 let mut result: Vec<String> = Vec::with_capacity(segments.len());
275
276 for segment in segments {
277 let should_merge = if let Some(prev) = result.last() {
278 QUOTED_PUNCT_END_RE.is_match(prev.trim_end())
280 && segment
282 .trim_start()
283 .chars()
284 .next()
285 .is_some_and(|c| c.is_lowercase())
286 } else {
287 false
288 };
289
290 if should_merge {
291 let prev = result.last_mut().unwrap();
292 push_segment_preserving_space(prev, &segment);
293 } else {
294 result.push(segment);
295 }
296 }
297
298 result
299}
300
301fn merge_splits_inside_delimiters(segments: Vec<String>) -> Vec<String> {
306 let mut result: Vec<String> = Vec::with_capacity(segments.len());
307 let mut state = DelimState::default();
308
309 for segment in segments {
310 if state.is_inside() {
311 if let Some(last) = result.last_mut() {
312 push_segment_preserving_space(last, &segment);
313 } else {
314 result.push(segment.clone());
315 }
316 } else {
317 result.push(segment.clone());
318 }
319 state.feed(&segment);
320 }
321
322 result
323}
324
325#[derive(Debug, Default, Clone)]
328pub struct DelimState {
329 ascii_double_open: bool,
330 ascii_single_open: bool,
332 curly_double_depth: i32,
333 curly_single_depth: i32,
334 guillemet_depth: i32,
335 latex_quote_depth: i32,
336 paren_depth: i32,
337 bracket_depth: i32,
338 brace_depth: i32,
339 last_char: Option<char>,
341 pending_escape: bool,
343}
344
345impl DelimState {
346 pub fn is_inside(&self) -> bool {
347 self.ascii_double_open
348 || self.ascii_single_open
349 || self.curly_double_depth > 0
350 || self.curly_single_depth > 0
351 || self.guillemet_depth > 0
352 || self.latex_quote_depth > 0
353 || self.paren_depth > 0
354 || self.bracket_depth > 0
355 || self.brace_depth > 0
356 }
357
358 pub fn feed(&mut self, text: &str) {
362 let mut iter = text.chars().peekable();
365 while let Some(ch) = iter.next() {
366 let prev = self.last_char;
367 let next = iter.peek().copied();
368
369 if self.pending_escape {
370 self.pending_escape = false;
371 self.last_char = Some(ch);
372 continue;
373 }
374
375 if ch == '`' && next == Some('`') {
379 let _ = iter.next(); if iter.peek() == Some(&'`') {
381 while iter.peek() == Some(&'`') {
382 let _ = iter.next();
383 }
384 self.last_char = Some('`');
385 continue;
386 }
387 self.latex_quote_depth += 1;
388 self.last_char = Some('`');
389 continue;
390 }
391 if ch == '\'' && next == Some('\'') {
392 let _ = iter.next();
393 self.latex_quote_depth = (self.latex_quote_depth - 1).max(0);
394 self.last_char = Some('\'');
395 continue;
396 }
397
398 if ch == '\\' && matches!(next, Some('"') | Some('\'')) {
400 self.last_char = iter.next();
401 continue;
402 }
403 if ch == '\\' && next.is_none() {
404 self.pending_escape = true;
405 self.last_char = Some('\\');
406 continue;
407 }
408
409 match ch {
410 '"' => self.ascii_double_open = !self.ascii_double_open,
411 '\'' => self.feed_ascii_single(prev, next),
412 '\u{201C}' => self.curly_double_depth += 1,
414 '\u{201D}' => self.curly_double_depth = (self.curly_double_depth - 1).max(0),
415 '\u{2018}' => self.curly_single_depth += 1,
417 '\u{2019}' => {
418 if self.curly_single_depth > 0 {
421 self.curly_single_depth -= 1;
422 }
423 }
424 '\u{00AB}' => self.guillemet_depth += 1,
425 '\u{00BB}' => self.guillemet_depth = (self.guillemet_depth - 1).max(0),
426 '(' => self.paren_depth += 1,
427 ')' => self.paren_depth = (self.paren_depth - 1).max(0),
428 '[' => self.bracket_depth += 1,
429 ']' => self.bracket_depth = (self.bracket_depth - 1).max(0),
430 '{' if prev != Some('\\') => self.brace_depth += 1,
431 '}' if prev != Some('\\') => {
432 self.brace_depth = (self.brace_depth - 1).max(0);
433 }
434 _ => {}
435 }
436 self.last_char = Some(ch);
437 }
438 }
439
440 fn feed_ascii_single(&mut self, prev: Option<char>, next: Option<char>) {
443 let prev_alnum = prev.is_some_and(|c| c.is_alphanumeric());
444 let next_alnum = next.is_some_and(|c| c.is_alphanumeric());
445 if prev_alnum && next_alnum {
447 return;
448 }
449 if self.ascii_single_open {
450 self.ascii_single_open = false;
453 return;
454 }
455 let opener = match prev {
457 None => true,
458 Some(c) if c.is_whitespace() => true,
459 Some('(' | '[' | '{' | '"' | '\u{201C}' | '\u{00AB}') => true,
460 Some('.' | '!' | '?' | ':' | ';' | ',') => true,
461 _ => false,
462 };
463 if opener {
464 self.ascii_single_open = true;
465 }
466 }
467}
468
469pub fn newlines_respect_delimiter_spans(formatted: &str) -> bool {
479 let trimmed_end = formatted.trim_end_matches('\n');
480 if trimmed_end.is_empty() {
481 return true;
482 }
483 let mut state = DelimState::default();
484 for line in trimmed_end.split('\n') {
485 if state.is_inside() {
486 return false;
487 }
488 state.feed(line);
489 }
490 true
491}
492
493fn is_abbreviation_ending(
494 s: &str,
495 abbrev_re: &Regex,
496 multi_re: &Regex,
497 extra: Option<&Regex>,
498) -> bool {
499 let trimmed = s.trim_end();
500 if !trimmed.ends_with('.') {
501 return false;
502 }
503 let before_dot = &trimmed[..trimmed.len() - 1];
504
505 if abbrev_re.is_match(before_dot) {
506 return true;
507 }
508
509 if multi_re.is_match(before_dot) {
510 return true;
511 }
512
513 if let Some(re) = extra {
514 if re.is_match(before_dot) {
515 return true;
516 }
517 }
518
519 false
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn split(text: &str) -> Vec<String> {
527 UnicodeSentenceSplitter::new().split(text)
528 }
529
530 #[test]
531 fn simple_sentences() {
532 assert_eq!(
533 split("Hello world. This is a test. Another sentence here."),
534 vec!["Hello world.", "This is a test.", "Another sentence here."]
535 );
536 }
537
538 #[test]
539 fn abbreviation_dr() {
540 assert_eq!(
541 split("Dr. Smith went home. He was tired."),
542 vec!["Dr. Smith went home.", "He was tired."]
543 );
544 }
545
546 #[test]
547 fn abbreviation_eg() {
548 assert_eq!(
549 split("Use a formatter, e.g. snapper. It works well."),
550 vec!["Use a formatter, e.g. snapper.", "It works well."]
551 );
552 }
553
554 #[test]
555 fn abbreviation_fig() {
556 assert_eq!(
557 split("See Fig. 3 for details. The results are clear."),
558 vec!["See Fig. 3 for details.", "The results are clear."]
559 );
560 }
561
562 #[test]
563 fn empty_input() {
564 assert_eq!(split(""), Vec::<String>::new());
565 }
566
567 #[test]
568 fn single_sentence() {
569 assert_eq!(split("Just one sentence."), vec!["Just one sentence."]);
570 }
571
572 #[test]
573 fn question_and_exclamation() {
574 assert_eq!(
575 split("Is this working? Yes! It is."),
576 vec!["Is this working?", "Yes!", "It is."]
577 );
578 }
579
580 #[test]
581 fn no_trailing_period() {
582 assert_eq!(
583 split("First sentence. Second without period"),
584 vec!["First sentence.", "Second without period"]
585 );
586 }
587
588 #[test]
589 fn extra_abbreviations() {
590 let splitter = UnicodeSentenceSplitter::with_extra_abbreviations(&[
593 "Abstr".to_string(),
594 "Suppl".to_string(),
595 ]);
596 assert_eq!(
597 splitter.split("See Abstr. 5 for details. The results follow."),
598 vec!["See Abstr. 5 for details.", "The results follow."]
599 );
600 let default = UnicodeSentenceSplitter::new();
602 let result = default.split("See Abstr. 5 for details. The results follow.");
603 assert!(result.len() > 1);
605 }
606
607 #[test]
608 fn inline_org_link_preserved() {
609 assert_eq!(
610 split("See [[https://example.com][Ex. Site]] for details. Then continue."),
611 vec![
612 "See [[https://example.com][Ex. Site]] for details.",
613 "Then continue."
614 ]
615 );
616 }
617
618 #[test]
619 fn inline_math_preserved() {
620 assert_eq!(
621 split("The value $x = 3.14$ matters. Next sentence."),
622 vec!["The value $x = 3.14$ matters.", "Next sentence."]
623 );
624 }
625
626 #[test]
627 fn inline_markdown_link_preserved() {
628 assert_eq!(
629 split("Visit [Example Inc.](https://example.com) now. Then read more."),
630 vec now.",
632 "Then read more."
633 ]
634 );
635 }
636
637 #[test]
638 fn inline_code_preserved() {
639 assert_eq!(
640 split("Use `std.io.Read` for input. Then process."),
641 vec!["Use `std.io.Read` for input.", "Then process."]
642 );
643 }
644
645 #[test]
646 fn org_bold_with_internal_period_not_split() {
647 assert_eq!(
649 split("End of first. *Bold spans period. Continues* after."),
650 vec!["End of first.", "*Bold spans period. Continues* after."]
651 );
652 }
653
654 #[test]
655 fn org_italic_with_internal_period_not_split() {
656 assert_eq!(
657 split("Lead-in. /Italic has a period. Still italic/ trail."),
658 vec!["Lead-in.", "/Italic has a period. Still italic/ trail."]
659 );
660 }
661
662 #[test]
663 fn angle_bracket_tail_after_period_preserved() {
664 assert_eq!(
666 split("snapshot field is Box[T], not Vec[T]"),
667 vec!["snapshot field is Box[T], not Vec[T]"]
668 );
669 assert_eq!(split("see <a.>"), vec!["see <a.>"]);
670 }
671
672 #[test]
673 fn double_quoted_span_with_internal_period_not_split() {
674 assert_eq!(
675 split(r#"He said "Hello world. How are you?" Then he left."#),
676 vec![r#"He said "Hello world. How are you?""#, "Then he left."]
677 );
678 }
679
680 #[test]
681 fn curly_double_quoted_span_with_internal_period_not_split() {
682 assert_eq!(
683 split("He said \u{201C}Hello world. How are you?\u{201D} Then he left."),
684 vec![
685 "He said \u{201C}Hello world. How are you?\u{201D}",
686 "Then he left."
687 ]
688 );
689 }
690
691 #[test]
692 fn quoted_title_with_abbrev_stays_one_sentence() {
693 assert_eq!(
694 split(r#"See the note "Fig. 3 is wrong." in the appendix."#),
695 vec![r#"See the note "Fig. 3 is wrong." in the appendix."#]
696 );
697 }
698
699 #[test]
700 fn plaintext_format_keeps_dialogue_quote_together() {
701 use crate::format::Format;
702 use crate::{FormatConfig, format_text};
703
704 let input = "He said \"Hello world. How are you?\" Then he left.\n";
705 let cfg = FormatConfig {
706 format: Format::Plaintext,
707 ..Default::default()
708 };
709 let out = format_text(input, &cfg).unwrap();
710 assert!(
711 !out.contains("world.\nHow"),
712 "must not break inside ASCII double quotes, got:\n{out}"
713 );
714 assert!(
715 out.contains("you?\"\nThen") || out.contains("you?\" Then"),
716 "may break after closing quote; got:\n{out}"
717 );
718 assert_eq!(format_text(&out, &cfg).unwrap(), out);
719 }
720
721 #[test]
722 fn paren_span_with_internal_period_capital_not_split() {
723 assert_eq!(
724 split("See (Fig. 3 is wrong. Really.) Next."),
725 vec!["See (Fig. 3 is wrong. Really.)", "Next."]
726 );
727 }
728
729 #[test]
730 fn bracket_span_with_internal_period_not_split() {
731 assert_eq!(
732 split("See [note. One] more."),
733 vec!["See [note. One] more."]
734 );
735 }
736
737 #[test]
738 fn latex_style_quotes_with_internal_period_not_split() {
739 assert_eq!(
740 split("He said ``Hello world. How?'' Then."),
741 vec!["He said ``Hello world. How?''", "Then."]
742 );
743 }
744
745 #[test]
746 fn escaped_ascii_quote_does_not_toggle_early() {
747 let out = split(r#"She said "He said \"no.\" Then left." Done."#);
750 assert_eq!(out.len(), 2, "got {out:?}");
751 assert!(
752 out[0].contains(r#"\"no.\""#) || out[0].contains("no."),
753 "{out:?}"
754 );
755 assert_eq!(out[1], "Done.");
756 }
757
758 #[test]
759 fn single_quoted_dialogue_with_internal_period_not_split() {
760 assert_eq!(
761 split("He said 'Hello world. How are you?' Then he left."),
762 vec!["He said 'Hello world. How are you?'", "Then he left."]
763 );
764 }
765
766 #[test]
767 fn apostrophe_contractions_still_split_sentences() {
768 assert_eq!(
769 split("Don't split here. Next sentence."),
770 vec!["Don't split here.", "Next sentence."]
771 );
772 assert_eq!(
773 split("It's fine. She said 'Go. Now.' Done."),
774 vec!["It's fine.", "She said 'Go. Now.'", "Done."]
775 );
776 }
777
778 #[test]
779 fn curly_single_quoted_dialogue_not_split() {
780 assert_eq!(
781 split("He said \u{2018}Hello world. How?\u{2019} Then."),
782 vec!["He said \u{2018}Hello world. How?\u{2019}", "Then."]
783 );
784 }
785
786 #[test]
787 fn newlines_invariant_holds_on_dialogue_output() {
788 use crate::format::Format;
789 use crate::{FormatConfig, format_text};
790
791 let samples = [
792 "He said \"Hello world. How are you?\" Then he left.\n",
793 "He said 'Hello world. How are you?' Then he left.\n",
794 "See (Fig. 3 is wrong. Really.) Next.\n",
795 "See [note. One] more. Trailing.\n",
796 "He said ``Hello world. How?'' Then.\n",
797 "Don't stop. It's ok. Done.\n",
798 ];
799 let cfg = FormatConfig {
800 format: Format::Plaintext,
801 ..Default::default()
802 };
803 for input in samples {
804 let out = format_text(input, &cfg).unwrap();
805 assert!(
806 newlines_respect_delimiter_spans(&out),
807 "newline inside delimiter span for input {input:?}, out:\n{out}"
808 );
809 assert_eq!(
810 format_text(&out, &cfg).unwrap(),
811 out,
812 "idempotence {input:?}"
813 );
814 }
815 }
816
817 #[test]
818 fn quoted_exclamation_no_false_split() {
819 assert_eq!(
820 split(r#"He said "wow!" and left. She agreed."#),
821 vec![r#"He said "wow!" and left."#, "She agreed."]
822 );
823 }
824
825 #[test]
826 fn paren_exclamation_no_false_split() {
827 assert_eq!(
828 split("He replied (with emphasis!) loudly. She agreed."),
829 vec!["He replied (with emphasis!) loudly.", "She agreed."]
830 );
831 }
832
833 #[test]
834 fn paren_question_no_false_split() {
835 assert_eq!(
836 split("The answer (really?) surprised them. Next sentence."),
837 vec!["The answer (really?) surprised them.", "Next sentence."]
838 );
839 }
840
841 #[test]
842 fn url_trailing_period_not_swallowed() {
843 assert_eq!(
844 split("Visit https://example.com/path. Then read more."),
845 vec!["Visit https://example.com/path.", "Then read more."]
846 );
847 }
848
849 #[test]
850 fn url_with_query_trailing_period() {
851 assert_eq!(
852 split("See https://example.com/path?q=1&r=2. Next sentence."),
853 vec!["See https://example.com/path?q=1&r=2.", "Next sentence."]
854 );
855 }
856
857 #[test]
858 fn ellipsis_splits() {
859 assert_eq!(
860 split("Sentence one... Sentence two."),
861 vec!["Sentence one...", "Sentence two."]
862 );
863 }
864
865 #[test]
866 fn quoted_period_end_of_sentence() {
867 assert_eq!(
869 split(r#"End of quote: "done." Start again."#),
870 vec![r#"End of quote: "done.""#, "Start again."]
871 );
872 }
873}