package engine import ( "bytes" "fmt" "maps" "path/filepath" "sort" dawg "github.com/iliadenisov/dafsa" ) // WordDiff is the set difference between two dictionaries of one variant: the // words present only in the new dictionary (Added) and only in the old one // (Removed), decoded to characters and each sorted by the variant's alphabet // order. It drives the admin dictionary-update preview. type WordDiff struct { Added []string Removed []string } // DictFiles returns a copy of the variant-to-committed-DAWG-filename map. It lets // callers outside the package (the dictionary-admin upload validation) check an // archive against the expected file set without sharing the engine's own map. func DictFiles() map[Variant]string { out := make(map[Variant]string, len(dictFiles)) maps.Copy(out, dictFiles) return out } // OpenFinder loads the committed DAWG of variant v from dir and returns its // finder. The caller owns the finder and must Close it. It backs enumeration of // a staged, not-yet-registered dictionary version. func OpenFinder(dir string, v Variant) (dawg.Finder, error) { name, ok := dictFiles[v] if !ok { return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v) } path := filepath.Join(dir, name) finder, err := dawg.Load(path) if err != nil { return nil, fmt.Errorf("engine: load %s dictionary from %s: %w", v, path, err) } return finder, nil } // Finder returns the loaded finder for the (variant, version) pair so its words // can be enumerated, or ErrUnknownVariant / ErrUnknownVersion when that // dictionary is not resident. The finder remains owned by the registry; callers // must not Close it. func (r *Registry) Finder(v Variant, version string) (dawg.Finder, error) { r.mu.RLock() defer r.mu.RUnlock() versions, ok := r.entries[v] if !ok { return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v) } e, ok := versions[version] if !ok { return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version) } return e.finder, nil } // DiffWords compares the old and new finders of variant v and returns the words // added and removed, decoded through v's alphabet. Both dictionaries are // enumerated as alphabet-index bytes and merged; only the differing words are // decoded, so a full-dictionary comparison does not materialise two large string // sets. func DiffWords(v Variant, old, updated dawg.Finder) (WordDiff, error) { rs, ok := v.ruleset() if !ok { return WordDiff{}, fmt.Errorf("%w: %d", ErrUnknownVariant, v) } oldWords := collectWords(old) newWords := collectWords(updated) var diff WordDiff i, j := 0, 0 for i < len(oldWords) && j < len(newWords) { switch cmp := bytes.Compare(oldWords[i], newWords[j]); { case cmp == 0: i++ j++ case cmp < 0: s, err := rs.Alphabet.Decode(oldWords[i]) if err != nil { return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err) } diff.Removed = append(diff.Removed, s) i++ default: s, err := rs.Alphabet.Decode(newWords[j]) if err != nil { return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err) } diff.Added = append(diff.Added, s) j++ } } for ; i < len(oldWords); i++ { s, err := rs.Alphabet.Decode(oldWords[i]) if err != nil { return WordDiff{}, fmt.Errorf("engine: decode %s removed word: %w", v, err) } diff.Removed = append(diff.Removed, s) } for ; j < len(newWords); j++ { s, err := rs.Alphabet.Decode(newWords[j]) if err != nil { return WordDiff{}, fmt.Errorf("engine: decode %s added word: %w", v, err) } diff.Added = append(diff.Added, s) } return diff, nil } // collectWords enumerates every complete word of f as a copy of its // alphabet-index bytes, sorted ascending. The dawg already enumerates in index // order; the explicit sort makes the merge in DiffWords robust to that being an // implementation detail. func collectWords(f dawg.Finder) [][]byte { out := make([][]byte, 0, f.NumAdded()) f.EnumerateB(func(_ int, word []byte, final bool) dawg.EnumerationResult { if final && len(word) > 0 { cp := make([]byte, len(word)) copy(cp, word) out = append(out, cp) } return dawg.Continue }) sort.Slice(out, func(a, b int) bool { return bytes.Compare(out[a], out[b]) < 0 }) return out }