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"\$\$[^$\n]+\$\$", r"\$[^$\n]+\$", r"\\\([^\\\n]+\\\)", 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"<[A-Za-z][A-Za-z0-9+.\-]*:[^\s<>]*>", r"<[^\s<>@]+@[^\s<>]+>", r#"https?://\S+[^.\s!?,;:)\]'""]"#, r"file:\S+", r"@@[a-zA-Z]+:[^@]*@@", ]
44 .join("|"),
45 )
46 .expect("valid inline token regex")
47});
48
49pub struct UnicodeSentenceSplitter {
53 extra_pattern: Option<Regex>,
55 lang_abbrev_pattern: Regex,
57 lang_multi_pattern: Regex,
59 extra_verbatim_commands: Vec<String>,
61}
62
63impl UnicodeSentenceSplitter {
64 pub fn new() -> Self {
66 Self::for_lang("en", &[])
67 }
68
69 pub fn with_extra_abbreviations(extras: &[String]) -> Self {
71 Self::for_lang("en", extras)
72 }
73
74 pub fn for_lang(lang: &str, extras: &[String]) -> Self {
76 let abbrevs = abbreviations::abbreviations_for_lang(lang);
77 let multi = abbreviations::multi_abbrevs_for_lang(lang);
78
79 let alts: Vec<&str> = abbrevs.to_vec();
80 let pattern = format!(r#"(?:^|[\s"'`(\[])(?:{})$"#, alts.join("|"));
81 let lang_abbrev_pattern = Regex::new(&pattern).expect("valid abbreviation regex");
82
83 let multi_alts: Vec<String> = multi.iter().map(|a| regex::escape(a)).collect();
84 let multi_pattern = format!(r"(?:^|\s)(?:{})$", multi_alts.join("|"));
85 let lang_multi_pattern =
86 Regex::new(&multi_pattern).expect("valid multi-abbreviation regex");
87
88 let extra_pattern = if extras.is_empty() {
89 None
90 } else {
91 let alts: Vec<String> = extras.iter().map(|a| regex::escape(a)).collect();
92 let pattern = format!(r"(?:^|\s)(?:{})$", alts.join("|"));
93 Some(Regex::new(&pattern).expect("valid extra abbreviation regex"))
94 };
95
96 Self {
97 extra_pattern,
98 lang_abbrev_pattern,
99 lang_multi_pattern,
100 extra_verbatim_commands: Vec::new(),
101 }
102 }
103
104 pub fn with_verbatim_commands(mut self, cmds: Vec<String>) -> Self {
106 self.extra_verbatim_commands = cmds;
107 self
108 }
109
110 pub(crate) fn verbatim_commands(&self) -> &[String] {
111 &self.extra_verbatim_commands
112 }
113}
114
115impl Default for UnicodeSentenceSplitter {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121pub fn protect_inline_tokens(text: &str) -> (String, Vec<String>) {
124 protect_inline_tokens_with(text, &[])
125}
126
127pub fn protect_inline_tokens_with(
130 text: &str,
131 extra_verbatim_commands: &[String],
132) -> (String, Vec<String>) {
133 let mut placeholders: Vec<String> = Vec::new();
134 let after_verb = protect_latex_verbatim(text, &mut placeholders, extra_verbatim_commands);
135 let after_spans = protect_paired_spans(&after_verb, &mut placeholders);
136 let protected = INLINE_TOKEN_RE.replace_all(&after_spans, |caps: ®ex::Captures| {
137 let idx = placeholders.len();
138 placeholders.push(caps[0].to_string());
139 format!("\x00PH{idx}\x00")
140 });
141 (protected.into_owned(), placeholders)
142}
143
144fn protect_latex_verbatim(
146 text: &str,
147 placeholders: &mut Vec<String>,
148 extra_verbatim_commands: &[String],
149) -> String {
150 let mut out = String::with_capacity(text.len());
151 let bytes = text.as_bytes();
152 let mut i = 0;
153 while i < text.len() {
154 if bytes[i] == b'\\' {
155 if let Some(end) = latex_verb_span_end_with(text, i, extra_verbatim_commands) {
156 push_placeholder(&mut out, placeholders, &text[i..end]);
157 i = end;
158 continue;
159 }
160 }
161 let ch = text[i..].chars().next().expect("i is in range");
162 out.push(ch);
163 i += ch.len_utf8();
164 }
165 out
166}
167
168pub(crate) fn latex_verb_span_end_with(
176 text: &str,
177 at: usize,
178 extra_verbatim_commands: &[String],
179) -> Option<usize> {
180 let rest = text.get(at..)?;
181 if !rest.starts_with('\\') {
182 return None;
183 }
184 let after_bs = at + 1;
185 let tail = text.get(after_bs..)?;
186 let (mut i, is_lst) = if let Some(stripped) = tail.strip_prefix("lstinline") {
187 if stripped.starts_with(|c: char| c.is_ascii_alphabetic()) {
188 return None;
189 }
190 (after_bs + "lstinline".len(), true)
191 } else if let Some(stripped) = tail.strip_prefix("verb") {
192 if stripped.starts_with(|c: char| c.is_ascii_alphabetic()) {
193 return None;
194 }
195 (after_bs + "verb".len(), false)
196 } else {
197 let name = match_extra_verb_command(tail, extra_verbatim_commands)?;
198 (after_bs + name.len(), false)
199 };
200
201 if text.get(i..)?.starts_with('*') {
202 i += 1;
203 }
204
205 if is_lst {
206 i = skip_ascii_ws(text, i);
207 if text.get(i..).is_some_and(|s| s.starts_with('[')) {
208 match skip_bracket_group(text, i) {
209 Some(end) => i = skip_ascii_ws(text, end),
210 None => return Some(line_end(text, i)),
211 }
212 }
213 }
214
215 let delim = text.get(i..).and_then(|s| s.chars().next())?;
216 if delim == '\n' {
217 return None;
218 }
219 i += delim.len_utf8();
220
221 if is_lst && delim == '{' {
222 return Some(find_unescaped_brace_close(text, i).unwrap_or_else(|| line_end(text, i)));
223 }
224
225 while i < text.len() {
226 let ch = text[i..].chars().next()?;
227 if ch == '\n' {
228 return Some(i);
229 }
230 if ch == delim {
231 return Some(i + ch.len_utf8());
232 }
233 i += ch.len_utf8();
234 }
235 Some(text.len())
236}
237
238fn line_end(text: &str, from: usize) -> usize {
239 text[from..]
240 .find('\n')
241 .map(|rel| from + rel)
242 .unwrap_or(text.len())
243}
244
245fn match_extra_verb_command<'a>(tail: &'a str, extras: &'a [String]) -> Option<&'a str> {
248 let mut best: Option<&str> = None;
249 for name in extras {
250 if name.is_empty() || name == "verb" || name == "lstinline" {
251 continue;
252 }
253 let Some(stripped) = tail.strip_prefix(name.as_str()) else {
254 continue;
255 };
256 if stripped.starts_with(|c: char| c.is_ascii_alphabetic()) {
257 continue;
258 }
259 if best.is_none_or(|b| name.len() > b.len()) {
260 best = Some(name.as_str());
261 }
262 }
263 best
264}
265
266fn skip_ascii_ws(text: &str, mut i: usize) -> usize {
267 while i < text.len() && matches!(text.as_bytes()[i], b' ' | b'\t') {
268 i += 1;
269 }
270 i
271}
272
273fn skip_bracket_group(text: &str, open_at: usize) -> Option<usize> {
274 let bytes = text.as_bytes();
275 if bytes.get(open_at) != Some(&b'[') {
276 return None;
277 }
278 let mut depth = 0;
279 let mut i = open_at;
280 while i < bytes.len() {
281 match bytes[i] {
282 b'\n' => return None,
283 b'[' => depth += 1,
284 b']' => {
285 depth -= 1;
286 if depth == 0 {
287 return Some(i + 1);
288 }
289 }
290 _ => {}
291 }
292 i += 1;
293 }
294 None
295}
296
297fn find_unescaped_brace_close(text: &str, mut i: usize) -> Option<usize> {
298 let bytes = text.as_bytes();
299 while i < bytes.len() {
300 if bytes[i] == b'\n' {
301 return None;
302 }
303 if bytes[i] == b'\\' && i + 1 < bytes.len() {
304 i += 2;
305 continue;
306 }
307 if bytes[i] == b'}' {
308 return Some(i + 1);
309 }
310 i += 1;
311 }
312 None
313}
314
315fn protect_paired_spans(text: &str, placeholders: &mut Vec<String>) -> String {
335 let mut out = String::with_capacity(text.len());
336 let bytes = text.as_bytes();
337 let mut i = 0;
338 while i < text.len() {
339 if bytes[i] == b'`' {
340 if let Some(end) = find_md_code_span(text, i) {
341 push_placeholder(&mut out, placeholders, &text[i..end]);
342 i = end;
343 continue;
344 }
345 } else if bytes[i] == b'=' {
346 if let Some(end) = find_org_paired_span(text, i, '=') {
347 push_placeholder(&mut out, placeholders, &text[i..end]);
348 i = end;
349 continue;
350 }
351 } else if bytes[i] == b'~' {
352 if let Some(end) = find_md_strike_span(text, i) {
355 push_placeholder(&mut out, placeholders, &text[i..end]);
356 i = end;
357 continue;
358 }
359 if let Some(end) = find_org_paired_span(text, i, '~') {
360 push_placeholder(&mut out, placeholders, &text[i..end]);
361 i = end;
362 continue;
363 }
364 } else if bytes[i] == b'*' {
365 if let Some(end) = find_md_emphasis_span(text, i) {
366 push_placeholder(&mut out, placeholders, &text[i..end]);
367 i = end;
368 continue;
369 }
370 }
371 let ch = text[i..].chars().next().expect("i is in range");
372 out.push(ch);
373 i += ch.len_utf8();
374 }
375 out
376}
377
378fn push_placeholder(out: &mut String, placeholders: &mut Vec<String>, span: &str) {
379 let idx = placeholders.len();
380 placeholders.push(span.to_string());
381 out.push_str(&format!("\x00PH{idx}\x00"));
382}
383
384fn find_org_paired_span(text: &str, open_at: usize, marker: char) -> Option<usize> {
385 const PRE: &str = " \t\n('\"{";
388 const POST: &str = " \t\n-.,:!?;'\")}[";
389
390 if open_at > 0 {
391 let prev = text[..open_at].chars().next_back()?;
392 if !PRE.contains(prev) {
393 return None;
394 }
395 }
396 let after_open = open_at + marker.len_utf8();
397 if after_open >= text.len() {
398 return None;
399 }
400 let first = text[after_open..].chars().next()?;
401 if first.is_whitespace() {
402 return None;
403 }
404
405 let mut j = after_open;
406 while j < text.len() {
407 let ch = text[j..].chars().next()?;
408 if ch == '\n' {
409 return None;
410 }
411 if ch == marker && j > after_open {
412 let prev = text[..j].chars().next_back()?;
413 if !prev.is_whitespace() {
414 let after_close = j + marker.len_utf8();
415 let post_ok =
416 after_close == text.len() || POST.contains(text[after_close..].chars().next()?);
417 if post_ok {
418 return Some(after_close);
419 }
420 }
421 }
422 j += ch.len_utf8();
423 }
424 None
425}
426
427fn find_md_emphasis_span(text: &str, open_at: usize) -> Option<usize> {
435 let bytes = text.as_bytes();
436 if bytes.get(open_at) != Some(&b'*') {
437 return None;
438 }
439 let n = count_ascii_run(bytes, open_at, b'*');
440 if n == 0 {
441 return None;
442 }
443 let before = md_edge_char(text, open_at, false);
444 let after = md_edge_char(text, open_at + n, true);
445 let (left, right) = md_flanking(before, after);
446 if !(left && (!right || is_md_punctuation(before))) {
447 return None;
448 }
449
450 let mut j = open_at + n;
451 while j < text.len() {
452 let ch = text[j..].chars().next()?;
453 if ch == '*' {
454 let m = count_ascii_run(bytes, j, b'*');
455 let c_before = md_edge_char(text, j, false);
456 let c_after = md_edge_char(text, j + m, true);
457 let (c_left, c_right) = md_flanking(c_before, c_after);
458 let can_close = c_right && (!c_left || is_md_punctuation(c_after));
459 let three_ok = ((n + m) % 3 != 0) || (n % 3 == 0);
460 if can_close && three_ok && j > open_at + n {
461 return Some(j + m);
462 }
463 j += m;
464 continue;
465 }
466 j += ch.len_utf8();
467 }
468 None
469}
470
471fn find_md_strike_span(text: &str, open_at: usize) -> Option<usize> {
475 let bytes = text.as_bytes();
476 if bytes.get(open_at) != Some(&b'~') || bytes.get(open_at + 1) != Some(&b'~') {
477 return None;
478 }
479 if bytes.get(open_at + 2) == Some(&b'~') {
480 return None;
481 }
482 let after_open = open_at + 2;
483 if after_open >= text.len() {
484 return None;
485 }
486 let first = text[after_open..].chars().next()?;
487 if first.is_whitespace() {
488 return None;
489 }
490 let mut j = after_open;
491 while j < text.len() {
492 let ch = text[j..].chars().next()?;
493 if ch == '~'
494 && bytes.get(j + 1) == Some(&b'~')
495 && bytes.get(j + 2) != Some(&b'~')
496 && j > after_open
497 {
498 let prev = text[..j].chars().next_back()?;
499 if !prev.is_whitespace() {
500 return Some(j + 2);
501 }
502 }
503 j += ch.len_utf8();
504 }
505 None
506}
507
508fn count_ascii_run(bytes: &[u8], start: usize, marker: u8) -> usize {
509 let mut n = 0;
510 while start + n < bytes.len() && bytes[start + n] == marker {
511 n += 1;
512 }
513 n
514}
515
516fn md_edge_char(text: &str, byte: usize, after: bool) -> char {
517 if after {
518 if byte >= text.len() {
519 '\n'
520 } else {
521 text[byte..].chars().next().unwrap_or('\n')
522 }
523 } else if byte == 0 {
524 '\n'
525 } else {
526 text[..byte].chars().next_back().unwrap_or('\n')
527 }
528}
529
530fn is_md_punctuation(c: char) -> bool {
531 if c.is_ascii() {
532 c.is_ascii_punctuation()
533 } else {
534 !c.is_alphanumeric() && !c.is_whitespace()
535 }
536}
537
538fn md_flanking(before: char, after: char) -> (bool, bool) {
539 let after_ws = after.is_whitespace();
540 let before_ws = before.is_whitespace();
541 let after_p = is_md_punctuation(after);
542 let before_p = is_md_punctuation(before);
543 let left = !after_ws && (!after_p || before_ws || before_p);
544 let right = !before_ws && (!before_p || after_ws || after_p);
545 (left, right)
546}
547
548fn find_md_code_span(text: &str, open_at: usize) -> Option<usize> {
549 let bytes = text.as_bytes();
550 if bytes.get(open_at) != Some(&b'`') {
551 return None;
552 }
553 let mut n = 0usize;
554 while open_at + n < bytes.len() && bytes[open_at + n] == b'`' {
555 n += 1;
556 }
557 let mut j = open_at + n;
558 while j < bytes.len() {
559 if bytes[j] == b'\n' {
560 return None;
561 }
562 if bytes[j] == b'`' {
563 let mut m = 0usize;
564 while j + m < bytes.len() && bytes[j + m] == b'`' {
565 m += 1;
566 }
567 if m == n && j > open_at + n {
568 return Some(j + m);
569 }
570 j += m;
571 } else {
572 j += 1;
573 }
574 }
575 None
576}
577
578pub fn atomic_inline_spans(text: &str) -> Vec<(usize, usize)> {
584 let mut spans = Vec::new();
585 let bytes = text.as_bytes();
586 let mut i = 0;
587 while i < text.len() {
588 if bytes[i] == b'`' {
589 if let Some(end) = find_md_code_span(text, i) {
590 spans.push((i, end));
591 i = end;
592 continue;
593 }
594 } else if bytes[i] == b'=' || bytes[i] == b'~' {
595 let marker = bytes[i] as char;
596 if let Some(end) = find_org_paired_span(text, i, marker) {
597 spans.push((i, end));
598 i = end;
599 continue;
600 }
601 }
602 let ch = text[i..].chars().next().expect("i is in range");
603 i += ch.len_utf8();
604 }
605 for m in INLINE_TOKEN_RE.find_iter(text) {
606 spans.push((m.start(), m.end()));
607 }
608 merge_byte_ranges(spans)
609}
610
611fn merge_byte_ranges(mut spans: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
612 if spans.len() <= 1 {
613 return spans;
614 }
615 spans.sort_unstable_by_key(|&(start, _)| start);
616 let mut out = Vec::with_capacity(spans.len());
617 let mut cur = spans[0];
618 for &(start, end) in &spans[1..] {
619 if start <= cur.1 {
620 cur.1 = cur.1.max(end);
621 } else {
622 out.push(cur);
623 cur = (start, end);
624 }
625 }
626 out.push(cur);
627 out
628}
629
630pub fn restore_inline_tokens(segments: Vec<String>, placeholders: &[String]) -> Vec<String> {
637 segments
638 .into_iter()
639 .map(|s| {
640 let mut restored = s.trim().to_string();
641 for (i, original) in placeholders.iter().enumerate().rev() {
642 let ph = format!("\x00PH{i}\x00");
643 restored = restored.replace(&ph, original);
644 }
645 restored
646 })
647 .filter(|s| !s.is_empty())
648 .collect()
649}
650
651impl SentenceSplitter for UnicodeSentenceSplitter {
652 fn split(&self, text: &str) -> Vec<String> {
653 let text = text.trim();
654 if text.is_empty() {
655 return vec![];
656 }
657
658 let (protected, placeholders) =
659 protect_inline_tokens_with(text, &self.extra_verbatim_commands);
660
661 let raw_segments: Vec<&str> = merge_tail_punctuation(&protected);
668
669 if raw_segments.is_empty() {
670 return vec![text.to_string()];
671 }
672
673 let merged = self.refine_segments_from_strs(&raw_segments);
674 restore_inline_tokens(merged, &placeholders)
675 }
676}
677
678impl UnicodeSentenceSplitter {
679 pub fn refine_segments(&self, segments: Vec<String>) -> Vec<String> {
684 if segments.is_empty() {
685 return segments;
686 }
687 let refs: Vec<&str> = segments.iter().map(String::as_str).collect();
688 self.refine_segments_from_strs(&refs)
689 }
690
691 fn refine_segments_from_strs(&self, raw_segments: &[&str]) -> Vec<String> {
692 let merged = merge_abbreviation_splits(
693 raw_segments,
694 &self.lang_abbrev_pattern,
695 &self.lang_multi_pattern,
696 self.extra_pattern.as_ref(),
697 );
698 let merged = merge_quoted_punct_splits(merged);
699 merge_splits_inside_delimiters(merged)
700 }
701}
702
703fn merge_tail_punctuation(text: &str) -> Vec<&str> {
716 use unicode_segmentation::UnicodeSegmentation;
717
718 fn has_content(s: &str) -> bool {
719 s.chars().any(|c| c.is_alphanumeric())
720 }
721
722 let bounds: Vec<&str> = text.split_sentence_bounds().collect();
723 if bounds.is_empty() {
724 return Vec::new();
725 }
726
727 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(bounds.len());
731 let mut cursor: usize = 0;
732 for seg in &bounds {
733 let start = cursor;
734 let end = cursor + seg.len();
735 if has_content(seg) {
736 merged.push((start, end));
737 } else if let Some(last) = merged.last_mut() {
738 last.1 = end;
740 } else {
741 merged.push((start, end));
744 }
745 cursor = end;
746 }
747
748 merged.into_iter().map(|(s, e)| &text[s..e]).collect()
749}
750
751fn merge_abbreviation_splits(
752 segments: &[&str],
753 abbrev_re: &Regex,
754 multi_re: &Regex,
755 extra: Option<&Regex>,
756) -> Vec<String> {
757 let mut result: Vec<String> = Vec::with_capacity(segments.len());
758
759 for &segment in segments {
760 let should_merge = if let Some(prev) = result.last() {
761 is_abbreviation_ending(prev, abbrev_re, multi_re, extra)
762 } else {
763 false
764 };
765
766 if should_merge {
767 let prev = result.last_mut().unwrap();
768 push_segment_preserving_space(prev, segment);
769 } else {
770 result.push(segment.to_string());
771 }
772 }
773
774 result
775}
776
777fn push_segment_preserving_space(dest: &mut String, piece: &str) {
785 if piece.is_empty() {
786 return;
787 }
788 let next = piece.chars().next();
789 let need_space = dest.chars().last().is_some_and(|c| !c.is_whitespace())
790 && next.is_some_and(|c| {
791 !c.is_whitespace() && (c.is_alphanumeric() || matches!(c, '"' | '\'' | '`' | '('))
792 });
793 if need_space {
794 dest.push(' ');
795 }
796 dest.push_str(piece);
797}
798
799fn merge_quoted_punct_splits(segments: Vec<String>) -> Vec<String> {
803 let mut result: Vec<String> = Vec::with_capacity(segments.len());
804
805 for segment in segments {
806 let should_merge = if let Some(prev) = result.last() {
807 QUOTED_PUNCT_END_RE.is_match(prev.trim_end())
809 && segment
811 .trim_start()
812 .chars()
813 .next()
814 .is_some_and(|c| c.is_lowercase())
815 } else {
816 false
817 };
818
819 if should_merge {
820 let prev = result.last_mut().unwrap();
821 push_segment_preserving_space(prev, &segment);
822 } else {
823 result.push(segment);
824 }
825 }
826
827 result
828}
829
830fn merge_splits_inside_delimiters(segments: Vec<String>) -> Vec<String> {
835 let mut result: Vec<String> = Vec::with_capacity(segments.len());
836 let mut state = DelimState::default();
837
838 for segment in segments {
839 if state.is_inside() {
840 if let Some(last) = result.last_mut() {
841 push_segment_preserving_space(last, &segment);
842 } else {
843 result.push(segment.clone());
844 }
845 } else {
846 result.push(segment.clone());
847 }
848 state.feed(&segment);
849 }
850
851 result
852}
853
854#[derive(Debug, Default, Clone)]
857pub struct DelimState {
858 ascii_double_open: bool,
859 ascii_single_open: bool,
861 curly_double_depth: i32,
862 curly_single_depth: i32,
863 guillemet_depth: i32,
864 latex_quote_depth: i32,
865 paren_depth: i32,
866 bracket_depth: i32,
867 brace_depth: i32,
868 last_char: Option<char>,
870 pending_escape: bool,
872}
873
874impl DelimState {
875 pub fn is_inside(&self) -> bool {
876 self.ascii_double_open
877 || self.ascii_single_open
878 || self.curly_double_depth > 0
879 || self.curly_single_depth > 0
880 || self.guillemet_depth > 0
881 || self.latex_quote_depth > 0
882 || self.paren_depth > 0
883 || self.bracket_depth > 0
884 || self.brace_depth > 0
885 }
886
887 pub fn feed(&mut self, text: &str) {
891 let mut iter = text.chars().peekable();
894 while let Some(ch) = iter.next() {
895 let prev = self.last_char;
896 let next = iter.peek().copied();
897
898 if self.pending_escape {
899 self.pending_escape = false;
900 self.last_char = Some(ch);
901 continue;
902 }
903
904 if ch == '`' && next == Some('`') {
908 let _ = iter.next(); if iter.peek() == Some(&'`') {
910 while iter.peek() == Some(&'`') {
911 let _ = iter.next();
912 }
913 self.last_char = Some('`');
914 continue;
915 }
916 self.latex_quote_depth += 1;
917 self.last_char = Some('`');
918 continue;
919 }
920 if ch == '\'' && next == Some('\'') {
921 let _ = iter.next();
922 self.latex_quote_depth = (self.latex_quote_depth - 1).max(0);
923 self.last_char = Some('\'');
924 continue;
925 }
926
927 if ch == '\\' && matches!(next, Some('"') | Some('\'')) {
929 self.last_char = iter.next();
930 continue;
931 }
932 if ch == '\\' && next.is_none() {
933 self.pending_escape = true;
934 self.last_char = Some('\\');
935 continue;
936 }
937
938 match ch {
939 '"' => self.ascii_double_open = !self.ascii_double_open,
940 '\'' => self.feed_ascii_single(prev, next),
941 '\u{201C}' => self.curly_double_depth += 1,
943 '\u{201D}' => self.curly_double_depth = (self.curly_double_depth - 1).max(0),
944 '\u{2018}' => self.curly_single_depth += 1,
946 '\u{2019}' => {
947 if self.curly_single_depth > 0 {
950 self.curly_single_depth -= 1;
951 }
952 }
953 '\u{00AB}' => self.guillemet_depth += 1,
954 '\u{00BB}' => self.guillemet_depth = (self.guillemet_depth - 1).max(0),
955 '(' => self.paren_depth += 1,
956 ')' => self.paren_depth = (self.paren_depth - 1).max(0),
957 '[' => self.bracket_depth += 1,
958 ']' => self.bracket_depth = (self.bracket_depth - 1).max(0),
959 '{' if prev != Some('\\') => self.brace_depth += 1,
960 '}' if prev != Some('\\') => {
961 self.brace_depth = (self.brace_depth - 1).max(0);
962 }
963 _ => {}
964 }
965 self.last_char = Some(ch);
966 }
967 }
968
969 fn feed_ascii_single(&mut self, prev: Option<char>, next: Option<char>) {
972 let prev_alnum = prev.is_some_and(|c| c.is_alphanumeric());
973 let next_alnum = next.is_some_and(|c| c.is_alphanumeric());
974 if prev_alnum && next_alnum {
976 return;
977 }
978 if self.ascii_single_open {
979 self.ascii_single_open = false;
982 return;
983 }
984 let opener = match prev {
986 None => true,
987 Some(c) if c.is_whitespace() => true,
988 Some('(' | '[' | '{' | '"' | '\u{201C}' | '\u{00AB}') => true,
989 Some('.' | '!' | '?' | ':' | ';' | ',') => true,
990 _ => false,
991 };
992 if opener {
993 self.ascii_single_open = true;
994 }
995 }
996}
997
998pub fn newlines_respect_delimiter_spans(formatted: &str) -> bool {
1012 let trimmed_end = formatted.trim_end_matches('\n');
1013 if trimmed_end.is_empty() {
1014 return true;
1015 }
1016 let (protected, _) = protect_inline_tokens(trimmed_end);
1017 let mut state = DelimState::default();
1018 for line in protected.split('\n') {
1019 if state.is_inside() {
1020 return false;
1021 }
1022 state.feed(line);
1023 }
1024 true
1025}
1026
1027fn is_abbreviation_ending(
1028 s: &str,
1029 abbrev_re: &Regex,
1030 multi_re: &Regex,
1031 extra: Option<&Regex>,
1032) -> bool {
1033 let trimmed = s.trim_end();
1034 if !trimmed.ends_with('.') {
1035 return false;
1036 }
1037 let before_dot = &trimmed[..trimmed.len() - 1];
1038
1039 if abbrev_re.is_match(before_dot) {
1040 return true;
1041 }
1042
1043 if multi_re.is_match(before_dot) {
1044 return true;
1045 }
1046
1047 if let Some(re) = extra {
1048 if re.is_match(before_dot) {
1049 return true;
1050 }
1051 }
1052
1053 false
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 fn split(text: &str) -> Vec<String> {
1061 UnicodeSentenceSplitter::new().split(text)
1062 }
1063
1064 #[test]
1065 fn simple_sentences() {
1066 assert_eq!(
1067 split("Hello world. This is a test. Another sentence here."),
1068 vec!["Hello world.", "This is a test.", "Another sentence here."]
1069 );
1070 }
1071
1072 #[test]
1073 fn abbreviation_dr() {
1074 assert_eq!(
1075 split("Dr. Smith went home. He was tired."),
1076 vec!["Dr. Smith went home.", "He was tired."]
1077 );
1078 }
1079
1080 #[test]
1081 fn abbreviation_eg() {
1082 assert_eq!(
1083 split("Use a formatter, e.g. snapper. It works well."),
1084 vec!["Use a formatter, e.g. snapper.", "It works well."]
1085 );
1086 }
1087
1088 #[test]
1089 fn abbreviation_fig() {
1090 assert_eq!(
1091 split("See Fig. 3 for details. The results are clear."),
1092 vec!["See Fig. 3 for details.", "The results are clear."]
1093 );
1094 }
1095
1096 #[test]
1097 fn placeholder_restore_survives_regex_wrapping_backticks() {
1098 let input = "`0`[`0``a`` `{``A`](`a` `)";
1102 let out = split(input);
1103 let joined = out.join("\n");
1104 assert!(!joined.contains('\u{0}'), "placeholder leaked: {joined:?}");
1105 let again = split(&joined);
1106 assert_eq!(again, out);
1107 }
1108
1109 #[test]
1110 fn wrt_abbreviation_does_not_split() {
1111 assert_eq!(
1112 split("Computed w.r.t. $x$. Next."),
1113 vec!["Computed w.r.t. $x$.".to_string(), "Next.".to_string()]
1114 );
1115 }
1116
1117 #[test]
1118 fn latex_inline_math_parens_stay_atomic() {
1119 assert_eq!(
1120 split(r"According to X, \(E=mc^2\). Next."),
1121 vec![
1122 r"According to X, \(E=mc^2\).".to_string(),
1123 "Next.".to_string(),
1124 ]
1125 );
1126 }
1127
1128 #[test]
1129 fn latex_verb_inner_punct_stays_atomic() {
1130 let text = r"Use \verb|a.b! c| here. Next.";
1131 let (_, placeholders) = protect_inline_tokens(text);
1132 assert!(
1133 placeholders.iter().any(|p| p == r"\verb|a.b! c|"),
1134 "verb span must be protected, got {placeholders:?}"
1135 );
1136 assert_eq!(
1137 split(text),
1138 vec![r"Use \verb|a.b! c| here.".to_string(), "Next.".to_string()]
1139 );
1140 }
1141
1142 #[test]
1143 fn extra_verbatim_command_is_tokenized_like_verb() {
1144 let text = r"Use \Verb|a.b! c| here. Next.";
1145 let extras = ["Verb".to_string()];
1146 let (_, placeholders) = protect_inline_tokens_with(text, &extras);
1147 assert!(
1148 placeholders.iter().any(|p| p == r"\Verb|a.b! c|"),
1149 "extra Verb span must be protected, got {placeholders:?}"
1150 );
1151 assert!(
1152 !protect_inline_tokens(text)
1153 .1
1154 .iter()
1155 .any(|p| p == r"\Verb|a.b! c|"),
1156 "unlisted Verb must not be protected"
1157 );
1158 }
1159
1160 #[test]
1161 fn extra_verb_does_not_steal_verbatim() {
1162 let extras = ["Verb".to_string()];
1163 assert_eq!(
1164 latex_verb_span_end_with(r"\Verbatim|x.y|", 0, &extras),
1165 None,
1166 "Verb must not match as a prefix of Verbatim"
1167 );
1168 assert_eq!(
1169 latex_verb_span_end_with(r"\Verb|x.y|", 0, &extras),
1170 Some(r"\Verb|x.y|".len())
1171 );
1172 let text = r"Use \Verbatim|x.y| here. Next.";
1173 let (_, placeholders) = protect_inline_tokens_with(text, &extras);
1174 assert!(
1175 placeholders.iter().all(|p| p != r"\Verbatim|x.y|"),
1176 "Verbatim must not become a verb span, got {placeholders:?}"
1177 );
1178 assert_eq!(
1179 UnicodeSentenceSplitter::new()
1180 .with_verbatim_commands(extras.to_vec())
1181 .split(text),
1182 vec![r"Use \Verbatim|x.y| here.".to_string(), "Next.".to_string()]
1183 );
1184 }
1185
1186 #[test]
1187 fn latex_lstinline_inner_percent_stays_atomic() {
1188 let text = r"Code \lstinline!%! here. Next.";
1189 let (_, placeholders) = protect_inline_tokens(text);
1190 assert!(
1191 placeholders.iter().any(|p| p == r"\lstinline!%!"),
1192 "lstinline span must be protected, got {placeholders:?}"
1193 );
1194 assert_eq!(
1195 split(text),
1196 vec![r"Code \lstinline!%! here.".to_string(), "Next.".to_string()]
1197 );
1198 }
1199
1200 #[test]
1201 fn latex_lstinline_optional_args_stay_atomic() {
1202 let text = r"See \lstinline[language=TeX]!a.b%! please. Next.";
1203 let (_, placeholders) = protect_inline_tokens(text);
1204 assert!(
1205 placeholders
1206 .iter()
1207 .any(|p| p == r"\lstinline[language=TeX]!a.b%!"),
1208 "lstinline with optional args must be protected, got {placeholders:?}"
1209 );
1210 assert_eq!(
1211 split(text),
1212 vec![
1213 r"See \lstinline[language=TeX]!a.b%! please.".to_string(),
1214 "Next.".to_string()
1215 ]
1216 );
1217 }
1218
1219 #[test]
1220 fn unmatched_latex_verb_extends_to_eol() {
1221 let text = r"See \verb|a%b. Next";
1222 let (_, placeholders) = protect_inline_tokens(text);
1223 assert!(
1224 placeholders.iter().any(|p| p == r"\verb|a%b. Next"),
1225 "unmatched verb must run to EOL, got {placeholders:?}"
1226 );
1227 assert_eq!(split(text), vec![text.to_string()]);
1228 }
1229
1230 #[test]
1231 fn latex_nbsp_after_abbrev_stays_attached() {
1232 let text = r"See Fig. ~1, Eq.~\ref{eq:diff}, and Dr. Smith. Next.";
1236 assert_eq!(
1237 split(text),
1238 vec![
1239 r"See Fig. ~1, Eq.~\ref{eq:diff}, and Dr. Smith.".to_string(),
1240 "Next.".to_string(),
1241 ]
1242 );
1243 }
1244
1245 #[test]
1246 fn empty_input() {
1247 assert_eq!(split(""), Vec::<String>::new());
1248 }
1249
1250 #[test]
1251 fn single_sentence() {
1252 assert_eq!(split("Just one sentence."), vec!["Just one sentence."]);
1253 }
1254
1255 #[test]
1256 fn question_and_exclamation() {
1257 assert_eq!(
1258 split("Is this working? Yes! It is."),
1259 vec!["Is this working?", "Yes!", "It is."]
1260 );
1261 }
1262
1263 #[test]
1264 fn no_trailing_period() {
1265 assert_eq!(
1266 split("First sentence. Second without period"),
1267 vec!["First sentence.", "Second without period"]
1268 );
1269 }
1270
1271 #[test]
1272 fn extra_abbreviations() {
1273 let splitter = UnicodeSentenceSplitter::with_extra_abbreviations(&[
1276 "Abstr".to_string(),
1277 "Suppl".to_string(),
1278 ]);
1279 assert_eq!(
1280 splitter.split("See Abstr. 5 for details. The results follow."),
1281 vec!["See Abstr. 5 for details.", "The results follow."]
1282 );
1283 let default = UnicodeSentenceSplitter::new();
1285 let result = default.split("See Abstr. 5 for details. The results follow.");
1286 assert!(result.len() > 1);
1288 }
1289
1290 #[test]
1291 fn inline_org_link_preserved() {
1292 assert_eq!(
1293 split("See [[https://example.com][Ex. Site]] for details. Then continue."),
1294 vec![
1295 "See [[https://example.com][Ex. Site]] for details.",
1296 "Then continue."
1297 ]
1298 );
1299 }
1300
1301 #[test]
1302 fn inline_math_preserved() {
1303 assert_eq!(
1304 split("The value $x = 3.14$ matters. Next sentence."),
1305 vec!["The value $x = 3.14$ matters.", "Next sentence."]
1306 );
1307 }
1308
1309 #[test]
1310 fn inline_markdown_link_preserved() {
1311 assert_eq!(
1312 split("Visit [Example Inc.](https://example.com) now. Then read more."),
1313 vec now.",
1315 "Then read more."
1316 ]
1317 );
1318 }
1319
1320 #[test]
1321 fn inline_code_preserved() {
1322 assert_eq!(
1323 split("Use `std.io.Read` for input. Then process."),
1324 vec!["Use `std.io.Read` for input.", "Then process."]
1325 );
1326 }
1327
1328 #[test]
1329 fn autolink_preserved() {
1330 assert_eq!(
1331 split("Visit <https://example.com/a.b> today. Then read more."),
1332 vec!["Visit <https://example.com/a.b> today.", "Then read more."]
1333 );
1334 }
1335
1336 #[test]
1337 fn atomic_inline_spans_cover_wrap_tokens() {
1338 let text = "See [the example site](https://ex.com) and `some long code` plus $E = m$ and [[https://example.com][the example site]] and <https://ex.com/a>.";
1339 let spans = atomic_inline_spans(text);
1340 let tokens: Vec<&str> = spans.iter().map(|&(s, e)| &text[s..e]).collect();
1341 assert!(
1342 tokens
1343 .iter()
1344 .any(|t| *t == "[the example site](https://ex.com)"),
1345 "markdown link: {tokens:?}"
1346 );
1347 assert!(
1348 tokens.iter().any(|t| *t == "`some long code`"),
1349 "inline code: {tokens:?}"
1350 );
1351 assert!(tokens.iter().any(|t| *t == "$E = m$"), "math: {tokens:?}");
1352 assert!(
1353 tokens
1354 .iter()
1355 .any(|t| *t == "[[https://example.com][the example site]]"),
1356 "org link: {tokens:?}"
1357 );
1358 assert!(
1359 tokens.iter().any(|t| *t == "<https://ex.com/a>"),
1360 "autolink: {tokens:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn org_bold_with_internal_period_not_split() {
1366 assert_eq!(
1368 split("End of first. *Bold spans period. Continues* after."),
1369 vec!["End of first.", "*Bold spans period. Continues* after."]
1370 );
1371 }
1372
1373 #[test]
1374 fn org_verbatim_inner_equals_pairs_to_the_real_closer() {
1375 let text = r#"so =x = 1 -- note.= reflows while =s = "x"= does not."#;
1379 let (protected, placeholders) = protect_inline_tokens(text);
1380 assert_eq!(
1381 placeholders,
1382 vec![
1383 r#"=x = 1 -- note.="#.to_string(),
1384 r#"=s = "x"="#.to_string(),
1385 ],
1386 "pairing must not close on the inner `=`; got {placeholders:?} from {protected:?}"
1387 );
1388 assert_eq!(split(text), vec![text.to_string()]);
1389 }
1390
1391 #[test]
1392 fn org_verbatim_inner_equals_alone_stays_one_sentence() {
1393 let text = "so =x = 1 -- note.= reflows here.";
1394 let (_, placeholders) = protect_inline_tokens(text);
1395 assert_eq!(placeholders, vec!["=x = 1 -- note.=".to_string()]);
1396 assert_eq!(split(text), vec![text.to_string()]);
1397 }
1398
1399 #[test]
1400 fn org_verbatim_second_span_alone_does_not_need_inner_equals() {
1401 let text = r#"so =x -- note.= reflows while =s = "x"= does not."#;
1402 let (_, placeholders) = protect_inline_tokens(text);
1403 assert_eq!(
1404 placeholders,
1405 vec!["=x -- note.=".to_string(), r#"=s = "x"="#.to_string(),]
1406 );
1407 assert_eq!(split(text), vec![text.to_string()]);
1408 }
1409
1410 #[test]
1411 fn org_code_span_with_dot_pl_stays_atomic() {
1412 let text = "~latexindent.pl~ covers LaTeX only. Snapper handles Org.";
1413 let (_, placeholders) = protect_inline_tokens(text);
1414 assert_eq!(placeholders, vec!["~latexindent.pl~".to_string()]);
1415 assert_eq!(
1416 split(text),
1417 vec![
1418 "~latexindent.pl~ covers LaTeX only.".to_string(),
1419 "Snapper handles Org.".to_string(),
1420 ]
1421 );
1422 }
1423
1424 #[test]
1425 fn markdown_code_span_with_dot_pl_stays_atomic() {
1426 let text = "`latexindent.pl` covers LaTeX only. Snapper handles Org.";
1427 let (_, placeholders) = protect_inline_tokens(text);
1428 assert_eq!(placeholders, vec!["`latexindent.pl`".to_string()]);
1429 assert_eq!(
1430 split(text),
1431 vec![
1432 "`latexindent.pl` covers LaTeX only.".to_string(),
1433 "Snapper handles Org.".to_string(),
1434 ]
1435 );
1436 }
1437
1438 #[test]
1439 fn org_code_inner_tilde_pairs_to_the_real_closer() {
1440 let text = r#"so ~x ~ 1 -- note.~ reflows while ~s ~ "x"~ does not."#;
1441 let (_, placeholders) = protect_inline_tokens(text);
1442 assert_eq!(
1443 placeholders,
1444 vec![
1445 r#"~x ~ 1 -- note.~"#.to_string(),
1446 r#"~s ~ "x"~"#.to_string(),
1447 ]
1448 );
1449 assert_eq!(split(text), vec![text.to_string()]);
1450 }
1451
1452 #[test]
1453 fn markdown_double_backticks_can_hold_a_backtick() {
1454 let text = r#"see ``x ` 1 -- note.`` and ``s ` "x"`` too."#;
1455 let (_, placeholders) = protect_inline_tokens(text);
1456 assert_eq!(
1457 placeholders,
1458 vec![
1459 r#"``x ` 1 -- note.``"#.to_string(),
1460 r#"``s ` "x"``"#.to_string(),
1461 ]
1462 );
1463 assert_eq!(split(text), vec![text.to_string()]);
1464 }
1465
1466 #[test]
1467 fn org_italic_with_internal_period_not_split() {
1468 assert_eq!(
1469 split("Lead-in. /Italic has a period. Still italic/ trail."),
1470 vec!["Lead-in.", "/Italic has a period. Still italic/ trail."]
1471 );
1472 }
1473
1474 #[test]
1475 fn angle_bracket_tail_after_period_preserved() {
1476 assert_eq!(
1478 split("snapshot field is Box[T], not Vec[T]"),
1479 vec!["snapshot field is Box[T], not Vec[T]"]
1480 );
1481 assert_eq!(split("see <a.>"), vec!["see <a.>"]);
1482 }
1483
1484 #[test]
1485 fn double_quoted_span_with_internal_period_not_split() {
1486 assert_eq!(
1487 split(r#"He said "Hello world. How are you?" Then he left."#),
1488 vec![r#"He said "Hello world. How are you?""#, "Then he left."]
1489 );
1490 }
1491
1492 #[test]
1493 fn curly_double_quoted_span_with_internal_period_not_split() {
1494 assert_eq!(
1495 split("He said \u{201C}Hello world. How are you?\u{201D} Then he left."),
1496 vec![
1497 "He said \u{201C}Hello world. How are you?\u{201D}",
1498 "Then he left."
1499 ]
1500 );
1501 }
1502
1503 #[test]
1504 fn quoted_title_with_abbrev_stays_one_sentence() {
1505 assert_eq!(
1506 split(r#"See the note "Fig. 3 is wrong." in the appendix."#),
1507 vec![r#"See the note "Fig. 3 is wrong." in the appendix."#]
1508 );
1509 }
1510
1511 #[test]
1512 fn plaintext_format_keeps_dialogue_quote_together() {
1513 use crate::format::Format;
1514 use crate::{FormatConfig, format_text};
1515
1516 let input = "He said \"Hello world. How are you?\" Then he left.\n";
1517 let cfg = FormatConfig {
1518 format: Format::Plaintext,
1519 ..Default::default()
1520 }
1521 .without_safety_backstops();
1522 let out = format_text(input, &cfg).unwrap();
1523 assert!(
1524 !out.contains("world.\nHow"),
1525 "must not break inside ASCII double quotes, got:\n{out}"
1526 );
1527 assert!(
1528 out.contains("you?\"\nThen") || out.contains("you?\" Then"),
1529 "may break after closing quote; got:\n{out}"
1530 );
1531 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1532 }
1533
1534 #[test]
1535 fn paren_span_with_internal_period_capital_not_split() {
1536 assert_eq!(
1537 split("See (Fig. 3 is wrong. Really.) Next."),
1538 vec!["See (Fig. 3 is wrong. Really.)", "Next."]
1539 );
1540 }
1541
1542 #[test]
1543 fn bracket_span_with_internal_period_not_split() {
1544 assert_eq!(
1545 split("See [note. One] more."),
1546 vec!["See [note. One] more."]
1547 );
1548 }
1549
1550 #[test]
1551 fn latex_style_quotes_with_internal_period_not_split() {
1552 assert_eq!(
1553 split("He said ``Hello world. How?'' Then."),
1554 vec!["He said ``Hello world. How?''", "Then."]
1555 );
1556 }
1557
1558 #[test]
1559 fn escaped_ascii_quote_does_not_toggle_early() {
1560 let out = split(r#"She said "He said \"no.\" Then left." Done."#);
1563 assert_eq!(out.len(), 2, "got {out:?}");
1564 assert!(
1565 out[0].contains(r#"\"no.\""#) || out[0].contains("no."),
1566 "{out:?}"
1567 );
1568 assert_eq!(out[1], "Done.");
1569 }
1570
1571 #[test]
1572 fn single_quoted_dialogue_with_internal_period_not_split() {
1573 assert_eq!(
1574 split("He said 'Hello world. How are you?' Then he left."),
1575 vec!["He said 'Hello world. How are you?'", "Then he left."]
1576 );
1577 }
1578
1579 #[test]
1580 fn apostrophe_contractions_still_split_sentences() {
1581 assert_eq!(
1582 split("Don't split here. Next sentence."),
1583 vec!["Don't split here.", "Next sentence."]
1584 );
1585 assert_eq!(
1586 split("It's fine. She said 'Go. Now.' Done."),
1587 vec!["It's fine.", "She said 'Go. Now.'", "Done."]
1588 );
1589 }
1590
1591 #[test]
1592 fn curly_single_quoted_dialogue_not_split() {
1593 assert_eq!(
1594 split("He said \u{2018}Hello world. How?\u{2019} Then."),
1595 vec!["He said \u{2018}Hello world. How?\u{2019}", "Then."]
1596 );
1597 }
1598
1599 #[test]
1600 fn newlines_invariant_holds_on_dialogue_output() {
1601 use crate::format::Format;
1602 use crate::{FormatConfig, format_text};
1603
1604 let samples = [
1605 "He said \"Hello world. How are you?\" Then he left.\n",
1606 "He said 'Hello world. How are you?' Then he left.\n",
1607 "See (Fig. 3 is wrong. Really.) Next.\n",
1608 "See [note. One] more. Trailing.\n",
1609 "He said ``Hello world. How?'' Then.\n",
1610 "Don't stop. It's ok. Done.\n",
1611 "[`[`].A\"\"]\"}\"''\n",
1615 ];
1616 let cfg = FormatConfig {
1617 format: Format::Plaintext,
1618 ..Default::default()
1619 }
1620 .without_safety_backstops();
1621 for input in samples {
1622 let out = format_text(input, &cfg).unwrap();
1623 assert!(
1624 newlines_respect_delimiter_spans(&out),
1625 "newline inside delimiter span for input {input:?}, out:\n{out}"
1626 );
1627 assert_eq!(
1628 format_text(&out, &cfg).unwrap(),
1629 out,
1630 "idempotence {input:?}"
1631 );
1632 }
1633 }
1634
1635 #[test]
1636 fn quoted_exclamation_no_false_split() {
1637 assert_eq!(
1638 split(r#"He said "wow!" and left. She agreed."#),
1639 vec![r#"He said "wow!" and left."#, "She agreed."]
1640 );
1641 }
1642
1643 #[test]
1644 fn paren_exclamation_no_false_split() {
1645 assert_eq!(
1646 split("He replied (with emphasis!) loudly. She agreed."),
1647 vec!["He replied (with emphasis!) loudly.", "She agreed."]
1648 );
1649 }
1650
1651 #[test]
1652 fn paren_question_no_false_split() {
1653 assert_eq!(
1654 split("The answer (really?) surprised them. Next sentence."),
1655 vec!["The answer (really?) surprised them.", "Next sentence."]
1656 );
1657 }
1658
1659 #[test]
1660 fn url_trailing_period_not_swallowed() {
1661 assert_eq!(
1662 split("Visit https://example.com/path. Then read more."),
1663 vec!["Visit https://example.com/path.", "Then read more."]
1664 );
1665 }
1666
1667 #[test]
1668 fn url_with_query_trailing_period() {
1669 assert_eq!(
1670 split("See https://example.com/path?q=1&r=2. Next sentence."),
1671 vec!["See https://example.com/path?q=1&r=2.", "Next sentence."]
1672 );
1673 }
1674
1675 #[test]
1676 fn ellipsis_splits() {
1677 assert_eq!(
1678 split("Sentence one... Sentence two."),
1679 vec!["Sentence one...", "Sentence two."]
1680 );
1681 }
1682
1683 #[test]
1684 fn quoted_period_end_of_sentence() {
1685 assert_eq!(
1687 split(r#"End of quote: "done." Start again."#),
1688 vec![r#"End of quote: "done.""#, "Start again."]
1689 );
1690 }
1691
1692 #[test]
1693 fn markdown_strong_with_internal_period_not_split() {
1694 let text = "This is **the end. Still bold** after.";
1696 let (_, placeholders) = protect_inline_tokens(text);
1697 assert!(
1698 placeholders.iter().any(|p| p == "**the end. Still bold**"),
1699 "strong span must be one token, got {placeholders:?}"
1700 );
1701 assert_eq!(split(text), vec![text.to_string()]);
1702 }
1703
1704 #[test]
1705 fn markdown_strong_may_split_after_closer() {
1706 assert_eq!(
1707 split("It is **complex**. Equity is hard."),
1708 vec![
1709 "It is **complex**.".to_string(),
1710 "Equity is hard.".to_string()
1711 ]
1712 );
1713 }
1714
1715 #[test]
1716 fn markdown_em_with_internal_period_not_split() {
1717 let text = "This is *the end. Still em* after.";
1718 let (_, placeholders) = protect_inline_tokens(text);
1719 assert!(
1720 placeholders.iter().any(|p| p == "*the end. Still em*"),
1721 "em span must be one token, got {placeholders:?}"
1722 );
1723 assert_eq!(split(text), vec![text.to_string()]);
1724 }
1725
1726 #[test]
1727 fn markdown_strike_with_internal_period_not_split() {
1728 let text = "This is ~~the end. Still strike~~ after.";
1729 let (_, placeholders) = protect_inline_tokens(text);
1730 assert!(
1731 placeholders
1732 .iter()
1733 .any(|p| p == "~~the end. Still strike~~"),
1734 "strike span must be one token, got {placeholders:?}"
1735 );
1736 assert_eq!(split(text), vec![text.to_string()]);
1737 }
1738
1739 #[test]
1740 fn markdown_strong_inner_star_does_not_close_early() {
1741 let text = "Wrap **a * b. C** after. Next.";
1744 let (_, placeholders) = protect_inline_tokens(text);
1745 assert!(
1746 placeholders.iter().any(|p| p == "**a * b. C**"),
1747 "must not close strong on the inner star, got {placeholders:?}"
1748 );
1749 assert_eq!(
1750 split(text),
1751 vec!["Wrap **a * b. C** after.".to_string(), "Next.".to_string()]
1752 );
1753 }
1754
1755 #[test]
1756 fn markdown_emphasis_format_text_does_not_break_inside_span() {
1757 use crate::format::Format;
1758 use crate::{FormatConfig, format_text};
1759
1760 let cfg = FormatConfig {
1761 format: Format::Markdown,
1762 ..Default::default()
1763 };
1764 let out = format_text("This is **the end. Still bold** after.\n", &cfg).unwrap();
1765 assert!(
1766 !out.contains("end.\nStill"),
1767 "must not split inside **...**, got:\n{out}"
1768 );
1769 assert_eq!(format_text(&out, &cfg).unwrap(), out);
1770
1771 let out = format_text("It is **complex**. Equity is hard.\n", &cfg).unwrap();
1772 assert!(
1773 out.contains("**complex**.") && out.contains("Equity is hard."),
1774 "may split after the closer, got:\n{out}"
1775 );
1776 assert!(
1777 !out.contains("**complex.\n"),
1778 "must not split before the closer, got:\n{out}"
1779 );
1780 }
1781}