421 lines
11 KiB
Go
421 lines
11 KiB
Go
package spell
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
type letterCase int
|
|
|
|
const (
|
|
lowerCase letterCase = iota
|
|
titleCase // "Paris"
|
|
upperCase // "PARIS"
|
|
mixedCase // "iPhone", "McDonald"
|
|
)
|
|
|
|
func caseOf(w string) letterCase {
|
|
upper, lower, firstUpper := 0, 0, false
|
|
for i, r := range w {
|
|
switch {
|
|
case unicode.IsUpper(r):
|
|
upper++
|
|
if i == 0 {
|
|
firstUpper = true
|
|
}
|
|
case unicode.IsLower(r):
|
|
lower++
|
|
}
|
|
}
|
|
switch {
|
|
case upper == 0:
|
|
return lowerCase
|
|
case lower == 0:
|
|
return upperCase
|
|
case upper == 1 && firstUpper:
|
|
return titleCase
|
|
}
|
|
return mixedCase
|
|
}
|
|
|
|
func capitalize(w string) string {
|
|
r, size := utf8.DecodeRuneInString(w)
|
|
return string(unicode.ToTitle(r)) + w[size:]
|
|
}
|
|
|
|
// Check reports whether word is spelled correctly. As in Hunspell, a
|
|
// capitalised or all-caps word is also accepted in its lower-case form
|
|
// ("Door", "DOOR"), but a lower-case proper noun is not ("paris").
|
|
func (d *Dictionary) Check(word string) bool {
|
|
w := d.iconv.Replace(word)
|
|
if d.accepts(w, false) {
|
|
return true
|
|
}
|
|
switch caseOf(w) {
|
|
case titleCase:
|
|
return d.accepts(strings.ToLower(w), true)
|
|
case upperCase:
|
|
lower := strings.ToLower(w)
|
|
return d.accepts(capitalize(lower), true) || d.accepts(lower, true)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// accepts checks one exact spelling, as a word or as a compound word.
|
|
func (d *Dictionary) accepts(w string, caseChanged bool) bool {
|
|
if d.valid(w, caseChanged) || (d.compound.enabled() && d.compoundOf(w, 0, "", caseChanged)) {
|
|
return true
|
|
}
|
|
for _, r := range d.compound.rules {
|
|
if d.matchesRule(w, r, 0, 0, caseChanged) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchesRule reports whether w splits into dictionary words (without
|
|
// affixes, as in Hunspell) whose flags follow rule from step i; parts
|
|
// counts the words already matched.
|
|
func (d *Dictionary) matchesRule(w string, rule compoundRule, i, parts int, cc bool) bool {
|
|
if i < len(rule) && rule[i].rep != 0 && d.matchesRule(w, rule, i+1, parts, cc) {
|
|
return true // an optional step left out
|
|
}
|
|
if w == "" {
|
|
return i == len(rule) && parts >= 2
|
|
}
|
|
if i == len(rule) {
|
|
return false
|
|
}
|
|
step := rule[i]
|
|
next := i + 1
|
|
if step.rep == '*' {
|
|
next = i
|
|
}
|
|
runes := []rune(w)
|
|
for n := d.compound.min; n <= len(runes); n++ {
|
|
part := string(runes[:n])
|
|
if d.isForbidden(part) || !d.stemHas(part, cc, func(f flagSet) bool { return f.has(step.flag) }) {
|
|
continue
|
|
}
|
|
if d.matchesRule(string(runes[n:]), rule, next, parts+1, cc) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Where a part sits in a compound word.
|
|
const (
|
|
partBegin = iota
|
|
partMiddle
|
|
partLast
|
|
)
|
|
|
|
// compoundOf reports whether w splits into compound parts, the first of
|
|
// which follows parts earlier ones (prev is the last of them).
|
|
func (d *Dictionary) compoundOf(w string, parts int, prev string, cc bool) bool {
|
|
c := d.compound
|
|
runes := []rune(w)
|
|
fits := func(n int) bool { return c.maxWords == 0 || n <= c.maxWords }
|
|
for i := c.min; i <= len(runes)-c.min; i++ {
|
|
head, rest := string(runes[:i]), string(runes[i:])
|
|
if c.checkDup && head == prev {
|
|
continue
|
|
}
|
|
if c.checkCase && (unicode.IsUpper(runes[i]) || unicode.IsUpper(runes[i-1])) {
|
|
continue
|
|
}
|
|
pos := partBegin
|
|
if parts > 0 {
|
|
pos = partMiddle
|
|
}
|
|
if !d.compoundPart(head, pos, cc) {
|
|
continue
|
|
}
|
|
if fits(parts+2) && !(c.checkDup && rest == head) && d.compoundPart(rest, partLast, cc) {
|
|
return true
|
|
}
|
|
if fits(parts+3) && d.compoundOf(rest, parts+1, head, cc) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// compoundPart checks one part of a compound word. The first part may take
|
|
// a prefix and the last one a suffix; other affixes need COMPOUNDPERMITFLAG.
|
|
func (d *Dictionary) compoundPart(w string, pos int, cc bool) bool {
|
|
c := d.compound
|
|
allowed := func(f flagSet) bool {
|
|
return f.has(c.any) || (pos == partBegin && f.has(c.begin)) ||
|
|
(pos == partMiddle && f.has(c.middle)) || (pos == partLast && f.has(c.last))
|
|
}
|
|
if d.isForbidden(w) {
|
|
return false
|
|
}
|
|
if d.stemHas(w, cc, func(f flagSet) bool { return !f.has(d.needAffix) && allowed(f) }) {
|
|
return true
|
|
}
|
|
affixOK := func(a *affix, at int) bool {
|
|
return !a.cont.has(c.forbid) && (pos == at || a.cont.has(c.permit))
|
|
}
|
|
if d.matchingSuffixes(w, func(s *affix, stem string) bool {
|
|
return affixOK(s, partLast) && d.stemHas(stem, cc, func(f flagSet) bool { return f.has(s.flag) && allowed(f) })
|
|
}) {
|
|
return true
|
|
}
|
|
return d.matchingPrefixes(w, func(p *affix, rest string) bool {
|
|
if !affixOK(p, partBegin) {
|
|
return false
|
|
}
|
|
if d.stemHas(rest, cc, func(f flagSet) bool { return f.has(p.flag) && allowed(f) }) {
|
|
return true
|
|
}
|
|
// Both: a prefix and a suffix that combine (cross product).
|
|
return p.cross && d.matchingSuffixes(rest, func(s *affix, stem string) bool {
|
|
return s.cross && affixOK(s, partLast) &&
|
|
d.stemHas(stem, cc, func(f flagSet) bool { return f.has(p.flag) && f.has(s.flag) && allowed(f) })
|
|
})
|
|
})
|
|
}
|
|
|
|
// valid checks one exact spelling. caseChanged is set when the word was
|
|
// lower-cased to be looked up, which KEEPCASE words forbid.
|
|
func (d *Dictionary) valid(w string, caseChanged bool) bool {
|
|
if w == "" || d.isForbidden(w) {
|
|
return false
|
|
}
|
|
if d.stemHas(w, caseChanged, func(f flagSet) bool { return !f.has(d.needAffix) && !f.has(d.onlyInComp) }) {
|
|
return true
|
|
}
|
|
return d.suffixed(w, caseChanged) || d.prefixed(w, caseChanged)
|
|
}
|
|
|
|
func (d *Dictionary) isForbidden(w string) bool {
|
|
if d.forbidden == 0 {
|
|
return false
|
|
}
|
|
for _, f := range d.words[w] {
|
|
if f.has(d.forbidden) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stemHas reports whether one of the homonyms of stem satisfies ok.
|
|
func (d *Dictionary) stemHas(stem string, caseChanged bool, ok func(flagSet) bool) bool {
|
|
for _, f := range d.words[stem] {
|
|
if f.has(d.forbidden) || (caseChanged && f.has(d.keepCase)) {
|
|
continue
|
|
}
|
|
if ok(f) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchingSuffixes calls fn for every suffix that w can end with, with the
|
|
// stem it leaves once the suffix is removed and the stripped text restored.
|
|
func (d *Dictionary) matchingSuffixes(w string, fn func(a *affix, stem string) bool) bool {
|
|
for n := 0; n <= min(d.maxSuffix, len(w)); n++ {
|
|
for _, a := range d.suffixes[w[len(w)-n:]] {
|
|
base := w[:len(w)-n]
|
|
if base == "" && !d.fullStrip {
|
|
continue
|
|
}
|
|
stem := base + a.strip
|
|
if stem == "" || !a.cond.matchEnd(stem) {
|
|
continue
|
|
}
|
|
if fn(a, stem) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (d *Dictionary) matchingPrefixes(w string, fn func(a *affix, stem string) bool) bool {
|
|
for n := 0; n <= min(d.maxPrefix, len(w)); n++ {
|
|
for _, a := range d.prefixes[w[:n]] {
|
|
rest := w[n:]
|
|
if rest == "" && !d.fullStrip {
|
|
continue
|
|
}
|
|
stem := a.strip + rest
|
|
if stem == "" || !a.cond.matchStart(stem) {
|
|
continue
|
|
}
|
|
if fn(a, stem) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// suffixed accepts stem+suffix, and stem+suffix+suffix when the inner
|
|
// suffix lists the outer one as a continuation class.
|
|
func (d *Dictionary) suffixed(w string, cc bool) bool {
|
|
return d.matchingSuffixes(w, func(s *affix, stem string) bool {
|
|
if !s.cont.has(d.needAffix) && !s.cont.has(d.circumfix) &&
|
|
d.stemHas(stem, cc, func(f flagSet) bool { return f.has(s.flag) }) {
|
|
return true
|
|
}
|
|
if !d.contFlags[s.flag] {
|
|
return false
|
|
}
|
|
return d.matchingSuffixes(stem, func(inner *affix, root string) bool {
|
|
return inner.cont.has(s.flag) &&
|
|
d.stemHas(root, cc, func(f flagSet) bool { return f.has(inner.flag) })
|
|
})
|
|
})
|
|
}
|
|
|
|
// prefixed accepts prefix+stem and prefix+stem+suffix.
|
|
func (d *Dictionary) prefixed(w string, cc bool) bool {
|
|
return d.matchingPrefixes(w, func(p *affix, rest string) bool {
|
|
if !p.cont.has(d.needAffix) && !p.cont.has(d.circumfix) &&
|
|
d.stemHas(rest, cc, func(f flagSet) bool { return f.has(p.flag) }) {
|
|
return true
|
|
}
|
|
return d.matchingSuffixes(rest, func(s *affix, stem string) bool {
|
|
if s.cont.has(d.circumfix) != p.cont.has(d.circumfix) {
|
|
return false
|
|
}
|
|
return d.stemHas(stem, cc, func(f flagSet) bool {
|
|
switch {
|
|
case f.has(s.flag) && f.has(p.flag):
|
|
return p.cross && s.cross
|
|
case f.has(s.flag):
|
|
return s.cont.has(p.flag) // the suffix allows the prefix
|
|
case f.has(p.flag):
|
|
return p.cont.has(s.flag)
|
|
}
|
|
return false
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
// maxSuggestions is how many corrections Suggest returns at most.
|
|
const maxSuggestions = 8
|
|
|
|
// Suggest returns likely corrections for a misspelled word, best first,
|
|
// in the word's case ("TEH" gives "THE"). It tries the word with a capital
|
|
// (proper nouns), the dictionary's table of common misspellings (REP), then
|
|
// every spelling one edit away, the most frequent kinds of typo first:
|
|
// swapped letters, an extra letter, a wrong letter, a missing letter, and
|
|
// a missing space between two words of three letters or more.
|
|
func (d *Dictionary) Suggest(word string) []string {
|
|
w := d.iconv.Replace(word)
|
|
wc := caseOf(w)
|
|
base := w
|
|
if wc == titleCase || wc == upperCase {
|
|
base = strings.ToLower(w)
|
|
}
|
|
|
|
var out, proper []string
|
|
seen := map[string]bool{w: true}
|
|
try := func(cand string) {
|
|
if len(out) >= maxSuggestions || seen[cand] {
|
|
return
|
|
}
|
|
seen[cand] = true
|
|
if d.suggestable(cand) {
|
|
out = append(out, cand)
|
|
return
|
|
}
|
|
// A lower-case candidate may be a proper noun ("pariss" → "Paris"),
|
|
// offered after the common words.
|
|
if caseOf(cand) == lowerCase {
|
|
if cand = capitalize(cand); !seen[cand] && d.suggestable(cand) {
|
|
seen[cand] = true
|
|
proper = append(proper, cand)
|
|
}
|
|
}
|
|
}
|
|
|
|
if wc == lowerCase && d.suggestable(capitalize(base)) {
|
|
out = append(out, capitalize(base))
|
|
}
|
|
for _, r := range d.rep {
|
|
for i := 0; ; {
|
|
j := strings.Index(base[i:], r.from)
|
|
if j < 0 {
|
|
break
|
|
}
|
|
at := i + j
|
|
if (!r.start || at == 0) && (!r.end || at+len(r.from) == len(base)) {
|
|
try(base[:at] + r.to + base[at+len(r.from):])
|
|
}
|
|
i = at + 1
|
|
}
|
|
}
|
|
|
|
runes := []rune(base)
|
|
edit := func(i, del int, ins ...rune) string {
|
|
return string(runes[:i]) + string(ins) + string(runes[i+del:])
|
|
}
|
|
for i := 0; i+1 < len(runes); i++ {
|
|
if runes[i] != runes[i+1] {
|
|
try(edit(i, 2, runes[i+1], runes[i]))
|
|
}
|
|
}
|
|
for i := range runes {
|
|
try(edit(i, 1))
|
|
}
|
|
for i := range runes {
|
|
for _, c := range d.try {
|
|
if c != runes[i] && !unicode.IsUpper(c) {
|
|
try(edit(i, 1, c))
|
|
}
|
|
}
|
|
}
|
|
for i := 0; i <= len(runes); i++ {
|
|
for _, c := range d.try {
|
|
if !unicode.IsUpper(c) {
|
|
try(edit(i, 0, c))
|
|
}
|
|
}
|
|
}
|
|
// Short words ("a", "re", "er") make most splits look valid but are
|
|
// rarely what was meant; REP tables cover the common cases ("a lot").
|
|
for i := 3; i+3 <= len(runes); i++ {
|
|
try(edit(i, 0, ' '))
|
|
}
|
|
|
|
out = append(out, proper...)
|
|
out = out[:min(len(out), maxSuggestions)]
|
|
for i, s := range out {
|
|
switch wc {
|
|
case upperCase:
|
|
s = strings.ToUpper(s)
|
|
case titleCase:
|
|
s = capitalize(s)
|
|
}
|
|
out[i] = d.oconv.Replace(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// suggestable checks a candidate, which may be two words separated by a
|
|
// space, and leaves out words marked NOSUGGEST (offensive words, mostly).
|
|
func (d *Dictionary) suggestable(cand string) bool {
|
|
for _, w := range strings.Split(cand, " ") {
|
|
if w == "" || strings.HasPrefix(w, "-") || strings.HasSuffix(w, "-") || !d.valid(w, false) {
|
|
return false
|
|
}
|
|
for _, f := range d.words[w] {
|
|
if f.has(d.noSuggest) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|