Skip to main content

ui/utils/
fuzzy_match.rs

1//! Hand-rolled subsequence fuzzy matching for Combobox and Command palette.
2//!
3//! No external crate — simple subsequence match with a basic relevance score
4//! (consecutive-char and word-boundary bonuses) for ranking.
5
6/// Returns `true` when every character of `query` appears in order within
7/// `candidate` (case-insensitive). An empty query matches everything.
8pub fn fuzzy_matches(query: &str, candidate: &str) -> bool {
9    fuzzy_subsequence_score(query, candidate).is_some()
10}
11
12/// Scores a case-insensitive subsequence match. Higher is better.
13/// Returns `None` when `query` is non-empty and not a subsequence of `candidate`.
14pub fn fuzzy_subsequence_score(query: &str, candidate: &str) -> Option<usize> {
15    let query = query.trim();
16    if query.is_empty() {
17        return Some(0);
18    }
19
20    let query_chars: Vec<char> = query.to_lowercase().chars().collect();
21    let candidate_lower: Vec<char> = candidate.to_lowercase().chars().collect();
22
23    let mut score = 0usize;
24    let mut qi = 0usize;
25    let mut last_match: Option<usize> = None;
26
27    for (ci, &c) in candidate_lower.iter().enumerate() {
28        if qi < query_chars.len() && c == query_chars[qi] {
29            score += 1;
30            if let Some(last) = last_match {
31                if ci == last + 1 {
32                    score += 2;
33                }
34            }
35            if ci == 0 || candidate_lower.get(ci.saturating_sub(1)) == Some(&' ') {
36                score += 3;
37            }
38            last_match = Some(ci);
39            qi += 1;
40        }
41    }
42
43    if qi == query_chars.len() {
44        Some(score)
45    } else {
46        None
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn empty_query_matches_all() {
56        assert!(fuzzy_matches("", "Anything"));
57        assert_eq!(fuzzy_subsequence_score("", "Anything"), Some(0));
58    }
59
60    #[test]
61    fn subsequence_match() {
62        assert!(fuzzy_matches("fb", "Foo Bar"));
63        assert!(!fuzzy_matches("bf", "Foo Bar"));
64    }
65
66    #[test]
67    fn consecutive_bonus_ranks_higher() {
68        let compact = fuzzy_subsequence_score("aa", "aaa").unwrap();
69        let spread = fuzzy_subsequence_score("aa", "aba").unwrap();
70        assert!(compact > spread);
71    }
72}