1use std::collections::HashMap;
16
17pub struct SequenceMatcher {
19 a: Vec<char>,
20 b: Vec<char>,
21 b2j: HashMap<char, Vec<usize>>,
23}
24
25impl SequenceMatcher {
26 pub fn new(a: &str, b: &str) -> SequenceMatcher {
27 let a: Vec<char> = a.chars().collect();
28 let b: Vec<char> = b.chars().collect();
29 let mut b2j: HashMap<char, Vec<usize>> = HashMap::new();
30 for (j, c) in b.iter().enumerate() {
31 b2j.entry(*c).or_default().push(j);
32 }
33 let n = b.len();
34 if n >= 200 {
35 let ntest = n / 100 + 1;
36 b2j.retain(|_, idxs| idxs.len() <= ntest);
37 }
38 SequenceMatcher { a, b, b2j }
39 }
40
41 fn find_longest_match(
45 &self,
46 alo: usize,
47 ahi: usize,
48 blo: usize,
49 bhi: usize,
50 ) -> (usize, usize, usize) {
51 let (mut besti, mut bestj, mut bestsize) = (alo, blo, 0usize);
52 let mut j2len: HashMap<usize, usize> = HashMap::new();
53 for i in alo..ahi {
54 let mut newj2len: HashMap<usize, usize> = HashMap::new();
55 if let Some(js) = self.b2j.get(&self.a[i]) {
56 for &j in js {
57 if j < blo {
58 continue;
59 }
60 if j >= bhi {
61 break;
62 }
63 let k = j
64 .checked_sub(1)
65 .and_then(|jm| j2len.get(&jm).copied())
66 .unwrap_or(0)
67 + 1;
68 newj2len.insert(j, k);
69 if k > bestsize {
70 besti = i + 1 - k;
71 bestj = j + 1 - k;
72 bestsize = k;
73 }
74 }
75 }
76 j2len = newj2len;
77 }
78 while besti > alo && bestj > blo && self.a[besti - 1] == self.b[bestj - 1] {
80 besti -= 1;
81 bestj -= 1;
82 bestsize += 1;
83 }
84 while besti + bestsize < ahi
85 && bestj + bestsize < bhi
86 && self.a[besti + bestsize] == self.b[bestj + bestsize]
87 {
88 bestsize += 1;
89 }
90 (besti, bestj, bestsize)
91 }
92
93 fn total_matches(&self) -> usize {
95 let mut queue = vec![(0usize, self.a.len(), 0usize, self.b.len())];
96 let mut matches = 0usize;
97 while let Some((alo, ahi, blo, bhi)) = queue.pop() {
98 let (i, j, k) = self.find_longest_match(alo, ahi, blo, bhi);
99 if k == 0 {
100 continue;
101 }
102 matches += k;
103 if alo < i && blo < j {
104 queue.push((alo, i, blo, j));
105 }
106 if i + k < ahi && j + k < bhi {
107 queue.push((i + k, ahi, j + k, bhi));
108 }
109 }
110 matches
111 }
112
113 pub fn ratio(&self) -> f64 {
115 let length = self.a.len() + self.b.len();
116 if length == 0 {
117 return 1.0;
118 }
119 2.0 * self.total_matches() as f64 / length as f64
120 }
121}
122
123pub fn get_close_matches(
129 word: &str,
130 possibilities: &[String],
131 n: usize,
132 cutoff: f64,
133) -> Vec<String> {
134 let mut scored: Vec<(f64, &String)> = possibilities
135 .iter()
136 .map(|x| (SequenceMatcher::new(x, word).ratio(), x))
137 .filter(|(r, _)| *r >= cutoff)
138 .collect();
139 scored.sort_by(|l, r| {
142 r.0.partial_cmp(&l.0)
143 .unwrap_or(std::cmp::Ordering::Equal)
144 .then_with(|| r.1.cmp(l.1))
145 });
146 scored.into_iter().take(n).map(|(_, x)| x.clone()).collect()
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
160 #[allow(clippy::excessive_precision)]
161 fn ratio_matches_cpython() {
162 let cases = [
163 ("laugh", "laughs", 0.90909090909090906),
164 ("clear throat", "clears throat", 0.95999999999999996),
165 ("surprised", "surprise", 0.94117647058823528),
166 ("narration", "narrating", 0.88888888888888884),
167 ("crying", "cries", 0.54545454545454541),
168 ("sigh", "sighs", 0.88888888888888884),
169 ("cough", "coughs", 0.90909090909090906),
170 ("gasp", "gasps", 0.88888888888888884),
171 ("angry", "angry", 1.0),
172 ("happy", "exhausted", 0.2857142857142857),
173 ("chuckle", "chuckles", 0.93333333333333335),
174 ];
175 for (a, b, want) in cases {
176 let got = SequenceMatcher::new(a, b).ratio();
177 assert_eq!(got, want, "ratio({a:?}, {b:?}) = {got:.17} want {want:.17}");
178 }
179 }
180
181 #[test]
182 fn autojunk_engages_at_two_hundred() {
183 let a = "the quick brown fox jumps over the lazy dog and then keeps running through the forest until it reaches the river where it stops to drink some water before heading back home to its den in the hills beyond the valley far away";
184 let b = "the quick brown fox jumped over a lazy dog and kept running through the forest till it reached the river where it stopped to drink water before heading home to its den in the hills past the valley very far away indeed";
185 assert_eq!(SequenceMatcher::new(a, b).ratio(), 0.29545454545454547);
186 assert_eq!(SequenceMatcher::new(b, a).ratio(), 0.4409090909090909);
187 }
188
189 #[test]
190 fn empty_sequences_are_identical() {
191 assert_eq!(SequenceMatcher::new("", "").ratio(), 1.0);
192 assert_eq!(SequenceMatcher::new("", "abc").ratio(), 0.0);
193 assert_eq!(SequenceMatcher::new("abc", "").ratio(), 0.0);
194 }
195
196 #[test]
197 fn close_matches_honour_the_cutoff() {
198 let cands: Vec<String> = [
199 "laugh",
200 "chuckle",
201 "sigh",
202 "surprised",
203 "clear throat",
204 "narration",
205 ]
206 .iter()
207 .map(|s| s.to_string())
208 .collect();
209 assert_eq!(get_close_matches("laughs", &cands, 1, 0.72), vec!["laugh"]);
210 assert_eq!(
211 get_close_matches("clears throat", &cands, 1, 0.72),
212 vec!["clear throat"]
213 );
214 assert_eq!(
215 get_close_matches("surprise", &cands, 1, 0.72),
216 vec!["surprised"]
217 );
218 assert!(get_close_matches("exhausted", &cands, 1, 0.72).is_empty());
219 assert!(get_close_matches("", &cands, 1, 0.72).is_empty());
220 }
221
222 #[test]
223 fn ties_break_on_the_later_candidate() {
224 let cands: Vec<String> = vec!["aX".into(), "aY".into()];
227 assert_eq!(get_close_matches("ab", &cands, 1, 0.4), vec!["aY"]);
228 }
229
230 #[test]
231 fn n_limits_the_result() {
232 let cands: Vec<String> = vec!["laugh".into(), "laughing".into(), "cough".into()];
233 assert_eq!(get_close_matches("laughs", &cands, 2, 0.5).len(), 2);
234 assert_eq!(get_close_matches("laughs", &cands, 1, 0.5), vec!["laugh"]);
235 }
236}