commit 8f6b08f
delthas
·
2026-03-11 12:20:33 +0000 UTC
parent 8da8a07
Add spell checking via harper-ls Integrate harper-ls as an LSP spell checker running in the background. When enabled with `spell-check true`, misspelled words are highlighted with curly underlines. IRC nicks from the current network are automatically added to the dictionary to avoid false positives. Harper-ls is communicated with over stdio using the LSP protocol. A write channel with backpressure ensures fast typing doesn't overwhelm the language server. If harper-ls is not installed, the feature is silently disabled. Inspired by patches from gildarts and Consolatis, which used aspell/gospell. This implementation uses harper-ls instead, avoiding external library dependencies. See: https://lists.sr.ht/~delthas/senpai-dev/patches/50746 See: https://lists.sr.ht/~delthas/senpai-dev/patches/59148 See: https://github.com/Consolatis/senpai/commit/99c010062d571665ce40e9b04caa10a643196c8e Fixes: https://todo.sr.ht/~delthas/senpai/99
7 files changed,
+515,
-2
M
app.go
+30,
-0
1@@ -158,6 +158,8 @@ type App struct {
2 shownBouncerNotice bool
3
4 closing atomic.Bool
5+
6+ harper *harperState
7 }
8
9 func NewApp(cfg Config) (app *App, err error) {
10@@ -254,6 +256,7 @@ func (app *App) Close() {
11 session.Close()
12 }
13 ui.DBusStop()
14+ app.harperClose()
15 app.closing.Store(true)
16 go func() {
17 // drain remaining events
18@@ -276,6 +279,7 @@ func (app *App) Run() {
19 if app.lastCloseTime.IsZero() {
20 app.lastCloseTime = time.Now()
21 }
22+ app.harperInit()
23 go app.uiLoop()
24 go app.ircLoop("")
25 app.eventLoop()
26@@ -687,6 +691,16 @@ func (app *App) handleUIEvent(ev interface{}) bool {
27 } else {
28 app.uploadingProgress = &ev.Progress
29 }
30+ case *events.EventSpellCheck:
31+ text := string(app.win.InputContent())
32+ typos := make([]events.TypoRange, len(ev.Typos))
33+ for i, t := range ev.Typos {
34+ typos[i] = events.TypoRange{
35+ Start: utf16OffsetToRuneIndex(text, t.Start),
36+ End: utf16OffsetToRuneIndex(text, t.End),
37+ }
38+ }
39+ app.win.SetTypos(typos)
40 case *events.EventOpenLink:
41 host, target, netID := app.findNetworkByLink(ev.Link)
42 if netID != "" {
43@@ -844,11 +858,13 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
44 if i := app.win.VerticalBufferOffset(y); i == app.win.ClickedBuffer() {
45 app.win.GoToBufferNo(i)
46 app.clearBufferCommand()
47+ app.spellCheck()
48 }
49 } else if app.win.ChannelWidth() == 0 && y == h-1 {
50 if i := app.win.HorizontalBufferOffset(x); i >= 0 && i == app.win.ClickedBuffer() {
51 app.win.GoToBufferNo(i)
52 app.clearBufferCommand()
53+ app.spellCheck()
54 }
55 } else if x > w-app.win.MemberWidth() && y < h-memberItems*2 {
56 if i := y - 2 + app.win.MemberOffset(); i >= 0 && i == app.win.ClickedMember() {
57@@ -919,6 +935,7 @@ func (app *App) handleAction(action string, args ...string) {
58 case "quit":
59 if app.win.InputClear() {
60 app.typing()
61+ app.spellCheck()
62 } else {
63 app.win.InputSet("/quit")
64 }
65@@ -939,15 +956,19 @@ func (app *App) handleAction(action string, args ...string) {
66 case "buffer-next":
67 app.win.NextBuffer()
68 app.win.ScrollToBuffer()
69+ app.spellCheck()
70 case "buffer-previous":
71 app.win.PreviousBuffer()
72 app.win.ScrollToBuffer()
73+ app.spellCheck()
74 case "buffer-next-unread":
75 app.win.NextUnreadBuffer()
76 app.win.ScrollToBuffer()
77+ app.spellCheck()
78 case "buffer-previous-unread":
79 app.win.PreviousUnreadBuffer()
80 app.win.ScrollToBuffer()
81+ app.spellCheck()
82 case "cursor-right-word":
83 app.win.InputRightWord()
84 case "cursor-left-word":
85@@ -963,32 +984,39 @@ func (app *App) handleAction(action string, args ...string) {
86 case "cursor-delete-previous-word":
87 if app.win.InputDeleteWord() {
88 app.typing()
89+ app.spellCheck()
90 }
91 case "cursor-delete-next-word":
92 if app.win.InputDeleteNextWord() {
93 app.typing()
94+ app.spellCheck()
95 }
96 case "cursor-delete-previous":
97 if app.win.InputBackspace() {
98 app.typing()
99+ app.spellCheck()
100 }
101 case "cursor-delete-next":
102 if app.win.InputDelete() {
103 app.typing()
104+ app.spellCheck()
105 }
106 case "cursor-delete-before":
107 if app.win.InputDeleteBefore() {
108 app.typing()
109+ app.spellCheck()
110 }
111 case "cursor-delete-after":
112 if app.win.InputDeleteAfter() {
113 app.typing()
114+ app.spellCheck()
115 }
116 case "search-editor":
117 app.win.InputBackSearch()
118 case "auto-complete":
119 if app.win.InputAutoComplete() {
120 app.typing()
121+ app.spellCheck()
122 }
123 case "close-overlay":
124 app.win.CloseOverlay()
125@@ -1028,6 +1056,7 @@ func (app *App) handleAction(action string, args ...string) {
126 maxInt := int(^uint(0) >> 1)
127 app.win.GoToBufferNo(maxInt)
128 }
129+ app.spellCheck()
130 }
131 case "none":
132 default:
133@@ -1127,6 +1156,7 @@ func (app *App) handleKeyEvent(ev vaxis.Key) {
134 app.win.InputRune(r)
135 }
136 app.typing()
137+ app.spellCheck()
138 return
139 }
140
+12,
-2
1@@ -98,8 +98,9 @@ type Config struct {
2
3 Channels []string
4
5- Typings bool
6- Mouse bool
7+ Typings bool
8+ Mouse bool
9+ SpellCheck bool
10
11 Highlights []string
12 OnHighlightPath string
13@@ -144,6 +145,7 @@ func Defaults() Config {
14 Channels: nil,
15 Typings: true,
16 Mouse: true,
17+ SpellCheck: false,
18 Highlights: nil,
19 OnHighlightPath: "",
20 OnHighlightBeep: false,
21@@ -385,6 +387,14 @@ func unmarshal(filename string, cfg *Config) (err error) {
22 if cfg.Mouse, err = strconv.ParseBool(mouse); err != nil {
23 return err
24 }
25+ case "spell-check":
26+ var spellCheck string
27+ if err := d.ParseParams(&spellCheck); err != nil {
28+ return err
29+ }
30+ if cfg.SpellCheck, err = strconv.ParseBool(spellCheck); err != nil {
31+ return err
32+ }
33 case "colors":
34 for _, child := range d.Children {
35 var colorStr string
+4,
-0
1@@ -122,6 +122,10 @@ pane-widths {
2 *mouse*
3 Enable or disable mouse support. Defaults to true.
4
5+*spell-check*
6+ Enable spell checking using harper-ls. Requires harper-ls to be installed.
7+ Defaults to false.
8+
9 *colors* { ... }
10 Settings for colors of different UI elements.
11
+9,
-0
1@@ -49,3 +49,12 @@ type EventFileUpload struct {
2 type EventOpenLink struct {
3 Link string
4 }
5+
6+type TypoRange struct {
7+ Start int
8+ End int
9+}
10+
11+type EventSpellCheck struct {
12+ Typos []TypoRange
13+}
+439,
-0
1@@ -0,0 +1,439 @@
2+package senpai
3+
4+import (
5+ "bufio"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "unicode/utf16"
15+
16+ "git.sr.ht/~delthas/senpai/events"
17+ "git.sr.ht/~delthas/senpai/ui"
18+)
19+
20+// Rules disabled for informal IM context.
21+// Full list: https://writewithharper.com/docs/rules
22+var harperDisabledLinters = map[string]bool{
23+ // Formality
24+ "SentenceCapitalization": false,
25+ "CapitalizePersonalPronouns": false,
26+ "LongSentences": false,
27+ "UnclosedQuotes": false,
28+ "OxfordComma": false,
29+ "Spaces": false,
30+ "NoFrenchSpaces": false,
31+ "AvoidCurses": false,
32+ "Hedging": false,
33+ "FillerWords": false,
34+ "DiscourseMarkers": false,
35+
36+ // Initialisms (btw, imo, ttyl, brb, etc.)
37+ "ByTheWay": false,
38+ "InMyOpinion": false,
39+ "InMyHumbleOpinion": false,
40+ "TalkToYouLater": false,
41+ "ForWhatItsWorth": false,
42+ "AsFarAsIKnow": false,
43+ "BeRightBack": false,
44+ "OhMyGod": false,
45+ "AsSoonAsPossible": false,
46+ "InRealLife": false,
47+ "ForYourInformation": false,
48+ "IDontKnow": false,
49+ "IfYouKnowYouKnow": false,
50+ "InCaseYouMissedIt": false,
51+ "ToBeHonest": false,
52+ "PleaseTakeALook": false,
53+ "NeverMind": false,
54+ "IfIRecallCorrectly": false,
55+ "Really": false,
56+ "ExplainLikeImFive": false,
57+ "RedundantIIRC": false,
58+
59+ // Casual abbreviations
60+ "ExpandThrough": false,
61+ "ExpandWith": false,
62+ "ExpandWithout": false,
63+ "ExpandForward": false,
64+ "ExpandBecause": false,
65+ "ExpandPrevious": false,
66+ "ExpandControl": false,
67+ "ExpandMinimum": false,
68+ "ExpandTimeShorthands": false,
69+ "ExpandMemoryShorthands": false,
70+ "ExpandParameter": false,
71+ "ExpandDependencies": false,
72+ "ExpandStandardInputAndOutput": false,
73+ "Cybersec": false,
74+
75+ // Overly prescriptive style
76+ "Dashes": false,
77+ "EllipsisLength": false,
78+ "SendAnEmailTo": false,
79+ "Touristic": false,
80+ "BoringWords": false,
81+ "Freezing": false,
82+ "Starving": false,
83+ "Excellent": false,
84+ "FatalOutcome": false,
85+ "AvoidAndAlso": false,
86+ "CondenseAllThe": false,
87+ "DotInitialisms": false,
88+
89+ // Too aggressive for technical/casual chat
90+ "SplitWords": false,
91+ "OrthographicConsistency": false,
92+ "ToDoHyphen": false,
93+ "PhrasalVerbAsCompoundNoun": false,
94+ "DisjointPrefixes": false,
95+ "NeedToNoun": false,
96+ "MissingTo": false,
97+ "MoreAdjective": false,
98+ "RightClick": false,
99+ "MassNouns": false,
100+ "HaveTakeALook": false,
101+}
102+
103+type harperConfig struct {
104+ HarperLS harperLSConfig `json:"harper-ls"`
105+}
106+
107+type harperLSConfig struct {
108+ Linters map[string]bool `json:"linters"`
109+ UserDictPath string `json:"userDictPath,omitempty"`
110+}
111+
112+type lspPosition struct {
113+ Character int `json:"character"`
114+}
115+
116+type lspRange struct {
117+ Start lspPosition `json:"start"`
118+ End lspPosition `json:"end"`
119+}
120+
121+type lspDiagnostic struct {
122+ Range lspRange `json:"range"`
123+}
124+
125+type lspMessage struct {
126+ ID json.RawMessage `json:"id"`
127+ Method string `json:"method"`
128+ Params json.RawMessage `json:"params"`
129+}
130+
131+type lspDiagnosticsParams struct {
132+ Diagnostics []lspDiagnostic `json:"diagnostics"`
133+}
134+
135+type harperState struct {
136+ cmd *exec.Cmd
137+ stdin io.WriteCloser
138+ stdout *bufio.Reader
139+ writes chan []byte
140+ textCh chan string
141+
142+ reqID int
143+ ver int
144+ dictPath string
145+ dictPrev string
146+}
147+
148+func (app *App) harperInit() {
149+ if !app.cfg.SpellCheck || app.cfg.Transient {
150+ return
151+ }
152+ path, err := exec.LookPath("harper-ls")
153+ if err != nil {
154+ return
155+ }
156+ cmd := exec.Command(path, "--stdio")
157+ stdin, err := cmd.StdinPipe()
158+ if err != nil {
159+ return
160+ }
161+ stdout, err := cmd.StdoutPipe()
162+ if err != nil {
163+ stdin.Close()
164+ return
165+ }
166+ if err := cmd.Start(); err != nil {
167+ stdin.Close()
168+ return
169+ }
170+
171+ dictFile, err := os.CreateTemp("", "senpai-harper-dict-*.txt")
172+ if err != nil {
173+ stdin.Close()
174+ cmd.Process.Kill()
175+ return
176+ }
177+ dictFile.Close()
178+
179+ h := &harperState{
180+ cmd: cmd,
181+ stdin: stdin,
182+ stdout: bufio.NewReader(stdout),
183+ writes: make(chan []byte, 64),
184+ textCh: make(chan string, 1),
185+ dictPath: dictFile.Name(),
186+ }
187+ go h.writeLoop()
188+
189+ if err := h.sendRequest("initialize", json.RawMessage(`{
190+ "capabilities": {
191+ "textDocument": {
192+ "publishDiagnostics": {}
193+ }
194+ },
195+ "rootUri": null,
196+ "processId": null
197+ }`)); err != nil {
198+ cmd.Process.Kill()
199+ return
200+ }
201+
202+ if err := h.sendNotification("initialized", json.RawMessage(`{}`)); err != nil {
203+ cmd.Process.Kill()
204+ return
205+ }
206+
207+ app.harper = h
208+ go app.harperLoop()
209+}
210+
211+func (app *App) harperLoop() {
212+ h := app.harper
213+ opened := false
214+ for {
215+ msg, err := h.readMessage()
216+ if err != nil {
217+ app.queueStatusLine("", ui.Line{
218+ Head: ui.PlainString("--"),
219+ Body: ui.PlainString("spell checker error: " + err.Error()),
220+ })
221+ return
222+ }
223+ var envelope lspMessage
224+ if err := json.Unmarshal(msg, &envelope); err != nil {
225+ continue
226+ }
227+ if envelope.ID != nil && envelope.Method != "" {
228+ switch envelope.Method {
229+ case "workspace/configuration":
230+ cfg, _ := json.Marshal([]harperConfig{{HarperLS: harperLSConfig{Linters: harperDisabledLinters, UserDictPath: h.dictPath}}})
231+ h.sendResponse(envelope.ID, cfg)
232+ if !opened {
233+ opened = true
234+ h.sendNotification("textDocument/didOpen", json.RawMessage(`{
235+ "textDocument": {
236+ "uri": "file:///senpai-input.txt",
237+ "languageId": "plaintext",
238+ "version": 0,
239+ "text": ""
240+ }
241+ }`))
242+ }
243+ default:
244+ h.sendResponse(envelope.ID, json.RawMessage(`null`))
245+ }
246+ continue
247+ }
248+ if envelope.Method != "textDocument/publishDiagnostics" {
249+ continue
250+ }
251+ var params lspDiagnosticsParams
252+ if err := json.Unmarshal(envelope.Params, ¶ms); err != nil {
253+ continue
254+ }
255+ var typos []events.TypoRange
256+ for _, d := range params.Diagnostics {
257+ typos = append(typos, events.TypoRange{
258+ Start: d.Range.Start.Character,
259+ End: d.Range.End.Character,
260+ })
261+ }
262+ app.postEvent(event{
263+ src: "*",
264+ content: &events.EventSpellCheck{Typos: typos},
265+ })
266+ }
267+}
268+
269+func (app *App) spellCheck() {
270+ if app.harper == nil {
271+ return
272+ }
273+ input := app.win.InputContent()
274+ if len(input) == 0 {
275+ app.win.SetTypos(nil)
276+ return
277+ }
278+ if isCommand(input) {
279+ app.win.SetTypos(nil)
280+ return
281+ }
282+ app.updateHarperDict()
283+ app.harper.sendChange(string(input))
284+}
285+
286+func (app *App) updateHarperDict() {
287+ netID, _ := app.win.CurrentBuffer()
288+ s := app.sessions[netID]
289+ if s == nil {
290+ return
291+ }
292+ words := s.Users()
293+ slices.Sort(words)
294+ content := strings.Join(words, "\n")
295+ if content == app.harper.dictPrev {
296+ return
297+ }
298+ app.harper.dictPrev = content
299+ os.WriteFile(app.harper.dictPath, []byte(content), 0600)
300+}
301+
302+func (app *App) harperClose() {
303+ if app.harper == nil {
304+ return
305+ }
306+ os.Remove(app.harper.dictPath)
307+ app.harper.sendNotification("shutdown", json.RawMessage(`null`))
308+ close(app.harper.writes)
309+ app.harper.cmd.Process.Kill()
310+}
311+
312+func (h *harperState) sendChange(text string) {
313+ select {
314+ case <-h.textCh:
315+ default:
316+ }
317+ h.textCh <- text
318+}
319+
320+func (h *harperState) sendRequest(method string, params json.RawMessage) error {
321+ h.reqID++
322+ msg, _ := json.Marshal(map[string]interface{}{
323+ "jsonrpc": "2.0",
324+ "id": h.reqID,
325+ "method": method,
326+ "params": params,
327+ })
328+ return h.writeMessage(msg)
329+}
330+
331+func (h *harperState) sendResponse(id json.RawMessage, result json.RawMessage) error {
332+ msg, _ := json.Marshal(map[string]interface{}{
333+ "jsonrpc": "2.0",
334+ "id": id,
335+ "result": result,
336+ })
337+ return h.writeMessage(msg)
338+}
339+
340+func (h *harperState) sendNotification(method string, params json.RawMessage) error {
341+ msg, _ := json.Marshal(map[string]interface{}{
342+ "jsonrpc": "2.0",
343+ "method": method,
344+ "params": params,
345+ })
346+ return h.writeMessage(msg)
347+}
348+
349+func (h *harperState) writeMessage(msg []byte) error {
350+ header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
351+ h.writes <- append([]byte(header), msg...)
352+ return nil
353+}
354+
355+func (h *harperState) writeLoop() {
356+ for {
357+ select {
358+ case msg, ok := <-h.writes:
359+ if !ok {
360+ return
361+ }
362+
363+ if _, err := h.stdin.Write(msg); err != nil {
364+ return
365+ }
366+ case text := <-h.textCh:
367+ h.ver++
368+ params, _ := json.Marshal(map[string]interface{}{
369+ "textDocument": map[string]interface{}{
370+ "uri": "file:///senpai-input.txt",
371+ "version": h.ver,
372+ },
373+ "contentChanges": []map[string]interface{}{
374+ {"text": text},
375+ },
376+ })
377+ msg, _ := json.Marshal(map[string]interface{}{
378+ "jsonrpc": "2.0",
379+ "method": "textDocument/didChange",
380+ "params": json.RawMessage(params),
381+ })
382+
383+ header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
384+ if _, err := h.stdin.Write(append([]byte(header), msg...)); err != nil {
385+ return
386+ }
387+ // Wait for the config response before accepting more text,
388+ // so we throttle to harper's processing speed.
389+ resp, ok := <-h.writes
390+ if !ok {
391+ return
392+ }
393+
394+ if _, err := h.stdin.Write(resp); err != nil {
395+ return
396+ }
397+ }
398+ }
399+}
400+
401+func (h *harperState) readMessage() ([]byte, error) {
402+ var contentLength int
403+ for {
404+ line, err := h.stdout.ReadString('\n')
405+ if err != nil {
406+ return nil, err
407+ }
408+ line = line[:len(line)-1]
409+ if len(line) > 0 && line[len(line)-1] == '\r' {
410+ line = line[:len(line)-1]
411+ }
412+ if line == "" {
413+ break
414+ }
415+ if len(line) > 16 && line[:16] == "Content-Length: " {
416+ contentLength, _ = strconv.Atoi(line[16:])
417+ }
418+ }
419+ if contentLength == 0 {
420+ return nil, fmt.Errorf("missing content length")
421+ }
422+ body := make([]byte, contentLength)
423+ if _, err := io.ReadFull(h.stdout, body); err != nil {
424+ return nil, err
425+ }
426+ return body, nil
427+}
428+
429+func utf16OffsetToRuneIndex(s string, utf16Offset int) int {
430+ runeIdx := 0
431+ utf16Pos := 0
432+ for _, r := range s {
433+ if utf16Pos >= utf16Offset {
434+ return runeIdx
435+ }
436+ utf16Pos += len(utf16.Encode([]rune{r}))
437+ runeIdx++
438+ }
439+ return runeIdx
440+}
+17,
-0
1@@ -3,6 +3,7 @@ package ui
2 import (
3 "strings"
4
5+ "git.sr.ht/~delthas/senpai/events"
6 "git.sr.ht/~rockorager/vaxis"
7 )
8
9@@ -73,6 +74,8 @@ type Editor struct {
10 // oldest (lowest) index in text of lines that were changed.
11 // used as an optimization to reduce copying when flushing lines.
12 oldestTextChange int
13+
14+ typos []events.TypoRange
15 }
16
17 // NewEditor returns a new Editor.
18@@ -323,6 +326,7 @@ func (e *Editor) Flush() string {
19 e.cursorIdx = 0
20 e.offsetIdx = 0
21 e.autoCache = nil
22+ e.typos = nil
23 e.backsearchEnd()
24 e.oldestTextChange = len(e.text) - 1
25 return content
26@@ -582,6 +586,10 @@ func (e *Editor) bumpOldestChange() {
27 }
28 }
29
30+func (e *Editor) SetTypos(typos []events.TypoRange) {
31+ e.typos = typos
32+}
33+
34 func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
35 var st vaxis.Style
36
37@@ -615,6 +623,15 @@ func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
38 if i >= autoStart && i < autoEnd {
39 s.UnderlineStyle = vaxis.UnderlineSingle
40 }
41+ if s.UnderlineStyle == vaxis.UnderlineOff {
42+ for _, t := range e.typos {
43+ if i >= t.Start && i < t.End {
44+ s.UnderlineStyle = vaxis.UnderlineCurly
45+ s.UnderlineColor = e.ui.config.Colors.Gray
46+ break
47+ }
48+ }
49+ }
50 if i == autoStart {
51 autoX = x
52 }
M
ui/ui.go
+4,
-0
1@@ -627,6 +627,10 @@ func (ui *UI) InputRune(r rune) {
2 ui.e.PutRune(r)
3 }
4
5+func (ui *UI) SetTypos(typos []events.TypoRange) {
6+ ui.e.SetTypos(typos)
7+}
8+
9 // InputEnter returns true if the event was eaten
10 func (ui *UI) InputEnter() bool {
11 return ui.e.Enter()