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"\$\$[^$\n]+\$\$",          // Display math: $$...$$
22            r"\$[^$\n]+\$",              // Inline math: $...$
23            r"\\\([^\\\n]+\\\)",         // LaTeX inline math: \(...\)
24            r"\\([a-zA-Z]+)\{[^}]*\}",   // LaTeX commands: \cmd{arg}
25            // Org emphasis must be protected before sentence splits so a line
26            // cannot begin with `*rest` (false headline) or leave markers open.
27            // Org requires a non-space immediately after the opener and before
28            // the closer; content may include spaces and sentence punctuation.
29            // (Rust `regex` has no lookbehind; encode the border as char classes.)
30            r"\*[^*\s\n](?:[^*\n]*[^*\s\n])?\*", // Org bold: *text*
31            r"/[^/\s\n](?:[^/\n]*[^/\s\n])?/",   // Org italic: /text/
32            r"_[^_\s\n](?:[^_\n]*[^_\s\n])?_",   // Org underline: _text_
33            r"\+[^\+\s\n](?:[^\+\n]*[^\+\s\n])?\+", // Org strike-through: +text+
34            // Org `=verbatim=` / `~code~` and Markdown backtick spans are
35            // paired below: a regex that forbids the delimiter inside the
36            // span closes on the first inner copy and leaves the real closer
37            // (and any period before it) unprotected.
38            r"<[A-Za-z][A-Za-z0-9+.\-]*:[^\s<>]*>", // Autolink: <http://...>
39            r"<[^\s<>@]+@[^\s<>]+>",                // Autolink: <user@host>
40            r#"https?://\S+[^.\s!?,;:)\]'""]"#,     // URLs (don't swallow trailing punctuation)
41            r"file:\S+",                            // Org file: links
42            r"@@[a-zA-Z]+:[^@]*@@",                 // Org inline export snippets: @@backend:value@@
43        ]
44        .join("|"),
45    )
46    .expect("valid inline token regex")
47});
48
49// Static patterns removed -- now compiled per-instance in UnicodeSentenceSplitter::for_lang().
50
51/// Sentence splitter using Unicode UAX #29 with abbreviation-aware merging.
52pub struct UnicodeSentenceSplitter {
53    /// Compiled regex for extra user-provided abbreviations, if any.
54    extra_pattern: Option<Regex>,
55    /// Compiled abbreviation pattern for the selected language.
56    lang_abbrev_pattern: Regex,
57    /// Compiled multi-abbreviation pattern for the selected language.
58    lang_multi_pattern: Regex,
59    /// Extra LaTeX command names tokenized like `\verb` before split.
60    extra_verbatim_commands: Vec<String>,
61}
62
63impl UnicodeSentenceSplitter {
64    /// Create a splitter with only built-in English abbreviations.
65    pub fn new() -> Self {
66        Self::for_lang("en", &[])
67    }
68
69    /// Create a splitter with additional user-provided abbreviations.
70    pub fn with_extra_abbreviations(extras: &[String]) -> Self {
71        Self::for_lang("en", extras)
72    }
73
74    /// Create a splitter for a specific language, optionally with extra abbreviations.
75    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    /// Extra LaTeX command names tokenized like `\verb` before split.
105    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
121/// Protect links, emphasis, math, and other inline tokens so a base segmenter
122/// (UAX or neural) cannot cut inside them. Shared by rules and neural paths.
123pub fn protect_inline_tokens(text: &str) -> (String, Vec<String>) {
124    protect_inline_tokens_with(text, &[])
125}
126
127/// Like [`protect_inline_tokens`], with extra LaTeX command names treated
128/// like `\verb` (delimiter is the next character).
129pub 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: &regex::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
144/// `\verb|...|` / `\lstinline[...]!...!` so inner `.!?%` cannot split or comment.
145fn 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
168/// Byte end of a `\verb` / `\lstinline` / extra-name span starting at `at`.
169///
170/// `\verb` / `\verb*`: next character is the delimiter; content runs to the
171/// same character. `\lstinline` / `\lstinline*` may take optional `[...]`
172/// before a delimiter or a `{...}` brace body. Extra names are tokenized
173/// like `\verb`. With no closer, the span runs to end of line so an inner
174/// `%` is not a comment.
175pub(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
245/// Longest extra command name that is a prefix of `tail` and is not
246/// followed by an ASCII letter (`\Verb` must not steal `\Verbatim`).
247fn 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
315/// Org `=`/`~`, Markdown backtick spans, CommonMark `*`/`**`, and GFM `~~`,
316/// paired to the real closer.
317///
318/// Org markers follow the same walk as pandoc's org reader
319/// (`verbatimBetween` / `emphasisStart` / `emphasisEnd`, the Emacs
320/// `org-emphasis-regexp-components` defaults). The opener sits after a pre
321/// character (start of text, whitespace, or `('"{`), the first and last
322/// interior characters are not whitespace, and the closer is the first
323/// matching marker whose next character is a post character (end of text,
324/// whitespace, or `-.,:!?;'")}[`). Inner copies of the marker are content.
325/// `pandoc -f org` reports those spans as `Code` inlines with class
326/// `verbatim` or bare `Code`.
327///
328/// Markdown inline code uses CommonMark / pandoc fence-length matching: a
329/// run of `n` backticks closes on the next run of exactly `n` backticks, so
330/// a double span can hold a single backtick.
331///
332/// Markdown `*` / `**` use CommonMark flanking (not Org's pre/post classes).
333/// GFM `~~strike~~` is an exact two-tilde run.
334fn 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            // GFM `~~strike~~` before Org `~code~` so a double run is not
353            // eaten as one org span that happens to close at the last tilde.
354            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    // pandoc org reader / org-emphasis-regexp-components defaults.
386    // Border (forbidden at the inner edges) is whitespace.
387    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
427/// CommonMark flanking for `*` / `**` (and longer runs).
428///
429/// Edges of the text count as whitespace. A run can open when it is
430/// left-flanking (and not also right-flanking unless the previous character
431/// is punctuation). It closes on the nearest later run of `*` that is
432/// right-flanking and satisfies the rule of three: the sum of opener and
433/// closer lengths is not a multiple of 3, unless both lengths are.
434fn 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
471/// GFM strikethrough: a run of exactly two `~` that is not followed by
472/// whitespace, closed by the next exact `~~` that is not preceded by
473/// whitespace.
474fn 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
578/// Byte ranges of inline tokens that wrapping must not split (links, images,
579/// inline code, autolinks, math, Org `[[...]]`, paired spans).
580///
581/// Ranges are half-open `[start, end)`, sorted, non-overlapping, and merged
582/// when a regex match wraps a paired span.
583pub 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
630/// Restore placeholders produced by [`protect_inline_tokens`] into each segment.
631///
632/// Later placeholders can wrap earlier ones (the regex pass runs after the
633/// paired-span walk and may match a markdown link that already contains
634/// `\x00PHn\x00`). Restore from the last index first so an outer wrapper
635/// expands before its inner tokens.
636pub 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        // UAX #29 sentence bounds. `unicode_sentences()` filters
662        // whitespace-only segments but also drops trailing closing
663        // punctuation like `>` after a sentence-terminating `.`, which
664        // clips inputs such as `Vec<...>` or `<a.>` at end-of-prose.
665        // We re-collect from the unfiltered iterator and merge any
666        // non-sentence tail back onto the preceding sentence.
667        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    /// Apply abbreviation + delimiter-span merges to an already-segmented list.
680    ///
681    /// Used by the neural backend so `--neural` shares the same post-pipeline
682    /// guarantees (dialogue quotes, `Dr.`, balanced spans) as the UAX path.
683    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
703/// Walk the UAX #29 sentence bounds and merge any trailing non-sentence
704/// segments back onto the preceding sentence. Without this glue, a prose
705/// region ending in characters like `>` after a sentence-terminating `.`
706/// would lose those characters: `Vec<...>` becomes `Vec<...`. The standard
707/// `unicode_sentences()` filter silently discards such tails because they
708/// contain no letter/digit/quote.
709///
710/// We never *split* further than the bounds iterator does; we only merge
711/// adjacent fragments where one is a real sentence and its neighbour is
712/// content-free (no alphanumeric characters). This mirrors the existing
713/// `unicode_sentences()` filter rule but reattaches the tail rather than
714/// dropping it.
715fn 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    // Build a merged Vec<&str> by walking left to right and re-slicing the
728    // original `text` so we return `&str`s. The slice boundaries align
729    // because `split_sentence_bounds` returns adjacent subslices.
730    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            // Glue onto the previous sentence.
739            last.1 = end;
740        } else {
741            // Leading whitespace/punctuation only: preserve as a segment;
742            // the downstream pipeline trims it.
743            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
777/// Append `piece` to `dest`, inserting a single space if neural/UAX segments
778/// were trimmed and would otherwise glue `world.` + `How` into `world.How`.
779///
780/// Do not invent a space before a mark that was attached to the period in
781/// the source. LaTeX `Eq.~\ref{}` uses `~` as a non-breaking space. Org
782/// `~code~` pairing does not close before `\`, so abbreviation merge sees
783/// `Eq.` + `~\ref` as two segments.
784fn 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
799/// Merge false splits caused by sentence punctuation inside quotes or parens.
800/// E.g., `He said "wow!"` + `and left.` should stay as one sentence when
801/// the next segment starts with a lowercase letter.
802fn 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            // Previous segment ends with punctuation + closing quote/paren
808            QUOTED_PUNCT_END_RE.is_match(prev.trim_end())
809                // Next segment starts with lowercase (continuation, not new sentence)
810                && 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
830/// Rejoin UAX segments while any “span” is still open: ASCII/curly/guillemet
831/// quotes (including dialogue single quotes with apostrophe heuristics),
832/// LaTeX ```` / `''` style quotes, and balanced `()` / `[]` / `{}`.
833/// Escaped `\"` / `\'` do not toggle quote state.
834fn 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/// Tracks delimiter nesting for span-aware sentence merging and invariants.
855/// Public to tests so property checks can share the exact production logic.
856#[derive(Debug, Default, Clone)]
857pub struct DelimState {
858    ascii_double_open: bool,
859    /// Dialogue-style ASCII single quotes (`'Hello.'`), not apostrophes.
860    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 character fed (survives chunk boundaries for apostrophe heuristics).
869    last_char: Option<char>,
870    /// When the previous chunk ended in `\`, the next `"` / `'` is escaped.
871    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    /// Feed `text` and update nesting. Used both in the splitter merge pass
888    /// and in regression/property tests that assert formatted output never
889    /// places a newline while still inside a span.
890    pub fn feed(&mut self, text: &str) {
891        // Walk by char index without allocating a `Vec<char>` per call (hot
892        // path: every segment in merge_splits_inside_delimiters + tests).
893        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            // LaTeX-style open `` and close '' (must run before single `'`).
905            // Markdown fences use ``` — treat runs of 3+ backticks as neutral
906            // so we do not leave latex_quote_depth stuck open across lines.
907            if ch == '`' && next == Some('`') {
908                let _ = iter.next(); // second `
909                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            // Escaped ASCII quotes do not toggle (may span chunk boundary).
928            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                // Curly doubles “ ”
942                '\u{201C}' => self.curly_double_depth += 1,
943                '\u{201D}' => self.curly_double_depth = (self.curly_double_depth - 1).max(0),
944                // Curly singles ‘ ’
945                '\u{2018}' => self.curly_single_depth += 1,
946                '\u{2019}' => {
947                    // U+2019 is also a common apostrophe; only close when open,
948                    // otherwise ignore (it's / don't).
949                    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    /// ASCII `'` is ambiguous (dialogue vs apostrophe). Open only in opener
970    /// context; never toggle on in-word apostrophes (`don't`, `it's`).
971    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        // Classic apostrophe: letter/digit on both sides.
975        if prev_alnum && next_alnum {
976            return;
977        }
978        if self.ascii_single_open {
979            // Prefer close; trailing possessive `papers'` has prev alnum and
980            // no next alnum — treat as close if we were open, else ignore.
981            self.ascii_single_open = false;
982            return;
983        }
984        // Open only at dialogue-like boundaries.
985        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
998/// Return `true` if `formatted` never inserts a **mid-document** line break
999/// while a delimiter span tracked by [`DelimState`] is still open.
1000///
1001/// A trailing final `\n` (POSIX text) is ignored even if a span is still open
1002/// (unbalanced input like a lone `{`). Any earlier `\n` while `is_inside()`
1003/// is rejected.
1004///
1005/// Inline code / links / emphasis are stripped via [`protect_inline_tokens`]
1006/// first so brackets inside `` `[` `` do not count as real spans (same as the
1007/// production splitter path).
1008///
1009/// Implementation feeds whole lines (not per-char) so apostrophe heuristics
1010/// see real `prev`/`next` neighbors; fails when a prior line left a span open.
1011pub 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        // Pathological backtick salad from proptest: the regex pass can wrap
1099        // a paired-span placeholder in a `[...](...)` match. Restore must
1100        // expand the outer token first or `\x00PHn\x00` leaks into output.
1101        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        // `Eq.~\ref{}` is one token in LaTeX. Org `~code~` pairing does
1233        // not take a closer before `\`, so abbreviation merge must not
1234        // insert a space between `Eq.` and `~`.
1235        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        // "Abstr" is not a built-in abbreviation, so the default splitter
1274        // would break at "Abstr." The extra list prevents that.
1275        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        // Without extra, "Abstr." would cause a false break:
1284        let default = UnicodeSentenceSplitter::new();
1285        let result = default.split("See Abstr. 5 for details. The results follow.");
1286        // Default splits at "Abstr." since it doesn't know the abbreviation
1287        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![
1314                "Visit [Example Inc.](https://example.com) 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        // Splitting would leave a line starting with `*Bold...` (false headline).
1367        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        // `pandoc -f org` makes two Code inlines, class verbatim, contents
1376        // `x = 1 -- note.` and `s = "x"`. A `=[^=]+=` regex instead closes
1377        // on the inner `=` and leaves the period after `note.` unprotected.
1378        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        // UAX #29 can drop a lone `>` after `.` without merge_tail_punctuation.
1477        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        // Backslash-escaped quotes are common in code-ish plaintext; do not
1561        // treat `\"` as ending the outer dialogue span.
1562        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            // Brackets inside inline code are opaque (protect_inline_tokens);
1612            // outer `[…].` closes before the period, so a following sentence
1613            // break is allowed.
1614            "[`[`].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        // "done." followed by uppercase Start is a real sentence boundary
1686        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        // CommonMark `**`: a period next to the closer is still inside the span.
1695        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        // Org `*bold*` closes on the first inner `*`. CommonMark flanking
1742        // keeps `**a * b. C**` as one strong span, so the period stays inside.
1743        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}