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{} e.invalidate() } // SetSpell sets whether spellcheck is enabled for the session. func (e *Editor) SetSpell(on bool) { e.spellOn = on; e.invalidate() } // ToggleSpell flips spellcheck and returns the new state. func (e *Editor) ToggleSpell() bool { e.spellOn = !e.spellOn; e.invalidate(); 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 e.invalidate() return err } // spellKnown is a cached membership test; the cache is dropped when the personal // dictionary changes. Session-ignored words always count as known. func (e *Editor) spellKnown(word string) bool { lw := strings.ToLower(word) if e.spellIgnore[lw] { return true } 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 } // IgnoreWord marks word correct for this session only (the popup's Ignore // action); it is not written to the personal dictionary. func (e *Editor) IgnoreWord(word string) { if e.spellIgnore == nil { e.spellIgnore = map[string]bool{} } e.spellIgnore[strings.ToLower(word)] = true e.invalidate() } // Suggest returns up to max spelling corrections for word, or nil without a // dictionary. func (e *Editor) Suggest(word string, max int) []string { if e.dict == nil { return nil } return e.dict.Suggest(word, max) } // FlaggedWordAt returns the misspelled word covering rune column col on the given // row (inclusive of the column just past the word, so a cursor resting at the // word's end still resolves it), plus its rune range [start,end). ok is false // when spellcheck is inactive or no flagged word is there. func (e *Editor) FlaggedWordAt(row, col int) (word string, start, end int, ok bool) { if !e.spellActive() || row < 0 || row >= len(e.Lines) { return "", 0, 0, false } all := ScanLines(e.Lines, e.theme) all = e.spellPass(all) pos := 0 for _, sp := range all[row] { n := len([]rune(sp.Text)) if sp.Wavy && col >= pos && col <= pos+n { return sp.Text, pos, pos + n, true } pos += n } return "", 0, 0, false } // ReplaceWordAt swaps the rune range [start,end) on row for replacement, parks // the cursor after it, marks the buffer dirty, and records an undo checkpoint. func (e *Editor) ReplaceWordAt(row, start, end int, replacement string) { if row < 0 || row >= len(e.Lines) { return } e.PushUndo() r := []rune(e.Lines[row]) if start < 0 || end > len(r) || start > end { return } e.Lines[row] = string(r[:start]) + replacement + string(r[end:]) e.invalidate() e.Cursor = Position{Row: row, Col: start + len([]rune(replacement))} e.anchor = nil e.Dirty = true e.setGoal() e.followCursor() } // 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 }