Skip to main content

msime_engine/shuangpin/
custom.rs

1//! 用户自定义双拼方案:校验 [`ShuangpinCustomTable`],把合法的表变成与内置方案同样的 `&'static ShuangpinProfile`,之后的切分、转换、辅助码和 `;` 键都走内置方案那一套,不另写一份解码。
2//!
3//! 合法的表按内容只留一份:同一张表反复校验、反复应用(每次偏好变更都会校验并重建 Engine)拿到的是同一个引用,只有第一次逐个检查编码;进程里只为用户实际用过的每一张不同的表各留一份(几 KB)。不合法的表不留任何东西。
4
5use std::collections::HashSet;
6use std::fmt;
7use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
8
9use super::profile::{accepted_syllables, default_profile, profile};
10use super::ShuangpinProfile;
11use crate::diagnostics;
12use crate::error::{EngineError, Result};
13use crate::types::{ShuangpinCustomTable, ShuangpinProfileKind};
14
15/// 自定义方案要给出键位的多字母声母;单字母声母固定在自己的字母键上。
16pub const MULTI_LETTER_INITIALS: [&str; 3] = ["zh", "ch", "sh"];
17/// 单字母声母。多字母声母不能放在这些键上:一个单键片段先按多字母声母读,放上去以后这个单字母声母就打不出来了。
18const SINGLE_LETTER_INITIALS: &[u8] = b"bpmfdtnlgkhjqxrzcsyw";
19/// 每个方案都要给出键位的 33 个韵母,与内置方案的韵母表相同(`builtin_profiles_use_the_same_units` 核对)。
20pub const FINALS: [&str; 33] = [
21    "a", "o", "e", "i", "u", "v", "ai", "ei", "ui", "ao", "ou", "iu", "ie", "ve", "ue", "an", "en",
22    "in", "un", "ang", "eng", "ing", "ong", "ia", "ua", "uo", "uai", "ian", "uan", "iao", "iang",
23    "uang", "iong",
24];
25/// 每个方案都要给出两键编码的 12 个零声母音节。
26pub const ZERO_INITIALS: [&str; 12] = [
27    "a", "ai", "an", "ang", "ao", "e", "ei", "en", "eng", "er", "o", "ou",
28];
29
30/// 表里的哪一部分。
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CustomTablePart {
33    Initials,
34    Finals,
35    ZeroInitials,
36}
37
38impl CustomTablePart {
39    pub fn name(self) -> &'static str {
40        match self {
41            Self::Initials => "initials",
42            Self::Finals => "finals",
43            Self::ZeroInitials => "zero_initials",
44        }
45    }
46}
47
48/// 表为什么不能用。宿主可以按种类给出自己的提示文字,`Display` 是给日志看的英文。
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum CustomProfileError {
51    /// 这一部分不认识的单位,例如 `initials` 里写了 `b`,`finals` 里写了 `iou`。
52    UnknownUnit { part: CustomTablePart, unit: String },
53    /// 同一个单位写了两次。
54    DuplicateUnit { part: CustomTablePart, unit: String },
55    /// 缺了这个单位的键位。
56    MissingUnit {
57        part: CustomTablePart,
58        unit: &'static str,
59    },
60    /// 键不是这里允许的:声母和韵母是一个键,零声母是两个键;键是字母或 `;`,`;` 只能做韵母或零声母编码的第二个键(它只能接在奇数长度的片段之后)。
61    InvalidKey {
62        part: CustomTablePart,
63        unit: String,
64        key: String,
65    },
66    /// 多字母声母放在了单字母声母的键上,或两个多字母声母放在了同一个键上。
67    InitialKeyTaken { unit: &'static str, key: String },
68    /// 两个不同的音节解出了同一个两键编码。`code` 是这个编码,`first` 和 `second` 是这两个音节。
69    AmbiguousCode {
70        code: String,
71        first: String,
72        second: String,
73    },
74}
75
76impl fmt::Display for CustomProfileError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::UnknownUnit { part, unit } => {
80                write!(formatter, "unknown {} unit {unit:?}", part.name())
81            }
82            Self::DuplicateUnit { part, unit } => {
83                write!(formatter, "{} unit {unit:?} appears twice", part.name())
84            }
85            Self::MissingUnit { part, unit } => {
86                write!(formatter, "{} unit {unit:?} has no key", part.name())
87            }
88            Self::InvalidKey { part, unit, key } => {
89                write!(
90                    formatter,
91                    "{} unit {unit:?} has an invalid key {key:?}",
92                    part.name()
93                )
94            }
95            Self::InitialKeyTaken { unit, key } => {
96                write!(formatter, "initial {unit:?} cannot take key {key:?}")
97            }
98            Self::AmbiguousCode {
99                code,
100                first,
101                second,
102            } => write!(
103                formatter,
104                "code {code:?} reads as both {first:?} and {second:?}"
105            ),
106        }
107    }
108}
109
110impl std::error::Error for CustomProfileError {}
111
112/// 规范化后的一张表:单位换成常量里的那个 `&'static str`,键转成小写,按常量的顺序排好。
113struct Canonical {
114    initials: Vec<(&'static str, String)>,
115    finals: Vec<(&'static str, String)>,
116    zero_initials: Vec<(&'static str, String)>,
117}
118
119#[derive(Clone, Copy, PartialEq, Eq)]
120enum InitialText {
121    Single(u8),
122    Multi(&'static str),
123}
124
125#[derive(Clone, Copy, PartialEq, Eq)]
126struct DecodedSyllable {
127    initial: InitialText,
128    final_unit: &'static str,
129}
130
131impl DecodedSyllable {
132    fn matches(self, text: &str) -> bool {
133        match self.initial {
134            InitialText::Single(key) => {
135                text.as_bytes().first() == Some(&key) && text.get(1..) == Some(self.final_unit)
136            }
137            InitialText::Multi(initial) => text
138                .strip_prefix(initial)
139                .is_some_and(|rest| rest == self.final_unit),
140        }
141    }
142
143    fn write_to(self, target: &mut String) {
144        target.clear();
145        match self.initial {
146            InitialText::Single(key) => target.push(char::from(key)),
147            InitialText::Multi(initial) => target.push_str(initial),
148        }
149        target.push_str(self.final_unit);
150    }
151}
152
153/// 校验 `table`,合法时返回它对应的方案。同一张表(单位和键相同,顺序和键的大小写不论)总是返回同一个引用。
154pub fn custom_profile(
155    table: &ShuangpinCustomTable,
156) -> Result<&'static ShuangpinProfile, CustomProfileError> {
157    let canonical = canonicalize(table)?;
158    let mut interned = interned();
159    if let Some(found) = interned.iter().find(|profile| canonical.matches(profile)) {
160        return Ok(found);
161    }
162    check_codes(&canonical)?;
163    let profile: &'static ShuangpinProfile = Box::leak(Box::new(ShuangpinProfile {
164        kind: ShuangpinProfileKind::Custom,
165        initials: leak_pairs(canonical.initials),
166        zero_initials: leak_pairs(canonical.zero_initials),
167        finals: leak_pairs(canonical.finals),
168    }));
169    interned.push(profile);
170    Ok(profile)
171}
172
173/// 会话用的双拼方案(`SessionOptions::shuangpin_profile` 与 `shuangpin_custom_profile`)。内置方案取内置的表;`Custom` 校验用户的表,没有表或表不合法时报 `INVALID_CUSTOM_SHUANGPIN_PROFILE`。`shuangpin_enabled` 为假(会话里没有双拼)时这份设置用不上,出错也按小鹤处理,免得一份只对双拼有意义的偏好让没有双拼的会话建不起来,与 `host::options::shuangpin_profile` 处理不合法的 ABI 值相同。
174pub fn session_profile(
175    kind: ShuangpinProfileKind,
176    table: Option<&ShuangpinCustomTable>,
177    shuangpin_enabled: bool,
178) -> Result<&'static ShuangpinProfile> {
179    if let Some(builtin) = profile(kind) {
180        return Ok(builtin);
181    }
182    let resolved = match table {
183        Some(table) => custom_profile(table).map_err(|error| error.to_string()),
184        None => Err("no table".to_owned()),
185    };
186    match resolved {
187        Ok(custom) => Ok(custom),
188        Err(_) if !shuangpin_enabled => Ok(default_profile()),
189        Err(reason) => Err(EngineError::invalid(format!(
190            "{}: {reason}",
191            diagnostics::INVALID_CUSTOM_SHUANGPIN_PROFILE
192        ))),
193    }
194}
195
196/// 只回答表能不能用。合法的表同样按内容留下一份,随后用它建会话(每次偏好变更都会先校验再重建)时直接复用,不再逐个编码检查一遍。
197pub fn validate_custom_profile(table: &ShuangpinCustomTable) -> Result<(), CustomProfileError> {
198    custom_profile(table).map(|_| ())
199}
200
201/// 与 [`validate_custom_profile`] 的结论相同,但什么也不留下:不进按内容去重的那份列表,也不 leak。给编辑中的表用,例如设置页每改一个键就问一次:编辑过程中的每一张合法的中间表都不是用户最后要用的那张,留下来就是永久泄漏,列表也越查越慢。真要用这张表建会话时把它交给 `SessionOptions::shuangpin_custom_profile`,会话按内容去重留下一份。
202pub fn check_custom_profile(table: &ShuangpinCustomTable) -> Result<(), CustomProfileError> {
203    check_codes(&canonicalize(table)?)
204}
205
206/// 内置方案写成自定义表的样子,用作编辑自定义方案的起点;`Custom` 没有内置的表,返回 `None`。单位按内置表里的顺序排列,顺序对表的含义没有影响。
207pub fn builtin_table(kind: ShuangpinProfileKind) -> Option<ShuangpinCustomTable> {
208    fn owned(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
209        pairs
210            .iter()
211            .map(|(unit, key)| ((*unit).to_owned(), (*key).to_owned()))
212            .collect()
213    }
214    let source = profile(kind)?;
215    Some(ShuangpinCustomTable {
216        initials: owned(source.initials),
217        finals: owned(source.finals),
218        zero_initials: owned(source.zero_initials),
219    })
220}
221
222/// 进程里按内容只留一份的合法方案([`custom_profile`])。
223fn interned() -> MutexGuard<'static, Vec<&'static ShuangpinProfile>> {
224    static INTERNED: OnceLock<Mutex<Vec<&'static ShuangpinProfile>>> = OnceLock::new();
225    INTERNED
226        .get_or_init(Mutex::default)
227        .lock()
228        .unwrap_or_else(PoisonError::into_inner)
229}
230
231fn leak_pairs(pairs: Vec<(&'static str, String)>) -> &'static [(&'static str, &'static str)] {
232    let leaked: Vec<(&'static str, &'static str)> = pairs
233        .into_iter()
234        .map(|(unit, key)| (unit, &*Box::leak(key.into_boxed_str())))
235        .collect();
236    Box::leak(leaked.into_boxed_slice())
237}
238
239impl Canonical {
240    fn matches(&self, profile: &ShuangpinProfile) -> bool {
241        fn same(left: &[(&'static str, String)], right: &[(&str, &str)]) -> bool {
242            left.len() == right.len()
243                && left.iter().all(|(unit, key)| {
244                    right
245                        .iter()
246                        .any(|(other_unit, other_key)| unit == other_unit && key == other_key)
247                })
248        }
249        same(&self.initials, profile.initials)
250            && same(&self.finals, profile.finals)
251            && same(&self.zero_initials, profile.zero_initials)
252    }
253
254    /// 单键片段读作的声母:多字母声母的键读作那个声母,其余就是这个字母本身(与 `utils::initial_for_key` 相同)。
255    fn initial_for_key(&self, key: u8) -> InitialText {
256        self.initials
257            .iter()
258            .find(|(_, mapped)| mapped.as_bytes() == [key])
259            .map_or(InitialText::Single(key), |(unit, _)| {
260                InitialText::Multi(unit)
261            })
262    }
263
264    /// 声母键加韵母键解出的音节,与 `utils::cvt_single_sp_to_pinyin` 跳过零声母表之后的那一步相同;同一个编码解出两个不同的音节时报错。
265    /// `buffer` 是拼音节用的临时存储,逐个编码复用。
266    fn decode_pair(
267        &self,
268        first: u8,
269        second: u8,
270        buffer: &mut String,
271    ) -> Result<Option<DecodedSyllable>, CustomProfileError> {
272        let accepted = accepted_syllables();
273        let mut found: Option<DecodedSyllable> = None;
274        let initial = self.initial_for_key(first);
275        for (unit, _) in self
276            .finals
277            .iter()
278            .filter(|(_, key)| key.as_bytes() == [second])
279        {
280            buffer.clear();
281            match initial {
282                InitialText::Single(key) => buffer.push(char::from(key)),
283                InitialText::Multi(unit) => buffer.push_str(unit),
284            }
285            let normalized = if *unit == "v"
286                && matches!(initial, InitialText::Single(b'j' | b'q' | b'x' | b'y'))
287            {
288                "u"
289            } else {
290                unit
291            };
292            buffer.push_str(normalized);
293            if !accepted.contains(buffer.as_str()) {
294                continue;
295            }
296            let decoded = DecodedSyllable {
297                initial,
298                final_unit: normalized,
299            };
300            match &found {
301                Some(previous) if *previous != decoded => {
302                    let mut previous_text = String::with_capacity(8);
303                    previous.write_to(&mut previous_text);
304                    return Err(CustomProfileError::AmbiguousCode {
305                        code: code_text(first, second),
306                        first: previous_text,
307                        second: buffer.clone(),
308                    });
309                }
310                Some(_) => {}
311                None => found = Some(decoded),
312            }
313        }
314        Ok(found)
315    }
316}
317
318fn code_text(first: u8, second: u8) -> String {
319    [char::from(first), char::from(second)].iter().collect()
320}
321
322fn canonicalize(table: &ShuangpinCustomTable) -> Result<Canonical, CustomProfileError> {
323    Ok(Canonical {
324        initials: canonical_part(
325            CustomTablePart::Initials,
326            &table.initials,
327            &MULTI_LETTER_INITIALS,
328        )?,
329        finals: canonical_part(CustomTablePart::Finals, &table.finals, &FINALS)?,
330        zero_initials: canonical_part(
331            CustomTablePart::ZeroInitials,
332            &table.zero_initials,
333            &ZERO_INITIALS,
334        )?,
335    })
336}
337
338fn canonical_part(
339    part: CustomTablePart,
340    entries: &[(String, String)],
341    units: &[&'static str],
342) -> Result<Vec<(&'static str, String)>, CustomProfileError> {
343    let mut keys: Vec<Option<String>> = vec![None; units.len()];
344    for (unit, key) in entries {
345        let Some(index) = units.iter().position(|known| known == unit) else {
346            return Err(CustomProfileError::UnknownUnit {
347                part,
348                unit: unit.clone(),
349            });
350        };
351        if keys[index].is_some() {
352            return Err(CustomProfileError::DuplicateUnit {
353                part,
354                unit: unit.clone(),
355            });
356        }
357        let key = key.to_ascii_lowercase();
358        if !key_is_valid(part, key.as_bytes()) {
359            return Err(CustomProfileError::InvalidKey {
360                part,
361                unit: unit.clone(),
362                key,
363            });
364        }
365        keys[index] = Some(key);
366    }
367    units
368        .iter()
369        .zip(keys)
370        .map(|(unit, key)| {
371            key.map(|key| (*unit, key))
372                .ok_or(CustomProfileError::MissingUnit { part, unit })
373        })
374        .collect()
375}
376
377/// `;` 只能做一个音节的第二个键:方案只在奇数长度的片段之后接受它(`ShuangpinScheme::accepts_ing_key`)。
378fn key_is_valid(part: CustomTablePart, key: &[u8]) -> bool {
379    match (part, key) {
380        (CustomTablePart::Initials, [key]) => key.is_ascii_lowercase(),
381        (CustomTablePart::Finals, [key]) => key.is_ascii_lowercase() || *key == b';',
382        (CustomTablePart::ZeroInitials, [first, second]) => {
383            first.is_ascii_lowercase() && (second.is_ascii_lowercase() || *second == b';')
384        }
385        _ => false,
386    }
387}
388
389fn check_codes(canonical: &Canonical) -> Result<(), CustomProfileError> {
390    // 内置手心表原样复制为自定义表时,继承其 `dk` 的 `dia`/`den` 同码例外。
391    let inherited_shoudao = canonical.matches(profile(ShuangpinProfileKind::Shoudao).unwrap());
392    let mut initial_keys: HashSet<u8> = HashSet::new();
393    for (unit, key) in &canonical.initials {
394        let byte = key.as_bytes()[0];
395        if SINGLE_LETTER_INITIALS.contains(&byte) || !initial_keys.insert(byte) {
396            return Err(CustomProfileError::InitialKeyTaken {
397                unit,
398                key: key.clone(),
399            });
400        }
401    }
402    let mut final_keys: Vec<u8> = canonical
403        .finals
404        .iter()
405        .map(|(_, key)| key.as_bytes()[0])
406        .collect();
407    final_keys.sort_unstable();
408    final_keys.dedup();
409    let mut buffer = String::with_capacity(8);
410    for first in b'a'..=b'z' {
411        for &second in &final_keys {
412            match canonical.decode_pair(first, second, &mut buffer) {
413                Err(CustomProfileError::AmbiguousCode {
414                    code,
415                    first,
416                    second,
417                }) if inherited_shoudao && code == "dk" && first == "den" && second == "dia" => {}
418                result => {
419                    result?;
420                }
421            }
422        }
423    }
424    let mut zero_codes: Vec<(&str, &str)> = Vec::with_capacity(canonical.zero_initials.len());
425    for (syllable, code) in &canonical.zero_initials {
426        // 零声母表先于声母加韵母查(`cvt_single_sp_to_pinyin`),编码一旦被别的音节用掉,那个音节就再也打不出来。
427        if let Some((other, _)) = zero_codes.iter().find(|(_, used)| used == code) {
428            return Err(CustomProfileError::AmbiguousCode {
429                code: code.clone(),
430                first: (*other).to_owned(),
431                second: (*syllable).to_owned(),
432            });
433        }
434        let bytes = code.as_bytes();
435        if let Some(decoded) = canonical.decode_pair(bytes[0], bytes[1], &mut buffer)? {
436            if !decoded.matches(syllable) {
437                return Err(CustomProfileError::AmbiguousCode {
438                    code: code.clone(),
439                    first: buffer.clone(),
440                    second: (*syllable).to_owned(),
441                });
442            }
443        }
444        zero_codes.push((syllable, code));
445    }
446    Ok(())
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::shuangpin::profile::profile;
453    use crate::shuangpin::scheme::ShuangpinScheme;
454    use crate::shuangpin::utils::cvt_single_sp_to_pinyin;
455    use crate::types::SchemeKey;
456
457    const BUILTIN: [ShuangpinProfileKind; 5] = [
458        ShuangpinProfileKind::Xiaohe,
459        ShuangpinProfileKind::Ziranma,
460        ShuangpinProfileKind::Shoudao,
461        ShuangpinProfileKind::Microsoft,
462        ShuangpinProfileKind::SmartAbc,
463    ];
464
465    fn owned(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
466        pairs
467            .iter()
468            .map(|(unit, key)| ((*unit).to_owned(), (*key).to_owned()))
469            .collect()
470    }
471
472    fn table_of(kind: ShuangpinProfileKind) -> ShuangpinCustomTable {
473        builtin_table(kind).unwrap()
474    }
475
476    fn set(units: impl IntoIterator<Item = &'static str>) -> Vec<&'static str> {
477        let mut units: Vec<&'static str> = units.into_iter().collect();
478        units.sort_unstable();
479        units
480    }
481
482    fn replace(entries: &mut [(String, String)], unit: &str, key: &str) {
483        entries
484            .iter_mut()
485            .find(|(known, _)| known == unit)
486            .unwrap()
487            .1 = key.to_owned();
488    }
489
490    #[test]
491    fn builtin_profiles_use_the_same_units() {
492        for kind in BUILTIN {
493            let source = profile(kind).unwrap();
494            assert_eq!(
495                set(source.initials.iter().map(|(unit, _)| *unit)),
496                set(MULTI_LETTER_INITIALS),
497                "{kind:?}"
498            );
499            assert_eq!(
500                set(source.finals.iter().map(|(unit, _)| *unit)),
501                set(FINALS),
502                "{kind:?}"
503            );
504            assert_eq!(
505                set(source.zero_initials.iter().map(|(unit, _)| *unit)),
506                set(ZERO_INITIALS),
507                "{kind:?}"
508            );
509        }
510    }
511
512    /// 每个内置方案写成自定义表都合法,解出的每个编码都和内置方案一样。
513    #[test]
514    fn builtin_tables_pass_and_decode_as_the_builtin_profile() {
515        for kind in BUILTIN {
516            let builtin = profile(kind).unwrap();
517            let custom = custom_profile(&table_of(kind)).unwrap();
518            assert_eq!(custom.kind, ShuangpinProfileKind::Custom);
519            assert_eq!(custom.uses_semicolon_key(), builtin.uses_semicolon_key());
520            for first in b'a'..=b'z' {
521                for second in (b'a'..=b'z').chain(*b";") {
522                    let code = code_text(first, second);
523                    assert_eq!(
524                        cvt_single_sp_to_pinyin(&code, custom),
525                        cvt_single_sp_to_pinyin(&code, builtin),
526                        "{kind:?} {code}"
527                    );
528                }
529            }
530        }
531    }
532
533    #[test]
534    fn shoudao_copy_only_inherits_its_known_collision_while_unchanged() {
535        let mut changed = table_of(ShuangpinProfileKind::Shoudao);
536        replace(&mut changed.zero_initials, "a", "oa");
537        assert!(matches!(
538            check_custom_profile(&changed),
539            Err(CustomProfileError::AmbiguousCode { code, .. }) if code == "dk"
540        ));
541    }
542
543    #[test]
544    fn the_same_table_is_interned_once() {
545        let first = custom_profile(&table_of(ShuangpinProfileKind::Ziranma)).unwrap();
546        let mut shuffled = table_of(ShuangpinProfileKind::Ziranma);
547        shuffled.finals.reverse();
548        for (_, key) in &mut shuffled.zero_initials {
549            *key = key.to_ascii_uppercase();
550        }
551        let second = custom_profile(&shuffled).unwrap();
552        assert!(std::ptr::eq(first, second));
553        let other = custom_profile(&table_of(ShuangpinProfileKind::Xiaohe)).unwrap();
554        assert!(!std::ptr::eq(first, other));
555    }
556
557    #[test]
558    fn checking_a_custom_profile_reuses_the_initial_text_buffer() {
559        let table = table_of(ShuangpinProfileKind::Xiaohe);
560        let canonical = canonicalize(&table).unwrap();
561        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
562            check_codes(&canonical).unwrap();
563        });
564        assert!(
565            allocations <= 10,
566            "custom profile code validation allocated {allocations} buffers"
567        );
568    }
569
570    #[test]
571    fn a_custom_profile_moves_finals_and_initials() {
572        // 小鹤的表,把 zh 换到 a 键,零声母改用 o 引导(像微软那样),ing 换到 `;`。
573        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
574        replace(&mut table.initials, "zh", "a");
575        table.zero_initials = owned(&[
576            ("a", "oa"),
577            ("ai", "od"),
578            ("an", "oj"),
579            ("ang", "oh"),
580            ("ao", "oc"),
581            ("e", "oe"),
582            ("ei", "ow"),
583            ("en", "of"),
584            ("eng", "og"),
585            ("er", "or"),
586            ("o", "oo"),
587            ("ou", "oz"),
588        ]);
589        replace(&mut table.finals, "ing", ";");
590        let custom = custom_profile(&table).unwrap();
591        assert!(custom.uses_semicolon_key());
592        assert_eq!(cvt_single_sp_to_pinyin("ah", custom), "zhang");
593        assert_eq!(cvt_single_sp_to_pinyin("oa", custom), "a");
594        assert_eq!(cvt_single_sp_to_pinyin("x;", custom), "xing");
595        assert_eq!(cvt_single_sp_to_pinyin("xk", custom), "");
596        assert_eq!(cvt_single_sp_to_pinyin("vh", custom), "");
597    }
598
599    #[test]
600    fn a_zero_initial_can_use_semicolon_without_a_final_on_semicolon() {
601        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
602        replace(&mut table.zero_initials, "o", "o;");
603        let custom = custom_profile(&table).unwrap();
604        assert_eq!(cvt_single_sp_to_pinyin("o;", custom), "o");
605        assert!(custom.uses_semicolon_key());
606
607        let mut scheme = ShuangpinScheme::new(custom);
608        assert!(scheme.handle_key(SchemeKey::Letter(b'o')));
609        assert!(scheme.handle_key(SchemeKey::Semicolon));
610        assert_eq!(scheme.preedit(), "o;");
611    }
612
613    #[test]
614    fn incomplete_or_unknown_units_are_refused() {
615        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
616        table.finals.retain(|(unit, _)| unit != "iong");
617        assert_eq!(
618            validate_custom_profile(&table),
619            Err(CustomProfileError::MissingUnit {
620                part: CustomTablePart::Finals,
621                unit: "iong",
622            })
623        );
624        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
625        table.initials.push(("b".to_owned(), "b".to_owned()));
626        assert_eq!(
627            validate_custom_profile(&table),
628            Err(CustomProfileError::UnknownUnit {
629                part: CustomTablePart::Initials,
630                unit: "b".to_owned(),
631            })
632        );
633        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
634        table.finals.push(("iu".to_owned(), "q".to_owned()));
635        assert_eq!(
636            validate_custom_profile(&table),
637            Err(CustomProfileError::DuplicateUnit {
638                part: CustomTablePart::Finals,
639                unit: "iu".to_owned(),
640            })
641        );
642        assert!(matches!(
643            validate_custom_profile(&ShuangpinCustomTable::default()),
644            Err(CustomProfileError::MissingUnit { .. })
645        ));
646    }
647
648    #[test]
649    fn keys_must_fit_their_place() {
650        for (part, unit, key) in [
651            (CustomTablePart::Finals, "iu", "qq"),
652            (CustomTablePart::Finals, "iu", "1"),
653            (CustomTablePart::Finals, "iu", ""),
654            (CustomTablePart::Initials, "zh", ";"),
655            (CustomTablePart::ZeroInitials, "a", "a"),
656            (CustomTablePart::ZeroInitials, "a", ";a"),
657            (CustomTablePart::ZeroInitials, "a", "aé"),
658        ] {
659            let mut table = table_of(ShuangpinProfileKind::Xiaohe);
660            let entries = match part {
661                CustomTablePart::Initials => &mut table.initials,
662                CustomTablePart::Finals => &mut table.finals,
663                CustomTablePart::ZeroInitials => &mut table.zero_initials,
664            };
665            replace(entries, unit, key);
666            assert_eq!(
667                validate_custom_profile(&table),
668                Err(CustomProfileError::InvalidKey {
669                    part,
670                    unit: unit.to_owned(),
671                    key: key.to_ascii_lowercase(),
672                }),
673                "{unit} {key:?}"
674            );
675        }
676    }
677
678    #[test]
679    fn initials_cannot_take_a_single_letter_initial_or_share_a_key() {
680        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
681        replace(&mut table.initials, "sh", "s");
682        assert_eq!(
683            validate_custom_profile(&table),
684            Err(CustomProfileError::InitialKeyTaken {
685                unit: "sh",
686                key: "s".to_owned(),
687            })
688        );
689        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
690        replace(&mut table.initials, "ch", "u");
691        assert!(matches!(
692            validate_custom_profile(&table),
693            Err(CustomProfileError::InitialKeyTaken { key, .. }) if key == "u"
694        ));
695    }
696
697    #[test]
698    fn colliding_codes_are_refused() {
699        // 小鹤里 ing 和 uai 同在 k 上;把 ang 也放上去,`hk` 就既是 hang 又是 huai。
700        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
701        replace(&mut table.finals, "ang", "k");
702        assert!(matches!(
703            validate_custom_profile(&table),
704            Err(CustomProfileError::AmbiguousCode { .. })
705        ));
706        // 零声母编码占了一个声母加韵母的音节:小鹤的 sh 在 u 上,`ua` 就是 sha。
707        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
708        replace(&mut table.zero_initials, "a", "ua");
709        assert_eq!(
710            validate_custom_profile(&table),
711            Err(CustomProfileError::AmbiguousCode {
712                code: "ua".to_owned(),
713                first: "sha".to_owned(),
714                second: "a".to_owned(),
715            })
716        );
717        // 两个零声母音节用了同一个编码。
718        let mut table = table_of(ShuangpinProfileKind::Xiaohe);
719        replace(&mut table.zero_initials, "o", "aa");
720        assert_eq!(
721            validate_custom_profile(&table),
722            Err(CustomProfileError::AmbiguousCode {
723                code: "aa".to_owned(),
724                first: "a".to_owned(),
725                second: "o".to_owned(),
726            })
727        );
728    }
729
730    #[test]
731    fn custom_has_no_builtin_table() {
732        assert_eq!(builtin_table(ShuangpinProfileKind::Custom), None);
733    }
734
735    /// 不留存的检查与会留存的校验对同一批表结论相同,包括错误的种类和位置。
736    #[test]
737    fn check_agrees_with_validate() {
738        let mut tables: Vec<ShuangpinCustomTable> = BUILTIN.into_iter().map(table_of).collect();
739        tables.push(ShuangpinCustomTable::default());
740        for (part, unit, key) in [
741            (CustomTablePart::Finals, "ang", "k"),
742            (CustomTablePart::Finals, "ing", ";"),
743            (CustomTablePart::Finals, "iu", "1"),
744            (CustomTablePart::Initials, "sh", "s"),
745            (CustomTablePart::Initials, "zh", "a"),
746            (CustomTablePart::ZeroInitials, "a", "ua"),
747            (CustomTablePart::ZeroInitials, "o", "aa"),
748            (CustomTablePart::ZeroInitials, "o", "o;"),
749        ] {
750            let mut table = table_of(ShuangpinProfileKind::Xiaohe);
751            let entries = match part {
752                CustomTablePart::Initials => &mut table.initials,
753                CustomTablePart::Finals => &mut table.finals,
754                CustomTablePart::ZeroInitials => &mut table.zero_initials,
755            };
756            replace(entries, unit, key);
757            tables.push(table);
758        }
759        let mut missing = table_of(ShuangpinProfileKind::Microsoft);
760        missing.finals.retain(|(unit, _)| unit != "iong");
761        tables.push(missing);
762        for table in &tables {
763            assert_eq!(check_custom_profile(table), validate_custom_profile(table));
764        }
765        assert!(tables
766            .iter()
767            .any(|table| check_custom_profile(table).is_ok()));
768        assert!(tables
769            .iter()
770            .any(|table| check_custom_profile(table).is_err()));
771    }
772
773    #[test]
774    fn check_leaves_nothing_behind() {
775        // 别的测试不会用到的一张合法表:自然码的表,零声母 o 改用 `o;`。
776        let mut table = table_of(ShuangpinProfileKind::Ziranma);
777        replace(&mut table.zero_initials, "o", "o;");
778        let canonical = canonicalize(&table).unwrap();
779        let interned_now = || interned().iter().any(|profile| canonical.matches(profile));
780        assert_eq!(check_custom_profile(&table), Ok(()));
781        assert_eq!(check_custom_profile(&table), Ok(()));
782        assert!(!interned_now());
783        validate_custom_profile(&table).unwrap();
784        assert!(interned_now());
785    }
786
787    #[test]
788    fn an_invalid_table_is_not_interned() {
789        let mut table = table_of(ShuangpinProfileKind::Shoudao);
790        replace(&mut table.finals, "ang", "k");
791        assert!(custom_profile(&table).is_err());
792        assert!(custom_profile(&table).is_err());
793    }
794}