Skip to main content

j_cli/util/
fuzzy.rs

1/// 匹配区间,表示 content 中匹配 target 的一段 [start, end)(左闭右开)
2#[derive(Debug, Clone)]
3pub struct Interval {
4    pub start: usize,
5    pub end: usize,
6}
7
8/// 大小写不敏感的子串匹配
9pub fn fuzzy_match(content: &str, target: &str) -> bool {
10    content.to_lowercase().contains(&target.to_lowercase())
11}
12
13/// 获取 content 中所有匹配 target 的区间(大小写不敏感)
14/// 返回的区间为 [start, end),基于原始 content 的字节索引(保证在 char boundary 上)
15pub fn get_match_intervals(content: &str, target: &str) -> Vec<Interval> {
16    let mut intervals = Vec::new();
17
18    if target.is_empty() {
19        return intervals;
20    }
21
22    let content_lower = content.to_lowercase();
23    let target_lower = target.to_lowercase();
24
25    // 建立 content_lower 字节索引 -> content 字节索引的映射
26    // 因为 to_lowercase 可能改变字节长度,需要通过 char 对齐
27    let content_chars: Vec<(usize, char)> = content.char_indices().collect();
28    let lower_chars: Vec<(usize, char)> = content_lower.char_indices().collect();
29
30    // 在 lowercase 版本中查找所有匹配
31    let mut search_from = 0;
32    while let Some(pos) = content_lower[search_from..].find(&target_lower) {
33        let lower_start = search_from + pos;
34        let lower_end = lower_start + target_lower.len();
35
36        // 找到 lower_start 和 lower_end 对应的 char 索引
37        let char_start_idx = lower_chars
38            .iter()
39            .position(|(byte_idx, _)| *byte_idx == lower_start);
40        let char_end_idx = lower_chars
41            .iter()
42            .position(|(byte_idx, _)| *byte_idx == lower_end)
43            .unwrap_or(lower_chars.len());
44
45        if let Some(char_start_idx) = char_start_idx {
46            // 映射回原始 content 的字节索引
47            let orig_start = content_chars[char_start_idx].0;
48            let orig_end = if char_end_idx < content_chars.len() {
49                content_chars[char_end_idx].0
50            } else {
51                content.len()
52            };
53
54            intervals.push(Interval {
55                start: orig_start,
56                end: orig_end,
57            });
58        }
59
60        // 按字符而非字节前进,避免在多字节字符中间切割
61        if let Some(char_start_idx) = char_start_idx {
62            if char_start_idx + 1 < lower_chars.len() {
63                search_from = lower_chars[char_start_idx + 1].0;
64            } else {
65                break;
66            }
67        } else {
68            break;
69        }
70    }
71
72    intervals
73}
74
75/// 将 content 中匹配 target 的部分用 ANSI 绿色高亮
76/// fuzzy = true 时使用大小写不敏感匹配,否则精确匹配
77pub fn highlight_matches(content: &str, target: &str, fuzzy: bool) -> String {
78    if !fuzzy {
79        // 精确匹配直接替换
80        return content.replace(target, &format!("\x1b[32m{}\x1b[0m", target));
81    }
82
83    let intervals = get_match_intervals(content, target);
84    if intervals.is_empty() {
85        return content.to_string();
86    }
87
88    let mut result = String::new();
89    let mut last_end = 0;
90
91    for interval in &intervals {
92        // 添加匹配前的内容
93        result.push_str(&content[last_end..interval.start]);
94        // 添加高亮的匹配内容(保留原始大小写)
95        result.push_str(&format!(
96            "\x1b[32m{}\x1b[0m",
97            &content[interval.start..interval.end]
98        ));
99        last_end = interval.end;
100    }
101
102    // 添加最后一段
103    result.push_str(&content[last_end..]);
104    result
105}