msime-engine 0.2.0

Input method engine for Chinese and other languages: quanpin, shuangpin, nine-key, wubi, zhuyin, Cantonese, Japanese, Korean and more, ranked over a word lattice with typo correction and learning from what the user picks. The engine of 水杉输入法 (Metasequoia IME).
Documentation
//! 符号模式(`S`):按分组码(`xl` 希腊字母、`sx` 数学、`szq` 带圈数字、`jt` 箭头)或符号名(`alpha`、`infinity`)查符号。
//!
//! 两条查法共用一张编进引擎的表,都不读磁盘:
//!
//! - 分组码表是 `symbol_groups.tsv`(`编码<TAB>中文组名<TAB>符号...`):分组码沿用此前那张表的界面,中文组名和每组的取字规则写在 `scripts/build_symbol_groups.py` 里,组员只按 Unicode 字符数据库 17.0.0 的区块、类别、字符名和分解映射挑选(Unicode License V3,全文在 `resources/licenses/Unicode-License-V3.txt`),组内按码位排序。
//! - LaTeX 命令名读 `latex_symbols.tsv`(本项目自写,MIT;是 msime-dictionary `sources/symbols/latex-symbols.tsv` 的同步副本,与官方短语表「LaTeX 符号」的数据源 `plugins/phrase_table/latex-symbols/symbols.tsv` 逐字节相同,见 `scripts/sync_symbol_tables.py`;放在 crate 里是为了 `cargo package` 能带上它),编进引擎,六个平台不用启用插件也能用:`sum` 得 ∑、`leq` 得 ≤、`pi` 得 π Π、`mathbbr` 得 ℝ。命令名去掉反斜杠和花括号、转成小写,只差大小写的合成一行、小写的在前(`rightarrow` 得 → ⇒),与那个短语表的编码规则相同;`\S`、`\P` 这种只剩一个字母的不收,单个字母是分组码前缀。只在这里查,不进普通打字的混排,`pi`、`in`、`le` 这些短命令名在混排里会和拼音、英文撞上。
//! - 符号名复用混排那半边(`symbol_candidates`)的 `symbol_candidates.tsv`:`alpha` 得 α、`infinity` 得 ∞。
//!
//! 打出的整串是混排表里的拉丁名称(`phi`、`epsilon`)时,正文用的希腊字母(φ、ε)排在最前;接着是整串 LaTeX 命令名的符号(`\phi` 排出来是 ϕ),打出完整命令名就是要那个符号,所以它排在分组之前(`nu` 的 ν 排在以 `nu` 开头的 `num` 组之前)。分组码先认整串(`xl` 只出希腊字母小写),整串没有时按前缀给整组(`x` 出所有以 `x` 开头的组),组内按表的顺序;同一个符号只出一次。符号名在分组码之后给,同样只认整串。都不中时由会话补上原样上屏的兜底行。
//!
//! 行的来源是 `CandidateSource::Emoji`(与 emoji、颜文字、混排符号行同一个值):选中时整段上屏、结束组字,不学习、不调频、不进个人上下文,也不能置顶、固定或删除。

use crate::types::{CandidateSource, WordItem};

/// 一次最多给出几行符号,分组码与符号名合计。与 Windows 正式版的 `query_symbols` 默认上限 200 一致:一个分组最多几百个符号(`kx` 康熙部首 214 个、`sx` 数学符号 197 个),整组放进列表靠翻页看全;生成器让除康熙部首以外的组都不超过这个数。
pub const SYMBOL_MODE_LIMIT: usize = 200;

const TABLE: &str = include_str!("symbol_groups.tsv");
const LATEX_TABLE: &str = include_str!("latex_symbols.tsv");

/// 表里的一行。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SymbolGroup {
    pub code: &'static str,
    pub name: &'static str,
    /// 空格分隔的符号。
    pub symbols: &'static str,
}

impl SymbolGroup {
    /// 这个组的符号,按表的顺序。
    pub fn symbols(&self) -> impl Iterator<Item = &'static str> {
        self.symbols.split(' ')
    }
}

/// 解析整张表,跳过空行和 `#` 注释;列数不对的行也跳过(表检查会把它们报出来)。
fn parse_groups(text: &'static str) -> Vec<SymbolGroup> {
    text.lines()
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .filter_map(|line| {
            let mut fields = line.split('\t');
            let (Some(code), Some(name), Some(symbols), None) =
                (fields.next(), fields.next(), fields.next(), fields.next())
            else {
                return None;
            };
            Some(SymbolGroup {
                code,
                name,
                symbols,
            })
        })
        .collect()
}

/// 表里的全部组,按文件的先后。
pub fn groups() -> &'static [SymbolGroup] {
    static GROUPS: std::sync::OnceLock<Vec<SymbolGroup>> = std::sync::OnceLock::new();
    GROUPS.get_or_init(|| parse_groups(TABLE))
}

/// LaTeX 命令名表(`\命令名<TAB>U+码位`)按符号模式的编码合并:去掉 `\` 和花括号、转成小写,同一个编码的符号按表的先后,大写开头的那条在表里排在小写的后面。码位解析不了的行跳过,表本身由 `gen_latex_symbols.py --check` 核对。
fn parse_latex(text: &str) -> Vec<(String, Vec<String>)> {
    let mut entries: Vec<(String, Vec<String>)> = Vec::new();
    for line in text.lines() {
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some((name, point)) = line.split_once('\t') else {
            continue;
        };
        let Some(symbol) = point
            .strip_prefix("U+")
            .and_then(|hex| u32::from_str_radix(hex, 16).ok())
            .and_then(char::from_u32)
        else {
            continue;
        };
        let code: String = name
            .chars()
            .filter(|character| character.is_ascii_alphabetic())
            .map(|character| character.to_ascii_lowercase())
            .collect();
        if code.len() < 2 {
            continue;
        }
        match entries.iter_mut().find(|(known, _)| *known == code) {
            Some((_, symbols)) => symbols.push(symbol.to_string()),
            None => entries.push((code, vec![symbol.to_string()])),
        }
    }
    entries
}

/// LaTeX 命令名(符号模式的编码)→ 符号。
fn latex_names() -> &'static std::collections::HashMap<String, Vec<String>> {
    static NAMES: std::sync::OnceLock<std::collections::HashMap<String, Vec<String>>> =
        std::sync::OnceLock::new();
    NAMES.get_or_init(|| parse_latex(LATEX_TABLE).into_iter().collect())
}

/// 编码只认 ASCII 字母,统一转小写;含别的字符时返回空串(查不到任何符号)。
fn normalize(code: &str) -> String {
    if code.is_empty() || !code.bytes().all(|byte| byte.is_ascii_alphabetic()) {
        return String::new();
    }
    code.to_ascii_lowercase()
}

/// 符号模式的行:先按整串拉丁名称和 LaTeX 命令名出,再按整串分组码出、未命中时按分组码前缀出,最后按其余符号名(中文名称的全拼、首字母)整串出。`code` 是去掉入口字母之后的输入。
pub fn query_symbol_mode(code: &str, limit: usize) -> Vec<WordItem> {
    let code = normalize(code);
    if code.is_empty() || limit == 0 {
        return Vec::new();
    }
    let mut rows: Vec<WordItem> = Vec::new();
    let mut push = |symbol: &str| {
        if rows.len() == limit || rows.iter().any(|row| row.word == symbol) {
            return;
        }
        rows.push(WordItem::new(
            code.clone(),
            symbol,
            0,
            CandidateSource::Emoji,
            "",
        ));
    };
    for symbol in crate::local::symbol_candidates::latin_name_symbols(&code) {
        push(symbol);
    }
    for symbol in latex_names().get(code.as_str()).into_iter().flatten() {
        push(symbol);
    }
    let groups = groups();
    // 有整串分组码时只返回该组,输入尚未完整时才按前缀列出相关组。
    let exact = groups.iter().any(|group| group.code == code);
    for group in groups {
        if (exact && group.code == code) || (!exact && group.code.starts_with(&code)) {
            for symbol in group.symbols() {
                push(symbol);
            }
        }
    }
    // 符号名:`alpha`、`infinity`、`gamma` 这些名称本身就是字母,与分组码共用同一段输入。
    for row in crate::local::symbol_candidates::query_mixed_symbols(
        &code,
        crate::types::SchemeType::Quanpin,
        crate::shuangpin::profile::default_profile(),
    ) {
        push(&row.item.word);
    }
    rows
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;

    fn texts(rows: &[WordItem]) -> Vec<&str> {
        rows.iter().map(|row| row.word.as_str()).collect()
    }

    #[test]
    fn the_table_follows_its_rules() {
        let groups = groups();
        assert_eq!(groups.len(), 87, "分组码个数");
        let mut codes = HashSet::new();
        let mut symbols = 0;
        for group in groups {
            assert!(
                group.code.bytes().all(|byte| byte.is_ascii_lowercase()),
                "编码只含小写字母:{}",
                group.code
            );
            assert!(codes.insert(group.code), "编码不重复:{}", group.code);
            assert!(
                !group.name.is_empty() && group.name.chars().all(crate::text::is_han),
                "组名只含汉字:{} {}",
                group.code,
                group.name
            );
            let count = group.symbols().count();
            assert!(count > 0, "组里至少一个符号:{}", group.code);
            // 整串分组码一次最多出 SYMBOL_MODE_LIMIT 行,超出的符号打不出来;康熙部首是 214 个部首的完整集合,是唯一的例外。
            assert!(
                count <= SYMBOL_MODE_LIMIT || group.code == "kx",
                "组里的符号不超过一次能给出的行数:{} {count}",
                group.code
            );
            for symbol in group.symbols() {
                symbols += 1;
                assert!(
                    !symbol.is_empty()
                        && !symbol
                            .chars()
                            .any(|character| character.is_whitespace() || character.is_control())
                        && symbol != "\u{FE0F}",
                    "符号非空、不含空白和控制字符:{} {symbol:?}",
                    group.code
                );
            }
        }
        assert_eq!(symbols, 4585, "符号总数");
    }

    #[test]
    fn group_codes_give_their_whole_group() {
        let xl = query_symbol_mode("xl", SYMBOL_MODE_LIMIT);
        let expected: Vec<_> = groups()
            .iter()
            .find(|group| group.code == "xl")
            .unwrap()
            .symbols()
            .collect();
        assert_eq!(texts(&xl), expected);
        let sx = query_symbol_mode("SX", SYMBOL_MODE_LIMIT);
        assert!(sx.iter().any(|row| row.word == "∑"), "{:?}", texts(&sx));
        let jt = query_symbol_mode("jt", SYMBOL_MODE_LIMIT);
        assert!(jt.iter().any(|row| row.word == "→"), "{:?}", texts(&jt));
        let szq = query_symbol_mode("szq", SYMBOL_MODE_LIMIT);
        assert_eq!(texts(&szq)[0], "①", "{:?}", texts(&szq));
        // 部首补充区成段等价的几个部首(艹 的三种写法、辶 的三种写法)出的是统一汉字,不是部首码位。
        let pp = query_symbol_mode("pp", SYMBOL_MODE_LIMIT);
        assert!(
            ["艹", "辶"]
                .iter()
                .all(|radical| texts(&pp).contains(radical)),
            "{:?}",
            texts(&pp)
        );
        assert!(!texts(&pp).contains(&"\u{2EBE}"), "{:?}", texts(&pp));
        let fj = query_symbol_mode("fj", SYMBOL_MODE_LIMIT);
        assert_eq!(texts(&fj)[..2], ["——", "……"], "{:?}", texts(&fj));
        assert!(query_symbol_mode("", SYMBOL_MODE_LIMIT).is_empty());
        assert!(query_symbol_mode("x'1", SYMBOL_MODE_LIMIT).is_empty());
    }

    #[test]
    fn incomplete_group_code_lists_multiple_groups() {
        let rows = query_symbol_mode("x", SYMBOL_MODE_LIMIT);
        let values = texts(&rows);
        assert!(values.contains(&"α"));
        assert!(values.contains(&"Α"));
        assert_eq!(values.iter().filter(|&&value| value == "α").count(), 1);
    }

    #[test]
    fn symbol_names_give_their_symbol() {
        let alpha = query_symbol_mode("alpha", SYMBOL_MODE_LIMIT);
        assert_eq!(texts(&alpha)[0], "α", "{:?}", texts(&alpha));
        let infinity = query_symbol_mode("infinity", SYMBOL_MODE_LIMIT);
        assert_eq!(texts(&infinity), vec!["∞"], "{:?}", texts(&infinity));
        // 符号名在分组码之后,前缀命中的组不会把 `alpha` 的 α 挤掉。
        let exact = query_symbol_mode("xl", SYMBOL_MODE_LIMIT);
        assert_eq!(exact.iter().filter(|row| row.word == "α").count(), 1);
    }

    #[test]
    fn the_latex_table_merges_case_variants_under_one_code() {
        let entries = parse_latex(LATEX_TABLE);
        // 395 行的表去掉注释、合并大小写和 \S、\P 之后的编码数。
        assert!(entries.len() > 250, "{}", entries.len());
        let codes: std::collections::HashSet<&str> =
            entries.iter().map(|(code, _)| code.as_str()).collect();
        assert_eq!(codes.len(), entries.len(), "编码不重复");
        for (code, symbols) in &entries {
            assert!(
                code.len() >= 2 && code.bytes().all(|byte| byte.is_ascii_lowercase()),
                "编码全小写、至少两个字母:{code}"
            );
            assert!(
                (1..=2).contains(&symbols.len()),
                "同一编码最多大小写两行:{code}"
            );
        }
        let symbols = |code: &str| latex_names()[code].clone();
        assert_eq!(symbols("gamma"), ["γ", "Γ"]);
        assert_eq!(symbols("rightarrow"), ["→", "⇒"]);
        assert_eq!(symbols("mathbbr"), ["ℝ"]);
        assert!(!latex_names().contains_key("s"));
    }

    #[test]
    fn latex_names_give_their_symbol_first() {
        for (code, expected) in [
            ("sum", "∑"),
            ("int", "∫"),
            ("infty", "∞"),
            ("leq", "≤"),
            ("neq", "≠"),
            ("partial", "∂"),
            ("nabla", "∇"),
            ("forall", "∀"),
            ("subseteq", "⊆"),
            ("in", "∈"),
            ("pi", "π"),
            ("mu", "μ"),
            ("eta", "η"),
            ("varphi", "φ"),
            ("mathbbr", "ℝ"),
            ("Rightarrow", "→"),
        ] {
            let rows = query_symbol_mode(code, SYMBOL_MODE_LIMIT);
            assert_eq!(
                texts(&rows).first(),
                Some(&expected),
                "{code}: {:?}",
                texts(&rows)
            );
        }
        // 大写开头的命令名转成小写后与小写的同一行,两种箭头都在。
        assert_eq!(
            texts(&query_symbol_mode("Rightarrow", SYMBOL_MODE_LIMIT)),
            vec!["→", "⇒"]
        );
        // 命令名排在以它开头的分组之前:`nu` 先给 ν,再给 `num` 组的 №。
        assert_eq!(
            texts(&query_symbol_mode("nu", SYMBOL_MODE_LIMIT))[..2],
            ["ν", "№"]
        );
        // 正文用的希腊字母排在 LaTeX 的字形变体之前:`phi` 先给 φ,再给 `\phi` 的 ϕ。
        assert_eq!(
            texts(&query_symbol_mode("phi", SYMBOL_MODE_LIMIT))[..3],
            ["φ", "Φ", "ϕ"]
        );
        // 只认整串:没打完的命令名不给。
        assert!(!texts(&query_symbol_mode("subse", SYMBOL_MODE_LIMIT)).contains(&"⊆"));
    }

    #[test]
    fn every_row_commits_whole_and_is_not_edited() {
        for row in query_symbol_mode("xl", SYMBOL_MODE_LIMIT) {
            assert_eq!(row.source, CandidateSource::Emoji);
            assert!(row.canonical_pinyin.is_empty());
        }
    }
}