use crate::data::{self, Boundary, Case, Mechanism, Position, Rule};
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind as AcMatchKind};
use regex_automata::hybrid::dfa::{Cache, OverlappingState, DFA};
use regex_automata::nfa::thompson;
use regex_automata::util::syntax;
use regex_automata::{Anchored, Input, MatchKind, PatternID};
use std::collections::HashSet;
use std::ops::Range;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct Hit {
pub rule: usize,
pub span: Range<usize>,
}
struct RxMeta {
rule: usize,
bound_start: bool,
bound_end: bool,
max_width: Option<usize>,
}
pub struct Compiled {
pub rules: Vec<Rule>,
ac_ci: AhoCorasick,
ac_ci_meta: Vec<usize>,
ac_cs: AhoCorasick,
ac_cs_meta: Vec<usize>,
rx_fwd: DFA,
rx_rev: DFA,
rx_meta: Vec<RxMeta>,
cp_rules: Vec<(usize, Vec<(u32, u32)>)>,
space_rules: Vec<(usize, Vec<u32>, usize)>,
participial_rules: Vec<(usize, HashSet<String>, usize)>,
}
static COMPILED: OnceLock<Result<Compiled, String>> = OnceLock::new();
pub fn compiled() -> &'static Compiled {
COMPILED
.get_or_init(build)
.as_ref()
.expect("embedded pattern data failed to compile (build defect)")
}
fn rewrite_pattern(p: &str) -> Result<(String, bool, bool), String> {
if p.contains("(?<") || p.contains("(?!") || p.contains("(?=") {
return Err(format!("unsupported look-around in pattern {p}"));
}
let bound_start = p.strip_prefix("(?i)").unwrap_or(p).starts_with(r"\b");
let bound_end = p.ends_with(r"\b");
Ok((p.replace(r"\b", r"(?-u:\b)"), bound_start, bound_end))
}
fn validate_bounded_width(pat: &str) -> Result<Option<usize>, String> {
let hir = regex_syntax::parse(pat)
.map_err(|e| format!("pattern {pat} failed width-validation parse: {e}"))?;
if unbounded_repetition(&hir) {
return Err(format!(
"pattern {pat} has an unbounded-width quantifier (*, +, or {{n,}}); \
bounded forms are required, whitespace included"
));
}
Ok(hir.properties().maximum_len())
}
fn unbounded_repetition(hir: ®ex_syntax::hir::Hir) -> bool {
use regex_syntax::hir::HirKind;
match hir.kind() {
HirKind::Repetition(rep) => rep.max.is_none() || unbounded_repetition(&rep.sub),
HirKind::Capture(c) => unbounded_repetition(&c.sub),
HirKind::Concat(v) | HirKind::Alternation(v) => v.iter().any(unbounded_repetition),
_ => false,
}
}
fn build() -> Result<Compiled, String> {
let rules = data::load()?;
let mut ci_pats: Vec<&str> = Vec::new();
let mut ci_meta: Vec<usize> = Vec::new();
let mut cs_pats: Vec<&str> = Vec::new();
let mut cs_meta: Vec<usize> = Vec::new();
let mut rx_pats: Vec<String> = Vec::new();
let mut rx_meta: Vec<RxMeta> = Vec::new();
let mut cp_rules: Vec<(usize, Vec<(u32, u32)>)> = Vec::new();
let mut space_rules: Vec<(usize, Vec<u32>, usize)> = Vec::new();
let mut participial_rules: Vec<(usize, HashSet<String>, usize)> = Vec::new();
for (idx, rule) in rules.iter().enumerate() {
match rule.mechanism {
Mechanism::WordSet | Mechanism::Regex => {
for term in &rule.terms {
match rule.case {
Case::Insensitive => {
ci_pats.push(term);
ci_meta.push(idx);
}
Case::Sensitive => {
cs_pats.push(term);
cs_meta.push(idx);
}
}
}
for p in &rule.patterns {
let (pat, bs, be) = rewrite_pattern(p)?;
let max_width = validate_bounded_width(&pat)?;
rx_pats.push(pat);
rx_meta.push(RxMeta {
rule: idx,
bound_start: bs,
bound_end: be,
max_width,
});
}
}
Mechanism::Codepoint => cp_rules.push((idx, rule.ranges.clone())),
Mechanism::PositionalSpace => {
space_rules.push((idx, rule.codepoints.clone(), rule.min_count));
}
Mechanism::ParticipialOpener => {
participial_rules.push((
idx,
rule.stoplist.iter().cloned().collect(),
rule.max_clause,
));
}
}
}
let ac_ci = AhoCorasickBuilder::new()
.match_kind(AcMatchKind::Standard)
.ascii_case_insensitive(true)
.build(&ci_pats)
.map_err(|e| format!("case-insensitive automaton: {e}"))?;
let ac_cs = AhoCorasickBuilder::new()
.match_kind(AcMatchKind::Standard)
.build(&cs_pats)
.map_err(|e| format!("case-sensitive automaton: {e}"))?;
let syn = syntax::Config::new()
.unicode(true)
.utf8(true)
.multi_line(true);
let fwd = DFA::builder()
.configure(
DFA::config()
.match_kind(MatchKind::All)
.starts_for_each_pattern(true)
.cache_capacity(4 * 1024 * 1024),
)
.syntax(syn)
.build_many(&rx_pats)
.map_err(|e| format!("forward dfa: {e}"))?;
let rev = DFA::builder()
.configure(
DFA::config()
.match_kind(MatchKind::All)
.starts_for_each_pattern(true)
.cache_capacity(4 * 1024 * 1024),
)
.thompson(thompson::Config::new().reverse(true))
.syntax(syn)
.build_many(&rx_pats)
.map_err(|e| format!("reverse dfa: {e}"))?;
let mut probe_cache = fwd.create_cache();
for (pid, pat) in rx_pats.iter().enumerate() {
let input = Input::new("").anchored(Anchored::Pattern(PatternID::new_unchecked(pid)));
if let Ok(Some(_)) = fwd.try_search_fwd(&mut probe_cache, &input) {
return Err(format!("pattern {pat} can match empty"));
}
}
Ok(Compiled {
rules,
ac_ci,
ac_ci_meta: ci_meta,
ac_cs,
ac_cs_meta: cs_meta,
rx_fwd: fwd,
rx_rev: rev,
rx_meta,
cp_rules,
space_rules,
participial_rules,
})
}
fn word_bounded(hay: &str, span: &Range<usize>) -> bool {
let before_ok = hay[..span.start]
.chars()
.next_back()
.map(|c| !unicode_ident::is_xid_continue(c))
.unwrap_or(true);
let after_ok = hay[span.end..]
.chars()
.next()
.map(|c| !unicode_ident::is_xid_continue(c))
.unwrap_or(true);
before_ok && after_ok
}
fn unicode_word_boundary(hay: &str, at: usize) -> bool {
let before = hay[..at]
.chars()
.next_back()
.map(unicode_ident::is_xid_continue)
.unwrap_or(false);
let after = hay[at..]
.chars()
.next()
.map(unicode_ident::is_xid_continue)
.unwrap_or(false);
before != after
}
fn joining_script(c: char) -> bool {
matches!(c as u32,
0x0600..=0x06FF
| 0x0700..=0x074F
| 0x0750..=0x077F
| 0x08A0..=0x08FF
| 0x0900..=0x0DFF
| 0x1000..=0x109F
| 0x1780..=0x17FF
| 0xFB50..=0xFDFF
| 0xFE70..=0xFEFC)
}
fn pictographic(c: char) -> bool {
matches!(c as u32,
0x2600..=0x27BF | 0x2B00..=0x2B5F | 0x1F000..=0x1FAFF)
}
fn at_block_start(src: &str, at: usize) -> bool {
for c in src[..at].chars().rev() {
match c {
' ' | '\t' | '-' | '*' | '\u{2022}' => continue,
'\n' | '\r' | '.' | '!' | '?' => return true,
_ => return false,
}
}
true
}
fn exempted(hay: &str, span: &Range<usize>, phrases: &[String]) -> bool {
if phrases.is_empty() {
return false;
}
let win_start =
crate::widen_to_char_boundaries(hay, span.start.saturating_sub(60)..span.start).start;
let win_end =
crate::widen_to_char_boundaries(hay, span.end..(span.end + 60).min(hay.len())).end;
let window = hay[win_start..win_end].to_lowercase();
let rel_start = hay[win_start..span.start].to_lowercase().len();
let rel_end = rel_start + hay[span.start..span.end].to_lowercase().len();
for phrase in phrases {
let mut at = 0usize;
while let Some(pos) = window[at..].find(phrase.as_str()) {
let s = at + pos;
let e = s + phrase.len();
if s <= rel_start && e >= rel_end {
return true;
}
at = s + 1;
}
}
false
}
fn scan_ac(cp: &Compiled, src: &str, hits: &mut Vec<Hit>) {
let passes: [(&AhoCorasick, &[usize]); 2] =
[(&cp.ac_ci, &cp.ac_ci_meta), (&cp.ac_cs, &cp.ac_cs_meta)];
for (ac, meta) in passes {
for m in ac.find_overlapping_iter(src) {
let rule_idx = meta[m.pattern().as_usize()];
let rule = &cp.rules[rule_idx];
let span = m.start()..m.end();
if rule.boundary == Boundary::Word && !word_bounded(src, &span) {
continue;
}
if rule.position == Position::BlockStart && !at_block_start(src, span.start) {
continue;
}
if exempted(src, &span, &rule.exemptions) {
continue;
}
hits.push(Hit {
rule: rule_idx,
span,
});
}
}
}
fn scan_participial(cp: &Compiled, src: &str, hits: &mut Vec<Hit>) {
if cp.participial_rules.is_empty() {
return;
}
for (i, c) in src.char_indices() {
if !c.is_ascii_uppercase() || !at_block_start(src, i) {
continue;
}
let word_len = src[i + 1..]
.bytes()
.take_while(|b| b.is_ascii_lowercase())
.count();
let word_end = i + 1 + word_len;
let word = &src[i..word_end];
if word.len() < 5 || word.len() > 30 || !word.ends_with("ing") {
continue;
}
if !src[word_end..].starts_with(' ') {
continue;
}
for (rule_idx, stoplist, max_clause) in &cp.participial_rules {
if stoplist.contains(&word.to_ascii_lowercase()) {
continue;
}
let clause = &src[word_end + 1..];
let limit = (*max_clause).min(clause.len());
let mut comma = None;
for (j, cc) in clause.char_indices() {
if j >= limit {
break;
}
match cc {
',' => {
comma = Some(j);
break;
}
'.' | '!' | '?' | ';' | ':' | '\n' => break,
_ => {}
}
}
let Some(j) = comma else { continue };
if j == 0 {
continue;
}
hits.push(Hit {
rule: *rule_idx,
span: i..word_end + 1 + j + 1,
});
}
}
}
fn scan_rx(cp: &Compiled, src: &str, hits: &mut Vec<Hit>) {
if cp.rx_meta.is_empty() || src.is_empty() {
return;
}
let mut fwd_cache: Cache = cp.rx_fwd.create_cache();
let mut rev_cache: Cache = cp.rx_rev.create_cache();
let input = Input::new(src);
let mut state = OverlappingState::start();
let mut seen: HashSet<(usize, usize, usize)> = HashSet::new();
loop {
if cp
.rx_fwd
.try_search_overlapping_fwd(&mut fwd_cache, &input, &mut state)
.is_err()
{
return;
}
let Some(hm) = state.get_match() else { break };
let pid = hm.pattern();
let end = hm.offset();
let meta = &cp.rx_meta[pid.as_usize()];
let rev_lo = match meta.max_width {
Some(w) => end.saturating_sub(w),
None => 0,
};
let rin = Input::new(src)
.range(rev_lo..end)
.anchored(Anchored::Pattern(pid));
let start = match cp.rx_rev.try_search_rev(&mut rev_cache, &rin) {
Ok(Some(h)) => h.offset(),
_ => continue,
};
if start >= end || !seen.insert((pid.as_usize(), start, end)) {
continue;
}
if (meta.bound_start && !unicode_word_boundary(src, start))
|| (meta.bound_end && !unicode_word_boundary(src, end))
{
continue;
}
hits.push(Hit {
rule: meta.rule,
span: start..end,
});
}
}
fn scan_codepoints(cp: &Compiled, src: &str, hits: &mut Vec<Hit>) {
let mut open: Vec<Option<Range<usize>>> = vec![None; cp.cp_rules.len()];
let mut candidates: Vec<Vec<Range<usize>>> = vec![Vec::new(); cp.space_rules.len()];
let mut prev: Option<char> = None;
let mut prev2: Option<char> = None;
let mut iter = src.char_indices().peekable();
while let Some((i, c)) = iter.next() {
let v = c as u32;
let end = i + c.len_utf8();
let next = iter.peek().map(|&(_, n)| n);
for (slot, (rule_idx, ranges)) in cp.cp_rules.iter().enumerate() {
if ranges.iter().any(|&(lo, hi)| lo <= v && v <= hi) {
let rule = &cp.rules[*rule_idx];
let in_range = |c: char| {
ranges
.iter()
.any(|&(lo, hi)| lo <= c as u32 && c as u32 <= hi)
};
let joining_exempt = || {
let back = match prev {
Some(p) if p as u32 == 0xFE0E || p as u32 == 0xFE0F => prev2,
p => p,
};
match (back, next) {
(Some(b), Some(f)) => {
(joining_script(b) && joining_script(f))
|| (pictographic(b) && pictographic(f))
}
_ => false,
}
};
if (rule.exempt_leading_bom && v == 0xFEFF && i == 0)
|| (rule.exempt_presentation_selector
&& (v == 0xFE0E || v == 0xFE0F)
&& prev.map(|p| !in_range(p)).unwrap_or(false))
|| (rule.exempt_joining_zwj && (v == 0x200C || v == 0x200D) && joining_exempt())
{
continue;
}
match &mut open[slot] {
Some(r) if r.end == i => r.end = end,
r => {
if let Some(done) = r.take() {
hits.push(Hit {
rule: *rule_idx,
span: done,
});
}
*r = Some(i..end);
}
}
}
}
for (slot, (_, codepoints, _)) in cp.space_rules.iter().enumerate() {
if codepoints.contains(&v)
&& prev.map(char::is_alphabetic).unwrap_or(false)
&& next.map(char::is_alphabetic).unwrap_or(false)
{
candidates[slot].push(i..end);
}
}
prev2 = prev;
prev = Some(c);
}
for (slot, (rule_idx, _)) in cp.cp_rules.iter().enumerate() {
if let Some(done) = open[slot].take() {
hits.push(Hit {
rule: *rule_idx,
span: done,
});
}
}
for (slot, (rule_idx, _, min_count)) in cp.space_rules.iter().enumerate() {
if candidates[slot].len() >= *min_count {
for span in candidates[slot].drain(..) {
hits.push(Hit {
rule: *rule_idx,
span,
});
}
}
}
}
pub fn scan_all(cp: &Compiled, src: &str) -> Vec<Hit> {
let mut hits = Vec::new();
scan_ac(cp, src, &mut hits);
scan_rx(cp, src, &mut hits);
scan_codepoints(cp, src, &mut hits);
scan_participial(cp, src, &mut hits);
resolve_overlaps(&mut hits);
hits
}
fn resolve_overlaps(hits: &mut Vec<Hit>) {
hits.sort_by(|a, b| {
(a.rule, a.span.start, std::cmp::Reverse(a.span.end)).cmp(&(
b.rule,
b.span.start,
std::cmp::Reverse(b.span.end),
))
});
let mut out: Vec<Hit> = Vec::new();
for h in hits.drain(..) {
if let Some(prev) = out.last() {
if prev.rule == h.rule && h.span.start >= prev.span.start && h.span.end <= prev.span.end
{
continue;
}
}
out.push(h);
}
*hits = out;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_compiles() {
let cp = compiled();
assert!(!cp.rules.is_empty());
assert!(!cp.rx_meta.is_empty());
assert!(!cp.cp_rules.is_empty());
assert_eq!(cp.space_rules.len(), 1);
}
#[test]
fn bounded_width_gate_rejects_unbounded_quantifiers() {
assert!(validate_bounded_width(r"\d+").is_err());
assert!(validate_bounded_width(r".*").is_err());
assert!(validate_bounded_width(r"a\s+b").is_err());
assert!(matches!(
validate_bounded_width(r"turn\d{1,4}search\d{0,4}"),
Ok(Some(_))
));
}
#[test]
fn rewrite_rejects_look_arounds() {
assert!(rewrite_pattern(r"(?<=\w)foo").is_err());
assert!(rewrite_pattern(r"foo(?=bar)").is_err());
assert!(rewrite_pattern(r"\bfoo\b").is_ok());
}
#[test]
fn contained_same_rule_spans_merge() {
let mut hits = vec![
Hit {
rule: 1,
span: 5..25,
},
Hit {
rule: 1,
span: 5..17,
},
Hit {
rule: 2,
span: 5..17,
},
];
resolve_overlaps(&mut hits);
assert_eq!(hits.len(), 2);
assert!(hits.iter().any(|h| h.rule == 1 && h.span == (5..25)));
assert!(hits.iter().any(|h| h.rule == 2 && h.span == (5..17)));
}
}