#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub(crate) enum TriggerKind {
Bar,
RubyOpen,
RubyClose,
AngleQuoteOpen,
AngleQuoteClose,
BracketOpen,
BracketClose,
Hash,
RefMark,
TortoiseOpen,
TortoiseClose,
QuoteOpen,
QuoteClose,
}
impl TriggerKind {
#[must_use]
pub(crate) const fn source_byte_len(self) -> u32 {
match self {
Self::Bar
| Self::RubyOpen
| Self::RubyClose
| Self::AngleQuoteOpen
| Self::AngleQuoteClose
| Self::BracketOpen
| Self::BracketClose
| Self::Hash
| Self::RefMark
| Self::TortoiseOpen
| Self::TortoiseClose
| Self::QuoteOpen
| Self::QuoteClose => 3,
}
}
}
#[inline]
#[must_use]
pub(crate) fn classify_trigger_bytes(window: [u8; 3]) -> Option<TriggerKind> {
Some(match window {
[0xE2, 0x80, 0xBB] => TriggerKind::RefMark, [0xE2, 0x89, 0xAA] => TriggerKind::AngleQuoteOpen, [0xE2, 0x89, 0xAB] => TriggerKind::AngleQuoteClose, [0xE3, 0x80, 0x8A] => TriggerKind::RubyOpen, [0xE3, 0x80, 0x8B] => TriggerKind::RubyClose, [0xE3, 0x80, 0x8C] => TriggerKind::QuoteOpen, [0xE3, 0x80, 0x8D] => TriggerKind::QuoteClose, [0xE3, 0x80, 0x94] => TriggerKind::TortoiseOpen, [0xE3, 0x80, 0x95] => TriggerKind::TortoiseClose, [0xEF, 0xBC, 0x83] => TriggerKind::Hash, [0xEF, 0xBC, 0xBB] => TriggerKind::BracketOpen, [0xEF, 0xBC, 0xBD] => TriggerKind::BracketClose, [0xEF, 0xBD, 0x9C] => TriggerKind::Bar, _ => return None,
})
}
pub(crate) const ALL_TRIGGER_TRIGRAMS: [[u8; 3]; 13] = [
[0xEF, 0xBD, 0x9C], [0xE3, 0x80, 0x8A], [0xE3, 0x80, 0x8B], [0xE2, 0x89, 0xAA], [0xE2, 0x89, 0xAB], [0xEF, 0xBC, 0xBB], [0xEF, 0xBC, 0xBD], [0xEF, 0xBC, 0x83], [0xE2, 0x80, 0xBB], [0xE3, 0x80, 0x94], [0xE3, 0x80, 0x95], [0xE3, 0x80, 0x8C], [0xE3, 0x80, 0x8D], ];
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
const TRIGGER_LEADING_BYTES: [u8; 3] = [0xE2, 0xE3, 0xEF];
const TRIGGER_MIDDLE_BYTES: [u8; 4] = [0x80, 0x89, 0xBC, 0xBD];
#[test]
fn single_char_trigger_byte_lens_match_utf8() {
for kind in [
TriggerKind::Bar,
TriggerKind::RubyOpen,
TriggerKind::RubyClose,
TriggerKind::AngleQuoteOpen,
TriggerKind::AngleQuoteClose,
TriggerKind::BracketOpen,
TriggerKind::BracketClose,
TriggerKind::Hash,
TriggerKind::RefMark,
TriggerKind::TortoiseOpen,
TriggerKind::TortoiseClose,
TriggerKind::QuoteOpen,
TriggerKind::QuoteClose,
] {
assert_eq!(kind.source_byte_len(), 3, "{kind:?}");
}
}
#[test]
fn classify_trigger_bytes_recognises_each_singleton() {
let cases: &[(&str, TriggerKind)] = &[
("|", TriggerKind::Bar),
("《", TriggerKind::RubyOpen),
("》", TriggerKind::RubyClose),
("≪", TriggerKind::AngleQuoteOpen),
("≫", TriggerKind::AngleQuoteClose),
("[", TriggerKind::BracketOpen),
("]", TriggerKind::BracketClose),
("#", TriggerKind::Hash),
("※", TriggerKind::RefMark),
("〔", TriggerKind::TortoiseOpen),
("〕", TriggerKind::TortoiseClose),
("「", TriggerKind::QuoteOpen),
("」", TriggerKind::QuoteClose),
];
for (s, expected) in cases {
let bytes = s.as_bytes();
assert_eq!(bytes.len(), 3, "trigger {s:?} must be 3 UTF-8 bytes");
let window: [u8; 3] = [bytes[0], bytes[1], bytes[2]];
assert_eq!(
classify_trigger_bytes(window),
Some(*expected),
"{s:?} should classify as {expected:?}"
);
}
}
#[test]
fn classify_trigger_bytes_returns_none_for_non_trigger() {
let bytes = "あ".as_bytes();
let window: [u8; 3] = [bytes[0], bytes[1], bytes[2]];
assert_eq!(classify_trigger_bytes(window), None);
}
#[test]
fn trigger_leading_bytes_are_complete_for_known_triggers() {
for entry_key in &ALL_TRIGGER_TRIGRAMS {
assert!(
TRIGGER_LEADING_BYTES.contains(&entry_key[0]),
"trigger byte sequence {entry_key:?} starts with {:#04X} \
which is not in TRIGGER_LEADING_BYTES — \
update the SIMD scanner mask",
entry_key[0]
);
}
}
#[test]
fn trigger_middle_bytes_are_complete_for_known_triggers() {
for entry_key in &ALL_TRIGGER_TRIGRAMS {
assert!(
TRIGGER_MIDDLE_BYTES.contains(&entry_key[1]),
"trigger {entry_key:?} middle byte {:#04X} not in TRIGGER_MIDDLE_BYTES",
entry_key[1]
);
}
}
#[test]
fn trigger_middle_bytes_has_no_redundant_entries() {
for &b in &TRIGGER_MIDDLE_BYTES {
let used = ALL_TRIGGER_TRIGRAMS.iter().any(|k| k[1] == b);
assert!(used, "middle byte {b:#04X} listed but unused");
}
}
#[test]
fn classify_match_and_trigram_array_cannot_drift() {
let mut kinds = HashSet::new();
for trigram in &ALL_TRIGGER_TRIGRAMS {
let kind = classify_trigger_bytes(*trigram)
.unwrap_or_else(|| panic!("{trigram:?} listed but classifies to None"));
assert!(kinds.insert(kind), "duplicate kind for {trigram:?}");
}
assert_eq!(kinds.len(), 13, "expected exactly 13 distinct triggers");
assert_eq!(ALL_TRIGGER_TRIGRAMS.len(), 13);
for &b0 in &TRIGGER_LEADING_BYTES {
for b1 in 0u8..=u8::MAX {
for b2 in 0u8..=u8::MAX {
let window = [b0, b1, b2];
if classify_trigger_bytes(window).is_some() {
assert!(
ALL_TRIGGER_TRIGRAMS.contains(&window),
"match accepts {window:?}, absent from ALL_TRIGGER_TRIGRAMS"
);
}
}
}
}
}
#[test]
fn trigger_leading_bytes_has_no_redundant_entries() {
for &b in &TRIGGER_LEADING_BYTES {
let used = ALL_TRIGGER_TRIGRAMS.iter().any(|k| k[0] == b);
assert!(
used,
"leading byte {b:#04X} listed in TRIGGER_LEADING_BYTES \
but no trigger uses it"
);
}
}
}