Skip to content

Commit bf76e1e

Browse files
committed
spell: follow affix continuation classes
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 37b242a commit bf76e1e

2 files changed

Lines changed: 129 additions & 20 deletions

File tree

internal/spell/aff.go

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,30 @@ type affix struct {
2525
CrossProduct bool // -
2626
}
2727

28-
// expand provides all variations of a given word based on this affix rule
29-
func (a affix) expand(word string, out []string) []string {
28+
// form is one generated word and the continuation flags of the rule that
29+
// produced it, which may be empty.
30+
type form struct {
31+
Word string
32+
Cont string
33+
}
34+
35+
// forms provides all variations of a given word based on this affix rule,
36+
// each paired with the continuation flags that still apply to it.
37+
func (a affix) forms(word string) []form {
38+
var out []form
3039
for _, r := range a.Rules {
3140
if r.matcher != nil && !r.matcher.MatchString(word) {
3241
continue
3342
}
3443
if a.Type == Prefix {
35-
out = append(out, r.AffixText+word)
44+
out = append(out, form{Word: r.AffixText + word, Cont: r.Cont})
3645
// TODO is does Strip apply to prefixes too?
3746
} else {
3847
stripWord := word
3948
if r.Strip != "" && strings.HasSuffix(word, r.Strip) {
4049
stripWord = word[:len(word)-len(r.Strip)]
4150
}
42-
out = append(out, stripWord+r.AffixText)
51+
out = append(out, form{Word: stripWord + r.AffixText, Cont: r.Cont})
4352
}
4453
}
4554
return out
@@ -48,9 +57,16 @@ func (a affix) expand(word string, out []string) []string {
4857
// rule is a Affix rule
4958
type rule struct {
5059
Strip string
51-
AffixText string // suffix or prefix text to add
52-
Pattern string // original matching pattern from AFF file
53-
matcher *regexp.Regexp // matcher to see if this rule applies or not
60+
AffixText string // suffix or prefix text to add
61+
62+
// Cont holds the continuation flags the rule carries, if any -- the
63+
// "34,22" of `SFX 1 0 t/34,22 e`. They name the affix classes that apply
64+
// again to the form this rule produces, which is how a dictionary spells
65+
// out an inflection built in more than one step.
66+
Cont string
67+
68+
Pattern string // original matching pattern from AFF file
69+
matcher *regexp.Regexp // matcher to see if this rule applies or not
5470
}
5571

5672
// dictConfig is a partial representation of a Hunspell AFF (Affix) file.
@@ -111,6 +127,11 @@ func (a dictConfig) parseFlags(flagStr string) []string {
111127
//
112128
// This also supports CompoundRule flags
113129
func (a dictConfig) expand(wordAffix string, out []string) ([]string, error) {
130+
return a.expandDepth(wordAffix, out, 0)
131+
}
132+
133+
// expandDepth is expand, tracking how many continuation classes deep it is.
134+
func (a dictConfig) expandDepth(wordAffix string, out []string, depth int) ([]string, error) {
114135
out = out[:0]
115136
idx := strings.Index(wordAffix, "/")
116137

@@ -156,7 +177,7 @@ func (a dictConfig) expand(wordAffix string, out []string) ([]string, error) {
156177
continue
157178
}
158179
if !af.CrossProduct {
159-
out = af.expand(word, out)
180+
out = a.appendForms(af.forms(word), out, depth)
160181
continue
161182
}
162183
if af.Type == Prefix {
@@ -168,22 +189,60 @@ func (a dictConfig) expand(wordAffix string, out []string) ([]string, error) {
168189

169190
// expand all suffixes with out any prefixes
170191
for _, suf := range suffixes {
171-
out = suf.expand(word, out)
192+
out = a.appendForms(suf.forms(word), out, depth)
172193
}
173194
for _, pre := range prefixes {
174-
prewords := pre.expand(word, nil)
175-
out = append(out, prewords...)
195+
prewords := pre.forms(word)
196+
out = a.appendForms(prewords, out, depth)
176197

177198
// now do cross product
178199
for _, suf := range suffixes {
179200
for _, w := range prewords {
180-
out = suf.expand(w, out)
201+
out = a.appendForms(suf.forms(w.Word), out, depth)
181202
}
182203
}
183204
}
184205
return out, nil
185206
}
186207

208+
// maxAffixDepth bounds how many times a continuation class may be followed.
209+
//
210+
// Hunspell's default is twofold affixation -- one continuation -- and this
211+
// allows one more for dictionaries that lean on longer chains. A bound is what
212+
// makes this safe at all: nothing stops an .aff file from having a class
213+
// continue to itself, and following that faithfully would not terminate.
214+
const maxAffixDepth = 2
215+
216+
// appendForms adds each generated form to out, then follows any continuation
217+
// flags it carries.
218+
//
219+
// This is the step Hunspell calls twofold affixation: `SFX 1 0 t/34,22 e` says
220+
// that after the rule builds its form, classes 34 and 22 apply to *that*. Not
221+
// following them leaves the further-inflected words unrecognized, which reads
222+
// to a user as their own dictionary not knowing an ordinary word -- most
223+
// visibly in Danish, Dutch and Hungarian, where inflection is built this way.
224+
func (a dictConfig) appendForms(forms []form, out []string, depth int) []string {
225+
for _, f := range forms {
226+
out = append(out, f.Word)
227+
if f.Cont == "" || depth >= maxAffixDepth {
228+
continue
229+
}
230+
// The continuation is expressed exactly like a dictionary entry, so
231+
// it is expanded as one.
232+
more, err := a.expandDepth(f.Word+"/"+f.Cont, nil, depth+1)
233+
if err != nil {
234+
continue
235+
}
236+
// expandDepth re-emits the word it was given; it is already in out.
237+
for _, w := range more {
238+
if w != f.Word {
239+
out = append(out, w)
240+
}
241+
}
242+
}
243+
return out
244+
}
245+
187246
// allDigits reports whether s is non-empty and contains only ASCII digits. It
188247
// distinguishes a PFX/SFX header's count field from a rule's affix text when
189248
// both lines have four fields. See #776.
@@ -370,23 +429,26 @@ func newDictConfig(file io.Reader) (*dictConfig, error) { //nolint:funlen
370429
// See #499.
371430
//
372431
// TODO: Is this safe to do in all cases?
373-
affixText := parts[3]
432+
affixText, cont := parts[3], ""
374433
if affixText == "0" {
375434
affixText = ""
376435
} else if i := strings.Index(affixText, "/"); i >= 0 {
377-
// Strip the affix's own continuation flags, e.g. the
378-
// "/34,22" in `SFX 1 0 t/34,22 e`. Otherwise they'd be
379-
// appended to the generated word ("stavet/34,22"), so the
380-
// real form ("stavet") is never recognized. See #1065.
436+
// Split off the affix's own continuation flags, e.g. the
437+
// "/34,22" in `SFX 1 0 t/34,22 e`. Left in place they would
438+
// be appended to the generated word ("stavet/34,22"), so
439+
// the real form ("stavet") is never recognized. See #1065.
381440
//
382-
// NOTE: We don't yet recursively apply continuation classes,
383-
// so some further-inflected forms remain unrecognized.
384-
affixText = affixText[:i]
441+
// They are kept rather than dropped: the flags name further
442+
// classes that apply to the form this rule produces, which
443+
// is how Hunspell builds a word like `stavets` from
444+
// `stave` in two steps. See expand.
445+
affixText, cont = affixText[:i], affixText[i+1:]
385446
}
386447

387448
a.Rules = append(a.Rules, rule{
388449
Strip: strip,
389450
AffixText: affixText,
451+
Cont: cont,
390452
Pattern: cond,
391453
matcher: matcher,
392454
})

internal/spell/aff_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package spell
33
import (
44
"strings"
55
"testing"
6+
"time"
67
)
78

89
func TestParseFlagsASCII(t *testing.T) {
@@ -217,6 +218,7 @@ coituum
217218
}{
218219
{"stave", true}, // base word, morphology stripped
219220
{"stavet", true}, // SFX 1 with continuation flags stripped
221+
{"stavets", true}, // SFX 1, then its continuation into SFX 34
220222
{"staves", true}, // SFX 34
221223
{"coituum", true}, // word before the malformed line still loaded
222224
{"thtis", false}, // a genuine misspelling is still caught
@@ -317,3 +319,48 @@ func TestConditionlessAffixRule(t *testing.T) {
317319
t.Error("expected suffixed 'kats' (conditionless SFX rule) to be recognized")
318320
}
319321
}
322+
323+
// TestContinuationCycleTerminates covers an .aff file whose affix class
324+
// continues to itself. Nothing in the format forbids it, and following it
325+
// faithfully would not terminate, so expansion is bounded -- the point of the
326+
// test is that loading finishes at all and still recognizes the forms the
327+
// bound does allow.
328+
func TestContinuationCycleTerminates(t *testing.T) {
329+
affContent := `SET UTF-8
330+
FLAG num
331+
332+
SFX 1 Y 1
333+
SFX 1 0 s/1 .
334+
`
335+
dicContent := `1
336+
loop/1
337+
`
338+
339+
done := make(chan *goSpell, 1)
340+
go func() {
341+
gs, err := newGoSpellReader(
342+
strings.NewReader(affContent),
343+
strings.NewReader(dicContent),
344+
)
345+
if err != nil {
346+
t.Error(err)
347+
done <- nil
348+
return
349+
}
350+
done <- gs
351+
}()
352+
353+
select {
354+
case gs := <-done:
355+
if gs == nil {
356+
return
357+
}
358+
for _, w := range []string{"loop", "loops"} {
359+
if !gs.spell(w) {
360+
t.Errorf("spell(%q) = false, want true", w)
361+
}
362+
}
363+
case <-time.After(10 * time.Second):
364+
t.Fatal("expansion did not terminate on a self-continuing affix class")
365+
}
366+
}

0 commit comments

Comments
 (0)