Files
Kharec b0bcb812b1 fix: bound the spelling checker's word cache
The remembered answers are keyed on user input and never evicted: a
long session could grow the map without bound. Clear it at 10000 words
and let it refill - a script repeats the same words, so re-checks are
cheap.
2026-10-15 17:56:48 +02:00

220 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package spell
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"unicode"
"plotzilla/internal/screenplay"
)
// Word is a word found in a text. Offset and Length count runes, like
// every offset exchanged with the editor.
type Word struct {
Text string
Offset int
Length int
}
func isApostrophe(r rune) bool { return r == '\'' || r == '’' }
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsMark(r) || unicode.IsDigit(r)
}
// Words splits text into words: runs of letters and digits, with the
// apostrophes inside them ("don't", "l'homme"). Hyphens, slashes and
// dots separate words, so each half of "well-known" is checked alone.
func Words(text string) []Word {
var words []Word
runes := []rune(text)
for i := 0; i < len(runes); {
if !isWordRune(runes[i]) {
i++
continue
}
start := i
for i < len(runes) && (isWordRune(runes[i]) ||
(isApostrophe(runes[i]) && i+1 < len(runes) && isWordRune(runes[i+1]))) {
i++
}
words = append(words, Word{Text: string(runes[start:i]), Offset: start, Length: i - start})
}
return words
}
// Misspelling locates a misspelled word: the index of its element in
// document order (as layout.Break) and its rune offset and length.
type Misspelling struct {
Element int `json:"element"`
Offset int `json:"offset"`
Length int `json:"length"`
Word string `json:"word"`
}
// jargon lists screenplay abbreviations that no dictionary knows.
var jargon = map[string]bool{
"int": true, "ext": true, "est": true, "cont'd": true, "contd": true,
"pov": true, "vo": true, "os": true, "oc": true, "ecu": true, "mos": true,
}
var sceneOpener = regexp.MustCompile(`(?i)^\s*(int\.?/ext|ext\.?/int|int|ext|est|i/e)\b`)
// Checker checks screenplays against one dictionary plus the user's own
// words, and remembers the answers: a script repeats the same words.
type Checker struct {
dict *Dictionary
mu sync.Mutex
cache map[string]bool
personal map[string]bool // lower-case
}
func NewChecker(d *Dictionary, personal []string) *Checker {
c := &Checker{dict: d, cache: map[string]bool{}, personal: map[string]bool{}}
for _, w := range personal {
c.personal[strings.ToLower(w)] = true
}
return c
}
// Add accepts word from now on, in any letter case.
func (c *Checker) Add(word string) {
c.mu.Lock()
defer c.mu.Unlock()
c.personal[strings.ToLower(normalize(word))] = true
}
func normalize(w string) string { return strings.ReplaceAll(w, "’", "'") }
// cacheLimit bounds the remembered answers, which are keyed on user input:
// a long session of typos would grow the map forever otherwise. When full,
// the cache is cleared and refilled; a script repeats the same words, so
// re-checks are cheap.
const cacheLimit = 10000
// Check reports whether word is correct. Words with digits ("3rd", "R2D2")
// and single letters are always accepted.
func (c *Checker) Check(word string) bool {
if len([]rune(word)) < 2 || strings.IndexFunc(word, unicode.IsDigit) >= 0 {
return true
}
lower := strings.ToLower(normalize(word))
c.mu.Lock()
defer c.mu.Unlock()
if jargon[lower] || c.personal[lower] {
return true
}
ok, found := c.cache[word]
if !found {
if len(c.cache) >= cacheLimit {
clear(c.cache)
}
ok = c.dict.Check(word)
c.cache[word] = ok
}
return ok
}
func (c *Checker) Suggest(word string) []string { return c.dict.Suggest(word) }
// Misspellings returns the misspelled words of a script. Character cues
// hold names, so they are not checked, and the names they introduce are
// accepted everywhere else (an action line often spells them in capitals).
func (c *Checker) Misspellings(doc *screenplay.Document) []Misspelling {
names := map[string]bool{}
for _, el := range doc.Elements {
if el.Type == screenplay.Character {
for _, w := range Words(screenplay.CharacterName(el.PrintedText())) {
names[strings.ToLower(normalize(w.Text))] = true
}
}
}
out := []Misspelling{}
for i, el := range doc.Elements {
if el.Type == screenplay.Character {
continue
}
text := el.Text()
skip := 0 // runes of a scene heading's "INT./EXT."
if el.Type == screenplay.Scene {
if m := sceneOpener.FindString(text); m != "" {
skip = len([]rune(m))
}
}
for _, w := range Words(text) {
if w.Offset < skip || isName(names, w.Text) || c.Check(w.Text) {
continue
}
out = append(out, Misspelling{Element: i, Offset: w.Offset, Length: w.Length, Word: w.Text})
}
}
return out
}
// isName reports whether w is one of the names, possibly possessive
// ("ELODIE's").
func isName(names map[string]bool, w string) bool {
lower := strings.ToLower(normalize(w))
return names[lower] || names[strings.TrimSuffix(lower, "'s")]
}
// Language is an installed dictionary, named after its files ("en_US").
type Language struct {
Code string
Aff string
Dic string
}
// Available lists the dictionaries found in dirs; when the same language
// is in several directories, the first one wins.
func Available(dirs []string) []Language {
seen := map[string]bool{}
var out []Language
for _, dir := range dirs {
matches, _ := filepath.Glob(filepath.Join(dir, "*.dic"))
sort.Strings(matches)
for _, dic := range matches {
code := strings.TrimSuffix(filepath.Base(dic), ".dic")
aff := strings.TrimSuffix(dic, ".dic") + ".aff"
if seen[code] || strings.HasPrefix(code, "hyph_") || strings.HasPrefix(code, "th_") {
continue
}
if _, err := os.Stat(aff); err != nil {
continue
}
seen[code] = true
out = append(out, Language{Code: code, Aff: aff, Dic: dic})
}
}
return out
}
// Preferred picks the dictionary that suits a locale such as
// "fr_FR.UTF-8": the same language and region, then the same language,
// then American English, then the first one.
func Preferred(codes []string, locale string) string {
if len(codes) == 0 {
return ""
}
locale, _, _ = strings.Cut(locale, ".")
locale, _, _ = strings.Cut(locale, "@")
lang, _, _ := strings.Cut(locale, "_")
for _, want := range []func(string) bool{
func(c string) bool { return locale != "" && c == locale },
func(c string) bool { return lang != "" && (c == lang || strings.HasPrefix(c, lang+"_")) },
func(c string) bool { return c == "en_US" },
func(c string) bool { return strings.HasPrefix(c, "en_") || c == "en" },
} {
for _, c := range codes {
if want(c) {
return c
}
}
}
return codes[0]
}