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
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, &params); 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 		}
+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()