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}