use regex::Regex;
use rustc_hash::FxHashSet;
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::QuotePair;
use crate::constants::is_sentence_terminator;
use super::quotes::{
OrphanCloserPositions, QuoteMispairing, collect_quote_ranges, extend_past_orphan_closer,
inner_terminator_boundary, is_symmetric_quote_closer, is_symmetric_quote_mispairing,
peel_leading_symmetric_quote, tag_quote_mispairing,
};
use super::trailing_markers::{MarkerTable, classify_trailing_marker, marker_bypasses_suppression};
static DEFAULT_SENTENCE_BREAK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
let pattern = format!(
r"\.(?:[ \t]+\.){{2,}}|[!?…](?:[ \t]+[!?…])+|[{}]+",
GLOBAL_SENTENCE_TERMINATORS.iter().collect::<String>()
);
Regex::new(&pattern).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());
fn starts_with_ascii_lowercase_or_digit(s: &str) -> bool {
s.as_bytes()
.first()
.is_some_and(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9'))
}
fn paragraph_break_at(bytes: &[u8], at: usize) -> Option<(usize, usize)> {
if bytes.get(at) != Some(&b'\n') {
return None;
}
let mut end = at + 1;
while bytes.get(end) == Some(&b'\r') {
end += 1;
}
(bytes.get(end) == Some(&b'\n')).then_some((at, end + 1))
}
pub(crate) fn paragraph_breaks(text: &str) -> impl Iterator<Item = (usize, usize)> + '_ {
let bytes = text.as_bytes();
let mut cursor = 0;
std::iter::from_fn(move || {
loop {
let newline = cursor + memchr::memchr(b'\n', &bytes[cursor..])?;
if let Some((start, end)) = paragraph_break_at(bytes, newline) {
cursor = end;
return Some((start, end));
}
cursor = newline + 1;
}
})
}
fn is_code_like_numbered_token(head: &str, next_word_approx: &str) -> bool {
head.bytes().next_back().is_some_and(|b| b.is_ascii_digit())
&& next_word_approx.starts_with(|c: char| c.is_alphabetic())
&& next_word_approx.bytes().any(|b| b.is_ascii_digit())
}
fn is_single_ascii_upper(s: &str) -> bool {
s.len() == 1 && s.as_bytes()[0].is_ascii_uppercase()
}
pub(crate) fn abbreviation_set_contains(set: &FxHashSet<String>, word: &str) -> bool {
if word.bytes().all(|b| b < 128 && !b.is_ascii_uppercase()) {
return set.contains(word);
}
if word.is_ascii() && word.len() <= 32 {
let mut buf = [0u8; 32];
for (i, b) in word.bytes().enumerate() {
buf[i] = b.to_ascii_lowercase();
}
let lower = unsafe { std::str::from_utf8_unchecked(&buf[..word.len()]) };
return set.contains(lower);
}
set.contains(word.to_lowercase().as_str())
}
fn starts_with_initial(s: &str) -> bool {
let mut chars = s.trim_start().chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_uppercase()
&& chars.next() == Some('.')
&& chars.next().is_none_or(char::is_whitespace)
}
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, out: &mut Vec<(usize, usize)>) {
out.clear();
if std::ptr::eq(regex, &*DEFAULT_SENTENCE_BREAK_REGEX) && text.is_ascii() {
scan_ascii_matches(text, out);
return;
}
for m in regex.find_iter(text) {
fold_match(out, text, m.start(), m.end());
}
}
fn fold_match(out: &mut Vec<(usize, usize)>, text: &str, start: usize, end: usize) {
if let Some(last) = out.last_mut()
&& should_fold(text, *last, start, end)
{
last.1 = end;
return;
}
out.push((start, end));
}
fn should_fold(
text: &str,
(prev_start, prev_end): (usize, usize),
start: usize,
end: usize,
) -> bool {
let prev = &text[prev_start..prev_end];
let candidate = &text[start..end];
let gap = &text[prev_end..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);
prev_is_emphatic && candidate_is_dot_run && separated_by_blanks
}
fn scan_ascii_matches(text: &str, out: &mut Vec<(usize, usize)>) {
let bytes = text.as_bytes();
let mut cursor = 0;
while let Some(rel) = memchr::memchr3(b'.', b'!', b'?', &bytes[cursor..]) {
let p = cursor + rel;
let spaced = if bytes[p] == b'.' {
spaced_run(bytes, p, |b| b == b'.', 3)
} else {
spaced_run(bytes, p, |b| b == b'!' || b == b'?', 2)
};
let end = spaced.unwrap_or_else(|| contiguous_run(bytes, p));
fold_match(out, text, p, end);
cursor = end;
}
}
fn spaced_run(
bytes: &[u8],
p: usize,
is_member: impl Fn(u8) -> bool,
min_total: usize,
) -> Option<usize> {
let mut count = 1;
let mut end = p + 1;
loop {
let mut k = end;
while matches!(bytes.get(k), Some(b' ' | b'\t')) {
k += 1;
}
if k > end && bytes.get(k).is_some_and(|&b| is_member(b)) {
count += 1;
end = k + 1;
} else {
break;
}
}
(count >= min_total).then_some(end)
}
fn contiguous_run(bytes: &[u8], p: usize) -> usize {
let mut end = p + 1;
while matches!(bytes.get(end), Some(b'.' | b'!' | b'?')) {
end += 1;
}
end
}
#[derive(Default)]
struct ParagraphScratch {
sentence_boundaries: Vec<usize>,
matches: Vec<(usize, usize)>,
skippable_ranges: Vec<SkippableRange>,
orphan_closers: OrphanCloserPositions,
}
impl ParagraphScratch {
fn with_capacity(capacity: usize) -> Self {
Self {
sentence_boundaries: Vec::with_capacity(capacity),
matches: Vec::with_capacity(capacity),
skippable_ranges: Vec::with_capacity(capacity),
orphan_closers: OrphanCloserPositions::default(),
}
}
}
fn boundary_symbol(paragraph: &str, end: usize) -> Option<&str> {
let trimmed = paragraph[..end].trim_end();
trimmed
.char_indices()
.next_back()
.and_then(|(idx, ch)| is_sentence_terminator(ch).then(|| &trimmed[idx..]))
}
fn push_separator_boundary<'a>(
boundaries: &mut Vec<SentenceBoundary<'a>>,
separator: &'a str,
start_byte: usize,
end_byte: usize,
char_offset: &mut usize,
) {
let separator_chars = separator.chars().count();
boundaries.push(SentenceBoundary {
start_index: *char_offset,
end_index: *char_offset + separator_chars,
start_byte,
end_byte,
text: separator,
boundary_symbol: None,
is_paragraph_break: true,
});
*char_offset += separator_chars;
}
fn push_paragraph_sentences<'a>(
paragraph: &'a str,
para_start: usize,
sentence_boundaries: &[usize],
char_offset: &mut usize,
boundaries: &mut Vec<SentenceBoundary<'a>>,
) {
debug_assert_eq!(sentence_boundaries.first().copied(), Some(0));
debug_assert_eq!(sentence_boundaries.last().copied(), Some(paragraph.len()));
for window in sentence_boundaries.windows(2) {
let seg_start = window[0];
let seg_end = window[1];
let sentence_text = ¶graph[seg_start..seg_end];
let end_offset = *char_offset + sentence_text.chars().count();
boundaries.push(SentenceBoundary {
start_index: *char_offset,
end_index: end_offset,
start_byte: para_start + seg_start,
end_byte: para_start + seg_end,
text: sentence_text,
boundary_symbol: boundary_symbol(paragraph, seg_end),
is_paragraph_break: false,
});
*char_offset = end_offset;
}
}
#[derive(Clone, Copy)]
pub(crate) struct NonListRegion {
pub(crate) len: usize,
pub(crate) binary_search: bool,
}
const BINARY_SEARCH_MIN_RANGES: usize = 64;
fn collect_sentence_breaks<L: Language + ?Sized>(
lang: &L,
paragraph: &str,
sentence_break_regex: &Regex,
scratch: &mut ParagraphScratch,
) {
let ParagraphScratch {
sentence_boundaries,
matches,
skippable_ranges,
orphan_closers,
} = scratch;
orphan_closers.reset();
sentence_boundaries.clear();
sentence_boundaries.push(0);
find_terminator_matches(paragraph, sentence_break_regex, matches);
lang.get_skippable_ranges(paragraph, skippable_ranges);
let non_list_len = skippable_ranges.len();
let non_list_region = NonListRegion {
len: non_list_len,
binary_search: non_list_len > BINARY_SEARCH_MIN_RANGES && !ranges_overlap(skippable_ranges),
};
let list_starts = super::list_markers::detect_list_items(paragraph);
add_list_item_ranges(skippable_ranges, &list_starts, paragraph.len());
for &(match_start, match_end) in matches.iter() {
let Some(boundary) = lang.find_boundary(paragraph, match_start, match_end) else {
continue;
};
let break_at = match containing_range(
lang,
paragraph,
boundary,
match_start,
match_end,
skippable_ranges,
non_list_region,
) {
Some(range) => inner_terminator_boundary(lang, paragraph, range, boundary),
None => Some(extend_past_orphan_closer(
lang,
paragraph,
boundary,
skippable_ranges,
non_list_region,
orphan_closers,
)),
};
if let Some(break_at) = break_at {
push_if_increasing(sentence_boundaries, break_at);
}
}
merge_list_item_boundaries(sentence_boundaries, &list_starts);
if *sentence_boundaries.last().unwrap() != paragraph.len() {
sentence_boundaries.push(paragraph.len());
}
}
fn ranges_overlap(start_sorted_ranges: &[SkippableRange]) -> bool {
let mut max_end = 0;
for r in start_sorted_ranges {
if r.start < max_end {
return true;
}
max_end = max_end.max(r.end);
}
false
}
fn select_containing_binary(
ranges: &[SkippableRange],
non_list_len: usize,
boundary: usize,
is_break: impl Fn(&SkippableRange) -> bool,
) -> Option<&SkippableRange> {
let (non_list, list) = ranges.split_at(non_list_len);
non_list
.partition_point(|r| r.start < boundary)
.checked_sub(1)
.map(|i| &non_list[i])
.filter(|&r| is_break(r))
.or_else(|| list.iter().find(|&r| is_break(r)))
}
fn containing_range<'r, L: Language + ?Sized>(
lang: &L,
paragraph: &str,
boundary: usize,
match_start: usize,
match_end: usize,
ranges: &'r [SkippableRange],
region: NonListRegion,
) -> Option<&'r SkippableRange> {
let is_break = |range: &SkippableRange| {
range.contains(boundary)
&& !is_symmetric_quote_mispairing(lang, paragraph, range, match_start, match_end)
};
if region.binary_search {
select_containing_binary(ranges, region.len, boundary, is_break)
} else {
ranges.iter().find(|&r| is_break(r))
}
}
fn add_list_item_ranges(
skippable_ranges: &mut Vec<SkippableRange>,
list_starts: &[usize],
paragraph_len: usize,
) {
for pair in list_starts.windows(2) {
skippable_ranges.push(SkippableRange::new(
pair[0],
pair[1],
SkippableRangeType::ListItem,
));
}
if let Some(&last) = list_starts.last() {
skippable_ranges.push(SkippableRange::new(
last,
paragraph_len,
SkippableRangeType::ListItem,
));
}
}
fn merge_list_item_boundaries(sentence_boundaries: &mut Vec<usize>, list_starts: &[usize]) {
if list_starts.is_empty() {
return;
}
for &start in list_starts {
if start > 0 {
sentence_boundaries.push(start);
}
}
sentence_boundaries.sort_unstable();
sentence_boundaries.dedup();
}
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,
pub quote_pair: Option<&'static QuotePair>,
pub quote_mispairing: QuoteMispairing,
}
impl SkippableRange {
pub fn new(start: usize, end: usize, range_type: SkippableRangeType) -> Self {
Self {
start,
end,
range_type,
quote_pair: None,
quote_mispairing: QuoteMispairing::None,
}
}
pub fn new_quote(start: usize, end: usize, pair: &'static QuotePair) -> Self {
Self {
start,
end,
range_type: SkippableRangeType::Quote,
quote_pair: Some(pair),
quote_mispairing: QuoteMispairing::None,
}
}
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 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 text_len = text.len();
let capacity = (text_len / 50).max(1);
let mut boundaries = Vec::with_capacity(capacity);
let mut scratch = ParagraphScratch::with_capacity(capacity);
let regex = self.get_sentence_break_regex();
let (mut para_start, mut char_offset) = (0usize, 0usize);
let trailing_separators = paragraph_breaks(text)
.map(Some)
.chain(std::iter::once(None));
for trailing_separator in trailing_separators {
let para_end = trailing_separator.map_or(text_len, |(sep_start, _)| sep_start);
let paragraph = &text[para_start..para_end];
collect_sentence_breaks(self, paragraph, regex, &mut scratch);
push_paragraph_sentences(
paragraph,
para_start,
&scratch.sentence_boundaries,
&mut char_offset,
&mut boundaries,
);
if let Some((sep_start, sep_end)) = trailing_separator {
push_separator_boundary(
&mut boundaries,
&text[sep_start..sep_end],
sep_start,
sep_end,
&mut char_offset,
);
para_start = sep_end;
}
}
boundaries
}
fn segment<'a>(&self, text: &'a str) -> Vec<&'a str> {
let text_len = text.len();
let capacity = (text_len / 50).max(1);
let mut sentences = Vec::with_capacity(capacity);
let mut scratch = ParagraphScratch::with_capacity(capacity);
let regex = self.get_sentence_break_regex();
let mut para_start = 0usize;
let trailing_separators = paragraph_breaks(text)
.map(Some)
.chain(std::iter::once(None));
for trailing_separator in trailing_separators {
let para_end = trailing_separator.map_or(text_len, |(sep_start, _)| sep_start);
let paragraph = &text[para_start..para_end];
collect_sentence_breaks(self, paragraph, regex, &mut scratch);
for window in scratch.sentence_boundaries.windows(2) {
let sentence = ¶graph[window[0]..window[1]];
if !sentence.is_empty() {
sentences.push(sentence);
}
}
if let Some((sep_start, sep_end)) = trailing_separator {
let separator = &text[sep_start..sep_end];
if !separator.is_empty() {
sentences.push(separator);
}
para_start = sep_end;
}
}
sentences
}
fn get_abbreviation_char(&self) -> &str {
"."
}
fn get_abbreviations(&self) -> &FxHashSet<String> {
static EMPTY_ABBREVS: LazyLock<FxHashSet<String>> = LazyLock::new(FxHashSet::default);
&EMPTY_ABBREVS
}
fn get_sentence_starters(&self) -> &FxHashSet<String> {
static EMPTY_STARTERS: LazyLock<FxHashSet<String>> = LazyLock::new(FxHashSet::default);
&EMPTY_STARTERS
}
fn get_fronting_words(&self) -> &FxHashSet<String> {
static EMPTY_FRONTING: LazyLock<FxHashSet<String>> = LazyLock::new(FxHashSet::default);
&EMPTY_FRONTING
}
#[inline]
fn get_trailing_markers(&self) -> &'static MarkerTable {
MarkerTable::empty()
}
fn get_boundary_extend(&self, word: &str) -> Option<usize> {
if self.continue_in_next_word(word.trim()) || CONTINUE_AFTER_NONWORD_REGEX.is_match(word) {
return None;
}
let mut count = 0;
for ch in word.chars() {
if ch.is_whitespace() || is_sentence_terminator(ch) {
count += ch.len_utf8();
} else {
break;
}
}
Some(count)
}
fn is_abbreviation(&self, head: &str, _tail: &str, separator: &str) -> bool {
let last_word = self.get_last_word(head);
self.is_abbreviation_for(last_word, separator)
}
fn is_abbreviation_for(&self, last_word: &str, separator: &str) -> bool {
if self.get_abbreviation_char() != separator || last_word.is_empty() {
return false;
}
abbreviation_set_contains(self.get_abbreviations(), last_word)
}
fn is_name_initial(&self, head: &str, next_word_approx: &str) -> bool {
let last_word = self.get_last_word(head);
self.is_name_initial_for(head, last_word, next_word_approx)
}
fn is_name_initial_for(&self, head: &str, last_word: &str, next_word_approx: &str) -> bool {
if !is_single_ascii_upper(last_word) {
return false;
}
let prefix = head[..head.len() - last_word.len()]
.trim_end_matches(|c: char| c.is_whitespace() || c == '.' || c == '/');
if self
.get_last_word(prefix)
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
{
return true;
}
starts_with_initial(next_word_approx)
}
fn next_word_is_sentence_starter(&self, next_word_approx: &str) -> bool {
let starters = self.get_sentence_starters();
if starters.is_empty() {
return false;
}
let trimmed = next_word_approx.trim_start();
let word_end = trimmed
.find(|c: char| c.is_whitespace() || c == ',' || is_sentence_terminator(c))
.unwrap_or(trimmed.len());
if word_end == 0 {
return false;
}
let starter_candidate = &trimmed[..word_end];
starters.contains(starter_candidate)
}
fn should_override_abbrev_suppression_for(
&self,
head: &str,
last_word: &str,
next_is_starter: bool,
) -> bool {
if !next_is_starter {
return false;
}
let tail_starts_uppercase = last_word
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase());
if tail_starts_uppercase {
return true;
}
if self.is_multi_dot_abbreviation(head, last_word.len()) {
return true;
}
last_word.chars().nth(1).is_some() && self.get_abbreviations().contains(last_word)
}
fn is_multi_dot_abbreviation(&self, head: &str, tail_len: usize) -> bool {
let last_word_full = self.get_last_word_full(head);
if last_word_full.len() <= tail_len {
return false;
}
abbreviation_set_contains(self.get_abbreviations(), last_word_full)
}
fn get_last_word_full<'a>(&self, text: &'a str) -> &'a str {
text.trim_end()
.rsplit(|c: char| c.is_whitespace() || c == '/')
.next()
.expect("str::rsplit always yields at least one element")
}
fn get_last_word<'a>(&self, text: &'a str) -> &'a str {
text.trim_end()
.rsplit(|c: char| c.is_whitespace() || c == '.' || c == '/')
.next()
.expect("str::rsplit always yields at least one element")
}
fn is_exclamation(&self, head: &str, _tail: &str) -> bool {
let last_word = self.get_last_word(head);
self.is_exclamation_for(last_word)
}
fn is_exclamation_for(&self, last_word: &str) -> bool {
if last_word.is_empty() {
return false;
}
EXCLAMATION_WORDS
.iter()
.any(|w| w.strip_suffix('!').is_some_and(|p| p == last_word))
}
fn has_strong_sentence_break(&self, paragraph: &str, start: usize, end: usize) -> bool {
if end - start != 1 || paragraph.as_bytes()[start] != b'.' {
return false;
}
debug_assert!(paragraph.is_char_boundary(start + 1));
let next_word_approx = self.get_next_word_approx(paragraph, start + 1);
let trimmed_next = next_word_approx.trim_start();
if !trimmed_next
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
{
return false;
}
let head = ¶graph[..start];
let last_word = self.get_last_word(head);
if last_word.is_empty() || is_single_ascii_upper(last_word) {
return false;
}
if self.is_abbreviation(head, last_word, ".")
|| self.is_multi_dot_abbreviation(head, last_word.len())
{
return false;
}
if is_symmetric_quote_closer(last_word) {
return true;
}
self.next_word_is_sentence_starter(trimmed_next)
}
fn get_next_word_approx<'a>(&self, text: &'a str, start: usize) -> &'a str {
if start >= text.len() {
return "";
}
debug_assert!(text.is_char_boundary(start));
let max_bytes = 30;
let end_pos = (start + max_bytes).min(text.len());
&text[start..text.ceil_char_boundary(end_pos)]
}
fn terminator_continues(&self, matched: &str, head: &str, next_word_approx: &str) -> bool {
if matched.chars().nth(1).is_some() {
return self.is_ellipsis_continuation(next_word_approx)
|| (head.chars().next_back().is_some_and(|c| !c.is_whitespace())
&& starts_with_ascii_lowercase_or_digit(next_word_approx));
}
self.continue_in_next_word(next_word_approx)
|| (matches!(matched, "!" | "?")
&& matches!(head.as_bytes().last(), Some(b' ' | b'\t'))
&& CONTINUE_AFTER_NONWORD_REGEX.is_match(next_word_approx))
}
fn period_suppresses_boundary(
&self,
head: &str,
last_word: &str,
next_word_approx: &str,
) -> bool {
let suppress = self.is_name_initial_for(head, last_word, next_word_approx)
|| self.is_abbreviation_for(last_word, ".");
let marker = classify_trailing_marker(head, self.get_trailing_markers());
if !suppress && marker.is_none() {
return false;
}
let next_is_starter = self.next_word_is_sentence_starter(next_word_approx);
let marker_bypass = marker.as_ref().is_some_and(|m| {
marker_bypasses_suppression(m, next_word_approx, next_is_starter, self)
});
!marker_bypass
&& !self.should_override_abbrev_suppression_for(head, last_word, next_is_starter)
}
fn find_boundary(&self, text: &str, start: usize, end: usize) -> Option<usize> {
let head = &text[..start];
let matched = &text[start..end];
let next_word_approx = self.get_next_word_approx(text, end);
if memchr::memchr(b'[', next_word_approx.as_bytes()).is_some()
&& let Some(m) = crate::constants::NUMBERED_REFERENCE_REGEX.find(next_word_approx)
{
return Some(end + m.end());
}
if self.terminator_continues(matched, head, next_word_approx) {
return None;
}
let last_word = self.get_last_word(head);
if matched == "." {
if is_code_like_numbered_token(head, next_word_approx) {
return None;
}
if self.period_suppresses_boundary(head, last_word, next_word_approx) {
return None;
}
}
if self.is_exclamation_for(last_word) {
return None;
}
let trailing_ws = next_word_approx.len() - next_word_approx.trim_start().len();
Some(end + trailing_ws)
}
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 starts_with_ascii_lowercase_or_digit(text_after_boundary) {
return true;
}
peel_leading_symmetric_quote(text_after_boundary).starts_with(',')
}
fn get_skippable_ranges(&self, text: &str, out: &mut Vec<SkippableRange>) {
out.clear();
collect_quote_ranges(text, out);
for mat in PARENS_REGEX.find_iter(text) {
out.push(SkippableRange::new(
mat.start(),
mat.end(),
SkippableRangeType::Parentheses,
));
}
for mat in EMAIL_REGEX.find_iter(text) {
out.push(SkippableRange::new(
mat.start(),
mat.end(),
SkippableRangeType::Email,
));
}
out.sort_unstable_by_key(|r| r.start);
tag_quote_mispairing(text, out);
}
}
#[cfg(test)]
mod tests {
use super::Language;
use crate::languages::Japanese;
#[test]
fn get_boundary_extend_sums_run_in_bytes() {
let lang = Japanese {};
assert_eq!(lang.get_boundary_extend(". X"), Some(2));
assert_eq!(lang.get_boundary_extend("。次"), Some(3));
assert_eq!(lang.get_boundary_extend("。。次"), Some(6));
assert_eq!(lang.get_boundary_extend(" X"), Some(6));
assert_eq!(lang.get_boundary_extend(""), Some(0));
assert_eq!(lang.get_boundary_extend(" foo"), None);
}
}