1use std::sync::Arc;
23
24use sea_query::{Alias, Condition, Expr, LikeExpr};
25use unicode_normalization::UnicodeNormalization;
26
27pub const MAX_CANDIDATES: usize = 8;
31
32pub trait Normalizer: Send + Sync + 'static {
37 fn key(&self) -> &'static str;
38 fn fold(&self, text: &str) -> String;
39}
40
41pub struct Lowercase;
43impl Normalizer for Lowercase {
44 fn key(&self) -> &'static str {
45 "case"
46 }
47 fn fold(&self, text: &str) -> String {
48 text.to_lowercase()
49 }
50}
51
52pub struct Diacritics;
55impl Normalizer for Diacritics {
56 fn key(&self) -> &'static str {
57 "diacritics"
58 }
59 fn fold(&self, text: &str) -> String {
60 text.nfkd()
61 .filter(|c| !is_combining_mark(*c))
62 .collect::<String>()
63 }
64}
65
66fn is_combining_mark(c: char) -> bool {
69 matches!(c as u32,
70 0x0300..=0x036F | 0x0483..=0x0489
72 | 0x0591..=0x05BD
73 | 0x0610..=0x061A
74 | 0x064B..=0x065F
75 | 0x0670
76 | 0x06D6..=0x06DC
77 | 0x0711
78 | 0x0730..=0x074A
79 | 0x07A6..=0x07B0
80 | 0x0900..=0x0903 | 0x093A..=0x094F
82 | 0x0951..=0x0957
83 | 0x0962..=0x0963
84 | 0x0981..=0x0983
85 | 0x09BC..=0x09CD
86 | 0x0A01..=0x0A03
87 | 0x0A3C..=0x0A4D
88 | 0x0C00..=0x0C04 | 0x0C3E..=0x0C56
90 | 0x0C81..=0x0C83 | 0x0CBC..=0x0CD6
92 | 0x1AB0..=0x1AFF
93 | 0x1DC0..=0x1DFF
94 | 0x20D0..=0x20F0
95 | 0xFE20..=0xFE2F)
96}
97
98pub struct Punctuation;
101impl Normalizer for Punctuation {
102 fn key(&self) -> &'static str {
103 "punctuation"
104 }
105 fn fold(&self, text: &str) -> String {
106 text.chars()
107 .filter(|c| !c.is_ascii_punctuation() && !matches!(c, '\u{2018}'..='\u{201F}'))
108 .collect()
109 }
110}
111
112pub struct Whitespace;
114impl Normalizer for Whitespace {
115 fn key(&self) -> &'static str {
116 "whitespace"
117 }
118 fn fold(&self, text: &str) -> String {
119 text.split_whitespace().collect::<Vec<_>>().join(" ")
120 }
121}
122
123#[derive(Clone, Default)]
130pub struct NormalizerChain {
131 folds: Vec<Arc<dyn Normalizer>>,
132}
133
134impl NormalizerChain {
135 pub fn new() -> Self {
136 Self::default()
137 }
138
139 pub fn with(mut self, fold: impl Normalizer) -> Self {
140 self.folds.push(Arc::new(fold));
141 self
142 }
143
144 pub fn standard() -> Self {
147 Self::new()
148 .with(Lowercase)
149 .with(Diacritics)
150 .with(Punctuation)
151 .with(Whitespace)
152 }
153
154 pub fn fold(&self, text: &str) -> String {
155 self.folds
156 .iter()
157 .fold(text.to_string(), |acc, f| f.fold(&acc))
158 }
159
160 pub fn fingerprint(&self) -> String {
163 self.folds
164 .iter()
165 .map(|f| f.key())
166 .collect::<Vec<_>>()
167 .join("+")
168 }
169
170 pub fn is_empty(&self) -> bool {
171 self.folds.is_empty()
172 }
173}
174
175pub trait QueryExpander: Send + Sync + 'static {
178 fn expand(&self, folded_q: &str) -> Vec<String>;
180}
181
182#[derive(Default)]
185pub struct Aliases {
186 pairs: Vec<(String, String)>,
187}
188
189impl Aliases {
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 pub fn pair(mut self, a: &str, b: &str) -> Self {
197 self.pairs.push((a.to_string(), b.to_string()));
198 self
199 }
200}
201
202impl QueryExpander for Aliases {
203 fn expand(&self, folded_q: &str) -> Vec<String> {
204 let mut out = Vec::new();
205 for (a, b) in &self.pairs {
206 if a == folded_q {
207 out.push(b.clone());
208 } else if b == folded_q {
209 out.push(a.clone());
210 }
211 }
212 out
213 }
214}
215
216pub struct Numerals;
219
220const NUMBER_WORDS: [(&str, &str); 20] = [
221 ("zero", "0"),
222 ("one", "1"),
223 ("two", "2"),
224 ("three", "3"),
225 ("four", "4"),
226 ("five", "5"),
227 ("six", "6"),
228 ("seven", "7"),
229 ("eight", "8"),
230 ("nine", "9"),
231 ("ten", "10"),
232 ("eleven", "11"),
233 ("twelve", "12"),
234 ("thirteen", "13"),
235 ("fourteen", "14"),
236 ("fifteen", "15"),
237 ("sixteen", "16"),
238 ("seventeen", "17"),
239 ("eighteen", "18"),
240 ("nineteen", "19"),
241];
242
243impl QueryExpander for Numerals {
244 fn expand(&self, folded_q: &str) -> Vec<String> {
245 let mut out = Vec::new();
246 for (word, digit) in NUMBER_WORDS {
247 let swapped_to_digit = swap_token(folded_q, word, digit);
250 if swapped_to_digit != folded_q {
251 out.push(swapped_to_digit);
252 }
253 let swapped_to_word = swap_token(folded_q, digit, word);
254 if swapped_to_word != folded_q {
255 out.push(swapped_to_word);
256 }
257 }
258 out
259 }
260}
261
262fn swap_token(text: &str, from: &str, to: &str) -> String {
264 text.split(' ')
265 .map(|t| if t == from { to } else { t })
266 .collect::<Vec<_>>()
267 .join(" ")
268}
269
270pub struct MatchCx<'a> {
273 pub column: &'a str,
274 pub candidates: &'a [String],
275}
276
277pub trait Matcher: Send + Sync + 'static {
282 fn key(&self) -> &'static str;
283 fn applies(&self, caps: &crate::capabilities::CapabilitySet) -> bool {
287 let _ = caps;
288 true
289 }
290 fn condition(&self, cx: &MatchCx<'_>) -> Condition;
291}
292
293pub struct LikeMatcher;
296
297impl Matcher for LikeMatcher {
298 fn key(&self) -> &'static str {
299 "like"
300 }
301 fn condition(&self, cx: &MatchCx<'_>) -> Condition {
302 let mut any = Condition::any();
303 for term in cx.candidates {
304 any = any.add(
305 Expr::expr(sea_query::Func::lower(Expr::col(Alias::new(cx.column))))
306 .like(LikeExpr::new(format!("%{term}%")).escape('\\')),
307 );
308 }
309 any
310 }
311}
312
313pub fn like_escape(text: &str) -> String {
315 let mut out = String::with_capacity(text.len());
316 for c in text.chars() {
317 if matches!(c, '\\' | '%' | '_') {
318 out.push('\\');
319 }
320 out.push(c);
321 }
322 out
323}
324
325pub struct SearchProfile {
328 chain: NormalizerChain,
329 expanders: Vec<Arc<dyn QueryExpander>>,
330 matchers: Vec<Arc<dyn Matcher>>,
331}
332
333impl Default for SearchProfile {
334 fn default() -> Self {
335 Self::new()
336 }
337}
338
339impl SearchProfile {
340 pub fn new() -> Self {
342 Self {
343 chain: NormalizerChain::new().with(Lowercase),
344 expanders: Vec::new(),
345 matchers: vec![Arc::new(LikeMatcher)],
346 }
347 }
348
349 pub fn folding() -> Self {
356 Self::new().with_chain(NormalizerChain::standard())
357 }
358
359 pub fn with_chain(mut self, chain: NormalizerChain) -> Self {
360 self.chain = chain;
361 self
362 }
363
364 pub fn with_expander(mut self, expander: impl QueryExpander) -> Self {
365 self.expanders.push(Arc::new(expander));
366 self
367 }
368
369 pub fn with_matcher(mut self, matcher: impl Matcher) -> Self {
372 self.matchers.insert(0, Arc::new(matcher));
373 self
374 }
375
376 pub fn chain(&self) -> &NormalizerChain {
377 &self.chain
378 }
379
380 pub fn fold(&self, text: &str) -> String {
381 self.chain.fold(text)
382 }
383
384 pub fn candidates(&self, q: &str) -> Vec<String> {
388 let folded = self.chain.fold(q.trim());
391 if folded.is_empty() {
392 return Vec::new();
393 }
394 let mut terms = vec![folded.clone()];
395 for expander in &self.expanders {
396 for alt in expander.expand(&folded) {
397 if !alt.is_empty() && !terms.contains(&alt) {
398 terms.push(alt);
399 }
400 }
401 }
402 terms.truncate(MAX_CANDIDATES);
403 terms.iter().map(|t| like_escape(t)).collect()
404 }
405
406 pub fn matcher(&self, caps: &crate::capabilities::CapabilitySet) -> &dyn Matcher {
410 for matcher in &self.matchers {
411 if matcher.applies(caps) {
412 return matcher.as_ref();
413 }
414 }
415 self.matchers
417 .last()
418 .expect("a profile always carries a matcher")
419 .as_ref()
420 }
421
422 pub fn condition(
429 &self,
430 caps: &crate::capabilities::CapabilitySet,
431 column: &str,
432 q: &str,
433 ) -> Option<Condition> {
434 let candidates = self.candidates(q);
435 if candidates.is_empty() {
436 return None;
437 }
438 Some(self.matcher(caps).condition(&MatchCx {
439 column,
440 candidates: &candidates,
441 }))
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn case_folds() {
451 assert_eq!(Lowercase.fold("ÁVILA Straße"), "ávila straße");
452 }
453
454 #[test]
455 fn diacritics_fold_to_their_base_letters() {
456 assert_eq!(Diacritics.fold("Ávila"), "Avila");
457 assert_eq!(Diacritics.fold("café"), "cafe");
458 assert_eq!(Diacritics.fold("Ünïcôdé"), "Unicode");
459 }
460
461 #[test]
463 fn compatibility_forms_decompose() {
464 assert_eq!(Diacritics.fold("file"), "file");
465 assert_eq!(Diacritics.fold("½"), "1⁄2");
466 }
467
468 #[test]
471 fn indic_text_keeps_its_base_characters() {
472 let kannada = "ಬೆಂಗಳೂರು";
473 let folded = Diacritics.fold(kannada);
474 assert!(folded.starts_with('ಬ'), "base letters survive: {folded}");
475 assert!(folded.chars().count() < kannada.chars().count());
476 }
477
478 #[test]
479 fn punctuation_folds_away() {
480 assert_eq!(Punctuation.fold("watson's"), "watsons");
481 assert_eq!(Punctuation.fold("jean-luc"), "jeanluc");
482 assert_eq!(Punctuation.fold("o\u{2019}brien"), "obrien");
483 }
484
485 #[test]
486 fn whitespace_collapses() {
487 assert_eq!(Whitespace.fold(" two words \n"), "two words");
488 }
489
490 #[test]
491 fn the_standard_chain_applies_every_fold() {
492 let chain = NormalizerChain::standard();
493 assert_eq!(chain.fold(" Ávila's Café "), "avilas cafe");
494 }
495
496 #[test]
497 fn a_fingerprint_names_the_chain_in_order() {
498 assert_eq!(
499 NormalizerChain::standard().fingerprint(),
500 "case+diacritics+punctuation+whitespace"
501 );
502 let reversed = NormalizerChain::new().with(Punctuation).with(Lowercase);
504 assert_eq!(reversed.fingerprint(), "punctuation+case");
505 }
506
507 #[test]
508 fn numerals_swap_whole_tokens_both_ways() {
509 let out = Numerals.expand("7 rivers");
510 assert!(out.contains(&"seven rivers".to_string()));
511 let out = Numerals.expand("seven rivers");
512 assert!(out.contains(&"7 rivers".to_string()));
513 }
514
515 #[test]
517 fn numerals_leave_words_containing_a_number_word_alone() {
518 assert!(Numerals.expand("money").is_empty());
519 assert!(Numerals.expand("nineteenth").is_empty());
520 assert!(Numerals.expand("someone").is_empty());
521 }
522
523 #[test]
524 fn aliases_expand_both_directions() {
525 let aliases = Aliases::new().pair("bengaluru", "bangalore");
526 assert_eq!(aliases.expand("bengaluru"), ["bangalore"]);
527 assert_eq!(aliases.expand("bangalore"), ["bengaluru"]);
528 assert!(aliases.expand("mysuru").is_empty());
529 }
530
531 #[test]
532 fn like_metacharacters_are_escaped() {
533 assert_eq!(like_escape("100%"), "100\\%");
534 assert_eq!(like_escape("a_b"), "a\\_b");
535 assert_eq!(like_escape("back\\slash"), "back\\\\slash");
536 }
537
538 #[test]
539 fn candidates_fold_expand_and_escape() {
540 let profile = SearchProfile::folding().with_expander(Numerals);
541 let out = profile.candidates(" 7 Rívers ");
542 assert_eq!(out[0], "7 rivers", "the folded query comes first");
543 assert!(out.contains(&"seven rivers".to_string()));
544 }
545
546 #[test]
547 fn candidates_escape_a_wildcard_in_the_query() {
548 let out = SearchProfile::new().candidates("100%");
549 assert_eq!(out, ["100\\%"]);
550 }
551
552 #[test]
553 fn an_empty_query_yields_no_candidates() {
554 assert!(SearchProfile::folding().candidates(" ").is_empty());
555 assert!(SearchProfile::folding().candidates("").is_empty());
556 assert!(SearchProfile::folding().candidates("!!!").is_empty());
558 assert!(SearchProfile::new().candidates(" ").is_empty());
560 }
561
562 #[test]
563 fn candidates_are_capped() {
564 struct Noisy;
566 impl QueryExpander for Noisy {
567 fn expand(&self, _q: &str) -> Vec<String> {
568 (0..50).map(|i| format!("alt{i}")).collect()
569 }
570 }
571 let out = SearchProfile::new().with_expander(Noisy).candidates("q");
572 assert_eq!(out.len(), MAX_CANDIDATES);
573 }
574
575 #[test]
576 fn duplicate_expansions_are_dropped() {
577 struct Same;
578 impl QueryExpander for Same {
579 fn expand(&self, q: &str) -> Vec<String> {
580 vec![q.to_string(), "other".to_string()]
581 }
582 }
583 let out = SearchProfile::new().with_expander(Same).candidates("q");
584 assert_eq!(out, ["q", "other"]);
585 }
586
587 #[test]
588 fn the_default_matcher_is_like() {
589 let caps = crate::capabilities::CapabilitySet::default();
590 assert_eq!(SearchProfile::new().matcher(&caps).key(), "like");
591 }
592
593 #[test]
594 fn a_matcher_that_does_not_apply_is_skipped() {
595 struct NeedsExtension;
596 impl Matcher for NeedsExtension {
597 fn key(&self) -> &'static str {
598 "trigram"
599 }
600 fn applies(&self, caps: &crate::capabilities::CapabilitySet) -> bool {
601 caps.has("pg_trgm")
602 }
603 fn condition(&self, _cx: &MatchCx<'_>) -> Condition {
604 Condition::any()
605 }
606 }
607 let profile = SearchProfile::new().with_matcher(NeedsExtension);
608 let bare = crate::capabilities::CapabilitySet::default();
609 assert_eq!(
610 profile.matcher(&bare).key(),
611 "like",
612 "falls back, not errors"
613 );
614 }
615
616 #[test]
617 fn no_condition_for_a_query_that_folds_away() {
618 let caps = crate::capabilities::CapabilitySet::default();
619 assert!(SearchProfile::folding()
620 .condition(&caps, "name", " ")
621 .is_none());
622 }
623}