soundex.gno
3.30 Kb · 122 lines
1// Package soundex implements the Soundex phonetic algorithm as a pure,
2// reusable package: names that sound alike in English encode to the same
3// four-character key, so "Robert" and "Rupert" both give R163.
4//
5// This is the 1918 Russell/Odell algorithm as used by the US census, with the
6// rules that are usually got wrong implemented explicitly:
7//
8// - the first letter is kept as-is and its digit still suppresses a
9// following consonant of the same code ("Pfister" → P236, not P123);
10// - 'h' and 'w' are TRANSPARENT: consonants either side of them are treated
11// as adjacent ("Ashcraft" → A261, not A226);
12// - vowels are not transparent — they separate, so a repeated code after a
13// vowel is emitted again ("Tymczak" → T522).
14//
15// Soundex is English-centric and lossy by design: it is a *blocking* key for
16// finding candidates, never a proof that two names match.
17//
18// A live demo of this package is at
19// [r/moul/x/daily/soundexdemo](/r/moul/x/daily/soundexdemo/v0).
20package soundex
21
22import "strings"
23
24// code returns the Soundex digit for a letter, or 0 for vowels and h/w/y.
25func code(c byte) byte {
26 switch c {
27 case 'B', 'F', 'P', 'V':
28 return '1'
29 case 'C', 'G', 'J', 'K', 'Q', 'S', 'X', 'Z':
30 return '2'
31 case 'D', 'T':
32 return '3'
33 case 'L':
34 return '4'
35 case 'M', 'N':
36 return '5'
37 case 'R':
38 return '6'
39 }
40 return 0
41}
42
43// upper returns c upper-cased if it is an ASCII letter, else 0.
44func upper(c byte) byte {
45 if c >= 'a' && c <= 'z' {
46 c -= 32
47 }
48 if c >= 'A' && c <= 'Z' {
49 return c
50 }
51 return 0
52}
53
54// Encode returns the four-character Soundex key for s, or "" when s contains
55// no ASCII letters. Non-letters are ignored, so "O'Brien" and "OBrien" agree.
56func Encode(s string) string {
57 // keep letters only
58 letters := make([]byte, 0, len(s))
59 for i := 0; i < len(s); i++ {
60 if c := upper(s[i]); c != 0 {
61 letters = append(letters, c)
62 }
63 }
64 if len(letters) == 0 {
65 return ""
66 }
67
68 out := []byte{letters[0]}
69 prev := code(letters[0]) // the first letter's own code still suppresses
70
71 for i := 1; i < len(letters) && len(out) < 4; i++ {
72 c := letters[i]
73 d := code(c)
74 switch {
75 case d == 0:
76 // 'h' and 'w' are transparent: leave prev alone so the consonants
77 // either side are treated as adjacent. Vowels (and 'y') separate,
78 // so they reset prev and allow the same code to repeat.
79 if c != 'H' && c != 'W' {
80 prev = 0
81 }
82 case d != prev:
83 out = append(out, d)
84 prev = d
85 default:
86 // same code as the previous consonant, and not separated by a
87 // vowel: collapsed.
88 }
89 }
90 for len(out) < 4 {
91 out = append(out, '0')
92 }
93 return string(out)
94}
95
96// Match reports whether two strings share a Soundex key. Empty keys never
97// match, so two inputs with no letters are not "the same name".
98func Match(a, b string) bool {
99 ka, kb := Encode(a), Encode(b)
100 return ka != "" && ka == kb
101}
102
103// EncodeAll returns the keys for each input, in order.
104func EncodeAll(names []string) []string {
105 out := make([]string, 0, len(names))
106 for _, n := range names {
107 out = append(out, Encode(n))
108 }
109 return out
110}
111
112// Normalize upper-cases and strips non-letters — the input Encode actually
113// sees. Exposed so callers can show their work.
114func Normalize(s string) string {
115 var b strings.Builder
116 for i := 0; i < len(s); i++ {
117 if c := upper(s[i]); c != 0 {
118 b.WriteByte(c)
119 }
120 }
121 return b.String()
122}