1use regex::Regex;
2use std::collections::HashSet;
3use std::sync::LazyLock;
4use sha2::{Digest, Sha256};
5
6pub fn normalize_text(text: &str) -> String {
7 text.trim().chars().flat_map(|ch| {
8 let c = match ch {
9 '\u{3000}' => ' ',
10 '\u{ff01}'..='\u{ff5e}' => char::from_u32(ch as u32 - 0xfee0).unwrap_or(ch),
11 '\u{2018}' | '\u{2019}' => '\'',
12 '\u{201c}' | '\u{201d}' => '"',
13 _ => ch,
14 };
15 c.to_lowercase()
16 }).collect::<String>()
17}
18
19pub fn is_cjk(ch: char) -> bool {
20 matches!(ch as u32, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af)
21}
22
23pub fn tokenize(text: &str) -> Vec<String> {
25 let normalized = normalize_text(text);
26 let mut tokens = Vec::new();
27 let mut word = String::new();
28 let mut run = Vec::new();
29 fn flush(word: &mut String, run: &mut Vec<char>, tokens: &mut Vec<String>) {
30 if !word.is_empty() { tokens.push(std::mem::take(word)); }
31 tokens.extend(run.iter().map(char::to_string));
32 tokens.extend(run.windows(2).map(|p| format!("{}{}", p[0], p[1])));
33 run.clear();
34 }
35 for ch in normalized.chars() {
36 if is_cjk(ch) {
37 if !word.is_empty() { tokens.push(std::mem::take(&mut word)); }
38 run.push(ch);
39 } else if ch.is_alphanumeric() || ch == '_' {
40 if !run.is_empty() { flush(&mut word, &mut run, &mut tokens); }
41 word.push(ch);
42 } else { flush(&mut word, &mut run, &mut tokens); }
43 }
44 flush(&mut word, &mut run, &mut tokens);
45 tokens
46}
47
48pub(crate) fn query_terms(text: &str, strict: bool) -> Vec<String> {
49 let tokens = tokenize(text);
50 let mut seen = HashSet::new();
51 tokens.into_iter().filter(|t| {
52 (strict || !(t.chars().count() == 2 && t.chars().all(is_cjk))) && seen.insert(t.clone())
55 }).collect()
56}
57
58const ASCII_UNITS: u64 = 25;
61const NON_ASCII_UNITS: u64 = 48;
62
63pub(crate) fn count_tokens(text: &str) -> usize {
66 let units: u64 = text.chars().map(|ch| if (ch as u32) < 128 { ASCII_UNITS } else { NON_ASCII_UNITS }).sum();
67 units.div_ceil(100) as usize
68}
69
70pub(crate) fn truncate_to_tokens(text: &str, budget: usize) -> String {
73 if budget == 0 { return String::new(); }
74 let limit = budget as u64 * 100;
75 let mut units: u64 = 0;
76 let mut end = 0usize;
77 for (index, ch) in text.char_indices() {
78 let cost = if (ch as u32) < 128 { ASCII_UNITS } else { NON_ASCII_UNITS };
79 if units + cost > limit { break; }
80 units += cost;
81 end = index + ch.len_utf8();
82 }
83 text[..end].to_string()
84}
85
86pub(crate) fn digest(text: &str) -> String { format!("{:x}", Sha256::digest(text.as_bytes())) }pub(crate) fn normalized_tag(text: &str) -> String {
87 normalize_text(text).split_whitespace().collect::<Vec<_>>().join(" ")
88}
89
90fn pattern(source: &str) -> Regex { Regex::new(source).expect("static markdown pattern compiles") }
96
97static MD_HTML: LazyLock<Regex> = LazyLock::new(|| pattern(r"<[^>]+>"));
98static MD_HEADING: LazyLock<Regex> = LazyLock::new(|| pattern(r"#+\s?"));
99static MD_BOLD: LazyLock<Regex> = LazyLock::new(|| pattern(r"(\*\*|__)(.*?)(\*\*|__)"));
100static MD_ITALIC: LazyLock<Regex> = LazyLock::new(|| pattern(r"(\*|_)(.*?)(\*|_)"));
101static MD_LINK: LazyLock<Regex> = LazyLock::new(|| pattern(r"\[(.*?)\]\(.*?\)"));
102static MD_IMAGE: LazyLock<Regex> = LazyLock::new(|| pattern(r"!\[.*?\]\(.*?\)"));
103static MD_FENCE: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?s)```.*?```"));
104static MD_CODE: LazyLock<Regex> = LazyLock::new(|| pattern(r"`([^`]+)`"));
105static MD_BULLET: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^[-*+]\s+"));
106static MD_ORDERED: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^\d+\.\s+"));
107static MD_QUOTE: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^>\s+"));
108static MD_RULE: LazyLock<Regex> = LazyLock::new(|| pattern(r"---+"));
109static MD_PIPE: LazyLock<Regex> = LazyLock::new(|| pattern(r"\|"));
110
111pub fn clean_markdown(input: &str) -> String {
114 let text = input.to_string();
115 let text = MD_HTML.replace_all(&text, "");
116 let text = MD_HEADING.replace_all(&text, "");
117 let text = MD_BOLD.replace_all(&text, "$2");
118 let text = MD_ITALIC.replace_all(&text, "$2");
119 let text = MD_LINK.replace_all(&text, "$1");
120 let text = MD_IMAGE.replace_all(&text, "");
121 let text = MD_FENCE.replace_all(&text, "");
122 let text = MD_CODE.replace_all(&text, "$1");
123 let text = MD_BULLET.replace_all(&text, "");
124 let text = MD_ORDERED.replace_all(&text, "");
125 let text = MD_QUOTE.replace_all(&text, "");
126 let text = MD_RULE.replace_all(&text, "");
127 let text = MD_PIPE.replace_all(&text, " ");
128 text.trim().to_string()
129}
130
131#[cfg(test)]
132mod tests {
133 use super::clean_markdown;
134
135 #[test]
137 fn clean_markdown_matches_reference() {
138 let cases = [
139 ("# 标题\n\n正文 **粗体** 与 *斜体* 和 _下划线_", "标题\n\n正文 粗体 与 斜体 和 下划线"),
140 ("见 [链接](http://a.b) 与 ", "见 链接 与 !图片"),
141 ("```py\nprint(1)\n```\n后面 `行内` 文字", "后面 行内 文字"),
142 ("- 项目一\n- 项目二\n1. 有序\n> 引用\n\n---", "项目一\n项目二\n有序\n引用"),
143 ("| 列A | 列B |\n|---|---|\n| 1 | 2 |", "列A 列B \n \n 1 2"),
144 ("<div>标签</div> 普通文本", "标签 普通文本"),
145 ("混合 **粗** [链](u) `码` 尾", "混合 粗 链 码 尾"),
146 (" ## 缩进标题 ", "缩进标题"),
147 ];
148 for (input, expected) in cases {
149 assert_eq!(clean_markdown(input), expected, "input={input:?}");
150 }
151 }
152}