package editor import ( "regexp" "strings" "glint/internal/spell" ) // urlRe matches URLs and bare emails so their letters aren't spellchecked. var urlRe = regexp.MustCompile(`(?:https?://|www\.|mailto:)\S+|\S+@\S+\.\S+`) // wordRe matches a candidate word: a letter followed by letters or apostrophes // (straight or curly), so possessives ride along with their base. var wordRe = regexp.MustCompile(`\p{L}[\p{L}'’]*`) // SetDict gives the editor a loaded dictionary; spellcheck is inert until one is // set. SetSpell toggles the user preference. func (e *Editor) SetDict(d *spell.Dict) { e.dict = d e.spellCache = map[string]bool{} } // SetSpell sets whether spellcheck is enabled for the session. func (e *Editor) SetSpell(on bool) { e.spellOn = on } // ToggleSpell flips spellcheck and returns the new state. func (e *Editor) ToggleSpell() bool { e.spellOn = !e.spellOn; return e.spellOn } // SpellEnabled reports the user's session toggle (independent of whether a dict // is loaded or the file is code). func (e *Editor) SpellEnabled() bool { return e.spellOn } // spellActive reports whether spellcheck should actually run: enabled, a // dictionary is loaded, and the buffer is prose (not a recognized code file, // which reuses the TASK-018 extension routing via codeFile). func (e *Editor) spellActive() bool { return e.spellOn && e.dict != nil && e.codeFile == "" } // AddToDictionary adds the word to the personal dictionary and invalidates the // spell cache so its underline clears on the next render. func (e *Editor) AddToDictionary(word string) error { if e.dict == nil { return nil } err := e.dict.Add(word) e.spellCache = map[string]bool{} // membership changed; drop cached verdicts return err } // spellKnown is a cached membership test; the cache is dropped when the personal // dictionary changes. func (e *Editor) spellKnown(word string) bool { lw := strings.ToLower(word) if v, ok := e.spellCache[lw]; ok { return v } k := e.dict.Known(lw) if e.spellCache == nil { e.spellCache = map[string]bool{} } e.spellCache[lw] = k return k } // spellPass splits every prose span into words and re-emits misspelled ones as // Wavy (undercurl) spans, leaving all other spans untouched. The per-line span // text is unchanged, so the markup-visible invariant holds. func (e *Editor) spellPass(all [][]Span) [][]Span { for li, spans := range all { var out []Span changed := false for _, sp := range spans { if !sp.Prose { out = append(out, sp) continue } split := e.splitProse(sp) if len(split) != 1 { changed = true } out = append(out, split...) } if changed { all[li] = out } } return all } // splitProse partitions one prose span's text, marking misspelled words with the // theme's undercurl. Words inside URLs/emails, acronyms, camel/brand casing, and // words under three letters are left alone. When nothing is misspelled the // original span is returned unchanged. func (e *Editor) splitProse(sp Span) []Span { text := sp.Text skips := urlRe.FindAllStringIndex(text, -1) var out []Span last := 0 emit := func(s string, wavy bool) { if s == "" { return } ns := sp ns.Text = s ns.Wavy = wavy if wavy { ns.UnderColor = e.theme.Spell } out = append(out, ns) } for _, w := range wordRe.FindAllStringIndex(text, -1) { word := text[w[0]:w[1]] if overlapsAny(w, skips) || !checkableWord(word) || e.spellKnown(word) { continue } emit(text[last:w[0]], false) emit(word, true) last = w[1] } if last == 0 { return []Span{sp} // nothing flagged } emit(text[last:], false) return out } // overlapsAny reports whether byte range r overlaps any range in rs. func overlapsAny(r []int, rs [][]int) bool { for _, s := range rs { if r[0] < s[1] && s[0] < r[1] { return true } } return false } // checkableWord filters out tokens that aren't ordinary prose words: anything // under three letters, all-caps acronyms (NASA, API), and internal-capital // brand/camelCase (iOS, GitHub). Only the first letter may be uppercase. func checkableWord(w string) bool { r := []rune(w) if len(r) < 3 { return false } allUpper := true for i, c := range r { if c >= 'a' && c <= 'z' || c == '\'' || c == '’' { allUpper = false } if i > 0 && c >= 'A' && c <= 'Z' { return false // internal uppercase: brand / camelCase } } return !allUpper }