use regex::Regex;
use std::collections::HashSet;
use std::sync::LazyLock;
use sha2::{Digest, Sha256};
pub fn normalize_text(text: &str) -> String {
text.trim().chars().flat_map(|ch| {
let c = match ch {
'\u{3000}' => ' ',
'\u{ff01}'..='\u{ff5e}' => char::from_u32(ch as u32 - 0xfee0).unwrap_or(ch),
'\u{2018}' | '\u{2019}' => '\'',
'\u{201c}' | '\u{201d}' => '"',
_ => ch,
};
c.to_lowercase()
}).collect::<String>()
}
pub fn is_cjk(ch: char) -> bool {
matches!(ch as u32, 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x20000..=0x323af)
}
pub fn tokenize(text: &str) -> Vec<String> {
let normalized = normalize_text(text);
let mut tokens = Vec::new();
let mut word = String::new();
let mut run = Vec::new();
fn flush(word: &mut String, run: &mut Vec<char>, tokens: &mut Vec<String>) {
if !word.is_empty() { tokens.push(std::mem::take(word)); }
tokens.extend(run.iter().map(char::to_string));
tokens.extend(run.windows(2).map(|p| format!("{}{}", p[0], p[1])));
run.clear();
}
for ch in normalized.chars() {
if is_cjk(ch) {
if !word.is_empty() { tokens.push(std::mem::take(&mut word)); }
run.push(ch);
} else if ch.is_alphanumeric() || ch == '_' {
if !run.is_empty() { flush(&mut word, &mut run, &mut tokens); }
word.push(ch);
} else { flush(&mut word, &mut run, &mut tokens); }
}
flush(&mut word, &mut run, &mut tokens);
tokens
}
pub(crate) fn query_terms(text: &str, strict: bool) -> Vec<String> {
let tokens = tokenize(text);
let mut seen = HashSet::new();
tokens.into_iter().filter(|t| {
(strict || !(t.chars().count() == 2 && t.chars().all(is_cjk))) && seen.insert(t.clone())
}).collect()
}
const ASCII_UNITS: u64 = 25;
const NON_ASCII_UNITS: u64 = 48;
pub(crate) fn count_tokens(text: &str) -> usize {
let units: u64 = text.chars().map(|ch| if (ch as u32) < 128 { ASCII_UNITS } else { NON_ASCII_UNITS }).sum();
units.div_ceil(100) as usize
}
pub(crate) fn truncate_to_tokens(text: &str, budget: usize) -> String {
if budget == 0 { return String::new(); }
let limit = budget as u64 * 100;
let mut units: u64 = 0;
let mut end = 0usize;
for (index, ch) in text.char_indices() {
let cost = if (ch as u32) < 128 { ASCII_UNITS } else { NON_ASCII_UNITS };
if units + cost > limit { break; }
units += cost;
end = index + ch.len_utf8();
}
text[..end].to_string()
}
pub(crate) fn digest(text: &str) -> String { format!("{:x}", Sha256::digest(text.as_bytes())) }pub(crate) fn normalized_tag(text: &str) -> String {
normalize_text(text).split_whitespace().collect::<Vec<_>>().join(" ")
}
fn pattern(source: &str) -> Regex { Regex::new(source).expect("static markdown pattern compiles") }
static MD_HTML: LazyLock<Regex> = LazyLock::new(|| pattern(r"<[^>]+>"));
static MD_HEADING: LazyLock<Regex> = LazyLock::new(|| pattern(r"#+\s?"));
static MD_BOLD: LazyLock<Regex> = LazyLock::new(|| pattern(r"(\*\*|__)(.*?)(\*\*|__)"));
static MD_ITALIC: LazyLock<Regex> = LazyLock::new(|| pattern(r"(\*|_)(.*?)(\*|_)"));
static MD_LINK: LazyLock<Regex> = LazyLock::new(|| pattern(r"\[(.*?)\]\(.*?\)"));
static MD_IMAGE: LazyLock<Regex> = LazyLock::new(|| pattern(r"!\[.*?\]\(.*?\)"));
static MD_FENCE: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?s)```.*?```"));
static MD_CODE: LazyLock<Regex> = LazyLock::new(|| pattern(r"`([^`]+)`"));
static MD_BULLET: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^[-*+]\s+"));
static MD_ORDERED: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^\d+\.\s+"));
static MD_QUOTE: LazyLock<Regex> = LazyLock::new(|| pattern(r"(?m)^>\s+"));
static MD_RULE: LazyLock<Regex> = LazyLock::new(|| pattern(r"---+"));
static MD_PIPE: LazyLock<Regex> = LazyLock::new(|| pattern(r"\|"));
pub fn clean_markdown(input: &str) -> String {
let text = input.to_string();
let text = MD_HTML.replace_all(&text, "");
let text = MD_HEADING.replace_all(&text, "");
let text = MD_BOLD.replace_all(&text, "$2");
let text = MD_ITALIC.replace_all(&text, "$2");
let text = MD_LINK.replace_all(&text, "$1");
let text = MD_IMAGE.replace_all(&text, "");
let text = MD_FENCE.replace_all(&text, "");
let text = MD_CODE.replace_all(&text, "$1");
let text = MD_BULLET.replace_all(&text, "");
let text = MD_ORDERED.replace_all(&text, "");
let text = MD_QUOTE.replace_all(&text, "");
let text = MD_RULE.replace_all(&text, "");
let text = MD_PIPE.replace_all(&text, " ");
text.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::clean_markdown;
#[test]
fn clean_markdown_matches_reference() {
let cases = [
("# 标题\n\n正文 **粗体** 与 *斜体* 和 _下划线_", "标题\n\n正文 粗体 与 斜体 和 下划线"),
("见 [链接](http://a.b) 与 ", "见 链接 与 !图片"),
("```py\nprint(1)\n```\n后面 `行内` 文字", "后面 行内 文字"),
("- 项目一\n- 项目二\n1. 有序\n> 引用\n\n---", "项目一\n项目二\n有序\n引用"),
("| 列A | 列B |\n|---|---|\n| 1 | 2 |", "列A 列B \n \n 1 2"),
("<div>标签</div> 普通文本", "标签 普通文本"),
("混合 **粗** [链](u) `码` 尾", "混合 粗 链 码 尾"),
(" ## 缩进标题 ", "缩进标题"),
];
for (input, expected) in cases {
assert_eq!(clean_markdown(input), expected, "input={input:?}");
}
}
}