Skip to main content

msime_engine/local/
url.rs

1//! 网址模式:在全拼、双拼、五笔的组字中键入 `www.`、`http:`、`https:`、`ftp.`、`ftp:`,或在由 ASCII 字母、数字组成的组字后键入 `@`(邮箱)后,后续按键原样作为 ASCII 网址输入,不再转成中文。第一行始终是显示整段网址的兜底行,后面跟着补全:邮箱后缀(`email_completions`)和顶级域名(`tld_completions`)。组字里原文是 `ht`、`www` 这类开头时,候选里另有 `https://`、`www.` 等补全(`scheme_completions`),选中后以它进入网址模式。
2
3/// 网址模式下作为输入而非标点的非字母字符:数字和网址里出现的符号。Shift+数字行的符号必须全部列出,宿主才不会把它们当成选候选。
4pub const SPELLING_SYMBOLS: &str = "0123456789-._~:/?#[]@!$&'()*+,;=%^";
5
6/// 网址的最长长度,超出后按键被吞掉。
7pub const INPUT_LIMIT: usize = 512;
8
9/// 触发词及其触发键。组字原文必须与触发词完全相同(全部小写 ASCII 字母)。
10const TRIGGERS: [(&str, &str); 4] = [("www", "."), ("http", ":"), ("https", ":"), ("ftp", ".:")];
11
12/// 把组字转成邮箱的键:组字原文由 ASCII 字母、数字组成(`is_email_local_part`)时,按下它进入网址模式写邮箱。
13const EMAIL_KEY: char = '@';
14
15/// 常用邮箱后缀,国内常用的在前,然后是海外的。与 Android、iOS、鸿蒙宿主各自的后缀表(任何输入框里打到 `xxx@` 时候选栏给出)是同一份、同一个顺序,`scripts/test-email-suffixes.py` 核对四处一致。
16pub const EMAIL_SUFFIXES: [&str; 20] = [
17    "@qq.com",
18    "@163.com",
19    "@126.com",
20    "@foxmail.com",
21    "@139.com",
22    "@189.cn",
23    "@aliyun.com",
24    "@gmail.com",
25    "@outlook.com",
26    "@hotmail.com",
27    "@live.com",
28    "@icloud.com",
29    "@yahoo.com",
30    "@proton.me",
31    "@protonmail.com",
32    "@aol.com",
33    "@mail.com",
34    "@gmx.com",
35    "@naver.com",
36    "@yahoo.co.jp",
37];
38
39/// 正在打的主机名补全的顶级域名,按给出的顺序。
40const TOP_LEVEL_DOMAINS: [&str; 5] = [".com", ".cn", ".net", ".org", ".com.cn"];
41
42/// 组字原文能补全成的网址开头:原文是左边这个词的前缀时给出右边的补全。
43const SCHEME_COMPLETIONS: [(&str, &str); 4] = [
44    ("https", "https://"),
45    ("http", "http://"),
46    ("www", "www."),
47    ("ftp", "ftp://"),
48];
49
50/// 组字原文为 `raw` 时能进入网址模式的按键:触发词的触发键,原文能做邮箱的本地部分时再加上 `@`;都不是时为空。
51pub fn entry_keys(raw: &str) -> String {
52    let triggers = trigger_keys(raw);
53    let email = is_email_local_part(raw);
54    let mut keys = String::with_capacity(triggers.len() + usize::from(email));
55    keys.push_str(triggers);
56    if email {
57        keys.push(EMAIL_KEY);
58    }
59    keys
60}
61
62/// 组字原文为 `raw` 时按下 `key` 是否进入网址模式。
63pub fn opens(raw: &str, key: u8) -> bool {
64    trigger_keys(raw).as_bytes().contains(&key)
65        || (char::from(key) == EMAIL_KEY && is_email_local_part(raw))
66}
67
68/// 原文是触发词时它的触发键,否则为空。
69fn trigger_keys(raw: &str) -> &'static str {
70    TRIGGERS
71        .iter()
72        .find(|(word, _)| *word == raw)
73        .map_or("", |(_, keys)| keys)
74}
75
76/// 组字原文能做邮箱的本地部分:非空,只有 ASCII 字母和数字(带撇号的拼音不算)。
77fn is_email_local_part(raw: &str) -> bool {
78    !raw.is_empty() && raw.bytes().all(|byte| byte.is_ascii_alphanumeric())
79}
80
81/// 五笔码长上限为 4,`http` 之后的 `s` 会被码表拒绝,所以五笔在这第 5 个字母上直接进入网址模式。
82pub fn wubi_continues(raw: &str, letter: u8) -> bool {
83    raw == "http" && letter == b's'
84}
85
86/// 网址模式接受的按键:字母、数字和 `SPELLING_SYMBOLS` 中的符号。其余按键结束网址。
87pub fn accepts(key: u8) -> bool {
88    key.is_ascii_alphanumeric() || SPELLING_SYMBOLS.as_bytes().contains(&key)
89}
90
91/// 组字原文 `raw` 是 `https`、`http`、`www`、`ftp` 的前缀且至少两个字母时给出的补全,按 `SCHEME_COMPLETIONS` 的顺序:`ht`、`htt`、`http` 给 `https://` 和 `http://`,`https` 只给 `https://`。大写字母是辅助码,不算。
92pub fn scheme_completions(raw: &str) -> impl Iterator<Item = &'static str> + '_ {
93    SCHEME_COMPLETIONS
94        .iter()
95        .filter(move |(word, _)| raw.len() >= 2 && word.starts_with(raw))
96        .map(|(_, completion)| *completion)
97}
98
99/// 网址模式的预编辑是 `<本地部分>@<已打的域名>` 时补全的邮箱:本地部分加上以 `@<已打的域名>` 开头(不分大小写)的每个后缀,按 `EMAIL_SUFFIXES` 的顺序。规则与 Android 的 `EmailSuffixPolicy.match` 相同:只有一个 `@`,本地部分非空且只含 ASCII 字母、数字和 `._%+-`,已打的域名只含 ASCII 字母、数字、`.` 和 `-`;已打的域名已经是一个完整后缀时没有补全。
100pub fn email_completions(preedit: &str) -> Vec<String> {
101    let Some((local, domain)) = preedit.split_once('@') else {
102        return Vec::new();
103    };
104    let local_part = !local.is_empty()
105        && local
106            .bytes()
107            .all(|byte| byte.is_ascii_alphanumeric() || b"._%+-".contains(&byte));
108    let typed_domain = domain
109        .bytes()
110        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-');
111    if !local_part || !typed_domain {
112        return Vec::new();
113    }
114    let matches = |suffix: &str| {
115        suffix.len() > domain.len() && suffix[1..1 + domain.len()].eq_ignore_ascii_case(domain)
116    };
117    if EMAIL_SUFFIXES
118        .iter()
119        .any(|suffix| suffix[1..].eq_ignore_ascii_case(domain))
120    {
121        return Vec::new();
122    }
123    EMAIL_SUFFIXES
124        .iter()
125        .filter(|suffix| matches(suffix))
126        .map(|suffix| format!("{local}{suffix}"))
127        .collect()
128}
129
130/// 网址模式的预编辑以 `www.` 开头或含 `://`、正在打的主机名还没有 `.`(`www.baidu`、`https://github`、`https://www.baidu`)时补全的顶级域名:预编辑加上 `TOP_LEVEL_DOMAINS` 的每一项。主机名为空或含其他符号(已经打到路径、端口)时没有补全。
131pub fn tld_completions(preedit: &str) -> Vec<String> {
132    let host = match preedit.find("://") {
133        Some(at) => &preedit[at + 3..],
134        None => match preedit.strip_prefix("www.") {
135            Some(host) => host,
136            None => return Vec::new(),
137        },
138    };
139    let label = host.strip_prefix("www.").unwrap_or(host);
140    if label.is_empty()
141        || !label
142            .bytes()
143            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
144    {
145        return Vec::new();
146    }
147    TOP_LEVEL_DOMAINS
148        .iter()
149        .map(|domain| format!("{preedit}{domain}"))
150        .collect()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{
156        accepts, email_completions, entry_keys, opens, scheme_completions, tld_completions,
157        wubi_continues, EMAIL_SUFFIXES, SPELLING_SYMBOLS,
158    };
159
160    #[test]
161    fn only_the_exact_scheme_words_open_the_mode() {
162        assert_eq!(entry_keys("www"), ".@");
163        assert_eq!(entry_keys("http"), ":@");
164        assert_eq!(entry_keys("https"), ":@");
165        assert_eq!(entry_keys("ftp"), ".:@");
166        // 不是触发词的原文只能用 `@` 进入(邮箱);带撇号或为空时都不能。
167        for raw in ["ww", "wwww", "Www", "ni", "mailto", "httpss", "abc123"] {
168            assert_eq!(entry_keys(raw), "@", "{raw}");
169        }
170        for raw in ["w'ww", "", "xi'an"] {
171            assert_eq!(entry_keys(raw), "", "{raw}");
172        }
173        assert!(opens("ftp", b':') && opens("ftp", b'.'));
174        assert!(!opens("www", b':') && opens("www", b'@') && !opens("ni", b'.'));
175        assert!(wubi_continues("http", b's'));
176        assert!(!wubi_continues("http", b't') && !wubi_continues("htt", b's'));
177    }
178
179    #[test]
180    fn every_listed_symbol_is_accepted_and_the_url_enders_are_not() {
181        assert!(SPELLING_SYMBOLS.bytes().all(accepts));
182        // Shift+数字行全部列出,宿主才不会把它们读成选候选。
183        assert!("!@#$%^&*()0123456789"
184            .bytes()
185            .all(|key| SPELLING_SYMBOLS.as_bytes().contains(&key)));
186        assert!(accepts(b'a') && accepts(b'Z') && accepts(b'7'));
187        for key in *b"\"<>\\{}|` " {
188            assert!(!accepts(key), "{}", key as char);
189        }
190    }
191
192    #[test]
193    fn scheme_completions_follow_the_typed_prefix() {
194        let completions = |raw| scheme_completions(raw).collect::<Vec<_>>();
195        for raw in ["ht", "htt", "http"] {
196            assert_eq!(completions(raw), ["https://", "http://"], "{raw}");
197        }
198        assert_eq!(completions("https"), ["https://"]);
199        assert_eq!(completions("ww"), ["www."]);
200        assert_eq!(completions("www"), ["www."]);
201        assert_eq!(completions("ft"), ["ftp://"]);
202        assert_eq!(completions("ftp"), ["ftp://"]);
203        for raw in ["h", "w", "", "httpss", "wwww", "Ht", "ni"] {
204            assert!(completions(raw).is_empty(), "{raw}");
205        }
206    }
207
208    #[test]
209    fn email_completions_narrow_by_the_typed_domain() {
210        let all = email_completions("zhangsan@");
211        assert_eq!(all.len(), EMAIL_SUFFIXES.len());
212        assert_eq!(all[0], "zhangsan@qq.com");
213        assert_eq!(all[19], "zhangsan@yahoo.co.jp");
214        assert_eq!(
215            email_completions("abc@1"),
216            ["abc@163.com", "abc@126.com", "abc@139.com", "abc@189.cn"]
217        );
218        // 不分大小写,本地部分照原样,后缀用表里的小写。
219        assert_eq!(email_completions("Zhang.San@GMa"), ["Zhang.San@gmail.com"]);
220        assert_eq!(
221            email_completions("abc@yahoo.c"),
222            ["abc@yahoo.com", "abc@yahoo.co.jp"]
223        );
224        // 已经是一个完整后缀,或者没有后缀接得上,都不给。
225        for preedit in [
226            "abc@qq.com",
227            "abc@QQ.COM",
228            "abc@yahoo.com",
229            "abc@example",
230            "abc",
231            "@qq",
232            "a@b@",
233            "https://a@",
234            "abc@q q",
235        ] {
236            assert!(email_completions(preedit).is_empty(), "{preedit}");
237        }
238    }
239
240    #[test]
241    fn tld_completions_follow_the_host_being_typed() {
242        assert_eq!(
243            tld_completions("www.baidu"),
244            [
245                "www.baidu.com",
246                "www.baidu.cn",
247                "www.baidu.net",
248                "www.baidu.org",
249                "www.baidu.com.cn"
250            ]
251        );
252        assert_eq!(tld_completions("https://github")[0], "https://github.com");
253        assert_eq!(
254            tld_completions("https://www.my-site")[4],
255            "https://www.my-site.com.cn"
256        );
257        for preedit in [
258            "www.",
259            "https://",
260            "www.baidu.com",
261            "https://github.com/a",
262            "https://localhost:8080",
263            "ftp.example",
264            "abc@qq",
265            "github",
266        ] {
267            assert!(tld_completions(preedit).is_empty(), "{preedit}");
268        }
269    }
270}