| 1 | //! Fuzzy matching for the palette and filename search (nucleo, smart case). |
| 2 | |
| 3 | use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; |
| 4 | use nucleo_matcher::{Config, Matcher, Utf32Str}; |
| 5 | |
| 6 | pub struct Fuzzy { |
| 7 | matcher: Matcher, |
| 8 | pattern: Pattern, |
| 9 | buf: Vec<char>, |
| 10 | } |
| 11 | |
| 12 | impl std::fmt::Debug for Fuzzy { |
| 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 14 | f.write_str("Fuzzy") |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | impl Fuzzy { |
| 19 | pub fn new(query: &str) -> Self { |
| 20 | Self { |
| 21 | matcher: Matcher::new(Config::DEFAULT), |
| 22 | pattern: Pattern::parse(query, CaseMatching::Smart, Normalization::Smart), |
| 23 | buf: Vec::new(), |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | /// Like [`Fuzzy::new`], tuned for paths (separators score as word boundaries). |
| 28 | pub fn for_paths(query: &str) -> Self { |
| 29 | let mut config = Config::DEFAULT; |
| 30 | config.set_match_paths(); |
| 31 | Self { |
| 32 | matcher: Matcher::new(config), |
| 33 | ..Self::new(query) |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | /// The match score, higher is better; `None` if it doesn't match. An empty query |
| 38 | /// matches everything with score 0. |
| 39 | pub fn score(&mut self, haystack: &str) -> Option<u32> { |
| 40 | let h = Utf32Str::new(haystack, &mut self.buf); |
| 41 | self.pattern.score(h, &mut self.matcher) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// Indices of `items` matching `query`, best first. Ties go to the shorter item, then keep |
| 46 | /// their original order (an empty query keeps every item in place). |
| 47 | pub fn rank<'a>(query: &str, items: impl IntoIterator<Item = &'a str>) -> Vec<usize> { |
| 48 | let mut f = Fuzzy::new(query); |
| 49 | let empty = query.trim().is_empty(); |
| 50 | let mut scored: Vec<(u32, usize, usize)> = items |
| 51 | .into_iter() |
| 52 | .enumerate() |
| 53 | .filter_map(|(i, s)| { |
| 54 | let len = if empty { 0 } else { s.chars().count() }; |
| 55 | f.score(s).map(|sc| (sc, len, i)) |
| 56 | }) |
| 57 | .collect(); |
| 58 | scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); |
| 59 | scored.into_iter().map(|(_, _, i)| i).collect() |
| 60 | } |
| 61 | |
| 62 | #[cfg(test)] |
| 63 | mod tests { |
| 64 | use super::*; |
| 65 | |
| 66 | #[test] |
| 67 | fn ranks_best_first() { |
| 68 | let items = ["next-mark", "next", "reload-config", "help"]; |
| 69 | let r = rank("nx", items); |
| 70 | assert_eq!(r[0], 1, "{r:?}"); |
| 71 | assert!(r.contains(&0)); |
| 72 | assert!(!r.contains(&3)); |
| 73 | assert_eq!(rank("", items), [0, 1, 2, 3]); |
| 74 | // Smart case: an upper-case letter makes it case-sensitive. |
| 75 | assert_eq!(rank("HELP", items), Vec::<usize>::new()); |
| 76 | } |
| 77 | } |