Skip to main content

msime_engine/
helpcode.rs

1//! Helpcode (auxiliary code) tables and matching (schemes-lang.md §2, `R/common/helpcode_utils.*`). A keymap maps one character to its 1-2 lowercase code letters. Quanpin and shuangpin filter or reorder with it, and the session and host facade annotate candidates with it.
2
3use std::collections::HashMap;
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use crate::assets::{CUSTOM_HELPCODE_DIRECTORY, CUSTOM_HELPCODE_PREFIX, HELPCODES};
10use crate::diagnostics::UNKNOWN_HELPCODE_SCHEMA;
11use crate::error::{EngineError, Result};
12use crate::text::{count_han_chars, first_han_char, last_han_char};
13use crate::types::WordItem;
14
15/// The shipped tables are below 150 KiB; leave room for larger compatible tables without
16/// allowing a user supplied file to make a session allocate without bound.
17const MAX_HELPCODE_BYTES: u64 = 1024 * 1024;
18
19/// A helpcode table: the code letters (one or two, lowercase) of each character, which the session uses to filter and order candidates and to annotate them. Read one from a resource directory with [`load_helpcode_keymap`], from a file's bytes with [`HelpcodeKeymap::from_table_bytes`], or build one with [`HelpcodeKeymap::from_codes`]; a session takes it through `SessionOptions::helpcode_table` or `Session::set_helpcode_table`.
20#[derive(Debug, Clone, PartialEq, Eq, Default)]
21pub struct HelpcodeKeymap {
22    codes: HashMap<String, String>,
23}
24
25/// Loaded once per session (and per schema change) and shared by every provider.
26pub type SharedKeymap = Arc<HelpcodeKeymap>;
27
28impl HelpcodeKeymap {
29    /// A table from character to code letters as given; the caller has already applied the file rules of [`load_helpcode_keymap`].
30    pub fn from_codes(codes: HashMap<String, String>) -> Self {
31        Self { codes }
32    }
33
34    /// 从码表文件的字节解析,规则与 [`load_helpcode_keymap`] 读文件时相同;给没有文件系统的宿主(网页引擎)用。超过 1 MiB 的表返回 None,与读文件时的上限一致。
35    pub fn from_table_bytes(bytes: &[u8]) -> Option<Self> {
36        if bytes.len() as u64 > MAX_HELPCODE_BYTES {
37            return None;
38        }
39        Some(Self::from_codes(parse_helpcode_table(bytes)))
40    }
41
42    /// The code letters of one character, lowercase.
43    pub fn code(&self, character: &str) -> Option<&str> {
44        self.codes.get(character).map(String::as_str)
45    }
46
47    /// Table size, only asserted by the loader tests here and in host/tests.rs.
48    #[cfg(test)]
49    pub(crate) fn len(&self) -> usize {
50        self.codes.len()
51    }
52
53    /// A built-in table that is missing from the resources loads as an empty map rather than failing.
54    pub(crate) fn is_empty(&self) -> bool {
55        self.codes.is_empty()
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SingleHelpcodeMatch {
61    None,
62    First,
63    Last,
64    Both,
65}
66
67/// The stem of a `custom/<stem>` schema, or `None` when the stem is empty or could name a file outside the custom directory (helpcode_utils.cpp:26-36).
68fn custom_schema_stem(schema: &str) -> Option<&str> {
69    let stem = schema.strip_prefix(CUSTOM_HELPCODE_PREFIX)?;
70    if stem.is_empty()
71        || stem.starts_with('.')
72        || stem.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|'])
73        || stem.bytes().any(|byte| byte < 0x20)
74    {
75        return None;
76    }
77    Some(stem)
78}
79
80fn built_in_helpcode_file(schema: &str) -> Option<&'static str> {
81    HELPCODES
82        .iter()
83        .find(|(name, _)| *name == schema)
84        .map(|(_, file)| *file)
85}
86
87/// A built-in schema name, or `custom/<stem>` with a stem that cannot escape the custom directory (helpcode_utils.cpp:26-41). The file's existence is checked by `load_helpcode_keymap`, not here.
88pub fn is_supported_helpcode_schema(schema: &str) -> bool {
89    built_in_helpcode_file(schema).is_some() || custom_schema_stem(schema).is_some()
90}
91
92/// 查找资源目录里的方案文件,不支持的名称返回 `UNKNOWN_HELPCODE_SCHEMA`。
93/// 自定义主干由 `custom_schema_stem` 限定为单个路径成分;优先小写 `.txt`,再按扩展名字典序尝试普通文件。
94/// 文件均缺失时仍返回小写路径,交给加载器按原规则处理。
95pub fn helpcode_path(resources: &Path, schema: &str) -> Result<PathBuf> {
96    if let Some(file) = built_in_helpcode_file(schema) {
97        return Ok(resources.join(file));
98    }
99    match custom_schema_stem(schema) {
100        Some(stem) => {
101            let mut filename = String::with_capacity(stem.len() + ".txt".len());
102            filename.push_str(stem);
103            filename.push_str(".txt");
104            let mut path = resources.join(CUSTOM_HELPCODE_DIRECTORY).join(filename);
105            for extension in ["txt", "TXT", "TXt", "TxT", "Txt", "tXT", "tXt", "txT"] {
106                path.set_extension(extension);
107                if std::fs::symlink_metadata(&path)
108                    .is_ok_and(|metadata| metadata.file_type().is_file())
109                {
110                    return Ok(path);
111                }
112            }
113            path.set_extension("txt");
114            Ok(path)
115        }
116        None => Err(EngineError::invalid(UNKNOWN_HELPCODE_SCHEMA)),
117    }
118}
119
120/// Parse a helpcode table (helpcode_utils.cpp:54-88): BOM and `\r` stripped, `#` lines skipped, `left=right` with the first up-to-two `a-z` letters of `right`, later lines overwrite. A missing custom file fails with `UNKNOWN_HELPCODE_SCHEMA`.
121pub fn load_helpcode_keymap(resources: &Path, schema: &str) -> Result<HelpcodeKeymap> {
122    let path = helpcode_path(resources, schema)?;
123    let built_in = built_in_helpcode_file(schema).is_some();
124    if !built_in {
125        let Some(directory) = path.parent() else {
126            return Err(EngineError::invalid(UNKNOWN_HELPCODE_SCHEMA));
127        };
128        let directory_is_real = std::fs::symlink_metadata(directory)
129            .map(|metadata| metadata.file_type().is_dir())
130            .unwrap_or(false);
131        let file_is_real = std::fs::symlink_metadata(&path)
132            .map(|metadata| metadata.file_type().is_file())
133            .unwrap_or(false);
134        if !directory_is_real || !file_is_real {
135            return Err(EngineError::invalid(UNKNOWN_HELPCODE_SCHEMA));
136        }
137    } else if let Ok(metadata) = std::fs::symlink_metadata(&path) {
138        // Resource files are shipped assets; a link here must not make the
139        // engine read outside the verified resource generation.
140        if !metadata.file_type().is_file() {
141            return Ok(HelpcodeKeymap::default());
142        }
143    }
144    let bytes = match crate::paths::open_file_no_follow(&path) {
145        Ok(file) => match read_helpcode_file(file) {
146            Some(bytes) => bytes,
147            None => return Ok(HelpcodeKeymap::default()),
148        },
149        // The C++ reads the table through an `ifstream` that is never checked (helpcode_utils.cpp:57-67), so any open or read failure (a resource tree without the built-in file, which fixtures and hosts rely on for the default `lantian` schema, a directory in its place, no permission, a Windows sharing violation on a custom table being edited, an I/O error) gives an empty table and the session is still created.
150        Err(_) => return Ok(HelpcodeKeymap::default()),
151    };
152    Ok(HelpcodeKeymap::from_codes(parse_helpcode_table(&bytes)))
153}
154
155/// 主资源确实缺少内置表时,读取宿主指定的随包副本;已有表和自定义表不回退。
156pub(crate) fn load_helpcode_keymap_with_fallback(
157    resources: &Path,
158    schema: &str,
159    fallback_resources: Option<&Path>,
160) -> Result<HelpcodeKeymap> {
161    if built_in_helpcode_file(schema).is_some() {
162        let path = helpcode_path(resources, schema)?;
163        if std::fs::symlink_metadata(path)
164            .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
165        {
166            if let Some(fallback) = fallback_resources.filter(|path| path.is_absolute()) {
167                return load_helpcode_keymap(fallback, schema);
168            }
169        }
170    }
171    load_helpcode_keymap(resources, schema)
172}
173
174fn read_helpcode_file(file: File) -> Option<Vec<u8>> {
175    let size = file.metadata().ok()?.len();
176    if size > MAX_HELPCODE_BYTES {
177        return None;
178    }
179    let mut bytes = Vec::with_capacity(usize::try_from(size).ok()?);
180    // Bound the read again in case the file grows after the metadata check.
181    if file
182        .take(MAX_HELPCODE_BYTES + 1)
183        .read_to_end(&mut bytes)
184        .is_err()
185        || bytes.len() as u64 > MAX_HELPCODE_BYTES
186    {
187        return None;
188    }
189    Some(bytes)
190}
191
192fn parse_helpcode_table(bytes: &[u8]) -> HashMap<String, String> {
193    let mut lines: Vec<&[u8]> = bytes.split(|&byte| byte == b'\n').collect();
194    // `getline` yields no final empty line after a trailing newline.
195    if lines.last().is_some_and(|line| line.is_empty()) {
196        lines.pop();
197    }
198    let mut codes = HashMap::with_capacity(lines.len());
199    for (number, mut line) in lines.into_iter().enumerate() {
200        if number == 0 {
201            line = line.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(line);
202        }
203        line = line.strip_suffix(b"\r").unwrap_or(line);
204        if line.first() == Some(&b'#') {
205            continue;
206        }
207        let Some(position) = line.iter().position(|&byte| byte == b'=') else {
208            continue;
209        };
210        if position == 0 {
211            continue;
212        }
213        // The code is the next one or two bytes, so a longer code is cut to two letters and anything not `a-z` rejects the line.
214        let right = &line[position + 1..];
215        let code = &right[..right.len().min(2)];
216        if code.is_empty() || !code.iter().all(u8::is_ascii_lowercase) {
217            continue;
218        }
219        // A key that is not UTF-8 could never equal a candidate word, so dropping it changes no lookup.
220        let Ok(key) = std::str::from_utf8(&line[..position]) else {
221            continue;
222        };
223        codes.insert(key.to_owned(), String::from_utf8_lossy(code).into_owned());
224    }
225    codes
226}
227
228/// The annotation shown beside a candidate: a single character's full code, or the first letters of the first and last Han characters' codes; wrapped in parentheses; uppercase entirely when `uppercase_all`, else only the second letter (helpcode_utils.cpp:189-241). Empty when a code is missing.
229pub fn compute_helpcodes(word: &str, uppercase_all: bool, keymap: &HelpcodeKeymap) -> String {
230    let single = count_han_chars(word) == 1;
231    let (first, last) = if single {
232        (keymap.code(word), None)
233    } else {
234        let (Some(first), Some(last)) = (
235            keymap.code(first_han_char(word)),
236            keymap.code(last_han_char(word)),
237        ) else {
238            return String::new();
239        };
240        (Some(first), Some(last))
241    };
242    let Some(first) = first else {
243        return String::new();
244    };
245    if first.is_empty() {
246        return String::new();
247    }
248    let capacity = if single { first.len() + 2 } else { 4 };
249    let mut result = String::with_capacity(capacity);
250    result.push('(');
251    if single {
252        for (index, character) in first.chars().enumerate() {
253            result.push(if uppercase_all || index == 1 {
254                character.to_ascii_uppercase()
255            } else {
256                character
257            });
258        }
259    } else {
260        let character = first.chars().next().expect("helpcode is non-empty");
261        result.push(if uppercase_all {
262            character.to_ascii_uppercase()
263        } else {
264            character
265        });
266        let last = last.expect("multi-character helpcode has a last code");
267        let character = last.chars().next().expect("helpcode is non-empty");
268        result.push(character.to_ascii_uppercase());
269    }
270    result.push(')');
271    result
272}
273
274/// Longer than one letter, not double mode, last letter uppercase.
275pub fn is_quanpin_single_help_mode(pinyin_with_cases: &str) -> bool {
276    pinyin_with_cases.len() > 1
277        && !is_quanpin_double_help_mode(pinyin_with_cases)
278        && pinyin_with_cases
279            .as_bytes()
280            .last()
281            .is_some_and(u8::is_ascii_uppercase)
282}
283
284/// Longer than two letters and the last two uppercase.
285pub fn is_quanpin_double_help_mode(pinyin_with_cases: &str) -> bool {
286    let bytes = pinyin_with_cases.as_bytes();
287    bytes.len() > 2 && bytes[bytes.len() - 2..].iter().all(u8::is_ascii_uppercase)
288}
289
290pub fn match_single_helpcode(
291    word: &str,
292    help_code: &str,
293    keymap: &HelpcodeKeymap,
294) -> SingleHelpcodeMatch {
295    if word.is_empty() || help_code.len() != 1 {
296        return SingleHelpcodeMatch::None;
297    }
298    let letter = help_code.as_bytes()[0];
299    let (matches_first, matches_last) = if count_han_chars(word) == 1 {
300        let Some(code) = keymap.code(word).map(str::as_bytes) else {
301            return SingleHelpcodeMatch::None;
302        };
303        (code[0] == letter, code.get(1) == Some(&letter))
304    } else {
305        let initial = |character: &str| keymap.code(character).map(|code| code.as_bytes()[0]);
306        (
307            initial(first_han_char(word)) == Some(letter),
308            initial(last_han_char(word)) == Some(letter),
309        )
310    };
311    match (matches_first, matches_last) {
312        (true, true) => SingleHelpcodeMatch::Both,
313        (true, false) => SingleHelpcodeMatch::First,
314        (false, true) => SingleHelpcodeMatch::Last,
315        (false, false) => SingleHelpcodeMatch::None,
316    }
317}
318
319pub fn matches_double_helpcodes(word: &str, help_codes: &str, keymap: &HelpcodeKeymap) -> bool {
320    if word.is_empty() || help_codes.len() != 2 {
321        return false;
322    }
323    let expected = help_codes.as_bytes();
324    if count_han_chars(word) == 1 {
325        return keymap
326            .code(word)
327            .is_some_and(|code| code.as_bytes() == expected);
328    }
329    match (
330        keymap.code(first_han_char(word)),
331        keymap.code(last_han_char(word)),
332    ) {
333        (Some(first), Some(last)) => {
334            first.as_bytes()[0] == expected[0] && last.as_bytes()[0] == expected[1]
335        }
336        _ => false,
337    }
338}
339
340/// First and Both matches, then Last, then the rest, each stable; unchanged unless `help_code` is one letter.
341pub fn reorder_candidates_with_single_helpcode(
342    mut candidates: Vec<WordItem>,
343    help_code: &str,
344    keymap: &HelpcodeKeymap,
345) -> Vec<WordItem> {
346    if help_code.len() != 1 {
347        return candidates;
348    }
349
350    let mut boundary = 0;
351    let mut index = 0;
352    while index < candidates.len() {
353        let is_first = matches!(
354            match_single_helpcode(&candidates[index].word, help_code, keymap),
355            SingleHelpcodeMatch::First | SingleHelpcodeMatch::Both
356        );
357        if is_first {
358            candidates[boundary..=index].rotate_right(1);
359            boundary += 1;
360        }
361        index += 1;
362    }
363
364    index = boundary;
365    while index < candidates.len() {
366        let is_last = matches!(
367            match_single_helpcode(&candidates[index].word, help_code, keymap),
368            SingleHelpcodeMatch::Last
369        );
370        if is_last {
371            candidates[boundary..=index].rotate_right(1);
372            boundary += 1;
373        }
374        index += 1;
375    }
376
377    candidates
378}
379
380/// 一码严格筛选:只保留至少一个汉字命中该码的候选;未命中的候选被排除。
381pub fn filter_candidates_with_single_helpcode(
382    candidates: Vec<WordItem>,
383    help_code: &str,
384    keymap: &HelpcodeKeymap,
385) -> Vec<WordItem> {
386    if help_code.len() != 1 {
387        return Vec::new();
388    }
389    candidates
390        .into_iter()
391        .filter(|candidate| {
392            !matches!(
393                match_single_helpcode(&candidate.word, help_code, keymap),
394                SingleHelpcodeMatch::None
395            )
396        })
397        .collect()
398}
399
400/// Only the double-helpcode matches; empty unless `help_codes` is two letters.
401pub fn filter_candidates_with_double_helpcodes(
402    candidates: Vec<WordItem>,
403    help_codes: &str,
404    keymap: &HelpcodeKeymap,
405) -> Vec<WordItem> {
406    if help_codes.len() != 2 {
407        return Vec::new();
408    }
409    candidates
410        .into_iter()
411        .filter(|candidate| matches_double_helpcodes(&candidate.word, help_codes, keymap))
412        .collect()
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::types::CandidateSource;
419
420    fn keymap(entries: &[(&str, &str)]) -> HelpcodeKeymap {
421        HelpcodeKeymap::from_codes(
422            entries
423                .iter()
424                .map(|(character, code)| ((*character).to_owned(), (*code).to_owned()))
425                .collect(),
426        )
427    }
428
429    fn items(words: &[&str]) -> Vec<WordItem> {
430        words
431            .iter()
432            .map(|word| WordItem::new("", *word, 1, CandidateSource::Database, ""))
433            .collect()
434    }
435
436    fn words(items: &[WordItem]) -> Vec<&str> {
437        items.iter().map(|item| item.word.as_str()).collect()
438    }
439
440    #[test]
441    fn schema_names() {
442        for schema in [
443            "lantian",
444            "ziranma",
445            "shouyou2_0",
446            "shouyouplus",
447            "xiaohe",
448            "jiajia",
449            "wubi86",
450            "custom/mine",
451        ] {
452            assert!(is_supported_helpcode_schema(schema), "{schema}");
453        }
454        for schema in [
455            "unknown",
456            "custom/",
457            "custom/.hidden",
458            "custom/..",
459            "custom/../x",
460            "custom/a/b",
461            "custom/a\\b",
462            "custom/a:b",
463            "custom/a\u{1}",
464            "Custom/x",
465        ] {
466            assert!(!is_supported_helpcode_schema(schema), "{schema:?}");
467            assert!(
468                helpcode_path(Path::new("/res"), schema).is_err(),
469                "{schema:?}"
470            );
471        }
472        let root = Path::new("/res");
473        assert_eq!(
474            helpcode_path(root, "xiaohe").unwrap(),
475            root.join("helpcodes/xiaohe_helpcode.txt")
476        );
477        assert_eq!(
478            helpcode_path(root, "custom/mine").unwrap(),
479            root.join("helpcodes/custom").join("mine.txt")
480        );
481        assert_eq!(
482            helpcode_path(root, "unknown").unwrap_err().to_string(),
483            UNKNOWN_HELPCODE_SCHEMA
484        );
485    }
486
487    #[test]
488    fn tables_parse_the_way_the_engine_reads_them() {
489        let parsed = parse_helpcode_table(
490            "\u{feff}你=ab\r\n# comment=zz\n好=c\n=de\nno code\n长=xyz\n坏=a1\n坏=q\n私=s\u{e643}\n多字=mn\n末=gh".as_bytes(),
491        );
492        let expected: HashMap<String, String> = [
493            ("你", "ab"),
494            ("好", "c"),
495            ("长", "xy"),
496            ("坏", "q"),
497            ("多字", "mn"),
498            ("末", "gh"),
499        ]
500        .into_iter()
501        .map(|(key, code)| (key.to_owned(), code.to_owned()))
502        .collect();
503        assert_eq!(parsed, expected);
504    }
505
506    /// helpcode_utils.cpp:57-67 reads through an `ifstream` it never checks, so a table that exists but cannot be read (a directory, no permission, a sharing violation) is an empty table and the session is still created.
507    #[test]
508    fn an_unreadable_table_is_an_empty_table() {
509        let resources = tempfile::tempdir().unwrap();
510        let built_in = helpcode_path(resources.path(), "lantian").unwrap();
511        std::fs::create_dir_all(&built_in).unwrap();
512        assert_eq!(
513            load_helpcode_keymap(resources.path(), "lantian")
514                .unwrap()
515                .len(),
516            0
517        );
518        #[cfg(unix)]
519        {
520            use std::os::unix::fs::PermissionsExt;
521            let custom = resources.path().join("helpcodes/custom");
522            std::fs::create_dir_all(&custom).unwrap();
523            let file = custom.join("locked.txt");
524            std::fs::write(&file, "你=aa\n").unwrap();
525            std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
526            // A process that can read the file anyway (root) has nothing to prove here.
527            if std::fs::read(&file).is_err() {
528                assert_eq!(
529                    load_helpcode_keymap(resources.path(), "custom/locked")
530                        .unwrap()
531                        .len(),
532                    0
533                );
534            }
535            std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
536        }
537    }
538
539    #[test]
540    fn builtin_fallback_only_handles_missing_builtin_files() {
541        let resources = tempfile::tempdir().unwrap();
542        let bundle = tempfile::tempdir().unwrap();
543        std::fs::create_dir_all(bundle.path().join("helpcodes/custom")).unwrap();
544        std::fs::write(
545            bundle.path().join("helpcodes/xiaohe_helpcode.txt"),
546            "合=ab\n",
547        )
548        .unwrap();
549        std::fs::write(
550            bundle.path().join("helpcodes/custom/synthetic.txt"),
551            "合=cd\n",
552        )
553        .unwrap();
554        let load = |schema| {
555            load_helpcode_keymap_with_fallback(resources.path(), schema, Some(bundle.path()))
556        };
557        assert_eq!(load("xiaohe").unwrap().code("合"), Some("ab"));
558        assert!(load("custom/synthetic").is_err());
559        assert!(load("unknown").is_err());
560        assert!(load_helpcode_keymap_with_fallback(
561            resources.path(),
562            "xiaohe",
563            Some(Path::new("relative"))
564        )
565        .unwrap()
566        .is_empty());
567        let primary = resources.path().join("helpcodes/xiaohe_helpcode.txt");
568        std::fs::create_dir_all(primary.parent().unwrap()).unwrap();
569        std::fs::write(&primary, "合=ef\n").unwrap();
570        assert_eq!(load("xiaohe").unwrap().code("合"), Some("ef"));
571        std::fs::write(&primary, "").unwrap();
572        assert!(load("xiaohe").unwrap().is_empty());
573        std::fs::remove_file(&primary).unwrap();
574        std::fs::create_dir(&primary).unwrap();
575        assert!(load("xiaohe").unwrap().is_empty());
576        #[cfg(unix)]
577        {
578            std::fs::remove_dir(&primary).unwrap();
579            std::os::unix::fs::symlink(
580                bundle.path().join("helpcodes/xiaohe_helpcode.txt"),
581                &primary,
582            )
583            .unwrap();
584            assert!(load("xiaohe").unwrap().is_empty());
585        }
586    }
587
588    #[test]
589    fn loading_resolves_built_in_and_custom_files() {
590        let resources = tempfile::tempdir().unwrap();
591        // A missing built-in table is an empty table, as the unchecked C++ stream gives.
592        assert_eq!(
593            load_helpcode_keymap(resources.path(), "lantian")
594                .unwrap()
595                .len(),
596            0
597        );
598        assert_eq!(
599            load_helpcode_keymap(resources.path(), "custom/synthetic")
600                .unwrap_err()
601                .to_string(),
602            UNKNOWN_HELPCODE_SCHEMA
603        );
604        assert_eq!(
605            load_helpcode_keymap(resources.path(), "unknown")
606                .unwrap_err()
607                .to_string(),
608            UNKNOWN_HELPCODE_SCHEMA
609        );
610        let custom = resources.path().join("helpcodes/custom");
611        std::fs::create_dir_all(&custom).unwrap();
612        std::fs::write(custom.join("synthetic.txt"), "\u{feff}你=aa\r\n#你=zz\r\n").unwrap();
613        let loaded = load_helpcode_keymap(resources.path(), "custom/synthetic").unwrap();
614        assert_eq!(loaded.code("你"), Some("aa"));
615        assert_eq!(loaded.len(), 1);
616        std::fs::write(resources.path().join("helpcodes/helpcode.txt"), "你=ab\n").unwrap();
617        assert_eq!(
618            load_helpcode_keymap(resources.path(), "lantian")
619                .unwrap()
620                .code("你"),
621            Some("ab")
622        );
623    }
624
625    #[test]
626    fn loading_custom_tables_accepts_extension_case_variants() {
627        let resources = tempfile::tempdir().unwrap();
628        let custom = resources.path().join("helpcodes/custom");
629        std::fs::create_dir_all(&custom).unwrap();
630        for (index, extension) in ["txt", "txT", "tXt", "tXT", "Txt", "TxT", "TXt", "TXT"]
631            .into_iter()
632            .enumerate()
633        {
634            let stem = format!("synthetic{index}");
635            std::fs::write(custom.join(format!("{stem}.{extension}")), "你=ab\n").unwrap();
636            let loaded = load_helpcode_keymap(resources.path(), &format!("custom/{stem}"))
637                .unwrap_or_else(|error| panic!("{extension}: {error}"));
638            assert_eq!(loaded.code("你"), Some("ab"));
639        }
640    }
641
642    #[test]
643    fn extension_variants_load_the_same_winner_as_the_catalog() {
644        let resources = tempfile::tempdir().unwrap();
645        let custom = resources.path().join("helpcodes/custom");
646        std::fs::create_dir_all(&custom).unwrap();
647        let canonical = custom.join("synthetic.txt");
648        let upper = custom.join("synthetic.TXT");
649        std::fs::write(&canonical, "你=ab\n").unwrap();
650        std::fs::write(&upper, "你=cd\n").unwrap();
651        if std::fs::read(&canonical).unwrap() == std::fs::read(&upper).unwrap() {
652            return;
653        }
654        let code = || {
655            load_helpcode_keymap(resources.path(), "custom/synthetic")
656                .unwrap()
657                .code("你")
658                .unwrap()
659                .to_owned()
660        };
661        assert_eq!(code(), "ab");
662        std::fs::remove_file(canonical).unwrap();
663        std::fs::write(custom.join("synthetic.TxT"), "你=ef\n").unwrap();
664        assert_eq!(code(), "cd");
665    }
666
667    #[test]
668    fn oversized_custom_table_is_not_loaded() {
669        let resources = tempfile::tempdir().unwrap();
670        let custom = resources.path().join("helpcodes/custom");
671        std::fs::create_dir_all(&custom).unwrap();
672        std::fs::write(
673            custom.join("oversized.txt"),
674            vec![b'x'; MAX_HELPCODE_BYTES as usize + 1],
675        )
676        .unwrap();
677
678        assert_eq!(
679            load_helpcode_keymap(resources.path(), "custom/oversized")
680                .unwrap()
681                .len(),
682            0
683        );
684    }
685
686    /// 字节解析和读文件是同一套规则,上限也相同。
687    #[test]
688    fn table_bytes_parse_like_the_file_and_share_its_bound() {
689        let resources = tempfile::tempdir().unwrap();
690        let table = "\u{feff}你=ab\r\n# 好=zz\n好=cdx\n";
691        std::fs::create_dir_all(resources.path().join("helpcodes")).unwrap();
692        std::fs::write(
693            resources.path().join("helpcodes/xiaohe_helpcode.txt"),
694            table,
695        )
696        .unwrap();
697        let loaded = load_helpcode_keymap(resources.path(), "xiaohe").unwrap();
698        let parsed = HelpcodeKeymap::from_table_bytes(table.as_bytes()).unwrap();
699        assert_eq!(parsed, loaded);
700        assert_eq!(parsed.code("好"), Some("cd"));
701        assert!(
702            HelpcodeKeymap::from_table_bytes(&vec![b'x'; MAX_HELPCODE_BYTES as usize + 1])
703                .is_none()
704        );
705    }
706
707    #[test]
708    fn bounded_helpcode_read_reserves_file_size() {
709        let resources = tempfile::tempdir().unwrap();
710        let path = resources.path().join("table.txt");
711        let contents = b"\xE4\xBD\xA0=aa\n\xE5\xA5\xBD=bb\n";
712        std::fs::write(&path, contents).unwrap();
713
714        let bytes = read_helpcode_file(File::open(path).unwrap()).unwrap();
715        assert_eq!(bytes, contents);
716        assert_eq!(bytes.capacity(), contents.len());
717    }
718
719    #[cfg(unix)]
720    #[test]
721    fn custom_tables_reject_symlinked_files_and_directories() {
722        use std::os::unix::fs::symlink;
723
724        let resources = tempfile::tempdir().unwrap();
725        let outside = tempfile::tempdir().unwrap();
726        let custom = resources.path().join("helpcodes/custom");
727        std::fs::create_dir_all(&custom).unwrap();
728        let external = outside.path().join("mine.txt");
729        std::fs::write(&external, "你=aa\n").unwrap();
730        for extension in ["txt", "TXT", "tXt"] {
731            let path = custom.join(format!("mine.{extension}"));
732            symlink(&external, &path).unwrap();
733            assert_eq!(
734                load_helpcode_keymap(resources.path(), "custom/mine")
735                    .unwrap_err()
736                    .to_string(),
737                UNKNOWN_HELPCODE_SCHEMA
738            );
739            std::fs::remove_file(path).unwrap();
740        }
741
742        std::fs::remove_dir(&custom).unwrap();
743        symlink(outside.path(), &custom).unwrap();
744        assert_eq!(
745            load_helpcode_keymap(resources.path(), "custom/mine")
746                .unwrap_err()
747                .to_string(),
748            UNKNOWN_HELPCODE_SCHEMA
749        );
750    }
751
752    // engine-bridge/src/tests.rs:512-545: the carried jiajia table loads whole.
753    #[test]
754    fn the_carried_jiajia_table_loads_whole() {
755        let resources = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources");
756        let loaded = load_helpcode_keymap(&resources, "jiajia").unwrap();
757        assert_eq!(loaded.len(), 7968);
758        for (character, code) in [("好", "nz"), ("你", "de"), ("中", "ks"), ("国", "ky")] {
759            assert_eq!(loaded.code(character), Some(code));
760        }
761    }
762
763    // 仓库里的五笔 86 码表(由 dict-builder 的 wubi86-helpcode 生成)整表载入,表头的 `#` 行不算数,码是全码的前两码。
764    #[test]
765    fn the_generated_wubi86_table_loads_whole() {
766        let resources = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources");
767        let loaded = load_helpcode_keymap(&resources, "wubi86").unwrap();
768        assert_eq!(loaded.len(), 20614);
769        for (character, code) in [("码", "dc"), ("一", "gg"), ("我", "tr"), ("工", "aa")] {
770            assert_eq!(loaded.code(character), Some(code));
771        }
772        assert_eq!(loaded.code("#"), None);
773    }
774
775    #[test]
776    fn annotations() {
777        let map = keymap(&[("阿", "ek"), ("姨", "nr"), ("一", "y")]);
778        let single = compute_helpcodes("阿", false, &map);
779        assert_eq!(single, "(eK)");
780        assert_eq!(single.capacity(), single.len());
781        let uppercase = compute_helpcodes("阿", true, &map);
782        assert_eq!(uppercase, "(EK)");
783        assert_eq!(uppercase.capacity(), uppercase.len());
784        assert_eq!(compute_helpcodes("一", false, &map), "(y)");
785        let pair = compute_helpcodes("阿姨", false, &map);
786        assert_eq!(pair, "(eN)");
787        assert_eq!(pair.capacity(), pair.len());
788        assert_eq!(compute_helpcodes("A阿姨B", true, &map), "(EN)");
789        assert_eq!(compute_helpcodes("阿好", false, &map), "");
790        assert_eq!(compute_helpcodes("好", false, &map), "");
791        assert_eq!(compute_helpcodes("", false, &map), "");
792    }
793
794    #[test]
795    fn help_modes() {
796        assert!(is_quanpin_single_help_mode("niH"));
797        assert!(!is_quanpin_single_help_mode("H"));
798        assert!(!is_quanpin_single_help_mode("niHA"));
799        assert!(!is_quanpin_single_help_mode("nih"));
800        assert!(is_quanpin_double_help_mode("niHA"));
801        assert!(!is_quanpin_double_help_mode("HA"));
802        assert!(!is_quanpin_double_help_mode("nihA"));
803    }
804
805    // shuangpin_dictionary.cpp:307-316, 377-386: 阿 (ek) and 阿姨 (ek, nr).
806    #[test]
807    fn matching() {
808        let map = keymap(&[("阿", "ek"), ("姨", "nr"), ("一", "y"), ("嗯", "ee")]);
809        assert_eq!(
810            match_single_helpcode("阿", "e", &map),
811            SingleHelpcodeMatch::First
812        );
813        assert_eq!(
814            match_single_helpcode("阿", "k", &map),
815            SingleHelpcodeMatch::Last
816        );
817        assert_eq!(
818            match_single_helpcode("嗯", "e", &map),
819            SingleHelpcodeMatch::Both
820        );
821        assert_eq!(
822            match_single_helpcode("一", "y", &map),
823            SingleHelpcodeMatch::First
824        );
825        assert_eq!(
826            match_single_helpcode("阿姨", "e", &map),
827            SingleHelpcodeMatch::First
828        );
829        assert_eq!(
830            match_single_helpcode("阿姨", "n", &map),
831            SingleHelpcodeMatch::Last
832        );
833        assert_eq!(
834            match_single_helpcode("阿阿", "e", &map),
835            SingleHelpcodeMatch::Both
836        );
837        assert_eq!(
838            match_single_helpcode("阿姨", "k", &map),
839            SingleHelpcodeMatch::None
840        );
841        assert_eq!(
842            match_single_helpcode("好", "e", &map),
843            SingleHelpcodeMatch::None
844        );
845        assert_eq!(
846            match_single_helpcode("阿", "ek", &map),
847            SingleHelpcodeMatch::None
848        );
849        assert!(matches_double_helpcodes("阿", "ek", &map));
850        assert!(!matches_double_helpcodes("一", "yy", &map));
851        assert!(matches_double_helpcodes("阿姨", "en", &map));
852        assert!(!matches_double_helpcodes("阿姨", "ek", &map));
853        assert!(!matches_double_helpcodes("阿好", "eh", &map));
854        assert!(!matches_double_helpcodes("阿", "e", &map));
855    }
856
857    #[test]
858    fn single_helpcode_reorder_reuses_the_input_buffer() {
859        let map = keymap(&[("你", "ab"), ("拟", "ba"), ("泥", "cc"), ("妮", "bb")]);
860        let candidates = items(&["泥", "你", "拟", "妮", "好"]);
861        let (reordered, allocations) = crate::ime::personal_rerank::allocations::count(|| {
862            reorder_candidates_with_single_helpcode(candidates, "b", &map)
863        });
864        assert_eq!(words(&reordered), ["拟", "妮", "你", "泥", "好"]);
865        assert_eq!(allocations, 0);
866    }
867
868    #[test]
869    fn reorder_and_filter() {
870        let map = keymap(&[("你", "ab"), ("拟", "ba"), ("泥", "cc"), ("妮", "bb")]);
871        let reordered = reorder_candidates_with_single_helpcode(
872            items(&["泥", "你", "拟", "妮", "好"]),
873            "b",
874            &map,
875        );
876        assert_eq!(words(&reordered), ["拟", "妮", "你", "泥", "好"]);
877        let unchanged = reorder_candidates_with_single_helpcode(items(&["泥", "你"]), "ab", &map);
878        assert_eq!(words(&unchanged), ["泥", "你"]);
879        let filtered =
880            filter_candidates_with_double_helpcodes(items(&["泥", "你", "拟"]), "ab", &map);
881        assert_eq!(words(&filtered), ["你"]);
882        assert!(filter_candidates_with_double_helpcodes(items(&["你"]), "a", &map).is_empty());
883        let strict = filter_candidates_with_single_helpcode(items(&["泥", "你", "拟"]), "a", &map);
884        assert_eq!(words(&strict), ["你", "拟"]);
885    }
886}