use regex::Regex;
use std::sync::LazyLock;
use crate::SentenceBoundary;
use crate::constants::EMAIL_REGEX;
use crate::constants::EXCLAMATION_WORDS;
use crate::constants::GLOBAL_SENTENCE_TERMINATORS;
use crate::constants::PARENS_REGEX;
use crate::constants::QUOTE_CLOSERS_BY_LEN;
use crate::constants::QUOTE_PAIRS;
use crate::constants::QUOTES_REGEX;
use crate::constants::SPACE_AFTER_SEPARATOR;
static DEFAULT_SENTENCE_BREAK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
let pattern = format!(
r"\.(?:[ \t]+\.){{2,}}|[!?…](?:[ \t]+[!?…])+|[{}]+",
GLOBAL_SENTENCE_TERMINATORS.join("")
);
Regex::new(&pattern).unwrap()
});
static CONTINUE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[0-9a-z]").unwrap());
static CONTINUE_AFTER_NONWORD_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\W*[0-9a-z]").unwrap());
pub(crate) static ELLIPSIS_CONTINUE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s+[0-9a-z]").unwrap());
static ELLIPSIS_GLUED_CONTINUE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[0-9a-z]").unwrap());
static PARA_SPLIT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n[\r]*\n").unwrap());
fn is_symmetric_quote_closer(closer: &str) -> bool {
QUOTE_PAIRS
.iter()
.any(|p| p.open == p.close && p.close == closer)
}
fn is_orphan_closer(
paragraph: &str,
boundary: usize,
closer: &str,
skippable_ranges: &[SkippableRange],
) -> bool {
if !is_symmetric_quote_closer(closer) {
return true;
}
let consumed_by_asymmetric_pair = |idx: usize| {
skippable_ranges
.iter()
.any(|r| r.is_quote() && idx >= r.start && idx < r.end)
};
let (mut before, mut at_or_after) = (0usize, 0usize);
for (idx, _) in paragraph.match_indices(closer) {
if consumed_by_asymmetric_pair(idx) {
continue;
}
if idx < boundary {
before += 1;
} else {
at_or_after += 1;
}
}
let unmatched_opener_before = before % 2 == 1;
let lone_stray_at_boundary = before == 0 && at_or_after == 1;
unmatched_opener_before || lone_stray_at_boundary
}
fn is_symmetric_quote_range(text: &str, range: &SkippableRange) -> bool {
if !range.is_quote() {
return false;
}
let span = &text[range.start..range.end];
QUOTE_PAIRS.iter().filter(|p| p.open == p.close).any(|p| {
span.len() >= 2 * p.open.len() && span.starts_with(p.open) && span.ends_with(p.close)
})
}
fn symmetric_token_count_is_odd(paragraph: &str, range: &SkippableRange) -> bool {
let Some(pair) = QUOTE_PAIRS
.iter()
.filter(|p| p.open == p.close)
.find(|p| paragraph[range.start..].starts_with(p.open))
else {
return false;
};
paragraph.matches(pair.open).count() % 2 == 1
}
fn quote_partially_overlaps_parens(quote: &SkippableRange, ranges: &[SkippableRange]) -> bool {
ranges
.iter()
.filter(|r| r.range_type == SkippableRangeType::Parentheses)
.any(|p| {
(quote.start < p.start && p.start < quote.end && quote.end < p.end)
|| (p.start < quote.start && quote.start < p.end && p.end < quote.end)
})
}
fn push_if_increasing(boundaries: &mut Vec<usize>, boundary: usize) {
debug_assert!(!boundaries.is_empty());
if boundary > *boundaries.last().unwrap() {
boundaries.push(boundary);
}
}
fn find_terminator_matches(text: &str, regex: &Regex) -> Vec<(usize, usize)> {
let mut out: Vec<(usize, usize)> = Vec::new();
for m in regex.find_iter(text) {
let (start, end) = (m.start(), m.end());
if let Some(last) = out.last_mut() {
let prev = &text[last.0..last.1];
let candidate = &text[start..end];
let gap = &text[last.1..start];
let is_blank = |c: char| matches!(c, ' ' | '\t');
let prev_is_emphatic = prev.ends_with(['!', '?', '…']);
let candidate_is_dot_run =
candidate.starts_with('.') && candidate.chars().all(|c| c == '.' || is_blank(c));
let separated_by_blanks = !gap.is_empty() && gap.chars().all(is_blank);
if prev_is_emphatic && candidate_is_dot_run && separated_by_blanks {
last.1 = end;
continue;
}
}
out.push((start, end));
}
out
}
pub fn continues_after_boundary(text: &str, months: &[&str]) -> bool {
if CONTINUE_AFTER_NONWORD_REGEX.is_match(text) {
return true;
}
let next_word = text
.split_whitespace()
.next()
.unwrap_or("")
.trim_matches(['.', '!', '?']);
if next_word.is_empty() {
return false;
}
let capitalized: String = next_word
.chars()
.enumerate()
.map(|(i, c)| {
if i == 0 {
c.to_uppercase().to_string()
} else {
c.to_string()
}
})
.collect();
months.contains(&next_word) || months.contains(&capitalized.as_str())
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SkippableRangeType {
Quote,
Parentheses,
Email,
ListItem,
}
#[derive(Debug, Clone, Copy)]
pub struct SkippableRange {
pub start: usize,
pub end: usize,
pub range_type: SkippableRangeType,
}
impl SkippableRange {
pub fn new(start: usize, end: usize, range_type: SkippableRangeType) -> Self {
Self {
start,
end,
range_type,
}
}
pub fn contains(&self, position: usize) -> bool {
position > self.start && position < self.end
}
pub fn is_quote(&self) -> bool {
self.range_type == SkippableRangeType::Quote
}
pub fn is_inner_terminator(&self, text: &str, boundary: usize) -> bool {
if !self.is_quote() || boundary >= self.end {
return false;
}
let head = &text[..self.end];
QUOTE_CLOSERS_BY_LEN
.iter()
.any(|c| head.ends_with(*c) && boundary + c.len() == self.end)
}
}
pub trait Language {
fn get_sentence_break_regex(&self) -> &'static Regex {
&DEFAULT_SENTENCE_BREAK_REGEX
}
fn get_sentence_boundaries<'a>(&self, text: &'a str) -> Vec<SentenceBoundary<'a>> {
let estimated_sentences = (text.len() / 50).max(1);
let mut boundaries = Vec::with_capacity(estimated_sentences);
let paragraphs: Vec<&str> = PARA_SPLIT_REGEX.split(text).collect();
let mut paragraph_offsets = Vec::with_capacity(paragraphs.len());
let mut current_offset = 0;
let mut paragraph_char_offsets = Vec::with_capacity(paragraphs.len());
let mut current_char_offset = 0;
for (i, paragraph) in paragraphs.iter().enumerate() {
paragraph_offsets.push(current_offset);
paragraph_char_offsets.push(current_char_offset);
current_offset += paragraph.len();
current_char_offset += paragraph.chars().count();
if i < paragraphs.len() - 1 {
current_offset += 2; current_char_offset += 2; }
}
let estimated_paragraph_sentences = 10; let mut sentence_boundaries = Vec::with_capacity(estimated_paragraph_sentences);
let sentence_break_regex = self.get_sentence_break_regex();
for (pindex, paragraph) in paragraphs.iter().enumerate() {
if pindex > 0 {
let paragraph_start = paragraph_offsets[pindex];
let paragraph_char_start = paragraph_char_offsets[pindex];
boundaries.push(SentenceBoundary {
start_index: paragraph_char_start - 2,
end_index: paragraph_char_start,
start_byte: paragraph_start - 2,
end_byte: paragraph_start,
text: "\n\n",
boundary_symbol: None,
is_paragraph_break: true,
});
}
let paragraph_start_offset = if pindex == 0 {
0
} else {
paragraph_offsets[pindex]
};
let paragraph_start_char_offset = if pindex == 0 {
0
} else {
paragraph_char_offsets[pindex]
};
sentence_boundaries.clear();
sentence_boundaries.push(0);
let matches = find_terminator_matches(paragraph, sentence_break_regex);
let mut skippable_ranges = self.get_skippable_ranges(paragraph);
let list_starts = super::list_markers::detect_list_items(paragraph);
if !list_starts.is_empty() {
for window in list_starts.windows(2) {
skippable_ranges.push(SkippableRange::new(
window[0],
window[1],
SkippableRangeType::ListItem,
));
}
let last = *list_starts.last().unwrap();
skippable_ranges.push(SkippableRange::new(
last,
paragraph.len(),
SkippableRangeType::ListItem,
));
skippable_ranges.sort_unstable_by_key(|r| r.start);
}
'next_match: for (start, end) in matches {
let Some(mut boundary) = self.find_boundary(paragraph, start, end) else {
continue;
};
for range in &skippable_ranges {
if !range.contains(boundary) {
continue;
}
if is_symmetric_quote_range(paragraph, range)
&& (quote_partially_overlaps_parens(range, &skippable_ranges)
|| (symmetric_token_count_is_odd(paragraph, range)
&& self.has_strong_sentence_break(paragraph, start, end)))
{
continue;
}
if range.is_inner_terminator(paragraph, boundary) {
let next_word = self.get_next_word_approx(paragraph, range.end);
let extend = self.get_boundary_extend(next_word);
if extend >= 0 {
push_if_increasing(
&mut sentence_boundaries,
range.end + extend as usize,
);
}
}
continue 'next_match;
}
boundary = self.extend_past_orphan_closer(paragraph, boundary, &skippable_ranges);
push_if_increasing(&mut sentence_boundaries, boundary);
}
if !list_starts.is_empty() {
for &start in &list_starts {
if start > 0 {
sentence_boundaries.push(start);
}
}
sentence_boundaries.sort_unstable();
sentence_boundaries.dedup();
}
if *sentence_boundaries.last().unwrap() != paragraph.len() {
sentence_boundaries.push(paragraph.len());
}
let mut prev_end_index = paragraph_start_char_offset;
let mut prev_end_byte = 0;
for i in 0..sentence_boundaries.len() - 1 {
let start = sentence_boundaries[i];
let end = sentence_boundaries[i + 1];
if start >= paragraph.len() || end > paragraph.len() || start > end {
continue;
}
let sentence_text = ¶graph[start..end];
let boundary_symbol = if end > 0 && end <= paragraph.len() {
let sentence_slice = ¶graph[..end];
let trimmed_slice = sentence_slice.trim_end();
trimmed_slice
.char_indices()
.next_back()
.and_then(|(idx, _)| {
let char_str = &trimmed_slice[idx..];
if GLOBAL_SENTENCE_TERMINATORS.contains(&char_str) {
Some(char_str.to_string())
} else {
None
}
})
} else {
None
};
let start_byte = paragraph_start_offset + start;
let end_byte = paragraph_start_offset + end;
let start_index = if start == prev_end_byte {
prev_end_index
} else {
let safe_prev = paragraph.floor_char_boundary(prev_end_byte);
let safe_start = paragraph.floor_char_boundary(start);
prev_end_index + paragraph[safe_prev..safe_start].chars().count()
};
let end_index = start_index + sentence_text.chars().count();
boundaries.push(SentenceBoundary {
start_index,
end_index,
start_byte,
end_byte,
text: sentence_text,
boundary_symbol,
is_paragraph_break: false,
});
prev_end_index = end_index;
prev_end_byte = end;
}
}
boundaries
}
fn segment<'a>(&self, text: &'a str) -> Vec<&'a str> {
let estimated_sentences = (text.len() / 50).max(1);
let mut sentences = Vec::with_capacity(estimated_sentences);
let boundaries = self.get_sentence_boundaries(text);
for boundary in boundaries {
if !boundary.text.is_empty() {
sentences.push(boundary.text);
}
}
sentences
}
fn get_abbreviation_char(&self) -> &str {
"."
}
fn get_abbreviations(&self) -> &[String] {
&[]
}
fn get_boundary_extend(&self, word: &str) -> i8 {
if self.continue_in_next_word(word.trim()) || CONTINUE_AFTER_NONWORD_REGEX.is_match(word) {
return -1;
}
let mut count = 0i8;
for ch in word.chars() {
if ch.is_whitespace() || GLOBAL_SENTENCE_TERMINATORS.contains(&ch.to_string().as_str())
{
count += 1;
if count == i8::MAX {
break; }
} else {
break;
}
}
word.ceil_char_boundary(count as usize) as i8
}
fn extend_past_orphan_closer(
&self,
paragraph: &str,
boundary: usize,
skippable_ranges: &[SkippableRange],
) -> usize {
if skippable_ranges.iter().any(|r| r.start == boundary) {
return boundary;
}
let Some(closer) = QUOTE_CLOSERS_BY_LEN.iter().find(|c| {
paragraph[boundary..].starts_with(**c)
&& is_orphan_closer(paragraph, boundary, c, skippable_ranges)
}) else {
return boundary;
};
if is_symmetric_quote_closer(closer) {
let has_earlier_symmetric_pair = skippable_ranges.iter().any(|r| {
r.end <= boundary
&& is_symmetric_quote_range(paragraph, r)
&& paragraph[r.start..].starts_with(*closer)
});
if has_earlier_symmetric_pair {
let after = ¶graph[boundary + closer.len()..];
let mut chars = after.chars();
let first = chars.next();
if first.is_some_and(char::is_whitespace) {
let next_non_ws = chars.find(|c| !c.is_whitespace());
if next_non_ws.is_some_and(|c| c.is_ascii_uppercase()) {
return boundary;
}
}
}
}
let advance_past_space = |pos: usize| {
SPACE_AFTER_SEPARATOR
.find(¶graph[pos..])
.map_or(pos, |m| pos + m.end())
};
let mut boundary = advance_past_space(boundary + closer.len());
let sentence_break_regex = self.get_sentence_break_regex();
while let Some(m) = sentence_break_regex
.find(¶graph[boundary..])
.filter(|m| m.start() == 0)
{
boundary = advance_past_space(boundary + m.end());
}
boundary
}
fn is_abbreviation(&self, head: &str, _tail: &str, separator: &str) -> bool {
if self.get_abbreviation_char() != separator {
return false;
}
let last_word = self.get_last_word(head);
if last_word.is_empty() {
return false;
}
let abbreviations = self.get_abbreviations();
let is_abbrev = abbreviations.contains(&last_word.to_string());
let is_abbrev_lower = abbreviations.contains(&last_word.to_lowercase());
let is_abbrev_upper = abbreviations.contains(&last_word.to_uppercase());
is_abbrev || is_abbrev_lower || is_abbrev_upper
}
fn get_last_word<'a>(&self, text: &'a str) -> &'a str {
text.trim_end()
.split(|c: char| c.is_whitespace() || c == '.' || c == '/')
.next_back()
.expect("str::split always yields at least one element")
}
fn is_exclamation(&self, head: &str, _tail: &str) -> bool {
let last_word = self.get_last_word(head);
let exclamation_word = format!("{}!", last_word);
EXCLAMATION_WORDS.contains(&exclamation_word.as_str())
}
fn has_strong_sentence_break(&self, paragraph: &str, start: usize, end: usize) -> bool {
if ¶graph[start..end] != "." {
return false;
}
let head = ¶graph[..start];
let trimmed = head.trim_end();
let last_word = match trimmed
.char_indices()
.rfind(|(_, c)| c.is_whitespace() || *c == '.')
{
Some((i, c)) => &trimmed[i + c.len_utf8()..],
None => trimmed,
};
if last_word.is_empty() {
return false;
}
if self.is_abbreviation(head, last_word, ".") {
return false;
}
if !is_symmetric_quote_closer(last_word) {
return false;
}
let next_index = paragraph.ceil_char_boundary(start + 1);
let next_word_approx = self.get_next_word_approx(paragraph, next_index);
next_word_approx
.trim_start()
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
}
fn get_next_word_approx<'a>(&self, text: &'a str, start: usize) -> &'a str {
if start >= text.len() {
return "";
}
let max_chars = 30;
let safe_start = text.floor_char_boundary(start);
let end_pos = (start + max_chars).min(text.len());
&text[safe_start..text.ceil_char_boundary(end_pos)]
}
fn find_boundary(&self, text: &str, start: usize, end: usize) -> Option<usize> {
let head = &text[..start];
let matched = &text[start..end];
let next_index = end;
let next_word_approx = self.get_next_word_approx(text, next_index);
if let Some(number_ref_match) =
crate::constants::NUMBERED_REFERENCE_REGEX.find(next_word_approx)
{
return Some(next_index + number_ref_match.end());
}
let is_multi_char_run = matched.chars().nth(1).is_some();
let continues = if is_multi_char_run {
self.is_ellipsis_continuation(next_word_approx)
|| (head.chars().next_back().is_some_and(|c| !c.is_whitespace())
&& ELLIPSIS_GLUED_CONTINUE_REGEX.is_match(next_word_approx))
} else {
self.continue_in_next_word(next_word_approx)
};
if continues {
return None;
}
if matched == "."
&& head.bytes().next_back().is_some_and(|b| b.is_ascii_digit())
&& next_word_approx
.chars()
.next()
.is_some_and(|c| c.is_alphabetic())
&& next_word_approx.bytes().any(|b| b.is_ascii_digit())
{
return None;
}
if self.is_abbreviation(head, next_word_approx, &text[start..end]) {
return None;
}
if self.is_exclamation(head, next_word_approx) {
return None;
}
if let Some(space_after_sep_match) =
crate::constants::SPACE_AFTER_SEPARATOR.find(next_word_approx)
{
return Some(next_index + space_after_sep_match.end());
}
Some(end)
}
fn is_ellipsis_continuation(&self, text_after_run: &str) -> bool {
ELLIPSIS_CONTINUE_REGEX.is_match(text_after_run)
}
fn continue_in_next_word(&self, text_after_boundary: &str) -> bool {
if CONTINUE_REGEX.is_match(text_after_boundary) {
return true;
}
let trimmed = text_after_boundary.trim_start();
trimmed.as_bytes().first() == Some(&b',')
}
fn get_skippable_ranges(&self, text: &str) -> Vec<SkippableRange> {
let estimated_ranges = (text.len() / 200).max(1);
let mut skippable_ranges = Vec::with_capacity(estimated_ranges);
for mat in QUOTES_REGEX.find_iter(text) {
skippable_ranges.push(SkippableRange::new(
mat.start(),
mat.end(),
SkippableRangeType::Quote,
));
}
for mat in PARENS_REGEX.find_iter(text) {
skippable_ranges.push(SkippableRange::new(
mat.start(),
mat.end(),
SkippableRangeType::Parentheses,
));
}
for mat in EMAIL_REGEX.find_iter(text) {
skippable_ranges.push(SkippableRange::new(
mat.start(),
mat.end(),
SkippableRangeType::Email,
));
}
skippable_ranges.sort_unstable_by_key(|r| r.start);
skippable_ranges
}
}