use super::candidate::{EnclosureSlot, Judge, Judgment, TerminatorKind};
use super::context::{fwd_chars, WINDOW_CHARS};
use crate::domain::error::DomainError;
use crate::domain::language::config::LanguageConfig;
use crate::domain::types::BoundaryFlags;
use regex::{Regex, RegexSet};
use std::collections::{HashMap, HashSet};
const CONTEXT_REACH: usize = 10;
const ABBREVIATION_REACH: usize = 21;
const ELLIPSIS_REGEX_REACH: usize = 20;
const LINE_START_REACH: usize = 11;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct CharClass {
pub terminator: bool,
pub enclosure: Option<EnclosureInfo>,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct EnclosureInfo {
pub slot: EnclosureSlot,
pub suppressible: bool,
}
#[derive(Debug)]
struct SuppressionPattern {
ch: char,
line_start: bool,
before: Option<String>,
after: Option<String>,
}
#[derive(Debug)]
pub(crate) struct CompiledRules {
ascii: [CharClass; 128],
other: HashMap<char, CharClass>,
asym_count: usize,
terminator_chars: HashSet<char>,
terminator_patterns: Vec<String>,
ellipsis_treat_as_boundary: bool,
ellipsis_patterns: Vec<String>,
ellipsis_context_rules: Vec<(EllipsisCondition, bool)>,
ellipsis_exceptions: Vec<(Regex, bool)>,
abbreviations: ReverseTrie,
starter_set: HashSet<String>,
starter_require_space: bool,
starter_min_len: usize,
suppression_patterns: Vec<SuppressionPattern>,
suppression_regexes: RegexSet,
}
#[derive(Debug)]
struct ReverseTrie {
nodes: Vec<TrieNode>,
}
#[derive(Debug, Default)]
struct TrieNode {
children: Vec<(char, u32)>,
is_end: bool,
}
impl ReverseTrie {
fn new() -> Self {
Self {
nodes: vec![TrieNode::default()],
}
}
fn insert(&mut self, abbr: &str) {
let mut node = 0usize;
for ch in abbr.chars().rev() {
let ch = lowercase_char(ch);
node = match self.nodes[node]
.children
.binary_search_by_key(&ch, |&(c, _)| c)
{
Ok(i) => self.nodes[node].children[i].1 as usize,
Err(i) => {
let idx = self.nodes.len();
self.nodes.push(TrieNode::default());
self.nodes[node].children.insert(i, (ch, idx as u32));
idx
}
};
}
self.nodes[node].is_end = true;
}
fn longest_match_ending_at(&self, text: &str, end: usize) -> Option<usize> {
if self.nodes.len() == 1 {
return None;
}
let mut node = 0usize;
let mut best = None;
for (count, (start, ch)) in text[..end].char_indices().rev().enumerate() {
if count >= ABBREVIATION_REACH {
break;
}
let ch = lowercase_char(ch);
match self.nodes[node]
.children
.binary_search_by_key(&ch, |&(c, _)| c)
{
Ok(i) => node = self.nodes[node].children[i].1 as usize,
Err(_) => break,
}
if self.nodes[node].is_end {
best = Some(end - start);
}
}
best
}
}
fn lowercase_char(ch: char) -> char {
ch.to_lowercase().next().unwrap_or(ch)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EllipsisCondition {
FollowedByCapital,
FollowedByLowercase,
Custom,
}
impl CompiledRules {
#[cfg(test)]
pub(crate) fn from_code(code: &str) -> Result<Self, DomainError> {
Self::from_config(crate::domain::language::config::get_language_config(code)?)
}
pub(crate) fn from_config(config: &LanguageConfig) -> Result<Self, DomainError> {
let required = required_window(config);
if required > WINDOW_CHARS {
return Err(DomainError::ConfigurationError(format!(
"language '{}' needs a ±{} character judgment window, but the \
algorithm's window is ±{} (WINDOW_CHARS)",
config.metadata.code, required, WINDOW_CHARS
)));
}
let mut ascii = [CharClass::default(); 128];
let mut other: HashMap<char, CharClass> = HashMap::new();
let mut classify = |ch: char, f: &mut dyn FnMut(&mut CharClass)| {
if (ch as u32) < 128 {
f(&mut ascii[ch as usize]);
} else {
f(other.entry(ch).or_default());
}
};
let terminator_chars: HashSet<char> = config.terminators.chars.iter().copied().collect();
let mut potential: Vec<char> = config.terminators.chars.clone();
for p in &config.terminators.patterns {
potential.extend(p.pattern.chars());
}
for p in &config.ellipsis.patterns {
potential.extend(p.chars());
}
for ch in potential {
classify(ch, &mut |c| c.terminator = true);
}
let fast_chars: HashSet<char> = config
.suppression
.fast_patterns
.iter()
.map(|p| p.char)
.collect();
let regexes_present = !config.suppression.regex_patterns.is_empty();
let mut asym_count = 0usize;
let mut sym_count = 0usize;
for pair in &config.enclosures.pairs {
if pair.symmetric {
let bit = u8::try_from(sym_count).map_err(|_| {
DomainError::ConfigurationError("too many symmetric enclosure types".into())
})?;
if sym_count >= 32 {
return Err(DomainError::ConfigurationError(
"at most 32 symmetric enclosure types are supported".into(),
));
}
sym_count += 1;
let slot = EnclosureSlot::Sym { bit };
for ch in [pair.open, pair.close] {
let suppressible = fast_chars.contains(&ch) || regexes_present;
classify(ch, &mut |c| {
c.enclosure = Some(EnclosureInfo { slot, suppressible })
});
}
} else {
let index = u8::try_from(asym_count).map_err(|_| {
DomainError::ConfigurationError("too many asymmetric enclosure types".into())
})?;
asym_count += 1;
for (ch, delta) in [(pair.open, 1i8), (pair.close, -1i8)] {
let suppressible = fast_chars.contains(&ch) || regexes_present;
let slot = EnclosureSlot::Asym { index, delta };
classify(ch, &mut |c| {
c.enclosure = Some(EnclosureInfo { slot, suppressible })
});
}
}
}
let ellipsis_context_rules = config
.ellipsis
.context_rules
.iter()
.map(|r| {
let cond = match r.condition.as_str() {
"followed_by_capital" => EllipsisCondition::FollowedByCapital,
"followed_by_lowercase" => EllipsisCondition::FollowedByLowercase,
_ => EllipsisCondition::Custom,
};
(cond, r.boundary)
})
.collect();
let ellipsis_exceptions = config
.ellipsis
.exceptions
.iter()
.map(|e| Ok((Regex::new(&e.regex)?, e.boundary)))
.collect::<Result<Vec<_>, regex::Error>>()
.map_err(|e| {
DomainError::InvalidLanguageRules(format!("invalid ellipsis regex: {e}"))
})?;
let suppression_regexes =
RegexSet::new(config.suppression.regex_patterns.iter().map(|p| &p.pattern)).map_err(
|e| DomainError::InvalidLanguageRules(format!("invalid suppression regex: {e}")),
)?;
let (starter_set, starter_require_space, starter_min_len) =
if let Some(ref starters) = config.sentence_starters {
let mut set = HashSet::new();
for words in starters.categories.values() {
for w in words {
if w.len() >= starters.min_word_length {
set.insert(w.clone());
}
}
}
(
set,
starters.require_following_space,
starters.min_word_length,
)
} else {
(HashSet::new(), true, 1)
};
Ok(Self {
ascii,
other,
asym_count,
terminator_chars,
terminator_patterns: config
.terminators
.patterns
.iter()
.map(|p| p.pattern.clone())
.collect(),
ellipsis_treat_as_boundary: config.ellipsis.treat_as_boundary,
ellipsis_patterns: config.ellipsis.patterns.clone(),
ellipsis_context_rules,
ellipsis_exceptions,
abbreviations: {
let mut trie = ReverseTrie::new();
for words in config.abbreviations.categories.values() {
for word in words {
trie.insert(word);
}
}
trie
},
starter_set,
starter_require_space,
starter_min_len,
suppression_patterns: config
.suppression
.fast_patterns
.iter()
.map(|p| SuppressionPattern {
ch: p.char,
line_start: p.line_start,
before: p.before.clone(),
after: p.after.clone(),
})
.collect(),
suppression_regexes,
})
}
pub(crate) fn asym_type_count(&self) -> usize {
self.asym_count
}
#[inline]
pub(crate) fn classify(&self, ch: char) -> CharClass {
if (ch as u32) < 128 {
self.ascii[ch as usize]
} else {
self.other.get(&ch).copied().unwrap_or_default()
}
}
fn ellipsis_completes_at(&self, w: &str, pos: usize) -> bool {
let ends_here = self.ellipsis_patterns.iter().any(|p| {
pos >= p.len() && w.is_char_boundary(pos - p.len()) && &w[pos - p.len()..pos] == p
});
if !ends_here {
return false;
}
!self.ellipsis_patterns.iter().any(|q| {
let qlen = q.len();
(pos.saturating_sub(qlen - 1)..=pos).any(|s| {
w.is_char_boundary(s)
&& w.len() >= s + qlen
&& w.is_char_boundary(s + qlen)
&& &w[s..s + qlen] == q
})
})
}
fn evaluate_ellipsis(&self, w: &str, term_pos: usize, following10: &str) -> Judgment {
let mut start = term_pos.saturating_sub(ELLIPSIS_REGEX_REACH);
while start > 0 && !w.is_char_boundary(start) {
start -= 1;
}
let mut end = (term_pos + ELLIPSIS_REGEX_REACH).min(w.len());
while end < w.len() && !w.is_char_boundary(end) {
end += 1;
}
let regex_window = &w[start..end];
for (regex, is_boundary) in &self.ellipsis_exceptions {
if regex.is_match(regex_window) {
return if *is_boundary {
Judgment::Boundary(BoundaryFlags::WEAK)
} else {
Judgment::NotBoundary
};
}
}
for (cond, is_boundary) in &self.ellipsis_context_rules {
let first_alpha = following10.chars().find(|c| c.is_alphabetic());
let matches = match cond {
EllipsisCondition::FollowedByCapital => {
first_alpha.map(char::is_uppercase).unwrap_or(false)
}
EllipsisCondition::FollowedByLowercase => {
first_alpha.map(char::is_lowercase).unwrap_or(false)
}
EllipsisCondition::Custom => false,
};
if matches {
return if *is_boundary {
Judgment::Boundary(BoundaryFlags::WEAK)
} else {
Judgment::NotBoundary
};
}
}
if self.ellipsis_treat_as_boundary {
Judgment::Boundary(BoundaryFlags::WEAK)
} else {
Judgment::NotBoundary
}
}
fn is_multi_period_context(&self, preceding10: &str, following10: &str) -> bool {
let mut letters_before = 0usize;
let mut before_run_start: Option<char> = None;
for ch in preceding10.chars().rev() {
if ch.is_alphabetic() && letters_before < 3 {
letters_before += 1;
} else {
before_run_start = Some(ch);
break;
}
}
if letters_before == 0 || letters_before > 2 {
return false;
}
if before_run_start.map(char::is_alphabetic).unwrap_or(false) {
return false;
}
let mut it = following10.chars().peekable();
while it.peek().is_some_and(|c| c.is_whitespace()) {
it.next();
}
let mut letters_after = 0usize;
while it.peek().is_some_and(|c| c.is_alphabetic()) && letters_after < 3 {
it.next();
letters_after += 1;
}
letters_after > 0 && letters_after <= 2 && it.peek() == Some(&'.')
}
fn abbreviation_ends_at(&self, w: &str, term_pos: usize) -> Option<usize> {
if term_pos == 0 {
return None;
}
let length = self.abbreviations.longest_match_ending_at(w, term_pos)?;
let abbr_start = term_pos - length;
let has_word_boundary = abbr_start == 0
|| w[..abbr_start]
.chars()
.next_back()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
has_word_boundary.then_some(length)
}
fn extract_next_word(following: &str) -> Option<(&str, &str)> {
let trimmed_start = following.len() - following.trim_start().len();
let rest = &following[trimmed_start..];
let word_len = rest
.char_indices()
.find(|(_, c)| !c.is_alphabetic())
.map(|(i, _)| i)
.unwrap_or(rest.len());
if word_len == 0 {
None
} else {
Some((&rest[..word_len], &rest[word_len..]))
}
}
fn is_sentence_starter(&self, word: &str, remaining: &str) -> bool {
if word.len() < self.starter_min_len || !self.starter_set.contains(word) {
return false;
}
if self.starter_require_space {
remaining.chars().next().is_some_and(char::is_whitespace)
} else {
true
}
}
}
fn required_window(config: &LanguageConfig) -> usize {
let longest_terminator_pattern = config
.terminators
.patterns
.iter()
.map(|p| p.pattern.chars().count())
.max()
.unwrap_or(0);
let longest_ellipsis_pattern = config
.ellipsis
.patterns
.iter()
.map(|p| p.chars().count())
.max()
.unwrap_or(0);
[
CONTEXT_REACH + 1,
ABBREVIATION_REACH + 1,
ELLIPSIS_REGEX_REACH + 1,
LINE_START_REACH,
longest_terminator_pattern + 1,
longest_ellipsis_pattern + 1,
]
.into_iter()
.max()
.unwrap_or(0)
}
impl Judge for CompiledRules {
fn judge(&self, w: &str, pos_in_window: usize, kind: TerminatorKind) -> Judgment {
let TerminatorKind::Char(ch) = kind;
let term_pos = pos_in_window - ch.len_utf8();
let following = &w[pos_in_window..];
let following10 = &following[..fwd_chars(following, 0, CONTEXT_REACH)];
let preceding = &w[..term_pos];
let preceding10 =
&preceding[super::context::back_chars(preceding, preceding.len(), CONTEXT_REACH)..];
if self.ellipsis_completes_at(w, pos_in_window) {
return self.evaluate_ellipsis(w, term_pos, following10);
}
if ch == '.' {
if following10.starts_with('.') {
return Judgment::NotBoundary;
}
if self.is_multi_period_context(preceding10, following10) {
return Judgment::NotBoundary;
}
}
if !self.terminator_chars.contains(&ch) {
return Judgment::NotBoundary;
}
for pattern in &self.terminator_patterns {
if pos_in_window >= pattern.len()
&& w.is_char_boundary(pos_in_window - pattern.len())
&& &w[pos_in_window - pattern.len()..pos_in_window] == pattern.as_str()
{
return Judgment::Boundary(BoundaryFlags::STRONG);
}
}
if let Some(next) = following.chars().next() {
let starts_two_char_pattern = self.terminator_patterns.iter().any(|p| {
let mut pc = p.chars();
pc.next() == Some(ch) && pc.next() == Some(next) && pc.next().is_none()
});
if starts_two_char_pattern {
return Judgment::NotBoundary;
}
}
if self.abbreviation_ends_at(w, term_pos).is_some() {
return match Self::extract_next_word(following10) {
Some((word, remaining)) => {
if self.is_sentence_starter(word, remaining) {
Judgment::Boundary(BoundaryFlags::WEAK)
} else {
Judgment::NotBoundary
}
}
None => Judgment::Boundary(BoundaryFlags::WEAK),
};
}
match ch {
'!' | '?' | '!' | '?' => Judgment::Boundary(BoundaryFlags::STRONG),
'.' | '。' => {
let digit_before = preceding
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_digit());
let digit_after = following.chars().next().is_some_and(|c| c.is_ascii_digit());
if digit_before && digit_after {
Judgment::NotBoundary
} else {
Judgment::Boundary(BoundaryFlags::WEAK)
}
}
_ => Judgment::NotBoundary,
}
}
fn suppress_enclosure(&self, w: &str, pos_in_window: usize, ch: char) -> bool {
let preceding = &w[..pos_in_window];
let following_after_ch = {
let mut it = w[pos_in_window..].chars();
it.next(); it.as_str()
};
for pattern in &self.suppression_patterns {
if pattern.ch != ch {
continue;
}
if pattern.line_start {
let mut offset = 0usize;
let mut found = false;
for c in preceding.chars().rev().take(LINE_START_REACH) {
if c == '\n' {
found = true;
break;
}
offset += 1;
}
let at_line_start = (found || offset < LINE_START_REACH) && offset <= 10;
if !at_line_start {
continue;
}
}
if let Some(ref class) = pattern.before {
if !char_matches_class(preceding.chars().next_back(), class) {
continue;
}
}
if let Some(ref class) = pattern.after {
if !char_matches_class(following_after_ch.chars().next(), class) {
continue;
}
}
return true;
}
if !self.suppression_regexes.is_empty() {
let start = super::context::back_chars(preceding, preceding.len(), 3);
let end = fwd_chars(following_after_ch, 0, 3);
let mut buf = [0u8; 32];
let mut len = 0;
for &b in &preceding.as_bytes()[start..] {
buf[len] = b;
len += 1;
}
len += ch.encode_utf8(&mut buf[len..]).len();
for &b in &following_after_ch.as_bytes()[..end] {
buf[len] = b;
len += 1;
}
let window = std::str::from_utf8(&buf[..len]).expect("window bytes are valid UTF-8");
if self.suppression_regexes.is_match(window) {
return true;
}
}
false
}
}
fn char_matches_class(ch: Option<char>, class: &str) -> bool {
match (ch, class) {
(Some(c), "alpha") => c.is_alphabetic(),
(Some(c), "alnum") => c.is_alphanumeric(),
(Some(c), "digit") => c.is_ascii_digit(),
(Some(c), "whitespace") => c.is_whitespace(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_configs_fit_the_window() {
for code in crate::domain::language::config::list_available_languages() {
CompiledRules::from_code(code)
.unwrap_or_else(|e| panic!("config '{code}' must compile: {e}"));
}
}
#[test]
fn oversized_pattern_is_rejected() {
let toml = format!(
r#"
[metadata]
code = "xx"
name = "Test"
[terminators]
chars = ["."]
[ellipsis]
patterns = ["{}"]
[enclosures]
pairs = []
[suppression]
fast_patterns = []
"#,
".".repeat(WINDOW_CHARS + 1)
);
let config: LanguageConfig = toml::from_str(&toml).unwrap();
let err = CompiledRules::from_config(&config).unwrap_err();
assert!(
err.to_string().contains("judgment window"),
"unexpected error: {err}"
);
}
#[test]
fn en_classification_covers_terminators_and_enclosures() {
let rules = CompiledRules::from_code("en").unwrap();
assert!(rules.classify('.').terminator);
assert!(rules.classify('?').terminator);
assert!(
rules.classify('…').terminator,
"ellipsis chars are evaluated"
);
assert!(!rules.classify('a').terminator);
let paren = rules.classify('(').enclosure.unwrap();
assert!(matches!(paren.slot, EnclosureSlot::Asym { delta: 1, .. }));
assert!(
paren.suppressible,
"en has suppression regexes, which the legacy rules evaluate for every enclosure char"
);
let apostrophe = rules.classify('\'').enclosure.unwrap();
assert!(matches!(apostrophe.slot, EnclosureSlot::Sym { .. }));
assert!(apostrophe.suppressible, "apostrophe has suppression rules");
assert!(rules.asym_type_count() >= 3);
let quote = rules.classify('"').enclosure.unwrap();
match (apostrophe.slot, quote.slot) {
(EnclosureSlot::Sym { bit: a }, EnclosureSlot::Sym { bit: q }) => assert_ne!(a, q),
other => panic!("both quotes must be symmetric, got {other:?}"),
}
}
}