#![allow(clippy::missing_safety_doc)]
use std::fs::File;
use std::path::Path;
use std::slice;
use crate::autocorrect::{
key_slip_repaired_outputs, roman_repaired_outputs, FstCandidate, FstLexicon, FstLoanwordMatch,
FstRepairedBaseline, FstSuggestOptions, FstSuggestResult, LoanwordLexicon,
LoanwordSearchOptions, RomanRepairOptions, FST_MAX_LEVENSHTEIN_DISTANCE,
};
use crate::autosuggest::{
AutosuggestLm, AutosuggestOptions, AutosuggestSession, PersonalAutosuggestConfig,
PersonalAutosuggestTextSuggestion,
};
use crate::ObadhEngine;
pub const OBADH_ABI_VERSION: u32 = 2;
const AUTOCORRECT_POOL_LIMIT: usize = 24;
const AUTOCORRECT_RESPONSE_LIMIT: usize = 8;
unsafe fn input_str<'a>(ptr: *const u8, len: usize) -> Option<&'a str> {
if len == 0 {
return Some("");
}
if ptr.is_null() {
return None;
}
std::str::from_utf8(slice::from_raw_parts(ptr, len)).ok()
}
unsafe fn write_bytes(bytes: &[u8], out: *mut u8, cap: usize) -> usize {
if !out.is_null() && cap >= bytes.len() {
slice::from_raw_parts_mut(out, cap)[..bytes.len()].copy_from_slice(bytes);
}
bytes.len()
}
unsafe fn write_str_list(items: &[String], out: *mut u8, cap: usize) -> usize {
let mut packed = Vec::with_capacity(4 + items.iter().map(|s| 4 + s.len()).sum::<usize>());
packed.extend_from_slice(&(items.len() as u32).to_le_bytes());
for item in items {
packed.extend_from_slice(&(item.len() as u32).to_le_bytes());
packed.extend_from_slice(item.as_bytes());
}
write_bytes(&packed, out, cap)
}
unsafe fn write_detailed_list(candidates: &[FstCandidate], out: *mut u8, cap: usize) -> usize {
let mut packed = Vec::with_capacity(4 + candidates.len() * 21);
packed.extend_from_slice(&(candidates.len() as u32).to_le_bytes());
for candidate in candidates {
packed.extend_from_slice(&(candidate.text.len() as u32).to_le_bytes());
packed.extend_from_slice(candidate.text.as_bytes());
packed.push(candidate.source.stable_code());
packed.extend_from_slice(&candidate.edit_cost.to_le_bytes());
packed.extend_from_slice(&candidate.roman_repair_cost.unwrap_or(0xFFFF).to_le_bytes());
packed.extend_from_slice(&candidate.frequency.to_le_bytes());
}
write_bytes(&packed, out, cap)
}
#[no_mangle]
pub extern "C" fn obadh_abi_version() -> u32 {
OBADH_ABI_VERSION
}
#[no_mangle]
pub unsafe extern "C" fn obadh_engine_version(out: *mut u8, cap: usize) -> usize {
write_bytes(env!("CARGO_PKG_VERSION").as_bytes(), out, cap)
}
#[no_mangle]
pub extern "C" fn obadh_engine_new() -> *mut ObadhEngine {
Box::into_raw(Box::new(ObadhEngine::new()))
}
#[no_mangle]
pub unsafe extern "C" fn obadh_engine_free(engine: *mut ObadhEngine) {
if !engine.is_null() {
drop(Box::from_raw(engine));
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_transliterate(
engine: *const ObadhEngine,
input: *const u8,
input_len: usize,
out: *mut u8,
cap: usize,
) -> usize {
let (Some(engine), Some(input)) = (engine.as_ref(), input_str(input, input_len)) else {
return 0;
};
write_bytes(engine.transliterate(input).as_bytes(), out, cap)
}
#[no_mangle]
pub unsafe extern "C" fn obadh_transliterate_lenient(
engine: *const ObadhEngine,
input: *const u8,
input_len: usize,
out: *mut u8,
cap: usize,
) -> usize {
let (Some(engine), Some(input)) = (engine.as_ref(), input_str(input, input_len)) else {
return 0;
};
write_bytes(engine.transliterate_lenient(input).as_bytes(), out, cap)
}
pub struct ObadhAutocorrect {
engine: ObadhEngine,
lexicon: FstLexicon<memmap2::Mmap>,
loanwords: Option<LoanwordLexicon<Vec<u8>>>,
}
unsafe fn mmap_fst_lexicon(path: &str) -> Option<FstLexicon<memmap2::Mmap>> {
let file = File::open(Path::new(path)).ok()?;
let mmap = memmap2::MmapOptions::new().map(&file).ok()?;
Some(FstLexicon::from_map(fst::Map::new(mmap).ok()?))
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autocorrect_open(
fst_path: *const u8,
fst_path_len: usize,
loanword_path: *const u8,
loanword_path_len: usize,
) -> *mut ObadhAutocorrect {
let Some(fst_path) = input_str(fst_path, fst_path_len) else {
return std::ptr::null_mut();
};
let Some(lexicon) = mmap_fst_lexicon(fst_path) else {
return std::ptr::null_mut();
};
let loanwords = match input_str(loanword_path, loanword_path_len) {
Some(path) if !path.is_empty() => match std::fs::read(path) {
Ok(bytes) => match LoanwordLexicon::from_bytes(bytes) {
Ok(loanwords) => Some(loanwords),
Err(_) => return std::ptr::null_mut(),
},
Err(_) => return std::ptr::null_mut(),
},
Some(_) => None,
None => return std::ptr::null_mut(),
};
Box::into_raw(Box::new(ObadhAutocorrect {
engine: ObadhEngine::new(),
lexicon,
loanwords,
}))
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autocorrect_free(autocorrect: *mut ObadhAutocorrect) {
if !autocorrect.is_null() {
drop(Box::from_raw(autocorrect));
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autocorrect_fingerprint(
autocorrect: *const ObadhAutocorrect,
) -> u64 {
match autocorrect.as_ref() {
Some(autocorrect) => autocorrect.lexicon.artifact_fingerprint(),
None => 0,
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autocorrect_word_frequency(
autocorrect: *const ObadhAutocorrect,
word: *const u8,
word_len: usize,
) -> u64 {
let (Some(autocorrect), Some(word)) = (autocorrect.as_ref(), input_str(word, word_len)) else {
return 0;
};
autocorrect.lexicon.exact_frequency(word).unwrap_or(0)
}
impl ObadhAutocorrect {
fn suggest_result(&self, roman: &str) -> Option<FstSuggestResult> {
if roman.trim().is_empty() {
return None;
}
let baseline = self.engine.transliterate(roman);
let mut repairs =
roman_repaired_outputs(roman, &baseline, RomanRepairOptions::default(), |text| {
self.engine.transliterate(text)
});
repairs.extend(key_slip_repaired_outputs(
roman,
&baseline,
self.lexicon.exact_frequency(&baseline),
|text| self.engine.transliterate(text),
|word| self.lexicon.exact_frequency(word).is_some(),
));
let repaired_baselines = repairs
.iter()
.map(|repair| FstRepairedBaseline {
roman_input: repair.roman_input.as_str(),
bangla_output: repair.bangla_output.as_str(),
repair_kind: repair.repair_kind,
repair_cost: repair.repair_cost,
})
.collect::<Vec<_>>();
let loanword_suggestions = match &self.loanwords {
Some(loanwords) => loanwords
.suggestions(roman, LoanwordSearchOptions::for_input(roman))
.unwrap_or_default(),
None => Vec::new(),
};
let loanword_matches = loanword_suggestions
.iter()
.map(|entry| FstLoanwordMatch {
roman_input: roman,
roman_repair: entry.english.as_str(),
bangla_output: entry.bangla.as_str(),
frequency: entry.frequency,
repair_kind: entry.kind.as_str(),
repair_cost: entry.edit_cost,
})
.collect::<Vec<_>>();
let options = FstSuggestOptions {
max_distance: FST_MAX_LEVENSHTEIN_DISTANCE,
max_candidates: AUTOCORRECT_POOL_LIMIT,
response_candidates: AUTOCORRECT_RESPONSE_LIMIT,
max_prefix_candidates: AUTOCORRECT_RESPONSE_LIMIT,
..FstSuggestOptions::default()
};
self.lexicon
.suggest_with_repaired_baselines_and_loanwords(
&baseline,
&repaired_baselines,
&loanword_matches,
options,
)
.ok()
}
fn suggest_texts(&self, roman: &str, limit: usize) -> Vec<String> {
let limit = limit.clamp(1, AUTOCORRECT_RESPONSE_LIMIT);
match self.suggest_result(roman) {
Some(result) => result
.candidates
.into_iter()
.map(|candidate| candidate.text)
.take(limit)
.collect(),
None => Vec::new(),
}
}
fn compose_texts(&self, roman: &str, limit: usize) -> Vec<String> {
if roman.trim().is_empty() {
return Vec::new();
}
let limit = limit.clamp(1, AUTOCORRECT_RESPONSE_LIMIT);
let mut candidates = Vec::with_capacity(limit);
candidates.push(self.engine.transliterate(roman));
for candidate in self.suggest_texts(roman, limit.saturating_sub(1)) {
if candidates.len() >= limit {
break;
}
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
candidates
}
fn word_alternatives_texts(&self, word: &str, limit: usize) -> Vec<String> {
if word.trim().is_empty() {
return Vec::new();
}
let limit = limit.clamp(1, AUTOCORRECT_RESPONSE_LIMIT);
let options = FstSuggestOptions {
max_distance: FST_MAX_LEVENSHTEIN_DISTANCE,
max_candidates: AUTOCORRECT_POOL_LIMIT,
response_candidates: limit,
max_prefix_candidates: limit,
..FstSuggestOptions::default()
};
match self
.lexicon
.suggest_with_repaired_baselines_and_loanwords(word, &[], &[], options)
{
Ok(result) => result
.candidates
.into_iter()
.map(|candidate| candidate.text)
.take(limit)
.collect(),
Err(_) => Vec::new(),
}
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autocorrect_suggest_detailed(
autocorrect: *const ObadhAutocorrect,
roman: *const u8,
roman_len: usize,
limit: usize,
out: *mut u8,
cap: usize,
) -> usize {
let (Some(autocorrect), Some(roman)) = (autocorrect.as_ref(), input_str(roman, roman_len))
else {
return 0;
};
let Some(result) = autocorrect.suggest_result(roman) else {
return 0;
};
let limit = limit.clamp(1, AUTOCORRECT_RESPONSE_LIMIT);
let candidates: Vec<FstCandidate> = result.candidates.into_iter().take(limit).collect();
write_detailed_list(&candidates, out, cap)
}
#[no_mangle]
pub unsafe extern "C" fn obadh_compose_suggestions(
autocorrect: *const ObadhAutocorrect,
roman: *const u8,
roman_len: usize,
limit: usize,
out: *mut u8,
cap: usize,
) -> usize {
let (Some(autocorrect), Some(roman)) = (autocorrect.as_ref(), input_str(roman, roman_len))
else {
return 0;
};
write_str_list(&autocorrect.compose_texts(roman, limit), out, cap)
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autocorrect_word_alternatives(
autocorrect: *const ObadhAutocorrect,
word: *const u8,
word_len: usize,
limit: usize,
out: *mut u8,
cap: usize,
) -> usize {
let (Some(autocorrect), Some(word)) = (autocorrect.as_ref(), input_str(word, word_len)) else {
return 0;
};
write_str_list(&autocorrect.word_alternatives_texts(word, limit), out, cap)
}
pub struct ObadhAutosuggest {
session: AutosuggestSession<'static, memmap2::Mmap>,
_lm: Box<AutosuggestLm<memmap2::Mmap>>,
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_open(
path: *const u8,
path_len: usize,
) -> *mut ObadhAutosuggest {
let Some(path) = input_str(path, path_len) else {
return std::ptr::null_mut();
};
let Ok(lm) = AutosuggestLm::from_path(path) else {
return std::ptr::null_mut();
};
let lm = Box::new(lm);
let lm_ref: &'static AutosuggestLm<memmap2::Mmap> =
&*(lm.as_ref() as *const AutosuggestLm<memmap2::Mmap>);
let session = AutosuggestSession::with_personal_config(
lm_ref,
PersonalAutosuggestConfig::default(),
AutosuggestOptions { max_candidates: 8 },
);
Box::into_raw(Box::new(ObadhAutosuggest { session, _lm: lm }))
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_free(autosuggest: *mut ObadhAutosuggest) {
if !autosuggest.is_null() {
drop(Box::from_raw(autosuggest));
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_fingerprint(
autosuggest: *const ObadhAutosuggest,
) -> u64 {
match autosuggest.as_ref() {
Some(autosuggest) => autosuggest._lm.artifact_fingerprint(),
None => 0,
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_commit(
autosuggest: *mut ObadhAutosuggest,
token: *const u8,
token_len: usize,
) -> i32 {
let (Some(autosuggest), Some(token)) = (autosuggest.as_mut(), input_str(token, token_len))
else {
return 0;
};
let learned = autosuggest.session.commit_token(token).unwrap_or(false);
i32::from(learned)
}
impl ObadhAutosuggest {
fn session_suggestions(&mut self, limit: usize) -> Vec<String> {
self.session.set_options(AutosuggestOptions {
max_candidates: limit,
});
if self.session.suggest().is_err() {
return Vec::new();
}
self.session.suggest_personal_text();
let personal = self.session.personal_text_suggestions().to_vec();
let model: Vec<String> = self
.session
.candidates()
.iter()
.map(|candidate| candidate.text.to_string())
.collect();
let mut values = Vec::with_capacity(limit);
self.push_personal(&personal, true, limit, &mut values);
for candidate in model {
if values.len() >= limit {
break;
}
if !values.contains(&candidate) {
values.push(candidate);
}
}
self.push_personal(&personal, false, limit, &mut values);
values
}
fn push_personal(
&self,
suggestions: &[PersonalAutosuggestTextSuggestion],
contextual: bool,
limit: usize,
values: &mut Vec<String>,
) {
for suggestion in suggestions {
if values.len() >= limit {
break;
}
if (suggestion.context_len > 0) != contextual {
continue;
}
if let Some(text) = self.session.personal_text_suggestion_text(*suggestion) {
let text = text.to_string();
if !values.contains(&text) {
values.push(text);
}
}
}
}
fn context_suggestions(&self, context: &str, limit: usize) -> Vec<String> {
match self._lm.suggest_for_text(
context,
AutosuggestOptions {
max_candidates: limit,
},
) {
Ok(result) => result
.candidates
.into_iter()
.map(|candidate| candidate.text.to_string())
.take(limit)
.collect(),
Err(_) => Vec::new(),
}
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_suggest(
autosuggest: *mut ObadhAutosuggest,
limit: usize,
out: *mut u8,
cap: usize,
) -> usize {
let Some(autosuggest) = autosuggest.as_mut() else {
return 0;
};
write_str_list(
&autosuggest.session_suggestions(limit.clamp(1, 16)),
out,
cap,
)
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_suggest_for_context(
autosuggest: *const ObadhAutosuggest,
context: *const u8,
context_len: usize,
limit: usize,
out: *mut u8,
cap: usize,
) -> usize {
let (Some(autosuggest), Some(context)) =
(autosuggest.as_ref(), input_str(context, context_len))
else {
return 0;
};
write_str_list(
&autosuggest.context_suggestions(context, limit.clamp(1, 16)),
out,
cap,
)
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_clear_session(autosuggest: *mut ObadhAutosuggest) {
if let Some(autosuggest) = autosuggest.as_mut() {
autosuggest.session.clear_context();
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_clear_personal(autosuggest: *mut ObadhAutosuggest) {
if let Some(autosuggest) = autosuggest.as_mut() {
autosuggest.session.personal_mut().clear();
}
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_export_personal(
autosuggest: *const ObadhAutosuggest,
out: *mut u8,
cap: usize,
) -> usize {
let Some(autosuggest) = autosuggest.as_ref() else {
return 0;
};
let mut bytes = Vec::with_capacity(autosuggest.session.personal_snapshot_len());
autosuggest.session.write_personal_snapshot_into(&mut bytes);
write_bytes(&bytes, out, cap)
}
#[no_mangle]
pub unsafe extern "C" fn obadh_autosuggest_import_personal(
autosuggest: *mut ObadhAutosuggest,
input: *const u8,
input_len: usize,
) -> i32 {
if input.is_null() || input_len == 0 {
return 0;
}
let Some(autosuggest) = autosuggest.as_mut() else {
return 0;
};
let bytes = slice::from_raw_parts(input, input_len);
i32::from(autosuggest.session.import_personal_snapshot(bytes).is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
use std::ptr;
unsafe fn read_sized(mut writer: impl FnMut(*mut u8, usize) -> usize) -> Vec<u8> {
let needed = writer(ptr::null_mut(), 0);
let mut buffer = vec![0_u8; needed];
let written = writer(buffer.as_mut_ptr(), buffer.len());
assert_eq!(written, needed);
buffer
}
struct DetailedRecord {
text: String,
source: u8,
edit_cost: u16,
roman_repair_cost: u16,
frequency: u64,
}
fn parse_detailed_list(bytes: &[u8]) -> Vec<DetailedRecord> {
let count = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
let mut offset = 4;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
let text_len =
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
offset += 4;
let text = String::from_utf8(bytes[offset..offset + text_len].to_vec()).unwrap();
offset += text_len;
let source = bytes[offset];
offset += 1;
let edit_cost = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap());
offset += 2;
let roman_repair_cost =
u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap());
offset += 2;
let frequency = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap());
offset += 8;
out.push(DetailedRecord {
text,
source,
edit_cost,
roman_repair_cost,
frequency,
});
}
out
}
fn parse_str_list(bytes: &[u8]) -> Vec<String> {
let count = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
let mut offset = 4;
let mut items = Vec::with_capacity(count);
for _ in 0..count {
let len = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
offset += 4;
items.push(String::from_utf8(bytes[offset..offset + len].to_vec()).unwrap());
offset += len;
}
items
}
#[test]
fn abi_version_is_pinned() {
assert_eq!(obadh_abi_version(), OBADH_ABI_VERSION);
}
#[test]
fn engine_transliterates_through_the_snprintf_contract() {
let engine = obadh_engine_new();
let input = b"ami";
let output = unsafe {
read_sized(|out, cap| {
obadh_transliterate(engine, input.as_ptr(), input.len(), out, cap)
})
};
assert_eq!(String::from_utf8(output).unwrap(), "আমি");
unsafe { obadh_engine_free(engine) };
}
#[test]
fn engine_version_matches_the_crate() {
let bytes = unsafe { read_sized(|out, cap| obadh_engine_version(out, cap)) };
assert_eq!(String::from_utf8(bytes).unwrap(), env!("CARGO_PKG_VERSION"));
}
#[test]
fn str_list_round_trips_including_empty_and_newline() {
let items = vec!["আমি".to_string(), String::new(), "ভাত\nখাই".to_string()];
let bytes = unsafe { read_sized(|out, cap| write_str_list(&items, out, cap)) };
assert_eq!(parse_str_list(&bytes), items);
}
#[test]
fn input_str_handles_empty_null_and_invalid() {
unsafe {
assert_eq!(input_str(ptr::null(), 0), Some(""));
assert_eq!(input_str(ptr::null(), 4), None);
let valid = b"hi";
assert_eq!(input_str(valid.as_ptr(), valid.len()), Some("hi"));
let invalid = [0xff_u8, 0xfe];
assert_eq!(input_str(invalid.as_ptr(), invalid.len()), None);
}
}
#[test]
fn opening_a_missing_artifact_returns_null_not_a_crash() {
let path = b"/nonexistent/obadh/bn.fst";
let autocorrect =
unsafe { obadh_autocorrect_open(path.as_ptr(), path.len(), ptr::null(), 0) };
assert!(autocorrect.is_null());
let autosuggest = unsafe { obadh_autosuggest_open(path.as_ptr(), path.len()) };
assert!(autosuggest.is_null());
unsafe {
obadh_autocorrect_free(ptr::null_mut());
obadh_autosuggest_free(ptr::null_mut());
obadh_engine_free(ptr::null_mut());
}
}
#[test]
fn null_handles_are_safe_no_ops() {
let word = b"word";
unsafe {
assert_eq!(
obadh_transliterate(ptr::null(), word.as_ptr(), word.len(), ptr::null_mut(), 0),
0
);
assert_eq!(
obadh_autocorrect_word_frequency(ptr::null(), word.as_ptr(), word.len()),
0
);
assert_eq!(obadh_autocorrect_fingerprint(ptr::null()), 0);
assert_eq!(obadh_autosuggest_fingerprint(ptr::null()), 0);
}
}
fn write_temp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("obadh_cabi_{name}"));
std::fs::write(&path, bytes).expect("temp write");
path
}
fn temp_fst(name: &str, entries: &[(&str, u64)]) -> std::path::PathBuf {
let mut sorted = entries.to_vec();
sorted.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
let mut builder = fst::MapBuilder::memory();
for (word, frequency) in sorted {
builder.insert(word.as_bytes(), frequency).expect("insert");
}
write_temp(name, builder.into_map().as_fst().as_bytes())
}
#[test]
fn autocorrect_compose_puts_the_baseline_first_and_word_alternatives_work() {
let path = temp_fst("ac.fst", &[("বাংলা", 10_000), ("বাংলাদেশ", 8_000)]);
let path_bytes = path.to_str().unwrap().as_bytes();
let autocorrect = unsafe {
obadh_autocorrect_open(path_bytes.as_ptr(), path_bytes.len(), ptr::null(), 0)
};
assert!(!autocorrect.is_null());
unsafe {
let word = "বাংলা".as_bytes();
assert_eq!(
obadh_autocorrect_word_frequency(autocorrect, word.as_ptr(), word.len()),
10_000
);
let absent = "নেই".as_bytes();
assert_eq!(
obadh_autocorrect_word_frequency(autocorrect, absent.as_ptr(), absent.len()),
0
);
assert_ne!(obadh_autocorrect_fingerprint(autocorrect), 0);
let roman = b"bangla";
let packed = read_sized(|out, cap| {
obadh_compose_suggestions(autocorrect, roman.as_ptr(), roman.len(), 5, out, cap)
});
let composed = parse_str_list(&packed);
let baseline = ObadhEngine::new().transliterate("bangla");
assert_eq!(composed.first(), Some(&baseline));
let alternatives = read_sized(|out, cap| {
obadh_autocorrect_word_alternatives(
autocorrect,
word.as_ptr(),
word.len(),
5,
out,
cap,
)
});
assert!(!parse_str_list(&alternatives).is_empty());
obadh_autocorrect_free(autocorrect);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn word_frequency_reports_counts_presence_and_zero_for_absent_or_invalid() {
let path = temp_fst("wordfreq.fst", &[("মানুস", 49), ("মানুষ", 95_278)]);
let path_bytes = path.to_str().unwrap().as_bytes();
let autocorrect = unsafe {
obadh_autocorrect_open(path_bytes.as_ptr(), path_bytes.len(), ptr::null(), 0)
};
assert!(!autocorrect.is_null());
let freq =
|w: &str| unsafe { obadh_autocorrect_word_frequency(autocorrect, w.as_ptr(), w.len()) };
assert_eq!(freq("মানুস"), 49);
assert_eq!(freq("মানুষ"), 95_278);
assert!(freq("মানুষ") / freq("মানুস") >= 100);
assert_eq!(freq("নেই"), 0);
let bad = [0xFF, 0xFEu8];
assert_eq!(
unsafe { obadh_autocorrect_word_frequency(autocorrect, bad.as_ptr(), bad.len()) },
0
);
unsafe { obadh_autocorrect_free(autocorrect) };
let _ = std::fs::remove_file(path);
}
#[test]
fn detailed_wire_format_is_exhaustive_and_exact() {
use crate::autocorrect::FstCandidateSource::*;
let sources = [
(Exact, 0u8),
(EditDistance, 1),
(DiacriticEdit, 2),
(OrthographicVowelLengthEdit, 3),
(PrefixCompletion, 4),
(StemSuffixCompletion, 5),
(SkeletonVowelDrop, 6),
(ConsonantConfusion, 7),
(RomanRepairExact, 8),
(EnglishLoanwordExact, 9),
(EnglishLoanwordFuzzy, 10),
];
let candidates: Vec<FstCandidate> = sources
.iter()
.enumerate()
.map(|(i, (source, _))| FstCandidate {
text: if i == 0 {
String::new()
} else {
format!("শব্দ{i}")
},
source: *source,
edit_cost: i as u16,
frequency: (i as u64) * 1000,
score: 0,
roman_repair: None,
roman_repair_kind: None,
roman_repair_cost: if i % 2 == 1 { Some(i as u16) } else { None },
})
.collect();
let bytes = unsafe { read_sized(|out, cap| write_detailed_list(&candidates, out, cap)) };
let records = parse_detailed_list(&bytes);
assert_eq!(records.len(), candidates.len());
for (i, (record, candidate)) in records.iter().zip(&candidates).enumerate() {
assert_eq!(record.text, candidate.text, "text {i}");
assert_eq!(record.source, sources[i].1, "stable code {i}");
assert_eq!(record.edit_cost, candidate.edit_cost, "edit_cost {i}");
assert_eq!(record.frequency, candidate.frequency, "frequency {i}");
let expected_rrc = candidate.roman_repair_cost.unwrap_or(0xFFFF);
assert_eq!(
record.roman_repair_cost, expected_rrc,
"roman_repair_cost {i}"
);
}
let empty = unsafe { read_sized(|out, cap| write_detailed_list(&[], out, cap)) };
assert_eq!(empty, 0u32.to_le_bytes());
assert!(parse_detailed_list(&empty).is_empty());
let needed = unsafe { write_detailed_list(&candidates, ptr::null_mut(), 0) };
let mut small = vec![0u8; needed - 1];
let reported = unsafe { write_detailed_list(&candidates, small.as_mut_ptr(), small.len()) };
assert_eq!(
reported, needed,
"too-small buffer still reports needed length"
);
assert!(
small.iter().all(|&b| b == 0),
"too-small buffer is not written"
);
}
#[test]
fn suggest_detailed_records_parse_with_sane_fields() {
let path = temp_fst("detailed.fst", &[("বাংলা", 137381), ("বাংলাদেশ", 8000)]);
let path_bytes = path.to_str().unwrap().as_bytes();
let autocorrect = unsafe {
obadh_autocorrect_open(path_bytes.as_ptr(), path_bytes.len(), ptr::null(), 0)
};
assert!(!autocorrect.is_null());
unsafe {
let roman = b"bangla";
let packed = read_sized(|out, cap| {
obadh_autocorrect_suggest_detailed(
autocorrect,
roman.as_ptr(),
roman.len(),
5,
out,
cap,
)
});
let records = parse_detailed_list(&packed);
assert!(!records.is_empty());
for record in &records {
assert!(!record.text.is_empty());
assert!(record.source <= 10, "unknown source code {}", record.source);
let _ = (record.edit_cost, record.roman_repair_cost, record.frequency);
}
obadh_autocorrect_free(autocorrect);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn suggest_detailed_banhla_on_real_bn_fst() {
let fst_path = "data/autocorrect/models/bn.fst";
let loan_path = "data/autocorrect/models/en_bn_loanwords.fst";
if !std::path::Path::new(fst_path).exists() {
eprintln!("skip: {fst_path} not resolved");
return;
}
let fst_bytes = fst_path.as_bytes();
let loan_bytes = loan_path.as_bytes();
let autocorrect = unsafe {
obadh_autocorrect_open(
fst_bytes.as_ptr(),
fst_bytes.len(),
loan_bytes.as_ptr(),
loan_bytes.len(),
)
};
assert!(!autocorrect.is_null());
unsafe {
let roman = b"banhla";
let packed = read_sized(|out, cap| {
obadh_autocorrect_suggest_detailed(
autocorrect,
roman.as_ptr(),
roman.len(),
5,
out,
cap,
)
});
let records = parse_detailed_list(&packed);
let top = &records[0];
assert_eq!(top.text, "বাংলা");
assert_eq!(top.source, 8, "expected roman_repair_exact"); assert_eq!(
top.roman_repair_cost, 2,
"the real roman-side cost is 2, not 1"
);
assert!(top.frequency > 100_000, "বাংলা is a very common word");
obadh_autocorrect_free(autocorrect);
}
}
#[test]
fn suggest_detailed_is_robust_over_real_bn_fst() {
let fst_path = "data/autocorrect/models/bn.fst";
let loan_path = "data/autocorrect/models/en_bn_loanwords.fst";
if !std::path::Path::new(fst_path).exists() {
eprintln!("skip: {fst_path} not resolved");
return;
}
let fst_bytes = fst_path.as_bytes();
let loan_bytes = loan_path.as_bytes();
let autocorrect = unsafe {
obadh_autocorrect_open(
fst_bytes.as_ptr(),
fst_bytes.len(),
loan_bytes.as_ptr(),
loan_bytes.len(),
)
};
assert!(!autocorrect.is_null());
let mut inputs: Vec<String> = vec![
"".into(),
" ".into(),
" ".into(),
"a".into(),
"banhla".into(),
"manus".into(),
"bondu".into(),
"sriti".into(),
"computer".into(),
"bigyan".into(),
"123".into(),
"!!!".into(),
"a,b,c".into(),
"কম্পিউটার".into(),
"日本".into(),
"aaaaaaaaaaaaaaaa".into(),
];
let alphabet: &[u8] = b"aAiIuUeEoOkgcjtTdDnpbmyrlshHNzwxRSC.,-0123456789 ";
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
for _ in 0..600 {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let len = 1 + (state >> 40) as usize % 16;
let word: String = (0..len)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
alphabet[(state >> 40) as usize % alphabet.len()] as char
})
.collect();
inputs.push(word);
}
let mut total_records = 0usize;
for input in &inputs {
let bytes = input.as_bytes();
let packed = unsafe {
read_sized(|out, cap| {
obadh_autocorrect_suggest_detailed(
autocorrect,
bytes.as_ptr(),
bytes.len(),
5,
out,
cap,
)
})
};
if packed.is_empty() {
continue; }
let records = parse_detailed_list(&packed);
for record in &records {
assert!(
record.source <= 10,
"{input:?}: bad source {}",
record.source
);
}
total_records += records.len();
}
assert!(
total_records > 0,
"the sweep should produce some corrections"
);
unsafe { obadh_autocorrect_free(autocorrect) };
}
#[test]
fn autosuggest_learns_a_word_and_surfaces_it_through_the_abi() {
use crate::autosuggest::artifact::test_support::{build_fixture, Row};
let tokens = ["<pad>", "<bos>", "<unk>", "আমি", "আজ", "ভাত", "খাই"];
let fixture = build_fixture(
&tokens,
&[(5, 100, 100), (6, 90, 90)],
&[Row {
context: vec![3],
candidates: vec![(6, 20, 20), (5, 10, 10)],
}],
);
let path = write_temp("as.bin", &fixture);
let path_bytes = path.to_str().unwrap().as_bytes();
let autosuggest = unsafe { obadh_autosuggest_open(path_bytes.as_ptr(), path_bytes.len()) };
assert!(!autosuggest.is_null());
unsafe {
let name = "নাসির".as_bytes();
assert_eq!(
obadh_autosuggest_commit(autosuggest, name.as_ptr(), name.len()),
1
);
let _ = read_sized(|out, cap| obadh_autosuggest_suggest(autosuggest, 5, out, cap));
let context = "আমি".as_bytes();
let _ = read_sized(|out, cap| {
obadh_autosuggest_suggest_for_context(
autosuggest,
context.as_ptr(),
context.len(),
5,
out,
cap,
)
});
obadh_autosuggest_free(autosuggest);
}
let _ = std::fs::remove_file(path);
}
#[test]
fn c_header_matches_the_exported_surface() {
let source = include_str!("cabi.rs");
let header = include_str!("../include/obadh.h");
let marker = "extern \"C\" fn ";
let mut missing = Vec::new();
for line in source.lines() {
let Some(index) = line.find(marker) else {
continue;
};
let name: String = line[index + marker.len()..]
.chars()
.take_while(|character| character.is_alphanumeric() || *character == '_')
.collect();
if name.starts_with("obadh_") && !header.contains(&name) {
missing.push(name);
}
}
assert!(
missing.is_empty(),
"C header is missing declarations for: {missing:?}"
);
assert!(
header.contains(&format!("#define OBADH_ABI_VERSION {OBADH_ABI_VERSION}")),
"C header ABI version does not match OBADH_ABI_VERSION"
);
}
}