use fst::automaton::Automaton;
const NUKTA: char = '\u{09BC}';
fn consonant_class(ch: char, nukta: bool) -> Option<u8> {
if nukta {
return Some(match ch {
'ড' => b'R', 'ঢ' => b'R', 'য' => b'y', _ => return consonant_class(ch, false),
});
}
Some(match ch {
'ক' => b'k',
'খ' => b'K',
'গ' => b'g',
'ঘ' => b'G',
'ঙ' => b'Y',
'চ' => b'c',
'ছ' => b'C',
'জ' | 'য' => b'j', 'ঝ' => b'J',
'ঞ' => b'V',
'ট' => b'T',
'ঠ' => b'U',
'ড' => b'D',
'ঢ' => b'E',
'ত' | 'ৎ' => b't', 'থ' => b'W',
'দ' => b'd',
'ধ' => b'F',
'ণ' | 'ন' => b'n',
'প' => b'p',
'ফ' => b'P',
'ব' => b'b',
'ভ' => b'B',
'ম' => b'm',
'র' => b'r',
'\u{09DC}' | '\u{09DD}' => b'R', 'ল' => b'l',
'শ' | 'ষ' => b's',
'স' => b'S',
'হ' => b'h',
'\u{09DF}' => b'y', _ => return None,
})
}
pub const MIN_SKELETON_LEN: usize = 2;
pub const MAX_SKELETON_LEN: usize = 12;
pub fn consonant_skeleton(word: &str) -> String {
let mut skeleton = Vec::with_capacity(word.len() / 3 + 1);
let mut chars = word.chars().peekable();
while let Some(ch) = chars.next() {
let nukta = chars.peek() == Some(&NUKTA);
if nukta {
chars.next(); }
if let Some(class) = consonant_class(ch, nukta) {
skeleton.push(class);
}
}
String::from_utf8(skeleton).unwrap_or_default()
}
pub fn is_indexable_skeleton(skeleton: &str) -> bool {
(MIN_SKELETON_LEN..=MAX_SKELETON_LEN).contains(&skeleton.len())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkeletonMatch {
pub word: String,
pub frequency: u64,
}
pub struct SkeletonAutomaton {
target: Vec<u8>,
}
impl SkeletonAutomaton {
pub fn for_baseline(baseline: &str) -> Option<Self> {
let skeleton = consonant_skeleton(baseline);
if !is_indexable_skeleton(&skeleton) {
return None;
}
Some(Self {
target: skeleton.into_bytes(),
})
}
fn commit(&self, mut state: SkeletonState, ch: char, nukta: bool) -> SkeletonState {
match consonant_class(ch, nukta) {
Some(class) => {
if state.matched < self.target.len() && class == self.target[state.matched] {
state.matched += 1;
state.held = None;
} else {
state.dead = true;
}
}
None => state.held = None,
}
state
}
fn on_char(&self, mut state: SkeletonState, ch: char) -> SkeletonState {
if ch == NUKTA {
if let Some(held) = state.held.take() {
return self.commit(state, held, true);
}
return state;
}
if let Some(held) = state.held.take() {
state = self.commit(state, held, false);
if state.dead {
return state;
}
}
if consonant_class(ch, false).is_some() {
state.held = Some(ch);
}
state
}
fn trailing_matched(&self, state: &SkeletonState) -> Option<usize> {
match state.held {
None => Some(state.matched),
Some(held) => match consonant_class(held, false) {
Some(class)
if state.matched < self.target.len()
&& class == self.target[state.matched] =>
{
Some(state.matched + 1)
}
Some(_) => None, None => Some(state.matched),
},
}
}
}
#[derive(Clone)]
pub struct SkeletonState {
matched: usize,
held: Option<char>,
pending: [u8; 4],
pending_len: u8,
expected_len: u8,
dead: bool,
}
impl SkeletonState {
fn dead(&self) -> Self {
let mut next = self.clone();
next.dead = true;
next
}
}
impl Automaton for SkeletonAutomaton {
type State = SkeletonState;
fn start(&self) -> Self::State {
SkeletonState {
matched: 0,
held: None,
pending: [0; 4],
pending_len: 0,
expected_len: 0,
dead: false,
}
}
fn is_match(&self, state: &Self::State) -> bool {
if state.dead || state.pending_len != 0 {
return false;
}
self.trailing_matched(state) == Some(self.target.len())
}
fn can_match(&self, state: &Self::State) -> bool {
!state.dead
}
fn accept(&self, state: &Self::State, byte: u8) -> Self::State {
if state.dead {
return state.clone();
}
if state.pending_len == 0 {
if byte <= 0x7f {
return self.on_char(state.clone(), byte as char);
}
let Some(expected_len) = utf8_expected_len(byte) else {
return state.dead();
};
let mut next = state.clone();
next.pending = [0; 4];
next.pending[0] = byte;
next.pending_len = 1;
next.expected_len = expected_len;
return next;
}
if !is_utf8_continuation(byte) {
return state.dead();
}
let mut next = state.clone();
next.pending[next.pending_len as usize] = byte;
next.pending_len += 1;
if next.pending_len < next.expected_len {
return next;
}
let bytes = &next.pending[..next.pending_len as usize];
let Ok(decoded) = std::str::from_utf8(bytes) else {
return state.dead();
};
let Some(ch) = decoded.chars().next() else {
return state.dead();
};
next.pending = [0; 4];
next.pending_len = 0;
next.expected_len = 0;
self.on_char(next, ch)
}
}
fn utf8_expected_len(byte: u8) -> Option<u8> {
match byte {
0xc2..=0xdf => Some(2),
0xe0..=0xef => Some(3),
0xf0..=0xf4 => Some(4),
_ => None,
}
}
fn is_utf8_continuation(byte: u8) -> bool {
matches!(byte, 0x80..=0xbf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_and_target_share_a_skeleton() {
assert_eq!(consonant_skeleton("ক্রল্ম"), consonant_skeleton("করলাম"));
assert_eq!(consonant_skeleton("দখলাম"), consonant_skeleton("দেখলাম"));
assert_eq!(consonant_skeleton("ত্মক"), consonant_skeleton("তোমাকে"));
}
#[test]
fn folds_only_identical_phonemes() {
assert_eq!(consonant_skeleton("শ"), consonant_skeleton("ষ")); assert_eq!(consonant_skeleton("জ"), consonant_skeleton("য")); assert_eq!(consonant_skeleton("ণ"), consonant_skeleton("ন")); assert_eq!(consonant_skeleton("ড়"), consonant_skeleton("ঢ়")); assert_eq!(consonant_skeleton("মানুষ"), consonant_skeleton("মানুশ"));
}
#[test]
fn nearby_and_distinct_phonemes_stay_distinct() {
assert_ne!(consonant_skeleton("স"), consonant_skeleton("শ")); assert_ne!(consonant_skeleton("র"), consonant_skeleton("ড়")); assert_ne!(consonant_skeleton("ট"), consonant_skeleton("ত")); assert_ne!(consonant_skeleton("ড"), consonant_skeleton("দ"));
assert_ne!(consonant_skeleton("ক"), consonant_skeleton("খ")); assert_ne!(consonant_skeleton("প"), consonant_skeleton("ফ"));
assert_ne!(consonant_skeleton("জ"), consonant_skeleton("ঝ")); }
#[test]
fn vowels_signs_hasant_and_marks_are_transparent() {
assert_eq!(consonant_skeleton("আওই"), "");
assert_eq!(consonant_skeleton("কাজ"), consonant_skeleton("কজ"));
}
#[test]
fn length_band_excludes_singletons_and_overlong() {
assert!(!is_indexable_skeleton("k"));
assert!(is_indexable_skeleton("kj"));
}
fn shares_skeleton(baseline: &str, word: &str) -> bool {
consonant_skeleton(baseline) == consonant_skeleton(word)
&& is_indexable_skeleton(&consonant_skeleton(baseline))
}
fn automaton_accepts(baseline: &str, word: &str) -> bool {
let Some(automaton) = SkeletonAutomaton::for_baseline(baseline) else {
return false;
};
let mut state = automaton.start();
for &byte in word.as_bytes() {
if !automaton.can_match(&state) {
return false;
}
state = automaton.accept(&state, byte);
}
automaton.is_match(&state)
}
#[test]
fn automaton_matches_skeleton_mates_and_rejects_others() {
let cases = [
("ক্রল্ম", "করলাম", true), ("করলাম", "করলাম", true), ("দখলাম", "দেখলাম", true),
("মানুশ", "মানুষ", true), ("কর", "করা", true), ("কর", "করান", false), ("কর", "কম", false), ("কর", "আকর", true), ];
for (baseline, word, expected) in cases {
assert_eq!(
automaton_accepts(baseline, word),
expected,
"automaton({baseline}, {word}) should be {expected}"
);
if is_indexable_skeleton(&consonant_skeleton(baseline)) {
assert_eq!(
automaton_accepts(baseline, word),
shares_skeleton(baseline, word),
"automaton disagrees with skeleton equality for ({baseline}, {word})"
);
}
}
}
#[test]
fn automaton_folds_nukta_like_the_skeleton() {
assert!(automaton_accepts("বড়", "বড়"));
assert!(automaton_accepts("বড়", "বড়ি"));
assert!(!automaton_accepts("বর", "বড়"));
}
}