Skip to main content

revue/utils/
fuzzy.rs

1//! Fuzzy matching utilities
2//!
3//! Provides fuzzy string matching for search, autocomplete, and command palettes.
4//!
5//! # Example
6//!
7//! ```rust,ignore
8//! use revue::utils::fuzzy_match;
9//!
10//! let result = fuzzy_match("fzf", "fuzzy finder");
11//! assert!(result.is_some());
12//! assert!(result.unwrap().score > 0);
13//!
14//! // Get matched indices for highlighting
15//! let result = fuzzy_match("cmd", "CommandPalette").unwrap();
16//! assert_eq!(result.indices, vec![0, 3, 7]); // C, m, d
17//! ```
18
19/// Result of a fuzzy match
20#[derive(Clone, Debug, PartialEq)]
21pub struct FuzzyMatch {
22    /// Match score (higher is better)
23    pub score: i32,
24    /// Indices of matched characters in the target string
25    pub indices: Vec<usize>,
26}
27
28impl FuzzyMatch {
29    /// Create a new fuzzy match result
30    pub fn new(score: i32, indices: Vec<usize>) -> Self {
31        Self { score, indices }
32    }
33}
34
35/// Fuzzy match a pattern against a target string
36///
37/// Returns `Some(FuzzyMatch)` if the pattern matches, `None` otherwise.
38/// The match is case-insensitive and allows characters to be non-contiguous.
39///
40/// # Scoring
41///
42/// - Consecutive matches: +3 bonus
43/// - Match at word start: +2 bonus
44/// - Match at string start: +2 bonus
45/// - Case-sensitive match: +1 bonus
46/// - Each matched character: +1
47///
48/// # Example
49///
50/// ```rust,ignore
51/// use revue::utils::fuzzy_match;
52///
53/// // Matches "fzf" in "fuzzy finder"
54/// let m = fuzzy_match("fzf", "fuzzy finder").unwrap();
55/// println!("Score: {}, Indices: {:?}", m.score, m.indices);
56///
57/// // No match
58/// assert!(fuzzy_match("xyz", "fuzzy finder").is_none());
59/// ```
60pub fn fuzzy_match(pattern: &str, target: &str) -> Option<FuzzyMatch> {
61    if pattern.is_empty() {
62        return Some(FuzzyMatch::new(0, vec![]));
63    }
64
65    let pattern_lower: Vec<char> = pattern.to_lowercase().chars().collect();
66    let pattern_chars: Vec<char> = pattern.chars().collect();
67    let target_chars: Vec<char> = target.chars().collect();
68    let target_lower: Vec<char> = target.to_lowercase().chars().collect();
69
70    let mut indices = Vec::with_capacity(pattern_lower.len());
71    let mut score = 0i32;
72    let mut pattern_idx = 0;
73    let mut prev_match_idx: Option<usize> = None;
74
75    for (i, &ch) in target_lower.iter().enumerate() {
76        if pattern_idx < pattern_lower.len() && ch == pattern_lower[pattern_idx] {
77            indices.push(i);
78
79            // Base score for match
80            score += 1;
81
82            // Bonus for case-sensitive match
83            if target_chars[i] == pattern_chars.get(pattern_idx).copied().unwrap_or(' ') {
84                score += 1;
85            }
86
87            // Bonus for consecutive matches
88            if let Some(prev) = prev_match_idx {
89                if i == prev + 1 {
90                    score += 3;
91                }
92            }
93
94            // Bonus for match at word boundary
95            if i == 0 {
96                score += 2; // Start of string
97            } else {
98                let prev_char = target_chars[i - 1];
99                if !prev_char.is_alphanumeric()
100                    || (prev_char.is_lowercase() && target_chars[i].is_uppercase())
101                {
102                    score += 2; // Word boundary or camelCase
103                }
104            }
105
106            prev_match_idx = Some(i);
107            pattern_idx += 1;
108        }
109    }
110
111    if pattern_idx == pattern_lower.len() {
112        Some(FuzzyMatch::new(score, indices))
113    } else {
114        None
115    }
116}
117
118/// Fuzzy match with a minimum score threshold
119pub fn fuzzy_match_threshold(pattern: &str, target: &str, min_score: i32) -> Option<FuzzyMatch> {
120    fuzzy_match(pattern, target).filter(|m| m.score >= min_score)
121}
122
123/// Score a fuzzy match (returns 0 if no match)
124pub fn fuzzy_score(pattern: &str, target: &str) -> i32 {
125    fuzzy_match(pattern, target).map(|m| m.score).unwrap_or(0)
126}
127
128/// Check if pattern matches target (simple boolean check)
129pub fn fuzzy_matches(pattern: &str, target: &str) -> bool {
130    fuzzy_match(pattern, target).is_some()
131}
132
133/// Filter and sort items by fuzzy match score
134///
135/// Returns items sorted by score (highest first), filtering out non-matches.
136///
137/// # Example
138///
139/// ```rust,ignore
140/// use revue::utils::fuzzy_filter;
141///
142/// let items = vec!["apple", "application", "banana", "appetite"];
143/// let results = fuzzy_filter("app", &items);
144/// // Returns: ["application", "appetite", "apple"] (sorted by score)
145/// ```
146pub fn fuzzy_filter<'a, T: AsRef<str>>(pattern: &str, items: &'a [T]) -> Vec<(&'a T, FuzzyMatch)> {
147    let mut matches: Vec<_> = items
148        .iter()
149        .filter_map(|item| fuzzy_match(pattern, item.as_ref()).map(|m| (item, m)))
150        .collect();
151
152    matches.sort_by(|a, b| b.1.score.cmp(&a.1.score));
153    matches
154}
155
156/// Filter items and return only the strings (without match info)
157pub fn fuzzy_filter_simple<'a, T: AsRef<str>>(pattern: &str, items: &'a [T]) -> Vec<&'a T> {
158    fuzzy_filter(pattern, items)
159        .into_iter()
160        .map(|(item, _)| item)
161        .collect()
162}
163
164/// A fuzzy matcher that can be reused for multiple matches
165#[derive(Clone, Debug)]
166pub struct FuzzyMatcher {
167    /// Pattern to match (lowercase)
168    pattern: Vec<char>,
169    /// Original pattern for case-sensitive bonus
170    original_chars: Vec<char>,
171    /// Minimum score threshold
172    min_score: i32,
173}
174
175impl FuzzyMatcher {
176    /// Create a new fuzzy matcher
177    pub fn new(pattern: &str) -> Self {
178        Self {
179            pattern: pattern.to_lowercase().chars().collect(),
180            original_chars: pattern.chars().collect(),
181            min_score: 0,
182        }
183    }
184
185    /// Set minimum score threshold
186    pub fn min_score(mut self, score: i32) -> Self {
187        self.min_score = score;
188        self
189    }
190
191    /// Check if the pattern is empty
192    pub fn is_empty(&self) -> bool {
193        self.pattern.is_empty()
194    }
195
196    /// Match against a target string
197    pub fn match_str(&self, target: &str) -> Option<FuzzyMatch> {
198        if self.pattern.is_empty() {
199            return Some(FuzzyMatch::new(0, vec![]));
200        }
201
202        let target_chars: Vec<char> = target.chars().collect();
203        let target_lower: Vec<char> = target.to_lowercase().chars().collect();
204
205        let mut indices = Vec::with_capacity(self.pattern.len());
206        let mut score = 0i32;
207        let mut pattern_idx = 0;
208        let mut prev_match_idx: Option<usize> = None;
209
210        for (i, &ch) in target_lower.iter().enumerate() {
211            if pattern_idx < self.pattern.len() && ch == self.pattern[pattern_idx] {
212                indices.push(i);
213                score += 1;
214
215                // Case-sensitive bonus
216                if let Some(orig_ch) = self.original_chars.get(pattern_idx).copied() {
217                    if target_chars[i] == orig_ch {
218                        score += 1;
219                    }
220                }
221
222                // Consecutive bonus
223                if let Some(prev) = prev_match_idx {
224                    if i == prev + 1 {
225                        score += 3;
226                    }
227                }
228
229                // Word boundary bonus
230                if i == 0 {
231                    score += 2;
232                } else {
233                    let prev_char = target_chars[i - 1];
234                    if !prev_char.is_alphanumeric()
235                        || (prev_char.is_lowercase() && target_chars[i].is_uppercase())
236                    {
237                        score += 2;
238                    }
239                }
240
241                prev_match_idx = Some(i);
242                pattern_idx += 1;
243            }
244        }
245
246        if pattern_idx == self.pattern.len() && score >= self.min_score {
247            Some(FuzzyMatch::new(score, indices))
248        } else {
249            None
250        }
251    }
252
253    /// Filter and sort items
254    pub fn filter<'a, T: AsRef<str>>(&self, items: &'a [T]) -> Vec<(&'a T, FuzzyMatch)> {
255        let mut matches: Vec<_> = items
256            .iter()
257            .filter_map(|item| self.match_str(item.as_ref()).map(|m| (item, m)))
258            .collect();
259
260        matches.sort_by(|a, b| b.1.score.cmp(&a.1.score));
261        matches
262    }
263}