const STOP_WORDS: &[&str] = &[
"a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because",
"been", "before", "but", "by", "can", "could", "did", "do", "does", "for", "from", "had",
"has", "have", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "my", "no",
"not", "of", "on", "or", "our", "out", "should", "so", "some", "than", "that", "the", "their",
"them", "then", "there", "these", "they", "this", "to", "up", "was", "we", "were", "what",
"when", "where", "which", "who", "why", "will", "with", "would", "you", "your", "的", "了",
"是", "在", "和", "就", "也", "都", "而", "及", "与", "或", "但", "被", "把", "让", "给", "从",
"对", "向", "于", "为", "以", "之", "其", "这", "那", "个", "我", "你", "他", "她", "它", "吗",
"呢", "吧", "啊", "呀", "嘛", "哪", "谁", "我们", "你们", "他们", "这个", "那个", "这些",
"那些", "这样", "那样", "什么", "怎么", "如何", "因为", "所以", "如果", "但是", "然后", "可以",
"还是", "已经", "没有", "就是", "一个", "一些",
];
pub fn is_cjk_script(ch: char) -> bool {
matches!(ch as u32,
0x2E80..=0x2FFF | 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0x20000..=0x3FFFF )
}
pub fn split_cjk_runs(text: &str) -> Vec<(&str, bool)> {
let mut runs: Vec<(&str, bool)> = Vec::new();
let mut start = 0usize;
let mut current: Option<bool> = None;
for (index, ch) in text.char_indices() {
let cjk = is_cjk_script(ch);
match current {
Some(prev) if prev == cjk => {}
Some(prev) => {
runs.push((&text[start..index], prev));
start = index;
current = Some(cjk);
}
None => current = Some(cjk),
}
}
if let Some(cjk) = current {
runs.push((&text[start..], cjk));
}
runs
}
pub fn cjk_bigrams(run: &str) -> Vec<String> {
let chars: Vec<char> = run.chars().collect();
match chars.len() {
0 => Vec::new(),
1 => vec![run.to_string()],
_ => chars
.windows(2)
.map(|pair| pair.iter().collect::<String>())
.collect(),
}
}
pub fn terms(query: &str, max_terms: usize) -> Vec<String> {
let mut terms: Vec<String> = Vec::new();
for raw in query.split(|c: char| !c.is_alphanumeric()) {
for (run, cjk) in split_cjk_runs(raw) {
let candidates = if cjk {
cjk_bigrams(run)
} else {
vec![run.to_lowercase()]
};
for term in candidates {
push_term(term, &mut terms);
if terms.len() >= max_terms {
return terms;
}
}
}
}
terms
}
fn push_term(term: String, terms: &mut Vec<String>) {
if term.is_empty() || is_stop_word(&term) || is_stop_pair(&term) {
return;
}
if term.is_ascii() && term.chars().count() < 2 {
return;
}
if terms.iter().any(|seen| seen == &term) {
return;
}
terms.push(term);
}
fn is_stop_word(term: &str) -> bool {
STOP_WORDS.contains(&term)
}
fn is_stop_char(ch: char) -> bool {
let mut buf = [0u8; 4];
is_stop_word(ch.encode_utf8(&mut buf))
}
fn is_stop_pair(term: &str) -> bool {
let mut chars = term.chars();
match (chars.next(), chars.next(), chars.next()) {
(Some(first), Some(second), None) => {
is_cjk_script(first)
&& is_cjk_script(second)
&& is_stop_char(first)
&& is_stop_char(second)
}
_ => false,
}
}
pub fn first_match_index(text: &str, needles: &[String]) -> Option<usize> {
if needles.is_empty() {
return None;
}
let folded = Folded::new(text);
let byte = needles
.iter()
.filter_map(|needle| folded.lower.find(needle.as_str()))
.min()?;
let lower_char = folded.lower[..byte].chars().count();
let original_byte = folded.origin[lower_char].0;
Some(text[..original_byte].chars().count())
}
pub fn case_insensitive_matches(text: &str, needle: &str) -> Vec<(usize, usize)> {
let folded = Folded::new(text);
let mut matches = Vec::new();
if needle.is_empty() {
return matches;
}
let mut search_from = 0usize;
let mut chars_before = 0usize;
while let Some(found) = folded.lower[search_from..].find(needle) {
let start = search_from + found;
let end = start + needle.len();
let start_char = chars_before + folded.lower[search_from..start].chars().count();
let end_char = start_char + needle.chars().count();
matches.push((folded.origin[start_char].0, folded.origin[end_char - 1].1));
chars_before = end_char;
search_from = end;
}
matches
}
struct Folded {
lower: String,
origin: Vec<(usize, usize)>,
}
impl Folded {
fn new(text: &str) -> Self {
let mut lower = String::with_capacity(text.len());
let mut origin: Vec<(usize, usize)> = Vec::with_capacity(text.len() + 1);
for (byte, ch) in text.char_indices() {
for lowered in ch.to_lowercase() {
origin.push((byte, byte + ch.len_utf8()));
lower.push(lowered);
}
}
Folded { lower, origin }
}
}
pub fn excerpt_containing(text: &str, needles: &[String], max_chars: usize) -> String {
let total = text.chars().count();
if total == 0 {
return String::new();
}
let start_char = match first_match_index(text, needles) {
Some(found) => found.saturating_sub(max_chars / 3),
None => 0,
};
let end_char = (start_char + max_chars).min(total);
let mut out: String = text
.chars()
.skip(start_char)
.take(end_char - start_char)
.collect();
if start_char > 0 {
out.insert(0, '…');
}
if end_char < total {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const MAX_TERMS: usize = 16;
#[test]
fn terms_drop_stop_words_single_chars_and_duplicates() {
assert_eq!(
terms("How do I fix the Cache cache?", MAX_TERMS),
vec!["fix".to_string(), "cache".to_string()]
);
assert_eq!(terms("C++ 改", MAX_TERMS), vec!["改".to_string()]);
assert!(terms("the and of", MAX_TERMS).is_empty());
}
#[test]
fn terms_split_cjk_into_bigrams() {
assert_eq!(
terms("缓存失效", MAX_TERMS),
vec!["缓存".to_string(), "存失".to_string(), "失效".to_string()]
);
assert_eq!(terms("改", MAX_TERMS), vec!["改".to_string()]);
assert_eq!(
terms("缓存BBC", MAX_TERMS),
vec!["缓存".to_string(), "bbc".to_string()]
);
}
#[test]
fn chinese_terms_keep_distinctive_words() {
let tokens = terms("如何修复缓存失效的问题", MAX_TERMS);
assert!(tokens.contains(&"缓存".to_string()), "{tokens:?}");
assert!(tokens.contains(&"失效".to_string()), "{tokens:?}");
assert!(tokens.contains(&"修复".to_string()), "{tokens:?}");
assert!(!tokens.contains(&"如何".to_string()), "中文虚词应被过滤");
assert!(terms("的了吗呢", MAX_TERMS).is_empty());
}
#[test]
fn terms_are_capped() {
let query = (0..40)
.map(|i| format!("term{i}"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(terms(&query, MAX_TERMS).len(), MAX_TERMS);
}
#[test]
fn cjk_runs_split_mixed_scripts() {
assert_eq!(
split_cjk_runs("缓存bbc失效"),
vec![("缓存", true), ("bbc", false), ("失效", true)]
);
assert!(split_cjk_runs("").is_empty());
assert_eq!(split_cjk_runs("plain"), vec![("plain", false)]);
}
#[test]
fn excerpt_windows_around_the_match() {
let needles = terms("needle", MAX_TERMS);
let text = format!("{} needle {}", "x".repeat(500), "y".repeat(500));
let out = excerpt_containing(&text, &needles, 60);
assert!(out.contains("needle"), "{out}");
assert!(out.starts_with('…') && out.ends_with('…'), "{out}");
assert!(out.chars().count() <= 62, "{}", out.chars().count());
}
#[test]
fn excerpt_without_needles_takes_the_head() {
let out = excerpt_containing("short text", &[], 40);
assert_eq!(out, "short text");
let long = "z".repeat(100);
let out = excerpt_containing(&long, &[], 10);
assert_eq!(out.chars().count(), 11, "截断后补一个省略号:{out}");
assert!(out.ends_with('…'));
}
#[test]
fn excerpt_locates_matches_even_if_lowercasing_changes_length() {
let text = format!("{}缓存失效{}", "İ".repeat(30), "x".repeat(30));
let out = excerpt_containing(&text, &terms("缓存失效", MAX_TERMS), 20);
assert!(out.contains("缓存失效"), "{out}");
}
#[test]
fn first_match_index_picks_the_earliest_needle() {
let needles = vec!["later".to_string(), "early".to_string()];
assert_eq!(first_match_index("early then later", &needles), Some(0));
assert_eq!(first_match_index("nothing here", &needles), None);
assert_eq!(first_match_index("anything", &[]), None);
}
#[test]
fn case_insensitive_matches_report_original_byte_ranges() {
let text = "İİİNEEDLE tail";
let ranges = case_insensitive_matches(text, "needle");
assert_eq!(ranges.len(), 1);
assert_eq!(&text[ranges[0].0..ranges[0].1], "NEEDLE", "{ranges:?}");
let text = "Cache cache CACHE";
let ranges = case_insensitive_matches(text, "cache");
assert_eq!(ranges.len(), 3, "{ranges:?}");
for (start, end) in &ranges {
assert_eq!(text[*start..*end].to_lowercase(), "cache");
}
assert!(ranges.windows(2).all(|w| w[0].0 < w[1].0));
assert!(case_insensitive_matches("anything", "").is_empty());
assert!(case_insensitive_matches("anything", "absent").is_empty());
}
}