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:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+87
View File
@@ -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)
}
+147
View File
@@ -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)
}
+188
View File
@@ -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)
}
}
+80
View File
@@ -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);
}
+18
View File
@@ -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
+167
View File
@@ -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
}
}
}
+227
View File
@@ -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")
}
}
+71
View File
@@ -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)
}