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
//! 不拿来拼整句的粗俗词。词表、豁免写法、收词标准和为什么只管拼句见 `offensive_words.txt` 的文件头。
//!
//! 词网格给多字词每个音节 `PHRASE_LENGTH_BONUS` 的奖励,一个只有几千权重的两字词也能压过同码的两个常用单字,所以一个罕见的粗俗词会被拼进用户本来想打的句子当首选(九键 我们走路去 解成「我们有巨乳」)。含粗俗词的长词条也一样(个鸡巴、吃屎了、是傻逼),所以按子串查:词里任何一段在表里就算命中,除非这一段被一条豁免写法整个盖住(水乳交融 里的 乳交、口交换机 里的 口交)。整句是猜出来的,用户没打这个词;命中的词因此不进词网格(整段输入恰好是这个词时除外,那时它就是这串输入对应的词库行),也不进纠错整句(纠错边的词来自另一串读音,盖住整段也不用)和上屏后的联想。词库行不受影响。

use std::cmp::Ordering;

const WORDS: &str = include_str!("offensive_words.txt");

/// 豁免写法在词表文件里以这个字符开头。
const EXEMPT_MARK: u8 = b'!';

const LISTED_COUNT: usize = count_entries(false);
const EXEMPT_COUNT: usize = count_entries(true);

/// 表里的词,按字节序排好,查表用二分查找。和豁免写法一样在编译期从 `offensive_words.txt` 解析出来:运行时不建表、不分配内存,第一次按键不付初始化的开销,数分配次数的测试也不受先后顺序影响。
static LISTED: [&str; LISTED_COUNT] = parse_entries(false);
/// 豁免写法,去掉了开头的 `!`。
static EXEMPT: [&str; EXEMPT_COUNT] = parse_entries(true);
/// 表里最长的词有几个字,按子串查时每个起点最多看这么长。
const LONGEST: usize = longest(&parse_entries::<LISTED_COUNT>(false));

/// 从 `rest` 开头取下一个条目:跳过空行和 `#` 开头的行,去掉首尾空白。返回这个条目和它之后剩下的部分。
const fn next_entry(mut rest: &'static [u8]) -> Option<(&'static [u8], &'static [u8])> {
    while !rest.is_empty() {
        let mut end = 0;
        while end < rest.len() && rest[end] != b'\n' {
            end += 1;
        }
        let (line, tail) = rest.split_at(end);
        rest = if tail.is_empty() {
            tail
        } else {
            tail.split_at(1).1
        };
        let line = line.trim_ascii();
        if !line.is_empty() && line[0] != b'#' {
            return Some((line, rest));
        }
    }
    None
}

/// 词表文件里有几个词(`exempt` 为假)或几条豁免写法(`exempt` 为真)。
const fn count_entries(exempt: bool) -> usize {
    let mut count = 0;
    let mut rest = WORDS.as_bytes();
    while let Some((entry, tail)) = next_entry(rest) {
        rest = tail;
        if (entry[0] == EXEMPT_MARK) == exempt {
            count += 1;
        }
    }
    count
}

/// 按字节比较两个串,与运行时 `str` 的 `Ord` 一致,二分查找因此能用编译期排出的顺序。
const fn compare(a: &[u8], b: &[u8]) -> Ordering {
    let mut i = 0;
    while i < a.len() && i < b.len() {
        if a[i] != b[i] {
            return if a[i] < b[i] {
                Ordering::Less
            } else {
                Ordering::Greater
            };
        }
        i += 1;
    }
    if a.len() < b.len() {
        Ordering::Less
    } else if a.len() > b.len() {
        Ordering::Greater
    } else {
        Ordering::Equal
    }
}

/// 取出全部词或全部豁免写法,按字节序插入排序。重复的条目和不是 UTF-8 的条目让编译失败。
const fn parse_entries<const N: usize>(exempt: bool) -> [&'static str; N] {
    let mut out = [""; N];
    let mut len = 0;
    let mut rest = WORDS.as_bytes();
    while let Some((entry, tail)) = next_entry(rest) {
        rest = tail;
        if (entry[0] == EXEMPT_MARK) != exempt {
            continue;
        }
        let text = if exempt { entry.split_at(1).1 } else { entry };
        let Ok(text) = std::str::from_utf8(text) else {
            panic!("offensive_words.txt 里有不是 UTF-8 的条目");
        };
        let mut at = len;
        while at > 0 {
            match compare(out[at - 1].as_bytes(), text.as_bytes()) {
                Ordering::Less => break,
                Ordering::Equal => panic!("offensive_words.txt 里有重复的条目"),
                Ordering::Greater => {
                    out[at] = out[at - 1];
                    at -= 1;
                }
            }
        }
        out[at] = text;
        len += 1;
    }
    out
}

/// 最长的条目有几个字(数 UTF-8 里不是续字节的字节)。
const fn longest(entries: &[&str]) -> usize {
    let mut longest = 0;
    let mut i = 0;
    while i < entries.len() {
        let bytes = entries[i].as_bytes();
        let mut chars = 0;
        let mut j = 0;
        while j < bytes.len() {
            if bytes[j] & 0xC0 != 0x80 {
                chars += 1;
            }
            j += 1;
        }
        if chars > longest {
            longest = chars;
        }
        i += 1;
    }
    longest
}

/// `word` 是否含粗俗词:它本身或其中任何一段在表里,而且这一段没有被豁免写法盖住。表里只有两字以上的词,单字(UTF-8 不超过 4 字节)不查表,词网格里大多数边是单字。
pub(crate) fn is_offensive(word: &str) -> bool {
    if word.len() <= 4 {
        return false;
    }
    for (start, _) in word.char_indices() {
        let rest = &word[start..];
        for (count, (offset, ch)) in rest.char_indices().enumerate() {
            if count >= LONGEST {
                break;
            }
            let end = offset + ch.len_utf8();
            if count >= 1
                && LISTED.binary_search(&&rest[..end]).is_ok()
                && !exempted(word, start, start + end)
            {
                return true;
            }
        }
    }
    false
}

/// `word[start..end]` 这一段命中了词表时,有没有一条豁免写法在 `word` 里出现并整个盖住这一段。
fn exempted(word: &str, start: usize, end: usize) -> bool {
    EXEMPT.iter().any(|pattern| {
        word.match_indices(pattern)
            .any(|(at, _)| at <= start && end <= at + pattern.len())
    })
}

/// 词网格里 `start..end` 这条词库行的边可不可以用 `word`:含粗俗词的词只在独占整段输入(`0..len`)时成边,这时整句就是这个词本身,与它的词库行相同。纠错边不用它,见 `decode_typo_on_graph_with_policy`。
pub(crate) fn admits_edge(word: &str, start: usize, end: usize, len: usize) -> bool {
    (start == 0 && end == len) || !is_offensive(word)
}

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

    use super::*;

    /// 用标准库逐行解析词表文件,与编译期的 `parse_entries` 互相核对。
    fn entries() -> impl Iterator<Item = &'static str> {
        WORDS
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty() && !line.starts_with('#'))
    }

    #[test]
    fn list_holds_only_multi_character_han_words_once_each() {
        let mut seen = HashSet::new();
        for entry in entries() {
            let text = entry.strip_prefix(char::from(EXEMPT_MARK)).unwrap_or(entry);
            assert!(text.chars().count() >= 2, "{entry} 不到两个字");
            assert!(crate::text::is_all_han(text), "{entry} 不全是汉字");
            assert!(seen.insert(entry), "{entry} 重复");
        }
        assert_eq!(seen.len(), LISTED.len() + EXEMPT.len());
    }

    #[test]
    fn compile_time_tables_match_the_file_and_are_sorted() {
        let mut listed: Vec<&str> = entries()
            .filter(|entry| !entry.starts_with(char::from(EXEMPT_MARK)))
            .collect();
        listed.sort_unstable();
        assert_eq!(LISTED.as_slice(), listed.as_slice());
        let mut exempt: Vec<&str> = entries()
            .filter_map(|entry| entry.strip_prefix(char::from(EXEMPT_MARK)))
            .collect();
        exempt.sort_unstable();
        assert_eq!(EXEMPT.as_slice(), exempt.as_slice());
        let longest = listed.iter().map(|word| word.chars().count()).max();
        assert_eq!(Some(LONGEST), longest);
    }

    #[test]
    fn every_exempt_pattern_covers_a_listed_word_and_is_not_one() {
        for pattern in &EXEMPT {
            assert!(
                LISTED.binary_search(pattern).is_err(),
                "{pattern} 既在表里又是豁免写法"
            );
            assert!(
                LISTED.iter().any(|word| pattern.contains(word)),
                "{pattern} 不含表里的词,豁免它没有意义"
            );
        }
    }

    #[test]
    fn offensive_words_are_recognised_and_ordinary_words_are_not() {
        assert!(is_offensive("巨乳"));
        assert!(is_offensive("他妈的"));
        for word in [
            "走路", "我们", "乳", "巨", "邮局", "坚韧", "阴道", "强奸", "",
        ] {
            assert!(!is_offensive(word), "{word}");
        }
    }

    #[test]
    fn a_longer_word_containing_an_offensive_word_is_offensive() {
        for word in [
            "个鸡巴",
            "净鸡巴",
            "吃屎了",
            "去吃屎",
            "是傻逼",
            "都是傻逼",
            "我们有巨乳",
            "去他妈的",
            "约炮吗",
            "童颜巨乳",
            "巨乳模特",
            "性奴调教",
        ] {
            assert!(is_offensive(word), "{word}");
        }
    }

    #[test]
    fn an_exempt_pattern_releases_only_the_part_it_covers() {
        for word in [
            "水乳交融",
            "口交换机",
            "多口交换机",
            "港口交货",
            "人口爆炸",
            "众口交赞",
            "内射函数",
            "马奶子酒",
            "奶子酒",
            "酸奶子",
            "奸夫淫妇",
            "狗吃屎",
            "摔了个狗吃屎",
            "狗改不了吃屎",
            "巨乳症",
            "巨乳缩小术",
            "性奴隶",
            "荡妇羞辱",
        ] {
            assert!(!is_offensive(word), "{word}");
        }
        // 豁免写法只放行它盖住的那一段,同一个词里别处的命中照样算。
        assert!(is_offensive("水乳交融傻逼"));
        assert!(is_offensive("口交换机口交"));
    }

    #[test]
    fn an_offensive_word_is_an_edge_only_when_it_spans_the_whole_input() {
        assert!(admits_edge("巨乳", 0, 2, 2));
        assert!(!admits_edge("巨乳", 3, 5, 5));
        assert!(!admits_edge("巨乳", 0, 2, 3));
        assert!(!admits_edge("吃屎了", 2, 5, 5));
        assert!(admits_edge("水乳交融", 2, 6, 6));
        assert!(admits_edge("走路", 2, 4, 5));
    }
}