package app import ( "strings" "time" "glint/internal/grammar" tea "github.com/charmbracelet/bubbletea" ) // grammarDebounce is how long editing settles before glint sends the buffer to // harper. Long enough to coalesce a burst of keystrokes into one didChange, // short enough that underlines feel live. const grammarDebounce = 400 * time.Millisecond // grammarDiagMsg carries a fresh batch of diagnostics from the harper listener. type grammarDiagMsg struct{ diags []grammar.Diag } // grammarDebounceMsg fires after an edit settles; gen guards against stale ticks. type grammarDebounceMsg struct{ gen int } // initGrammar starts harper-ls when grammar is enabled (config not "off") and the // binary is present, then flags the editor to render grammar underlines. A // missing binary or a start failure leaves grammar silently off — glint behaves // exactly as before. func (a *App) initGrammar() { if strings.EqualFold(a.cfg.Grammar, "off") || !grammar.Available() { return } c, err := grammar.Start() if err != nil { return } a.grammar = c a.editor.SetGrammar(true) } // grammarListen returns a command that blocks for the next diagnostic batch. It // is re-issued after each batch so diagnostics keep flowing; nil when grammar is // off so Init/Update can compose it unconditionally. func (a *App) grammarListen() tea.Cmd { if a.grammar == nil { return nil } ch := a.grammar.Diagnostics() return func() tea.Msg { batch, ok := <-ch if !ok { return nil } return grammarDiagMsg{diags: batch} } } // applyGrammarDiags retains the latest batch and renders it, dropping any lint the // user has ignored this session. func (a *App) applyGrammarDiags(diags []grammar.Diag) { a.lastGrammarDiags = diags a.refreshGrammarDiags() } // refreshGrammarDiags buckets the retained batch by logical line and hands it to the // editor, skipping session-ignored lints. Called after a new batch and after an // ignore, so an ignored underline clears immediately without waiting for harper. func (a *App) refreshGrammarDiags() { byLine := map[int][][2]int{} for _, d := range a.lastGrammarDiags { if a.grammarIgnored(d.Code, a.editor.RuneRangeText(d.Line, d.StartCol, d.EndCol)) { continue } byLine[d.Line] = append(byLine[d.Line], [2]int{d.StartCol, d.EndCol}) } a.editor.SetGrammarDiags(byLine) } // grammarIgnoreKey identifies an ignored grammar lint by its rule code and the exact // flagged text, so the ignore survives edits that move the span and suppresses every // identical occurrence — mirroring spellcheck's whole-word Ignore. func grammarIgnoreKey(code, text string) string { return code + "\x00" + text } // grammarIgnored reports whether a (code, text) lint is ignored this session. func (a *App) grammarIgnored(code, text string) bool { if a.grammarIgnores == nil { return false } _, ok := a.grammarIgnores[grammarIgnoreKey(code, text)] return ok } // ignoreGrammar suppresses a grammar lint for the rest of the session and re-renders // so its underline disappears at once. (Harper's own HarperIgnoreLint is a no-op over // LSP, so glint owns the ignore list; it resets on the next launch.) func (a *App) ignoreGrammar(code, text string) { if a.grammarIgnores == nil { a.grammarIgnores = map[string]struct{}{} } a.grammarIgnores[grammarIgnoreKey(code, text)] = struct{}{} a.refreshGrammarDiags() } // grammarOpen (re)registers the current buffer with harper after the open // document changes (a load, a new blank buffer, or a save-as that renames it), // closing the previous document first. No-op without a client. func (a *App) grammarOpen() { if a.grammar == nil { return } if a.grammarOpened { a.grammar.DidClose(a.grammarPath) } a.grammarPath = a.path a.grammarOpened = true text := string(a.editor.Bytes()) a.grammarText = text a.grammar.DidOpen(a.path, text) a.editor.SetGrammarDiags(nil) // drop the previous doc's underlines until harper replies } // grammarChanged schedules a debounced didChange after an editor keystroke and // returns the tick command (nil without a client). Only the latest tick's // generation survives grammarFlush, so a burst collapses to one sync. func (a *App) grammarChanged() tea.Cmd { if a.grammar == nil { return nil } a.grammarGen++ gen := a.grammarGen return tea.Tick(grammarDebounce, func(time.Time) tea.Msg { return grammarDebounceMsg{gen: gen} }) } // grammarFlush sends the buffer to harper when the debounce tick is the latest // one and the text actually changed since the last sync. func (a *App) grammarFlush(gen int) { if a.grammar == nil || gen != a.grammarGen { return } text := string(a.editor.Bytes()) if text == a.grammarText { return } a.grammarText = text a.grammar.DidChange(a.grammarPath, text) } // toggleGrammar flips grammar checking from the spell popup. With no client it // lazily starts harper (so grammar=off in config can still be enabled at // runtime), reporting when the binary is missing. It returns the listener // command to start after a lazy start, else nil. func (a *App) toggleGrammar() tea.Cmd { if a.grammar == nil { if !grammar.Available() { a.status = "Grammar unavailable — brew install harper" return nil } c, err := grammar.Start() if err != nil { a.status = "Grammar start failed: " + err.Error() return nil } a.grammar = c a.editor.SetGrammar(true) a.grammarOpen() a.status = "Grammar on" return a.grammarListen() } if on := a.editor.ToggleGrammar(); on { a.status = "Grammar on" } else { a.status = "Grammar off" } return nil } // closeGrammar shuts the harper subprocess down at exit. func (a *App) closeGrammar() { if a.grammar != nil { _ = a.grammar.Close() a.grammar = nil } } // Close releases external resources (the harper subprocess). Safe to call more // than once; run() invokes it after the program loop returns. func (a *App) Close() { a.closeGrammar() }