use std::sync::LazyLock;
use crate::constants::QUOTE_CLOSERS_BY_LEN;
use crate::constants::QUOTE_PAIRS;
use crate::constants::QUOTES_REGEX;
use crate::constants::QuotePair;
use crate::constants::SPACE_AFTER_SEPARATOR;
use super::language::{Language, NonListRegion, SkippableRange, SkippableRangeType};
fn has_possible_inline_sentence_break(span: &str) -> bool {
let bytes = span.as_bytes();
for dot_pos in memchr::memchr_iter(b'.', bytes) {
let tail = &bytes[dot_pos + 1..];
let Some(blanks) = tail.iter().position(|&b| !matches!(b, b' ' | b'\t')) else {
continue;
};
if blanks > 0 && tail[blanks].is_ascii_uppercase() {
return true;
}
}
false
}
pub(crate) 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: &'static str,
skippable_ranges: &[SkippableRange],
cache: &mut OrphanCloserPositions,
) -> bool {
if !is_symmetric_quote_closer(closer) {
return true;
}
let (before, total) = cache.before_and_total(paragraph, closer, boundary, skippable_ranges);
let at_or_after = total - before;
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 identify_quote_pair(span: &str) -> Option<&'static QuotePair> {
QUOTE_PAIRS.iter().find(|p| span.starts_with(p.open))
}
pub(crate) fn collect_quote_ranges(text: &str, out: &mut Vec<SkippableRange>) {
if memchr::memchr3(b'\'', b'`', 0xE3, text.as_bytes()).is_none() {
scan_unambiguous_quotes(text, out);
return;
}
append_regex_quote_pairs(text, out);
append_space_padded_quote_pairs(text, out);
}
fn append_regex_quote_pairs(text: &str, out: &mut Vec<SkippableRange>) {
for mat in QUOTES_REGEX.find_iter(text) {
let pair = identify_quote_pair(&text[mat.start()..])
.expect("QUOTES_REGEX match must start with a known pair opener");
out.push(SkippableRange::new_quote(mat.start(), mat.end(), pair));
}
}
static CLEAN_QUOTE_OPENERS: LazyLock<Vec<(char, &'static QuotePair)>> = LazyLock::new(|| {
let is_clean = |s: &str| !s.contains(['\'', '`']);
QUOTE_PAIRS
.iter()
.filter(|p| is_clean(p.open) && is_clean(p.close))
.filter_map(|p| {
let mut chars = p.open.chars();
let c = chars.next()?;
chars.next().is_none().then_some((c, p))
})
.collect()
});
fn clean_opener_pair(c: char) -> Option<&'static QuotePair> {
CLEAN_QUOTE_OPENERS
.iter()
.find_map(|&(opener, pair)| (opener == c).then_some(pair))
}
fn scan_unambiguous_quotes(text: &str, out: &mut Vec<SkippableRange>) {
let bytes = text.as_bytes();
let mut cursor = 0;
while let Some(rel) = memchr::memchr3(b'"', 0xC2, 0xE2, &bytes[cursor..]) {
let opener = cursor + rel;
let c = text[opener..]
.chars()
.next()
.expect("a lead byte cannot be at end of text");
let Some(pair) = clean_opener_pair(c) else {
cursor = opener + c.len_utf8();
continue;
};
let content_start = opener + pair.open.len();
match text[content_start..].find(pair.close) {
Some(off) => {
let end = content_start + off + pair.close.len();
out.push(SkippableRange::new_quote(opener, end, pair));
cursor = end;
}
None => cursor = content_start,
}
}
}
pub(crate) fn is_symmetric_quote_range(range: &SkippableRange) -> bool {
range.quote_pair.is_some_and(|p| p.open == p.close)
}
#[derive(Default)]
struct ParityCache {
last: Option<(&'static str, bool)>,
}
impl ParityCache {
fn token_count_is_odd(&mut self, paragraph: &str, token: &'static str) -> bool {
if let Some((seen, odd)) = self.last
&& seen == token
{
return odd;
}
let odd = paragraph.matches(token).count() % 2 == 1;
self.last = Some((token, odd));
odd
}
}
fn symmetric_token_count_is_odd(
paragraph: &str,
range: &SkippableRange,
cache: &mut ParityCache,
) -> bool {
let Some(pair) = range.quote_pair else {
return false;
};
cache.token_count_is_odd(paragraph, pair.open)
}
fn paren_containing(parens: &[SkippableRange], x: usize) -> Option<&SkippableRange> {
parens
.partition_point(|p| p.start < x)
.checked_sub(1)
.map(|i| &parens[i])
.filter(|p| x < p.end)
}
fn quote_partially_overlaps_parens(quote: &SkippableRange, parens: &[SkippableRange]) -> bool {
debug_assert!(
parens.windows(2).all(|w| w[0].end <= w[1].start),
"parens must be sorted and disjoint"
);
if let Some(p) = paren_containing(parens, quote.start)
&& p.end < quote.end
{
return true;
}
if let Some(p) = paren_containing(parens, quote.end)
&& p.start > quote.start
{
return true;
}
false
}
pub(crate) fn peel_leading_symmetric_quote(s: &str) -> &str {
let trimmed = s.trim_start();
let Some(&first) = trimmed.as_bytes().first() else {
return trimmed;
};
if first.is_ascii() && !matches!(first, b'\'' | b'"' | b'`') {
return trimmed;
}
QUOTE_PAIRS
.iter()
.filter(|p| p.open == p.close)
.find_map(|p| trimmed.strip_prefix(p.close))
.map(str::trim_start)
.unwrap_or(trimmed)
}
fn is_in_quote_range(ranges: &[SkippableRange], idx: usize) -> bool {
let pos = ranges.partition_point(|r| r.start <= idx);
ranges[..pos].iter().any(|r| r.is_quote() && idx < r.end)
}
fn neighbors(text: &str, idx: usize, token: &str) -> (Option<char>, Option<char>) {
(
text[..idx].chars().next_back(),
text[idx + token.len()..].chars().next(),
)
}
fn is_contraction_quote(text: &str, idx: usize, token: &str) -> bool {
let (prev, next) = neighbors(text, idx, token);
prev.is_some_and(|c| c.is_alphanumeric()) && next.is_some_and(|c| c.is_alphanumeric())
}
fn is_quote_candidate(text: &str, idx: usize, token: &str, ranges: &[SkippableRange]) -> bool {
!is_in_quote_range(ranges, idx) && !is_contraction_quote(text, idx, token)
}
fn is_opener_shape(text: &str, idx: usize, token: &str) -> bool {
let (prev, next) = neighbors(text, idx, token);
prev.is_none_or(char::is_whitespace) && next.is_some_and(char::is_whitespace)
}
fn starts_new_utterance(text: &str, from: usize) -> bool {
let tail = &text[from..];
let trimmed = tail.trim_start_matches([' ', '\t']);
trimmed.len() < tail.len()
&& trimmed
.as_bytes()
.first()
.is_some_and(u8::is_ascii_uppercase)
}
fn candidate_opens_clean_span(
text: &str,
idx: usize,
token: &str,
ranges: &[SkippableRange],
) -> bool {
if !is_opener_shape(text, idx, token) {
return false;
}
let content_start = idx + token.len();
text[content_start..]
.match_indices(token)
.map(|(rel, _)| content_start + rel)
.find(|&y| is_quote_candidate(text, y, token, ranges))
.is_some_and(|y| !has_possible_inline_sentence_break(&text[content_start..y]))
}
fn quote_candidates_should_pair(
text: &str,
opener: usize,
closer: usize,
token: &str,
ranges: &[SkippableRange],
) -> bool {
if !is_opener_shape(text, opener, token) {
return false;
}
let span = &text[opener + token.len()..closer];
let back_to_back = starts_new_utterance(text, closer + token.len())
&& has_possible_inline_sentence_break(span);
!back_to_back || !candidate_opens_clean_span(text, closer, token, ranges)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuoteMispairing {
#[default]
None,
Certain,
Possible,
}
pub(crate) fn tag_quote_mispairing(paragraph: &str, ranges: &mut [SkippableRange]) {
let mut cache = ParityCache::default();
let parens: Vec<SkippableRange> = ranges
.iter()
.filter(|r| r.range_type == SkippableRangeType::Parentheses)
.copied()
.collect();
for slot in ranges.iter_mut() {
if !slot.is_quote() {
continue;
}
let range = *slot;
let class = if !is_symmetric_quote_range(&range) {
QuoteMispairing::None
} else if quote_partially_overlaps_parens(&range, &parens) {
QuoteMispairing::Certain
} else if symmetric_token_count_is_odd(paragraph, &range, &mut cache) {
QuoteMispairing::Possible
} else {
QuoteMispairing::None
};
slot.quote_mispairing = class;
}
}
pub(crate) fn is_symmetric_quote_mispairing<L: Language + ?Sized>(
lang: &L,
paragraph: &str,
range: &SkippableRange,
start: usize,
end: usize,
) -> bool {
match range.quote_mispairing {
QuoteMispairing::None => false,
QuoteMispairing::Certain => true,
QuoteMispairing::Possible => lang.has_strong_sentence_break(paragraph, start, end),
}
}
fn append_space_padded_quote_pairs(text: &str, ranges: &mut Vec<SkippableRange>) {
let bytes = text.as_bytes();
if memchr::memchr2(b'\'', b'`', bytes).is_none() {
return;
}
for pair in QUOTE_PAIRS
.iter()
.filter(|p| p.ambiguous && p.open == p.close)
{
let token = pair.close;
debug_assert_eq!(
token.len(),
1,
"ambiguous symmetric tokens are single-byte ASCII"
);
if !bytes.contains(&token.as_bytes()[0]) {
continue;
}
let mut pending: Option<usize> = None;
for (idx, _) in text.match_indices(token) {
if !is_quote_candidate(text, idx, token, ranges) {
continue;
}
if let Some(opener) = pending
&& quote_candidates_should_pair(text, opener, idx, token, ranges)
{
let end = idx + token.len();
ranges.push(SkippableRange::new_quote(opener, end, pair));
pending = None;
} else {
pending = Some(idx);
}
}
}
}
fn closer_opens_next_sentence(
paragraph: &str,
boundary: usize,
closer: &str,
skippable_ranges: &[SkippableRange],
) -> bool {
let opener_shaped = is_symmetric_quote_closer(closer)
&& paragraph[..boundary]
.chars()
.next_back()
.is_none_or(char::is_whitespace)
&& starts_new_utterance(paragraph, boundary + closer.len());
opener_shaped
&& skippable_ranges.iter().any(|r| {
r.end <= boundary
&& is_symmetric_quote_range(r)
&& paragraph[r.start..].starts_with(closer)
})
}
#[derive(Default)]
pub(crate) struct OrphanCloserPositions {
token: Option<&'static str>,
positions: Vec<usize>,
}
impl OrphanCloserPositions {
pub(crate) fn reset(&mut self) {
self.token = None;
}
fn before_and_total(
&mut self,
paragraph: &str,
token: &'static str,
boundary: usize,
ranges: &[SkippableRange],
) -> (usize, usize) {
if self.token != Some(token) {
self.positions.clear();
self.positions.extend(
paragraph
.match_indices(token)
.map(|(idx, _)| idx)
.filter(|&idx| is_quote_candidate(paragraph, idx, token, ranges)),
);
self.token = Some(token);
}
let before = self.positions.partition_point(|&p| p < boundary);
(before, self.positions.len())
}
}
fn range_starts_at(ranges: &[SkippableRange], boundary: usize, region: NonListRegion) -> bool {
if !region.binary_search {
return ranges.iter().any(|r| r.start == boundary);
}
let non_list = &ranges[..region.len];
let idx = non_list.partition_point(|r| r.start < boundary);
non_list.get(idx).is_some_and(|r| r.start == boundary)
|| ranges[region.len..].iter().any(|r| r.start == boundary)
}
fn is_inner_terminator(range: &SkippableRange, text: &str, boundary: usize) -> bool {
if !range.is_quote() || boundary >= range.end {
return false;
}
let head = &text[..range.end];
QUOTE_CLOSERS_BY_LEN
.iter()
.any(|c| head.ends_with(*c) && boundary + c.len() == range.end)
}
pub(crate) fn inner_terminator_boundary<L: Language + ?Sized>(
lang: &L,
paragraph: &str,
range: &SkippableRange,
boundary: usize,
) -> Option<usize> {
if !is_inner_terminator(range, paragraph, boundary) {
return None;
}
let next_word = lang.get_next_word_approx(paragraph, range.end);
lang.get_boundary_extend(next_word)
.map(|extend| range.end + extend)
}
pub(crate) fn extend_past_orphan_closer<L: Language + ?Sized>(
lang: &L,
paragraph: &str,
boundary: usize,
skippable_ranges: &[SkippableRange],
non_list_region: NonListRegion,
orphan_closers: &mut OrphanCloserPositions,
) -> usize {
if range_starts_at(skippable_ranges, boundary, non_list_region) {
return boundary;
}
let mut found = None;
for c in QUOTE_CLOSERS_BY_LEN.iter() {
if paragraph[boundary..].starts_with(c)
&& is_orphan_closer(paragraph, boundary, c, skippable_ranges, orphan_closers)
{
found = Some(c);
break;
}
}
let Some(closer) = found else {
return boundary;
};
if closer_opens_next_sentence(paragraph, boundary, closer, skippable_ranges) {
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 = lang.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
}