use rustc_hash::FxHashSet;
use std::collections::HashMap;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PhoneticFeature {
Voiced,
Voiceless,
Stop,
Fricative,
Affricate,
Nasal,
Approximant,
Lateral,
Rhotic,
Bilabial,
Labiodental,
Dental,
Alveolar,
PostAlveolar,
Palatal,
Velar,
Glottal,
Vowel,
Consonant,
High,
Mid,
Low,
Front,
Central,
Back,
Rounded,
Unrounded,
Sibilant,
Aspirated,
Tense,
Pharyngealized,
Labialized,
Velarized,
Retroflex,
Uvular,
Pharyngeal,
Epiglottal,
Tap,
Trill,
Ejective,
Implosive,
Click,
}
static FEATURE_TABLE: LazyLock<HashMap<char, FxHashSet<PhoneticFeature>>> = LazyLock::new(|| {
use PhoneticFeature::*;
let mut table: HashMap<char, FxHashSet<PhoneticFeature>> = HashMap::new();
table.insert('a', [Vowel, Low, Central, Unrounded].into_iter().collect());
table.insert('e', [Vowel, Mid, Front, Unrounded].into_iter().collect());
table.insert('i', [Vowel, High, Front, Unrounded].into_iter().collect());
table.insert('o', [Vowel, Mid, Back, Rounded].into_iter().collect());
table.insert('u', [Vowel, High, Back, Rounded].into_iter().collect());
table.insert('A', [Vowel, Low, Central, Unrounded].into_iter().collect());
table.insert('E', [Vowel, Mid, Front, Unrounded].into_iter().collect());
table.insert('I', [Vowel, High, Front, Unrounded].into_iter().collect());
table.insert('O', [Vowel, Mid, Back, Rounded].into_iter().collect());
table.insert('U', [Vowel, High, Back, Rounded].into_iter().collect());
for (accented, base) in [
('à', 'a'),
('á', 'a'),
('â', 'a'),
('ã', 'a'),
('ä', 'a'),
('å', 'a'),
('è', 'e'),
('é', 'e'),
('ê', 'e'),
('ë', 'e'),
('ì', 'i'),
('í', 'i'),
('î', 'i'),
('ï', 'i'),
('ò', 'o'),
('ó', 'o'),
('ô', 'o'),
('õ', 'o'),
('ö', 'o'),
('ù', 'u'),
('ú', 'u'),
('û', 'u'),
('ü', 'u'),
('À', 'A'),
('Á', 'A'),
('Â', 'A'),
('Ã', 'A'),
('Ä', 'A'),
('Å', 'A'),
('È', 'E'),
('É', 'E'),
('Ê', 'E'),
('Ë', 'E'),
('Ì', 'I'),
('Í', 'I'),
('Î', 'I'),
('Ï', 'I'),
('Ò', 'O'),
('Ó', 'O'),
('Ô', 'O'),
('Õ', 'O'),
('Ö', 'O'),
('Ù', 'U'),
('Ú', 'U'),
('Û', 'U'),
('Ü', 'U'),
] {
if let Some(features) = table.get(&base) {
table.insert(accented, features.clone());
}
}
table.insert(
'p',
[Consonant, Stop, Bilabial, Voiceless].into_iter().collect(),
);
table.insert(
'b',
[Consonant, Stop, Bilabial, Voiced].into_iter().collect(),
);
table.insert(
'P',
[Consonant, Stop, Bilabial, Voiceless].into_iter().collect(),
);
table.insert(
'B',
[Consonant, Stop, Bilabial, Voiced].into_iter().collect(),
);
table.insert(
't',
[Consonant, Stop, Alveolar, Voiceless].into_iter().collect(),
);
table.insert(
'd',
[Consonant, Stop, Alveolar, Voiced].into_iter().collect(),
);
table.insert(
'T',
[Consonant, Stop, Alveolar, Voiceless].into_iter().collect(),
);
table.insert(
'D',
[Consonant, Stop, Alveolar, Voiced].into_iter().collect(),
);
table.insert(
'k',
[Consonant, Stop, Velar, Voiceless].into_iter().collect(),
);
table.insert('g', [Consonant, Stop, Velar, Voiced].into_iter().collect());
table.insert(
'K',
[Consonant, Stop, Velar, Voiceless].into_iter().collect(),
);
table.insert('G', [Consonant, Stop, Velar, Voiced].into_iter().collect());
table.insert(
'c',
[Consonant, Stop, Velar, Voiceless].into_iter().collect(),
); table.insert(
'C',
[Consonant, Stop, Velar, Voiceless].into_iter().collect(),
);
table.insert(
'q',
[Consonant, Stop, Velar, Voiceless].into_iter().collect(),
);
table.insert(
'Q',
[Consonant, Stop, Velar, Voiceless].into_iter().collect(),
);
table.insert(
'f',
[Consonant, Fricative, Labiodental, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'v',
[Consonant, Fricative, Labiodental, Voiced]
.into_iter()
.collect(),
);
table.insert(
'F',
[Consonant, Fricative, Labiodental, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'V',
[Consonant, Fricative, Labiodental, Voiced]
.into_iter()
.collect(),
);
table.insert(
's',
[Consonant, Fricative, Alveolar, Voiceless, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'z',
[Consonant, Fricative, Alveolar, Voiced, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'S',
[Consonant, Fricative, Alveolar, Voiceless, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'Z',
[Consonant, Fricative, Alveolar, Voiced, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'h',
[Consonant, Fricative, Glottal, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'H',
[Consonant, Fricative, Glottal, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'm',
[Consonant, Nasal, Bilabial, Voiced].into_iter().collect(),
);
table.insert(
'n',
[Consonant, Nasal, Alveolar, Voiced].into_iter().collect(),
);
table.insert(
'M',
[Consonant, Nasal, Bilabial, Voiced].into_iter().collect(),
);
table.insert(
'N',
[Consonant, Nasal, Alveolar, Voiced].into_iter().collect(),
);
table.insert(
'ñ',
[Consonant, Nasal, Palatal, Voiced].into_iter().collect(),
);
table.insert(
'Ñ',
[Consonant, Nasal, Palatal, Voiced].into_iter().collect(),
);
table.insert('ŋ', [Consonant, Nasal, Velar, Voiced].into_iter().collect());
table.insert(
'l',
[Consonant, Approximant, Lateral, Alveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'r',
[Consonant, Approximant, Rhotic, Alveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'w',
[Consonant, Approximant, Bilabial, Velar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'y',
[Consonant, Approximant, Palatal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'L',
[Consonant, Approximant, Lateral, Alveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'R',
[Consonant, Approximant, Rhotic, Alveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'W',
[Consonant, Approximant, Bilabial, Velar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'Y',
[Consonant, Approximant, Palatal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'x',
[Consonant, Fricative, Velar, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'X',
[Consonant, Fricative, Velar, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'j',
[Consonant, Affricate, PostAlveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'J',
[Consonant, Affricate, PostAlveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ç',
[Consonant, Fricative, Alveolar, Voiceless, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ɫ',
[Consonant, Approximant, Lateral, Alveolar, Voiced, Velarized]
.into_iter()
.collect(),
);
table.insert('\u{02B0}', [Aspirated].into_iter().collect());
table.insert('\u{02E4}', [Pharyngealized].into_iter().collect());
table.insert('\u{02B7}', [Labialized].into_iter().collect());
table.insert('\u{02E0}', [Velarized].into_iter().collect());
table.insert('\u{0348}', [Tense].into_iter().collect());
table.insert(
'ʈ',
[Consonant, Stop, Retroflex, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ɖ',
[Consonant, Stop, Retroflex, Voiced].into_iter().collect(),
);
table.insert(
'ɳ',
[Consonant, Nasal, Retroflex, Voiced].into_iter().collect(),
);
table.insert(
'ɽ',
[Consonant, Tap, Retroflex, Voiced].into_iter().collect(),
);
table.insert(
'ɭ',
[Consonant, Approximant, Lateral, Retroflex, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ʂ',
[Consonant, Fricative, Retroflex, Voiceless, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ʐ',
[Consonant, Fricative, Retroflex, Voiced, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ɻ',
[Consonant, Approximant, Retroflex, Voiced]
.into_iter()
.collect(),
);
table.insert(
'q',
[Consonant, Stop, Uvular, Voiceless].into_iter().collect(),
);
table.insert('ɢ', [Consonant, Stop, Uvular, Voiced].into_iter().collect());
table.insert(
'ɴ',
[Consonant, Nasal, Uvular, Voiced].into_iter().collect(),
);
table.insert(
'χ',
[Consonant, Fricative, Uvular, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ʁ',
[Consonant, Fricative, Uvular, Voiced].into_iter().collect(),
);
table.insert(
'ʀ',
[Consonant, Trill, Uvular, Voiced].into_iter().collect(),
);
table.insert(
'ħ',
[Consonant, Fricative, Pharyngeal, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ʕ',
[Consonant, Fricative, Pharyngeal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ʜ',
[Consonant, Fricative, Epiglottal, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ʢ',
[Consonant, Fricative, Epiglottal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ʡ',
[Consonant, Stop, Epiglottal, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ɾ',
[Consonant, Tap, Alveolar, Voiced].into_iter().collect(),
);
table.insert(
'ʙ',
[Consonant, Trill, Bilabial, Voiced].into_iter().collect(),
);
table.insert('ʼ', [Ejective].into_iter().collect());
table.insert(
'ɓ',
[Consonant, Stop, Bilabial, Voiced, Implosive]
.into_iter()
.collect(),
);
table.insert(
'ɗ',
[Consonant, Stop, Alveolar, Voiced, Implosive]
.into_iter()
.collect(),
);
table.insert(
'ɠ',
[Consonant, Stop, Velar, Voiced, Implosive]
.into_iter()
.collect(),
);
table.insert(
'ʄ',
[Consonant, Stop, Palatal, Voiced, Implosive]
.into_iter()
.collect(),
);
table.insert(
'ʛ',
[Consonant, Stop, Uvular, Voiced, Implosive]
.into_iter()
.collect(),
);
table.insert('ʘ', [Consonant, Click, Bilabial].into_iter().collect());
table.insert('ǀ', [Consonant, Click, Dental].into_iter().collect());
table.insert('ǃ', [Consonant, Click, Alveolar].into_iter().collect());
table.insert('ǂ', [Consonant, Click, Palatal].into_iter().collect());
table.insert('ǁ', [Consonant, Click, Lateral].into_iter().collect());
table.insert('ə', [Vowel, Mid, Central, Unrounded].into_iter().collect());
table.insert('ɐ', [Vowel, Low, Central, Unrounded].into_iter().collect());
table.insert('ɪ', [Vowel, High, Front, Unrounded].into_iter().collect());
table.insert('ʏ', [Vowel, High, Front, Rounded].into_iter().collect());
table.insert('ʊ', [Vowel, High, Back, Rounded].into_iter().collect());
table.insert('ɛ', [Vowel, Mid, Front, Unrounded].into_iter().collect());
table.insert('œ', [Vowel, Mid, Front, Rounded].into_iter().collect());
table.insert('ɔ', [Vowel, Mid, Back, Rounded].into_iter().collect());
table.insert('ʌ', [Vowel, Mid, Back, Unrounded].into_iter().collect());
table.insert('æ', [Vowel, Low, Front, Unrounded].into_iter().collect());
table.insert('ɑ', [Vowel, Low, Back, Unrounded].into_iter().collect());
table.insert('ɒ', [Vowel, Low, Back, Rounded].into_iter().collect());
table.insert('ø', [Vowel, Mid, Front, Rounded].into_iter().collect());
table.insert('ɤ', [Vowel, Mid, Back, Unrounded].into_iter().collect());
table.insert('y', [Vowel, High, Front, Rounded].into_iter().collect());
table.insert('ɯ', [Vowel, High, Back, Unrounded].into_iter().collect());
table.insert('ɨ', [Vowel, High, Central, Unrounded].into_iter().collect());
table.insert('ʉ', [Vowel, High, Central, Rounded].into_iter().collect());
table.insert('ɘ', [Vowel, Mid, Central, Unrounded].into_iter().collect());
table.insert('ɵ', [Vowel, Mid, Central, Rounded].into_iter().collect());
table.insert('ɜ', [Vowel, Mid, Central, Unrounded].into_iter().collect());
table.insert('ɞ', [Vowel, Mid, Central, Rounded].into_iter().collect());
table.insert('ɶ', [Vowel, Low, Front, Rounded].into_iter().collect());
table.insert(
'c',
[Consonant, Stop, Palatal, Voiceless].into_iter().collect(),
);
table.insert(
'ɟ',
[Consonant, Stop, Palatal, Voiced].into_iter().collect(),
);
table.insert(
'ɲ',
[Consonant, Nasal, Palatal, Voiced].into_iter().collect(),
);
table.insert(
'ç',
[Consonant, Fricative, Palatal, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ʝ',
[Consonant, Fricative, Palatal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ʎ',
[Consonant, Approximant, Lateral, Palatal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'j',
[Consonant, Approximant, Palatal, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ʃ',
[Consonant, Fricative, PostAlveolar, Voiceless, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ʒ',
[Consonant, Fricative, PostAlveolar, Voiced, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ɕ',
[Consonant, Fricative, PostAlveolar, Voiceless, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ʑ',
[Consonant, Fricative, PostAlveolar, Voiced, Sibilant]
.into_iter()
.collect(),
);
table.insert(
'ɬ',
[Consonant, Fricative, Lateral, Alveolar, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ɮ',
[Consonant, Fricative, Lateral, Alveolar, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ʔ',
[Consonant, Stop, Glottal, Voiceless].into_iter().collect(),
);
table.insert(
'θ',
[Consonant, Fricative, Dental, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'ð',
[Consonant, Fricative, Dental, Voiced].into_iter().collect(),
);
table.insert(
'ʋ',
[Consonant, Approximant, Labiodental, Voiced]
.into_iter()
.collect(),
);
table.insert(
'ɸ',
[Consonant, Fricative, Bilabial, Voiceless]
.into_iter()
.collect(),
);
table.insert(
'β',
[Consonant, Fricative, Bilabial, Voiced]
.into_iter()
.collect(),
);
table
});
static FEATURE_INDEX: LazyLock<HashMap<PhoneticFeature, FxHashSet<char>>> = LazyLock::new(|| {
let mut index: HashMap<PhoneticFeature, FxHashSet<char>> = HashMap::new();
for (c, features) in FEATURE_TABLE.iter() {
for feature in features {
index.entry(*feature).or_default().insert(*c);
}
}
index
});
pub fn get_features(c: char) -> FxHashSet<PhoneticFeature> {
FEATURE_TABLE.get(&c).cloned().unwrap_or_default()
}
pub fn chars_with_features(features: &[PhoneticFeature]) -> FxHashSet<char> {
if features.is_empty() {
return FxHashSet::default();
}
let mut result: FxHashSet<char> = FEATURE_INDEX.get(&features[0]).cloned().unwrap_or_default();
for feature in &features[1..] {
if let Some(chars) = FEATURE_INDEX.get(feature) {
result.retain(|c| chars.contains(c));
} else {
return FxHashSet::default();
}
}
result
}
pub fn chars_with_any_feature(features: &[PhoneticFeature]) -> FxHashSet<char> {
let mut result = FxHashSet::default();
for feature in features {
if let Some(chars) = FEATURE_INDEX.get(feature) {
result.extend(chars.iter());
}
}
result
}
pub fn are_similar(c1: char, c2: char) -> bool {
let f1 = get_features(c1);
let f2 = get_features(c2);
f1.intersection(&f2).next().is_some()
}
pub fn get_similar_chars(c: char) -> FxHashSet<char> {
let features = get_features(c);
let mut result = FxHashSet::default();
for feature in features {
if let Some(chars) = FEATURE_INDEX.get(&feature) {
result.extend(chars.iter());
}
}
result.remove(&c);
result
}
pub fn get_voicing_pair(c: char) -> Option<char> {
let features = get_features(c);
if features.is_empty() {
return None;
}
let place = features.iter().find(|f| {
matches!(
f,
PhoneticFeature::Bilabial
| PhoneticFeature::Labiodental
| PhoneticFeature::Dental
| PhoneticFeature::Alveolar
| PhoneticFeature::PostAlveolar
| PhoneticFeature::Palatal
| PhoneticFeature::Velar
| PhoneticFeature::Glottal
| PhoneticFeature::Retroflex
| PhoneticFeature::Uvular
| PhoneticFeature::Pharyngeal
| PhoneticFeature::Epiglottal
)
})?;
let manner = features.iter().find(|f| {
matches!(
f,
PhoneticFeature::Stop
| PhoneticFeature::Fricative
| PhoneticFeature::Affricate
| PhoneticFeature::Nasal
| PhoneticFeature::Approximant
| PhoneticFeature::Tap
| PhoneticFeature::Trill
)
})?;
let target_voicing = if features.contains(&PhoneticFeature::Voiced) {
PhoneticFeature::Voiceless
} else if features.contains(&PhoneticFeature::Voiceless) {
PhoneticFeature::Voiced
} else {
return None;
};
let additional_features: Vec<PhoneticFeature> = features
.iter()
.filter(|f| matches!(f, PhoneticFeature::Sibilant | PhoneticFeature::Lateral))
.copied()
.collect();
let candidates = chars_with_features(&[*place, *manner, target_voicing]);
let is_pulmonic = |ch: &char| {
let f = get_features(*ch);
!f.contains(&PhoneticFeature::Implosive) && !f.contains(&PhoneticFeature::Click)
};
let score_candidate = |ch: &char| -> i32 {
let ch_features = get_features(*ch);
let mut score: i32 = 0;
for f in &additional_features {
if ch_features.contains(f) {
score += 10;
}
}
if ch_features.contains(&PhoneticFeature::Lateral)
&& !features.contains(&PhoneticFeature::Lateral)
{
score -= 5;
}
if ch_features.contains(&PhoneticFeature::Sibilant)
&& !features.contains(&PhoneticFeature::Sibilant)
{
score -= 5;
}
score
};
let mut best_candidate: Option<char> = None;
let mut best_score: i32 = i32::MIN;
for &ch in candidates.iter() {
if ch.is_lowercase() == c.is_lowercase() && is_pulmonic(&ch) {
let score = score_candidate(&ch);
if best_candidate.is_none() || score > best_score {
best_candidate = Some(ch);
best_score = score;
}
}
}
best_candidate.or_else(|| {
candidates
.into_iter()
.find(|ch| ch.is_lowercase() == c.is_lowercase())
})
}
pub fn expand_feature_based(c: char) -> Vec<char> {
let features = get_features(c);
if features.is_empty() {
return vec![c];
}
let place = features.iter().find(|f| {
matches!(
f,
PhoneticFeature::Bilabial
| PhoneticFeature::Labiodental
| PhoneticFeature::Dental
| PhoneticFeature::Alveolar
| PhoneticFeature::PostAlveolar
| PhoneticFeature::Palatal
| PhoneticFeature::Velar
| PhoneticFeature::Glottal
| PhoneticFeature::Retroflex
| PhoneticFeature::Uvular
| PhoneticFeature::Pharyngeal
| PhoneticFeature::Epiglottal
)
});
let manner = features.iter().find(|f| {
matches!(
f,
PhoneticFeature::Stop
| PhoneticFeature::Fricative
| PhoneticFeature::Affricate
| PhoneticFeature::Nasal
| PhoneticFeature::Approximant
| PhoneticFeature::Tap
| PhoneticFeature::Trill
)
});
let mut result = vec![c];
if let (Some(place), Some(manner)) = (place, manner) {
let variants = chars_with_features(&[*place, *manner]);
for variant in variants {
if variant != c && variant.is_lowercase() == c.is_lowercase() {
result.push(variant);
}
}
}
result.sort();
result.dedup();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_features_vowels() {
let features = get_features('a');
assert!(features.contains(&PhoneticFeature::Vowel));
assert!(features.contains(&PhoneticFeature::Low));
assert!(!features.contains(&PhoneticFeature::Consonant));
}
#[test]
fn test_get_features_consonants() {
let features = get_features('p');
assert!(features.contains(&PhoneticFeature::Consonant));
assert!(features.contains(&PhoneticFeature::Stop));
assert!(features.contains(&PhoneticFeature::Bilabial));
assert!(features.contains(&PhoneticFeature::Voiceless));
}
#[test]
fn test_get_features_unknown() {
let features = get_features('£');
assert!(features.is_empty());
}
#[test]
fn test_chars_with_features_voiced_stops() {
let chars = chars_with_features(&[PhoneticFeature::Voiced, PhoneticFeature::Stop]);
assert!(chars.contains(&'b'));
assert!(chars.contains(&'d'));
assert!(chars.contains(&'g'));
assert!(!chars.contains(&'p')); assert!(!chars.contains(&'m')); }
#[test]
fn test_chars_with_any_feature() {
let chars = chars_with_any_feature(&[PhoneticFeature::Nasal]);
assert!(chars.contains(&'m'));
assert!(chars.contains(&'n'));
assert!(chars.contains(&'ŋ'));
}
#[test]
fn test_are_similar() {
assert!(are_similar('b', 'p'));
assert!(are_similar('b', 'd'));
assert!(are_similar('a', 'e')); }
#[test]
fn test_get_similar_chars() {
let similar = get_similar_chars('p');
assert!(similar.contains(&'b'));
assert!(similar.contains(&'t'));
assert!(!similar.contains(&'p')); }
#[test]
fn test_get_voicing_pair() {
assert_eq!(get_voicing_pair('p'), Some('b'));
assert_eq!(get_voicing_pair('b'), Some('p'));
assert_eq!(get_voicing_pair('t'), Some('d'));
assert_eq!(get_voicing_pair('d'), Some('t'));
assert_eq!(get_voicing_pair('k'), Some('g'));
let g_pair = get_voicing_pair('g');
assert!(
g_pair == Some('k') || g_pair == Some('c') || g_pair == Some('q'),
"Expected 'k', 'c', or 'q' for 'g' voicing pair, got {:?}",
g_pair
);
assert_eq!(get_voicing_pair('f'), Some('v'));
assert_eq!(get_voicing_pair('v'), Some('f'));
assert_eq!(get_voicing_pair('s'), Some('z'));
assert_eq!(get_voicing_pair('z'), Some('s'));
}
#[test]
fn test_expand_feature_based() {
let expanded = expand_feature_based('p');
assert!(expanded.contains(&'p'));
assert!(expanded.contains(&'b')); }
#[test]
fn test_accented_vowels_have_same_features() {
let a_features = get_features('a');
let acute_features = get_features('á');
assert_eq!(a_features, acute_features);
}
#[test]
fn test_uppercase_has_same_features() {
let p_lower = get_features('p');
let p_upper = get_features('P');
assert_eq!(p_lower, p_upper);
}
}