1#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum Sensitivity {
16 #[default]
18 Base,
19 Accent,
21 Case,
23 Variant,
25}
26
27impl Sensitivity {
28 pub const ALL: [Sensitivity; 4] = [
30 Sensitivity::Base,
31 Sensitivity::Accent,
32 Sensitivity::Case,
33 Sensitivity::Variant,
34 ];
35
36 pub fn label(self) -> &'static str {
38 match self {
39 Sensitivity::Base => "base",
40 Sensitivity::Accent => "accent",
41 Sensitivity::Case => "case",
42 Sensitivity::Variant => "variant",
43 }
44 }
45
46 fn ignores_case(self) -> bool {
47 matches!(self, Sensitivity::Base | Sensitivity::Accent)
48 }
49
50 fn ignores_accents(self) -> bool {
51 matches!(self, Sensitivity::Base | Sensitivity::Case)
52 }
53}
54
55#[derive(Clone, Copy, Debug, Default)]
57pub struct Filter {
58 sensitivity: Sensitivity,
59}
60
61impl Filter {
62 pub fn new(sensitivity: Sensitivity) -> Self {
64 Self { sensitivity }
65 }
66
67 pub fn contains(&self, text: &str, substring: &str) -> bool {
69 self.fold(text).contains(&self.fold(substring))
70 }
71
72 pub fn starts_with(&self, text: &str, prefix: &str) -> bool {
74 self.fold(text).starts_with(&self.fold(prefix))
75 }
76
77 pub fn ends_with(&self, text: &str, suffix: &str) -> bool {
79 self.fold(text).ends_with(&self.fold(suffix))
80 }
81
82 fn fold(&self, s: &str) -> String {
84 let mut out = String::with_capacity(s.len());
85 for ch in s.chars() {
86 let ch = if self.sensitivity.ignores_accents() {
87 unaccent(ch)
88 } else {
89 ch
90 };
91 if self.sensitivity.ignores_case() {
92 out.extend(ch.to_lowercase());
93 } else {
94 out.push(ch);
95 }
96 }
97 out
98 }
99}
100
101fn unaccent(ch: char) -> char {
107 const FOLD: &[(char, char, &str)] = &[
108 ('\u{00C0}', '\u{00C5}', "AAAAAA"),
109 ('\u{00C8}', '\u{00CB}', "EEEE"),
110 ('\u{00CC}', '\u{00CF}', "IIII"),
111 ('\u{00D2}', '\u{00D6}', "OOOOO"),
112 ('\u{00D9}', '\u{00DC}', "UUUU"),
113 ('\u{00E0}', '\u{00E5}', "aaaaaa"),
114 ('\u{00E8}', '\u{00EB}', "eeee"),
115 ('\u{00EC}', '\u{00EF}', "iiii"),
116 ('\u{00F2}', '\u{00F6}', "ooooo"),
117 ('\u{00F9}', '\u{00FC}', "uuuu"),
118 ];
119 match ch {
120 '\u{00C7}' => 'C',
121 '\u{00E7}' => 'c',
122 '\u{00D1}' => 'N',
123 '\u{00F1}' => 'n',
124 '\u{00DD}' | '\u{0178}' => 'Y',
125 '\u{00FD}' | '\u{00FF}' => 'y',
126 '\u{00D8}' => 'O',
127 '\u{00F8}' => 'o',
128 _ => {
129 for (first, last, bases) in FOLD {
130 if ch >= *first && ch <= *last {
131 let at = ch as u32 - *first as u32;
132 if let Some(base) = bases.chars().nth(at as usize) {
133 return base;
134 }
135 }
136 }
137 ch
138 }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn base_ignores_case_and_accents() {
148 let f = Filter::new(Sensitivity::Base);
149 assert!(f.contains("Café", "cafe"));
150 assert!(f.contains("CAFE", "café"));
151 }
152
153 #[test]
154 fn accent_keeps_accents_and_drops_case() {
155 let f = Filter::new(Sensitivity::Accent);
156 assert!(f.contains("Café", "CAFÉ"));
157 assert!(!f.contains("Café", "cafe"));
158 }
159
160 #[test]
161 fn case_keeps_case_and_drops_accents() {
162 let f = Filter::new(Sensitivity::Case);
163 assert!(f.contains("Café", "Cafe"));
164 assert!(!f.contains("Café", "cafe"));
165 }
166
167 #[test]
168 fn variant_keeps_both() {
169 let f = Filter::new(Sensitivity::Variant);
170 assert!(f.contains("Café", "Café"));
171 assert!(!f.contains("Café", "Cafe"));
172 assert!(!f.contains("Café", "café"));
173 }
174
175 #[test]
176 fn starts_and_ends_anchor() {
177 let f = Filter::new(Sensitivity::Base);
178 assert!(f.starts_with("Ångström", "ang"));
179 assert!(!f.starts_with("Ångström", "strom"));
180 assert!(f.ends_with("Ångström", "STROM"));
181 assert!(!f.ends_with("Ångström", "ang"));
182 }
183
184 #[test]
185 fn the_default_is_v3s_base() {
186 assert_eq!(Sensitivity::default(), Sensitivity::Base);
187 assert!(Filter::default().contains("Ñandú", "nandu"));
188 }
189
190 #[test]
191 fn a_letter_outside_the_table_is_its_own_base() {
192 let f = Filter::new(Sensitivity::Base);
193 assert!(f.contains("Δέλτα", "Δέλτα"));
195 assert!(f.contains("東京", "東京"));
196 }
197}