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