After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.
This commit is contained in:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package spellcheck owns offline dictionary work and personal exceptions.
|
||||
package spellcheck
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include "native.h"
|
||||
#include <stdlib.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Dictionary is used only by the service's worker, including Close.
|
||||
type Dictionary interface {
|
||||
Check(string) (bool, error)
|
||||
Suggest(string) ([]string, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
type EnchantDictionary struct {
|
||||
native *C.SpellDictionary
|
||||
}
|
||||
|
||||
func open_dictionary(language string) (Dictionary, error) {
|
||||
if language == "" {
|
||||
return nil, fmt.Errorf("the session has no spelling language")
|
||||
}
|
||||
tag := C.CString(language)
|
||||
defer C.free(unsafe.Pointer(tag))
|
||||
var status C.int
|
||||
native := C.spell_dictionary_open(tag, &status)
|
||||
if native == nil {
|
||||
switch status {
|
||||
case 1:
|
||||
return nil, fmt.Errorf("Enchant 2 could not be loaded")
|
||||
case 2:
|
||||
return nil, fmt.Errorf("Enchant 2 has an incompatible interface")
|
||||
case 4:
|
||||
return nil, fmt.Errorf("dictionary for %s is unavailable", language)
|
||||
default:
|
||||
return nil, fmt.Errorf("could not initialize Enchant")
|
||||
}
|
||||
}
|
||||
return &EnchantDictionary{native: native}, nil
|
||||
}
|
||||
|
||||
func (d *EnchantDictionary) Check(word string) (bool, error) {
|
||||
value := C.CString(word)
|
||||
defer C.free(unsafe.Pointer(value))
|
||||
result := C.spell_dictionary_check(d.native, value, C.size_t(len(word)))
|
||||
if result < 0 {
|
||||
return false, fmt.Errorf("dictionary check failed")
|
||||
}
|
||||
return result == 0, nil
|
||||
}
|
||||
|
||||
func (d *EnchantDictionary) Suggest(word string) ([]string, error) {
|
||||
value := C.CString(word)
|
||||
defer C.free(unsafe.Pointer(value))
|
||||
var count C.size_t
|
||||
list := C.spell_dictionary_suggest(d.native, value, C.size_t(len(word)), &count)
|
||||
if list == nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer C.spell_dictionary_free_suggestions(d.native, list)
|
||||
// Preserve provider ranking, with a bounded result set for the picker.
|
||||
var suggestions []string
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range unsafe.Slice(list, int(min(count, 128))) {
|
||||
word := C.GoString(item)
|
||||
if word != "" && !seen[word] {
|
||||
seen[word] = true
|
||||
suggestions = append(suggestions, word)
|
||||
}
|
||||
}
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func (d *EnchantDictionary) Close() {
|
||||
C.spell_dictionary_close(d.native)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package spellcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Exceptions_path(home string) string {
|
||||
return filepath.Join(home, ".local/share/stormux/fenrirscreenreader/spelling/exceptions.json")
|
||||
}
|
||||
|
||||
func load_exceptions(path string) (map[string]string, error) {
|
||||
words := make(map[string]string)
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return words, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > 1<<20 {
|
||||
return nil, fmt.Errorf("exceptions file exceeds 1 MiB")
|
||||
}
|
||||
var entries []string
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, word := range entries {
|
||||
if !valid_word(word) {
|
||||
return nil, fmt.Errorf("invalid exceptions file")
|
||||
}
|
||||
words[strings.ToLower(word)] = word
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
func exception_list(words map[string]string) []string {
|
||||
entries := make([]string, 0, len(words))
|
||||
for _, word := range words {
|
||||
entries = append(entries, word)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i]) < strings.ToLower(entries[j]) })
|
||||
return entries
|
||||
}
|
||||
|
||||
// Each edit reloads the latest list while holding a stable, per-file lock.
|
||||
// Atomic replacement alone cannot protect against another reader's stale copy.
|
||||
func update_exceptions(ctx context.Context, path string, operation Operation, word string) (map[string]string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer lock.Close()
|
||||
info, err := lock.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Sys().(*syscall.Stat_t).Uid != uint32(os.Geteuid()) {
|
||||
return nil, fmt.Errorf("exceptions lock must be private and owned by the current account")
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
for {
|
||||
if err := waitCtx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
return nil, err
|
||||
}
|
||||
timer := time.NewTimer(10 * time.Millisecond)
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
timer.Stop()
|
||||
return nil, waitCtx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
words, err := load_exceptions(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := strings.ToLower(word)
|
||||
if operation == Add {
|
||||
words[key] = word
|
||||
} else {
|
||||
delete(words, key)
|
||||
}
|
||||
if err := save_exceptions(path, words); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
func save_exceptions(path string, words map[string]string) error {
|
||||
data, err := json.MarshalIndent(exception_list(words), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > 1<<20 {
|
||||
return fmt.Errorf("exceptions file exceeds 1 MiB")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.CreateTemp(dir, ".exceptions-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(file.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package spellcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExceptionEditsPreserveOtherReadersChanges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "exceptions.json")
|
||||
options := Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }}
|
||||
first, second := New_service(context.Background(), options), New_service(context.Background(), options)
|
||||
defer first.Close()
|
||||
defer second.Close()
|
||||
// Both readers load the original list, then the user switches between them.
|
||||
receive_result(t, first, Request{Operation: Inspect})
|
||||
receive_result(t, second, Request{Operation: Inspect})
|
||||
for _, edit := range []struct {
|
||||
service *Service
|
||||
request Request
|
||||
want []string
|
||||
}{
|
||||
{first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha"}},
|
||||
{second, Request{Operation: Add, Word: "Beta"}, []string{"Alpha", "Beta"}},
|
||||
{first, Request{Operation: Remove, Word: "ALPHA"}, []string{"Beta"}},
|
||||
{second, Request{Operation: Add, Word: "Gamma"}, []string{"Beta", "Gamma"}},
|
||||
{first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha", "Beta", "Gamma"}},
|
||||
} {
|
||||
result := receive_result(t, edit.service, edit.request)
|
||||
if result.Err != nil || !reflect.DeepEqual(result.Exceptions, edit.want) {
|
||||
t.Fatalf("edit %v: result=%+v, want=%v", edit.request, result, edit.want)
|
||||
}
|
||||
words, err := load_exceptions(path)
|
||||
if err != nil || !reflect.DeepEqual(exception_list(words), edit.want) {
|
||||
t.Fatalf("saved exceptions=%v, error=%v, want=%v", words, err, edit.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separate processes verify that the transaction lock works across readers.
|
||||
func TestExceptionWriterProcess(t *testing.T) {
|
||||
path := os.Getenv("FENRIR_EXCEPTION_TEST_PATH")
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
service := New_service(context.Background(), Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }})
|
||||
defer service.Close()
|
||||
encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin)
|
||||
result := receive_result(t, service, Request{Operation: Inspect})
|
||||
if result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
if err := encoder.Encode(result.Exceptions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
var request Request
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := receive_result(t, service, request)
|
||||
if result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
if err := encoder.Encode(result.Exceptions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentExceptionProcessesKeepBothEdits(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "exceptions.json")
|
||||
type Writer struct {
|
||||
encoder *json.Encoder
|
||||
decoder *json.Decoder
|
||||
}
|
||||
start_writer := func() Writer {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExceptionWriterProcess$")
|
||||
cmd.Env = append(os.Environ(), "FENRIR_EXCEPTION_TEST_PATH="+path)
|
||||
cmd.Stderr = os.Stderr
|
||||
input, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
input.Close()
|
||||
err := cmd.Wait()
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
writer := Writer{json.NewEncoder(input), json.NewDecoder(output)}
|
||||
var ready []string
|
||||
if err := writer.decoder.Decode(&ready); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return writer
|
||||
}
|
||||
first, second := start_writer(), start_writer()
|
||||
for _, edit := range []struct {
|
||||
writer Writer
|
||||
word string
|
||||
}{{first, "Alpha"}, {second, "Beta"}} {
|
||||
if err := edit.writer.encoder.Encode(Request{Operation: Add, Word: edit.word}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, writer := range []Writer{first, second} {
|
||||
var words []string
|
||||
if err := writer.decoder.Decode(&words); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
words, err := load_exceptions(path)
|
||||
if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha", "Beta"}) {
|
||||
t.Fatal("lost concurrent addition", words, err)
|
||||
}
|
||||
info, err := os.Stat(path + ".lock")
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("lock permissions", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExceptionLockCancellationAndInvalidStorePreserveData(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "exceptions.json")
|
||||
if err := save_exceptions(path, map[string]string{"alpha": "Alpha"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lock.Close()
|
||||
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := update_exceptions(ctx, path, Add, "Beta"); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal("lock wait ignored cancellation", err)
|
||||
}
|
||||
words, err := load_exceptions(path)
|
||||
if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha"}) {
|
||||
t.Fatal("cancelled edit changed data", words, err)
|
||||
}
|
||||
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := update_exceptions(context.Background(), path, Add, "Beta"); err != nil {
|
||||
t.Fatal("edit failed after lock release", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("invalid JSON"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := update_exceptions(context.Background(), path, Add, "Gamma"); err == nil {
|
||||
t.Fatal("edit overwrote invalid shared file")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != "invalid JSON" {
|
||||
t.Fatal("invalid file was replaced", string(data), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "native.h"
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
// Enchant 2's public ABI uses opaque broker/dictionary pointers. Resolve that
|
||||
// ABI at runtime so the dynamic linker never requires Enchant to start Fenrir.
|
||||
typedef struct _EnchantBroker EnchantBroker;
|
||||
typedef struct _EnchantDict EnchantDict;
|
||||
|
||||
struct SpellDictionary {
|
||||
void *library;
|
||||
EnchantBroker *broker;
|
||||
EnchantDict *dictionary;
|
||||
EnchantBroker *(*broker_init)(void);
|
||||
void (*broker_free)(EnchantBroker *broker);
|
||||
EnchantDict *(*request_dict)(EnchantBroker *broker, const char *language);
|
||||
void (*free_dict)(EnchantBroker *broker, EnchantDict *dictionary);
|
||||
int (*check)(EnchantDict *dictionary, const char *word, ssize_t length);
|
||||
char **(*suggest)(EnchantDict *dictionary, const char *word, ssize_t length, size_t *count);
|
||||
void (*free_suggestions)(EnchantDict *dictionary, char **suggestions);
|
||||
};
|
||||
|
||||
void spell_dictionary_close(SpellDictionary *context) {
|
||||
if (!context) return;
|
||||
if (context->dictionary) context->free_dict(context->broker, context->dictionary);
|
||||
if (context->broker) context->broker_free(context->broker);
|
||||
if (context->library) dlclose(context->library);
|
||||
free(context);
|
||||
}
|
||||
|
||||
SpellDictionary *spell_dictionary_open(const char *language, int *status) {
|
||||
*status = 3;
|
||||
SpellDictionary *context = calloc(1, sizeof(*context));
|
||||
if (!context) return NULL;
|
||||
context->library = dlopen("libenchant-2.so.2", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!context->library) {
|
||||
*status = 1;
|
||||
goto failed;
|
||||
}
|
||||
*status = 2;
|
||||
#define LOAD(field, symbol) \
|
||||
context->field = dlsym(context->library, symbol); \
|
||||
if (!context->field) goto failed
|
||||
LOAD(broker_init, "enchant_broker_init");
|
||||
LOAD(broker_free, "enchant_broker_free");
|
||||
LOAD(request_dict, "enchant_broker_request_dict");
|
||||
LOAD(free_dict, "enchant_broker_free_dict");
|
||||
LOAD(check, "enchant_dict_check");
|
||||
LOAD(suggest, "enchant_dict_suggest");
|
||||
LOAD(free_suggestions, "enchant_dict_free_string_list");
|
||||
#undef LOAD
|
||||
*status = 3;
|
||||
context->broker = context->broker_init();
|
||||
if (!context->broker) goto failed;
|
||||
*status = 4;
|
||||
context->dictionary = context->request_dict(context->broker, language);
|
||||
if (!context->dictionary) goto failed;
|
||||
*status = 0;
|
||||
return context;
|
||||
failed:
|
||||
spell_dictionary_close(context);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int spell_dictionary_check(SpellDictionary *context, const char *word, size_t length) {
|
||||
return context->check(context->dictionary, word, (ssize_t)length);
|
||||
}
|
||||
|
||||
char **spell_dictionary_suggest(SpellDictionary *context, const char *word,
|
||||
size_t length, size_t *count) {
|
||||
return context->suggest(context->dictionary, word, (ssize_t)length, count);
|
||||
}
|
||||
|
||||
void spell_dictionary_free_suggestions(SpellDictionary *context, char **suggestions) {
|
||||
context->free_suggestions(context->dictionary, suggestions);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#ifndef FENRIR_SPELLCHECK_NATIVE_H
|
||||
#define FENRIR_SPELLCHECK_NATIVE_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct SpellDictionary SpellDictionary;
|
||||
|
||||
SpellDictionary *spell_dictionary_open(const char *language, int *status);
|
||||
int spell_dictionary_check(SpellDictionary *context, const char *word, size_t length);
|
||||
char **spell_dictionary_suggest(SpellDictionary *context, const char *word,
|
||||
size_t length, size_t *count);
|
||||
void spell_dictionary_free_suggestions(SpellDictionary *context, char **suggestions);
|
||||
void spell_dictionary_close(SpellDictionary *context);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package spellcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TestDictionary struct {
|
||||
calls []string
|
||||
gate <-chan struct{}
|
||||
}
|
||||
|
||||
func (d *TestDictionary) Check(word string) (bool, error) {
|
||||
if d.gate != nil {
|
||||
<-d.gate
|
||||
}
|
||||
d.calls = append(d.calls, word)
|
||||
return word == "hello", nil
|
||||
}
|
||||
func (*TestDictionary) Suggest(string) ([]string, error) {
|
||||
return []string{"hello", "help", "held"}, nil
|
||||
}
|
||||
func (*TestDictionary) Close() {}
|
||||
|
||||
func receive_result(t *testing.T, service *Service, request Request) Result {
|
||||
t.Helper()
|
||||
if !service.Submit(request) {
|
||||
t.Fatal("request not accepted")
|
||||
}
|
||||
select {
|
||||
case result := <-service.Results():
|
||||
return result
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker timed out")
|
||||
return Result{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLanguagePrecedenceAndNoInventedFallback(t *testing.T) {
|
||||
for _, name := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
t.Setenv("LANG", "en-GB.UTF-8")
|
||||
t.Setenv("LANGUAGE", "fr:de")
|
||||
if Language() != "en_GB" {
|
||||
t.Fatal(Language())
|
||||
}
|
||||
t.Setenv("LC_MESSAGES", "de_DE.UTF-8@euro")
|
||||
if Language() != "de_DE" {
|
||||
t.Fatal(Language())
|
||||
}
|
||||
t.Setenv("LC_ALL", "fr_FR.UTF-8")
|
||||
if Language() != "fr_FR" {
|
||||
t.Fatal(Language())
|
||||
}
|
||||
for _, language := range []string{"C", "C.UTF-8", "POSIX", "../../en_US", "en_US:fr"} {
|
||||
t.Setenv("LC_ALL", language)
|
||||
if Language() != "" {
|
||||
t.Fatal(language, Language())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordExtractionUnicodeContractionsAndTerminalTokens(t *testing.T) {
|
||||
for input, want := range map[string]string{"hello,": "hello", "(café)": "café", "don't": "don't", "l’été": "l’été", "naïve": "naïve", "co-operate": "co-operate", "猫": "猫", "123": "", "123": "", "foo42": "", "-foo": "", "./hello": "", "/tmp/hello": "", "name@example.com": "", "https://example.com": "", "some_identifier": "", "foo.bar": "", "--option": "", "@name": "", "#topic": "", "VAR=value": "", "\x00": "", "": ""} {
|
||||
if got := Word(input); got != want {
|
||||
t.Errorf("%q: %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalExceptionsPersistenceRemoveAndFailedWriteIsolation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "spelling", "exceptions.json")
|
||||
dict := &TestDictionary{}
|
||||
options := Options{Language: "en_US", ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return dict, nil }}
|
||||
service := New_service(context.Background(), options)
|
||||
defer service.Close()
|
||||
if got := receive_result(t, service, Request{Word: "helo"}); got.Correct || !reflect.DeepEqual(got.Suggestions, []string{"hello", "help", "held"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, word := range []string{"Fenrir", "Café"} {
|
||||
if got := receive_result(t, service, Request{Operation: Add, Word: word}); got.Err != nil {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
for _, operation := range []Operation{Inspect, Automatic} {
|
||||
if got := receive_result(t, service, Request{Operation: operation, Word: "FENRIR"}); !got.Correct || len(got.Suggestions) != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
if len(dict.calls) != 1 {
|
||||
t.Fatal("exception reached dictionary", dict.calls)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal(info, err)
|
||||
}
|
||||
service.Close()
|
||||
options.Language = "de_DE"
|
||||
service = New_service(context.Background(), options)
|
||||
defer service.Close()
|
||||
if got := receive_result(t, service, Request{Word: "café"}); !got.Correct {
|
||||
t.Fatal("exception was language-specific", got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Operation: Remove, Word: "Fenrir"}); got.Err != nil || !reflect.DeepEqual(got.Exceptions, []string{"Café"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Word: "Fenrir"}); got.Correct {
|
||||
t.Fatal("removed exception accepted", got)
|
||||
}
|
||||
// Replace the parent with a file so failure is deterministic even as root.
|
||||
if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+"-saved"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Dir(path), []byte("blocked"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Operation: Add, Word: "wrongword"}); got.Err == nil {
|
||||
t.Fatal("failed write announced success", got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Word: "wrongword"}); got.Correct {
|
||||
t.Fatal("failed addition changed memory", got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Operation: Remove, Word: "Café"}); got.Err == nil {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Word: "café"}); !got.Correct {
|
||||
t.Fatal("failed removal changed memory", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATHCommandsBuiltinsAndNeverExecute(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("PATH", root)
|
||||
marker := filepath.Join(root, "executed")
|
||||
if err := os.WriteFile(filepath.Join(root, "tac"), []byte("#!/bin/sh\necho bad > '"+marker+"'\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "notexecutable"), []byte("data"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "directory"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dict := &TestDictionary{}
|
||||
service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(root, "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return dict, nil }})
|
||||
defer service.Close()
|
||||
for _, word := range []string{"tac", "cd", "readarray", "ulimit"} {
|
||||
if got := receive_result(t, service, Request{Operation: Automatic, Word: word}); !got.Correct {
|
||||
t.Fatal(word, got)
|
||||
}
|
||||
}
|
||||
if len(dict.calls) != 0 {
|
||||
t.Fatal(dict.calls)
|
||||
}
|
||||
for _, word := range []string{"notexecutable", "directory", "typo"} {
|
||||
if got := receive_result(t, service, Request{Operation: Automatic, Word: word}); got.Correct {
|
||||
t.Fatal(word, got)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("command executed", err)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Word: "tac"}); got.Correct {
|
||||
t.Fatal("manual lexical check skipped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingDictionaryStillAllowsExceptionManagement(t *testing.T) {
|
||||
service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return nil, errors.New("missing dictionary") }})
|
||||
defer service.Close()
|
||||
if got := receive_result(t, service, Request{Word: "hello"}); got.Available || got.DictionaryError == nil || got.Correct {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Operation: Add, Word: "Fenrir"}); got.Err != nil {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := receive_result(t, service, Request{Word: "fenrir"}); !got.Correct {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowDictionaryDoesNotBlockInputSubmissionOrShutdown(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{gate: gate}, nil }})
|
||||
started := time.Now()
|
||||
for range 100 {
|
||||
service.Submit(Request{Operation: Automatic, Word: "helo"})
|
||||
}
|
||||
if time.Since(started) > 100*time.Millisecond {
|
||||
t.Fatal("input blocked")
|
||||
}
|
||||
close(gate)
|
||||
service.Close()
|
||||
if service.Submit(Request{}) {
|
||||
t.Fatal("closed worker accepted request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActualEnchantDictionaryAndRankedSuggestions(t *testing.T) {
|
||||
dict, err := open_dictionary("en_US")
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
defer dict.Close()
|
||||
if correct, err := dict.Check("hello"); err != nil || !correct {
|
||||
t.Fatal(correct, err)
|
||||
}
|
||||
if correct, err := dict.Check("helo"); err != nil || correct {
|
||||
t.Fatal(correct, err)
|
||||
}
|
||||
if suggestions, err := dict.Suggest("helo"); err != nil || len(suggestions) == 0 {
|
||||
t.Fatal(suggestions, err)
|
||||
}
|
||||
if dict, err := open_dictionary("zz_ZZ"); err == nil {
|
||||
dict.Close()
|
||||
t.Fatal("missing dictionary accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package spellcheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Language uses the effective session message locale, with no English fallback.
|
||||
// LANGUAGE is a translation fallback list, not the session's regional locale.
|
||||
func Language() string {
|
||||
for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return language_tag(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func language_tag(value string) string {
|
||||
value, _, _ = strings.Cut(strings.TrimSpace(value), ".")
|
||||
value, _, _ = strings.Cut(value, "@")
|
||||
if value == "C" || value == "POSIX" {
|
||||
return ""
|
||||
}
|
||||
value = strings.ReplaceAll(value, "-", "_")
|
||||
for _, char := range value {
|
||||
if !unicode.IsLetter(char) && char != '_' {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Word strips surrounding prose punctuation but leaves structured terminal
|
||||
// tokens intact so they can be excluded rather than checked as fragments.
|
||||
func Word(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "-") || strings.HasPrefix(value, "#") || strings.HasPrefix(value, "@") ||
|
||||
strings.ContainsAny(value, "/\\@_=0123456789") || strings.Contains(value, "://") {
|
||||
return ""
|
||||
}
|
||||
value = strings.TrimFunc(value, func(char rune) bool { return !unicode.IsLetter(char) && !unicode.IsMark(char) })
|
||||
if !valid_word(value) {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func valid_word(value string) bool {
|
||||
if !utf8.ValidString(value) || utf8.RuneCountInString(value) > 128 || value == "" {
|
||||
return false
|
||||
}
|
||||
letters := false
|
||||
for _, char := range value {
|
||||
if unicode.IsLetter(char) {
|
||||
letters = true
|
||||
} else if !unicode.IsMark(char) && char != '\'' && char != '’' && char != '-' && char != '‐' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return letters
|
||||
}
|
||||
|
||||
func Separator(char rune) bool {
|
||||
return unicode.IsSpace(char) || unicode.IsPunct(char) && !strings.ContainsRune("'.:/@\\_=-’‐#", char)
|
||||
}
|
||||
Reference in new issue
Block a user