Skip to main content

common/parser_tools/
sentence.rs

1//! Sentence boundaries — the granularity between a word and a block.
2//!
3//! `TextDocument::sentence_at` and `SelectionType::SentenceUnderCursor` rest on this module.
4//! Like `find_word_boundaries` and `SelectionType::BlockUnderCursor`, a sentence is
5//! **block-scoped**: a paragraph break always ends a sentence, so the search never leaves the
6//! caret's block.
7//!
8//! Both queries here are pure functions over `&str` — no document, no store, no threads — for
9//! the same reason `document_search::matching` is: a host app measuring sentence
10//! length across a whole manuscript cannot afford to build a document per scene just to ask
11//! how long its sentences are, and an app that rolled its own splitter would disagree with
12//! this crate's caret navigation about where a sentence ends.
13//!
14//! [`sentence_bounds`] answers "which sentence contains this offset" (the caret's question);
15//! [`sentences`] answers "what are all the sentences" (the statistics question). Both are
16//! built on the same boundary pass, so they cannot drift apart.
17//!
18//! ## Why UAX #29 alone is not enough
19//!
20//! [`unicode-segmentation`]'s `split_sentence_bound_indices` implements UAX #29, which already
21//! gets far more right than it is usually given credit for. Its rule SB8 suppresses a break
22//! after a period when a **lower-case** word follows, which covers the bulk of real
23//! abbreviations for free — German `z.B. gestern`, Russian `т. д. и т. п.`, Swedish
24//! `bl.a. igår` and Finnish `esim. eilen` all stay in one sentence with no help from us. Digits
25//! behave the same way, so `Nr. 5`, `ca. 1920` and `S. 42` are fine too.
26//!
27//! Exactly one failure mode survives: **an abbreviation followed by a capitalised word**. In
28//! prose that is nearly always a title before a name — `Mr. Smith`, `M. Dupont`, `Dr. Ayşe`,
29//! `Sr. García`, `Sig. Rossi`, `prof. Nowak`, `κ. Παπαδόπουλος`, `د. أحمد` — plus a short tail
30//! of reference abbreviations that precede a capitalised noun (`Vgl. Abb.`, `Kap. Zwei`).
31//! `Profile::abbreviations` is that list and nothing more, which is what keeps it reviewable:
32//! a term that can legitimately *end* a sentence must never appear in it. `etc.` is the
33//! cautionary example — "…pears, etc. Then he left." is two sentences, so suppressing `etc.`
34//! would silently weld them together.
35//!
36//! Two smaller corrections are punctuation, not vocabulary.
37//!
38//! *Spaced closing marks.* UAX #29 keeps a closing quotation mark with the sentence it closes
39//! (rules SB9/SB10 admit `Close*` after a terminator), so English `?"`, German `?«` and Polish
40//! `?”` all need no help. French is the exception, because it writes a space before the closing
41//! guillemet: `« Vraiment ? »` strands the `»` at the head of the next sentence.
42//! `Profile::spaced_closers` is that narrow repair, and it is deliberately per-language — `"`
43//! opens as often as it closes, so a general rule here would weld `He left. "Come," she said.`
44//! into one sentence.
45//!
46//! *Extra terminators.* Greek asks questions with `;` — an ordinary ASCII semicolon, which UAX
47//! #29 quite correctly does not treat as a sentence ending. `Profile::extra_terminators` adds
48//! it back for Greek only. (Its `·` is the Greek *semicolon* and rightly keeps not terminating.)
49//!
50//! A language with no profile falls back to plain UAX #29. That is a real fallback rather than a
51//! stub — it mis-splits only at *title + Name*, and every other rule above still applies.
52//!
53//! Hebrew needs no abbreviation list at all, and its empty profile is deliberate: Hebrew
54//! abbreviations end in geresh (`׳`) or gershayim (`״`), not a full stop, so UAX #29 never
55//! splits them in the first place.
56//!
57//! [`unicode-segmentation`]: https://docs.rs/unicode-segmentation
58
59use unicode_segmentation::UnicodeSegmentation;
60
61// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
62// Locale profiles
63// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
64
65/// How one language tailors the UAX #29 defaults.
66struct Profile {
67    /// Abbreviations that do not end a sentence, **lower-cased and without the trailing
68    /// period**. Only terms that essentially never end a sentence belong here — see the module
69    /// docs.
70    abbreviations: &'static [&'static str],
71    /// Closing marks this language writes with a **space** before them, which is the one case
72    /// UAX #29's own `Close*` handling cannot reach. Empty for every language but French and
73    /// the ones that follow its typography.
74    spaced_closers: &'static [char],
75    /// Characters this language ends a sentence with that UAX #29 does not — Greek `;`.
76    extra_terminators: &'static [char],
77    /// Whether this language also gets [`LATIN_SHARED`], the titles most Latin-script European
78    /// languages spell the same way.
79    ///
80    /// An explicit flag rather than something inferred from the profile's own entries. An
81    /// earlier version guessed — "a profile whose entries are all ASCII-ish is Latin-script" —
82    /// and was wrong three ways: `[].iter().all(..)` is vacuously **true**, so both the
83    /// no-locale fallback and Hebrew (deliberately empty) silently inherited the whole list,
84    /// and Serbian, which mixes both scripts in one row, was excluded from it.
85    latin_titles: bool,
86}
87
88/// French (and Breton, which follows French typography) put a space — often a narrow no-break
89/// one — before a closing guillemet.
90const FRENCH_SPACED: &[char] = &['»', '\u{203A}'];
91
92/// Languages that spell their titles in their own script, and so do **not** inherit
93/// [`LATIN_SHARED`] — `dr`/`prof`/`st` would only add noise there. Every other language in
94/// [`PROFILES`] does inherit it, as does no language at all… except the untailored fallback,
95/// which by definition gets nothing.
96///
97/// Serbian is deliberately absent: it is written in both scripts, and its row carries both
98/// spellings, so it wants the Latin titles too.
99const NON_LATIN_TITLES: &[&str] = &["ru", "uk", "be", "bg", "mk", "el", "ar", "he"];
100
101/// Languages that end sentences with something UAX #29 does not recognise. Kept apart from
102/// [`PROFILES`] because exactly one language needs it, and threading a fourth column through
103/// forty rows to say "none" would bury the table.
104const EXTRA_TERMINATORS: &[(&str, &[char])] = &[
105    // The Greek question mark is written as an ordinary semicolon; U+037E is its rarely-typed
106    // canonical twin. `·` (U+0387) is Greek's *semicolon* and must keep not terminating.
107    ("el", &[';', '\u{37e}']),
108];
109
110/// Titles and reference abbreviations shared by most Latin-script European languages. Merged
111/// into each profile below rather than repeated in it.
112const LATIN_SHARED: &[&str] = &["dr", "prof", "st", "sr", "jr", "cf", "vs", "ing", "mag"];
113
114/// The per-language tailoring table, keyed by primary language subtag.
115///
116/// Adding a language is adding a row. Entries are lower-cased with the trailing period
117/// stripped; a multi-word abbreviation such as `p. ex` keeps its internal spacing.
118const PROFILES: &[(&str, &[&str], &[char])] = &[
119    // ── Germanic ──
120    (
121        "en",
122        &[
123            "mr", "mrs", "ms", "mx", "messrs", "rev", "hon", "gen", "col", "capt", "lt", "sgt",
124            "maj", "adm", "gov", "pres", "supt", "msgr", "fr", "br", "sen", "rep", "mt", "ft",
125            "esq", "viz", "al",
126        ],
127        &[],
128    ),
129    (
130        "de",
131        &[
132            "hr", "fr", "frl", "hl", "vgl", "abb", "kap", "nr", "bd", "jg", "ggf", "bspw", "sen",
133            "dipl", "verw", "geb", "gest", "ehem",
134        ],
135        &[],
136    ),
137    (
138        "nl",
139        &[
140            "dhr", "mevr", "mw", "mej", "ir", "drs", "mr", "jhr", "sint", "afb", "blz", "zgn",
141        ],
142        &[],
143    ),
144    ("da", &["hr", "fru", "frk", "skt", "jf", "afd"], &[]),
145    ("sv", &["hr", "fru", "frk", "jfr", "avd", "kap"], &[]),
146    ("nb", &["hr", "fru", "frk", "jf", "avd", "kap"], &[]),
147    ("nn", &["hr", "fru", "frk", "jf", "avd", "kap"], &[]),
148    ("no", &["hr", "fru", "frk", "jf", "avd", "kap"], &[]),
149    ("is", &["hr", "frú", "sr", "bls", "sbr"], &[]),
150    ("af", &["mnr", "mev", "mej", "ds", "prof"], &[]),
151    // ── Romance ──
152    (
153        "fr",
154        &[
155            "m", "mm", "mme", "mmes", "mlle", "mlles", "me", "mgr", "pr", "ste", "sts", "stes",
156            "vve", "p. ex", "av. j.-c", "ap. j.-c", "chap", "réf", "fig",
157        ],
158        FRENCH_SPACED,
159    ),
160    (
161        "es",
162        &[
163            "sra", "srta", "dra", "profa", "d", "dª", "dña", "sto", "sta", "san", "lic", "arq",
164            "ud", "uds", "vid", "núm", "pág",
165        ],
166        &[],
167    ),
168    (
169        "pt",
170        &[
171            "sra", "srta", "dra", "profa", "eng", "arq", "d", "dom", "sto", "sta", "exmo", "exma",
172            "pág",
173        ],
174        &[],
175    ),
176    (
177        "it",
178        &[
179            "sig", "sigg", "sig.ra", "dott", "dott.ssa", "arch", "avv", "on", "mons", "egr",
180            "spett", "geom", "rag", "pag",
181        ],
182        &[],
183    ),
184    (
185        "ca",
186        &["sra", "dra", "sta", "mn", "núm", "pàg", "il·lm"],
187        &[],
188    ),
189    ("gl", &["sra", "srta", "dra", "sta", "sto", "páx"], &[]),
190    ("ro", &["dl", "dna", "dra", "sf", "nr", "pag"], &[]),
191    // ── Slavic (Latin script) ──
192    (
193        "pl",
194        &[
195            "p", "pan", "pani", "inż", "mgr", "ks", "św", "hab", "red", "płk", "gen", "por", "rys",
196        ],
197        &[],
198    ),
199    (
200        "cs",
201        &[
202            "p", "pí", "bc", "judr", "mudr", "phdr", "sv", "plk", "gen", "obr", "kap",
203        ],
204        &[],
205    ),
206    (
207        "sk",
208        &["p", "pí", "bc", "judr", "mudr", "sv", "plk", "obr"],
209        &[],
210    ),
211    ("sl", &["g", "ga", "gdč", "sv", "št"], &[]),
212    ("hr", &["g", "gđa", "gđica", "sv", "br", "sl"], &[]),
213    ("bs", &["g", "gđa", "gđica", "sv", "br"], &[]),
214    // Serbian is written in both scripts, so both spellings live in one profile — the match is
215    // on the text, not on the tag's script subtag.
216    (
217        "sr",
218        &[
219            "g", "gđa", "sv", "br", "г", "гђа", "др", "проф", "св", "инж",
220        ],
221        &[],
222    ),
223    // ── Slavic (Cyrillic script) ──
224    (
225        "ru",
226        &[
227            "г", "гн", "г-н", "г-жа", "д-р", "проф", "акад", "тов", "св", "им", "ул", "пл", "обл",
228            "стр", "рис", "табл", "гл", "изд",
229        ],
230        &[],
231    ),
232    (
233        "uk",
234        &[
235            "п", "пан", "пані", "д-р", "проф", "св", "вул", "пл", "обл", "стор", "мал", "гл",
236        ],
237        &[],
238    ),
239    (
240        "be",
241        &["сп", "спн", "д-р", "праф", "св", "вул", "стар"],
242        &[],
243    ),
244    (
245        "bg",
246        &[
247            "г", "г-н", "г-жа", "г-ца", "д-р", "проф", "инж", "св", "ул", "пл", "стр", "фиг",
248        ],
249        &[],
250    ),
251    (
252        "mk",
253        &["г", "г-дин", "г-ѓа", "д-р", "проф", "св", "ул", "стр"],
254        &[],
255    ),
256    // ── Baltic / Finno-Ugric ──
257    (
258        "lt",
259        &["p", "ponas", "ponia", "doc", "inž", "šv", "psl"],
260        &[],
261    ),
262    ("lv", &["k-gs", "k-dze", "doc", "inž", "sv", "lpp"], &[]),
263    ("et", &["hr", "pr", "prl", "dots", "lk"], &[]),
264    (
265        "fi",
266        &["hra", "rva", "nti", "tri", "ks", "vrt", "kuva"],
267        &[],
268    ),
269    ("hu", &["id", "ifj", "özv", "szt", "vö", "kb", "ún"], &[]),
270    // ── Hellenic ──
271    (
272        "el",
273        &[
274            "κ", "κα", "κος", "κύρ", "δρ", "καθ", "αγ", "σελ", "βλ", "εικ", "κεφ",
275        ],
276        &[],
277    ),
278    // ── Celtic / other European ──
279    ("ga", &["an t-uas", "uas", "naomh", "lch"], &[]),
280    ("cy", &["athro", "sant", "santes", "tud"], &[]),
281    ("gd", &["mgr", "an t-oll", "naomh"], &[]),
282    ("br", &["ao", "itron", "sant", "santez"], FRENCH_SPACED),
283    ("sq", &["z", "znj", "zonj", "sh", "shën", "faq"], &[]),
284    ("eu", &["jn", "and", "dk", "or"], &[]),
285    ("mt", &["sur", "sinjura", "san", "santa", "paġ"], &[]),
286    // ── Turkic ──
287    // Turkish lower-cases `İ` (U+0130) to `i` + U+0307 in Rust's locale-independent
288    // `to_lowercase`, so entries that begin with it are written in that decomposed form.
289    (
290        "tr",
291        &[
292            "sn",
293            "doç",
294            "av",
295            "öğr",
296            "yrd",
297            "alb",
298            "gen",
299            "hz",
300            "bkz",
301            "sf",
302            "şek",
303            "i\u{307}st",
304        ],
305        &[],
306    ),
307    // ── Semitic ──
308    // Arabic honorifics and reference marks; Arabic writes its own question mark (`؟`), which
309    // UAX #29 already treats as a terminator.
310    ("ar", &["د", "أ", "م", "ص", "ج", "ط", "هـ"], &[]),
311    // Hebrew deliberately carries no abbreviations — see the module docs.
312    ("he", &[], &[]),
313];
314
315impl Profile {
316    /// The profile for a BCP-47-ish tag (`en`, `en-US`, `pt_BR`, `sr-Latn`), matched on the
317    /// primary language subtag. An unknown or absent tag yields the bare UAX #29 profile.
318    fn for_locale(locale: Option<&str>) -> Profile {
319        // No locale means no tailoring at all — plain UAX #29, including no shared titles.
320        const BARE: Profile = Profile {
321            abbreviations: &[],
322            spaced_closers: &[],
323            extra_terminators: &[],
324            latin_titles: false,
325        };
326        let Some(tag) = locale else {
327            return BARE;
328        };
329        let primary = tag
330            .split(['-', '_'])
331            .next()
332            .unwrap_or("")
333            .to_ascii_lowercase();
334        let extra_terminators = EXTRA_TERMINATORS
335            .iter()
336            .find(|(lang, _)| *lang == primary)
337            .map_or(&[][..], |(_, marks)| marks);
338        match PROFILES.iter().find(|(lang, ..)| *lang == primary) {
339            Some((_, abbreviations, spaced_closers)) => Profile {
340                abbreviations,
341                spaced_closers,
342                extra_terminators,
343                latin_titles: !NON_LATIN_TITLES.contains(&primary.as_str()),
344            },
345            // A language the table does not name gets plain UAX #29 — not the Latin titles,
346            // which would be a guess about a script we know nothing about.
347            None => Profile {
348                extra_terminators,
349                ..BARE
350            },
351        }
352    }
353}
354
355// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
356// The two tailoring rules
357// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
358
359/// Whether the text ending at a candidate break finishes with a known abbreviation, so the
360/// break is spurious.
361fn ends_with_abbreviation(before: &str, profile: &Profile) -> bool {
362    let trimmed = before.trim_end();
363    let Some(stem) = trimmed.strip_suffix('.') else {
364        // Only a period is ever suppressed. `?` and `!` genuinely end sentences, and no
365        // abbreviation ends in one.
366        return false;
367    };
368    let lower = stem.to_lowercase();
369    let matches = |abbr: &&str| {
370        if lower == *abbr {
371            return true;
372        }
373        // Anchor on a boundary so `st` cannot fire inside `august`. A preceding space covers
374        // running prose; the opening bracket and quotation marks cover `(cf` and `“Dr`.
375        lower.strip_suffix(*abbr).is_some_and(|head| {
376            head.ends_with([
377                ' ', '\u{a0}', '\u{202f}', '(', '[', '"', '\'', '«', '»', '“', '„',
378            ])
379        })
380    };
381    profile.abbreviations.iter().any(matches)
382        || (profile.latin_titles && LATIN_SHARED.iter().any(matches))
383}
384
385/// Whether a candidate break would strand this language's spaced closing mark at the head of
386/// the next sentence, where it belongs to the one just ended.
387///
388/// Only the spaced case is ours to fix: UAX #29 already keeps a closer that abuts its
389/// terminator, so `?"` / `?«` / `?”` never reach here.
390fn strands_closing_mark(after: &str, profile: &Profile) -> bool {
391    !profile.spaced_closers.is_empty()
392        && after
393            .chars()
394            .next()
395            .is_some_and(|c| profile.spaced_closers.contains(&c))
396}
397
398/// Marks that can close a quotation, for the attribution rule below. Unlike the spaced-closer
399/// list this one may be general, because the rule it feeds also requires a lower-case word to
400/// follow — an opening quote is never followed by one in the same breath.
401const QUOTE_CLOSERS: &[char] = &['"', '\'', '»', '«', '”', '“', '’', '›', '‹', ')'];
402
403/// Whether a candidate break would split a line of dialogue from the speech tag that follows
404/// it: `"Are you sure?" he asked.`
405///
406/// UAX #29 breaks unconditionally after `STerm Close* Sp*` (rule SB11), unlike the period case
407/// (SB8), which suppresses the break when a lower-case word follows. So `"Go home." She left.`
408/// and `"Are you sure?" he asked.` are treated alike, and in fiction — where dialogue plus its
409/// attribution is the commonest sentence there is — that splits nearly every quoted line in
410/// two. This extends SB8's own signal, a following lower-case word, to the `?`/`!` case.
411///
412/// Case is the signal, so this cannot help Arabic or Hebrew, which have none. Their dialogue
413/// keeps UAX #29's split; there is nothing in the text to distinguish the two readings.
414fn splits_dialogue_from_attribution(before: &str, after: &str) -> bool {
415    if !before.trim_end().ends_with(QUOTE_CLOSERS) {
416        return false;
417    }
418    after
419        .chars()
420        .find(|c| c.is_alphabetic())
421        .is_some_and(char::is_lowercase)
422}
423
424/// Byte offsets just past this language's own terminators, each carrying any whitespace that
425/// follows so the segment shape matches UAX #29's (which includes the trailing space).
426fn extra_terminator_breaks(text: &str, profile: &Profile) -> Vec<usize> {
427    if profile.extra_terminators.is_empty() {
428        return Vec::new();
429    }
430    let mut out = Vec::new();
431    for (byte, ch) in text.char_indices() {
432        if !profile.extra_terminators.contains(&ch) {
433            continue;
434        }
435        let after = byte + ch.len_utf8();
436        let run = text[after..]
437            .char_indices()
438            .find(|(_, c)| !c.is_whitespace())
439            .map_or(text.len() - after, |(i, _)| i);
440        let brk = after + run;
441        if brk > 0 && brk < text.len() {
442            out.push(brk);
443        }
444    }
445    out
446}
447
448// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
449// The query
450// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
451
452/// The sentence of `text` containing `char_offset`, as block-relative **char** offsets, with
453/// trailing whitespace trimmed off the end so a highlight stops at the terminator rather than
454/// trailing into the gap before the next sentence.
455///
456/// `None` when `text` holds no sentence to point at (empty, or whitespace only).
457///
458/// `char_offset` may sit at the very end of `text`, which resolves to the last sentence — the
459/// caret is *after* the final character, exactly as `find_word_boundaries` treats the end of
460/// the last word.
461pub fn sentence_bounds(
462    text: &str,
463    char_offset: usize,
464    locale: Option<&str>,
465) -> Option<(usize, usize)> {
466    let char_breaks = char_breaks(text, locale)?;
467
468    let total = *char_breaks.last()?;
469    let offset = char_offset.min(total);
470    let idx = char_breaks
471        .windows(2)
472        .position(|w| offset >= w[0] && offset < w[1])
473        // A caret at the very end belongs to the final sentence.
474        .unwrap_or(char_breaks.len().saturating_sub(2));
475    let (start, end) = (*char_breaks.get(idx)?, *char_breaks.get(idx + 1)?);
476
477    trim_trailing_whitespace(text, start, end)
478}
479
480/// One sentence of a text: the slice itself, and where it sits as **char** offsets.
481///
482/// Both fields describe the same span — `text` is `char_range` already sliced out. They are
483/// paired rather than left to the caller because a statistics caller wants the substring (to
484/// count words in it) while a highlighting caller wants the range, and recovering one from the
485/// other means a fresh O(n) char→byte walk per sentence.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct Sentence<'a> {
488    pub text: &'a str,
489    pub char_range: std::ops::Range<usize>,
490}
491
492/// Every sentence of `text`, in order, with the same boundary rules
493/// [`sentence_bounds`] applies — trailing whitespace trimmed, whitespace-only
494/// segments dropped.
495///
496/// This is the batch form of [`sentence_bounds`], for callers measuring a whole
497/// text rather than locating one caret. Asking `sentence_bounds` at every offset
498/// instead would re-run the boundary pass per character.
499///
500/// Empty when `text` holds no sentence (empty, or whitespace only).
501pub fn sentences<'a>(text: &'a str, locale: Option<&str>) -> Vec<Sentence<'a>> {
502    let Some(char_breaks) = char_breaks(text, locale) else {
503        return Vec::new();
504    };
505
506    // char offset → byte offset in ONE pass, for the same reason the char_breaks pass itself
507    // is one pass: slicing each sentence with `.chars().skip(n)` would be quadratic.
508    let mut byte_at: Vec<usize> = Vec::with_capacity(char_breaks.len());
509    let mut next = 0usize;
510    for (chars, (byte, _)) in text.char_indices().enumerate() {
511        while next < char_breaks.len() && char_breaks[next] == chars {
512            byte_at.push(byte);
513            next += 1;
514        }
515    }
516    while next < char_breaks.len() {
517        byte_at.push(text.len());
518        next += 1;
519    }
520
521    let mut out = Vec::with_capacity(char_breaks.len().saturating_sub(1));
522    for i in 0..char_breaks.len().saturating_sub(1) {
523        let (start, end) = (char_breaks[i], char_breaks[i + 1]);
524        // Whitespace-only segments trim to nothing and are not sentences.
525        let Some((start, trimmed_end)) = trim_trailing_whitespace(text, start, end) else {
526            continue;
527        };
528        // `trim_trailing_whitespace` only ever pulls `end` back, so the byte offset of the
529        // trimmed end is the byte offset of `end` minus the bytes of the trimmed chars.
530        let byte_start = byte_at[i];
531        let byte_end = if trimmed_end == end {
532            byte_at[i + 1]
533        } else {
534            let mut b = byte_at[i + 1];
535            let mut back = end - trimmed_end;
536            while back > 0 {
537                b -= text[..b].chars().next_back().map_or(0, char::len_utf8);
538                back -= 1;
539            }
540            b
541        };
542        out.push(Sentence {
543            text: &text[byte_start..byte_end],
544            char_range: start..trimmed_end,
545        });
546    }
547    out
548}
549
550/// The sentence boundaries of `text` as **char** offsets, bracketed by `0` and the text's char
551/// length so every sentence is a `[i, i+1)` window over the returned list.
552///
553/// `None` when `text` is empty. Both public queries build on this, which is what keeps them
554/// from ever disagreeing about where a sentence ends.
555fn char_breaks(text: &str, locale: Option<&str>) -> Option<Vec<usize>> {
556    if text.is_empty() {
557        return None;
558    }
559    let profile = Profile::for_locale(locale);
560
561    // UAX #29 byte breakpoints plus this language's own terminators, minus the ones the
562    // tailoring rules reject. `0` and `text.len()` bracket the list so every sentence is a
563    // `[i, i+1)` window over it.
564    let mut byte_breaks: Vec<usize> = vec![0];
565    let extra = extra_terminator_breaks(text, &profile);
566    let candidates = text
567        .split_sentence_bound_indices()
568        .map(|(i, _)| i)
569        .chain(extra.iter().copied());
570    for i in candidates {
571        if i == 0 {
572            continue;
573        }
574        if ends_with_abbreviation(&text[..i], &profile)
575            || strands_closing_mark(&text[i..], &profile)
576            || splits_dialogue_from_attribution(&text[..i], &text[i..])
577        {
578            continue;
579        }
580        byte_breaks.push(i);
581    }
582    // The extra terminators are appended out of order and may duplicate a UAX #29 break.
583    byte_breaks.sort_unstable();
584    byte_breaks.dedup();
585    if byte_breaks.last() != Some(&text.len()) {
586        byte_breaks.push(text.len());
587    }
588
589    // Byte → char offsets in ONE pass over the text. Converting each breakpoint with
590    // `text[..b].chars().count()` would be quadratic, and a scene's paragraph is long enough
591    // for that to matter on every keystroke.
592    let mut char_breaks: Vec<usize> = Vec::with_capacity(byte_breaks.len());
593    let mut next = 0usize;
594    let mut chars = 0usize;
595    for (byte, _) in text.char_indices() {
596        while next < byte_breaks.len() && byte_breaks[next] == byte {
597            char_breaks.push(chars);
598            next += 1;
599        }
600        chars += 1;
601    }
602    // Whatever remains lands at the end of the text (always at least `text.len()` itself).
603    while next < byte_breaks.len() {
604        char_breaks.push(chars);
605        next += 1;
606    }
607
608    Some(char_breaks)
609}
610
611/// Pull `end` back over any trailing whitespace, so the returned range covers the sentence and
612/// not the gap after it. `None` when nothing but whitespace is left.
613fn trim_trailing_whitespace(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
614    let mut trimmed = end;
615    let mut it = text
616        .chars()
617        .skip(start)
618        .take(end - start)
619        .collect::<Vec<_>>();
620    while trimmed > start {
621        match it.pop() {
622            Some(c) if c.is_whitespace() => trimmed -= 1,
623            _ => break,
624        }
625    }
626    (trimmed > start).then_some((start, trimmed))
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    /// The sentences of `text`, as the caret would see them stepping through it.
634    fn split(text: &str, locale: Option<&str>) -> Vec<String> {
635        let total = text.chars().count();
636        let mut out: Vec<String> = Vec::new();
637        let mut at = 0usize;
638        while at <= total {
639            match sentence_bounds(text, at, locale) {
640                Some((s, e)) => {
641                    let piece: String = text.chars().skip(s).take(e - s).collect();
642                    if out.last() != Some(&piece) {
643                        out.push(piece);
644                    }
645                    at = e.max(at + 1);
646                }
647                None => at += 1,
648            }
649        }
650        out
651    }
652
653    // ── the abbreviation rule ──
654
655    #[test]
656    fn a_title_does_not_end_a_sentence_even_before_a_capital() {
657        assert_eq!(
658            split("Mr. Smith went home. He was tired.", Some("en-US")),
659            ["Mr. Smith went home.", "He was tired."]
660        );
661        assert_eq!(
662            split("M. Dupont est parti. Il était tard.", Some("fr-FR")),
663            ["M. Dupont est parti.", "Il était tard."]
664        );
665        assert_eq!(
666            split("Dr. Ayşe geldi. Sonra gitti.", Some("tr")),
667            ["Dr. Ayşe geldi.", "Sonra gitti."]
668        );
669        assert_eq!(
670            split("Vino el Sr. García. Se sentó.", Some("es")),
671            ["Vino el Sr. García.", "Se sentó."]
672        );
673        assert_eq!(
674            split("Ήρθε ο κ. Παπαδόπουλος. Κάθισε.", Some("el")),
675            ["Ήρθε ο κ. Παπαδόπουλος.", "Κάθισε."]
676        );
677        assert_eq!(
678            split("قال د. أحمد شيئا. ثم صمت.", Some("ar")),
679            ["قال د. أحمد شيئا.", "ثم صمت."]
680        );
681    }
682
683    /// The shared Latin title list reaches a language whose own row does not repeat it.
684    #[test]
685    fn the_shared_latin_titles_reach_every_latin_language() {
686        for (tag, text, want) in [
687            ("nl", "Dhr. Jansen kwam. Hij zweeg.", "Dhr. Jansen kwam."),
688            (
689                "pl",
690                "Przyszedł prof. Nowak. Potem wyszedł.",
691                "Przyszedł prof. Nowak.",
692            ),
693            (
694                "cs",
695                "Přišel pan Dr. Novák. Pak odešel.",
696                "Přišel pan Dr. Novák.",
697            ),
698            ("hu", "Megjött Dr. Nagy. Aztán elment.", "Megjött Dr. Nagy."),
699            (
700                "it",
701                "È arrivato il Sig. Rossi. Poi tacque.",
702                "È arrivato il Sig. Rossi.",
703            ),
704        ] {
705            assert_eq!(split(text, Some(tag))[0], want, "{tag}");
706        }
707    }
708
709    /// The rule that keeps the list safe: a term that can end a sentence must not be in it.
710    #[test]
711    fn a_terminating_abbreviation_still_ends_its_sentence() {
712        assert_eq!(
713            split("He bought apples, pears, etc. Then he left.", Some("en")),
714            ["He bought apples, pears, etc.", "Then he left."]
715        );
716    }
717
718    /// `st` must not fire inside `august`, which is what the boundary anchor is for.
719    #[test]
720    fn an_abbreviation_only_matches_as_a_whole_word() {
721        assert_eq!(
722            split("They met in august. Rain fell.", Some("en")),
723            ["They met in august.", "Rain fell."]
724        );
725    }
726
727    /// UAX #29 already suppresses an abbreviation before a lower-case word or a digit; these pin
728    /// that we have not broken it while adding the capital-letter case.
729    #[test]
730    fn uax29_already_handles_lowercase_and_numeric_followers() {
731        assert_eq!(
732            split("Er kam um 5 Uhr, z.B. gestern. Dann ging er.", Some("de")),
733            ["Er kam um 5 Uhr, z.B. gestern.", "Dann ging er."]
734        );
735        assert_eq!(
736            split("Он пришёл в 5 ч. утра. Потом ушёл.", Some("ru")),
737            ["Он пришёл в 5 ч. утра.", "Потом ушёл."]
738        );
739        assert_eq!(
740            split("It cost 3.50 euros. Not bad.", Some("en")),
741            ["It cost 3.50 euros.", "Not bad."]
742        );
743    }
744
745    // ── the closing-mark rule ──
746
747    /// UAX #29 keeps a closer that abuts its terminator, in every script. These pin that we
748    /// rely on it rather than re-implementing it — an earlier draft added a general
749    /// closing-mark rule here and welded `He left. "Come,"` into one sentence.
750    #[test]
751    fn an_abutting_closing_quote_needs_no_tailoring() {
752        assert_eq!(
753            split(
754                "She paused. \"Are you sure?\" he asked. Then silence.",
755                Some("en")
756            ),
757            [
758                "She paused.",
759                "\"Are you sure?\" he asked.",
760                "Then silence."
761            ]
762        );
763        assert_eq!(
764            split("Powiedział: „Naprawdę?” Potem wyszedł.", Some("pl")),
765            ["Powiedział: „Naprawdę?”", "Potem wyszedł."]
766        );
767    }
768
769    /// French writes a space before the closing guillemet; without the spaced rule the `»`
770    /// lands at the head of the next sentence.
771    #[test]
772    fn french_guillemets_close_across_their_space() {
773        assert_eq!(
774            split(
775                "Mme Aubry hésita. « Vraiment ? » demanda-t-il. Puis le silence.",
776                Some("fr")
777            ),
778            [
779                "Mme Aubry hésita.",
780                "« Vraiment ? » demanda-t-il.",
781                "Puis le silence."
782            ]
783        );
784    }
785
786    /// The commonest sentence in fiction: a quoted line plus its speech tag. UAX #29 rule SB11
787    /// splits every one of them, so this is the tailoring that matters most in a novel.
788    #[test]
789    fn dialogue_keeps_its_speech_tag() {
790        for (tag, text) in [
791            ("en", "\"Are you sure?\" he asked."),
792            ("en", "\"Go away!\" she shouted."),
793            ("de", "»Wirklich?« fragte sie."),
794            ("fr", "« Vraiment ? » demanda-t-il."),
795            ("pl", "„Naprawdę?” zapytał."),
796        ] {
797            assert_eq!(split(text, Some(tag)), [text], "{tag}: {text}");
798        }
799    }
800
801    /// …but a capitalised word after the quote really is a new sentence, and must stay one.
802    /// This is the same signal UAX #29's own SB8 uses for the period case.
803    #[test]
804    fn a_new_sentence_after_dialogue_still_splits() {
805        assert_eq!(
806            split("\"Are you sure?\" He turned away.", Some("en")),
807            ["\"Are you sure?\"", "He turned away."]
808        );
809        assert_eq!(
810            split("»Wirklich?« Sie ging fort.", Some("de")),
811            ["»Wirklich?«", "Sie ging fort."]
812        );
813    }
814
815    /// German reverses the guillemets, so its closer abuts the terminator and needs no spaced
816    /// rule — and must not gain one, or an opening quote would be swallowed.
817    #[test]
818    fn an_opening_quote_after_a_terminator_starts_a_new_sentence() {
819        assert_eq!(
820            split("Er schwieg. »Wirklich?« fragte sie.", Some("de")),
821            ["Er schwieg.", "»Wirklich?« fragte sie."]
822        );
823        assert_eq!(
824            split("He left. \"Come,\" she said.", Some("en")),
825            ["He left.", "\"Come,\" she said."]
826        );
827    }
828
829    // ── scripts that need no tailoring ──
830
831    #[test]
832    fn scripts_with_their_own_terminators_work_untailored() {
833        assert_eq!(
834            split("أين تذهب؟ لا أعرف.", Some("ar")),
835            ["أين تذهب؟", "لا أعرف."]
836        );
837        assert_eq!(
838            split("¿Adónde vas? No sé.", Some("es")),
839            ["¿Adónde vas?", "No sé."]
840        );
841    }
842
843    /// Greek asks questions with an ASCII semicolon, which UAX #29 rightly leaves alone — so
844    /// this is the one place a terminator has to be *added* rather than suppressed.
845    #[test]
846    fn the_greek_question_mark_ends_a_sentence() {
847        assert_eq!(
848            split("Πού πηγαίνεις; Δεν ξέρω. Ίσως αύριο.", Some("el")),
849            ["Πού πηγαίνεις;", "Δεν ξέρω.", "Ίσως αύριο."]
850        );
851        // U+037E, the canonical twin nobody types.
852        assert_eq!(
853            split("Πού πηγαίνεις\u{37e} Δεν ξέρω.", Some("el")),
854            ["Πού πηγαίνεις\u{37e}", "Δεν ξέρω."]
855        );
856        // The ano teleia is Greek's *semicolon* and keeps not terminating.
857        assert_eq!(split("Ήρθε· κάθισε.", Some("el")), ["Ήρθε· κάθισε."]);
858        // …and a semicolon in any other language stays a semicolon.
859        assert_eq!(
860            split("He came; she left.", Some("en")),
861            ["He came; she left."]
862        );
863    }
864
865    /// Hebrew abbreviations end in geresh, not a period, so UAX #29 never splits them — which
866    /// is why the Hebrew profile is deliberately empty.
867    #[test]
868    fn hebrew_abbreviations_need_no_suppression() {
869        assert_eq!(
870            split("הוא קרא ספרים וכו׳ ואז עצר.", Some("he")),
871            ["הוא קרא ספרים וכו׳ ואז עצר."]
872        );
873    }
874
875    /// Turkish `İ` lower-cases to `i` + U+0307 under Rust's locale-independent mapping, so the
876    /// table stores that form. This pins the pairing.
877    #[test]
878    fn turkish_dotted_capital_i_matches_its_table_entry() {
879        assert_eq!("İst.".to_lowercase(), "i\u{307}st.");
880        assert_eq!(
881            split("İst. Üniversitesi açıldı. Sonra kapandı.", Some("tr")),
882            ["İst. Üniversitesi açıldı.", "Sonra kapandı."]
883        );
884    }
885
886    // ── the fallback ──
887
888    #[test]
889    fn an_unknown_locale_falls_back_to_plain_uax29() {
890        // Everything UAX #29 gets right on its own still works; only the tailoring is absent.
891        assert_eq!(
892            split("She paused. \"Are you sure?\" he asked.", Some("zz-ZZ")),
893            ["She paused.", "\"Are you sure?\" he asked."]
894        );
895        assert_eq!(
896            split("Mr. Smith went home.", Some("zz")),
897            ["Mr.", "Smith went home."]
898        );
899        assert_eq!(
900            split("Mr. Smith went home.", None),
901            ["Mr.", "Smith went home."]
902        );
903    }
904
905    /// **The shared-title list must not leak into languages that never asked for it.**
906    ///
907    /// An earlier version inferred "is this Latin-script?" from the profile's own entries, and
908    /// `[].iter().all(..)` is vacuously true — so the untailored fallback and Hebrew (whose row
909    /// is deliberately empty) both silently inherited `dr`/`prof`/`st`/…. Only `mr` happening
910    /// not to be in that list kept the test above from catching it.
911    #[test]
912    fn the_shared_latin_titles_reach_only_the_languages_that_want_them() {
913        // The untailored fallback tailors nothing at all.
914        for locale in [None, Some("zz")] {
915            assert_eq!(
916                split("Dr. Smith went home. He left.", locale),
917                ["Dr.", "Smith went home.", "He left."],
918                "locale {locale:?} must get plain UAX #29"
919            );
920        }
921        // Hebrew spells its abbreviations with geresh, so a Latin honorific is not one of its
922        // titles and must not suppress a break.
923        assert_eq!(
924            split("Prof. Cohen arrived. He sat.", Some("he")),
925            ["Prof.", "Cohen arrived.", "He sat."]
926        );
927        // Nor do the languages that spell their own titles in their own script.
928        for tag in ["ru", "el", "ar", "bg", "uk", "mk", "be"] {
929            assert_eq!(
930                split("Dr. Smith went home.", Some(tag))[0],
931                "Dr.",
932                "{tag} spells its own titles and must not inherit the Latin ones"
933            );
934        }
935    }
936
937    /// Serbian is written in both scripts and its row carries both spellings, so it wants the
938    /// Latin titles as well — the case the old entry-sniffing heuristic excluded.
939    #[test]
940    fn a_dual_script_language_still_gets_the_latin_titles() {
941        assert_eq!(
942            split("Dr. Novak je stigao. Onda je otišao.", Some("sr")),
943            ["Dr. Novak je stigao.", "Onda je otišao."]
944        );
945        // …and its own Cyrillic titles keep working.
946        assert_eq!(
947            split("Др. Новак је стигао. Онда је отишао.", Some("sr"))[0],
948            "Др. Новак је стигао."
949        );
950    }
951
952    // ── shape of the returned range ──
953
954    #[test]
955    fn the_range_stops_at_the_terminator_not_the_gap_after_it() {
956        let text = "One. Two.";
957        // The caret inside "One." resolves to exactly "One." — the following space is not part
958        // of the band.
959        assert_eq!(sentence_bounds(text, 1, Some("en")), Some((0, 4)));
960        // The caret in the gap still belongs to the sentence it follows.
961        assert_eq!(sentence_bounds(text, 4, Some("en")), Some((0, 4)));
962        assert_eq!(sentence_bounds(text, 5, Some("en")), Some((5, 9)));
963    }
964
965    #[test]
966    fn a_caret_at_the_very_end_belongs_to_the_last_sentence() {
967        let text = "One. Two.";
968        assert_eq!(
969            sentence_bounds(text, text.chars().count(), Some("en")),
970            Some((5, 9))
971        );
972        // Past the end is clamped rather than panicking.
973        assert_eq!(sentence_bounds(text, 9_999, Some("en")), Some((5, 9)));
974    }
975
976    #[test]
977    fn empty_and_blank_text_have_no_sentence() {
978        assert_eq!(sentence_bounds("", 0, Some("en")), None);
979        assert_eq!(sentence_bounds("   ", 1, Some("en")), None);
980    }
981
982    #[test]
983    fn a_single_sentence_block_is_one_range() {
984        assert_eq!(
985            sentence_bounds("Just the one", 5, Some("en")),
986            Some((0, 12))
987        );
988    }
989
990    /// Offsets are **char** offsets, so a block of multi-byte text must not report byte
991    /// positions — the caret would land mid-character.
992    #[test]
993    fn offsets_are_char_based_not_byte_based() {
994        let text = "Ééé. Ààà.";
995        assert_eq!(text.len(), 15, "multi-byte on purpose");
996        assert_eq!(sentence_bounds(text, 0, Some("fr")), Some((0, 4)));
997        assert_eq!(sentence_bounds(text, 5, Some("fr")), Some((5, 9)));
998    }
999
1000    // ── the batch query ──
1001
1002    /// Every fixture the caret-stepping tests above use, run through both queries. This is
1003    /// the guarantee that matters: a statistics caller and a caret caller must never disagree
1004    /// about where a sentence ends, and the only way to keep that true as the tailoring rules
1005    /// evolve is to check it on the same corpus both are specified against.
1006    #[test]
1007    fn the_batch_query_agrees_with_the_caret_query_everywhere() {
1008        let corpus: &[(&str, Option<&str>)] = &[
1009            ("Mr. Smith went home. He was tired.", Some("en-US")),
1010            ("M. Dupont est parti. Il était tard.", Some("fr-FR")),
1011            ("Dr. Ayşe geldi. Sonra gitti.", Some("tr")),
1012            ("Vino el Sr. García. Se sentó.", Some("es")),
1013            ("Ήρθε ο κ. Παπαδόπουλος. Κάθισε.", Some("el")),
1014            ("« Vraiment ? » Elle partit.", Some("fr-FR")),
1015            ("Ένα ερώτημα; Και μια απάντηση.", Some("el")),
1016            ("\"Come,\" she said. He left.", Some("en")),
1017            ("He left. \"Come,\" she said.", Some("en")),
1018            ("z.B. gestern war es kalt. Heute nicht.", Some("de")),
1019            ("One two three.", Some("en")),
1020            ("Ééé. Ààà.", Some("fr")),
1021            ("Just the one", Some("en")),
1022            ("First! Second? Third.", Some("en")),
1023            ("No locale profile at all. Second one.", Some("xx")),
1024            ("", Some("en")),
1025            ("   ", Some("en")),
1026        ];
1027        for (text, locale) in corpus {
1028            let batch: Vec<String> = sentences(text, *locale)
1029                .into_iter()
1030                .map(|s| s.text.to_string())
1031                .collect();
1032            assert_eq!(
1033                batch,
1034                split(text, *locale),
1035                "disagreement on {text:?} ({locale:?})"
1036            );
1037        }
1038    }
1039
1040    /// `text` and `char_range` must describe the same span — the struct pairs them precisely
1041    /// so a caller can use either without re-deriving the other.
1042    #[test]
1043    fn the_slice_and_the_range_describe_the_same_span() {
1044        let text = "Ééé les uns. Ààà les autres. Fin.";
1045        for s in sentences(text, Some("fr")) {
1046            let by_range: String = text
1047                .chars()
1048                .skip(s.char_range.start)
1049                .take(s.char_range.len())
1050                .collect();
1051            assert_eq!(s.text, by_range, "slice and range disagree for {s:?}");
1052        }
1053    }
1054
1055    #[test]
1056    fn a_text_with_no_sentence_yields_none() {
1057        assert!(sentences("", Some("en")).is_empty());
1058        assert!(sentences("   \n\t ", Some("en")).is_empty());
1059    }
1060
1061    /// Trailing whitespace is trimmed per sentence, so a word count over the slices does not
1062    /// pick up the gap between them, and the ranges stay adjacent-but-not-overlapping.
1063    #[test]
1064    fn batch_sentences_are_trimmed_and_ordered() {
1065        let out = sentences("One.   Two.   Three.", Some("en"));
1066        assert_eq!(
1067            out.iter().map(|s| s.text).collect::<Vec<_>>(),
1068            ["One.", "Two.", "Three."]
1069        );
1070        for pair in out.windows(2) {
1071            assert!(
1072                pair[0].char_range.end <= pair[1].char_range.start,
1073                "ranges must not overlap: {:?} then {:?}",
1074                pair[0],
1075                pair[1]
1076            );
1077        }
1078    }
1079
1080    // ── documented limitations ──
1081    //
1082    // These pin behaviour that is *wrong* but deliberately not fixed here: correcting them
1083    // changes already-shipped caret navigation, which is separate work. They exist so the
1084    // gap is visible to a statistics caller, which — unlike a caret — has no human watching
1085    // each boundary land.
1086
1087    /// A literal ellipsis never ends a sentence, so an ellipsis-heavy passage reads as one
1088    /// very long sentence. UAX #29 treats `…` as `SContinue`, not a terminator.
1089    #[test]
1090    fn documented_limitation_a_literal_ellipsis_does_not_split() {
1091        assert_eq!(
1092            sentences("He hesitated… She waited…", Some("en")).len(),
1093            1,
1094            "known gap: U+2026 is SContinue under UAX #29, so this reads as one sentence"
1095        );
1096    }
1097
1098    /// French em-dash dialogue has no closing mark, so a turn boundary is invisible to
1099    /// UAX #29 and two speakers' turns merge into one sentence.
1100    #[test]
1101    fn documented_limitation_french_em_dash_dialogue_turns_merge() {
1102        let turns = "— Tu viens ?\n— Non.";
1103        // Block-scoped splitting still breaks on the newline; within one line it would not.
1104        let one_line = "— Tu viens ? — Non, dit-il.";
1105        assert_eq!(
1106            sentences(one_line, Some("fr-FR")).len(),
1107            1,
1108            "known gap: an em-dash turn has no closing mark for the splitter to see"
1109        );
1110        assert!(
1111            sentences(turns, Some("fr-FR")).len() >= 2,
1112            "a newline still separates turns"
1113        );
1114    }
1115}