1use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
8use nucleo_matcher::{Config, Matcher, Utf32Str};
9
10#[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
42pub struct FuzzyQuery {
51 pattern: Pattern,
52 matcher: Matcher,
53 buffer: Vec<char>,
54 empty: bool,
55}
56
57impl FuzzyQuery {
58 #[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 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 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
90pub fn fuzzy_match(query: &str, candidate: &str) -> bool {
93 FuzzyQuery::new(query).matches(candidate)
94}
95
96pub 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
121pub 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}