Skip to main content

msime_engine/
language_dictionary.rs

1//! The read-only dictionaries of the Cantonese, Zhuyin and Stroke schemes (`msime-cantonese.db`, `msime-zhuyin.db`, `msime-stroke.db`). They ship beside the resource set rather than inside it, so the engine opens one only when its scheme is activated and treats a missing or unknown file as the scheme being unavailable.
2//!
3//! dict-builder writes every file with `SCHEMA` and the metadata below; this module is the one definition of that contract.
4
5use std::path::Path;
6
7use rusqlite::{types::Type, Connection, OpenFlags, OptionalExtension};
8
9use crate::diagnostics;
10use crate::error::{EngineError, Result};
11
12/// The schema version the engine reads. A file with any other `format_version` is refused.
13pub const FORMAT_VERSION: u32 = 1;
14
15/// The whole schema. `entries.key` is the syllables of an entry joined by a single space; in `msime-stroke.db` it is the character's stroke code (`hspnz` letters, no spaces).
16pub const SCHEMA: &str = "\
17CREATE TABLE metadata(name TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID;
18CREATE TABLE syllables(syllable TEXT PRIMARY KEY) WITHOUT ROWID;
19CREATE TABLE entries(key TEXT NOT NULL, text TEXT NOT NULL, weight INTEGER NOT NULL, PRIMARY KEY(key, text)) WITHOUT ROWID;
20CREATE INDEX entries_by_key_weight ON entries(key, weight DESC);
21";
22
23/// `metadata` row holding `FORMAT_VERSION` as decimal text.
24pub const METADATA_FORMAT_VERSION: &str = "format_version";
25/// `metadata` row holding the commit of the source data the file was built from.
26pub const METADATA_SOURCE_COMMIT: &str = "source_commit";
27/// `metadata` row holding the SPDX identifier of the source data's licence.
28pub const METADATA_LICENSE: &str = "license";
29
30/// One row of `entries`.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct LanguageEntry {
33    pub text: String,
34    pub weight: i64,
35}
36
37/// An open language dictionary whose format version has been checked.
38pub struct LanguageDictionary {
39    connection: Connection,
40}
41
42/// Opens `path` read-only and checks its format version (`LANGUAGE_DICTIONARY_UNAVAILABLE`, `LANGUAGE_DICTIONARY_VERSION_UNSUPPORTED`); never creates the file.
43pub fn open_read_only(path: &Path) -> Result<LanguageDictionary> {
44    // An empty path would open a private temporary database rather than fail.
45    if path.as_os_str().is_empty() {
46        return Err(EngineError::failed(
47            diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE,
48        ));
49    }
50    let unavailable =
51        |_: rusqlite::Error| EngineError::failed(diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE);
52    // A shipped resource like the offline glosses (host/glosses.rs), not user data: no symlink policy on the parent path and no busy wait, since nothing writes the file.
53    let path = crate::paths::sqlite_path_no_follow_allow_parent_symlinks(path)
54        .map_err(|_| EngineError::failed(diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE))?;
55    let connection = Connection::open_with_flags(
56        &path,
57        OpenFlags::SQLITE_OPEN_READ_ONLY
58            | OpenFlags::SQLITE_OPEN_NOFOLLOW
59            | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
60    )
61    .map_err(unavailable)?;
62    connection
63        .busy_timeout(std::time::Duration::ZERO)
64        .map_err(unavailable)?;
65    let dictionary = LanguageDictionary { connection };
66    // SQLite opens lazily, so a file that is not a database first fails on this read.
67    let version = dictionary
68        .metadata(METADATA_FORMAT_VERSION)
69        .map_err(|_| EngineError::failed(diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE))?;
70    if version.as_deref() != Some(FORMAT_VERSION.to_string().as_str()) {
71        return Err(EngineError::failed(
72            diagnostics::LANGUAGE_DICTIONARY_VERSION_UNSUPPORTED,
73        ));
74    }
75    Ok(dictionary)
76}
77
78impl LanguageDictionary {
79    /// 浏览全部词条,或按编码前缀、词条文字筛选后分页;`instr` 按字面处理通配符。
80    pub(crate) fn browse(
81        &self,
82        query: &str,
83        offset: usize,
84        limit: usize,
85    ) -> Result<Vec<crate::dictionary::DictRow>> {
86        let query = query.to_ascii_lowercase();
87        let mut statement = self.connection.prepare_cached(
88            "SELECT key,text,weight FROM entries WHERE ?1='' OR instr(key,?1)=1 OR instr(text,?1)>0 ORDER BY key,text LIMIT ?2 OFFSET ?3",
89        )?;
90        let rows = statement.query_map((query, limit as i64, offset as i64), |row| {
91            Ok(crate::dictionary::DictRow {
92                key: row.get(0)?,
93                value: row.get(1)?,
94                weight: row.get(2)?,
95            })
96        })?;
97        Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
98    }
99
100    /// The `metadata` value stored under `name`.
101    pub fn metadata(&self, name: &str) -> Result<Option<String>> {
102        Ok(self
103            .connection
104            .prepare_cached("SELECT value FROM metadata WHERE name = ?1")?
105            .query_row((name,), |row| row.get(0))
106            .optional()?)
107    }
108
109    /// The entries stored under exactly `key`, heaviest first and by text within a weight, at most `limit`.
110    pub fn lookup(&self, key: &str, limit: usize) -> Result<Vec<LanguageEntry>> {
111        let mut result = Vec::new();
112        self.lookup_into(key, limit, &mut result)?;
113        Ok(result)
114    }
115
116    /// 将精确键的查询结果写入已有缓冲,保留其中字符串的容量。
117    pub fn lookup_into(
118        &self,
119        key: &str,
120        limit: usize,
121        result: &mut Vec<LanguageEntry>,
122    ) -> Result<()> {
123        let capacity = query_capacity(limit);
124        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
125        let mut statement = self.connection.prepare_cached(
126            "SELECT text, weight FROM entries WHERE key = ?1 ORDER BY weight DESC, text ASC LIMIT ?2",
127        )?;
128        let mut length = 0;
129        let mut rows = statement.query((key, limit))?;
130        while let Some(row) = rows.next()? {
131            let text = row.get_ref(0)?.as_str().map_err(|error| {
132                rusqlite::Error::FromSqlConversionFailure(0, Type::Text, Box::new(error))
133            })?;
134            let weight = row.get(1)?;
135            reserve_query_capacity(result, length, capacity);
136            if let Some(entry) = result.get_mut(length) {
137                entry.text.clear();
138                entry.text.push_str(text);
139                entry.weight = weight;
140            } else {
141                result.push(LanguageEntry {
142                    text: text.to_owned(),
143                    weight,
144                });
145            }
146            length += 1;
147        }
148        result.truncate(length);
149        Ok(())
150    }
151
152    /// Whether `syllable` is in the scheme's syllable inventory.
153    pub fn has_syllable(&self, syllable: &str) -> Result<bool> {
154        Ok(self
155            .connection
156            .prepare_cached("SELECT 1 FROM syllables WHERE syllable = ?1")?
157            .exists((syllable,))?)
158    }
159
160    /// The whole syllable inventory, for a scheme that segments typed letters against it in memory.
161    pub fn syllables(&self) -> Result<Vec<String>> {
162        let mut statement = self
163            .connection
164            .prepare_cached("SELECT syllable FROM syllables")?;
165        let rows = statement.query_map((), |row| row.get(0))?;
166        Ok(rows.collect::<rusqlite::Result<_>>()?)
167    }
168
169    /// The entries whose key completes the last syllable of `prefix`: the key starts with `prefix` and has no syllable boundary after it, so `nei h` finds `nei hou` but not `nei hou aa`. Each comes with its key, heaviest first and by text within a weight, at most `limit`.
170    pub fn lookup_completions(
171        &self,
172        prefix: &str,
173        limit: usize,
174    ) -> Result<Vec<(String, LanguageEntry)>> {
175        let mut result = Vec::new();
176        self.lookup_completions_into(prefix, limit, &mut result)?;
177        Ok(result)
178    }
179
180    /// 将补全查询结果写入已有缓冲,保留键和值字符串的容量。
181    pub fn lookup_completions_into(
182        &self,
183        prefix: &str,
184        limit: usize,
185        result: &mut Vec<(String, LanguageEntry)>,
186    ) -> Result<()> {
187        let capacity = query_capacity(limit);
188        // Keys are space-joined syllables, so every key starting with `prefix` sorts at or after it and before `prefix` with its last character incremented.
189        let Some(upper) = completion_upper_bound(prefix) else {
190            result.clear();
191            return Ok(());
192        };
193        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
194        let mut statement = self.connection.prepare_cached(
195            "SELECT key, text, weight FROM entries WHERE key >= ?1 AND key < ?2 AND instr(substr(key, length(?1) + 1), ' ') = 0 ORDER BY weight DESC, text ASC LIMIT ?3",
196        )?;
197        let mut length = 0;
198        let mut rows = statement.query((prefix, upper, limit))?;
199        while let Some(row) = rows.next()? {
200            let key = row.get_ref(0)?.as_str().map_err(|error| {
201                rusqlite::Error::FromSqlConversionFailure(0, Type::Text, Box::new(error))
202            })?;
203            let text = row.get_ref(1)?.as_str().map_err(|error| {
204                rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
205            })?;
206            let weight = row.get(2)?;
207            reserve_query_capacity(result, length, capacity);
208            if let Some((existing_key, entry)) = result.get_mut(length) {
209                existing_key.clear();
210                existing_key.push_str(key);
211                entry.text.clear();
212                entry.text.push_str(text);
213                entry.weight = weight;
214            } else {
215                result.push((
216                    key.to_owned(),
217                    LanguageEntry {
218                        text: text.to_owned(),
219                        weight,
220                    },
221                ));
222            }
223            length += 1;
224        }
225        result.truncate(length);
226        Ok(())
227    }
228
229    /// 键以 `prefix` 开头的全部词条的文字,不分先后、可能重复。九宫格的笔画筛选用它取「笔顺以这几笔开头的字」:键按前缀落在一段连续区间里,只扫这一段。
230    pub fn texts_with_key_prefix(&self, prefix: &str) -> Result<Vec<String>> {
231        let Some(upper) = completion_upper_bound(prefix) else {
232            return Ok(Vec::new());
233        };
234        let mut statement = self
235            .connection
236            .prepare_cached("SELECT text FROM entries WHERE key >= ?1 AND key < ?2")?;
237        let rows = statement.query_map((prefix, upper), |row| row.get(0))?;
238        Ok(rows.collect::<rusqlite::Result<_>>()?)
239    }
240
241    /// 匹配 `pattern` 的词条,`wildcard` 匹配任意非空格字符,其他字符按字面匹配。补全只匹配键前缀且余下部分不能有音节边界;精确匹配整个键。按权重降序、文字升序返回至多 `limit` 条。首个通配符前的字面前缀限制扫描范围,只有开头通配符需要整表过滤。
242    pub fn lookup_pattern(
243        &self,
244        pattern: &str,
245        wildcard: char,
246        completions: bool,
247        limit: usize,
248    ) -> Result<Vec<(String, LanguageEntry)>> {
249        if pattern.is_empty() {
250            return Ok(Vec::new());
251        }
252        let mut result = Vec::new();
253        self.lookup_pattern_into(pattern, wildcard, completions, limit, &mut result)?;
254        Ok(result)
255    }
256
257    /// 将通配查询写入已有缓冲,复用键与文字的字符串容量;空模式清空结果。
258    pub fn lookup_pattern_into(
259        &self,
260        pattern: &str,
261        wildcard: char,
262        completions: bool,
263        limit: usize,
264        result: &mut Vec<(String, LanguageEntry)>,
265    ) -> Result<()> {
266        if pattern.is_empty() {
267            result.clear();
268            return Ok(());
269        }
270        let glob = glob_pattern(pattern, wildcard, completions);
271        let length = i64::try_from(pattern.chars().count()).unwrap_or(i64::MAX);
272        let literal = pattern.split(wildcard).next().unwrap_or_default();
273        let capacity = query_capacity(limit);
274        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
275        // 字面前缀为空(通配符打头)时没有可用的键范围,只能整表按 GLOB 过滤。
276        match completion_upper_bound(literal) {
277            Some(upper) => {
278                let mut statement = self.connection.prepare_cached(
279                    "SELECT key, text, weight FROM entries WHERE key >= ?1 AND key < ?2 AND key GLOB ?3 AND instr(substr(key, ?4 + 1), ' ') = 0 ORDER BY weight DESC, text ASC LIMIT ?5",
280                )?;
281                read_keyed_rows_into(
282                    &mut statement.query((literal, upper, glob, length, limit))?,
283                    result,
284                    capacity,
285                )?;
286            }
287            None => {
288                let mut statement = self.connection.prepare_cached(
289                    "SELECT key, text, weight FROM entries WHERE key GLOB ?1 AND instr(substr(key, ?2 + 1), ' ') = 0 ORDER BY weight DESC, text ASC LIMIT ?3",
290                )?;
291                read_keyed_rows_into(
292                    &mut statement.query((glob, length, limit))?,
293                    result,
294                    capacity,
295                )?;
296            }
297        }
298        Ok(())
299    }
300
301    /// 键按位置逐段取自 `positions[i]`(以单个空格连接)的词条,各带自己的键;按权重从重到轻,再按文字、键排序,至多 `limit` 条。每个位置都只有一个读音时就是 `lookup` 对连接后的键查询,结果与它逐条相同。
302    pub fn lookup_readings(
303        &self,
304        positions: &[&[String]],
305        limit: usize,
306    ) -> Result<Vec<(String, LanguageEntry)>> {
307        let Some((first, rest)) = positions.split_first() else {
308            return Ok(Vec::new());
309        };
310        if positions.iter().all(|readings| readings.len() == 1) {
311            let key = join_single_readings(positions);
312            let mut entries = self.lookup(&key, limit)?;
313            if entries.len() <= 1 {
314                return Ok(entries
315                    .pop()
316                    .map(|entry| vec![(key, entry)])
317                    .unwrap_or_default());
318            }
319            let mut entries = entries.into_iter();
320            let first = entries.next().expect("多结果查询至少有一条结果");
321            let mut result = Vec::with_capacity(entries.len() + 1);
322            result.push((key, first));
323            for entry in entries {
324                result.push((result[0].0.clone(), entry));
325            }
326            return Ok(result);
327        }
328        let mut result = Vec::new();
329        if rest.is_empty() {
330            let limit = i64::try_from(limit).unwrap_or(i64::MAX);
331            let mut statement = self.connection.prepare_cached(
332                "SELECT text, weight FROM entries WHERE key = ?1 ORDER BY weight DESC, text ASC LIMIT ?2",
333            )?;
334            for reading in first.iter() {
335                let mut rows = statement.query((reading, limit))?;
336                while let Some(row) = rows.next()? {
337                    result.push((
338                        reading.clone(),
339                        LanguageEntry {
340                            text: row.get(0)?,
341                            weight: row.get(1)?,
342                        },
343                    ));
344                }
345            }
346        } else {
347            // 后面各位置拼成一个 GLOB,配合首个读音的键范围只扫以它开头的多音节词;不加 SQL `LIMIT`,因为 GLOB 只是粗筛,精确的校验在 Rust 里做,提前截断可能丢掉正确的行。
348            let tail = readings_glob(rest);
349            let mut statement = self.connection.prepare_cached(
350                "SELECT key, text, weight FROM entries WHERE key >= ?1 AND key < ?2 AND key GLOB ?3",
351            )?;
352            let mut lower = String::new();
353            let mut upper = String::new();
354            let mut glob = String::new();
355            for reading in first.iter() {
356                // 每个首读音查询结束后复用范围与 GLOB 的存储,容量不足时再补足。
357                lower.clear();
358                lower.reserve_exact(reading.len() + 1);
359                lower.push_str(reading);
360                lower.push(' ');
361                upper.clear();
362                upper.reserve_exact(reading.len() + 1);
363                upper.push_str(reading);
364                upper.push('!');
365                glob.clear();
366                glob.reserve_exact(reading.len() * 2 + 1 + tail.len());
367                push_glob_literal(&mut glob, reading);
368                glob.push(' ');
369                glob.push_str(&tail);
370                let mut rows = statement.query((lower.as_str(), upper.as_str(), glob.as_str()))?;
371                while let Some(row) = rows.next()? {
372                    let key = row.get_ref(0)?.as_str().map_err(|error| {
373                        rusqlite::Error::FromSqlConversionFailure(0, Type::Text, Box::new(error))
374                    })?;
375                    let text = row.get_ref(1)?.as_str().map_err(|error| {
376                        rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
377                    })?;
378                    let weight = row.get(2)?;
379                    if key_matches(key, positions) {
380                        result.push((
381                            key.to_owned(),
382                            LanguageEntry {
383                                text: text.to_owned(),
384                                weight,
385                            },
386                        ));
387                    }
388                }
389            }
390        }
391        result.sort_by(|(left_key, left), (right_key, right)| {
392            right
393                .weight
394                .cmp(&left.weight)
395                .then_with(|| left.text.cmp(&right.text))
396                .then_with(|| left_key.cmp(right_key))
397        });
398        result.truncate(limit);
399        Ok(result)
400    }
401}
402
403fn join_single_readings(positions: &[&[String]]) -> String {
404    let capacity = positions
405        .iter()
406        .map(|readings| readings[0].len())
407        .sum::<usize>()
408        .saturating_add(positions.len().saturating_sub(1));
409    let mut key = String::with_capacity(capacity);
410    for (index, readings) in positions.iter().enumerate() {
411        if index != 0 {
412            key.push(' ');
413        }
414        key.push_str(&readings[0]);
415    }
416    key
417}
418
419/// `positions` 各位置的 GLOB,位置之间用空格。只有一个读音的位置按字面匹配;多个读音且字数相同时,逐个字符下标写出该下标上出现过的字符组成的字符类;字数不同、或字符类里会混进 GLOB 自己的 `]` `^` `-` 时退成任意字符,交给 `key_matches` 精确校验。
420fn readings_glob(positions: &[&[String]]) -> String {
421    let mut glob = String::new();
422    for (index, readings) in positions.iter().enumerate() {
423        if index > 0 {
424            glob.push(' ');
425        }
426        if let [reading] = readings {
427            push_glob_literal(&mut glob, reading);
428            continue;
429        }
430        let lengths: Vec<usize> = readings
431            .iter()
432            .map(|reading| reading.chars().count())
433            .collect();
434        if lengths.windows(2).any(|pair| pair[0] != pair[1]) {
435            glob.push('*');
436            continue;
437        }
438        for position in 0..lengths.first().copied().unwrap_or_default() {
439            let mut class: Vec<char> = readings
440                .iter()
441                .filter_map(|reading| reading.chars().nth(position))
442                .collect();
443            class.sort_unstable();
444            class.dedup();
445            match class.as_slice() {
446                [only] => push_glob_literal(&mut glob, &only.to_string()),
447                _ if class
448                    .iter()
449                    .any(|character| matches!(character, ']' | '^' | '-')) =>
450                {
451                    glob.push('?');
452                }
453                _ => {
454                    glob.push('[');
455                    glob.extend(class);
456                    glob.push(']');
457                }
458            }
459        }
460    }
461    glob
462}
463
464/// 按字面匹配 `text`:GLOB 的元字符写成只含它自己的字符类。
465fn push_glob_literal(glob: &mut String, text: &str) {
466    for character in text.chars() {
467        match character {
468            '*' | '?' | '[' => {
469                glob.push('[');
470                glob.push(character);
471                glob.push(']');
472            }
473            _ => glob.push(character),
474        }
475    }
476}
477
478/// `key` 的每个音节是否都是对应位置允许的读音,且音节数与位置数相同。
479fn key_matches(key: &str, positions: &[&[String]]) -> bool {
480    let mut syllables = key.split(' ');
481    positions.iter().all(|readings| {
482        syllables
483            .next()
484            .is_some_and(|syllable| readings.iter().any(|reading| reading == syllable))
485    }) && syllables.next().is_none()
486}
487
488/// `pattern` as a GLOB: the wildcard becomes `[^ ]`, so it never matches a syllable boundary, GLOB's own metacharacters are matched literally, and a pattern for completions ends in `*`.
489fn glob_pattern(pattern: &str, wildcard: char, completions: bool) -> String {
490    let mut glob = String::with_capacity(pattern.len() * 4 + 1);
491    for character in pattern.chars() {
492        match character {
493            _ if character == wildcard => glob.push_str("[^ ]"),
494            '*' | '?' | '[' => {
495                glob.push('[');
496                glob.push(character);
497                glob.push(']');
498            }
499            _ => glob.push(character),
500        }
501    }
502    if completions {
503        glob.push('*');
504    }
505    glob
506}
507
508fn read_keyed_rows_into(
509    rows: &mut rusqlite::Rows<'_>,
510    result: &mut Vec<(String, LanguageEntry)>,
511    capacity: Option<usize>,
512) -> rusqlite::Result<()> {
513    let mut length = 0;
514    while let Some(row) = rows.next()? {
515        let key = row.get_ref(0)?.as_str().map_err(|error| {
516            rusqlite::Error::FromSqlConversionFailure(0, Type::Text, Box::new(error))
517        })?;
518        let text = row.get_ref(1)?.as_str().map_err(|error| {
519            rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
520        })?;
521        let weight = row.get(2)?;
522        reserve_query_capacity(result, length, capacity);
523        if let Some((existing_key, entry)) = result.get_mut(length) {
524            existing_key.clear();
525            existing_key.push_str(key);
526            entry.text.clear();
527            entry.text.push_str(text);
528            entry.weight = weight;
529        } else {
530            result.push((
531                key.to_owned(),
532                LanguageEntry {
533                    text: text.to_owned(),
534                    weight,
535                },
536            ));
537        }
538        length += 1;
539    }
540    result.truncate(length);
541    Ok(())
542}
543
544fn reserve_query_capacity<T>(result: &mut Vec<T>, length: usize, capacity: Option<usize>) {
545    if length == 0 && result.is_empty() && result.capacity() == 0 {
546        if let Some(capacity) = capacity {
547            result.reserve_exact(capacity);
548        }
549    }
550}
551
552fn completion_upper_bound(prefix: &str) -> Option<String> {
553    let last = prefix.chars().next_back()?;
554    let next = char::from_u32(u32::from(last) + 1)?;
555    let head = &prefix[..prefix.len() - last.len_utf8()];
556    let mut upper = String::with_capacity(head.len() + next.len_utf8());
557    upper.push_str(head);
558    upper.push(next);
559    Some(upper)
560}
561
562fn query_capacity(limit: usize) -> Option<usize> {
563    (limit < i32::MAX as usize).then_some(limit)
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn build(path: &Path, format_version: &str) {
571        let connection = Connection::open(path).unwrap();
572        connection.execute_batch(SCHEMA).unwrap();
573        for (name, value) in [
574            (METADATA_FORMAT_VERSION, format_version),
575            (
576                METADATA_SOURCE_COMMIT,
577                "259f0e48bba840c3a2e0d117539e96937f3d89bc",
578            ),
579            (METADATA_LICENSE, "CC-BY-4.0"),
580        ] {
581            connection
582                .execute("INSERT INTO metadata VALUES (?1, ?2)", (name, value))
583                .unwrap();
584        }
585        for syllable in ["nei", "hou"] {
586            connection
587                .execute("INSERT INTO syllables VALUES (?1)", (syllable,))
588                .unwrap();
589        }
590        for (key, text, weight) in [
591            ("nei hou", "你好", 900),
592            ("nei hou", "妳好", 40),
593            ("nei hou", "你號", 40),
594            ("nei", "你", 5000),
595        ] {
596            connection
597                .execute(
598                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
599                    (key, text, weight),
600                )
601                .unwrap();
602        }
603    }
604
605    fn message(error: EngineError) -> String {
606        error.to_string()
607    }
608
609    #[test]
610    fn completion_upper_bound_increments_only_the_last_character() {
611        assert_eq!(completion_upper_bound("nei h").as_deref(), Some("nei i"));
612        assert_eq!(completion_upper_bound("ㄋㄧˇ").as_deref(), Some("ㄋㄧˈ"));
613    }
614
615    #[test]
616    fn browse_pages_all_rows_and_filters_literal_keys_and_words_without_writes() {
617        let dir = tempfile::tempdir().unwrap();
618        let path = dir.path().join("dictionary.db");
619        build(&path, &FORMAT_VERSION.to_string());
620        Connection::open(&path)
621            .unwrap()
622            .execute_batch("INSERT INTO entries VALUES('ㄋㄧˇ', '你%_', 10);")
623            .unwrap();
624        let before = std::fs::read(&path).unwrap();
625        let dictionary = open_read_only(&path).unwrap();
626        let all = dictionary.browse("", 0, 20).unwrap();
627        assert_eq!(all.len(), 5);
628        assert_eq!(dictionary.browse("", 2, 2).unwrap(), all[2..4]);
629        assert_eq!(dictionary.browse("NEI", 0, 10).unwrap().len(), 4);
630        assert_eq!(dictionary.browse("好", 0, 10).unwrap().len(), 2);
631        assert_eq!(dictionary.browse("ㄋ", 0, 10).unwrap()[0].value, "你%_");
632        assert_eq!(dictionary.browse("%_", 0, 10).unwrap()[0].value, "你%_");
633        assert!(dictionary.browse("不存在", 0, 10).unwrap().is_empty());
634        assert_eq!(std::fs::read(&path).unwrap(), before);
635    }
636
637    #[test]
638    fn round_trips_entries_syllables_and_metadata() {
639        let dir = tempfile::tempdir().unwrap();
640        let path = dir.path().join("msime-cantonese.db");
641        build(&path, &FORMAT_VERSION.to_string());
642
643        let dictionary = open_read_only(&path).unwrap();
644        let entry = |text: &str, weight| LanguageEntry {
645            text: text.to_owned(),
646            weight,
647        };
648        let entries = dictionary.lookup("nei hou", 10).unwrap();
649        assert_eq!(entries.capacity(), 10);
650        assert_eq!(
651            entries,
652            vec![entry("你好", 900), entry("你號", 40), entry("妳好", 40)]
653        );
654        assert_eq!(
655            dictionary.lookup("nei hou", 1).unwrap(),
656            vec![entry("你好", 900)]
657        );
658        assert_eq!(
659            dictionary.lookup("nei", 10).unwrap(),
660            vec![entry("你", 5000)]
661        );
662        let empty = dictionary.lookup("ngo", 10).unwrap();
663        assert!(empty.is_empty());
664        assert_eq!(empty.capacity(), 0);
665        assert!(dictionary.has_syllable("hou").unwrap());
666        assert!(!dictionary.has_syllable("ho").unwrap());
667        assert_eq!(
668            dictionary.metadata(METADATA_LICENSE).unwrap().as_deref(),
669            Some("CC-BY-4.0")
670        );
671        assert_eq!(dictionary.metadata("missing").unwrap(), None);
672    }
673
674    #[test]
675    fn lists_syllables_and_completes_the_last_syllable() {
676        let dir = tempfile::tempdir().unwrap();
677        let path = dir.path().join("msime-cantonese.db");
678        build(&path, &FORMAT_VERSION.to_string());
679        let connection = Connection::open(&path).unwrap();
680        for (key, text, weight) in [("nei hou aa", "你好呀", 10), ("nei i", "你意", 5)] {
681            connection
682                .execute(
683                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
684                    (key, text, weight),
685                )
686                .unwrap();
687        }
688        drop(connection);
689
690        let dictionary = open_read_only(&path).unwrap();
691        let mut syllables = dictionary.syllables().unwrap();
692        syllables.sort();
693        assert_eq!(syllables, ["hou", "nei"]);
694        let completions = |prefix: &str, limit| {
695            let rows = dictionary.lookup_completions(prefix, limit).unwrap();
696            (
697                rows.capacity(),
698                rows.into_iter()
699                    .map(|(key, entry)| (key, entry.text))
700                    .collect::<Vec<_>>(),
701            )
702        };
703        let pair = |key: &str, text: &str| (key.to_owned(), text.to_owned());
704        let (capacity, rows) = completions("nei h", 10);
705        assert_eq!(capacity, 10);
706        assert_eq!(
707            rows,
708            [
709                pair("nei hou", "你好"),
710                pair("nei hou", "你號"),
711                pair("nei hou", "妳好")
712            ]
713        );
714        assert_eq!(completions("nei h", 1).1, [pair("nei hou", "你好")]);
715        assert_eq!(completions("ne", 10).1, [pair("nei", "你")]);
716        assert!(completions("nei ho", 0).1.is_empty());
717        let (capacity, rows) = completions("ngo", 10);
718        assert!(rows.is_empty());
719        assert_eq!(capacity, 0);
720        let (capacity, rows) = completions("", 10);
721        assert!(rows.is_empty());
722        assert_eq!(capacity, 0);
723    }
724
725    #[test]
726    fn glob_pattern_maps_the_wildcard_and_escapes_glob_metacharacters() {
727        assert_eq!(glob_pattern("hxs", 'x', false), "h[^ ]s");
728        assert_eq!(glob_pattern("hxs", 'x', true), "h[^ ]s*");
729        assert_eq!(glob_pattern("a*?[", 'x', false), "a[*][?][[]");
730    }
731
732    // 合成的笔画数据:键是笔顺字母串。通配符可以在中间、末尾或开头;补全只匹配前缀,精确只匹配同长度的键;带空格的键(将来的词组)不进单字结果。
733    #[test]
734    fn matches_wildcard_patterns_within_the_literal_prefix() {
735        let dir = tempfile::tempdir().unwrap();
736        let path = dir.path().join("msime-stroke.db");
737        build(&path, &FORMAT_VERSION.to_string());
738        let connection = Connection::open(&path).unwrap();
739        for (key, text, weight) in [
740            ("hs", "十", 800),
741            ("hh", "二", 900),
742            ("hhh", "三", 700),
743            ("hsh", "土", 600),
744            ("hpn", "大", 950),
745            ("sh", "上", 650),
746            ("hs hs", "十十", 5000),
747            ("a*b", "星", 1),
748        ] {
749            connection
750                .execute(
751                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
752                    (key, text, weight),
753                )
754                .unwrap();
755        }
756        drop(connection);
757
758        let dictionary = open_read_only(&path).unwrap();
759        let texts = |pattern: &str, completions, limit| {
760            let rows = dictionary
761                .lookup_pattern(pattern, 'x', completions, limit)
762                .unwrap();
763            let mut reused = Vec::new();
764            dictionary
765                .lookup_pattern_into(pattern, 'x', completions, limit, &mut reused)
766                .unwrap();
767            assert_eq!(reused, rows);
768            (
769                rows.capacity(),
770                rows.into_iter()
771                    .map(|(key, entry)| format!("{key}:{}", entry.text))
772                    .collect::<Vec<_>>(),
773            )
774        };
775        let (capacity, rows) = texts("hx", false, 10);
776        assert_eq!(capacity, 10);
777        assert_eq!(rows, ["hh:二", "hs:十"]);
778        assert_eq!(
779            texts("hx", true, 10).1,
780            ["hpn:大", "hh:二", "hs:十", "hhh:三", "hsh:土"]
781        );
782        assert_eq!(texts("hx", true, 2).1, ["hpn:大", "hh:二"]);
783        assert_eq!(texts("hxh", false, 10).1, ["hhh:三", "hsh:土"]);
784        assert_eq!(texts("xh", false, 10).1, ["hh:二", "sh:上"]);
785        assert_eq!(
786            texts("xxx", false, 10).1,
787            ["nei:你", "hpn:大", "hhh:三", "hsh:土", "a*b:星"]
788        );
789        assert_eq!(texts("hs", false, 10).1, ["hs:十"]);
790        assert_eq!(texts("hsx", true, 10).1, ["hsh:土"]);
791        // GLOB 的元字符按字面匹配。
792        assert_eq!(texts("a*b", false, 10).1, ["a*b:星"]);
793        let (capacity, rows) = texts("a?b", false, 10);
794        assert!(rows.is_empty());
795        assert_eq!(capacity, 0);
796        assert!(texts("", true, 10).1.is_empty());
797        let (capacity, rows) = texts("xxxxxx", true, 10);
798        assert!(rows.is_empty());
799        assert_eq!(capacity, 0);
800        assert!(texts("hx", true, 0).1.is_empty());
801    }
802
803    #[test]
804    fn wildcard_query_reuses_row_strings_and_truncates_stale_results() {
805        let dir = tempfile::tempdir().unwrap();
806        let path = dir.path().join("msime-stroke.db");
807        build(&path, &FORMAT_VERSION.to_string());
808        let dictionary = open_read_only(&path).unwrap();
809        let mut result = Vec::new();
810        for pattern in ["nei xxx", "xxx xxx"] {
811            dictionary
812                .lookup_pattern_into(pattern, 'x', false, 10, &mut result)
813                .unwrap();
814            assert_eq!(result.len(), 3);
815            let expected = result.clone();
816            let pointers = result
817                .iter()
818                .map(|(key, entry)| (key.as_ptr(), entry.text.as_ptr()))
819                .collect::<Vec<_>>();
820            let vector = result.as_ptr();
821            for (key, entry) in &mut result {
822                key.clear();
823                entry.text.clear();
824                entry.weight = -9;
825            }
826            dictionary
827                .lookup_pattern_into(pattern, 'x', false, 10, &mut result)
828                .unwrap();
829            assert_eq!(result, expected);
830            assert_eq!(result.as_ptr(), vector);
831            assert_eq!(
832                result
833                    .iter()
834                    .map(|(key, entry)| (key.as_ptr(), entry.text.as_ptr()))
835                    .collect::<Vec<_>>(),
836                pointers
837            );
838            dictionary
839                .lookup_pattern_into(pattern, 'x', false, 1, &mut result)
840                .unwrap();
841            assert_eq!(result, expected[..1]);
842        }
843        for (pattern, limit) in [("xxx xxx", 0), ("missing", 10), ("", 10)] {
844            dictionary
845                .lookup_pattern_into("nei xxx", 'x', false, 10, &mut result)
846                .unwrap();
847            assert!(!result.is_empty());
848            dictionary
849                .lookup_pattern_into(pattern, 'x', false, limit, &mut result)
850                .unwrap();
851            assert!(result.is_empty());
852        }
853    }
854
855    fn readings(values: &[&str]) -> Vec<String> {
856        values.iter().map(|value| (*value).to_owned()).collect()
857    }
858
859    // 合成的注音数据:九键下一个位置有多个读音,查询只返回逐段都属于对应位置、且音节数一致的键。
860    #[test]
861    fn looks_up_keys_whose_syllables_fall_in_each_position() {
862        let dir = tempfile::tempdir().unwrap();
863        let path = dir.path().join("msime-zhuyin.db");
864        build(&path, &FORMAT_VERSION.to_string());
865        let connection = Connection::open(&path).unwrap();
866        for (key, text, weight) in [
867            ("ㄋㄧˇ", "你", 1000),
868            ("ㄋㄧˇ", "妳", 300),
869            ("ㄌㄧˇ", "李", 1200),
870            ("ㄌㄧˇ", "你", 5),
871            ("ㄋㄧˇ ㄏㄠˇ", "你好", 500),
872            ("ㄌㄧˇ ㄏㄠˇ", "李好", 500),
873            ("ㄋㄧˇ ㄏㄠˋ", "你號", 900),
874            ("ㄋㄧ ㄏㄠˇ", "妮好", 900),
875            ("ㄋㄧˇ ㄏㄠˇ ㄇㄚ˙", "你好嗎", 900),
876            ("ㄋㄧˇ ㄏㄨㄚˇ", "你畫", 900),
877            ("ㄌㄧˇ ㄎㄠˇ", "李考", 400),
878            ("ㄋㄧˇ ㄍㄠˇ", "你搞", 700),
879        ] {
880            connection
881                .execute(
882                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
883                    (key, text, weight),
884                )
885                .unwrap();
886        }
887        drop(connection);
888        let dictionary = open_read_only(&path).unwrap();
889        let ni_li = readings(&["ㄋㄧˇ", "ㄌㄧˇ"]);
890        let hao = readings(&["ㄏㄠˇ"]);
891        let gao_kao_hao = readings(&["ㄍㄠˇ", "ㄎㄠˇ", "ㄏㄠˇ"]);
892        let rows = |positions: &[&[String]], limit| {
893            dictionary
894                .lookup_readings(positions, limit)
895                .unwrap()
896                .into_iter()
897                .map(|(key, entry)| format!("{key}:{}:{}", entry.text, entry.weight))
898                .collect::<Vec<_>>()
899        };
900
901        // 声调不符(ㄏㄠˋ)、首音节一声(ㄋㄧ)、多一个音节、韵母字数不同(ㄏㄨㄚˇ)的键都被排除;同权重按文字再按键排序。
902        assert_eq!(
903            rows(&[&ni_li, &hao], usize::MAX),
904            ["ㄋㄧˇ ㄏㄠˇ:你好:500", "ㄌㄧˇ ㄏㄠˇ:李好:500"]
905        );
906        // 后面的位置也可以有多个读音。
907        assert_eq!(
908            rows(&[&ni_li, &gao_kao_hao], usize::MAX),
909            [
910                "ㄋㄧˇ ㄍㄠˇ:你搞:700",
911                "ㄋㄧˇ ㄏㄠˇ:你好:500",
912                "ㄌㄧˇ ㄏㄠˇ:李好:500",
913                "ㄌㄧˇ ㄎㄠˇ:李考:400",
914            ]
915        );
916        assert_eq!(rows(&[&ni_li, &gao_kao_hao], 1), ["ㄋㄧˇ ㄍㄠˇ:你搞:700"]);
917        // 单个位置多个读音:按权重合并,同一个字可以在两个读音下各出现一次。
918        assert_eq!(
919            rows(&[&ni_li], usize::MAX),
920            [
921                "ㄌㄧˇ:李:1200",
922                "ㄋㄧˇ:你:1000",
923                "ㄋㄧˇ:妳:300",
924                "ㄌㄧˇ:你:5"
925            ]
926        );
927        assert_eq!(rows(&[&ni_li], 2), ["ㄌㄧˇ:李:1200", "ㄋㄧˇ:你:1000"]);
928        // 每个位置只有一个读音时与 `lookup` 逐条相同。
929        let ni = readings(&["ㄋㄧˇ"]);
930        let expected: Vec<String> = dictionary
931            .lookup("ㄋㄧˇ ㄏㄠˇ", usize::MAX)
932            .unwrap()
933            .into_iter()
934            .map(|entry| format!("ㄋㄧˇ ㄏㄠˇ:{}:{}", entry.text, entry.weight))
935            .collect();
936        assert_eq!(rows(&[&ni, &hao], usize::MAX), expected);
937        assert_eq!(rows(&[&ni], 1), ["ㄋㄧˇ:你:1000"]);
938        assert!(rows(&[], 10).is_empty());
939        assert!(rows(&[&hao, &ni_li], 10).is_empty());
940    }
941
942    #[test]
943    fn single_reading_result_does_not_clone_the_query_key() {
944        let dir = tempfile::tempdir().unwrap();
945        let path = dir.path().join("msime-zhuyin.db");
946        build(&path, &FORMAT_VERSION.to_string());
947        let connection = Connection::open(&path).unwrap();
948        connection
949            .execute(
950                "INSERT INTO entries VALUES (?1, ?2, ?3)",
951                ("ㄋㄧˇ", "你", 1000),
952            )
953            .unwrap();
954        drop(connection);
955
956        let dictionary = open_read_only(&path).unwrap();
957        let reading = readings(&["ㄋㄧˇ"]);
958        // 预热语句缓存,统计只覆盖候选查询本身。
959        let expected = dictionary.lookup_readings(&[&reading], 1).unwrap();
960        let (actual, allocations) = crate::ime::personal_rerank::allocations::count(|| {
961            dictionary.lookup_readings(&[&reading], 1).unwrap()
962        });
963
964        assert_eq!(actual, expected);
965        assert_eq!(
966            allocations, 5,
967            "单结果查询不应为键再分配副本:{allocations}"
968        );
969    }
970
971    #[test]
972    fn alternative_readings_write_rows_without_temporary_result_vectors() {
973        let dir = tempfile::tempdir().unwrap();
974        let path = dir.path().join("msime-zhuyin.db");
975        build(&path, &FORMAT_VERSION.to_string());
976        let connection = Connection::open(&path).unwrap();
977        for (key, text, weight) in [
978            ("ㄋㄧˇ", "你", 1000),
979            ("ㄋㄧˇ", "妳", 900),
980            ("ㄌㄧˇ", "李", 800),
981            ("ㄌㄧˇ", "里", 700),
982        ] {
983            connection
984                .execute(
985                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
986                    (key, text, weight),
987                )
988                .unwrap();
989        }
990        drop(connection);
991
992        let dictionary = open_read_only(&path).unwrap();
993        let readings = readings(&["ㄋㄧˇ", "ㄌㄧˇ"]);
994        // 预热语句缓存,统计只覆盖候选查询本身。
995        let expected = dictionary.lookup_readings(&[&readings], 10).unwrap();
996        let (actual, allocations) = crate::ime::personal_rerank::allocations::count(|| {
997            dictionary.lookup_readings(&[&readings], 10).unwrap()
998        });
999
1000        assert_eq!(actual, expected);
1001        assert!(
1002            allocations <= 10,
1003            "多个读音查询不应为每个读音建立临时结果向量:{allocations}"
1004        );
1005    }
1006
1007    #[test]
1008    fn missing_alternative_readings_reuse_query_string_storage() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let path = dir.path().join("msime-zhuyin.db");
1011        build(&path, &FORMAT_VERSION.to_string());
1012        let dictionary = open_read_only(&path).unwrap();
1013        let first = readings(&[
1014            "ㄅㄚ", "ㄆㄚ", "ㄇㄚ", "ㄈㄚ", "ㄉㄚ", "ㄊㄚ", "ㄋㄚ", "ㄌㄚ",
1015        ]);
1016        let rest = readings(&["ㄏㄠˇ"]);
1017        // 预热语句缓存,只统计一次多读音未命中查询的 Rust 分配。
1018        assert!(dictionary
1019            .lookup_readings(&[&first, &rest], usize::MAX)
1020            .unwrap()
1021            .is_empty());
1022        let (actual, allocations) = crate::ime::personal_rerank::allocations::count(|| {
1023            dictionary
1024                .lookup_readings(&[&first, &rest], usize::MAX)
1025                .unwrap()
1026        });
1027        assert!(actual.is_empty());
1028        assert_eq!(actual.capacity(), 0);
1029        assert!(
1030            allocations <= 8,
1031            "多个首读音不应各自分配查询字符串:{allocations}"
1032        );
1033    }
1034
1035    #[test]
1036    fn reused_reading_query_strings_keep_literal_keys_order_and_limits() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let path = dir.path().join("msime-zhuyin.db");
1039        build(&path, &FORMAT_VERSION.to_string());
1040        let connection = Connection::open(&path).unwrap();
1041        for (key, text, weight) in [
1042            ("a tail", "row-a", 5),
1043            ("b tale", "row-b", 90),
1044            ("synthetic*?[ tail", "same", 70),
1045            ("ㄋㄧˇ tail", "same", 70),
1046            ("syntheticXX[ tail", "wrong-literal", 1000),
1047            ("a taie", "wrong-reading", 1000),
1048            ("a tail extra", "wrong-span", 1000),
1049        ] {
1050            connection
1051                .execute(
1052                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
1053                    (key, text, weight),
1054                )
1055                .unwrap();
1056        }
1057        drop(connection);
1058        let dictionary = open_read_only(&path).unwrap();
1059        // 短、长、Unicode、GLOB 元字符及重复读音交替,验证复用不会残留上一次的键。
1060        let first = readings(&["a", "ㄋㄧˇ", "a", "synthetic*?[", "b"]);
1061        let rest = readings(&["tail", "tale"]);
1062        let expected = [
1063            ("b tale", "row-b", 90),
1064            ("synthetic*?[ tail", "same", 70),
1065            ("ㄋㄧˇ tail", "same", 70),
1066            ("a tail", "row-a", 5),
1067            ("a tail", "row-a", 5),
1068        ]
1069        .map(|(key, text, weight)| {
1070            (
1071                key.to_owned(),
1072                LanguageEntry {
1073                    text: text.to_owned(),
1074                    weight,
1075                },
1076            )
1077        });
1078        for limit in [0, 1, 3, usize::MAX] {
1079            let actual = dictionary.lookup_readings(&[&first, &rest], limit).unwrap();
1080            assert_eq!(actual, expected[..limit.min(expected.len())]);
1081        }
1082        let actual = dictionary
1083            .lookup_readings(&[&first, &rest], usize::MAX)
1084            .unwrap();
1085        drop(first);
1086        drop(rest);
1087        drop(dictionary);
1088        assert_eq!(actual, expected);
1089    }
1090
1091    #[test]
1092    fn reading_glob_rejects_rows_before_allocating_their_keys() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let path = dir.path().join("msime-zhuyin.db");
1095        build(&path, &FORMAT_VERSION.to_string());
1096        let connection = Connection::open(&path).unwrap();
1097        for (key, text, weight) in [
1098            ("ㄋㄧˇ ㄏㄠˇ", "你好", 1000),
1099            ("ㄋㄧˇ ㄏㄠˇ ㄇㄚ˙", "你好嗎", 900),
1100            ("ㄋㄧˇ ㄏㄠˇ ㄉㄜ˙", "你的", 800),
1101        ] {
1102            connection
1103                .execute(
1104                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
1105                    (key, text, weight),
1106                )
1107                .unwrap();
1108        }
1109        drop(connection);
1110
1111        let dictionary = open_read_only(&path).unwrap();
1112        let first = readings(&["ㄋㄧˇ"]);
1113        let rest = readings(&["ㄏㄠˇ", "ㄏㄠˇ ㄇㄚ˙"]);
1114        let expected = dictionary
1115            .lookup_readings(&[&first, &rest], usize::MAX)
1116            .unwrap();
1117        let (actual, allocations) = crate::ime::personal_rerank::allocations::count(|| {
1118            dictionary
1119                .lookup_readings(&[&first, &rest], usize::MAX)
1120                .unwrap()
1121        });
1122
1123        assert_eq!(actual, expected);
1124        assert!(
1125            allocations <= 11,
1126            "粗筛淘汰行不应分配键字符串:{allocations}"
1127        );
1128    }
1129
1130    #[test]
1131    fn multiple_reading_results_clone_the_query_key_only_after_the_first_row() {
1132        let dir = tempfile::tempdir().unwrap();
1133        let path = dir.path().join("msime-zhuyin.db");
1134        build(&path, &FORMAT_VERSION.to_string());
1135        let connection = Connection::open(&path).unwrap();
1136        for (text, weight) in [("你", 1000), ("妳", 900), ("尼", 800)] {
1137            connection
1138                .execute(
1139                    "INSERT INTO entries VALUES (?1, ?2, ?3)",
1140                    ("ㄋㄧˇ", text, weight),
1141                )
1142                .unwrap();
1143        }
1144        drop(connection);
1145
1146        let dictionary = open_read_only(&path).unwrap();
1147        let reading = readings(&["ㄋㄧˇ"]);
1148        // 预热语句缓存,统计只覆盖候选查询本身。
1149        let expected = dictionary.lookup_readings(&[&reading], 10).unwrap();
1150        let (actual, allocations) = crate::ime::personal_rerank::allocations::count(|| {
1151            dictionary.lookup_readings(&[&reading], 10).unwrap()
1152        });
1153
1154        assert_eq!(actual, expected);
1155        assert_eq!(
1156            allocations, 9,
1157            "多结果查询的第一行应直接取得原键:{allocations}"
1158        );
1159    }
1160
1161    #[test]
1162    fn joins_single_readings_in_order() {
1163        let ni = readings(&["ㄋㄧˇ"]);
1164        let hao = readings(&["ㄏㄠˇ"]);
1165        assert_eq!(join_single_readings(&[&ni, &hao]), "ㄋㄧˇ ㄏㄠˇ");
1166    }
1167
1168    #[test]
1169    fn readings_glob_writes_literals_classes_and_wildcards() {
1170        let ni_li = readings(&["ㄋㄧˇ", "ㄌㄧˇ"]);
1171        let hao = readings(&["ㄏㄠˇ"]);
1172        let uneven = readings(&["ㄏㄠˇ", "ㄏㄨㄚˇ"]);
1173        let meta = readings(&["a*", "b-"]);
1174        assert_eq!(readings_glob(&[&hao]), "ㄏㄠˇ");
1175        assert_eq!(readings_glob(&[&hao, &ni_li]), "ㄏㄠˇ [ㄋㄌ]ㄧˇ");
1176        assert_eq!(readings_glob(&[&uneven, &hao]), "* ㄏㄠˇ");
1177        assert_eq!(readings_glob(&[&meta]), "[ab]?");
1178        assert_eq!(readings_glob(&[&readings(&["a?"])]), "a[?]");
1179        assert!(key_matches("ㄋㄧˇ ㄏㄠˇ", &[&ni_li, &hao]));
1180        assert!(!key_matches("ㄋㄧˇ", &[&ni_li, &hao]));
1181        assert!(!key_matches("ㄋㄧˇ ㄏㄠˇ ㄏㄠˇ", &[&ni_li, &hao]));
1182    }
1183
1184    #[cfg(unix)]
1185    #[test]
1186    fn opens_through_a_symlinked_directory() {
1187        let dir = tempfile::tempdir().unwrap();
1188        let real = dir.path().join("real");
1189        std::fs::create_dir(&real).unwrap();
1190        build(&real.join("msime-zhuyin.db"), &FORMAT_VERSION.to_string());
1191        let link = dir.path().join("link");
1192        std::os::unix::fs::symlink(&real, &link).unwrap();
1193
1194        let dictionary = open_read_only(&link.join("msime-zhuyin.db")).unwrap();
1195        assert!(dictionary.has_syllable("nei").unwrap());
1196    }
1197
1198    #[test]
1199    fn refuses_an_unknown_format_version() {
1200        let dir = tempfile::tempdir().unwrap();
1201        let path = dir.path().join("msime-zhuyin.db");
1202        build(&path, "2");
1203        assert_eq!(
1204            message(open_read_only(&path).err().unwrap()),
1205            diagnostics::LANGUAGE_DICTIONARY_VERSION_UNSUPPORTED
1206        );
1207    }
1208
1209    #[test]
1210    fn refuses_missing_empty_and_foreign_files_without_creating_them() {
1211        let dir = tempfile::tempdir().unwrap();
1212        let missing = dir.path().join("msime-cantonese.db");
1213        assert_eq!(
1214            message(open_read_only(&missing).err().unwrap()),
1215            diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE
1216        );
1217        assert!(!missing.exists());
1218        assert_eq!(
1219            message(open_read_only(Path::new("")).err().unwrap()),
1220            diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE
1221        );
1222        let foreign = dir.path().join("foreign.db");
1223        std::fs::write(&foreign, b"not a database").unwrap();
1224        assert_eq!(
1225            message(open_read_only(&foreign).err().unwrap()),
1226            diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE
1227        );
1228        let no_metadata = dir.path().join("no_metadata.db");
1229        Connection::open(&no_metadata)
1230            .unwrap()
1231            .execute_batch("CREATE TABLE other(x)")
1232            .unwrap();
1233        assert_eq!(
1234            message(open_read_only(&no_metadata).err().unwrap()),
1235            diagnostics::LANGUAGE_DICTIONARY_UNAVAILABLE
1236        );
1237    }
1238}