Skip to main content

snapper_fmt/sentence/
unicode.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4/// Matches segments ending with sentence punctuation followed by closing quotes/parens,
5/// where the punctuation is not a true sentence boundary (e.g., `"wow!" and`, `(emphasis!) loudly`).
6static 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
12/// Patterns for inline tokens that should not be split across sentences.
13/// These get replaced with safe placeholders before sentence detection.
14static INLINE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
15    Regex::new(
16        &[
17            r"\[\[[^\]]*\]\]",           // Org links: [[url]] or [[url][desc]]
18            r"\[\[[^\]]*\]\[[^\]]*\]\]", // Org links with desc
19            r"\[[^\]]+\]\([^)]+\)",      // Markdown links: [text](url)
20            r"!\[[^\]]*\]\([^)]+\)",     // Markdown images: ![alt](url)
21            r"\$[^$]+\$",                // Inline math: $...$
22            r"\\([a-zA-Z]+)\{[^}]*\}",   // LaTeX commands: \cmd{arg}
23            // Org emphasis must be protected before sentence splits so a line
24            // cannot begin with `*rest` (false headline) or leave markers open.
25            // Org requires a non-space immediately after the opener and before
26            // the closer; content may include spaces and sentence punctuation.
27            // (Rust `regex` has no lookbehind; encode the border as char classes.)
28            r"\*[^*\s\n](?:[^*\n]*[^*\s\n])?\*", // Org bold: *text*
29            r"/[^/\s\n](?:[^/\n]*[^/\s\n])?/",   // Org italic: /text/
30            r"_[^_\s\n](?:[^_\n]*[^_\s\n])?_",   // Org underline: _text_
31            r"\+[^\+\s\n](?:[^\+\n]*[^\+\s\n])?\+", // Org strike-through: +text+
32            r"~[^~\n]+~",                        // Org inline code: ~code~
33            r"=[^=\n]+=",                        // Org verbatim: =text=
34            r"`[^`\n]+`",                        // Markdown inline code: `code`
35            r#"https?://\S+[^.\s!?,;:)\]'""]"#,  // URLs (don't swallow trailing punctuation)
36            r"file:\S+",                         // Org file: links
37            r"@@[a-zA-Z]+:[^@]*@@",              // Org inline export snippets: @@backend:value@@
38        ]
39        .join("|"),
40    )
41    .expect("valid inline token regex")
42});
43
44// Static patterns removed -- now compiled per-instance in UnicodeSentenceSplitter::for_lang().
45
46/// Sentence splitter using Unicode UAX #29 with abbreviation-aware merging.
47pub struct UnicodeSentenceSplitter {
48    /// Compiled regex for extra user-provided abbreviations, if any.
49    extra_pattern: Option<Regex>,
50    /// Compiled abbreviation pattern for the selected language.
51    lang_abbrev_pattern: Regex,
52    /// Compiled multi-abbreviation pattern for the selected language.
53    lang_multi_pattern: Regex,
54}
55
56impl UnicodeSentenceSplitter {
57    /// Create a splitter with only built-in English abbreviations.
58    pub fn new() -> Self {
59        Self::for_lang("en", &[])
60    }
61
62    /// Create a splitter with additional user-provided abbreviations.
63    pub fn with_extra_abbreviations(extras: &[String]) -> Self {
64        Self::for_lang("en", extras)
65    }
66
67    /// Create a splitter for a specific language, optionally with extra abbreviations.
68    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
103/// Protect links, emphasis, math, and other inline tokens so a base segmenter
104/// (UAX or neural) cannot cut inside them. Shared by rules and neural paths.
105pub 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: &regex::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
115/// Restore placeholders produced by [`protect_inline_tokens`] into each segment.
116pub 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        // UAX #29 sentence bounds. `unicode_sentences()` filters
141        // whitespace-only segments but also drops trailing closing
142        // punctuation like `>` after a sentence-terminating `.`, which
143        // clips inputs such as `Vec<...>` or `<a.>` at end-of-prose.
144        // We re-collect from the unfiltered iterator and merge any
145        // non-sentence tail back onto the preceding sentence.
146        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    /// Apply abbreviation + delimiter-span merges to an already-segmented list.
159    ///
160    /// Used by the neural backend so `--neural` shares the same post-pipeline
161    /// guarantees (dialogue quotes, `Dr.`, balanced spans) as the UAX path.
162    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
182/// Walk the UAX #29 sentence bounds and merge any trailing non-sentence
183/// segments back onto the preceding sentence. Without this glue, a prose
184/// region ending in characters like `>` after a sentence-terminating `.`
185/// would lose those characters: `Vec<...>` becomes `Vec<...`. The standard
186/// `unicode_sentences()` filter silently discards such tails because they
187/// contain no letter/digit/quote.
188///
189/// We never *split* further than the bounds iterator does; we only merge
190/// adjacent fragments where one is a real sentence and its neighbour is
191/// content-free (no alphanumeric characters). This mirrors the existing
192/// `unicode_sentences()` filter rule but reattaches the tail rather than
193/// dropping it.
194fn 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    // Build a merged Vec<&str> by walking left to right and re-slicing the
207    // original `text` so we return `&str`s. The slice boundaries align
208    // because `split_sentence_bounds` returns adjacent subslices.
209    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            // Glue onto the previous sentence.
218            last.1 = end;
219        } else {
220            // Leading whitespace/punctuation only: preserve as a segment;
221            // the downstream pipeline trims it.
222            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
256/// Append `piece` to `dest`, inserting a single space if neural/UAX segments
257/// were trimmed and would otherwise glue `world.` + `How` into `world.How`.
258fn 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
270/// Merge false splits caused by sentence punctuation inside quotes or parens.
271/// E.g., `He said "wow!"` + `and left.` should stay as one sentence when
272/// the next segment starts with a lowercase letter.
273fn 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            // Previous segment ends with punctuation + closing quote/paren
279            QUOTED_PUNCT_END_RE.is_match(prev.trim_end())
280                // Next segment starts with lowercase (continuation, not new sentence)
281                && 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
301/// Rejoin UAX segments while any “span” is still open: ASCII/curly/guillemet
302/// quotes (including dialogue single quotes with apostrophe heuristics),
303/// LaTeX ```` / `''` style quotes, and balanced `()` / `[]` / `{}`.
304/// Escaped `\"` / `\'` do not toggle quote state.
305fn 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/// Tracks delimiter nesting for span-aware sentence merging and invariants.
326/// Public to tests so property checks can share the exact production logic.
327#[derive(Debug, Default, Clone)]
328pub struct DelimState {
329    ascii_double_open: bool,
330    /// Dialogue-style ASCII single quotes (`'Hello.'`), not apostrophes.
331    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 character fed (survives chunk boundaries for apostrophe heuristics).
340    last_char: Option<char>,
341    /// When the previous chunk ended in `\`, the next `"` / `'` is escaped.
342    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    /// Feed `text` and update nesting. Used both in the splitter merge pass
359    /// and in regression/property tests that assert formatted output never
360    /// places a newline while still inside a span.
361    pub fn feed(&mut self, text: &str) {
362        // Walk by char index without allocating a `Vec<char>` per call (hot
363        // path: every segment in merge_splits_inside_delimiters + tests).
364        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            // LaTeX-style open `` and close '' (must run before single `'`).
376            // Markdown fences use ``` — treat runs of 3+ backticks as neutral
377            // so we do not leave latex_quote_depth stuck open across lines.
378            if ch == '`' && next == Some('`') {
379                let _ = iter.next(); // second `
380                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            // Escaped ASCII quotes do not toggle (may span chunk boundary).
399            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                // Curly doubles “ ”
413                '\u{201C}' => self.curly_double_depth += 1,
414                '\u{201D}' => self.curly_double_depth = (self.curly_double_depth - 1).max(0),
415                // Curly singles ‘ ’
416                '\u{2018}' => self.curly_single_depth += 1,
417                '\u{2019}' => {
418                    // U+2019 is also a common apostrophe; only close when open,
419                    // otherwise ignore (it's / don't).
420                    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    /// ASCII `'` is ambiguous (dialogue vs apostrophe). Open only in opener
441    /// context; never toggle on in-word apostrophes (`don't`, `it's`).
442    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        // Classic apostrophe: letter/digit on both sides.
446        if prev_alnum && next_alnum {
447            return;
448        }
449        if self.ascii_single_open {
450            // Prefer close; trailing possessive `papers'` has prev alnum and
451            // no next alnum — treat as close if we were open, else ignore.
452            self.ascii_single_open = false;
453            return;
454        }
455        // Open only at dialogue-like boundaries.
456        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
469/// Return `true` if `formatted` never inserts a **mid-document** line break
470/// while a delimiter span tracked by [`DelimState`] is still open.
471///
472/// A trailing final `\n` (POSIX text) is ignored even if a span is still open
473/// (unbalanced input like a lone `{`). Any earlier `\n` while `is_inside()`
474/// is rejected.
475///
476/// Implementation feeds whole lines (not per-char) so apostrophe heuristics
477/// see real `prev`/`next` neighbors; fails when a prior line left a span open.
478pub 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        // "Abstr" is not a built-in abbreviation, so the default splitter
591        // would break at "Abstr." The extra list prevents that.
592        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        // Without extra, "Abstr." would cause a false break:
601        let default = UnicodeSentenceSplitter::new();
602        let result = default.split("See Abstr. 5 for details. The results follow.");
603        // Default splits at "Abstr." since it doesn't know the abbreviation
604        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![
631                "Visit [Example Inc.](https://example.com) 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        // Splitting would leave a line starting with `*Bold...` (false headline).
648        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        // UAX #29 can drop a lone `>` after `.` without merge_tail_punctuation.
665        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        // Backslash-escaped quotes are common in code-ish plaintext; do not
748        // treat `\"` as ending the outer dialogue span.
749        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        // "done." followed by uppercase Start is a real sentence boundary
868        assert_eq!(
869            split(r#"End of quote: "done." Start again."#),
870            vec![r#"End of quote: "done.""#, "Start again."]
871        );
872    }
873}