use super::phase2::{ascii_fold_regex_src, gate_prefix_literals, MIN_PREFIX_BYTES};
use super::phase2_first_bigram::FirstBigramSet;
use crate::anchored_regex::AnchoredRegex;
use crate::types::*;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use regex_syntax::hir::literal::{ExtractKind, Extractor};
const MAX_LITERALS_PER_PATTERN: usize = 32;
pub(crate) const CONFIRMED_MAX_LITERALS_PER_PATTERN: usize = 8;
pub(crate) struct Phase2AnchorIndex {
anchor_ac: Option<AhoCorasick>,
anchor_first_bigram: Option<FirstBigramSet>,
literal_patterns: Vec<Vec<u32>>,
eligible: Vec<bool>,
always_active_eligible: Vec<bool>,
always_anchor_ac: Option<AhoCorasick>,
always_anchor_literals: Vec<String>,
always_anchor_first_bigram: Option<FirstBigramSet>,
always_literal_patterns: Vec<Vec<u32>>,
anchored: Vec<Option<AnchoredRegex>>,
eligible_count: usize,
plain_anchor_ac: Option<AhoCorasick>,
plain_anchor_first_bigram: Option<FirstBigramSet>,
plain_literal_patterns: Vec<Vec<u32>>,
plain_always_mark: Vec<u32>,
}
impl Phase2AnchorIndex {
pub(crate) fn eligible_count(&self) -> usize {
self.eligible_count
}
#[inline]
pub(crate) fn is_eligible(&self, phase2_idx: usize) -> bool {
if self.anchor_ac.is_none() {
return false;
}
matches!(self.eligible.get(phase2_idx), Some(true)) }
#[inline]
pub(crate) fn is_always_active_eligible(&self, phase2_idx: usize) -> bool {
if self.always_anchor_ac.is_none() {
return false;
}
matches!(self.always_active_eligible.get(phase2_idx), Some(true)) }
pub(crate) fn always_anchor_literals(&self) -> &[String] {
&self.always_anchor_literals
}
pub(crate) fn build(
phase2_patterns: &[(CompiledPattern, Vec<String>)],
always_active_indices: &[usize],
) -> Option<Self> {
let mut eligible = vec![false; phase2_patterns.len()];
let mut anchored: Vec<Option<AnchoredRegex>> =
(0..phase2_patterns.len()).map(|_| None).collect();
let mut literal_ids: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut literals: Vec<String> = Vec::new();
let mut literal_patterns: Vec<Vec<u32>> = Vec::new();
let mut plain_literal_ids: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut plain_literals: Vec<String> = Vec::new();
let mut plain_literal_patterns: Vec<Vec<u32>> = Vec::new();
let mut plain_always_mark: Vec<u32> = Vec::new();
for (idx, (pattern, _keywords)) in phase2_patterns.iter().enumerate() {
let ci = pattern.regex.is_case_insensitive();
if let Some(pattern_literals) = required_prefix_literals(pattern.regex.as_str()) {
for lit in &pattern_literals {
let id = *literal_ids.entry(lit.clone()).or_insert_with(|| {
literals.push(lit.clone());
literal_patterns.push(Vec::new());
literals.len() - 1
});
literal_patterns[id].push(idx as u32);
}
eligible[idx] = true;
anchored[idx] = Some(AnchoredRegex::new(pattern.regex.as_str(), ci));
continue;
}
if !ci {
let folded_src = ascii_fold_regex_src(pattern.regex.as_str());
match leading_literals_of_folded(&folded_src) {
Some(lits) => {
for lit in &lits {
let id = *plain_literal_ids.entry(lit.clone()).or_insert_with(|| {
plain_literals.push(lit.clone());
plain_literal_patterns.push(Vec::new());
plain_literals.len() - 1
});
plain_literal_patterns[id].push(idx as u32);
}
anchored[idx] = Some(AnchoredRegex::new(&folded_src, false));
}
None => plain_always_mark.push(idx as u32),
}
}
}
let eligible_count = eligible.iter().filter(|&&e| e).count();
if eligible_count == 0 && plain_literals.is_empty() && plain_always_mark.is_empty() {
return None;
}
let mut always_active_eligible = vec![false; phase2_patterns.len()];
for &i in always_active_indices {
if eligible.get(i).copied().is_some_and(|v| v) {
always_active_eligible[i] = true;
}
}
let mut always_literals: Vec<String> = Vec::new();
let mut always_literal_patterns: Vec<Vec<u32>> = Vec::new();
for (lit_id, pats) in literal_patterns.iter().enumerate() {
let filtered = pats
.iter()
.copied()
.filter(|&pat| matches!(always_active_eligible.get(pat as usize), Some(true)))
.collect::<Vec<_>>();
if !filtered.is_empty() {
if let Some(lit) = literals.get(lit_id) {
always_literals.push(lit.clone());
always_literal_patterns.push(filtered);
}
}
}
let anchor_first_bigram = (!literals.is_empty())
.then(|| FirstBigramSet::from_literals(literals.iter().map(String::as_bytes), true));
let anchor_ac = if literals.is_empty() {
None
} else {
match AhoCorasickBuilder::new()
.match_kind(MatchKind::Standard)
.ascii_case_insensitive(true)
.build(&literals)
{
Ok(ac) => Some(ac),
Err(error) => {
tracing::warn!(
literals = literals.len(),
%error,
"phase-2 shared-anchor Aho-Corasick build failed; shared-anchor optimization disabled for case-insensitive patterns (recall preserved)"
);
None
}
}
};
let always_anchor_first_bigram = (!always_literals.is_empty()).then(|| {
FirstBigramSet::from_literals(always_literals.iter().map(String::as_bytes), true)
});
let always_anchor_ac = if always_literals.is_empty() {
None
} else {
match AhoCorasickBuilder::new()
.match_kind(MatchKind::Standard)
.ascii_case_insensitive(true)
.build(&always_literals)
{
Ok(ac) => Some(ac),
Err(error) => {
tracing::warn!(
literals = always_literals.len(),
%error,
"phase-2 always-active shared-anchor Aho-Corasick build failed; always-active anchored patterns stay on the RegexSet path (recall preserved)"
);
None
}
}
};
let plain_anchor_first_bigram = (!plain_literals.is_empty()).then(|| {
FirstBigramSet::from_literals(plain_literals.iter().map(String::as_bytes), false)
});
let plain_anchor_ac = if plain_literals.is_empty() {
None
} else {
match AhoCorasickBuilder::new()
.match_kind(MatchKind::Standard)
.build(&plain_literals)
{
Ok(ac) => Some(ac),
Err(error) => {
tracing::warn!(
literals = plain_literals.len(),
%error,
"phase-2 plain-anchor Aho-Corasick build failed; plain localizer disabled (recall preserved)"
);
None
}
}
};
Some(Self {
anchor_ac,
anchor_first_bigram,
literal_patterns,
eligible,
always_active_eligible,
always_anchor_ac,
always_anchor_literals: always_literals,
always_anchor_first_bigram,
always_literal_patterns,
anchored,
eligible_count,
plain_anchor_ac,
plain_anchor_first_bigram,
plain_literal_patterns,
plain_always_mark,
})
}
pub(crate) fn collect_candidates(
&self,
text: &str,
is_active: impl Fn(usize) -> bool,
is_allowed: impl Fn(usize) -> bool,
out: &mut Vec<(u32, u32)>,
) {
out.clear();
let Some(ac) = &self.anchor_ac else {
return;
};
if self
.anchor_first_bigram
.as_ref()
.is_some_and(|gate| !gate.may_have_match(text))
{
return;
}
for m in ac.find_overlapping_iter(text) {
let lit_id = m.pattern().as_usize();
let pos = m.start() as u32;
if let Some(pats) = self.literal_patterns.get(lit_id) {
for &pat in pats {
let p = pat as usize;
if is_allowed(p) && (self.is_always_active_eligible(p) || is_active(p)) {
out.push((pat, pos));
}
}
}
}
out.sort_unstable();
out.dedup();
}
pub(crate) fn collect_always_active_candidates(
&self,
text: &str,
is_allowed: impl Fn(usize) -> bool,
out: &mut Vec<(u32, u32)>,
) {
out.clear();
let Some(ac) = &self.always_anchor_ac else {
return;
};
if self
.always_anchor_first_bigram
.as_ref()
.is_some_and(|gate| !gate.may_have_match(text))
{
return;
}
for m in ac.find_overlapping_iter(text) {
let lit_id = m.pattern().as_usize();
let pos = m.start() as u32;
if let Some(pats) = self.always_literal_patterns.get(lit_id) {
for &pat in pats {
if is_allowed(pat as usize) {
out.push((pat, pos));
}
}
}
}
out.sort_unstable();
out.dedup();
}
pub(crate) fn collect_always_active_candidates_from_literal_matches(
&self,
literal_matches: &[(u32, u32)],
is_allowed: impl Fn(usize) -> bool,
out: &mut Vec<(u32, u32)>,
) {
out.clear();
for &(literal_id, pos) in literal_matches {
let Some(patterns) = self.always_literal_patterns.get(literal_id as usize) else {
continue;
};
for &pattern in patterns {
if is_allowed(pattern as usize) {
out.push((pattern, pos));
}
}
}
out.sort_unstable();
out.dedup();
}
pub(crate) fn anchored_regex(&self, phase2_idx: usize) -> Option<&AnchoredRegex> {
let anchored = self.anchored.get(phase2_idx)?.as_ref()?;
Some(anchored)
}
pub(crate) fn has_plain_localizer(&self, phase2_plain_localizer: bool) -> bool {
if !phase2_plain_localizer {
return false;
}
self.plain_anchor_ac.is_some() || !self.plain_always_mark.is_empty()
}
pub(crate) fn plain_always_mark(&self) -> &[u32] {
&self.plain_always_mark
}
pub(crate) fn collect_plain_candidates(
&self,
text: &str,
is_allowed: impl Fn(usize) -> bool,
out: &mut Vec<(u32, u32)>,
) {
out.clear();
let Some(ac) = &self.plain_anchor_ac else {
return;
};
if self
.plain_anchor_first_bigram
.as_ref()
.is_some_and(|gate| !gate.may_have_match(text))
{
return;
}
for m in ac.find_overlapping_iter(text) {
let lit_id = m.pattern().as_usize();
let pos = m.start() as u32;
if let Some(pats) = self.plain_literal_patterns.get(lit_id) {
for &pat in pats {
if is_allowed(pat as usize) {
out.push((pat, pos));
}
}
}
}
out.sort_unstable();
out.dedup();
}
}
fn leading_literals_of_folded(folded: &str) -> Option<Vec<String>> {
const MAX_VARIANTS: usize = 64;
let hir = regex_syntax::ParserBuilder::new()
.build()
.parse(folded)
.ok()?; let mut extractor = Extractor::new();
extractor.kind(ExtractKind::Prefix);
let seq = extractor.extract(&hir);
if !seq.is_finite() {
return None;
}
let literals = seq.literals()?;
if literals.is_empty() || literals.len() > MAX_VARIANTS {
return None;
}
let mut out: Vec<String> = Vec::with_capacity(literals.len());
for lit in literals {
if lit.len() < MIN_PREFIX_BYTES {
return None;
}
out.push(std::str::from_utf8(lit.as_bytes()).ok()?.to_string()); }
out.sort_unstable();
out.dedup();
Some(out)
}
pub(crate) fn required_prefix_literals(src: &str) -> Option<Vec<String>> {
required_prefix_literals_with_cap(src, MAX_LITERALS_PER_PATTERN)
}
pub(crate) fn required_prefix_literals_with_cap(
src: &str,
max_literals_per_pattern: usize,
) -> Option<Vec<String>> {
let literals = gate_prefix_literals(src)?;
let mut out: Vec<String> = Vec::with_capacity(literals.len());
for lit in literals {
debug_assert!(lit.len() >= MIN_PREFIX_BYTES);
debug_assert!(lit.is_ascii());
let s = std::str::from_utf8(&lit)
.ok()? .to_ascii_lowercase();
out.push(s);
}
out.sort_unstable();
out.dedup();
if out.len() > max_literals_per_pattern {
return None;
}
Some(out)
}