#[derive(Debug, Clone, Copy, Default)]
pub struct FuzzyConfig {
pub z_zh: bool,
pub c_ch: bool,
pub s_sh: bool,
pub n_l: bool,
pub f_h: bool,
pub r_l: bool,
pub in_ing: bool,
pub en_eng: bool,
pub an_ang: bool,
}
impl FuzzyConfig {
pub const fn strict() -> Self {
Self {
z_zh: false,
c_ch: false,
s_sh: false,
n_l: false,
f_h: false,
r_l: false,
in_ing: false,
en_eng: false,
an_ang: false,
}
}
pub const fn permissive() -> Self {
Self {
z_zh: true,
c_ch: true,
s_sh: true,
n_l: true,
f_h: true,
r_l: true,
in_ing: true,
en_eng: true,
an_ang: true,
}
}
pub fn expand(&self, syl: &str) -> Vec<String> {
let mut out = vec![syl.to_string()];
let initial_swaps: &[(bool, &str, &str)] = &[
(self.z_zh, "zh", "z"),
(self.z_zh, "z", "zh"),
(self.c_ch, "ch", "c"),
(self.c_ch, "c", "ch"),
(self.s_sh, "sh", "s"),
(self.s_sh, "s", "sh"),
(self.n_l, "n", "l"),
(self.n_l, "l", "n"),
(self.f_h, "f", "h"),
(self.f_h, "h", "f"),
(self.r_l, "r", "l"),
(self.r_l, "l", "r"),
];
for (on, from, to) in initial_swaps {
if *on && let Some(rest) = syl.strip_prefix(from) {
let mut alt = String::with_capacity(syl.len());
alt.push_str(to);
alt.push_str(rest);
if alt != syl && !out.contains(&alt) {
out.push(alt);
}
}
}
let final_swaps: &[(bool, &str, &str)] = &[
(self.in_ing, "ing", "in"),
(self.in_ing, "in", "ing"),
(self.en_eng, "eng", "en"),
(self.en_eng, "en", "eng"),
(self.an_ang, "ang", "an"),
(self.an_ang, "an", "ang"),
];
for (on, from, to) in final_swaps {
if *on && syl.ends_with(from) {
let head = &syl[..syl.len() - from.len()];
let mut alt = String::with_capacity(syl.len());
alt.push_str(head);
alt.push_str(to);
if alt != syl && !out.contains(&alt) {
out.push(alt);
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_strict() {
let f = FuzzyConfig::default();
assert_eq!(f.expand("zhong"), vec!["zhong"]);
}
#[test]
fn z_zh_swap_both_directions() {
let f = FuzzyConfig {
z_zh: true,
..FuzzyConfig::default()
};
let out = f.expand("zhong");
assert!(out.contains(&"zhong".to_string()));
assert!(out.contains(&"zong".to_string()));
let out = f.expand("zai");
assert!(out.contains(&"zhai".to_string()));
}
#[test]
fn final_swaps_independent() {
let f = FuzzyConfig {
in_ing: true,
..FuzzyConfig::default()
};
assert!(f.expand("xing").contains(&"xin".to_string()));
assert!(f.expand("xin").contains(&"xing".to_string()));
assert_eq!(f.expand("xian"), vec!["xian"]);
}
#[test]
fn permissive_includes_canonical_first() {
let f = FuzzyConfig::permissive();
let out = f.expand("zhong");
assert_eq!(out[0], "zhong");
assert!(out.contains(&"zong".to_string()));
}
#[test]
fn initial_and_final_compose_only_singly() {
let f = FuzzyConfig::permissive();
let out = f.expand("zin");
assert!(
out.contains(&"zhin".to_string()),
"expected single z→zh: {out:?}"
);
assert!(
out.contains(&"zing".to_string()),
"expected single in→ing: {out:?}"
);
assert!(
!out.contains(&"zhing".to_string()),
"cascade leaked: {out:?}"
);
}
}