Skip to main content

rab/tui/
fuzzy.rs

1/// Result of fuzzy matching.
2#[derive(Debug, Clone, PartialEq)]
3pub struct FuzzyMatch {
4    pub matches: bool,
5    pub score: f64,
6}
7
8/// Fuzzy match a query against text.
9/// Characters must appear in order (not necessarily consecutive).
10/// Lower score = better match.
11pub fn fuzzy_match(query: &str, text: &str) -> FuzzyMatch {
12    let query_lower = query.to_lowercase();
13    let text_lower = text.to_lowercase();
14
15    let match_query = |normalized_query: &str| -> FuzzyMatch {
16        if normalized_query.is_empty() {
17            return FuzzyMatch {
18                matches: true,
19                score: 0.0,
20            };
21        }
22
23        let query_chars: Vec<char> = normalized_query.chars().collect();
24        let text_chars: Vec<char> = text_lower.chars().collect();
25
26        if query_chars.len() > text_chars.len() {
27            return FuzzyMatch {
28                matches: false,
29                score: 0.0,
30            };
31        }
32
33        let mut query_idx = 0;
34        let mut score: f64 = 0.0;
35        let mut last_match_idx: i32 = -1;
36        let mut consecutive = 0;
37
38        for (i, ch) in text_chars.iter().enumerate() {
39            if query_idx >= query_chars.len() {
40                break;
41            }
42
43            if *ch == query_chars[query_idx] {
44                let is_word_boundary = i == 0
45                    || matches!(
46                        text_lower.as_bytes().get(i - 1),
47                        Some(b' ') | Some(b'-') | Some(b'_') | Some(b'.') | Some(b'/') | Some(b':')
48                    );
49
50                if last_match_idx == (i as i32) - 1 {
51                    consecutive += 1;
52                    score -= (consecutive as f64) * 5.0;
53                } else {
54                    consecutive = 0;
55                    if last_match_idx >= 0 {
56                        score += ((i as i32) - last_match_idx - 1) as f64 * 2.0;
57                    }
58                }
59
60                if is_word_boundary {
61                    score -= 10.0;
62                }
63
64                score += (i as f64) * 0.1;
65                last_match_idx = i as i32;
66                query_idx += 1;
67            }
68        }
69
70        if query_idx < query_chars.len() {
71            return FuzzyMatch {
72                matches: false,
73                score: 0.0,
74            };
75        }
76
77        if normalized_query == text_lower.as_str() {
78            score -= 100.0;
79        }
80
81        FuzzyMatch {
82            matches: true,
83            score,
84        }
85    };
86
87    let primary = match_query(&query_lower);
88    if primary.matches {
89        return primary;
90    }
91
92    // Try swapped alphanumeric (e.g., "gpt5.2" for "5.2-gpt")
93    let swapped = swap_alphanumeric(&query_lower);
94    if swapped.is_empty() {
95        return primary;
96    }
97
98    let swapped_match = match_query(&swapped);
99    if swapped_match.matches {
100        FuzzyMatch {
101            matches: true,
102            score: swapped_match.score + 5.0,
103        }
104    } else {
105        primary
106    }
107}
108
109/// Swap letter/digit groups in a query string.
110/// "codex52" -> "52codex", "gpt5.2" -> "5.2gpt"
111fn swap_alphanumeric(query: &str) -> String {
112    // Check for alpha then digits pattern
113    if let Some(pos) = query.find(|c: char| c.is_ascii_digit())
114        && pos > 0
115        && query[..pos].chars().all(|c| c.is_ascii_alphabetic())
116    {
117        let alpha = &query[..pos];
118        let rest = &query[pos..];
119        return format!("{}{}", rest, alpha);
120    }
121    // Check for digits then alpha pattern
122    if let Some(pos) = query.find(|c: char| c.is_ascii_alphabetic())
123        && pos > 0
124        && query[..pos].chars().all(|c| c.is_ascii_digit())
125    {
126        let digits = &query[..pos];
127        let rest = &query[pos..];
128        return format!("{}{}", rest, digits);
129    }
130    String::new()
131}
132
133/// Filter and sort items by fuzzy match quality (best matches first).
134/// Supports whitespace- and slash-separated tokens: all tokens must match.
135pub fn fuzzy_filter<T>(items: &[T], query: &str, get_text: impl Fn(&T) -> &str) -> Vec<usize> {
136    if query.trim().is_empty() {
137        return (0..items.len()).collect();
138    }
139
140    let tokens: Vec<&str> = query
141        .trim()
142        .split(|c: char| c.is_whitespace() || c == '/')
143        .filter(|t| !t.is_empty())
144        .collect();
145
146    if tokens.is_empty() {
147        return (0..items.len()).collect();
148    }
149
150    let mut results: Vec<(usize, f64)> = Vec::new();
151
152    for (idx, item) in items.iter().enumerate() {
153        let text = get_text(item);
154        let mut total_score = 0.0;
155        let mut all_match = true;
156
157        for token in &tokens {
158            let m = fuzzy_match(token, text);
159            if m.matches {
160                total_score += m.score;
161            } else {
162                all_match = false;
163                break;
164            }
165        }
166
167        if all_match {
168            results.push((idx, total_score));
169        }
170    }
171
172    results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
173    results.into_iter().map(|(idx, _)| idx).collect()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_empty_query_matches_all() {
182        let result = fuzzy_match("", "anything");
183        assert!(result.matches);
184        assert_eq!(result.score, 0.0);
185    }
186
187    #[test]
188    fn test_query_longer_than_text() {
189        let result = fuzzy_match("longquery", "short");
190        assert!(!result.matches);
191    }
192
193    #[test]
194    fn test_characters_must_appear_in_order() {
195        let in_order = fuzzy_match("abc", "aXbXc");
196        assert!(in_order.matches);
197
198        let out_of_order = fuzzy_match("abc", "cba");
199        assert!(!out_of_order.matches);
200    }
201
202    #[test]
203    fn test_case_insensitive() {
204        assert!(fuzzy_match("ABC", "abc").matches);
205        assert!(fuzzy_match("abc", "ABC").matches);
206    }
207
208    #[test]
209    fn test_consecutive_better_than_scattered() {
210        let consecutive = fuzzy_match("foo", "foobar");
211        let scattered = fuzzy_match("foo", "f_o_o_bar");
212        assert!(consecutive.matches);
213        assert!(scattered.matches);
214        assert!(consecutive.score < scattered.score);
215    }
216
217    #[test]
218    fn test_word_boundary_better() {
219        let at_boundary = fuzzy_match("fb", "foo-bar");
220        let not_boundary = fuzzy_match("fb", "afbx");
221        assert!(at_boundary.matches);
222        assert!(not_boundary.matches);
223        assert!(at_boundary.score < not_boundary.score);
224    }
225
226    #[test]
227    fn test_swapped_alphanumeric() {
228        let result = fuzzy_match("codex52", "gpt-5.2-codex");
229        assert!(result.matches);
230    }
231
232    #[test]
233    fn test_fuzzy_filter_empty_query_returns_all() {
234        let items = vec!["apple", "banana", "cherry"];
235        let result = fuzzy_filter(&items, "", |s| s);
236        assert_eq!(result, vec![0, 1, 2]);
237    }
238
239    #[test]
240    fn test_fuzzy_filter_filters_non_matching() {
241        let items = vec!["apple", "banana", "cherry"];
242        let result = fuzzy_filter(&items, "an", |s| s);
243        assert!(result.contains(&1)); // banana
244        assert!(!result.contains(&0)); // apple has "a" but not after "n"
245        // Actually "apple" does contain "a" and "p" and "p" and "l" and "e"
246        // "an": 'a' at 0, then 'n' - apple doesn't have 'n'
247        assert!(!result.contains(&2)); // cherry doesn't have 'a' then 'n'
248    }
249
250    #[test]
251    fn test_fuzzy_filter_sorts_by_quality() {
252        let items = vec!["a_p_p", "app", "application"];
253        let result = fuzzy_filter(&items, "app", |s| s);
254        assert_eq!(items[result[0]], "app");
255    }
256
257    #[test]
258    fn test_fuzzy_filter_slash_separated() {
259        let items = vec!["gpt-5.5 openai-codex"];
260        let result = fuzzy_filter(&items, "openai-codex/gpt-5.5", |s| s);
261        assert_eq!(result.len(), 1);
262    }
263}