Skip to main content

vtcode_commons/
search.rs

1//! Fuzzy text search shared by core and UI surfaces.
2//!
3//! Canonical home for the nucleo-matcher-backed query primitives. Both
4//! `vtcode-core` (slash-command suggestions, tool routing) and `vtcode-ui`
5//! (modal lists, palettes) delegate here so the matcher behavior cannot drift.
6
7use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
8use nucleo_matcher::{Config, Matcher, Utf32Str};
9
10/// Normalizes a user-provided query by trimming whitespace, collapsing internal
11/// spaces, and converting everything to lowercase.
12///
13/// ```
14/// # use vtcode_commons::search::normalize_query;
15/// assert_eq!(normalize_query("  Foo   Bar  "), "foo bar");
16/// assert!(normalize_query("   ").is_empty());
17/// ```
18#[must_use]
19pub fn normalize_query(query: &str) -> String {
20    let trimmed = query.trim();
21    if trimmed.is_empty() {
22        return String::new();
23    }
24
25    let mut normalized = String::with_capacity(trimmed.len());
26    let mut last_was_space = false;
27    for ch in trimmed.chars() {
28        if ch.is_whitespace() {
29            if !last_was_space && !normalized.is_empty() {
30                normalized.push(' ');
31            }
32            last_was_space = true;
33        } else {
34            normalized.extend(ch.to_lowercase());
35            last_was_space = false;
36        }
37    }
38
39    normalized.trim_end().to_owned()
40}
41
42/// A reusable fuzzy query that parses the search pattern and allocates its
43/// `Matcher` and scratch buffer exactly once, then scores many candidates.
44///
45/// The one-shot helpers [`fuzzy_score`] and [`fuzzy_match`] rebuild all of
46/// this state on every call. When scoring a collection (command palettes,
47/// history pickers, file lists) prefer building a single `FuzzyQuery` before
48/// the loop and reusing it per candidate to avoid the per-item pattern parse
49/// and matcher allocation.
50pub struct FuzzyQuery {
51    pattern: Pattern,
52    matcher: Matcher,
53    buffer: Vec<char>,
54    empty: bool,
55}
56
57impl FuzzyQuery {
58    /// Compile a query once for repeated scoring.
59    #[must_use]
60    pub fn new(query: &str) -> Self {
61        Self {
62            pattern: Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart),
63            matcher: Matcher::new(Config::DEFAULT),
64            buffer: Vec::new(),
65            empty: query.is_empty(),
66        }
67    }
68
69    /// Score a candidate against the compiled query.
70    ///
71    /// Returns `Some(0)` for an empty query (matches everything), `Some(score)`
72    /// on a fuzzy match, and `None` when the candidate does not match.
73    pub fn score(&mut self, candidate: &str) -> Option<u32> {
74        if self.empty {
75            return Some(0);
76        }
77        let utf32_candidate = Utf32Str::new(candidate, &mut self.buffer);
78        self.pattern.score(utf32_candidate, &mut self.matcher)
79    }
80
81    /// Returns true when the candidate fuzzy-matches the compiled query.
82    pub fn matches(&mut self, candidate: &str) -> bool {
83        if self.empty {
84            return true;
85        }
86        self.score(candidate).is_some()
87    }
88}
89
90/// Returns true when every term in the query appears as a fuzzy match
91/// within the candidate text using nucleo-matcher.
92pub fn fuzzy_match(query: &str, candidate: &str) -> bool {
93    FuzzyQuery::new(query).matches(candidate)
94}
95
96/// Returns true when the characters from `needle` can be found in order within
97/// `haystack` (kept for backward compatibility).
98pub fn fuzzy_subsequence(needle: &str, haystack: &str) -> bool {
99    if needle.is_empty() {
100        return true;
101    }
102
103    let mut needle_chars = needle.chars();
104    let mut current = match needle_chars.next() {
105        Some(value) => value,
106        None => return true,
107    };
108
109    for ch in haystack.chars() {
110        if ch == current {
111            match needle_chars.next() {
112                Some(next) => current = next,
113                None => return true,
114            }
115        }
116    }
117
118    false
119}
120
121/// Returns a score for the fuzzy match between query and candidate using nucleo-matcher.
122/// Returns None if no match is found, Some(score) if a match exists.
123pub fn fuzzy_score(query: &str, candidate: &str) -> Option<u32> {
124    FuzzyQuery::new(query).score(candidate)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn normalize_query_trims_and_lowercases() {
133        let normalized = normalize_query("   Foo   Bar   BAZ  ");
134        assert_eq!(normalized, "foo bar baz");
135    }
136
137    #[test]
138    fn normalize_query_handles_whitespace_only() {
139        assert!(normalize_query("   ").is_empty());
140    }
141
142    #[test]
143    fn fuzzy_subsequence_requires_in_order_match() {
144        assert!(fuzzy_subsequence("abc", "a_b_c"));
145        assert!(!fuzzy_subsequence("abc", "acb"));
146    }
147
148    #[test]
149    fn fuzzy_match_supports_multiple_terms() {
150        assert!(fuzzy_match("run cmd", "run command"));
151        assert!(!fuzzy_match("missing", "run command"));
152    }
153
154    #[test]
155    fn fuzzy_match_with_nucleo_basic() {
156        assert!(fuzzy_match("smr", "src/main.rs"));
157        assert!(fuzzy_match("src main", "src/main.rs"));
158        assert!(fuzzy_match("main", "src/main.rs"));
159        assert!(!fuzzy_match("xyz", "src/main.rs"));
160    }
161
162    #[test]
163    fn fuzzy_score_returns_some_for_matches() {
164        assert!(fuzzy_score("smr", "src/main.rs").is_some());
165        assert!(fuzzy_score("main", "src/main.rs").is_some());
166    }
167
168    #[test]
169    fn fuzzy_score_returns_none_for_non_matches() {
170        assert!(fuzzy_score("xyz", "src/main.rs").is_none());
171    }
172
173    #[test]
174    fn fuzzy_query_reuse_matches_one_shot() {
175        let candidates = ["src/main.rs", "src/lib.rs", "docs/readme.md", "xyz"];
176        let mut query = FuzzyQuery::new("main");
177        for candidate in candidates {
178            assert_eq!(query.score(candidate), fuzzy_score("main", candidate));
179        }
180    }
181
182    #[test]
183    fn fuzzy_query_empty_matches_everything() {
184        let mut query = FuzzyQuery::new("");
185        assert_eq!(query.score("anything"), Some(0));
186        assert!(query.matches("anything"));
187    }
188}