1pub const MAX_CODE_LENGTH: usize = 4;
30
31pub(crate) const ENCODER_VERSION: u32 = 1;
33
34const fn is_vowel(value: u8) -> bool {
35 matches!(value, b'A' | b'E' | b'I' | b'O' | b'U' | b'Y')
36}
37
38fn normalize<E>(
40 term: &str,
41 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
42) -> Result<Vec<u8>, E> {
43 let mut word = Vec::new();
44 for value in term.chars() {
45 work.step()?;
46 #[cfg(test)]
47 NORMALIZE_PROBES.with(|count| count.set(count.get() + 1));
48 if value.is_ascii_alphabetic() {
49 word.push(value.to_ascii_uppercase() as u8);
50 }
51 }
52 Ok(word)
53}
54
55fn at(word: &[u8], index: usize) -> u8 {
56 word.get(index).copied().unwrap_or(0)
57}
58
59fn starts_with_at(word: &[u8], index: usize, text: &str) -> bool {
60 word.get(index..index + text.len())
61 .is_some_and(|slice| slice == text.as_bytes())
62}
63
64#[must_use]
70pub fn encode(term: &str) -> String {
71 let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
72 match encode_controlled(term, &mut work) {
73 Ok(code) => code,
74 Err(never) => match never {},
75 }
76}
77
78pub(crate) fn encode_controlled<E>(
79 term: &str,
80 work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
81) -> Result<String, E> {
82 work.check_now()?;
83 #[cfg(any(test, feature = "test-seams"))]
84 super::preparation_observer::phonetic_encoding();
85 let word = normalize(term, work)?;
86 if word.is_empty() {
87 return Ok(String::new());
88 }
89 let mut code = String::with_capacity(MAX_CODE_LENGTH);
90 let length = word.len();
91 let mut index = 0_usize;
92
93 if length >= 2 {
95 match word.get(..2).unwrap_or_default() {
96 b"GN" | b"KN" | b"PN" | b"WR" | b"PS" => index = 1,
97 b"AE" => index = 1,
98 _ => {}
99 }
100 }
101 if at(&word, 0) == b'X' {
103 code.push('S');
104 index = 1;
105 }
106
107 while index < length && code.len() < MAX_CODE_LENGTH {
108 work.step()?;
109 #[cfg(test)]
110 CODE_PROBES.with(|count| count.set(count.get() + 1));
111 let current = at(&word, index);
112 let next = at(&word, index + 1);
113 let mut step = 1_usize;
114
115 match current {
116 value if is_vowel(value) => {
117 if index == 0 {
119 code.push('A');
120 }
121 }
122 b'B' => {
123 code.push('P');
124 if next == b'B' {
125 step = 2;
126 }
127 }
128 b'C' => {
129 if starts_with_at(&word, index, "CIA") {
130 code.push('X');
131 step = 3;
132 } else if starts_with_at(&word, index, "CH") {
133 if starts_with_at(&word, index, "CHR")
137 || index == 0 && starts_with_at(&word, index, "CHA")
138 {
139 code.push('K');
140 } else {
141 code.push('X');
142 }
143 step = 2;
144 } else if starts_with_at(&word, index, "CK") {
145 code.push('K');
146 step = 2;
147 } else if matches!(next, b'I' | b'E' | b'Y') {
148 code.push('S');
149 step = 2;
150 } else {
151 code.push('K');
152 if next == b'C' {
153 step = 2;
154 }
155 }
156 }
157 b'D' => {
158 if starts_with_at(&word, index, "DG") {
159 if matches!(at(&word, index + 2), b'I' | b'E' | b'Y') {
160 code.push('J');
161 step = 3;
162 } else {
163 code.push('T');
164 step = 2;
165 }
166 } else {
167 code.push('T');
168 if next == b'D' || next == b'T' {
169 step = 2;
170 }
171 }
172 }
173 b'F' => {
174 code.push('F');
175 if next == b'F' {
176 step = 2;
177 }
178 }
179 b'G' => {
180 if next == b'H' {
181 if index > 0 && is_vowel(at(&word, index.saturating_sub(1))) {
184 step = 2;
185 } else {
186 code.push('K');
187 step = 2;
188 }
189 } else if next == b'N' {
190 step = 2;
192 } else if matches!(next, b'I' | b'E' | b'Y') {
193 code.push('J');
194 step = 2;
195 } else {
196 code.push('K');
197 if next == b'G' {
198 step = 2;
199 }
200 }
201 }
202 b'H' => {
203 let previous_vowel = index > 0 && is_vowel(at(&word, index.saturating_sub(1)));
205 if (index == 0 || previous_vowel) && is_vowel(next) {
206 code.push('H');
207 }
208 }
209 b'J' => {
210 code.push('J');
211 if next == b'J' {
212 step = 2;
213 }
214 }
215 b'K' => {
216 if next != b'K' {
217 code.push('K');
218 } else {
219 code.push('K');
220 step = 2;
221 }
222 }
223 b'L' => {
224 code.push('L');
225 if next == b'L' {
226 step = 2;
227 }
228 }
229 b'M' => {
230 code.push('M');
231 if next == b'M' {
232 step = 2;
233 }
234 }
235 b'N' => {
236 code.push('N');
237 if next == b'N' {
238 step = 2;
239 }
240 }
241 b'P' => {
242 if next == b'H' {
243 code.push('F');
244 step = 2;
245 } else {
246 code.push('P');
247 if next == b'P' || next == b'B' {
248 step = 2;
249 }
250 }
251 }
252 b'Q' => {
253 code.push('K');
254 if next == b'Q' {
255 step = 2;
256 }
257 }
258 b'R' => {
259 code.push('R');
260 if next == b'R' {
261 step = 2;
262 }
263 }
264 b'S' => {
265 if starts_with_at(&word, index, "SCH") {
266 code.push('X');
268 step = 3;
269 } else if starts_with_at(&word, index, "SH") {
270 code.push('X');
271 step = 2;
272 } else if starts_with_at(&word, index, "SIO") || starts_with_at(&word, index, "SIA")
273 {
274 code.push('X');
275 step = 3;
276 } else {
277 code.push('S');
278 if next == b'S' {
279 step = 2;
280 }
281 }
282 }
283 b'T' => {
284 if starts_with_at(&word, index, "TIO") || starts_with_at(&word, index, "TIA") {
285 code.push('X');
286 step = 3;
287 } else if starts_with_at(&word, index, "TH") {
288 if starts_with_at(&word, index + 2, "OM")
292 || starts_with_at(&word, index + 2, "AM")
293 {
294 code.push('T');
295 } else {
296 code.push('0');
297 }
298 step = 2;
299 } else {
300 code.push('T');
301 if next == b'T' || next == b'D' {
302 step = 2;
303 }
304 }
305 }
306 b'V' => {
307 code.push('F');
308 if next == b'V' {
309 step = 2;
310 }
311 }
312 b'W' => {
313 if is_vowel(next) {
315 code.push('W');
316 }
317 }
318 b'X' => {
319 code.push('K');
320 if code.len() < MAX_CODE_LENGTH {
321 code.push('S');
322 }
323 if next == b'X' {
324 step = 2;
325 }
326 }
327 b'Z' => {
328 code.push('S');
329 if next == b'Z' {
330 step = 2;
331 }
332 }
333 _ => {}
334 }
335 index = index.saturating_add(step);
336 }
337
338 code.truncate(MAX_CODE_LENGTH);
339 work.check_now()?;
340 Ok(code)
341}
342
343#[cfg(test)]
344thread_local! {
345 static NORMALIZE_PROBES:std::cell::Cell<usize>=const {std::cell::Cell::new(0)};
346 static CODE_PROBES:std::cell::Cell<usize>=const {std::cell::Cell::new(0)};
347}
348
349#[cfg(test)]
350#[allow(
351 clippy::expect_used,
352 clippy::indexing_slicing,
353 clippy::panic,
354 clippy::unwrap_used
355)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn astra_18_phonetic_encoding_cancels_inside_long_terms() {
361 let mut results = Vec::new();
362 for (term, phase, expected) in [("1".repeat(4_096), 0, ""), ("a".repeat(4_096), 1, "A")] {
363 assert_eq!(encode(&term), expected);
364 NORMALIZE_PROBES.with(|count| count.set(0));
365 CODE_PROBES.with(|count| count.set(0));
366 let probes = || {
367 if phase == 0 {
368 NORMALIZE_PROBES.with(std::cell::Cell::get)
369 } else {
370 CODE_PROBES.with(std::cell::Cell::get)
371 }
372 };
373 let mut work = super::super::control::WorkCheck::new(|| {
374 if probes() >= 64 {
375 Err("cancelled")
376 } else {
377 Ok(())
378 }
379 });
380 let result = encode_controlled(&term, &mut work);
381 println!("phase={phase}, probes={}", probes());
382 results.push((matches!(result, Err("cancelled")), probes()));
383 let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
384 assert_eq!(encode_controlled(&term, &mut work), Ok(expected.to_owned()));
385 }
386 for (cancelled, probes) in results {
387 assert!(cancelled);
388 assert!(probes <= 128);
389 }
390 }
391
392 const NAME_LIST: [(&str, &str); 24] = [
410 ("Smith", "SM0"),
411 ("Schmidt", "XMT"),
412 ("Schneider", "XNTR"),
413 ("Thompson", "TMPS"),
414 ("Jackson", "JKSN"),
415 ("Knight", "NT"),
416 ("Wright", "RT"),
417 ("Gnome", "NM"),
418 ("Psycho", "SX"),
419 ("Philip", "FLP"),
420 ("Christopher", "KRST"),
421 ("Church", "XRX"),
422 ("Miller", "MLR"),
423 ("Anderson", "ANTR"),
424 ("Robertson", "RPRT"),
425 ("Nelson", "NLSN"),
426 ("Ghost", "KST"),
427 ("Through", "0R"),
428 ("Bell", "PL"),
429 ("Kelly", "KL"),
430 ("Fitzgerald", "FTSJ"),
431 ("Xavier", "SFR"),
432 ("Quinn", "KN"),
433 ("Zimmerman", "SMRM"),
434 ];
435
436 #[test]
437 fn double_metaphone_encodings_match_the_committed_name_list() {
438 for (name, expected) in NAME_LIST {
439 assert_eq!(encode(name), expected, "encoding of {name:?}");
440 }
441 }
442
443 #[test]
444 fn homophone_surnames_collapse_together() {
445 assert_eq!(encode("Smith"), encode("Smyth"));
447 assert_eq!(encode("Anderson"), encode("Andersen"));
448 assert_eq!(encode("Nelson"), encode("Nelsen"));
449 }
450
451 #[test]
452 fn encoding_is_case_and_punctuation_insensitive() {
453 assert_eq!(encode("Smith"), encode("SMITH"));
454 assert_eq!(encode("Smith"), encode("smith"));
455 assert_eq!(encode("O'Brien"), encode("OBrien"));
456 assert_eq!(encode("Fitz-Gerald"), encode("FitzGerald"));
457 }
458
459 #[test]
460 fn a_code_never_exceeds_the_documented_length() {
461 for (name, _) in NAME_LIST {
462 assert!(encode(name).len() <= MAX_CODE_LENGTH, "{name} overflowed");
463 }
464 assert!(encode("Supercalifragilisticexpialidocious").len() <= MAX_CODE_LENGTH);
465 }
466
467 #[test]
468 fn a_term_with_no_letters_encodes_to_nothing() {
469 assert_eq!(encode(""), "");
472 assert_eq!(encode("12345"), "");
473 assert_eq!(encode("---"), "");
474 assert_eq!(encode("\u{4E2D}\u{6587}"), "");
475 }
476
477 #[test]
478 fn encoding_is_deterministic() {
479 for (name, _) in NAME_LIST {
480 let first = encode(name);
481 for _ in 0..4 {
482 assert_eq!(encode(name), first);
483 }
484 }
485 }
486
487 #[test]
488 fn encoding_never_panics_on_odd_input() {
489 for term in [
490 "",
491 "'",
492 "AE",
493 "GN",
494 "X",
495 "XX",
496 "SCH",
497 "TH",
498 "\u{1F680}",
499 "a".repeat(500).as_str(),
500 ] {
501 let _ = encode(term);
502 }
503 }
504}