168 lines
4.7 KiB
Go
168 lines
4.7 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package spellcheck
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Operation int
|
|
|
|
const (
|
|
Inspect Operation = iota
|
|
Automatic
|
|
Add
|
|
Remove
|
|
)
|
|
|
|
type Request struct {
|
|
Operation Operation
|
|
Word string
|
|
Generation, Revision uint64
|
|
}
|
|
type Result struct {
|
|
Request
|
|
Language string
|
|
Word string
|
|
Correct, Available bool
|
|
Suggestions, Exceptions []string
|
|
Err, DictionaryError error
|
|
}
|
|
type Options struct {
|
|
Language, ExceptionsPath string
|
|
OpenDictionary func(string) (Dictionary, error)
|
|
}
|
|
type Service struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
requests, automatic chan Request
|
|
results chan Result
|
|
done chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func New_service(parent context.Context, options Options) *Service {
|
|
ctx, cancel := context.WithCancel(parent)
|
|
s := &Service{ctx: ctx, cancel: cancel, requests: make(chan Request, 16), automatic: make(chan Request, 32), results: make(chan Result, 32), done: make(chan struct{})}
|
|
go s.run(options)
|
|
return s
|
|
}
|
|
func (s *Service) Results() <-chan Result { return s.results }
|
|
func (s *Service) Submit(request Request) bool {
|
|
queue := s.requests
|
|
if request.Operation == Automatic {
|
|
queue = s.automatic
|
|
}
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return false
|
|
default:
|
|
}
|
|
select {
|
|
case queue <- request:
|
|
return true
|
|
case <-s.ctx.Done():
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
func (s *Service) Close() { s.once.Do(s.cancel); <-s.done }
|
|
|
|
type commandEntry struct {
|
|
valid bool
|
|
expires time.Time
|
|
}
|
|
|
|
// Commands are tested as executable PATH entries, never executed. Builtins are
|
|
// accepted separately because they have no executable file. Aliases/functions
|
|
// belong to the child shell and cannot be discovered from the reader process.
|
|
func valid_command(word string, cache map[string]commandEntry) bool {
|
|
if strings.ContainsAny(word, "/\\\x00") {
|
|
return false
|
|
}
|
|
if strings.Contains(" alias bg bind break builtin caller cd command compgen complete compopt continue declare dirs disown echo enable eval exec exit export false fc fg getopts hash help history jobs kill let local logout mapfile popd printf pushd pwd read readarray readonly return set shift shopt source test times trap true type typeset ulimit umask unalias unset wait ", " "+word+" ") {
|
|
return true
|
|
}
|
|
if entry, ok := cache[word]; ok && time.Now().Before(entry.expires) {
|
|
return entry.valid
|
|
}
|
|
_, err := exec.LookPath(word)
|
|
valid := err == nil || errors.Is(err, exec.ErrDot)
|
|
if len(cache) >= 1024 {
|
|
clear(cache)
|
|
}
|
|
cache[word] = commandEntry{valid, time.Now().Add(30 * time.Second)}
|
|
return valid
|
|
}
|
|
|
|
func (s *Service) run(options Options) {
|
|
defer close(s.done)
|
|
open := options.OpenDictionary
|
|
if open == nil {
|
|
open = open_dictionary
|
|
}
|
|
dict, dictionaryErr := open(options.Language)
|
|
if dict != nil {
|
|
defer dict.Close()
|
|
}
|
|
words, storeErr := load_exceptions(options.ExceptionsPath)
|
|
cache := make(map[string]commandEntry)
|
|
for {
|
|
var request Request
|
|
// Prefer interactive actions over a backlog of automatic checks.
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case request = <-s.requests:
|
|
default:
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case request = <-s.requests:
|
|
case request = <-s.automatic:
|
|
}
|
|
}
|
|
result := Result{Request: request, Language: options.Language, Word: Word(request.Word), Available: dict != nil && dictionaryErr == nil, DictionaryError: dictionaryErr, Err: storeErr}
|
|
if request.Operation == Add || request.Operation == Remove {
|
|
result.Word = strings.TrimSpace(request.Word)
|
|
if result.Err == nil && !valid_word(result.Word) {
|
|
result.Err = fmt.Errorf("enter a single word of at most 128 characters")
|
|
}
|
|
if result.Err == nil {
|
|
var draft map[string]string
|
|
draft, result.Err = update_exceptions(s.ctx, options.ExceptionsPath, request.Operation, result.Word)
|
|
if result.Err == nil {
|
|
words = draft
|
|
}
|
|
}
|
|
} else if result.Err == nil && result.Word != "" {
|
|
_, result.Correct = words[strings.ToLower(result.Word)]
|
|
if request.Operation == Automatic && !result.Correct {
|
|
result.Correct = valid_command(result.Word, cache)
|
|
}
|
|
if !result.Correct && result.Available {
|
|
result.Correct, result.Err = dict.Check(result.Word)
|
|
}
|
|
if result.Err == nil && !result.Correct && result.Available && request.Operation == Inspect {
|
|
result.Suggestions, result.Err = dict.Suggest(result.Word)
|
|
}
|
|
}
|
|
if request.Operation != Automatic {
|
|
result.Exceptions = exception_list(words)
|
|
}
|
|
select {
|
|
case s.results <- result:
|
|
case <-s.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|