1#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct FuzzyMatch {
6 pub(crate) score: i32,
7 pub(crate) positions: Vec<usize>,
9}
10
11impl FuzzyMatch {
12 #[must_use]
15 pub fn score(&self) -> i32 {
16 self.score
17 }
18
19 #[must_use]
22 pub fn positions(&self) -> &[usize] {
23 &self.positions
24 }
25}
26
27#[must_use]
45pub fn fuzzy(query: &str, text: &str) -> Option<FuzzyMatch> {
46 let lower_char = |c: char| c.to_lowercase().next().unwrap_or(c);
49 let wanted: Vec<char> = query.chars().filter(|c| !c.is_whitespace()).map(lower_char).collect();
50 if wanted.is_empty() {
51 return Some(FuzzyMatch { score: 0, positions: Vec::new() });
52 }
53 let chars: Vec<char> = text.chars().collect();
54 let lower: Vec<char> = chars.iter().copied().map(lower_char).collect();
55 let word_start = |i: usize| {
56 i == 0 || !chars[i - 1].is_alphanumeric() || (chars[i].is_uppercase() && chars[i - 1].is_lowercase())
57 };
58 let mut best: Option<FuzzyMatch> = None;
60 for first in (0..lower.len()).filter(|i| lower[*i] == wanted[0]) {
61 let mut positions = vec![first];
62 let mut at = first;
63 for c in &wanted[1..] {
64 let Some(next) = (at + 1..lower.len()).find(|i| lower[*i] == *c) else {
65 break;
66 };
67 positions.push(next);
68 at = next;
69 }
70 if positions.len() < wanted.len() {
71 break;
72 }
73 let mut score = 0;
74 for (index, position) in positions.iter().enumerate() {
75 score += 1;
76 if word_start(*position) {
77 score += 4;
78 }
79 if index > 0 && positions[index - 1] + 1 == *position {
80 score += 3;
81 }
82 }
83 score -= i32::try_from(positions[positions.len() - 1] - positions[0]).unwrap_or(i32::MAX / 2) / 4;
84 score -= i32::try_from(first).unwrap_or(0).min(8) / 4;
85 if best.as_ref().is_none_or(|b| score > b.score) {
86 best = Some(FuzzyMatch { score, positions });
87 }
88 }
89 best
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn matches_in_order_ignoring_case() {
98 let found = fuzzy("rst", "Restart container").expect("matches");
99 assert_eq!(found.positions, vec![0, 2, 3]);
100 assert!(fuzzy("tsr", "Restart").is_none());
101 assert_eq!(fuzzy("", "anything").map(|m| m.score), Some(0));
102 assert_eq!(fuzzy("o l", "Open logs").map(|m| m.positions), Some(vec![0, 5]));
103 }
104
105 #[test]
106 fn a_letter_whose_lower_case_is_two_characters_matches_itself() {
107 assert_eq!(fuzzy("İz", "İzmir").map(|m| m.positions), Some(vec![0, 1]));
110 assert_eq!(fuzzy("iz", "İzmir").map(|m| m.positions), Some(vec![0, 1]));
111 }
112
113 #[test]
114 fn word_starts_and_runs_score_higher() {
115 let starts = fuzzy("ol", "Open logs").expect("matches").score;
116 let middle = fuzzy("ol", "Pool").expect("matches").score;
117 assert!(starts > middle, "{starts} {middle}");
118 let run = fuzzy("dep", "Deploy").expect("matches").score;
119 let spread = fuzzy("dep", "Delete old snapshots and prune").expect("matches").score;
120 assert!(run > spread);
121 let better = fuzzy("log", "Toggle logs").expect("matches");
122 assert_eq!(better.positions, vec![7, 8, 9], "the word start beats the first occurrence");
123 }
124}