1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
|
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() }
|