Files

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
}
}
}