Skip to main content

vtcode_tui/ui/
search.rs

1use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
2use nucleo_matcher::{Config, Matcher, Utf32Str};
3
4/// Normalizes a user-provided query by trimming whitespace, collapsing internal
5/// spaces, and converting everything to lowercase ASCII.
6pub fn normalize_query(query: &str) -> String {
7    let trimmed = query.trim();
8    if trimmed.is_empty() {
9        return String::new();
10    }
11
12    let mut normalized = String::with_capacity(trimmed.len());
13    let mut last_was_space = false;
14    for ch in trimmed.chars() {
15        if ch.is_whitespace() {
16            if !last_was_space && !normalized.is_empty() {
17                normalized.push(' ');
18            }
19            last_was_space = true;
20        } else {
21            normalized.extend(ch.to_lowercase());
22            last_was_space = false;
23        }
24    }
25
26    normalized.trim_end().to_owned()
27}
28
29/// Returns true when every term in the query appears as a fuzzy match
30/// within the candidate text using nucleo-matcher.
31pub fn fuzzy_match(query: &str, candidate: &str) -> bool {
32    if query.is_empty() {
33        return true;
34    }
35
36    let mut matcher = Matcher::new(Config::DEFAULT);
37    let mut buffer = Vec::new();
38    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
39    let utf32_candidate = Utf32Str::new(candidate, &mut buffer);
40    pattern.score(utf32_candidate, &mut matcher).is_some()
41}
42
43/// Returns true when every whitespace-separated term in `query` appears as a
44/// case-insensitive substring within `candidate`. Both `query` and `candidate`
45/// are expected to be pre-lowered (via [`normalize_query`] and construction-time
46/// lowering respectively), so this function performs zero allocations.
47#[inline]
48pub fn exact_terms_match(query: &str, candidate: &str) -> bool {
49    if query.is_empty() {
50        return true;
51    }
52    query
53        .split_whitespace()
54        .all(|term| candidate.contains(term))
55}
56
57/// Returns true when the characters from `needle` can be found in order within
58/// `haystack` (kept for backward compatibility).
59pub fn fuzzy_subsequence(needle: &str, haystack: &str) -> bool {
60    if needle.is_empty() {
61        return true;
62    }
63
64    let mut needle_chars = needle.chars();
65    let mut current = match needle_chars.next() {
66        Some(value) => value,
67        None => return true,
68    };
69
70    for ch in haystack.chars() {
71        if ch == current {
72            match needle_chars.next() {
73                Some(next) => current = next,
74                None => return true,
75            }
76        }
77    }
78
79    false
80}
81
82/// Returns a score for the fuzzy match between query and candidate using nucleo-matcher.
83/// Returns None if no match is found, Some(score) if a match exists.
84pub fn fuzzy_score(query: &str, candidate: &str) -> Option<u32> {
85    if query.is_empty() {
86        return Some(0); // Default score for empty query
87    }
88
89    let mut matcher = Matcher::new(Config::DEFAULT);
90    let mut buffer = Vec::new();
91    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
92    let utf32_candidate = Utf32Str::new(candidate, &mut buffer);
93    pattern.score(utf32_candidate, &mut matcher)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn normalize_query_trims_and_lowercases() {
102        let normalized = normalize_query("   Foo   Bar   BAZ  ");
103        assert_eq!(normalized, "foo bar baz");
104    }
105
106    #[test]
107    fn normalize_query_handles_whitespace_only() {
108        assert!(normalize_query("   ").is_empty());
109    }
110
111    #[test]
112    fn fuzzy_subsequence_requires_in_order_match() {
113        assert!(fuzzy_subsequence("abc", "a_b_c"));
114        assert!(!fuzzy_subsequence("abc", "acb"));
115    }
116
117    #[test]
118    fn fuzzy_match_supports_multiple_terms() {
119        assert!(fuzzy_match("run cmd", "run command"));
120        assert!(!fuzzy_match("missing", "run command"));
121    }
122
123    #[test]
124    fn fuzzy_match_with_nucleo_basic() {
125        // Test that nucleo-based fuzzy matching works
126        assert!(fuzzy_match("smr", "src/main.rs"));
127        assert!(fuzzy_match("src main", "src/main.rs"));
128        assert!(fuzzy_match("main", "src/main.rs"));
129        assert!(!fuzzy_match("xyz", "src/main.rs"));
130    }
131
132    #[test]
133    fn fuzzy_score_returns_some_for_matches() {
134        // Test that fuzzy scoring returns Some for valid matches
135        assert!(fuzzy_score("smr", "src/main.rs").is_some());
136        assert!(fuzzy_score("main", "src/main.rs").is_some());
137    }
138
139    #[test]
140    fn fuzzy_score_returns_none_for_non_matches() {
141        // Test that fuzzy scoring returns None for non-matches
142        assert!(fuzzy_score("xyz", "src/main.rs").is_none());
143    }
144
145    #[test]
146    fn exact_terms_match_requires_substring() {
147        // Candidates are pre-lowered (as done by ModalListState construction)
148        assert!(exact_terms_match("openai", "openai openai gpt-5.4 gpt-5.4"));
149        assert!(exact_terms_match("gpt", "openai openai gpt-5.4 gpt-5.4"));
150        assert!(!exact_terms_match(
151            "anthropic",
152            "openai openai gpt-5.4 gpt-5.4"
153        ));
154    }
155
156    #[test]
157    fn exact_terms_match_multi_term_requires_all() {
158        let candidate = "anthropic anthropic claude 4 sonnet claude-4-sonnet";
159        assert!(exact_terms_match("anthropic claude", candidate));
160        assert!(exact_terms_match("sonnet", candidate));
161        assert!(!exact_terms_match("anthropic gpt", candidate));
162    }
163
164    #[test]
165    fn exact_terms_match_empty_query_matches_everything() {
166        assert!(exact_terms_match("", "anything"));
167    }
168
169    #[test]
170    fn exact_terms_match_rejects_fuzzy_subsequences() {
171        assert!(!exact_terms_match("smr", "src/main.rs"));
172    }
173
174    #[test]
175    fn exact_terms_match_provider_filtering() {
176        let openai = "openai openai gpt-5.4 gpt-5.4 reasoning tools image";
177        let anthropic = "anthropic anthropic claude 4 sonnet claude-4-sonnet reasoning tools";
178        let gemini = "gemini gemini gemini 2.5 pro gemini-2.5-pro reasoning tools";
179
180        // Single provider term filters correctly
181        assert!(exact_terms_match("openai", openai));
182        assert!(!exact_terms_match("openai", anthropic));
183        assert!(!exact_terms_match("openai", gemini));
184
185        // Provider + model narrows further
186        assert!(exact_terms_match("openai gpt", openai));
187        assert!(!exact_terms_match("openai claude", openai));
188
189        // Capability filter works across providers
190        assert!(exact_terms_match("reasoning", openai));
191        assert!(exact_terms_match("reasoning", anthropic));
192    }
193}