Skip to main content

msime_engine/
nine_key_shuangpin.rs

1//! 九键双拼(#6572):每个音节固定两个数字,第一位是声母所在的键,第二位是韵母所在的键;零声母音节按方案给的两位编码;只打了声母的一位单独算一位,后面的韵母还没打。一个两位编码可以对应好几个音节,但每个音节只有一个编码。
2//!
3//! 这里只管方案本身:内置方案的表、用户表的校验、音节的编码和键面提示。解码复用九宫格:方案经 [`crate::KeyGrid::NineKeyShuangpin`] 交给 `nine_key::NineKeySession`,它照这里给的编码建拼写表,路径搜索、锁定、切分、整句、学习和候选排序与全拼九宫格是同一套。
4//!
5//! 合法的表按内容只留一份,与自定义 26 键双拼(`shuangpin::custom`)相同:同一张表反复校验、反复应用拿到的是同一个引用,进程里只为实际用过的每一张不同的表各留一份(含它的拼写表,几十 KB);不合法的表不留任何东西。
6
7use 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
16/// 九键双拼的表要给出数字的 23 个声母:九键上每个声母都要放到某个数字上,不像 26 键那样单字母声母固定在自己的字母键上。
17pub 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
22/// 九键数字在键面上的顺序:电话键盘的 1 到 9,再是 0。
23pub const DIGIT_ORDER: &[u8; 10] = b"1234567890";
24
25// 内置方案的表。数字按公开描述独立整理,来源和取舍见 `.agents/notes/implemented/feature/2026-10-10-nine-key-shuangpin.md`;单韵母 a e i o u ü 没有单独列出的,按这几个方案共同的约定留在电话键盘上自己字母所在的键(a 2、e 3、i 4、o 6、u 和 ü 8)。
26
27/// 米旮旯 2:ch 在 5,sh、zh 在 8,零声母在 6;其余声母在电话键盘上自己字母所在的键。
28const 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
93/// 米旮旯 3:ch 在 3,r 在 4,sh、zh 在 8,零声母在 2,u、ü 和 ei 在 0 键(点讯版,也是现在搜狗的版本;百度早年不支持 0 键时把 ei 挪到 8、u 和 ü 留在 8,那一版不收录)。
94const 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
159/// 辜氏 2 低重码:ch 在 3,sh、zh 在 8,零声母在 2,in、ing、eng 在 0 键,1 键放 ou、ong、iong。
160const 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
225/// 一个内置方案:声母和韵母各一个数字,零声母音节都是「零声母键 + 韵母键」。
226struct BuiltinTable {
227    initials: [(&'static str, &'static str); 23],
228    finals: [(&'static str, &'static str); 33],
229    /// 零声母所在的键,零声母音节的第一位。
230    zero_initial_key: u8,
231    /// `er` 所在的键。`er` 只作零声母音节出现,不在韵母表里,三个方案的描述都把它和别的韵母一起列在某个键上。
232    er_key: u8,
233}
234
235impl BuiltinTable {
236    /// 零声母音节的两位编码:零声母键,再是它的韵母所在的键(`er` 是 `er_key`)。
237    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
277/// 内置方案写成用户表的样子,作为编辑自定义九键双拼的起点;`Custom` 没有内置的表,返回 `None`。
278pub fn builtin_table(kind: NineKeyShuangpinKind) -> Option<NineKeyShuangpinTable> {
279    builtin_source(kind).map(BuiltinTable::table)
280}
281
282/// 表为什么不能用,带出错的位置。宿主可以按种类给出自己的提示文字,`Display` 是给日志和代理看的英文。
283///
284/// 没有「编码冲突」这一类:九键上一个两位编码本来就对应好几个音节,零声母音节和声母加韵母的音节共用编码(米旮旯 3 的「安」和「班」都是 25)也是方案的一部分,不会让哪个音节打不出来;每个音节恰好一个编码由「每个单位恰好一个键」保证。
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub enum NineKeyShuangpinError {
287    /// 这一部分不认识的单位,例如 `finals` 里写了 `iou`。
288    UnknownUnit { part: CustomTablePart, unit: String },
289    /// 同一个单位写了两次。
290    DuplicateUnit { part: CustomTablePart, unit: String },
291    /// 缺了这个单位的键位。
292    MissingUnit {
293        part: CustomTablePart,
294        unit: &'static str,
295    },
296    /// 键不是这里允许的:声母和韵母是一个数字,零声母是两个数字,数字是 `0`–`9`。
297    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
327/// 一个音节怎么拆:声母加韵母,或者零声母。
328enum Parts {
329    Consonant {
330        initial: &'static str,
331        final_unit: &'static str,
332    },
333    Zero,
334}
335
336/// 按拼写拆音节:先认 `zh` `ch` `sh`,再认单字母声母,都不是的是零声母音节。`ju` 拆成 j 加 `u`、`lve` 拆成 l 加 `ve`,与拼写一致:一个音节只有一种拆法,所以只有一个编码。
337fn 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
359/// 九键双拼能打的音节:九宫格的完整音节表去掉 `jv`、`lue` 这类只是别的拼写的别名(词库只存 `ju`、`lve`),再加上零声母表里的 `eng`。按音节表的顺序。
360pub(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/// 规范化后的一张表:单位换成常量里的那个 `&'static str`,键换成数字字节,按常量的顺序排好。
378#[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
452/// 一张两键双拼表,进程里按内容只留一份,以 `&'static` 交给九宫格;九键用户表使用数字,14 键表从已解析的字母方案派生。
453pub struct NineKeyShuangpin {
454    canonical: Canonical,
455    /// 每个能打的音节的两位编码。
456    codes: HashMap<&'static str, [u8; 2]>,
457    /// 14 键保留原方案接受的其他编码,例如微软双拼把 j+ü 也解作 ju。九键用户表每个音节只有一种编码,此表为空。
458    aliases: HashMap<&'static str, Vec<[u8; 2]>>,
459    /// 每个组码键上能起头一个音节的字母,按字母序:声母的首字母(`zh` 算 `z`),以及编码从这个键开始的零声母音节的首字母。槽位由 `key_slot` 确定。
460    letters: [String; 62],
461    /// 九宫格的拼写表,第一次在九宫格里用这张表时建(`KeyGrid::spelling_table`)。
462    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                    // 每个音节都拆得开(`every_syllable_has_one_code` 核对),走不到这里。
510                    _ => 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    /// 完整音节的两位编码;不是能打的音节时为 `None`。
551    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    /// 声母所在的键。
563    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    /// 能打的音节和它的编码,按音节表的顺序。
572    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    /// 只打了一位时,这一位能代表的拼写片段:声母(`zh`、`b`),以及零声母音节的首字母(编码从这个键开始的 `a`、`e`、`o`)。片段都不完整,后面还要接韵母。
580    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    /// 拼写片段在 `digits` 开头占几位:是完整音节、编码正好是开头两位时两位,是这一位上的单键片段(`one_key_pieces`)时一位,都不是时为 `None`。`a` 这样的片段两样都可能,按数字定。
595    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    /// 只打了声母那一位时读音行显示的部分:声母(`zh`、`b`),零声母音节是首字母。
619    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    /// 组码键上能起头一个音节的字母,见 `letters` 字段;不是组码或这个键上没有时为空。
632    pub fn letters(&self, digit: u8) -> &str {
633        key_slot(digit).map_or("", |index| self.letters[index].as_str())
634    }
635
636    /// 这张表用到了这个组码键:有声母、韵母或零声母编码放在它上面。
637    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    /// `'` 分开的拼写的数字码:完整音节两位,单独的声母一位;有一段两样都不是时整个返回空串。
649    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    /// 核对全拼的两键编码与输入共同覆盖的前缀;逐音节核对,保留别名而不展开所有编码的笛卡尔积。
665    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    /// 关键词(日期时间的 `riqi`、`sj` 这类小写字母)正好拼出 `digits`:能切成完整音节时按音节的编码比,否则每个字母当一个音节的首字母按声母的键比(简拼)。首字母 `z` `c` `s` 也可以是 `zh` `ch` `sh`(`sj` 是 shi'jian),与简拼按首字母查词相同。
691    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    /// 简拼行(一位数字一个音节)的每个音节都以对应那一位上的声母开头。九宫格的简拼按首字母查词库,`z` 同时查出 `z` 和 `zh` 开头的词,而九键双拼里它们常在不同的键上。
709    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    /// 键面提示:按电话键盘的 1 到 9、0 列出每个用到的键上的声母和韵母,以及零声母编码是否从这个键开始。声母按电话键盘的习惯排:单字母声母按字母序在前,`ch` `sh` `zh` 在后(3 键是 d f ch);韵母按韵母表的顺序。`ü` 开头的韵母写成 `ü`,与 26 键的提示相同。
721    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/// 一个数字键的键面提示,见 [`NineKeyShuangpin::key_hints`]。
760#[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
768/// 进程里按内容只留一份的合法表。
769fn 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
777/// 校验 `table`,合法时返回它对应的方案。同一张表(单位和键相同,顺序不论)总是返回同一个引用。
778pub 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
791/// 将已解析的 26 键双拼方案投到字母组;分号与 L 共键,缓存同时区分方案和布局。九键用户表的数字校验不变。
792fn 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        // 原方案的单字母键用大写标记精确输入;整组编码仍排在前面,默认编码不变。
863        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
931/// 两位编码的每一位都可保留整组或限定字母;分号继续走 L 整组。
932fn 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
948/// 按当前内置或自定义双拼方案派生 14 键表;自定义表沿用 26 键校验,不接受另一套字母用户表。
949pub 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
958/// 按当前内置或自定义双拼方案派生 18 键表,沿用 26 键方案校验。
959pub 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
968/// 与 [`nine_key_shuangpin`] 的结论相同,但什么也不留下,给编辑中的表用(设置页每改一个键就问一次)。
969pub fn check_table(table: &NineKeyShuangpinTable) -> Result<(), NineKeyShuangpinError> {
970    canonicalize(table).map(|_| ())
971}
972
973/// 一张表的键面提示,不留下任何东西(设置页预览编辑中的表时用);表不合法时返回原因。
974pub fn table_key_hints(
975    table: &NineKeyShuangpinTable,
976) -> Result<Vec<NineKeyShuangpinKeyHint>, NineKeyShuangpinError> {
977    Ok(NineKeyShuangpin::build(canonicalize(table)?).key_hints())
978}
979
980/// 方案 `kind` 的表:内置方案取内置的表,`Custom` 校验 `custom`(没有表时报缺了第一个声母)。
981pub 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        // 每个能打的音节都拆得成声母加韵母表里的韵母,或者是零声母表里的音节,所以每张合法的表都给每个音节恰好一个编码。
1203        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    /// 内置表写成用户表再读回来,是同一份方案;打乱顺序也一样。
1226    #[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    /// 米旮旯 3 按公开描述的几处核对:声母加韵母,零声母在 2 键,u、ü、ei 在 0 键。
1252    #[test]
1253    fn migala_3_follows_its_published_rules() {
1254        let profile = resolve(NineKeyShuangpinKind::Migala3, None).unwrap();
1255        // 助记诗:「相(iang)爱(ai)在 2 键上,铅(ian)华(ua)在 9 键上」。
1256        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    /// 辜氏 2 低重码的零声母按来源列出的例子:爱 22、熬 27、诶 23、耳 23、嗯 23、安 25、昂 25。
1273    #[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    /// 一个编码可以对应好几个音节,零声母音节和声母加韵母的音节也可以共用编码。
1303    #[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    /// 1 和 0 都可以用;一张把零声母放在 1 键、谁也不和谁共用编码的表照样合法。
1378    #[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        // ri'qi:r 4 + i 4,q 7 + i 4。
1410        assert!(profile.spells_keyword("riqi", "4474"));
1411        assert!(profile.spells_keyword("rq", "47"));
1412        assert!(!profile.spells_keyword("rq", "4474"));
1413        // shi'jian:sh 8 + i 4,j 5 + ian 9。简拼 sj 的 s 可以是 s(7)也可以是 sh(8)。
1414        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}