1use std::collections::HashMap;
8use std::fmt;
9use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
10
11use crate::nine_key::SpellingTable;
12use crate::pinyin::syllables::{canonical_lattice_syllable, intact_pinyin_list};
13use crate::shuangpin::custom::{CustomTablePart, FINALS, ZERO_INITIALS};
14use crate::types::{NineKeyShuangpinKind, NineKeyShuangpinTable};
15
16pub const INITIALS: [&str; 23] = [
18 "b", "p", "m", "f", "d", "t", "n", "l", "g", "k", "h", "j", "q", "x", "r", "z", "c", "s", "y",
19 "w", "zh", "ch", "sh",
20];
21
22pub const DIGIT_ORDER: &[u8; 10] = b"1234567890";
24
25const MIGALA_2: BuiltinTable = BuiltinTable {
29 initials: [
30 ("b", "2"),
31 ("p", "7"),
32 ("m", "6"),
33 ("f", "3"),
34 ("d", "3"),
35 ("t", "8"),
36 ("n", "6"),
37 ("l", "5"),
38 ("g", "4"),
39 ("k", "5"),
40 ("h", "4"),
41 ("j", "5"),
42 ("q", "7"),
43 ("x", "9"),
44 ("r", "7"),
45 ("z", "9"),
46 ("c", "2"),
47 ("s", "7"),
48 ("y", "9"),
49 ("w", "9"),
50 ("zh", "8"),
51 ("ch", "5"),
52 ("sh", "8"),
53 ],
54 finals: [
55 ("a", "2"),
56 ("o", "6"),
57 ("e", "3"),
58 ("i", "4"),
59 ("u", "8"),
60 ("v", "8"),
61 ("ai", "2"),
62 ("ei", "3"),
63 ("ui", "9"),
64 ("ao", "2"),
65 ("ou", "6"),
66 ("iu", "1"),
67 ("ie", "1"),
68 ("ve", "9"),
69 ("ue", "9"),
70 ("an", "2"),
71 ("en", "3"),
72 ("in", "1"),
73 ("un", "9"),
74 ("ang", "5"),
75 ("eng", "5"),
76 ("ing", "4"),
77 ("ong", "5"),
78 ("ia", "1"),
79 ("ua", "9"),
80 ("uo", "9"),
81 ("uai", "8"),
82 ("ian", "4"),
83 ("uan", "8"),
84 ("iao", "4"),
85 ("iang", "7"),
86 ("uang", "7"),
87 ("iong", "7"),
88 ],
89 zero_initial_key: b'6',
90 er_key: b'3',
91};
92
93const MIGALA_3: BuiltinTable = BuiltinTable {
95 initials: [
96 ("b", "2"),
97 ("p", "7"),
98 ("m", "6"),
99 ("f", "3"),
100 ("d", "3"),
101 ("t", "8"),
102 ("n", "6"),
103 ("l", "5"),
104 ("g", "4"),
105 ("k", "5"),
106 ("h", "4"),
107 ("j", "5"),
108 ("q", "7"),
109 ("x", "9"),
110 ("r", "4"),
111 ("z", "9"),
112 ("c", "2"),
113 ("s", "7"),
114 ("y", "9"),
115 ("w", "9"),
116 ("zh", "8"),
117 ("ch", "3"),
118 ("sh", "8"),
119 ],
120 finals: [
121 ("a", "2"),
122 ("o", "6"),
123 ("e", "3"),
124 ("i", "4"),
125 ("u", "0"),
126 ("v", "0"),
127 ("ai", "2"),
128 ("ei", "0"),
129 ("ui", "6"),
130 ("ao", "7"),
131 ("ou", "1"),
132 ("iu", "8"),
133 ("ie", "4"),
134 ("ve", "3"),
135 ("ue", "3"),
136 ("an", "5"),
137 ("en", "3"),
138 ("in", "6"),
139 ("un", "8"),
140 ("ang", "5"),
141 ("eng", "8"),
142 ("ing", "1"),
143 ("ong", "1"),
144 ("ia", "4"),
145 ("ua", "9"),
146 ("uo", "6"),
147 ("uai", "9"),
148 ("ian", "9"),
149 ("uan", "7"),
150 ("iao", "9"),
151 ("iang", "2"),
152 ("uang", "4"),
153 ("iong", "6"),
154 ],
155 zero_initial_key: b'2',
156 er_key: b'3',
157};
158
159const GUSHI_2_LOW: BuiltinTable = BuiltinTable {
161 initials: [
162 ("b", "2"),
163 ("p", "7"),
164 ("m", "6"),
165 ("f", "3"),
166 ("d", "3"),
167 ("t", "8"),
168 ("n", "6"),
169 ("l", "5"),
170 ("g", "4"),
171 ("k", "5"),
172 ("h", "4"),
173 ("j", "5"),
174 ("q", "7"),
175 ("x", "9"),
176 ("r", "7"),
177 ("z", "9"),
178 ("c", "2"),
179 ("s", "7"),
180 ("y", "9"),
181 ("w", "9"),
182 ("zh", "8"),
183 ("ch", "3"),
184 ("sh", "8"),
185 ],
186 finals: [
187 ("a", "2"),
188 ("o", "6"),
189 ("e", "3"),
190 ("i", "4"),
191 ("u", "8"),
192 ("v", "8"),
193 ("ai", "2"),
194 ("ei", "3"),
195 ("ui", "8"),
196 ("ao", "7"),
197 ("ou", "1"),
198 ("iu", "4"),
199 ("ie", "4"),
200 ("ve", "8"),
201 ("ue", "8"),
202 ("an", "5"),
203 ("en", "3"),
204 ("in", "0"),
205 ("un", "6"),
206 ("ang", "5"),
207 ("eng", "0"),
208 ("ing", "0"),
209 ("ong", "1"),
210 ("ia", "4"),
211 ("ua", "9"),
212 ("uo", "6"),
213 ("uai", "9"),
214 ("ian", "9"),
215 ("uan", "7"),
216 ("iao", "9"),
217 ("iang", "9"),
218 ("uang", "7"),
219 ("iong", "1"),
220 ],
221 zero_initial_key: b'2',
222 er_key: b'3',
223};
224
225struct BuiltinTable {
227 initials: [(&'static str, &'static str); 23],
228 finals: [(&'static str, &'static str); 33],
229 zero_initial_key: u8,
231 er_key: u8,
233}
234
235impl BuiltinTable {
236 fn zero_initial_code(&self, syllable: &str) -> String {
238 let final_key = if syllable == "er" {
239 char::from(self.er_key)
240 } else {
241 self.finals
242 .iter()
243 .find(|(final_unit, _)| *final_unit == syllable)
244 .and_then(|(_, key)| key.chars().next())
245 .unwrap_or('?')
246 };
247 format!("{}{final_key}", char::from(self.zero_initial_key))
248 }
249
250 fn table(&self) -> NineKeyShuangpinTable {
251 let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
252 pairs
253 .iter()
254 .map(|(unit, key)| ((*unit).to_owned(), (*key).to_owned()))
255 .collect()
256 };
257 NineKeyShuangpinTable {
258 initials: owned(&self.initials),
259 finals: owned(&self.finals),
260 zero_initials: ZERO_INITIALS
261 .iter()
262 .map(|syllable| ((*syllable).to_owned(), self.zero_initial_code(syllable)))
263 .collect(),
264 }
265 }
266}
267
268fn builtin_source(kind: NineKeyShuangpinKind) -> Option<&'static BuiltinTable> {
269 match kind {
270 NineKeyShuangpinKind::Migala2 => Some(&MIGALA_2),
271 NineKeyShuangpinKind::Migala3 => Some(&MIGALA_3),
272 NineKeyShuangpinKind::Gushi2Low => Some(&GUSHI_2_LOW),
273 NineKeyShuangpinKind::Custom => None,
274 }
275}
276
277pub fn builtin_table(kind: NineKeyShuangpinKind) -> Option<NineKeyShuangpinTable> {
279 builtin_source(kind).map(BuiltinTable::table)
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
286pub enum NineKeyShuangpinError {
287 UnknownUnit { part: CustomTablePart, unit: String },
289 DuplicateUnit { part: CustomTablePart, unit: String },
291 MissingUnit {
293 part: CustomTablePart,
294 unit: &'static str,
295 },
296 InvalidKey {
298 part: CustomTablePart,
299 unit: String,
300 key: String,
301 },
302}
303
304impl fmt::Display for NineKeyShuangpinError {
305 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306 match self {
307 Self::UnknownUnit { part, unit } => {
308 write!(formatter, "unknown {} unit {unit:?}", part.name())
309 }
310 Self::DuplicateUnit { part, unit } => {
311 write!(formatter, "{} unit {unit:?} appears twice", part.name())
312 }
313 Self::MissingUnit { part, unit } => {
314 write!(formatter, "{} unit {unit:?} has no key", part.name())
315 }
316 Self::InvalidKey { part, unit, key } => write!(
317 formatter,
318 "{} unit {unit:?} has an invalid key {key:?}",
319 part.name()
320 ),
321 }
322 }
323}
324
325impl std::error::Error for NineKeyShuangpinError {}
326
327enum Parts {
329 Consonant {
330 initial: &'static str,
331 final_unit: &'static str,
332 },
333 Zero,
334}
335
336fn parts(syllable: &'static str) -> Parts {
338 for initial in ["zh", "ch", "sh"] {
339 if let Some(final_unit) = syllable.strip_prefix(initial) {
340 return Parts::Consonant {
341 initial,
342 final_unit,
343 };
344 }
345 }
346 let initial = INITIALS
347 .iter()
348 .take(20)
349 .find(|initial| syllable.as_bytes().first() == initial.as_bytes().first());
350 match initial {
351 Some(initial) => Parts::Consonant {
352 initial,
353 final_unit: &syllable[1..],
354 },
355 None => Parts::Zero,
356 }
357}
358
359pub(crate) fn syllables() -> &'static [&'static str] {
361 static SYLLABLES: OnceLock<Vec<&'static str>> = OnceLock::new();
362 SYLLABLES.get_or_init(|| {
363 let mut syllables: Vec<&'static str> = intact_pinyin_list()
364 .iter()
365 .copied()
366 .filter(|syllable| canonical_lattice_syllable(syllable) == *syllable)
367 .collect();
368 for syllable in ZERO_INITIALS {
369 if !syllables.contains(&syllable) {
370 syllables.push(syllable);
371 }
372 }
373 syllables
374 })
375}
376
377#[derive(PartialEq, Eq)]
379struct Canonical {
380 initials: Vec<(&'static str, u8)>,
381 finals: Vec<(&'static str, u8)>,
382 zero_initials: Vec<(&'static str, [u8; 2])>,
383}
384
385fn canonicalize(table: &NineKeyShuangpinTable) -> Result<Canonical, NineKeyShuangpinError> {
386 let single = |key: &[u8]| match key {
387 [digit] if digit.is_ascii_digit() => Some(*digit),
388 _ => None,
389 };
390 let pair = |key: &[u8]| match key {
391 [first, second] if first.is_ascii_digit() && second.is_ascii_digit() => {
392 Some([*first, *second])
393 }
394 _ => None,
395 };
396 Ok(Canonical {
397 initials: canonical_part(
398 CustomTablePart::Initials,
399 &table.initials,
400 &INITIALS,
401 single,
402 )?,
403 finals: canonical_part(CustomTablePart::Finals, &table.finals, &FINALS, single)?,
404 zero_initials: canonical_part(
405 CustomTablePart::ZeroInitials,
406 &table.zero_initials,
407 &ZERO_INITIALS,
408 pair,
409 )?,
410 })
411}
412
413fn canonical_part<K: Copy>(
414 part: CustomTablePart,
415 entries: &[(String, String)],
416 units: &[&'static str],
417 parse: impl Fn(&[u8]) -> Option<K>,
418) -> Result<Vec<(&'static str, K)>, NineKeyShuangpinError> {
419 let mut keys: Vec<Option<K>> = vec![None; units.len()];
420 for (unit, key) in entries {
421 let Some(index) = units.iter().position(|known| known == unit) else {
422 return Err(NineKeyShuangpinError::UnknownUnit {
423 part,
424 unit: unit.clone(),
425 });
426 };
427 if keys[index].is_some() {
428 return Err(NineKeyShuangpinError::DuplicateUnit {
429 part,
430 unit: unit.clone(),
431 });
432 }
433 let Some(parsed) = parse(key.as_bytes()) else {
434 return Err(NineKeyShuangpinError::InvalidKey {
435 part,
436 unit: unit.clone(),
437 key: key.clone(),
438 });
439 };
440 keys[index] = Some(parsed);
441 }
442 units
443 .iter()
444 .zip(keys)
445 .map(|(unit, key)| {
446 key.map(|key| (*unit, key))
447 .ok_or(NineKeyShuangpinError::MissingUnit { part, unit })
448 })
449 .collect()
450}
451
452pub struct NineKeyShuangpin {
454 canonical: Canonical,
455 codes: HashMap<&'static str, [u8; 2]>,
457 aliases: HashMap<&'static str, Vec<[u8; 2]>>,
459 letters: [String; 62],
461 pub(crate) spelling: OnceLock<SpellingTable>,
463}
464
465impl fmt::Debug for NineKeyShuangpin {
466 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
467 formatter
468 .debug_struct("NineKeyShuangpin")
469 .field("initials", &self.canonical.initials)
470 .field("finals", &self.canonical.finals)
471 .field("zero_initials", &self.canonical.zero_initials)
472 .finish_non_exhaustive()
473 }
474}
475
476fn key_slot(key: u8) -> Option<usize> {
477 match key {
478 b'0'..=b'9' => Some(usize::from(key - b'0')),
479 b'a'..=b'z' => Some(10 + usize::from(key - b'a')),
480 b'A'..=b'Z' => Some(36 + usize::from(key - b'A')),
481 _ => None,
482 }
483}
484
485impl NineKeyShuangpin {
486 fn build(canonical: Canonical) -> Self {
487 let initial_key = |unit: &str| {
488 canonical
489 .initials
490 .iter()
491 .find(|(known, _)| *known == unit)
492 .map(|(_, key)| *key)
493 };
494 let final_key = |unit: &str| {
495 canonical
496 .finals
497 .iter()
498 .find(|(known, _)| *known == unit)
499 .map(|(_, key)| *key)
500 };
501 let mut codes = HashMap::with_capacity(syllables().len());
502 for &syllable in syllables() {
503 let code = match parts(syllable) {
504 Parts::Consonant {
505 initial,
506 final_unit,
507 } => match (initial_key(initial), final_key(final_unit)) {
508 (Some(first), Some(second)) => [first, second],
509 _ => continue,
511 },
512 Parts::Zero => match canonical
513 .zero_initials
514 .iter()
515 .find(|(known, _)| *known == syllable)
516 {
517 Some((_, code)) => *code,
518 None => continue,
519 },
520 };
521 codes.insert(syllable, code);
522 }
523 let mut letters: [Vec<u8>; 62] = std::array::from_fn(|_| Vec::new());
524 let mut add = |digit: u8, letter: u8| {
525 let Some(index) = key_slot(digit) else { return };
526 let slot = &mut letters[index];
527 if !slot.contains(&letter) {
528 slot.push(letter);
529 }
530 };
531 for (initial, key) in &canonical.initials {
532 add(*key, initial.as_bytes()[0]);
533 }
534 for (syllable, code) in &canonical.zero_initials {
535 add(code[0], syllable.as_bytes()[0]);
536 }
537 let letters = letters.map(|mut slot| {
538 slot.sort_unstable();
539 String::from_utf8(slot).unwrap_or_default()
540 });
541 Self {
542 canonical,
543 codes,
544 aliases: HashMap::new(),
545 letters,
546 spelling: OnceLock::new(),
547 }
548 }
549
550 pub fn syllable_code(&self, syllable: &str) -> Option<&[u8; 2]> {
552 self.codes.get(syllable)
553 }
554
555 pub(crate) fn syllable_codes(&self, syllable: &str) -> impl Iterator<Item = &[u8; 2]> {
556 self.codes
557 .get(syllable)
558 .into_iter()
559 .chain(self.aliases.get(syllable).into_iter().flatten())
560 }
561
562 pub fn initial_key(&self, initial: &str) -> Option<u8> {
564 self.canonical
565 .initials
566 .iter()
567 .find(|(unit, _)| *unit == initial)
568 .map(|(_, key)| *key)
569 }
570
571 pub(crate) fn coded_syllables(&self) -> impl Iterator<Item = (&'static str, &[u8; 2])> {
573 syllables().iter().flat_map(|&syllable| {
574 self.syllable_codes(syllable)
575 .map(move |code| (syllable, code))
576 })
577 }
578
579 pub(crate) fn one_key_pieces(&self) -> Vec<(&'static str, u8)> {
581 let mut pieces: Vec<(&'static str, u8)> = self.canonical.initials.clone();
582 for (syllable, code) in &self.canonical.zero_initials {
583 let letter = &syllable[..1];
584 if !pieces
585 .iter()
586 .any(|(piece, key)| *piece == letter && *key == code[0])
587 {
588 pieces.push((letter, code[0]));
589 }
590 }
591 pieces
592 }
593
594 pub(crate) fn piece_code_len(&self, piece: &str, digits: &str) -> Option<usize> {
596 let digits = digits.as_bytes();
597 if digits.len() >= 2
598 && self
599 .syllable_codes(piece)
600 .any(|code| code[..] == digits[..2])
601 {
602 return Some(2);
603 }
604 let first = *digits.first()?;
605 let one_key = self
606 .canonical
607 .initials
608 .iter()
609 .any(|(unit, key)| *unit == piece && *key == first)
610 || self
611 .canonical
612 .zero_initials
613 .iter()
614 .any(|(syllable, code)| code[0] == first && syllable.get(..1) == Some(piece));
615 one_key.then_some(1)
616 }
617
618 pub fn initial_part<'a>(&self, syllable: &'a str) -> &'a str {
620 let length = if ["zh", "ch", "sh"]
621 .iter()
622 .any(|initial| syllable.starts_with(initial))
623 {
624 2
625 } else {
626 1
627 };
628 syllable.get(..length).unwrap_or(syllable)
629 }
630
631 pub fn letters(&self, digit: u8) -> &str {
633 key_slot(digit).map_or("", |index| self.letters[index].as_str())
634 }
635
636 pub fn uses_key(&self, digit: u8) -> bool {
638 key_slot(digit).is_some()
639 && (self.canonical.initials.iter().any(|(_, key)| *key == digit)
640 || self.canonical.finals.iter().any(|(_, key)| *key == digit)
641 || self
642 .canonical
643 .zero_initials
644 .iter()
645 .any(|(_, code)| code.contains(&digit)))
646 }
647
648 pub fn encode(&self, pinyin: &str) -> String {
650 let mut code = String::with_capacity(pinyin.len());
651 for piece in pinyin.split('\'') {
652 if let Some(pair) = self.codes.get(piece) {
653 code.push(char::from(pair[0]));
654 code.push(char::from(pair[1]));
655 } else if let Some(key) = self.initial_key(piece) {
656 code.push(char::from(key));
657 } else {
658 return String::new();
659 }
660 }
661 code
662 }
663
664 pub(crate) fn matching_prefix_len(&self, pinyin: &str, input: &str) -> Option<usize> {
666 let mut offset = 0;
667 for piece in pinyin.split('\'') {
668 let remaining = input.as_bytes().get(offset..).unwrap_or_default();
669 if self.codes.contains_key(piece) {
670 let covered = remaining.len().min(2);
671 if !self
672 .syllable_codes(piece)
673 .any(|code| code[..covered] == remaining[..covered])
674 {
675 return None;
676 }
677 offset += 2;
678 } else {
679 if !self.canonical.initials.iter().any(|(unit, key)| {
680 *unit == piece && remaining.first().is_none_or(|value| value == key)
681 }) {
682 return None;
683 }
684 offset += 1;
685 }
686 }
687 (offset > 0).then_some(offset.min(input.len()))
688 }
689
690 pub fn spells_keyword(&self, keyword: &str, digits: &str) -> bool {
692 let pieces = crate::pinyin::segment::cut_one_piece_min_segments(keyword, true);
693 if !pieces.is_empty()
694 && pieces.len() * 2 == digits.len()
695 && self.matching_prefix_len(&pieces.join("'"), digits) == Some(digits.len())
696 {
697 return true;
698 }
699 keyword.len() == digits.len()
700 && keyword.bytes().zip(digits.bytes()).all(|(letter, digit)| {
701 self.canonical
702 .initials
703 .iter()
704 .any(|(initial, key)| *key == digit && initial.as_bytes()[0] == letter)
705 })
706 }
707
708 pub fn spells_initials(&self, canonical_pinyin: &str, digits: &str) -> bool {
710 let mut count = 0;
711 for (syllable, &digit) in canonical_pinyin.split('\'').zip(digits.as_bytes()) {
712 count += 1;
713 if !self.syllable_codes(syllable).any(|code| code[0] == digit) {
714 return false;
715 }
716 }
717 count == digits.len()
718 }
719
720 pub fn key_hints(&self) -> Vec<NineKeyShuangpinKeyHint> {
722 let umlaut = |unit: &str| match unit.strip_prefix('v') {
723 Some(rest) => format!("ü{rest}"),
724 None => unit.to_owned(),
725 };
726 DIGIT_ORDER
727 .iter()
728 .filter(|digit| self.uses_key(**digit))
729 .map(|&digit| NineKeyShuangpinKeyHint {
730 key: char::from(digit),
731 initials: {
732 let mut initials: Vec<&str> = self
733 .canonical
734 .initials
735 .iter()
736 .filter(|(_, key)| *key == digit)
737 .map(|(unit, _)| *unit)
738 .collect();
739 initials.sort_by_key(|unit| (unit.len(), *unit));
740 initials.into_iter().map(str::to_owned).collect()
741 },
742 finals: self
743 .canonical
744 .finals
745 .iter()
746 .filter(|(_, key)| *key == digit)
747 .map(|(unit, _)| umlaut(unit))
748 .collect(),
749 zero_initial: self
750 .canonical
751 .zero_initials
752 .iter()
753 .any(|(_, code)| code[0] == digit),
754 })
755 .collect()
756 }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
761pub struct NineKeyShuangpinKeyHint {
762 pub key: char,
763 pub initials: Vec<String>,
764 pub finals: Vec<String>,
765 pub zero_initial: bool,
766}
767
768fn interned() -> MutexGuard<'static, Vec<&'static NineKeyShuangpin>> {
770 static INTERNED: OnceLock<Mutex<Vec<&'static NineKeyShuangpin>>> = OnceLock::new();
771 INTERNED
772 .get_or_init(Mutex::default)
773 .lock()
774 .unwrap_or_else(PoisonError::into_inner)
775}
776
777pub fn nine_key_shuangpin(
779 table: &NineKeyShuangpinTable,
780) -> Result<&'static NineKeyShuangpin, NineKeyShuangpinError> {
781 let canonical = canonicalize(table)?;
782 let mut interned = interned();
783 if let Some(found) = interned.iter().find(|found| found.canonical == canonical) {
784 return Ok(found);
785 }
786 let leaked: &'static NineKeyShuangpin = Box::leak(Box::new(NineKeyShuangpin::build(canonical)));
787 interned.push(leaked);
788 Ok(leaked)
789}
790
791fn letter_key_shuangpin(
793 profile: &'static crate::shuangpin::ShuangpinProfile,
794 eighteen_key: bool,
795) -> Option<&'static NineKeyShuangpin> {
796 type DerivedTable = (
797 bool,
798 &'static crate::shuangpin::ShuangpinProfile,
799 &'static NineKeyShuangpin,
800 );
801 static DERIVED: OnceLock<Mutex<Vec<DerivedTable>>> = OnceLock::new();
802 let mut derived = DERIVED
803 .get_or_init(Mutex::default)
804 .lock()
805 .unwrap_or_else(PoisonError::into_inner);
806 if let Some((_, _, table)) = derived
807 .iter()
808 .find(|(layout, source, _)| *layout == eighteen_key && std::ptr::eq(*source, profile))
809 {
810 return Some(table);
811 }
812 let key = |value: u8| {
813 if eighteen_key {
814 crate::nine_key::eighteen_key_code(value)
815 } else if value == b';' {
816 Some(b'l')
817 } else {
818 crate::KeyGrid::FourteenKey.code_of(value)
819 }
820 };
821 let lookup = |pairs: &[(&str, &str)], unit: &str| {
822 pairs
823 .iter()
824 .find(|(name, _)| *name == unit)
825 .and_then(|(_, value)| match value.as_bytes() {
826 [letter] => key(*letter),
827 _ => None,
828 })
829 };
830 let mut canonical = Canonical {
831 initials: INITIALS
832 .iter()
833 .map(|&unit| {
834 let value = if unit.len() == 1 {
835 key(unit.as_bytes()[0])
836 } else {
837 lookup(profile.initials, unit)
838 }?;
839 Some((unit, value))
840 })
841 .collect::<Option<Vec<_>>>()?,
842 finals: FINALS
843 .iter()
844 .map(|&unit| Some((unit, lookup(profile.finals, unit)?)))
845 .collect::<Option<Vec<_>>>()?,
846 zero_initials: ZERO_INITIALS
847 .iter()
848 .map(|&unit| {
849 let code = profile
850 .zero_initials
851 .iter()
852 .find(|(name, _)| *name == unit)?
853 .1;
854 let [first, second] = code.as_bytes() else {
855 return None;
856 };
857 Some((unit, [key(*first)?, key(*second)?]))
858 })
859 .collect::<Option<Vec<_>>>()?,
860 };
861 if eighteen_key {
862 for &unit in &INITIALS {
864 let raw = if unit.len() == 1 {
865 unit.as_bytes()[0]
866 } else {
867 profile
868 .initials
869 .iter()
870 .find(|(name, _)| *name == unit)?
871 .1
872 .as_bytes()[0]
873 };
874 canonical.initials.push((unit, raw.to_ascii_uppercase()));
875 }
876 for &unit in &FINALS {
877 let raw = profile
878 .finals
879 .iter()
880 .find(|(name, _)| *name == unit)?
881 .1
882 .as_bytes()[0];
883 if raw.is_ascii_lowercase() {
884 canonical.finals.push((unit, raw.to_ascii_uppercase()));
885 }
886 }
887 for &(unit, raw) in profile.zero_initials {
888 let [first, second] = *raw.as_bytes() else {
889 return None;
890 };
891 let group = [key(first)?, key(second)?];
892 for pair in precise_pair_variants([first, second], group) {
893 if !canonical.zero_initials.contains(&(unit, pair)) {
894 canonical.zero_initials.push((unit, pair));
895 }
896 }
897 }
898 }
899 let mut table = NineKeyShuangpin::build(canonical);
900 table
901 .codes
902 .retain(|syllable, _| crate::shuangpin::profile::accepted_syllables().contains(syllable));
903 for first in b'a'..=b'z' {
904 for second in (b'a'..=b'z').chain(std::iter::once(b';')) {
905 let raw = String::from_utf8(vec![first, second]).ok()?;
906 let decoded = crate::shuangpin::utils::cvt_single_sp_to_pinyin(&raw, profile);
907 let Some(&syllable) = syllables().iter().find(|&&value| value == decoded) else {
908 continue;
909 };
910 let group = [key(first)?, key(second)?];
911 let variants = if eighteen_key {
912 precise_pair_variants([first, second], group).to_vec()
913 } else {
914 vec![group]
915 };
916 for code in variants {
917 if table.codes.get(syllable) != Some(&code) {
918 let aliases = table.aliases.entry(syllable).or_default();
919 if !aliases.contains(&code) {
920 aliases.push(code);
921 }
922 }
923 }
924 }
925 }
926 let leaked: &'static NineKeyShuangpin = Box::leak(Box::new(table));
927 derived.push((eighteen_key, profile, leaked));
928 Some(leaked)
929}
930
931fn precise_pair_variants(raw: [u8; 2], group: [u8; 2]) -> [[u8; 2]; 4] {
933 let precise = std::array::from_fn::<_, 2, _>(|index| {
934 if raw[index].is_ascii_lowercase() {
935 raw[index].to_ascii_uppercase()
936 } else {
937 group[index]
938 }
939 });
940 [
941 group,
942 [precise[0], group[1]],
943 [group[0], precise[1]],
944 precise,
945 ]
946}
947
948pub fn resolve_fourteen_key_shuangpin(
950 kind: crate::ShuangpinProfileKind,
951 custom: Option<&crate::ShuangpinCustomTable>,
952) -> crate::Result<&'static NineKeyShuangpin> {
953 let profile = crate::shuangpin::custom::session_profile(kind, custom, true)?;
954 letter_key_shuangpin(profile, false)
955 .ok_or_else(|| crate::EngineError::invalid("invalid fourteen-key shuangpin profile"))
956}
957
958pub fn resolve_eighteen_key_shuangpin(
960 kind: crate::ShuangpinProfileKind,
961 custom: Option<&crate::ShuangpinCustomTable>,
962) -> crate::Result<&'static NineKeyShuangpin> {
963 let profile = crate::shuangpin::custom::session_profile(kind, custom, true)?;
964 letter_key_shuangpin(profile, true)
965 .ok_or_else(|| crate::EngineError::invalid("invalid eighteen-key shuangpin profile"))
966}
967
968pub fn check_table(table: &NineKeyShuangpinTable) -> Result<(), NineKeyShuangpinError> {
970 canonicalize(table).map(|_| ())
971}
972
973pub fn table_key_hints(
975 table: &NineKeyShuangpinTable,
976) -> Result<Vec<NineKeyShuangpinKeyHint>, NineKeyShuangpinError> {
977 Ok(NineKeyShuangpin::build(canonicalize(table)?).key_hints())
978}
979
980pub fn resolve(
982 kind: NineKeyShuangpinKind,
983 custom: Option<&NineKeyShuangpinTable>,
984) -> Result<&'static NineKeyShuangpin, NineKeyShuangpinError> {
985 match builtin_table(kind) {
986 Some(table) => nine_key_shuangpin(&table),
987 None => nine_key_shuangpin(custom.unwrap_or(&NineKeyShuangpinTable::default())),
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 #[test]
996 fn eighteen_key_preserves_every_accepted_builtin_and_custom_encoding() {
997 use crate::types::ShuangpinProfileKind;
998 let groups = [
999 "q", "we", "rt", "y", "u", "io", "p", "a", "sd", "fg", "h", "jk", "l;", "z", "xc", "v",
1000 "bn", "m",
1001 ];
1002 let expected_key = |letter| {
1003 groups
1004 .iter()
1005 .find(|group| group.as_bytes().contains(&letter))
1006 .unwrap()
1007 .as_bytes()[0]
1008 };
1009 for kind in [
1010 ShuangpinProfileKind::Xiaohe,
1011 ShuangpinProfileKind::Ziranma,
1012 ShuangpinProfileKind::Microsoft,
1013 ShuangpinProfileKind::Shoudao,
1014 ShuangpinProfileKind::SmartAbc,
1015 ] {
1016 let profile = crate::shuangpin::profile::profile(kind).unwrap();
1017 let custom_source = crate::shuangpin::custom::builtin_table(kind).unwrap();
1018 let table = resolve_eighteen_key_shuangpin(kind, None).unwrap();
1019 let fourteen = resolve_fourteen_key_shuangpin(kind, None).unwrap();
1020 assert!(
1021 !std::ptr::eq(table, fourteen),
1022 "布局必须拥有独立的派生表和拼写缓存"
1023 );
1024 assert!(std::ptr::eq(
1025 table,
1026 resolve_eighteen_key_shuangpin(kind, None).unwrap()
1027 ));
1028 let custom =
1029 resolve_eighteen_key_shuangpin(ShuangpinProfileKind::Custom, Some(&custom_source))
1030 .unwrap();
1031 let grid = crate::KeyGrid::EighteenKeyShuangpin(table);
1032 for letter in (b'a'..=b'z').chain(std::iter::once(b';')) {
1033 assert_eq!(grid.code_of(letter), Some(expected_key(letter)));
1034 }
1035 for first in b'a'..=b'z' {
1036 for second in (b'a'..=b'z').chain(std::iter::once(b';')) {
1037 let raw = String::from_utf8(vec![first, second]).unwrap();
1038 let syllable = crate::shuangpin::utils::cvt_single_sp_to_pinyin(&raw, profile);
1039 if syllable.is_empty() {
1040 continue;
1041 }
1042 let projected = [expected_key(first), expected_key(second)];
1043 for actual in [table, custom] {
1044 assert!(
1045 actual
1046 .syllable_codes(&syllable)
1047 .any(|code| *code == projected),
1048 "{kind:?} {raw} {syllable} {projected:?}"
1049 );
1050 let input = String::from_utf8(projected.to_vec()).unwrap();
1051 assert_eq!(actual.matching_prefix_len(&syllable, &input), Some(2));
1052 }
1053 }
1054 }
1055 }
1056 assert!(resolve_eighteen_key_shuangpin(ShuangpinProfileKind::Custom, None).is_err());
1057 assert!(resolve_eighteen_key_shuangpin(
1058 ShuangpinProfileKind::Custom,
1059 Some(&crate::ShuangpinCustomTable::default())
1060 )
1061 .is_err());
1062 }
1063
1064 #[test]
1065 fn fourteen_key_preserves_every_accepted_builtin_and_custom_encoding() {
1066 use crate::types::ShuangpinProfileKind;
1067 for kind in [
1068 ShuangpinProfileKind::Xiaohe,
1069 ShuangpinProfileKind::Ziranma,
1070 ShuangpinProfileKind::Microsoft,
1071 ShuangpinProfileKind::Shoudao,
1072 ShuangpinProfileKind::SmartAbc,
1073 ] {
1074 let profile = crate::shuangpin::profile::profile(kind).unwrap();
1075 let custom_source = crate::shuangpin::custom::builtin_table(kind).unwrap();
1076 let table = resolve_fourteen_key_shuangpin(kind, None).unwrap();
1077 assert!(std::ptr::eq(
1078 table,
1079 resolve_fourteen_key_shuangpin(kind, None).unwrap()
1080 ));
1081 let custom =
1082 resolve_fourteen_key_shuangpin(ShuangpinProfileKind::Custom, Some(&custom_source))
1083 .unwrap();
1084 let grid = crate::KeyGrid::FourteenKey;
1085 for first in b'a'..=b'z' {
1086 for second in (b'a'..=b'z').chain(std::iter::once(b';')) {
1087 let raw = String::from_utf8(vec![first, second]).unwrap();
1088 let syllable = crate::shuangpin::utils::cvt_single_sp_to_pinyin(&raw, profile);
1089 if syllable.is_empty() {
1090 continue;
1091 }
1092 let projected = [
1093 grid.code_of(first).unwrap(),
1094 if second == b';' {
1095 b'l'
1096 } else {
1097 grid.code_of(second).unwrap()
1098 },
1099 ];
1100 for actual in [table, custom] {
1101 assert!(
1102 actual
1103 .syllable_codes(&syllable)
1104 .any(|code| *code == projected),
1105 "{kind:?} {raw} {syllable} {projected:?}"
1106 );
1107 let input = String::from_utf8(projected.to_vec()).unwrap();
1108 assert_eq!(
1109 actual.matching_prefix_len(&syllable, &input),
1110 Some(2),
1111 "{kind:?} {raw}"
1112 );
1113 }
1114 }
1115 }
1116 }
1117 }
1118
1119 #[test]
1120 fn eighteen_key_exact_pairs_match_only_the_source_profile() {
1121 use crate::types::ShuangpinProfileKind;
1122 for kind in [
1123 ShuangpinProfileKind::Xiaohe,
1124 ShuangpinProfileKind::Ziranma,
1125 ShuangpinProfileKind::Microsoft,
1126 ShuangpinProfileKind::Shoudao,
1127 ShuangpinProfileKind::SmartAbc,
1128 ] {
1129 let profile = crate::shuangpin::profile::profile(kind).unwrap();
1130 let custom = crate::shuangpin::custom::builtin_table(kind).unwrap();
1131 for table in [
1132 resolve_eighteen_key_shuangpin(kind, None).unwrap(),
1133 resolve_eighteen_key_shuangpin(ShuangpinProfileKind::Custom, Some(&custom))
1134 .unwrap(),
1135 ] {
1136 for first in b'a'..=b'z' {
1137 for second in b'a'..=b'z' {
1138 let raw = String::from_utf8(vec![first, second]).unwrap();
1139 let expected =
1140 crate::shuangpin::utils::cvt_single_sp_to_pinyin(&raw, profile);
1141 let exact = [first.to_ascii_uppercase(), second.to_ascii_uppercase()];
1142 let found: Vec<_> = table
1143 .coded_syllables()
1144 .filter(|(_, code)| **code == exact)
1145 .map(|(syllable, _)| syllable)
1146 .collect();
1147 if expected.is_empty() {
1148 assert!(found.is_empty(), "{kind:?} {raw}: {found:?}");
1149 } else {
1150 assert_eq!(found, [expected.as_str()], "{kind:?} {raw}");
1151 }
1152 }
1153 }
1154 }
1155 }
1156 }
1157
1158 #[test]
1159 fn fourteen_key_keeps_semicolon_aliases_and_rejects_invalid_custom_tables() {
1160 use crate::types::ShuangpinProfileKind;
1161 let table = resolve_fourteen_key_shuangpin(ShuangpinProfileKind::Microsoft, None).unwrap();
1162 assert_eq!(table.encode("qing"), "ql");
1163 for code in ["ju", "jt"] {
1164 assert_eq!(table.matching_prefix_len("ju", code), Some(2));
1165 assert_eq!(table.piece_code_len("ju", code), Some(2));
1166 assert_eq!(
1167 table.matching_prefix_len("ju'ni", &format!("{code}bu")),
1168 Some(4)
1169 );
1170 }
1171 assert_eq!(table.matching_prefix_len("ju", "ja"), None);
1172 assert_eq!(table.matching_prefix_len("ju'unknown", "ju"), None);
1173 assert!(resolve_fourteen_key_shuangpin(ShuangpinProfileKind::Custom, None).is_err());
1174 assert!(resolve_fourteen_key_shuangpin(
1175 ShuangpinProfileKind::Custom,
1176 Some(&crate::ShuangpinCustomTable::default())
1177 )
1178 .is_err());
1179 let mut digits = builtin_table(NineKeyShuangpinKind::Migala3).unwrap();
1180 digits.initials[0].1 = "q".to_owned();
1181 assert!(check_table(&digits).is_err(), "九键用户表仍只接受数字");
1182 }
1183
1184 fn table_of(kind: NineKeyShuangpinKind) -> NineKeyShuangpinTable {
1185 builtin_table(kind).unwrap()
1186 }
1187
1188 fn code(profile: &NineKeyShuangpin, syllable: &str) -> String {
1189 profile.encode(syllable)
1190 }
1191
1192 fn replace(entries: &mut [(String, String)], unit: &str, key: &str) {
1193 entries
1194 .iter_mut()
1195 .find(|(known, _)| known == unit)
1196 .unwrap()
1197 .1 = key.to_owned();
1198 }
1199
1200 #[test]
1201 fn every_syllable_has_one_code() {
1202 for &syllable in syllables() {
1204 match parts(syllable) {
1205 Parts::Consonant {
1206 initial,
1207 final_unit,
1208 } => {
1209 assert!(INITIALS.contains(&initial), "{syllable}");
1210 assert!(FINALS.contains(&final_unit), "{syllable}");
1211 }
1212 Parts::Zero => assert!(ZERO_INITIALS.contains(&syllable), "{syllable}"),
1213 }
1214 }
1215 for kind in NineKeyShuangpinKind::BUILTIN {
1216 let profile = resolve(kind, None).unwrap();
1217 assert_eq!(profile.codes.len(), syllables().len(), "{kind:?}");
1218 }
1219 assert!(!syllables().contains(&"jv"));
1220 assert!(!syllables().contains(&"lue"));
1221 assert!(syllables().contains(&"lve"));
1222 assert!(syllables().contains(&"eng"));
1223 }
1224
1225 #[test]
1227 fn builtin_tables_round_trip() {
1228 for kind in NineKeyShuangpinKind::BUILTIN {
1229 let table = table_of(kind);
1230 assert_eq!(table.initials.len(), 23);
1231 assert_eq!(table.finals.len(), 33);
1232 assert_eq!(table.zero_initials.len(), 12);
1233 let builtin = resolve(kind, None).unwrap();
1234 let custom = resolve(NineKeyShuangpinKind::Custom, Some(&table)).unwrap();
1235 assert!(std::ptr::eq(builtin, custom), "{kind:?}");
1236 let mut shuffled = table.clone();
1237 shuffled.initials.reverse();
1238 shuffled.finals.rotate_left(7);
1239 assert!(std::ptr::eq(
1240 builtin,
1241 nine_key_shuangpin(&shuffled).unwrap()
1242 ));
1243 assert_eq!(check_table(&table), Ok(()));
1244 }
1245 assert_eq!(builtin_table(NineKeyShuangpinKind::Custom), None);
1246 let migala2 = resolve(NineKeyShuangpinKind::Migala2, None).unwrap();
1247 let migala3 = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1248 assert!(!std::ptr::eq(migala2, migala3));
1249 }
1250
1251 #[test]
1253 fn migala_3_follows_its_published_rules() {
1254 let profile = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1255 assert_eq!(code(profile, "xiang"), "92");
1257 assert_eq!(code(profile, "ai"), "22");
1258 assert_eq!(code(profile, "qian"), "79");
1259 assert_eq!(code(profile, "hua"), "49");
1260 assert_eq!(code(profile, "zhong"), "81");
1261 assert_eq!(code(profile, "guo"), "46");
1262 assert_eq!(code(profile, "chu"), "30");
1263 assert_eq!(code(profile, "lv"), "50");
1264 assert_eq!(code(profile, "ju"), "50");
1265 assert_eq!(code(profile, "ren"), "43");
1266 assert_eq!(code(profile, "ei"), "20");
1267 assert_eq!(code(profile, "er"), "23");
1268 assert_eq!(code(profile, "zhong'g"), "814");
1269 assert_eq!(code(profile, "zhong'xyz"), "");
1270 }
1271
1272 #[test]
1274 fn gushi_2_low_zero_initials_match_the_published_examples() {
1275 let profile = resolve(NineKeyShuangpinKind::Gushi2Low, None).unwrap();
1276 for (syllable, expected) in [
1277 ("ai", "22"),
1278 ("ao", "27"),
1279 ("ei", "23"),
1280 ("er", "23"),
1281 ("en", "23"),
1282 ("an", "25"),
1283 ("ang", "25"),
1284 ] {
1285 assert_eq!(code(profile, syllable), expected, "{syllable}");
1286 }
1287 assert_eq!(code(profile, "xing"), "90");
1288 assert_eq!(code(profile, "xiong"), "91");
1289 assert_eq!(code(profile, "lve"), "58");
1290 }
1291
1292 #[test]
1293 fn migala_2_puts_the_zero_initial_on_six() {
1294 let profile = resolve(NineKeyShuangpinKind::Migala2, None).unwrap();
1295 assert_eq!(code(profile, "ou"), "66");
1296 assert_eq!(code(profile, "ang"), "65");
1297 assert_eq!(code(profile, "chang"), "55");
1298 assert_eq!(code(profile, "shuai"), "88");
1299 assert_eq!(code(profile, "lia"), "51");
1300 }
1301
1302 #[test]
1304 fn codes_are_shared_between_syllables() {
1305 let profile = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1306 let mut on_25: Vec<&str> = profile
1307 .coded_syllables()
1308 .filter(|(_, code)| **code == *b"25")
1309 .map(|(syllable, _)| syllable)
1310 .collect();
1311 on_25.sort_unstable();
1312 assert_eq!(on_25, ["an", "ang", "ban", "bang", "can", "cang"]);
1313 }
1314
1315 #[test]
1316 fn invalid_tables_name_the_place() {
1317 let mut table = table_of(NineKeyShuangpinKind::Migala3);
1318 table.finals.retain(|(unit, _)| unit != "iong");
1319 assert_eq!(
1320 check_table(&table),
1321 Err(NineKeyShuangpinError::MissingUnit {
1322 part: CustomTablePart::Finals,
1323 unit: "iong",
1324 })
1325 );
1326 let mut table = table_of(NineKeyShuangpinKind::Migala3);
1327 table.finals.push(("iou".to_owned(), "1".to_owned()));
1328 assert_eq!(
1329 check_table(&table),
1330 Err(NineKeyShuangpinError::UnknownUnit {
1331 part: CustomTablePart::Finals,
1332 unit: "iou".to_owned(),
1333 })
1334 );
1335 let mut table = table_of(NineKeyShuangpinKind::Migala3);
1336 table.initials.push(("zh".to_owned(), "8".to_owned()));
1337 assert_eq!(
1338 check_table(&table),
1339 Err(NineKeyShuangpinError::DuplicateUnit {
1340 part: CustomTablePart::Initials,
1341 unit: "zh".to_owned(),
1342 })
1343 );
1344 for (part, unit, key) in [
1345 (CustomTablePart::Initials, "b", "a"),
1346 (CustomTablePart::Initials, "b", "22"),
1347 (CustomTablePart::Finals, "ang", ""),
1348 (CustomTablePart::Finals, "ang", "*"),
1349 (CustomTablePart::ZeroInitials, "a", "2"),
1350 (CustomTablePart::ZeroInitials, "a", "2a"),
1351 (CustomTablePart::ZeroInitials, "a", "222"),
1352 ] {
1353 let mut table = table_of(NineKeyShuangpinKind::Migala3);
1354 let entries = match part {
1355 CustomTablePart::Initials => &mut table.initials,
1356 CustomTablePart::Finals => &mut table.finals,
1357 CustomTablePart::ZeroInitials => &mut table.zero_initials,
1358 };
1359 replace(entries, unit, key);
1360 assert_eq!(
1361 check_table(&table),
1362 Err(NineKeyShuangpinError::InvalidKey {
1363 part,
1364 unit: unit.to_owned(),
1365 key: key.to_owned(),
1366 }),
1367 "{unit} {key:?}"
1368 );
1369 assert_eq!(nine_key_shuangpin(&table).map(|_| ()), check_table(&table));
1370 }
1371 assert!(matches!(
1372 resolve(NineKeyShuangpinKind::Custom, None),
1373 Err(NineKeyShuangpinError::MissingUnit { .. })
1374 ));
1375 }
1376
1377 #[test]
1379 fn a_custom_table_may_use_one_and_zero() {
1380 let mut table = table_of(NineKeyShuangpinKind::Migala3);
1381 for (syllable, key) in table.zero_initials.iter_mut() {
1382 let final_key = &key[1..];
1383 *key = format!("1{final_key}");
1384 assert_eq!(key.len(), 2, "{syllable}");
1385 }
1386 let profile = nine_key_shuangpin(&table).unwrap();
1387 assert_eq!(code(profile, "an"), "15");
1388 assert!(profile.uses_key(b'1'));
1389 assert!(profile.uses_key(b'0'));
1390 assert_eq!(profile.letters(b'1'), "aeo");
1391 }
1392
1393 #[test]
1394 fn one_key_pieces_and_letters_follow_the_table() {
1395 let profile = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1396 assert_eq!(profile.letters(b'2'), "abceo");
1397 assert_eq!(profile.letters(b'8'), "stz");
1398 assert_eq!(profile.letters(b'1'), "");
1399 let pieces = profile.one_key_pieces();
1400 assert!(pieces.contains(&("zh", b'8')));
1401 assert!(pieces.contains(&("a", b'2')));
1402 assert!(pieces.contains(&("o", b'2')));
1403 assert_eq!(pieces.iter().filter(|(piece, _)| *piece == "a").count(), 1);
1404 }
1405
1406 #[test]
1407 fn keywords_and_initials_spell_digits() {
1408 let profile = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1409 assert!(profile.spells_keyword("riqi", "4474"));
1411 assert!(profile.spells_keyword("rq", "47"));
1412 assert!(!profile.spells_keyword("rq", "4474"));
1413 assert!(profile.spells_keyword("shijian", "8459"));
1415 assert!(profile.spells_keyword("sj", "75"));
1416 assert!(profile.spells_keyword("sj", "85"));
1417 assert!(!profile.spells_keyword("sj", "95"));
1418 assert!(profile.spells_initials("zhong'guo", "84"));
1419 assert!(!profile.spells_initials("zou'guo", "84"));
1420 assert!(!profile.spells_initials("zhong", "84"));
1421 }
1422
1423 #[test]
1424 fn key_hints_list_each_used_key() {
1425 let profile = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1426 let hints = profile.key_hints();
1427 let keys: String = hints.iter().map(|hint| hint.key).collect();
1428 assert_eq!(keys, "1234567890");
1429 let two = &hints[1];
1430 assert_eq!(two.initials, ["b", "c"]);
1431 assert_eq!(two.finals, ["a", "ai", "iang"]);
1432 assert!(two.zero_initial);
1433 let zero = &hints[9];
1434 assert!(zero.initials.is_empty());
1435 assert_eq!(zero.finals, ["u", "ü", "ei"]);
1436 assert_eq!(hints[2].initials, ["d", "f", "ch"]);
1437 assert_eq!(hints[7].initials, ["t", "sh", "zh"]);
1438 assert!(!zero.zero_initial);
1439 let migala2 = resolve(NineKeyShuangpinKind::Migala2, None).unwrap();
1440 assert!(migala2.key_hints().iter().all(|hint| hint.key != '0'));
1441 }
1442}