1pub const SPELLING_SYMBOLS: &str = "0123456789-._~:/?#[]@!$&'()*+,;=%^";
5
6pub const INPUT_LIMIT: usize = 512;
8
9const TRIGGERS: [(&str, &str); 4] = [("www", "."), ("http", ":"), ("https", ":"), ("ftp", ".:")];
11
12const EMAIL_KEY: char = '@';
14
15pub 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
39const TOP_LEVEL_DOMAINS: [&str; 5] = [".com", ".cn", ".net", ".org", ".com.cn"];
41
42const SCHEME_COMPLETIONS: [(&str, &str); 4] = [
44 ("https", "https://"),
45 ("http", "http://"),
46 ("www", "www."),
47 ("ftp", "ftp://"),
48];
49
50pub 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
62pub 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
68fn trigger_keys(raw: &str) -> &'static str {
70 TRIGGERS
71 .iter()
72 .find(|(word, _)| *word == raw)
73 .map_or("", |(_, keys)| keys)
74}
75
76fn is_email_local_part(raw: &str) -> bool {
78 !raw.is_empty() && raw.bytes().all(|byte| byte.is_ascii_alphanumeric())
79}
80
81pub fn wubi_continues(raw: &str, letter: u8) -> bool {
83 raw == "http" && letter == b's'
84}
85
86pub fn accepts(key: u8) -> bool {
88 key.is_ascii_alphanumeric() || SPELLING_SYMBOLS.as_bytes().contains(&key)
89}
90
91pub 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
99pub 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
130pub 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 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 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 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 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}