use super::lookup::hash_word;
use super::transpose::{transpose_alphabet, TransposeConfig};
use super::{N_HASH_DICT, RULE_GROUP_END, RULE_GROUP_START, RULE_PHONEMES};
use crate::phoneme::load::PhonemeData;
pub fn parse_list_dsl(source: &str, phdata: &PhonemeData) -> Vec<ListEntry> {
let mut out = Vec::new();
for line in source.lines() {
let line = line.split("//").next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace().peekable();
let mut flags: Vec<u8> = Vec::new();
while let Some(cond) = parts.peek().and_then(|t| condition_flag_byte(t)) {
flags.push(cond);
parts.next();
}
let Some(word) = parts.next() else {
continue;
};
let mut mnemonics: Option<&str> = None;
for tok in parts {
if tok.starts_with('$') {
continue; }
if mnemonics.is_none() {
mnemonics = Some(tok);
}
}
let codes = mnemonics
.map(|m| crate::translate::parse_inline_phonemes(m, phdata))
.unwrap_or_default();
out.push(ListEntry { word: word.to_string(), phonemes: codes, flags });
}
out
}
fn condition_flag_byte(tok: &str) -> Option<u8> {
let rest = tok.strip_prefix('?')?;
let (base, num) = match rest.strip_prefix('!') {
Some(n) => (132u8, n),
None => (100u8, rest),
};
let cond: u8 = num.parse().ok()?;
(cond < 32).then_some(base + cond)
}
#[derive(Debug, Clone, Default)]
pub struct Rule {
pub match_letters: String,
pub pre_context: String,
pub post_context: String,
pub phonemes: Vec<u8>,
}
impl Rule {
pub fn new(match_letters: &str, phonemes: &[u8]) -> Self {
Rule { match_letters: match_letters.to_string(), phonemes: phonemes.to_vec(), ..Default::default() }
}
}
#[derive(Debug, Clone)]
pub struct RuleGroup {
pub name: String,
pub rules: Vec<Rule>,
}
pub fn compile_dictionary(
dir: &std::path::Path,
lang: &str,
phdata: &PhonemeData,
transpose: &TransposeConfig,
) -> Result<Vec<u8>, String> {
let list_src = std::fs::read_to_string(dir.join(format!("{lang}_list"))).unwrap_or_default();
let rules_src = std::fs::read_to_string(dir.join(format!("{lang}_rules"))).unwrap_or_default();
let entries = parse_list_dsl(&list_src, phdata);
let groups = parse_rules_dsl(&rules_src, phdata);
let replace = parse_replace_dsl(&rules_src);
let rules = if groups.is_empty() {
vec![RULE_GROUP_END]
} else {
compile_rules(&groups)
};
compile_dict_full(&entries, transpose, &rules, &replace)
}
pub fn parse_replace_dsl(source: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
let mut in_replace = false;
for line in source.lines() {
let line = line.split("//").next().unwrap_or("");
let trimmed = line.trim();
if trimmed == ".replace" {
in_replace = true;
continue;
}
if trimmed.starts_with('.') {
in_replace = false;
continue;
}
if !in_replace || trimmed.is_empty() {
continue;
}
let mut parts = trimmed.split_whitespace();
if let (Some(from), Some(to)) = (parts.next(), parts.next()) {
pairs.push((from.to_string(), to.to_string()));
}
}
pairs
}
pub fn parse_rules_dsl(source: &str, phdata: &PhonemeData) -> Vec<RuleGroup> {
let mut groups: Vec<RuleGroup> = Vec::new();
for line in source.lines() {
let line = line.split("//").next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if let Some(name) = line.strip_prefix(".group") {
groups.push(RuleGroup { name: name.trim().to_string(), rules: Vec::new() });
continue;
}
if line.starts_with('.') {
continue;
}
let Some(group) = groups.last_mut() else { continue };
let mut parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
continue;
}
let mnemonics = parts.pop().unwrap();
let letter_spec: String = parts.concat();
let (pre, rest) = letter_spec.split_once(')').unwrap_or(("", letter_spec.as_str()));
let (matched, post) = rest.split_once('(').unwrap_or((rest, ""));
let match_letters = matched.strip_prefix(group.name.as_str()).unwrap_or(matched).to_string();
let phonemes = crate::translate::parse_inline_phonemes(mnemonics, phdata);
group.rules.push(Rule {
match_letters,
pre_context: pre.to_string(),
post_context: post.to_string(),
phonemes,
});
}
groups
}
fn encode_context(s: &str, reverse: bool) -> Vec<u8> {
let mut tokens: Vec<Vec<u8>> = s
.bytes()
.map(|c| match c {
b'_' => vec![super::RULE_SPACE],
b'A'..=b'Z' => vec![super::RULE_LETTERGP, c],
other => vec![other],
})
.collect();
if reverse {
tokens.reverse();
}
tokens.into_iter().flatten().collect()
}
pub fn compile_rules(groups: &[RuleGroup]) -> Vec<u8> {
let mut out = Vec::new();
for g in groups {
out.push(RULE_GROUP_START);
out.extend_from_slice(g.name.as_bytes());
out.push(0); for r in &g.rules {
out.extend_from_slice(r.match_letters.as_bytes());
if !r.pre_context.is_empty() {
out.push(super::RULE_PRE);
out.extend(encode_context(&r.pre_context, true));
}
if !r.post_context.is_empty() {
out.push(super::RULE_POST);
out.extend(encode_context(&r.post_context, false));
}
out.push(RULE_PHONEMES);
out.extend_from_slice(&r.phonemes);
out.push(0); }
out.push(RULE_GROUP_END);
}
out
}
#[derive(Debug, Clone)]
pub struct ListEntry {
pub word: String,
pub phonemes: Vec<u8>,
pub flags: Vec<u8>,
}
impl ListEntry {
pub fn new(word: &str, phonemes: &[u8]) -> Self {
ListEntry { word: word.to_string(), phonemes: phonemes.to_vec(), flags: Vec::new() }
}
}
pub fn compile_dict(entries: &[ListEntry], transpose: &TransposeConfig) -> Result<Vec<u8>, String> {
compile_dict_with_rules(entries, transpose, &[RULE_GROUP_END])
}
pub fn compile_dict_with_rules(
entries: &[ListEntry],
transpose: &TransposeConfig,
rules: &[u8],
) -> Result<Vec<u8>, String> {
compile_dict_full(entries, transpose, rules, &[])
}
fn compile_replace_section(pairs: &[(String, String)], rules_offset: usize) -> Vec<u8> {
let mut out = vec![RULE_GROUP_START, super::RULE_REPLACEMENTS];
let aligned = ((rules_offset + 1 + 4) & !3) - rules_offset; while out.len() < aligned {
out.push(0);
}
for (from, to) in pairs {
out.extend_from_slice(from.as_bytes());
out.push(0);
out.extend_from_slice(to.as_bytes());
out.push(0);
}
out.extend_from_slice(&[0, 0, 0, 0]); out.push(RULE_GROUP_END);
out
}
pub fn compile_dict_full(
entries: &[ListEntry],
transpose: &TransposeConfig,
rules: &[u8],
replace: &[(String, String)],
) -> Result<Vec<u8>, String> {
let mut buckets: Vec<Vec<u8>> = vec![Vec::new(); N_HASH_DICT];
for e in entries {
let t = transpose_alphabet(&e.word, transpose);
let compressed = &t.bytes;
let wlen = t.wlen;
let ix = compressed.len();
let mut hash_buf = compressed.clone();
let wb = e.word.as_bytes();
if ix < wb.len() {
hash_buf.extend_from_slice(&wb[ix..]);
}
let hash = hash_word(&hash_buf);
let no_phonemes = e.phonemes.is_empty();
let word_info = (wlen & 0x7f) | if no_phonemes { 0x80 } else { 0 };
let mut entry = vec![0u8, word_info]; entry.extend_from_slice(compressed);
if !no_phonemes {
entry.extend_from_slice(&e.phonemes);
entry.push(0); }
entry.extend_from_slice(&e.flags);
if entry.len() > u8::MAX as usize {
return Err(format!("dict entry for {:?} too long ({} bytes)", e.word, entry.len()));
}
entry[0] = entry.len() as u8;
buckets[hash].extend_from_slice(&entry);
}
let mut out = Vec::new();
out.extend_from_slice(&(N_HASH_DICT as u32).to_le_bytes()); out.extend_from_slice(&[0u8; 4]); for bucket in &buckets {
out.extend_from_slice(bucket);
out.push(0); }
let rules_offset = out.len();
if !replace.is_empty() {
out.extend(compile_replace_section(replace, rules_offset));
}
out.extend_from_slice(rules); out[4..8].copy_from_slice(&(rules_offset as u32).to_le_bytes());
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dictionary::file::Dictionary;
use crate::dictionary::lookup::{lookup, LookupCtx};
#[test]
fn round_trip_list_entries() {
let entries = vec![
ListEntry::new("hello", &[72, 108, 60]),
ListEntry::new("world", &[50, 51, 52, 53]),
ListEntry::new("a", &[147]),
ListEntry::new("cat", &[75, 97, 116]),
];
let bytes = compile_dict(&entries, &TransposeConfig::LATIN).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let ctx = LookupCtx { lookup_symbol: true, ..Default::default() };
for e in &entries {
let r = lookup(&dict, &e.word, &ctx)
.unwrap_or_else(|| panic!("compiled entry {:?} not found", e.word));
assert!(r.flags1.found(), "{:?} should be FLAG_FOUND", e.word);
assert_eq!(r.phonemes, e.phonemes, "phonemes for {:?}", e.word);
}
assert!(lookup(&dict, "zzqxyw", &ctx).is_none());
}
#[test]
fn list_dsl_parse_compile_read() {
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
eprintln!("[SKIP] no local phoneme data");
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let source = "\
// a small word list
hello h@l'oU
world w'3:ld
";
let entries = parse_list_dsl(source, &phdata);
assert_eq!(entries.len(), 2, "comment + 2 words");
assert_eq!(entries[0].word, "hello");
assert!(!entries[0].phonemes.is_empty(), "mnemonics resolved to codes");
let bytes = compile_dict(&entries, &TransposeConfig::LATIN).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let r = lookup(&dict, "hello", &LookupCtx::default()).expect("'hello' found");
assert_eq!(r.phonemes, entries[0].phonemes, "round-trip phonemes");
}
#[test]
fn rules_dsl_parse_and_compile() {
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
eprintln!("[SKIP] no local phoneme data");
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let source = "\
// two groups with simple rules
.group a
\ta\t@
\tat\teI
.group b
\tb\tb
";
let groups = parse_rules_dsl(source, &phdata);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].name, "a");
assert_eq!(groups[0].rules.len(), 2);
assert_eq!(groups[0].rules[0].match_letters, ""); assert_eq!(groups[0].rules[1].match_letters, "t"); assert!(!groups[0].rules[0].phonemes.is_empty(), "mnemonics resolved");
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
assert!(dict.groups.groups1[b'a' as usize].is_some(), "group 'a' registered");
assert!(dict.groups.groups1[b'b' as usize].is_some(), "group 'b' registered");
}
#[test]
fn compile_dictionary_from_source_files() {
let data = std::path::PathBuf::from("espeak-ng-data");
if !data.join("phontab").exists() {
eprintln!("[SKIP] no local phoneme data");
return;
}
let mut phdata = PhonemeData::load(&data).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let src = std::env::temp_dir().join("espeak_rs_dictsource");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("tx_list"), "hello\th@l'oU\nworld\tw'3:ld\n").unwrap();
std::fs::write(src.join("tx_rules"), ".group a\n\ta\t@\n").unwrap();
let bytes = compile_dictionary(&src, "tx", &phdata, &TransposeConfig::LATIN).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let r = lookup(&dict, "hello", &LookupCtx::default()).expect("'hello' found");
assert!(!r.phonemes.is_empty(), "list phonemes empty");
assert!(dict.groups.groups1[b'a' as usize].is_some(), "rule group 'a' registered");
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn compiled_rule_actually_translates() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
eprintln!("[SKIP] no local phoneme data");
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
assert!(s_code != 0, "'s' phoneme should exist");
let groups = vec![RuleGroup {
name: "t".into(),
rules: vec![Rule::new("", &[s_code])],
}];
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let stress = StressOpts::for_lang("en");
let opts = LangOptions::for_lang("en");
let wr = word_to_phonemes("t", &dict, &phdata, &stress, &opts);
assert!(
wr.phonemes.contains(&s_code),
"compiled rule did not fire (phonemes = {:?})",
wr.phonemes
);
}
#[test]
fn rules_dsl_parses_context_syntax() {
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let source = ".group a\n\tk)a\t@\n\ta(b\ts\n";
let groups = parse_rules_dsl(source, &phdata);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].rules.len(), 2);
assert_eq!(groups[0].rules[0].pre_context, "k");
assert_eq!(groups[0].rules[0].match_letters, ""); assert_eq!(groups[0].rules[1].post_context, "b");
assert_eq!(groups[0].rules[1].match_letters, "");
}
#[test]
fn rules_dsl_reads_phonemes_from_last_column() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
let b_code = phdata.lookup_phoneme("b");
let source = ".group a\n\t_) a (_\ts\n\tS) a\tb\n\ta\tb\n";
let groups = parse_rules_dsl(source, &phdata);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].rules[0].pre_context, "_");
assert_eq!(groups[0].rules[0].post_context, "_");
assert_eq!(groups[0].rules[0].phonemes, vec![s_code], "phonemes from last column");
assert_eq!(groups[0].rules[2].phonemes, vec![b_code]);
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let stress = StressOpts::for_lang("en");
let opts = LangOptions::for_lang("en");
let hit = word_to_phonemes("a", &dict, &phdata, &stress, &opts);
assert!(hit.phonemes.contains(&s_code), "whole-word 'a' → /s/: {:?}", hit.phonemes);
}
#[test]
fn list_entry_dollar_flags_are_not_phonemes() {
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let flags_only = parse_list_dsl("gxis\t$u+ $pause\n", &phdata);
assert_eq!(flags_only.len(), 1);
assert!(flags_only[0].phonemes.is_empty(), "flags-only entry must have no phonemes");
let with_ph = parse_list_dsl("cat\tk'at\t$verbf\n", &phdata);
assert!(!with_ph[0].phonemes.is_empty(), "phonemes parsed when present");
}
#[test]
fn list_entry_condition_prefix_gates_lookup() {
use crate::dictionary::lookup::{lookup, LookupCtx};
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let entries = parse_list_dsl("?4 gramx\tk'at\n", &phdata);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].word, "gramx", "the word is `gramx`, not `?4`");
assert_eq!(entries[0].flags, vec![104], "?4 → flag byte 100+4");
let bytes = compile_dict(&entries, &TransposeConfig::LATIN).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
assert!(lookup(&dict, "gramx", &LookupCtx::default()).is_none(), "gated off by default");
let ctx = LookupCtx { dict_condition: 1 << 4, ..Default::default() };
assert!(lookup(&dict, "gramx", &ctx).is_some(), "matches when condition 4 set");
}
#[test]
fn replace_section_compiles_decodes_and_applies() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let dir = std::path::PathBuf::from("espeak-ng-data");
if !dir.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&dir).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
let src = ".replace\n\tz\tq\n.group q\n\tq\ts\n";
let replace = parse_replace_dsl(src);
assert_eq!(replace, vec![("z".to_string(), "q".to_string())]);
let groups = parse_rules_dsl(src, &phdata);
let rules = compile_rules(&groups);
let bytes = compile_dict_full(&[], &TransposeConfig::LATIN, &rules, &replace).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
assert_eq!(dict.replace_pairs.len(), 1);
let opts = LangOptions::for_lang("en");
let stress = StressOpts::for_lang("en");
let out = word_to_phonemes("z", &dict, &phdata, &stress, &opts);
assert!(out.phonemes.contains(&s_code), "z→q→/s/ via .replace: {:?}", out.phonemes);
}
#[test]
fn compiled_rule_post_context_gates_match() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let data = std::path::PathBuf::from("espeak-ng-data");
if !data.join("phontab").exists() {
eprintln!("[SKIP] no local phoneme data");
return;
}
let mut phdata = PhonemeData::load(&data).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
let rule = Rule {
post_context: "b".into(),
phonemes: vec![s_code],
..Default::default()
};
let groups = vec![RuleGroup { name: "a".into(), rules: vec![rule] }];
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let stress = StressOpts::for_lang("en");
let opts = LangOptions::for_lang("en");
let hit = word_to_phonemes("ab", &dict, &phdata, &stress, &opts);
assert!(hit.phonemes.contains(&s_code), "post-context rule should fire on 'ab': {:?}", hit.phonemes);
let miss = word_to_phonemes("ax", &dict, &phdata, &stress, &opts);
assert!(!miss.phonemes.contains(&s_code), "post-context rule wrongly fired on 'ax': {:?}", miss.phonemes);
}
#[test]
fn compiled_rule_pre_context_gates_match() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let data = std::path::PathBuf::from("espeak-ng-data");
if !data.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&data).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
let k_code = phdata.lookup_phoneme("k");
let b_code = phdata.lookup_phoneme("b");
let groups = vec![
RuleGroup {
name: "a".into(),
rules: vec![Rule { pre_context: "k".into(), phonemes: vec![s_code], ..Default::default() }],
},
RuleGroup { name: "k".into(), rules: vec![Rule::new("", &[k_code])] },
RuleGroup { name: "b".into(), rules: vec![Rule::new("", &[b_code])] },
];
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let stress = StressOpts::for_lang("en");
let opts = LangOptions::for_lang("en");
let hit = word_to_phonemes("ka", &dict, &phdata, &stress, &opts);
assert!(hit.phonemes.contains(&s_code), "pre-context rule should fire on 'ka': {:?}", hit.phonemes);
let miss = word_to_phonemes("ba", &dict, &phdata, &stress, &opts);
assert!(!miss.phonemes.contains(&s_code), "pre-context rule wrongly fired on 'ba': {:?}", miss.phonemes);
}
#[test]
fn compiled_word_boundary_context_gates_match() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let data = std::path::PathBuf::from("espeak-ng-data");
if !data.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&data).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
let b_code = phdata.lookup_phoneme("b");
let groups = vec![
RuleGroup {
name: "a".into(),
rules: vec![Rule { pre_context: "_".into(), phonemes: vec![s_code], ..Default::default() }],
},
RuleGroup { name: "b".into(), rules: vec![Rule::new("", &[b_code])] },
];
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let stress = StressOpts::for_lang("en");
let opts = LangOptions::for_lang("en");
let hit = word_to_phonemes("a", &dict, &phdata, &stress, &opts);
assert!(hit.phonemes.contains(&s_code), "word-start rule should fire on 'a': {:?}", hit.phonemes);
let miss = word_to_phonemes("ba", &dict, &phdata, &stress, &opts);
assert!(!miss.phonemes.contains(&s_code), "word-start rule wrongly fired on 'ba': {:?}", miss.phonemes);
}
#[test]
fn compiled_letter_group_context_gates_match() {
use crate::dictionary::stress::StressOpts;
use crate::translate::{word_to_phonemes, LangOptions};
let data = std::path::PathBuf::from("espeak-ng-data");
if !data.join("phontab").exists() {
return;
}
let mut phdata = PhonemeData::load(&data).expect("phdata");
phdata.select_table_by_name("en").expect("en table");
let s_code = phdata.lookup_phoneme("s");
let a_code = phdata.lookup_phoneme("a");
let k_code = phdata.lookup_phoneme("k");
let groups = vec![
RuleGroup {
name: "b".into(),
rules: vec![Rule { pre_context: "A".into(), phonemes: vec![s_code], ..Default::default() }],
},
RuleGroup { name: "a".into(), rules: vec![Rule::new("", &[a_code])] },
RuleGroup { name: "k".into(), rules: vec![Rule::new("", &[k_code])] },
];
let rules = compile_rules(&groups);
let bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let stress = StressOpts::for_lang("en");
let opts = LangOptions::for_lang("en");
let hit = word_to_phonemes("ab", &dict, &phdata, &stress, &opts);
assert!(hit.phonemes.contains(&s_code), "vowel-group rule should fire on 'ab': {:?}", hit.phonemes);
let miss = word_to_phonemes("kb", &dict, &phdata, &stress, &opts);
assert!(!miss.phonemes.contains(&s_code), "vowel-group rule wrongly fired on 'kb': {:?}", miss.phonemes);
}
#[test]
fn round_trip_rules_group() {
use crate::dictionary::RULE_PHONEMES;
let phonemes = vec![100u8, 101, 102];
let groups = vec![RuleGroup {
name: "a".into(),
rules: vec![Rule::new("", &phonemes)],
}];
let rules = compile_rules(&groups);
let dict_bytes = compile_dict_with_rules(&[], &TransposeConfig::LATIN, &rules).expect("compile");
let dict = Dictionary::from_bytes("en", dict_bytes).expect("valid dict");
let off = dict.groups.groups1[b'a' as usize].expect("group 'a' not registered");
assert_eq!(dict.data[off], RULE_PHONEMES);
assert_eq!(&dict.data[off + 1..off + 1 + phonemes.len()], phonemes.as_slice());
assert_eq!(dict.data[off + 1 + phonemes.len()], 0, "rule not null-terminated");
}
#[test]
fn flags_only_entry_round_trips() {
let entry = ListEntry { word: "the".into(), phonemes: vec![], flags: vec![32 + 3] };
let bytes = compile_dict(&[entry], &TransposeConfig::LATIN).expect("compile");
let dict = Dictionary::from_bytes("en", bytes).expect("valid dict");
let r = lookup(&dict, "the", &LookupCtx::default()).expect("found");
assert!(r.phonemes.is_empty(), "flags-only entry has no phonemes");
assert!(r.flags2.contains(1 << 3), "flags2 bit 3 should be set");
}
}