1use 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
15const MAX_HELPCODE_BYTES: u64 = 1024 * 1024;
18
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
21pub struct HelpcodeKeymap {
22 codes: HashMap<String, String>,
23}
24
25pub type SharedKeymap = Arc<HelpcodeKeymap>;
27
28impl HelpcodeKeymap {
29 pub fn from_codes(codes: HashMap<String, String>) -> Self {
31 Self { codes }
32 }
33
34 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 pub fn code(&self, character: &str) -> Option<&str> {
44 self.codes.get(character).map(String::as_str)
45 }
46
47 #[cfg(test)]
49 pub(crate) fn len(&self) -> usize {
50 self.codes.len()
51 }
52
53 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
67fn 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
87pub fn is_supported_helpcode_schema(schema: &str) -> bool {
89 built_in_helpcode_file(schema).is_some() || custom_schema_stem(schema).is_some()
90}
91
92pub 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
120pub 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 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 Err(_) => return Ok(HelpcodeKeymap::default()),
151 };
152 Ok(HelpcodeKeymap::from_codes(parse_helpcode_table(&bytes)))
153}
154
155pub(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 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 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 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 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
228pub 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
274pub 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
284pub 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
340pub 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
380pub 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
400pub 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 #[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 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 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 #[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 #[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 #[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 #[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}