use unicode_segmentation::UnicodeSegmentation;
struct Profile {
abbreviations: &'static [&'static str],
spaced_closers: &'static [char],
extra_terminators: &'static [char],
latin_titles: bool,
}
const FRENCH_SPACED: &[char] = &['»', '\u{203A}'];
const NON_LATIN_TITLES: &[&str] = &["ru", "uk", "be", "bg", "mk", "el", "ar", "he"];
const EXTRA_TERMINATORS: &[(&str, &[char])] = &[
("el", &[';', '\u{37e}']),
];
const LATIN_SHARED: &[&str] = &["dr", "prof", "st", "sr", "jr", "cf", "vs", "ing", "mag"];
const PROFILES: &[(&str, &[&str], &[char])] = &[
(
"en",
&[
"mr", "mrs", "ms", "mx", "messrs", "rev", "hon", "gen", "col", "capt", "lt", "sgt",
"maj", "adm", "gov", "pres", "supt", "msgr", "fr", "br", "sen", "rep", "mt", "ft",
"esq", "viz", "al",
],
&[],
),
(
"de",
&[
"hr", "fr", "frl", "hl", "vgl", "abb", "kap", "nr", "bd", "jg", "ggf", "bspw", "sen",
"dipl", "verw", "geb", "gest", "ehem",
],
&[],
),
(
"nl",
&[
"dhr", "mevr", "mw", "mej", "ir", "drs", "mr", "jhr", "sint", "afb", "blz", "zgn",
],
&[],
),
("da", &["hr", "fru", "frk", "skt", "jf", "afd"], &[]),
("sv", &["hr", "fru", "frk", "jfr", "avd", "kap"], &[]),
("nb", &["hr", "fru", "frk", "jf", "avd", "kap"], &[]),
("nn", &["hr", "fru", "frk", "jf", "avd", "kap"], &[]),
("no", &["hr", "fru", "frk", "jf", "avd", "kap"], &[]),
("is", &["hr", "frú", "sr", "bls", "sbr"], &[]),
("af", &["mnr", "mev", "mej", "ds", "prof"], &[]),
(
"fr",
&[
"m", "mm", "mme", "mmes", "mlle", "mlles", "me", "mgr", "pr", "ste", "sts", "stes",
"vve", "p. ex", "av. j.-c", "ap. j.-c", "chap", "réf", "fig",
],
FRENCH_SPACED,
),
(
"es",
&[
"sra", "srta", "dra", "profa", "d", "dª", "dña", "sto", "sta", "san", "lic", "arq",
"ud", "uds", "vid", "núm", "pág",
],
&[],
),
(
"pt",
&[
"sra", "srta", "dra", "profa", "eng", "arq", "d", "dom", "sto", "sta", "exmo", "exma",
"pág",
],
&[],
),
(
"it",
&[
"sig", "sigg", "sig.ra", "dott", "dott.ssa", "arch", "avv", "on", "mons", "egr",
"spett", "geom", "rag", "pag",
],
&[],
),
(
"ca",
&["sra", "dra", "sta", "mn", "núm", "pàg", "il·lm"],
&[],
),
("gl", &["sra", "srta", "dra", "sta", "sto", "páx"], &[]),
("ro", &["dl", "dna", "dra", "sf", "nr", "pag"], &[]),
(
"pl",
&[
"p", "pan", "pani", "inż", "mgr", "ks", "św", "hab", "red", "płk", "gen", "por", "rys",
],
&[],
),
(
"cs",
&[
"p", "pí", "bc", "judr", "mudr", "phdr", "sv", "plk", "gen", "obr", "kap",
],
&[],
),
(
"sk",
&["p", "pí", "bc", "judr", "mudr", "sv", "plk", "obr"],
&[],
),
("sl", &["g", "ga", "gdč", "sv", "št"], &[]),
("hr", &["g", "gđa", "gđica", "sv", "br", "sl"], &[]),
("bs", &["g", "gđa", "gđica", "sv", "br"], &[]),
(
"sr",
&[
"g", "gđa", "sv", "br", "г", "гђа", "др", "проф", "св", "инж",
],
&[],
),
(
"ru",
&[
"г", "гн", "г-н", "г-жа", "д-р", "проф", "акад", "тов", "св", "им", "ул", "пл", "обл",
"стр", "рис", "табл", "гл", "изд",
],
&[],
),
(
"uk",
&[
"п", "пан", "пані", "д-р", "проф", "св", "вул", "пл", "обл", "стор", "мал", "гл",
],
&[],
),
(
"be",
&["сп", "спн", "д-р", "праф", "св", "вул", "стар"],
&[],
),
(
"bg",
&[
"г", "г-н", "г-жа", "г-ца", "д-р", "проф", "инж", "св", "ул", "пл", "стр", "фиг",
],
&[],
),
(
"mk",
&["г", "г-дин", "г-ѓа", "д-р", "проф", "св", "ул", "стр"],
&[],
),
(
"lt",
&["p", "ponas", "ponia", "doc", "inž", "šv", "psl"],
&[],
),
("lv", &["k-gs", "k-dze", "doc", "inž", "sv", "lpp"], &[]),
("et", &["hr", "pr", "prl", "dots", "lk"], &[]),
(
"fi",
&["hra", "rva", "nti", "tri", "ks", "vrt", "kuva"],
&[],
),
("hu", &["id", "ifj", "özv", "szt", "vö", "kb", "ún"], &[]),
(
"el",
&[
"κ", "κα", "κος", "κύρ", "δρ", "καθ", "αγ", "σελ", "βλ", "εικ", "κεφ",
],
&[],
),
("ga", &["an t-uas", "uas", "naomh", "lch"], &[]),
("cy", &["athro", "sant", "santes", "tud"], &[]),
("gd", &["mgr", "an t-oll", "naomh"], &[]),
("br", &["ao", "itron", "sant", "santez"], FRENCH_SPACED),
("sq", &["z", "znj", "zonj", "sh", "shën", "faq"], &[]),
("eu", &["jn", "and", "dk", "or"], &[]),
("mt", &["sur", "sinjura", "san", "santa", "paġ"], &[]),
(
"tr",
&[
"sn",
"doç",
"av",
"öğr",
"yrd",
"alb",
"gen",
"hz",
"bkz",
"sf",
"şek",
"i\u{307}st",
],
&[],
),
("ar", &["د", "أ", "م", "ص", "ج", "ط", "هـ"], &[]),
("he", &[], &[]),
];
impl Profile {
fn for_locale(locale: Option<&str>) -> Profile {
const BARE: Profile = Profile {
abbreviations: &[],
spaced_closers: &[],
extra_terminators: &[],
latin_titles: false,
};
let Some(tag) = locale else {
return BARE;
};
let primary = tag
.split(['-', '_'])
.next()
.unwrap_or("")
.to_ascii_lowercase();
let extra_terminators = EXTRA_TERMINATORS
.iter()
.find(|(lang, _)| *lang == primary)
.map_or(&[][..], |(_, marks)| marks);
match PROFILES.iter().find(|(lang, ..)| *lang == primary) {
Some((_, abbreviations, spaced_closers)) => Profile {
abbreviations,
spaced_closers,
extra_terminators,
latin_titles: !NON_LATIN_TITLES.contains(&primary.as_str()),
},
None => Profile {
extra_terminators,
..BARE
},
}
}
}
fn ends_with_abbreviation(before: &str, profile: &Profile) -> bool {
let trimmed = before.trim_end();
let Some(stem) = trimmed.strip_suffix('.') else {
return false;
};
let lower = stem.to_lowercase();
let matches = |abbr: &&str| {
if lower == *abbr {
return true;
}
lower.strip_suffix(*abbr).is_some_and(|head| {
head.ends_with([
' ', '\u{a0}', '\u{202f}', '(', '[', '"', '\'', '«', '»', '“', '„',
])
})
};
profile.abbreviations.iter().any(matches)
|| (profile.latin_titles && LATIN_SHARED.iter().any(matches))
}
fn strands_closing_mark(after: &str, profile: &Profile) -> bool {
!profile.spaced_closers.is_empty()
&& after
.chars()
.next()
.is_some_and(|c| profile.spaced_closers.contains(&c))
}
const QUOTE_CLOSERS: &[char] = &['"', '\'', '»', '«', '”', '“', '’', '›', '‹', ')'];
fn splits_dialogue_from_attribution(before: &str, after: &str) -> bool {
if !before.trim_end().ends_with(QUOTE_CLOSERS) {
return false;
}
after
.chars()
.find(|c| c.is_alphabetic())
.is_some_and(char::is_lowercase)
}
fn extra_terminator_breaks(text: &str, profile: &Profile) -> Vec<usize> {
if profile.extra_terminators.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for (byte, ch) in text.char_indices() {
if !profile.extra_terminators.contains(&ch) {
continue;
}
let after = byte + ch.len_utf8();
let run = text[after..]
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map_or(text.len() - after, |(i, _)| i);
let brk = after + run;
if brk > 0 && brk < text.len() {
out.push(brk);
}
}
out
}
pub fn sentence_bounds(
text: &str,
char_offset: usize,
locale: Option<&str>,
) -> Option<(usize, usize)> {
let char_breaks = char_breaks(text, locale)?;
let total = *char_breaks.last()?;
let offset = char_offset.min(total);
let idx = char_breaks
.windows(2)
.position(|w| offset >= w[0] && offset < w[1])
.unwrap_or(char_breaks.len().saturating_sub(2));
let (start, end) = (*char_breaks.get(idx)?, *char_breaks.get(idx + 1)?);
trim_trailing_whitespace(text, start, end)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sentence<'a> {
pub text: &'a str,
pub char_range: std::ops::Range<usize>,
}
pub fn sentences<'a>(text: &'a str, locale: Option<&str>) -> Vec<Sentence<'a>> {
let Some(char_breaks) = char_breaks(text, locale) else {
return Vec::new();
};
let mut byte_at: Vec<usize> = Vec::with_capacity(char_breaks.len());
let mut next = 0usize;
for (chars, (byte, _)) in text.char_indices().enumerate() {
while next < char_breaks.len() && char_breaks[next] == chars {
byte_at.push(byte);
next += 1;
}
}
while next < char_breaks.len() {
byte_at.push(text.len());
next += 1;
}
let mut out = Vec::with_capacity(char_breaks.len().saturating_sub(1));
for i in 0..char_breaks.len().saturating_sub(1) {
let (start, end) = (char_breaks[i], char_breaks[i + 1]);
let Some((start, trimmed_end)) = trim_trailing_whitespace(text, start, end) else {
continue;
};
let byte_start = byte_at[i];
let byte_end = if trimmed_end == end {
byte_at[i + 1]
} else {
let mut b = byte_at[i + 1];
let mut back = end - trimmed_end;
while back > 0 {
b -= text[..b].chars().next_back().map_or(0, char::len_utf8);
back -= 1;
}
b
};
out.push(Sentence {
text: &text[byte_start..byte_end],
char_range: start..trimmed_end,
});
}
out
}
fn char_breaks(text: &str, locale: Option<&str>) -> Option<Vec<usize>> {
if text.is_empty() {
return None;
}
let profile = Profile::for_locale(locale);
let mut byte_breaks: Vec<usize> = vec![0];
let extra = extra_terminator_breaks(text, &profile);
let candidates = text
.split_sentence_bound_indices()
.map(|(i, _)| i)
.chain(extra.iter().copied());
for i in candidates {
if i == 0 {
continue;
}
if ends_with_abbreviation(&text[..i], &profile)
|| strands_closing_mark(&text[i..], &profile)
|| splits_dialogue_from_attribution(&text[..i], &text[i..])
{
continue;
}
byte_breaks.push(i);
}
byte_breaks.sort_unstable();
byte_breaks.dedup();
if byte_breaks.last() != Some(&text.len()) {
byte_breaks.push(text.len());
}
let mut char_breaks: Vec<usize> = Vec::with_capacity(byte_breaks.len());
let mut next = 0usize;
let mut chars = 0usize;
for (byte, _) in text.char_indices() {
while next < byte_breaks.len() && byte_breaks[next] == byte {
char_breaks.push(chars);
next += 1;
}
chars += 1;
}
while next < byte_breaks.len() {
char_breaks.push(chars);
next += 1;
}
Some(char_breaks)
}
fn trim_trailing_whitespace(text: &str, start: usize, end: usize) -> Option<(usize, usize)> {
let mut trimmed = end;
let mut it = text
.chars()
.skip(start)
.take(end - start)
.collect::<Vec<_>>();
while trimmed > start {
match it.pop() {
Some(c) if c.is_whitespace() => trimmed -= 1,
_ => break,
}
}
(trimmed > start).then_some((start, trimmed))
}
#[cfg(test)]
mod tests {
use super::*;
fn split(text: &str, locale: Option<&str>) -> Vec<String> {
let total = text.chars().count();
let mut out: Vec<String> = Vec::new();
let mut at = 0usize;
while at <= total {
match sentence_bounds(text, at, locale) {
Some((s, e)) => {
let piece: String = text.chars().skip(s).take(e - s).collect();
if out.last() != Some(&piece) {
out.push(piece);
}
at = e.max(at + 1);
}
None => at += 1,
}
}
out
}
#[test]
fn a_title_does_not_end_a_sentence_even_before_a_capital() {
assert_eq!(
split("Mr. Smith went home. He was tired.", Some("en-US")),
["Mr. Smith went home.", "He was tired."]
);
assert_eq!(
split("M. Dupont est parti. Il était tard.", Some("fr-FR")),
["M. Dupont est parti.", "Il était tard."]
);
assert_eq!(
split("Dr. Ayşe geldi. Sonra gitti.", Some("tr")),
["Dr. Ayşe geldi.", "Sonra gitti."]
);
assert_eq!(
split("Vino el Sr. García. Se sentó.", Some("es")),
["Vino el Sr. García.", "Se sentó."]
);
assert_eq!(
split("Ήρθε ο κ. Παπαδόπουλος. Κάθισε.", Some("el")),
["Ήρθε ο κ. Παπαδόπουλος.", "Κάθισε."]
);
assert_eq!(
split("قال د. أحمد شيئا. ثم صمت.", Some("ar")),
["قال د. أحمد شيئا.", "ثم صمت."]
);
}
#[test]
fn the_shared_latin_titles_reach_every_latin_language() {
for (tag, text, want) in [
("nl", "Dhr. Jansen kwam. Hij zweeg.", "Dhr. Jansen kwam."),
(
"pl",
"Przyszedł prof. Nowak. Potem wyszedł.",
"Przyszedł prof. Nowak.",
),
(
"cs",
"Přišel pan Dr. Novák. Pak odešel.",
"Přišel pan Dr. Novák.",
),
("hu", "Megjött Dr. Nagy. Aztán elment.", "Megjött Dr. Nagy."),
(
"it",
"È arrivato il Sig. Rossi. Poi tacque.",
"È arrivato il Sig. Rossi.",
),
] {
assert_eq!(split(text, Some(tag))[0], want, "{tag}");
}
}
#[test]
fn a_terminating_abbreviation_still_ends_its_sentence() {
assert_eq!(
split("He bought apples, pears, etc. Then he left.", Some("en")),
["He bought apples, pears, etc.", "Then he left."]
);
}
#[test]
fn an_abbreviation_only_matches_as_a_whole_word() {
assert_eq!(
split("They met in august. Rain fell.", Some("en")),
["They met in august.", "Rain fell."]
);
}
#[test]
fn uax29_already_handles_lowercase_and_numeric_followers() {
assert_eq!(
split("Er kam um 5 Uhr, z.B. gestern. Dann ging er.", Some("de")),
["Er kam um 5 Uhr, z.B. gestern.", "Dann ging er."]
);
assert_eq!(
split("Он пришёл в 5 ч. утра. Потом ушёл.", Some("ru")),
["Он пришёл в 5 ч. утра.", "Потом ушёл."]
);
assert_eq!(
split("It cost 3.50 euros. Not bad.", Some("en")),
["It cost 3.50 euros.", "Not bad."]
);
}
#[test]
fn an_abutting_closing_quote_needs_no_tailoring() {
assert_eq!(
split(
"She paused. \"Are you sure?\" he asked. Then silence.",
Some("en")
),
[
"She paused.",
"\"Are you sure?\" he asked.",
"Then silence."
]
);
assert_eq!(
split("Powiedział: „Naprawdę?” Potem wyszedł.", Some("pl")),
["Powiedział: „Naprawdę?”", "Potem wyszedł."]
);
}
#[test]
fn french_guillemets_close_across_their_space() {
assert_eq!(
split(
"Mme Aubry hésita. « Vraiment ? » demanda-t-il. Puis le silence.",
Some("fr")
),
[
"Mme Aubry hésita.",
"« Vraiment ? » demanda-t-il.",
"Puis le silence."
]
);
}
#[test]
fn dialogue_keeps_its_speech_tag() {
for (tag, text) in [
("en", "\"Are you sure?\" he asked."),
("en", "\"Go away!\" she shouted."),
("de", "»Wirklich?« fragte sie."),
("fr", "« Vraiment ? » demanda-t-il."),
("pl", "„Naprawdę?” zapytał."),
] {
assert_eq!(split(text, Some(tag)), [text], "{tag}: {text}");
}
}
#[test]
fn a_new_sentence_after_dialogue_still_splits() {
assert_eq!(
split("\"Are you sure?\" He turned away.", Some("en")),
["\"Are you sure?\"", "He turned away."]
);
assert_eq!(
split("»Wirklich?« Sie ging fort.", Some("de")),
["»Wirklich?«", "Sie ging fort."]
);
}
#[test]
fn an_opening_quote_after_a_terminator_starts_a_new_sentence() {
assert_eq!(
split("Er schwieg. »Wirklich?« fragte sie.", Some("de")),
["Er schwieg.", "»Wirklich?« fragte sie."]
);
assert_eq!(
split("He left. \"Come,\" she said.", Some("en")),
["He left.", "\"Come,\" she said."]
);
}
#[test]
fn scripts_with_their_own_terminators_work_untailored() {
assert_eq!(
split("أين تذهب؟ لا أعرف.", Some("ar")),
["أين تذهب؟", "لا أعرف."]
);
assert_eq!(
split("¿Adónde vas? No sé.", Some("es")),
["¿Adónde vas?", "No sé."]
);
}
#[test]
fn the_greek_question_mark_ends_a_sentence() {
assert_eq!(
split("Πού πηγαίνεις; Δεν ξέρω. Ίσως αύριο.", Some("el")),
["Πού πηγαίνεις;", "Δεν ξέρω.", "Ίσως αύριο."]
);
assert_eq!(
split("Πού πηγαίνεις\u{37e} Δεν ξέρω.", Some("el")),
["Πού πηγαίνεις\u{37e}", "Δεν ξέρω."]
);
assert_eq!(split("Ήρθε· κάθισε.", Some("el")), ["Ήρθε· κάθισε."]);
assert_eq!(
split("He came; she left.", Some("en")),
["He came; she left."]
);
}
#[test]
fn hebrew_abbreviations_need_no_suppression() {
assert_eq!(
split("הוא קרא ספרים וכו׳ ואז עצר.", Some("he")),
["הוא קרא ספרים וכו׳ ואז עצר."]
);
}
#[test]
fn turkish_dotted_capital_i_matches_its_table_entry() {
assert_eq!("İst.".to_lowercase(), "i\u{307}st.");
assert_eq!(
split("İst. Üniversitesi açıldı. Sonra kapandı.", Some("tr")),
["İst. Üniversitesi açıldı.", "Sonra kapandı."]
);
}
#[test]
fn an_unknown_locale_falls_back_to_plain_uax29() {
assert_eq!(
split("She paused. \"Are you sure?\" he asked.", Some("zz-ZZ")),
["She paused.", "\"Are you sure?\" he asked."]
);
assert_eq!(
split("Mr. Smith went home.", Some("zz")),
["Mr.", "Smith went home."]
);
assert_eq!(
split("Mr. Smith went home.", None),
["Mr.", "Smith went home."]
);
}
#[test]
fn the_shared_latin_titles_reach_only_the_languages_that_want_them() {
for locale in [None, Some("zz")] {
assert_eq!(
split("Dr. Smith went home. He left.", locale),
["Dr.", "Smith went home.", "He left."],
"locale {locale:?} must get plain UAX #29"
);
}
assert_eq!(
split("Prof. Cohen arrived. He sat.", Some("he")),
["Prof.", "Cohen arrived.", "He sat."]
);
for tag in ["ru", "el", "ar", "bg", "uk", "mk", "be"] {
assert_eq!(
split("Dr. Smith went home.", Some(tag))[0],
"Dr.",
"{tag} spells its own titles and must not inherit the Latin ones"
);
}
}
#[test]
fn a_dual_script_language_still_gets_the_latin_titles() {
assert_eq!(
split("Dr. Novak je stigao. Onda je otišao.", Some("sr")),
["Dr. Novak je stigao.", "Onda je otišao."]
);
assert_eq!(
split("Др. Новак је стигао. Онда је отишао.", Some("sr"))[0],
"Др. Новак је стигао."
);
}
#[test]
fn the_range_stops_at_the_terminator_not_the_gap_after_it() {
let text = "One. Two.";
assert_eq!(sentence_bounds(text, 1, Some("en")), Some((0, 4)));
assert_eq!(sentence_bounds(text, 4, Some("en")), Some((0, 4)));
assert_eq!(sentence_bounds(text, 5, Some("en")), Some((5, 9)));
}
#[test]
fn a_caret_at_the_very_end_belongs_to_the_last_sentence() {
let text = "One. Two.";
assert_eq!(
sentence_bounds(text, text.chars().count(), Some("en")),
Some((5, 9))
);
assert_eq!(sentence_bounds(text, 9_999, Some("en")), Some((5, 9)));
}
#[test]
fn empty_and_blank_text_have_no_sentence() {
assert_eq!(sentence_bounds("", 0, Some("en")), None);
assert_eq!(sentence_bounds(" ", 1, Some("en")), None);
}
#[test]
fn a_single_sentence_block_is_one_range() {
assert_eq!(
sentence_bounds("Just the one", 5, Some("en")),
Some((0, 12))
);
}
#[test]
fn offsets_are_char_based_not_byte_based() {
let text = "Ééé. Ààà.";
assert_eq!(text.len(), 15, "multi-byte on purpose");
assert_eq!(sentence_bounds(text, 0, Some("fr")), Some((0, 4)));
assert_eq!(sentence_bounds(text, 5, Some("fr")), Some((5, 9)));
}
#[test]
fn the_batch_query_agrees_with_the_caret_query_everywhere() {
let corpus: &[(&str, Option<&str>)] = &[
("Mr. Smith went home. He was tired.", Some("en-US")),
("M. Dupont est parti. Il était tard.", Some("fr-FR")),
("Dr. Ayşe geldi. Sonra gitti.", Some("tr")),
("Vino el Sr. García. Se sentó.", Some("es")),
("Ήρθε ο κ. Παπαδόπουλος. Κάθισε.", Some("el")),
("« Vraiment ? » Elle partit.", Some("fr-FR")),
("Ένα ερώτημα; Και μια απάντηση.", Some("el")),
("\"Come,\" she said. He left.", Some("en")),
("He left. \"Come,\" she said.", Some("en")),
("z.B. gestern war es kalt. Heute nicht.", Some("de")),
("One two three.", Some("en")),
("Ééé. Ààà.", Some("fr")),
("Just the one", Some("en")),
("First! Second? Third.", Some("en")),
("No locale profile at all. Second one.", Some("xx")),
("", Some("en")),
(" ", Some("en")),
];
for (text, locale) in corpus {
let batch: Vec<String> = sentences(text, *locale)
.into_iter()
.map(|s| s.text.to_string())
.collect();
assert_eq!(
batch,
split(text, *locale),
"disagreement on {text:?} ({locale:?})"
);
}
}
#[test]
fn the_slice_and_the_range_describe_the_same_span() {
let text = "Ééé les uns. Ààà les autres. Fin.";
for s in sentences(text, Some("fr")) {
let by_range: String = text
.chars()
.skip(s.char_range.start)
.take(s.char_range.len())
.collect();
assert_eq!(s.text, by_range, "slice and range disagree for {s:?}");
}
}
#[test]
fn a_text_with_no_sentence_yields_none() {
assert!(sentences("", Some("en")).is_empty());
assert!(sentences(" \n\t ", Some("en")).is_empty());
}
#[test]
fn batch_sentences_are_trimmed_and_ordered() {
let out = sentences("One. Two. Three.", Some("en"));
assert_eq!(
out.iter().map(|s| s.text).collect::<Vec<_>>(),
["One.", "Two.", "Three."]
);
for pair in out.windows(2) {
assert!(
pair[0].char_range.end <= pair[1].char_range.start,
"ranges must not overlap: {:?} then {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn documented_limitation_a_literal_ellipsis_does_not_split() {
assert_eq!(
sentences("He hesitated… She waited…", Some("en")).len(),
1,
"known gap: U+2026 is SContinue under UAX #29, so this reads as one sentence"
);
}
#[test]
fn documented_limitation_french_em_dash_dialogue_turns_merge() {
let turns = "— Tu viens ?\n— Non.";
let one_line = "— Tu viens ? — Non, dit-il.";
assert_eq!(
sentences(one_line, Some("fr-FR")).len(),
1,
"known gap: an em-dash turn has no closing mark for the splitter to see"
);
assert!(
sentences(turns, Some("fr-FR")).len() >= 2,
"a newline still separates turns"
);
}
}