Files
kjv_api/internal/kjv/spell.go
T
2026-10-26 20:30:43 -07:00

254 lines
5.6 KiB
Go

package kjv
import (
"sort"
"strings"
"unicode"
)
// Speller checks and suggests spellings from kjv.dic (a plain word list) and
// kjv.aff (which only adds a TRY order and REP replacement hints), so no C
// library is needed.
type Speller struct {
words map[string]bool // exact entries
byLower map[string][]string // lowercase -> entries
list []string // lowercase entries, dictionary order, unique
try []rune
rep [][2]string
}
func NewSpeller(dic, aff []byte) *Speller {
s := &Speller{words: map[string]bool{}, byLower: map[string][]string{}}
lines := strings.Split(string(dic), "\n")
seen := map[string]bool{}
for i, line := range lines {
line = strings.TrimSpace(line)
if i == 0 || line == "" { // first line is the entry count
continue
}
if k := strings.IndexByte(line, '/'); k >= 0 {
line = line[:k]
}
s.words[line] = true
low := strings.ToLower(line)
s.byLower[low] = append(s.byLower[low], line)
if !seen[low] {
seen[low] = true
s.list = append(s.list, low)
}
}
for _, line := range strings.Split(string(aff), "\n") {
f := strings.Fields(line)
switch {
case len(f) == 2 && f[0] == "TRY":
s.try = []rune(f[1])
case len(f) == 3 && f[0] == "REP":
s.rep = append(s.rep, [2]string{f[1], strings.ReplaceAll(f[2], "_", " ")})
}
}
return s
}
func stripSyntax(w string) string {
return strings.Map(func(r rune) rune {
if r == '+' || r == '^' || r == '*' {
return -1
}
return r
}, w)
}
func hasLetter(w string) bool {
for _, r := range w {
if unicode.IsLetter(r) {
return true
}
}
return false
}
func (s *Speller) Spell(word string) bool {
word = stripSyntax(word)
if !hasLetter(word) || s.words[word] {
return true
}
low := strings.ToLower(word)
if low != word && (word == strings.ToUpper(word) || word == capitalize(low)) {
return len(s.byLower[low]) > 0
}
return false
}
func capitalize(w string) string {
r := []rune(w)
if len(r) == 0 {
return w
}
return strings.ToUpper(string(r[:1])) + strings.ToLower(string(r[1:]))
}
func (s *Speller) known(low string) []string { return s.byLower[low] }
// Suggest returns likely corrections, best first.
func (s *Speller) Suggest(word string) []string {
word = stripSyntax(word)
low := strings.ToLower(word)
if low == "" {
return nil
}
var out []string
seen := map[string]bool{}
add := func(cands ...string) {
for _, c := range cands {
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
}
addLower := func(l string) { // dictionary form(s) of a lowercase candidate
if l == low {
return
}
add(s.known(l)...)
}
if low != word { // wrong capitalization of a known word
add(s.known(low)...)
}
// replacement hints from the .aff file, applied at each occurrence
for _, r := range s.rep {
for at := 0; ; {
i := strings.Index(low[at:], r[0])
if i < 0 {
break
}
i += at
cand := low[:i] + r[1] + low[i+len(r[0]):]
if strings.Contains(cand, " ") {
if p := strings.Fields(cand); len(p) == 2 && len(s.known(p[0])) > 0 && len(s.known(p[1])) > 0 {
add(p[0] + " " + p[1])
}
} else {
addLower(cand)
}
at = i + 1
}
}
rs := []rune(low)
// one edit away: swap, delete, replace, insert
for i := 0; i+1 < len(rs); i++ {
c := append([]rune(nil), rs...)
c[i], c[i+1] = c[i+1], c[i]
addLower(string(c))
}
for i := range rs {
addLower(string(rs[:i]) + string(rs[i+1:]))
}
for i := range rs {
for _, t := range s.try {
addLower(string(rs[:i]) + string(t) + string(rs[i+1:]))
}
}
for i := 0; i <= len(rs); i++ {
for _, t := range s.try {
addLower(string(rs[:i]) + string(t) + string(rs[i:]))
}
}
// two words run together ("lordgod")
for i := 1; i < len(rs); i++ {
a, b := string(rs[:i]), string(rs[i:])
if len(a) > 1 && len(b) > 1 && len(s.known(a)) > 0 && len(s.known(b)) > 0 {
add(a + " " + b)
}
}
// still little to offer: anything within two edits, closest first
if len(out) < 3 {
type cand struct {
w string
dist int
pre int
}
var cs []cand
for _, w := range s.list {
if abs(len(w)-len(low)) > 2 || w == low {
continue
}
if d := editDistance(low, w, 2); d <= 2 {
cs = append(cs, cand{w, d, commonPrefix(low, w)})
}
}
sort.SliceStable(cs, func(i, j int) bool {
if cs[i].dist != cs[j].dist {
return cs[i].dist < cs[j].dist
}
return cs[i].pre > cs[j].pre
})
for _, c := range cs {
addLower(c.w)
if len(out) >= 8 {
break
}
}
}
if len(out) > 10 {
out = out[:10]
}
if word == strings.ToUpper(word) && len(word) > 1 {
for i := range out {
out[i] = strings.ToUpper(out[i])
}
} else if word == capitalize(low) && word != low {
for i := range out {
out[i] = capitalize(out[i])
}
}
return out
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func commonPrefix(a, b string) int {
n := 0
for n < len(a) && n < len(b) && a[n] == b[n] {
n++
}
return n
}
// editDistance is optimal-string-alignment distance, giving up above limit.
func editDistance(a, b string, limit int) int {
ra, rb := []rune(a), []rune(b)
prev2 := make([]int, len(rb)+1)
prev := make([]int, len(rb)+1)
cur := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur[0] = i
best := cur[0]
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
v := min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
if i > 1 && j > 1 && ra[i-1] == rb[j-2] && ra[i-2] == rb[j-1] {
v = min(v, prev2[j-2]+1)
}
cur[j] = v
best = min(best, v)
}
if best > limit {
return limit + 1
}
prev2, prev, cur = prev, cur, prev2
}
return prev[len(rb)]
}