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 buckets a diagnostic batch by logical line and hands it to // the editor as green-undercurl ranges. func (a *App) applyGrammarDiags(diags []grammar.Diag) { byLine := map[int][][2]int{} for _, d := range diags { byLine[d.Line] = append(byLine[d.Line], [2]int{d.StartCol, d.EndCol}) } a.editor.SetGrammarDiags(byLine) } // 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() }