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> {
parse_list_dsl_mode(source, phdata, false)
}
pub fn parse_list_dsl_mode(
source: &str,
phdata: &PhonemeData,
lang_textmode: bool,
) -> Vec<ListEntry> {
const BITNUM_FLAG_TEXTMODE: u8 = 29;
const BITNUM_FLAG_ONLY_S: u8 = 0x2f;
let mut file_textmode = false;
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 flags: Vec<u8> = Vec::new();
let mut text_not_phonemes = false;
let mut rest: Vec<&str> = Vec::new();
for tok in line.split_whitespace() {
if let Some(cond) = condition_flag_byte(tok) {
flags.push(cond);
continue;
}
if tok.starts_with('$') {
match tok {
"$textmode" => file_textmode = true,
"$phonememode" => file_textmode = false,
"$text" => text_not_phonemes = true,
_ => {
if let Some(byte) = dollar_flag_byte(tok) {
flags.push(byte);
}
}
}
continue;
}
rest.push(tok);
}
text_not_phonemes |= file_textmode;
let Some((word, tail)) = rest.split_first() else {
continue;
};
let (word, tail) = (*word, tail);
let joined;
let mnemonics: Option<&str> = match tail {
[] => None,
[one] => Some(one),
many if text_not_phonemes => {
joined = many.join(" ");
let cut = joined
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= 156)
.last()
.unwrap_or(0);
Some(&joined[..cut.min(joined.len())])
}
many => Some(many[0]),
};
if text_not_phonemes != lang_textmode {
flags.push(BITNUM_FLAG_TEXTMODE);
}
let codes = match mnemonics {
Some(m) if text_not_phonemes => m.as_bytes().to_vec(),
Some(m) => {
let ph = crate::translate::parse_inline_phonemes(m, phdata);
if ph.contains(&crate::phoneme::PHON_SWITCH) {
flags.push(BITNUM_FLAG_ONLY_S);
}
ph
}
None => Vec::new(),
};
let word = if word.starts_with('_') {
word.to_string()
} else if let Some(hex) = word.strip_prefix("U+") {
match u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
Some(c) => c.to_string(),
None => word.to_string(),
}
} else {
let has_letter = word.chars().any(char::is_alphabetic);
if has_letter && word.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase) {
const BITNUM_FLAG_ALLCAPS: u8 = 0x2a;
flags.push(BITNUM_FLAG_ALLCAPS);
}
word.to_lowercase()
};
out.push(ListEntry { word, phonemes: codes, flags });
}
out
}
pub fn dollar_flag_byte(tok: &str) -> Option<u8> {
Some(match tok {
"$1" => 0x41, "$2" => 0x42, "$3" => 0x43, "$4" => 0x44,
"$5" => 0x45, "$6" => 0x46, "$7" => 0x47,
"$u" => 0x48, "$u1" => 0x49, "$u2" => 0x4a, "$u3" => 0x4b,
"$u+" => 0x4c, "$u1+" => 0x4d, "$u2+" => 0x4e, "$u3+" => 0x4f,
"$pause" => 8, "$strend" => 9, "$strend2" => 10, "$unstressend" => 11,
"$accent_before" => 12, "$abbrev" => 13, "$double" => 14,
"$alt" | "$alt1" => 15, "$alt2" => 16, "$alt3" => 17, "$alt4" => 18,
"$alt5" => 19, "$alt6" => 20, "$alt7" => 21,
"$combine" => 23, "$dot" => 24, "$hasdot" => 25,
"$max3" => 27, "$brk" => 28, "$text" => 29,
"$verbf" => 0x20, "$verbsf" => 0x21, "$nounf" => 0x22, "$pastf" => 0x23,
"$verb" => 0x24, "$noun" => 0x25, "$past" => 0x26, "$verbextend" => 0x28,
"$capital" => 0x29, "$allcaps" => 0x2a, "$accent" => 0x2b,
"$sentence" => 0x2d, "$only" => 0x2e, "$onlys" => 0x2f, "$stem" => 0x30,
"$atend" => 0x31, "$atstart" => 0x32, "$native" => 0x33,
_ => return None,
})
}
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>,
pub condition: 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 letter_group: Option<(u8, Vec<String>)>,
pub group3_ix: u8,
}
pub fn compile_dictionary(
dir: &std::path::Path,
lang: &str,
phdata: &PhonemeData,
transpose: &TransposeConfig,
) -> Result<Vec<u8>, String> {
let rules_src = std::fs::read_to_string(dir.join(format!("{lang}_rules"))).unwrap_or_default();
let chinese = matches!(lang, "cmn" | "yue" | "zh" | "zhy");
let order: &[&str] = if chinese {
&["roots", "list", "listx", "emoji", "extra"]
} else {
&["roots", "listx", "list", "emoji", "extra"]
};
let mut entries = Vec::new();
for part in order {
let src = std::fs::read_to_string(dir.join(format!("{lang}_{part}"))).unwrap_or_default();
if src.is_empty() {
continue;
}
entries.extend(parse_list_dsl_mode(&src, phdata, chinese));
}
let groups = parse_rules_dsl_offset(
&rules_src,
phdata,
super::file::letter_bits_offset_for(lang),
);
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> {
parse_rules_dsl_offset(source, phdata, 0)
}
pub fn parse_rules_dsl_offset(
source: &str,
phdata: &PhonemeData,
letter_bits_offset: u32,
) -> 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") {
let name = name.trim();
let group3_ix = match (letter_bits_offset > 0, name.chars().next()) {
(true, Some(c)) => match (c as u32).checked_sub(letter_bits_offset) {
Some(ix) if ix < 128 => ix as u8 + 1,
_ => 0,
},
_ => 0,
};
let name = match name.strip_prefix("0x").and_then(|h| u32::from_str_radix(h, 16).ok()) {
Some(code) if code > 0x100 => {
String::from_utf8_lossy(&[(code >> 8) as u8, code as u8]).into_owned()
}
Some(code) => String::from_utf8_lossy(&[code as u8]).into_owned(),
None if group3_ix != 0 => name.to_string(),
None => {
match name.len() > 2 {
true => std::str::from_utf8(&name.as_bytes()[..2])
.map(str::to_string)
.unwrap_or_else(|_| name.chars().next().into_iter().collect()),
false => name.to_string(),
}
}
};
groups.push(RuleGroup {
name,
rules: Vec::new(),
letter_group: None,
group3_ix,
});
continue;
}
if let Some(rest) = line.strip_prefix(".L") {
let mut it = rest.split_whitespace();
if let Some(num) = it.next() {
let (digits, first) = num.split_at(num.len().min(2));
if let Ok(n) = digits.parse::<u8>() {
let mut items: Vec<String> = Vec::new();
if !first.is_empty() {
items.push(first.replace('_', " "));
}
items.extend(it.map(|w| w.replace('_', " ")));
if n > 0 && (n as usize) < 26 && !items.is_empty() {
groups.push(RuleGroup {
name: String::new(),
rules: Vec::new(),
letter_group: Some((n, items)),
group3_ix: 0,
});
}
}
}
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.is_empty() {
continue;
}
let mut condition = 0u8;
if let Some(rest) = parts[0].strip_prefix('?') {
let (neg, digits) = match rest.strip_prefix('!') {
Some(d) => (32u8, d),
None => (0, rest),
};
if let Ok(n) = digits.parse::<u8>() {
if n > 0 && (n as u32 + neg as u32) < 255 {
condition = n + neg;
}
}
parts.remove(0);
if parts.is_empty() {
continue;
}
}
let mut split = 1;
while split < parts.len() {
let t = parts[split];
let spec_so_far: String = parts[..split].concat();
if t.starts_with('(') || spec_so_far.ends_with(')') {
split += 1;
} else {
break;
}
}
let letter_spec: String = parts[..split].concat();
let mnemonics: String = parts[split..].concat();
let mnemonics = mnemonics.as_str();
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,
condition,
});
}
groups
}
fn encode_context(s: &str, reverse: bool) -> Vec<u8> {
use super::{
RULE_CAPITAL, RULE_DEC_SCORE, RULE_DEL_FWD, RULE_DIGIT, RULE_DOLLAR, RULE_DOUBLE,
RULE_ENDING, RULE_IFVERB, RULE_INC_SCORE, RULE_LETTERGP, RULE_LETTERGP2, RULE_NONALPHA,
RULE_NOTVOWEL, RULE_NOVOWELS, RULE_NO_SUFFIX, RULE_SKIPCHARS, RULE_SPACE, RULE_SPELLING,
RULE_STRESSED, RULE_SYLLABLE, SUFX_A, SUFX_D, SUFX_E, SUFX_I, SUFX_M, SUFX_P, SUFX_V,
};
const LETTERGP: [u8; 9] = [0, 1, 2, 0, 0, 4, 5, 3, 6];
let bytes = s.as_bytes();
let mut tokens: Vec<Vec<u8>> = Vec::with_capacity(bytes.len());
let pair = |cmd: u8, operand: u8| -> Vec<u8> {
if reverse { vec![operand, cmd] } else { vec![cmd, operand] }
};
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
i += 1;
let token = match c {
b'_' => vec![RULE_SPACE],
b'A' | b'B' | b'C' | b'F' | b'G' | b'H' => {
pair(RULE_LETTERGP, LETTERGP[(c - b'A') as usize] + b'A')
}
b'Y' => pair(RULE_LETTERGP, LETTERGP[8] + b'A'),
b'D' => vec![RULE_DIGIT],
b'K' => vec![RULE_NOTVOWEL],
b'N' => vec![RULE_NO_SUFFIX],
b'V' => vec![RULE_IFVERB],
b'Z' => vec![RULE_NONALPHA],
b'+' => vec![RULE_INC_SCORE],
b'<' => vec![RULE_DEC_SCORE],
b'@' => vec![RULE_SYLLABLE],
b'&' => vec![RULE_STRESSED],
b'%' => vec![RULE_DOUBLE],
b'#' => vec![RULE_DEL_FWD],
b'!' => vec![RULE_CAPITAL],
b'W' => vec![RULE_SPELLING],
b'X' => vec![RULE_NOVOWELS],
b'J' => vec![RULE_SKIPCHARS],
b'T' => vec![RULE_DOLLAR, 0x11],
b'L' if i + 1 < bytes.len()
&& bytes[i].is_ascii_digit()
&& bytes[i + 1].is_ascii_digit() =>
{
let n = (bytes[i] - b'0') * 10 + (bytes[i + 1] - b'0');
i += 2;
pair(RULE_LETTERGP2, b'A' + n)
}
b'$' => {
const NAMES: &[(&str, u8)] = &[
("unpr", 0x01), ("noprefix", 0x02), ("list", 0x03),
("w_alt1", 0x11), ("w_alt2", 0x12), ("w_alt3", 0x13),
("w_alt4", 0x14), ("w_alt5", 0x15), ("w_alt6", 0x16),
("w_alt", 0x11),
("p_alt1", 0x21), ("p_alt2", 0x22), ("p_alt3", 0x23),
("p_alt4", 0x24), ("p_alt5", 0x25), ("p_alt6", 0x26),
("p_alt", 0x21),
];
let rest = &s[i..];
match NAMES.iter().find(|(n, _)| rest.starts_with(n)) {
Some((n, v)) => {
i += n.len();
pair(RULE_DOLLAR, *v)
}
None => pair(RULE_DOLLAR, 0),
}
}
b'P' | b'S' => {
let mut sxflags: u32 = 0x0080_8000 | if c == b'P' { SUFX_P } else { 0 };
let mut value: u32 = 0;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
match bytes[i] {
b'e' => sxflags |= SUFX_E,
b'i' => sxflags |= SUFX_I,
b'p' => sxflags |= SUFX_P,
b'v' => sxflags |= SUFX_V,
b'd' => sxflags |= SUFX_D,
b'f' => sxflags |= 0x2000, b'q' => sxflags |= 0x4000, b't' => sxflags |= 0x10000, b'b' => sxflags |= 0x20000, b'a' => sxflags |= SUFX_A,
b'm' => sxflags |= SUFX_M,
d if d.is_ascii_digit() => value = value * 10 + (d - b'0') as u32,
_ => {}
}
i += 1;
}
vec![
RULE_ENDING,
(sxflags >> 16) as u8,
(sxflags >> 8) as u8,
(value | 0x80) as u8,
]
}
other => vec![other],
};
tokens.push(token);
}
let mut out: Vec<u8> = tokens.into_iter().flatten().collect();
if reverse {
out.reverse();
}
out
}
pub fn compile_rules(groups: &[RuleGroup]) -> Vec<u8> {
let mut out = Vec::new();
for g in groups {
let Some((num, items)) = &g.letter_group else { continue };
out.push(RULE_GROUP_START);
out.push(super::RULE_LETTERGP2);
out.push(b'A' + num);
let max = items.iter().map(|i| i.len()).max().unwrap_or(0);
for len in (1..=max).rev() {
for item in items.iter().filter(|i| i.len() == len) {
out.extend_from_slice(item.as_bytes());
out.push(0);
}
}
out.push(RULE_GROUP_END);
}
let mut order: Vec<usize> = (0..groups.len())
.filter(|&i| groups[i].letter_group.is_none())
.collect();
order.sort_by(|&a, &b| {
let (na, nb) = (groups[a].name.as_bytes(), groups[b].name.as_bytes());
nb.len().cmp(&na.len()).then(na.cmp(nb)).then(a.cmp(&b))
});
let mut prev_name: Option<&str> = None;
for (n, &gi) in order.iter().enumerate() {
let g = &groups[gi];
if prev_name != Some(g.name.as_str()) {
if n > 0 {
out.push(RULE_GROUP_END);
}
out.push(RULE_GROUP_START);
if g.group3_ix != 0 {
out.push(1);
out.push(g.group3_ix);
} else {
out.extend_from_slice(g.name.as_bytes());
}
out.push(0); prev_name = Some(g.name.as_str());
}
let mut recs: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(g.rules.len());
for r in &g.rules {
let mut tail = r.match_letters.as_bytes().to_vec();
if r.condition != 0 {
tail.push(super::RULE_CONDITION);
tail.push(r.condition);
}
if !r.pre_context.is_empty() {
let (marker, body) = match r.pre_context.strip_prefix('_') {
Some(rest) => (super::RULE_PRE_ATSTART, rest),
None => (super::RULE_PRE, r.pre_context.as_str()),
};
tail.push(marker);
tail.extend(encode_context(body, true));
}
if !r.post_context.is_empty() {
tail.push(super::RULE_POST);
tail.extend(encode_context(&r.post_context, false));
}
recs.push((r.phonemes.clone(), tail));
}
recs.sort();
let mut common: Option<&[u8]> = None;
for i in 0..recs.len() {
let (ph, tail) = &recs[i];
if !ph.is_empty() && common == Some(ph.as_slice()) {
out.extend_from_slice(tail);
out.push(0);
continue;
}
if !ph.is_empty() && recs.get(i + 1).is_some_and(|(next, _)| next == ph) {
out.push(super::RULE_PH_COMMON);
common = Some(ph.as_slice());
}
out.extend_from_slice(tail);
out.push(RULE_PHONEMES);
out.extend_from_slice(ph);
out.push(0); }
}
if !order.is_empty() {
out.push(RULE_GROUP_END);
}
out.push(0); 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.iter().rev() {
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 {
entry.truncate(2 + compressed.len());
entry[1] = (wlen & 0x7f) | 0x80;
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])],
letter_group: None,
group3_ix: 0,
}];
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] , letter_group: None, group3_ix: 0 }];
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() }],
letter_group: None,
group3_ix: 0,
},
RuleGroup { name: "k".into(), rules: vec![Rule::new("", &[k_code])], letter_group: None, group3_ix: 0 },
RuleGroup { name: "b".into(), rules: vec![Rule::new("", &[b_code])], letter_group: None, group3_ix: 0 },
];
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() }], letter_group: None, group3_ix: 0 },
RuleGroup { name: "b".into(), rules: vec![Rule::new("", &[b_code])], letter_group: None, group3_ix: 0 },
];
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() }], letter_group: None, group3_ix: 0 },
RuleGroup { name: "a".into(), rules: vec![Rule::new("", &[a_code])], letter_group: None, group3_ix: 0 },
RuleGroup { name: "k".into(), rules: vec![Rule::new("", &[k_code])], letter_group: None, group3_ix: 0 },
];
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)], letter_group: None, group3_ix: 0 }];
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");
}
#[test]
fn list_entry_dollar_flags_are_compiled() {
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("a\teI\t$atend\nto\ttu:\t$u $allcaps\n", &phdata);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].flags, vec![0x31]);
assert_eq!(entries[1].flags, vec![0x48, 0x2a]);
assert!(!entries[0].phonemes.is_empty(), "the phoneme column still parses");
}
#[test]
fn unknown_dollar_flags_are_ignored() {
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("x\ta\t$textmode $nosuchflag\n", &phdata);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].flags, vec![29]);
assert_eq!(entries[0].phonemes, b"a", "the column is text, not phonemes");
let entries = parse_list_dsl("x\ta\t$nosuchflag\n", &phdata);
assert_eq!(entries.len(), 1);
assert!(entries[0].flags.is_empty(), "no flag bytes for an unknown name");
assert!(!entries[0].phonemes.is_empty());
let entries = parse_list_dsl("$textmode\nx\ta\n$phonememode\ny\ta\n", &phdata);
assert_eq!(entries.len(), 2, "the directives are not entries");
assert_eq!(entries[0].phonemes, b"a", "before $phonememode: text");
assert_ne!(entries[1].phonemes, b"a", "after $phonememode: phonemes");
}
#[test]
fn rule_without_phonemes_is_silent() {
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 groups = parse_rules_dsl(".group c\n c k\n c (q\n", &phdata);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].rules.len(), 2);
assert!(!groups[0].rules[0].phonemes.is_empty(), "`c k` has phonemes");
assert_eq!(groups[0].rules[1].post_context, "q");
assert!(groups[0].rules[1].phonemes.is_empty(), "`c (q` is silent");
}
#[test]
fn letter_groups_compile_and_are_referenced() {
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 groups = parse_rules_dsl(
".L01 l r\n.L02 i y\n.group c\n c k\n _) c (L01Y k\n",
&phdata,
);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].letter_group, Some((1, vec!["l".into(), "r".into()])));
assert_eq!(groups[1].letter_group, Some((2, vec!["i".into(), "y".into()])));
assert_eq!(groups[2].name, "c");
let rules = compile_rules(&groups);
assert_eq!(rules[0], super::super::RULE_GROUP_START);
assert_eq!(rules[1], super::super::RULE_LETTERGP2);
assert_eq!(rules[2], b'A' + 1);
let refs = rules
.windows(2)
.filter(|w| w[0] == super::super::RULE_LETTERGP2 && w[1] == b'A' + 1)
.count();
assert!(refs >= 2, "the definition and the reference both encode group 1");
}
#[test]
fn malformed_letter_group_is_ignored() {
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");
for src in [".Lxx a b\n", ".L99\n", ".L\n"] {
let groups = parse_rules_dsl(src, &phdata);
assert!(
groups.iter().all(|g| g.letter_group.is_none()),
"{src:?} should not define a group"
);
}
}
}