use regex::Regex;
use std::sync::LazyLock;
static QUOTED_PUNCT_END_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r##"[.!?]["')\]]+\s*$"##).expect("valid quoted-punct regex"));
use crate::abbreviations;
use crate::sentence::SentenceSplitter;
static INLINE_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r"\[\[[^\]]*\]\]", r"\[\[[^\]]*\]\[[^\]]*\]\]", r"\[[^\]]+\]\([^)]+\)", r"!\[[^\]]*\]\([^)]+\)", r"\$[^$]+\$", r"\\([a-zA-Z]+)\{[^}]*\}", r"\*[^*\s\n](?:[^*\n]*[^*\s\n])?\*", r"/[^/\s\n](?:[^/\n]*[^/\s\n])?/", r"_[^_\s\n](?:[^_\n]*[^_\s\n])?_", r"\+[^\+\s\n](?:[^\+\n]*[^\+\s\n])?\+", r"~[^~\n]+~", r"=[^=\n]+=", r"`[^`\n]+`", r#"https?://\S+[^.\s!?,;:)\]'""]"#, r"file:\S+", r"@@[a-zA-Z]+:[^@]*@@", ]
.join("|"),
)
.expect("valid inline token regex")
});
pub struct UnicodeSentenceSplitter {
extra_pattern: Option<Regex>,
lang_abbrev_pattern: Regex,
lang_multi_pattern: Regex,
}
impl UnicodeSentenceSplitter {
pub fn new() -> Self {
Self::for_lang("en", &[])
}
pub fn with_extra_abbreviations(extras: &[String]) -> Self {
Self::for_lang("en", extras)
}
pub fn for_lang(lang: &str, extras: &[String]) -> Self {
let abbrevs = abbreviations::abbreviations_for_lang(lang);
let multi = abbreviations::multi_abbrevs_for_lang(lang);
let alts: Vec<&str> = abbrevs.to_vec();
let pattern = format!(r#"(?:^|[\s"'`(\[])(?:{})$"#, alts.join("|"));
let lang_abbrev_pattern = Regex::new(&pattern).expect("valid abbreviation regex");
let multi_alts: Vec<String> = multi.iter().map(|a| regex::escape(a)).collect();
let multi_pattern = format!(r"(?:^|\s)(?:{})$", multi_alts.join("|"));
let lang_multi_pattern =
Regex::new(&multi_pattern).expect("valid multi-abbreviation regex");
let extra_pattern = if extras.is_empty() {
None
} else {
let alts: Vec<String> = extras.iter().map(|a| regex::escape(a)).collect();
let pattern = format!(r"(?:^|\s)(?:{})$", alts.join("|"));
Some(Regex::new(&pattern).expect("valid extra abbreviation regex"))
};
Self {
extra_pattern,
lang_abbrev_pattern,
lang_multi_pattern,
}
}
}
impl Default for UnicodeSentenceSplitter {
fn default() -> Self {
Self::new()
}
}
impl SentenceSplitter for UnicodeSentenceSplitter {
fn split(&self, text: &str) -> Vec<String> {
let text = text.trim();
if text.is_empty() {
return vec![];
}
let mut placeholders: Vec<String> = Vec::new();
let protected = INLINE_TOKEN_RE.replace_all(text, |caps: ®ex::Captures| {
let idx = placeholders.len();
placeholders.push(caps[0].to_string());
format!("\x00PH{idx}\x00")
});
let raw_segments: Vec<&str> = merge_tail_punctuation(&protected);
if raw_segments.is_empty() {
return vec![text.to_string()];
}
let merged = merge_abbreviation_splits(
&raw_segments,
&self.lang_abbrev_pattern,
&self.lang_multi_pattern,
self.extra_pattern.as_ref(),
);
let merged = merge_quoted_punct_splits(merged);
merged
.into_iter()
.map(|s| {
let mut restored = s.trim().to_string();
for (i, original) in placeholders.iter().enumerate() {
let ph = format!("\x00PH{i}\x00");
restored = restored.replace(&ph, original);
}
restored
})
.filter(|s| !s.is_empty())
.collect()
}
}
fn merge_tail_punctuation(text: &str) -> Vec<&str> {
use unicode_segmentation::UnicodeSegmentation;
fn has_content(s: &str) -> bool {
s.chars().any(|c| c.is_alphanumeric())
}
let bounds: Vec<&str> = text.split_sentence_bounds().collect();
if bounds.is_empty() {
return Vec::new();
}
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(bounds.len());
let mut cursor: usize = 0;
for seg in &bounds {
let start = cursor;
let end = cursor + seg.len();
if has_content(seg) {
merged.push((start, end));
} else if let Some(last) = merged.last_mut() {
last.1 = end;
} else {
merged.push((start, end));
}
cursor = end;
}
merged
.into_iter()
.map(|(s, e)| &text[s..e])
.collect()
}
fn merge_abbreviation_splits(
segments: &[&str],
abbrev_re: &Regex,
multi_re: &Regex,
extra: Option<&Regex>,
) -> Vec<String> {
let mut result: Vec<String> = Vec::with_capacity(segments.len());
for &segment in segments {
let should_merge = if let Some(prev) = result.last() {
is_abbreviation_ending(prev, abbrev_re, multi_re, extra)
} else {
false
};
if should_merge {
let prev = result.last_mut().unwrap();
prev.push_str(segment);
} else {
result.push(segment.to_string());
}
}
result
}
fn merge_quoted_punct_splits(segments: Vec<String>) -> Vec<String> {
let mut result: Vec<String> = Vec::with_capacity(segments.len());
for segment in segments {
let should_merge = if let Some(prev) = result.last() {
QUOTED_PUNCT_END_RE.is_match(prev.trim_end())
&& segment
.trim_start()
.chars()
.next()
.is_some_and(|c| c.is_lowercase())
} else {
false
};
if should_merge {
let prev = result.last_mut().unwrap();
prev.push_str(&segment);
} else {
result.push(segment);
}
}
result
}
fn is_abbreviation_ending(
s: &str,
abbrev_re: &Regex,
multi_re: &Regex,
extra: Option<&Regex>,
) -> bool {
let trimmed = s.trim_end();
if !trimmed.ends_with('.') {
return false;
}
let before_dot = &trimmed[..trimmed.len() - 1];
if abbrev_re.is_match(before_dot) {
return true;
}
if multi_re.is_match(before_dot) {
return true;
}
if let Some(re) = extra {
if re.is_match(before_dot) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn split(text: &str) -> Vec<String> {
UnicodeSentenceSplitter::new().split(text)
}
#[test]
fn simple_sentences() {
assert_eq!(
split("Hello world. This is a test. Another sentence here."),
vec!["Hello world.", "This is a test.", "Another sentence here."]
);
}
#[test]
fn abbreviation_dr() {
assert_eq!(
split("Dr. Smith went home. He was tired."),
vec!["Dr. Smith went home.", "He was tired."]
);
}
#[test]
fn abbreviation_eg() {
assert_eq!(
split("Use a formatter, e.g. snapper. It works well."),
vec!["Use a formatter, e.g. snapper.", "It works well."]
);
}
#[test]
fn abbreviation_fig() {
assert_eq!(
split("See Fig. 3 for details. The results are clear."),
vec!["See Fig. 3 for details.", "The results are clear."]
);
}
#[test]
fn empty_input() {
assert_eq!(split(""), Vec::<String>::new());
}
#[test]
fn single_sentence() {
assert_eq!(split("Just one sentence."), vec!["Just one sentence."]);
}
#[test]
fn question_and_exclamation() {
assert_eq!(
split("Is this working? Yes! It is."),
vec!["Is this working?", "Yes!", "It is."]
);
}
#[test]
fn no_trailing_period() {
assert_eq!(
split("First sentence. Second without period"),
vec!["First sentence.", "Second without period"]
);
}
#[test]
fn extra_abbreviations() {
let splitter = UnicodeSentenceSplitter::with_extra_abbreviations(&[
"Abstr".to_string(),
"Suppl".to_string(),
]);
assert_eq!(
splitter.split("See Abstr. 5 for details. The results follow."),
vec!["See Abstr. 5 for details.", "The results follow."]
);
let default = UnicodeSentenceSplitter::new();
let result = default.split("See Abstr. 5 for details. The results follow.");
assert!(result.len() > 1);
}
#[test]
fn inline_org_link_preserved() {
assert_eq!(
split("See [[https://example.com][Ex. Site]] for details. Then continue."),
vec![
"See [[https://example.com][Ex. Site]] for details.",
"Then continue."
]
);
}
#[test]
fn inline_math_preserved() {
assert_eq!(
split("The value $x = 3.14$ matters. Next sentence."),
vec!["The value $x = 3.14$ matters.", "Next sentence."]
);
}
#[test]
fn inline_markdown_link_preserved() {
assert_eq!(
split("Visit [Example Inc.](https://example.com) now. Then read more."),
vec now.",
"Then read more."
]
);
}
#[test]
fn inline_code_preserved() {
assert_eq!(
split("Use `std.io.Read` for input. Then process."),
vec!["Use `std.io.Read` for input.", "Then process."]
);
}
#[test]
fn org_bold_with_internal_period_not_split() {
assert_eq!(
split("End of first. *Bold spans period. Continues* after."),
vec!["End of first.", "*Bold spans period. Continues* after."]
);
}
#[test]
fn org_italic_with_internal_period_not_split() {
assert_eq!(
split("Lead-in. /Italic has a period. Still italic/ trail."),
vec!["Lead-in.", "/Italic has a period. Still italic/ trail."]
);
}
#[test]
fn angle_bracket_tail_after_period_preserved() {
assert_eq!(
split("snapshot field is Box[T], not Vec[T]"),
vec!["snapshot field is Box[T], not Vec[T]"]
);
assert_eq!(split("see <a.>"), vec!["see <a.>"]);
}
#[test]
fn quoted_exclamation_no_false_split() {
assert_eq!(
split(r#"He said "wow!" and left. She agreed."#),
vec![r#"He said "wow!" and left."#, "She agreed."]
);
}
#[test]
fn paren_exclamation_no_false_split() {
assert_eq!(
split("He replied (with emphasis!) loudly. She agreed."),
vec!["He replied (with emphasis!) loudly.", "She agreed."]
);
}
#[test]
fn paren_question_no_false_split() {
assert_eq!(
split("The answer (really?) surprised them. Next sentence."),
vec!["The answer (really?) surprised them.", "Next sentence."]
);
}
#[test]
fn url_trailing_period_not_swallowed() {
assert_eq!(
split("Visit https://example.com/path. Then read more."),
vec!["Visit https://example.com/path.", "Then read more."]
);
}
#[test]
fn url_with_query_trailing_period() {
assert_eq!(
split("See https://example.com/path?q=1&r=2. Next sentence."),
vec!["See https://example.com/path?q=1&r=2.", "Next sentence."]
);
}
#[test]
fn ellipsis_splits() {
assert_eq!(
split("Sentence one... Sentence two."),
vec!["Sentence one...", "Sentence two."]
);
}
#[test]
fn quoted_period_end_of_sentence() {
assert_eq!(
split(r#"End of quote: "done." Start again."#),
vec![r#"End of quote: "done.""#, "Start again."]
);
}
}