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