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 the characters from `needle` can be found in order within
44/// `haystack` (kept for backward compatibility).
45pub fn fuzzy_subsequence(needle: &str, haystack: &str) -> bool {
46    if needle.is_empty() {
47        return true;
48    }
49
50    let mut needle_chars = needle.chars();
51    let mut current = match needle_chars.next() {
52        Some(value) => value,
53        None => return true,
54    };
55
56    for ch in haystack.chars() {
57        if ch == current {
58            match needle_chars.next() {
59                Some(next) => current = next,
60                None => return true,
61            }
62        }
63    }
64
65    false
66}
67
68/// Returns a score for the fuzzy match between query and candidate using nucleo-matcher.
69/// Returns None if no match is found, Some(score) if a match exists.
70pub fn fuzzy_score(query: &str, candidate: &str) -> Option<u32> {
71    if query.is_empty() {
72        return Some(0); // Default score for empty query
73    }
74
75    let mut matcher = Matcher::new(Config::DEFAULT);
76    let mut buffer = Vec::new();
77    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
78    let utf32_candidate = Utf32Str::new(candidate, &mut buffer);
79    pattern.score(utf32_candidate, &mut matcher)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn normalize_query_trims_and_lowercases() {
88        let normalized = normalize_query("   Foo   Bar   BAZ  ");
89        assert_eq!(normalized, "foo bar baz");
90    }
91
92    #[test]
93    fn normalize_query_handles_whitespace_only() {
94        assert!(normalize_query("   ").is_empty());
95    }
96
97    #[test]
98    fn fuzzy_subsequence_requires_in_order_match() {
99        assert!(fuzzy_subsequence("abc", "a_b_c"));
100        assert!(!fuzzy_subsequence("abc", "acb"));
101    }
102
103    #[test]
104    fn fuzzy_match_supports_multiple_terms() {
105        assert!(fuzzy_match("run cmd", "run command"));
106        assert!(!fuzzy_match("missing", "run command"));
107    }
108
109    #[test]
110    fn fuzzy_match_with_nucleo_basic() {
111        // Test that nucleo-based fuzzy matching works
112        assert!(fuzzy_match("smr", "src/main.rs"));
113        assert!(fuzzy_match("src main", "src/main.rs"));
114        assert!(fuzzy_match("main", "src/main.rs"));
115        assert!(!fuzzy_match("xyz", "src/main.rs"));
116    }
117
118    #[test]
119    fn fuzzy_score_returns_some_for_matches() {
120        // Test that fuzzy scoring returns Some for valid matches
121        assert!(fuzzy_score("smr", "src/main.rs").is_some());
122        assert!(fuzzy_score("main", "src/main.rs").is_some());
123    }
124
125    #[test]
126    fn fuzzy_score_returns_none_for_non_matches() {
127        // Test that fuzzy scoring returns None for non-matches
128        assert!(fuzzy_score("xyz", "src/main.rs").is_none());
129    }
130}