feat: grammar checking via Harper (harper-ls, green undercurl) (TASK-043)
85cc4e9de595b4e8f06fe3b4f8ec60e5d76b1aad
humdrum <me@humdrum.me> · 2026-07-14 10:29
parent 01d8b00b
feat: grammar checking via Harper (harper-ls, green undercurl) (TASK-043) Add optional grammar checking with a green curly underline, distinct from the red spellcheck undercurl. glint speaks LSP to a harper-ls subprocess over stdio, so the build stays pure-Go and zero-cgo (harper-core is Rust). - internal/grammar: minimal LSP client — Content-Length framing, the initialize handshake, and the workspace/configuration + registerCapability server requests harper gates diagnostics behind. Full-document sync; UTF-16 -> rune column mapping. Available() gates the whole feature on harper-ls being on PATH, so an absent binary is a clean no-op. - Rendering reuses the undercurl span infra (TASK-020): new theme.Grammar green in all three palettes; a grammarPass layers green undercurl after spellPass. Red spelling wins when a word is both misspelled and in a grammar span. - Async plumbing (first tea.Cmd use): a channel listener delivers diagnostic batches; edits trigger a 400ms-debounced didChange with a generation guard and text-dedupe. harper starts in Init (not New) so unit tests never spawn a subprocess; the process is reaped on quit. - Runtime toggle folded into the Alt+; proofing popup (g); config key grammar = auto | on | off (auto = on when harper-ls is present). Help, README, and the config wizard updated. Tests: framing, position mapping, red-wins overlap, inactive/code-file gating, plus a live end-to-end harper round-trip (skipped without the binary or under -short). TASK-044 filed as follow-up: grammar suggestions & ignore via codeAction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
23 files changed
README.md +24 −4
@@ -77,7 +77,7 @@ | `Ctrl+D` | today's daily note |
| `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
| `Ctrl+B` | new note in the inbox |
| `Ctrl+T` | cycle theme (flexoki-light → flexoki-dark → charm) |
-| `Alt+;` · click | spellcheck popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `t` toggle spellcheck, `Esc` close |
+| `Alt+;` · click | proofing popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `t` toggle spellcheck, `g` toggle grammar, `Esc` close |
| `Ctrl+/` | toggle the in-editor help overlay (keys + commands) |
| `Ctrl+Q` | quit (press twice if there are unsaved changes) |
| `Esc` | clear the selection, or close find / back to the editor |
@@ -134,6 +134,7 @@ daily_format = "2006-01-02" # Go time layout for daily-note filenames
theme = "auto" # auto | flexoki-light | flexoki-dark | charm (auto detects macOS appearance)
glamour_style = "" # override the preview style; "" follows the theme
spellcheck = "auto" # auto | on | off (auto = on for prose/notes, off for code files)
+grammar = "auto" # auto | on | off (Harper; auto = on when harper-ls is installed)
font_display = "" # PDF export heading/cover font stack; "" = Georgia serif
font_body = "" # PDF export body font stack; "" = system-ui sans
font_mono = "" # PDF export code font stack; "" = ui-monospace
@@ -199,11 +200,30 @@
`Alt+;` (or clicking an underlined word) opens a popup with up to five
suggestions ranked by edit distance — pick one with `1`–`9` to replace in place,
`a` to add the word to your personal dictionary, `i` to ignore it for the
-session, or `t` to toggle spellcheck off and on (`Alt+;` opens a toggle-only
-popup when no word is flagged). The personal dictionary is a plain, hand-editable
-file at
+session, `t` to toggle spellcheck off and on, or `g` to toggle grammar checking
+(`Alt+;` opens a toggle-only popup when no word is flagged). The personal
+dictionary is a plain, hand-editable file at
`~/.config/glint/dict.txt` (one word per line). Set `spellcheck = off` to disable
it, or `on` to force it on.
+
+## Grammar (Harper)
+
+Grammar checking is optional and off unless you opt in. glint speaks LSP to
+[Harper](https://writewithharper.com/)'s `harper-ls` in a subprocess, so the
+build stays pure-Go with no new linked dependency. Install it and glint picks it
+up automatically:
+
+```sh
+brew install harper
+```
+
+Grammar issues get a **green** curly underline, distinct from spellcheck's red.
+When a word is both misspelled and inside a grammar span, the red spelling
+underline wins. Diagnostics refresh as you type (debounced) without blocking the
+UI. Toggle it at runtime with `g` in the `Alt+;` popup, and control it with
+`grammar = auto | on | off` (auto = on when `harper-ls` is on your `PATH`). With
+no `harper-ls` installed, grammar is silently inert and glint behaves exactly as
+before.
The curly underline uses the `4:3` SGR underline-style and `58` underline-color
codes — supported by Ghostty, kitty, WezTerm, foot, and recent VTE terminals.
- → Grammar-checking-via-Harper-harper-ls-green-undercurl.md +69 −0
@@ -0,0 +1,69 @@
+---
+id: TASK-043
+title: 'Grammar checking via Harper (harper-ls, green undercurl)'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-14 02:45'
+updated_date: '2026-07-14 15:25'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 42000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add grammar checking with green undercurl underlines, alongside the red-undercurl spellcheck (TASK-020).
+
+APPROACH (decided):
+- Engine: Harper (writewithharper.com). Spawn harper-ls as an LSP subprocess over stdio — keeps glint zero-cgo (harper-core is Rust; no FFI bind).
+- Dependency model: OPTIONAL / auto-detect. Grammar turns on only if harper-ls is found on PATH; silently off otherwise. Power users opt in via `brew install harper`. No Homebrew formula coupling.
+- Rendering: REUSE existing undercurl span infra (Span.Wavy + Span.UnderColor, raw SGR from TASK-020). Add theme.Grammar (green) beside theme.Spell (red) in internal/theme/theme.go + all 3 palettes in themes.go. Grammar diagnostics render as Wavy spans with UnderColor=theme.Grammar.
+
+PLUMBING:
+- Minimal LSP client (one file, internal/grammar/): initialize -> didOpen -> didChange (debounced on edit) -> receive textDocument/publishDiagnostics.
+- Map LSP diagnostic ranges (line/char) to editor rune ranges -> green undercurl spans, layered in a pass like spellPass (internal/editor/spellcheck.go:135). Mind: selection overrides Wavy; cursor cell suppresses undercurl (span.go).
+- Session toggle like SetSpell/ToggleSpell; likely a keybind + config flag. Grammar suggestions could reuse the suggest popup (internal/app/spell.go) later.
+
+OPEN QUESTIONS:
+- Debounce interval / async model in the Bubbletea loop (harper-ls replies asynchronously; need a tea.Cmd + msg for diagnostics).
+- Harper license check before bundling any config.
+- Overlap policy when a word is both misspelled (red) and in a grammar span (green).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Grammar underlines render green, distinct from red spellcheck
+- [x] #2 harper-ls run as optional LSP subprocess; feature off cleanly when binary absent
+- [x] #3 Zero-cgo build and Homebrew formula unchanged
+- [x] #4 theme.Grammar added to all themes
+- [x] #5 Debounced diagnostics update on edit without blocking the UI
+- [x] #6 Session toggle to enable/disable grammar checking
+<!-- AC:END -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+RESOLVED (probed harper-ls 2.6.0 live):
+- Sync: textDocumentSync.change=1 (Full) -> didChange sends whole buffer, version++.
+- Handshake: initialize -> initialized. MUST answer server->client requests or diagnostics never come: workspace/configuration -> reply [{}] (array len = items), client/registerCapability -> reply null. Ignore other server requests with null.
+- Diagnostics: textDocument/publishDiagnostics, diagnostics[].range{start,end}{line,character(UTF-16)}, message, code, source=Harper, severity 4(Hint). Render ALL as green undercurl.
+- character offsets are UTF-16 code units -> map utf16->rune per line.
+- Use file's real dir as rootUri (missing dir logs a non-fatal backend error).
+- Toggle UX (decided): add 'Toggle grammar' row to the Alt+; spell popup (no new global key). Config: grammar=auto|on|off, default auto=on iff harper-ls on PATH.
+- Overlap: red spelling wins; grammar green only paints spans not already Wavy.
+<!-- SECTION:NOTES:END -->
- → Grammar-suggestions-ignore-via-Harper-codeAction.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-044
+title: Grammar suggestions & ignore via Harper codeAction
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-07-14 15:48'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 43000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Wire Harper's fixes/ignore into the Alt+; popup. Currently glint only renders green grammar underlines (TASK-043). Harper advertises codeActionProvider + executeCommand commands (HarperIgnoreLint, HarperAddToUserDict, HarperAddToFileDict, HarperAddToWSDict). Add: on a grammar span at cursor/click, send textDocument/codeAction for the diagnostic range -> show returned fixes as replacement rows in the proofing popup (reuse spellOption/applySpell plumbing); Ignore -> executeCommand HarperIgnoreLint; Add to dict -> HarperAddToUserDict. Needs: LSP client request/response correlation (id->reply channel; the current client only fires notifications + answers server requests), codeAction result parsing (WorkspaceEdit / Command), and mapping the grammar Diag back to harper's diagnostic (code+range). Store the raw LSP diagnostics (code, range, data) alongside the rune-mapped Diag so codeAction can reference them.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Grammar span at cursor offers Harper's fix suggestions in the Alt+; popup
+- [ ] #2 Selecting a fix applies harper's replacement to the buffer
+- [ ] #3 Ignore removes the grammar underline via HarperIgnoreLint
+- [ ] #4 Add-to-dictionary uses HarperAddToUserDict
+<!-- AC:END -->
internal/app/app.go +29 −1
@@ -14,6 +14,7 @@
"glint/internal/config"
"glint/internal/editor"
"glint/internal/export"
+ "glint/internal/grammar"
"glint/internal/help"
"glint/internal/picker"
"glint/internal/preview"
@@ -85,6 +86,14 @@ quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
pending pendingDiscard // armed open-while-dirty confirmation
mouseDragged bool // a drag motion happened since the last left-press (TASK-027)
+
+ // Harper grammar checking (TASK-043). grammar is nil when disabled or the
+ // binary is absent; everything grammar then no-ops.
+ grammar *grammar.Client
+ grammarPath string // path last opened with harper (its URI basis)
+ grammarOpened bool // a document is currently open with harper
+ grammarText string // text last synced to harper (skips redundant didChange)
+ grammarGen int // debounce generation; only the latest tick sends
}
// New builds an App with an empty editor.
@@ -174,6 +183,7 @@ a.editor.SetCursor(pos) // restore where we left this file
}
a.mode = ModeEditor
a.status = path
+ a.grammarOpen() // hand the freshly loaded buffer to harper
return nil
}
@@ -222,7 +232,15 @@ // straight into preview (glint -p), so native terminal text selection works
// immediately instead of only after the first Ctrl+P round-trip.
func (a *App) InPreview() bool { return a.mode == ModePreview }
-func (a *App) Init() tea.Cmd { return nil }
+// Init starts harper (when enabled and installed), opens the current buffer with
+// it, and returns the diagnostics listener. Starting here — rather than in New —
+// keeps the subprocess out of unit tests, which construct an App but never run
+// the program loop. The listener is a no-op command when grammar is off.
+func (a *App) Init() tea.Cmd {
+ a.initGrammar()
+ a.grammarOpen() // hand harper the buffer main already loaded (client was nil then)
+ return a.grammarListen()
+}
// Update routes messages. Global keys are handled first, then mode-specific.
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -234,6 +252,12 @@ case tea.KeyMsg:
return a.handleKey(msg)
case tea.MouseMsg:
return a.handleMouse(msg)
+ case grammarDiagMsg:
+ a.applyGrammarDiags(msg.diags)
+ return a, a.grammarListen() // keep listening for the next batch
+ case grammarDebounceMsg:
+ a.grammarFlush(msg.gen)
+ return a, nil
}
return a, nil
}
@@ -335,6 +359,7 @@ a.quitArmed = true
a.status = "Unsaved changes — Ctrl+Q again to quit"
return a, nil
}
+ a.closeGrammar()
return a, tea.Quit
case tea.KeyCtrlS:
return a.save()
@@ -401,6 +426,7 @@
switch a.mode {
case ModeEditor:
a.editor.HandleKey(msg)
+ return a, a.grammarChanged() // debounced re-lint after the edit settles
case ModeSaveAs:
if msg.Type == tea.KeyEnter {
return a.saveAs()
@@ -564,6 +590,7 @@ a.path = p
a.editor.Dirty = false
a.mode = ModeEditor
a.status = "Saved " + p
+ a.grammarOpen() // the buffer got a real name/URI; reopen it with harper
return a, nil
}
@@ -677,6 +704,7 @@ a.editor.SetContent(nil)
a.path = ""
a.mode = ModeEditor
a.status = "New note"
+ a.grammarOpen() // new blank buffer -> reopen with harper under the untitled URI
}
// openNoteAt creates the note at p (if absent) and opens it.
internal/app/grammar.go +151 −0
@@ -0,0 +1,151 @@
+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() }
internal/app/grammar_test.go +75 −0
@@ -0,0 +1,75 @@
+package app
+
+import (
+ "strings"
+ "testing"
+
+ "glint/internal/grammar"
+ "glint/internal/theme"
+)
+
+// TestApplyGrammarDiagsRendersUnderline checks the app buckets a diagnostic batch
+// by line and the editor renders it as a green undercurl. FlexokiDark's Grammar
+// green is #879A39 -> the underline-color SGR carries its RGB (135,154,57).
+func TestApplyGrammarDiagsRendersUnderline(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetTheme(theme.FlexokiDark())
+ a.editor.SetContent([]byte("This is a a test"))
+ a.editor.SetGrammar(true)
+
+ a.applyGrammarDiags([]grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Message: "repeat"}})
+
+ const greenSGR = "58:2::135:154:57" // undercurl color for #879A39
+ if !strings.Contains(a.editor.View(), greenSGR) {
+ t.Error("expected a green grammar undercurl (SGR " + greenSGR + ") in the rendered view")
+ }
+}
+
+// TestGrammarEndToEnd drives the whole pipeline against the real harper: Init
+// starts the subprocess and opens the buffer, the listener command blocks for the
+// first diagnostic batch, and applying it renders a green undercurl. Skipped
+// without harper on PATH or under -short.
+func TestGrammarEndToEnd(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping live harper test under -short")
+ }
+ if !grammar.Available() {
+ t.Skip("harper-ls not on PATH")
+ }
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetTheme(theme.FlexokiDark())
+ a.editor.SetContent([]byte("This is a a test.\n"))
+
+ listen := a.Init() // starts harper, opens the buffer, returns the listener cmd
+ defer a.Close()
+ if a.grammar == nil {
+ t.Fatal("Init did not start a harper client")
+ }
+ msg := listen() // blocks until harper publishes diagnostics
+ diag, ok := msg.(grammarDiagMsg)
+ if !ok {
+ t.Fatalf("listener returned %T, want grammarDiagMsg", msg)
+ }
+ a.applyGrammarDiags(diag.diags)
+
+ const greenSGR = "58:2::135:154:57" // #879A39 undercurl
+ if !strings.Contains(a.editor.View(), greenSGR) {
+ t.Error("no green grammar undercurl after live harper round-trip")
+ }
+}
+
+// TestGrammarNilClientNoOps confirms the debounce/flush path is inert without a
+// running harper client, so plain app tests never touch a subprocess.
+func TestGrammarNilClientNoOps(t *testing.T) {
+ a := newApp()
+ if a.grammar != nil {
+ t.Fatal("newApp should not start a harper client")
+ }
+ if cmd := a.grammarChanged(); cmd != nil {
+ t.Error("grammarChanged should return nil without a client")
+ }
+ a.grammarFlush(a.grammarGen) // must not panic
+ a.grammarOpen() // must not panic
+}
internal/app/spell.go +19 −6
@@ -12,10 +12,11 @@ // spellKind distinguishes the popup's action rows.
type spellKind int
const (
- spellSuggest spellKind = iota // replace the word with value
- spellAdd // add the word to the personal dictionary
- spellIgnore // ignore the word for this session
- spellToggle // turn spellcheck on/off for the session
+ spellSuggest spellKind = iota // replace the word with value
+ spellAdd // add the word to the personal dictionary
+ spellIgnore // ignore the word for this session
+ spellToggle // turn spellcheck on/off for the session
+ grammarToggle // turn Harper grammar checking on/off (TASK-043)
)
// spellOption is one selectable row in the misspelled-word popup.
@@ -49,6 +50,7 @@ opts = append(opts,
spellOption{label: "Add to dictionary", kind: spellAdd},
spellOption{label: "Ignore", kind: spellIgnore},
spellOption{label: "Toggle spellcheck", kind: spellToggle},
+ spellOption{label: "Toggle grammar", kind: grammarToggle},
)
a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts}
a.mode = ModeSpell
@@ -67,7 +69,10 @@ }
if a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col) {
return true
}
- a.spell = spellPopup{options: []spellOption{{label: "Toggle spellcheck", kind: spellToggle}}}
+ a.spell = spellPopup{options: []spellOption{
+ {label: "Toggle spellcheck", kind: spellToggle},
+ {label: "Toggle grammar", kind: grammarToggle},
+ }}
a.mode = ModeSpell
a.status = ""
return true
@@ -102,6 +107,8 @@ case r == 'i' || r == 'I':
return a.applySpell(a.kindIndex(spellIgnore))
case r == 't' || r == 'T':
return a.applySpell(a.kindIndex(spellToggle))
+ case r == 'g' || r == 'G':
+ return a.applySpell(a.kindIndex(grammarToggle))
}
}
}
@@ -144,6 +151,10 @@ a.status = "Spellcheck on"
} else {
a.status = "Spellcheck off"
}
+ case grammarToggle:
+ cmd := a.toggleGrammar()
+ a.mode = ModeEditor
+ return a, cmd
}
a.mode = ModeEditor
return a, nil
@@ -175,7 +186,9 @@ label = "a Add"
case spellIgnore:
label = "i Ignore"
case spellToggle:
- label = "t Toggle"
+ label = "t Spell"
+ case grammarToggle:
+ label = "g Grammar"
}
if i == a.spell.sel {
label = selStyle.Render(" " + label + " ")
internal/app/spell_test.go +5 −4
@@ -38,13 +38,14 @@ a.setSize(100, 24)
a.editor.SetContent([]byte("all correct words"))
a.editor.SetCursor(editorPos(0, 1))
a.handleKey(altSemicolon)
- // With no flagged word, Alt+; opens a minimal toggle-only popup (no word, a
- // single Toggle option) so spellcheck can always be turned off/on.
+ // With no flagged word, Alt+; opens a minimal toggle-only popup (no word, just
+ // the spellcheck and grammar toggles) so proofing can always be turned off/on.
if a.mode != ModeSpell {
t.Fatal("Alt+; did not open the toggle-only popup")
}
- if a.spell.word != "" || len(a.spell.options) != 1 || a.spell.options[0].kind != spellToggle {
- t.Errorf("want toggle-only popup, got word=%q opts=%+v", a.spell.word, a.spell.options)
+ if a.spell.word != "" || len(a.spell.options) != 2 ||
+ a.spell.options[0].kind != spellToggle || a.spell.options[1].kind != grammarToggle {
+ t.Errorf("want spell+grammar toggle popup, got word=%q opts=%+v", a.spell.word, a.spell.options)
}
}
internal/config/config.go +5 −0
@@ -20,6 +20,7 @@ GlamourStyle string `toml:"glamour_style"`
Theme string `toml:"theme"`
InboxDir string `toml:"inbox_dir"`
Spellcheck string `toml:"spellcheck"` // auto | on | off (TASK-020)
+ Grammar string `toml:"grammar"` // auto | on | off — Harper grammar check (TASK-043)
// PDF/printable export fonts (TASK-021). CSS font-family stacks that
// override the house-style --font-* tokens. Defaults are portable
@@ -39,6 +40,7 @@ DailySubdir: "Daily",
DailyFormat: "2006-01-02",
Theme: "auto",
Spellcheck: "auto",
+ Grammar: "auto",
FontDisplay: "Georgia, \"Times New Roman\", serif",
FontBody: "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
FontMono: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
@@ -141,6 +143,9 @@ cfg.InboxDir = fileCfg.InboxDir
}
if fileCfg.Spellcheck != "" {
cfg.Spellcheck = fileCfg.Spellcheck
+ }
+ if fileCfg.Grammar != "" {
+ cfg.Grammar = fileCfg.Grammar
}
if fileCfg.FontDisplay != "" {
cfg.FontDisplay = fileCfg.FontDisplay
internal/configui/configui.go +10 −0
@@ -33,6 +33,7 @@ inboxDir := cfg.InboxDir
dailySubdir := orDefault(cfg.DailySubdir, "Daily")
glamour := cfg.GlamourStyle
spellcheck := orDefault(cfg.Spellcheck, "auto")
+ grammar := orDefault(cfg.Grammar, "auto")
// Match the current layout to a preset; otherwise offer it as custom.
fmtChoice := customLayout
@@ -95,6 +96,14 @@ huh.NewOption("auto", "auto"),
huh.NewOption("on", "on"),
huh.NewOption("off", "off"),
).Value(&spellcheck),
+ huh.NewSelect[string]().
+ Title("Grammar (Harper)").
+ Description("Green undercurl on grammar issues. Needs harper-ls (brew install harper). auto = on when installed.").
+ Options(
+ huh.NewOption("auto", "auto"),
+ huh.NewOption("on", "on"),
+ huh.NewOption("off", "off"),
+ ).Value(&grammar),
),
huh.NewGroup(
huh.NewInput().
@@ -121,6 +130,7 @@ DailySubdir: dailySubdir,
DailyFormat: dailyFmt,
GlamourStyle: glamour,
Spellcheck: spellcheck,
+ Grammar: grammar,
// Export fonts aren't part of the wizard; preserve any existing values
// so a `glint -c` run doesn't silently drop them.
FontDisplay: cfg.FontDisplay,
internal/editor/editor.go +3 −0
@@ -46,6 +46,9 @@ dict *spell.Dict // loaded spellchecker; nil = inert (TASK-020)
spellOn bool // session spellcheck toggle
spellCache map[string]bool // word -> known, cleared when the personal dict changes
spellIgnore map[string]bool // words ignored for this session only
+
+ grammarOn bool // session grammar toggle (Harper; TASK-043)
+ grammarDiags map[int][][2]int // logical line -> green-undercurl rune ranges [start,end)
}
// SetLanguage selects the scanner from the file's extension: markdown/text/no
internal/editor/grammar.go +76 −0
@@ -0,0 +1,76 @@
+package editor
+
+import "github.com/charmbracelet/lipgloss"
+
+// SetGrammar sets whether grammar checking is enabled for the session. Diagnostics
+// keep arriving from the app regardless; this only gates their rendering.
+func (e *Editor) SetGrammar(on bool) { e.grammarOn = on; e.invalidate() }
+
+// ToggleGrammar flips grammar checking and returns the new state.
+func (e *Editor) ToggleGrammar() bool { e.grammarOn = !e.grammarOn; e.invalidate(); return e.grammarOn }
+
+// GrammarEnabled reports the user's session toggle for grammar checking.
+func (e *Editor) GrammarEnabled() bool { return e.grammarOn }
+
+// SetGrammarDiags replaces the grammar-underline ranges, keyed by logical line
+// (each range is a [start,end) rune column pair). Callers pass whatever Harper
+// last reported for the current buffer; an empty map clears all grammar
+// underlines. The visual model is invalidated so the change shows on next render.
+func (e *Editor) SetGrammarDiags(byLine map[int][][2]int) {
+ e.grammarDiags = byLine
+ e.invalidate()
+}
+
+// grammarActive reports whether grammar underlines should render: enabled and the
+// buffer is prose (grammar, like spellcheck, skips code files).
+func (e *Editor) grammarActive() bool {
+ return e.grammarOn && e.codeFile == "" && len(e.grammarDiags) > 0
+}
+
+// grammarPass overlays a green undercurl on each grammar range, layered after
+// spellPass so a word already flagged red (misspelled) keeps its red underline —
+// spelling outranks grammar on the same text.
+func (e *Editor) grammarPass(all [][]Span) [][]Span {
+ for li := range all {
+ ranges := e.grammarDiags[li]
+ if len(ranges) == 0 {
+ continue
+ }
+ spans := all[li]
+ for _, r := range ranges {
+ spans = overlayUndercurl(spans, r[0], r[1], e.theme.Grammar)
+ }
+ all[li] = spans
+ }
+ return all
+}
+
+// overlayUndercurl marks the rune range [a,b) of spans with a curly underline in
+// color, splitting spans at the range boundaries (like overlaySelection). Only
+// prose spans not already Wavy are marked, so grammar green never repaints a
+// misspelling's red nor underlines markup punctuation.
+func overlayUndercurl(spans []Span, a, b int, color lipgloss.Color) []Span {
+ total := 0
+ for _, sp := range spans {
+ total += len([]rune(sp.Text))
+ }
+ if a < 0 {
+ a = 0
+ }
+ if b > total {
+ b = total
+ }
+ if a >= b {
+ return spans
+ }
+ out := sliceSpans(spans, 0, a)
+ mid := sliceSpans(spans, a, b)
+ for i := range mid {
+ if mid[i].Prose && !mid[i].Wavy {
+ mid[i].Wavy = true
+ mid[i].UnderColor = color
+ }
+ }
+ out = append(out, mid...)
+ return append(out, sliceSpans(spans, b, total)...)
+}
internal/editor/grammar_test.go +82 −0
@@ -0,0 +1,82 @@
+package editor
+
+import (
+ "testing"
+
+ "glint/internal/spell"
+ "glint/internal/theme"
+)
+
+// grammarSpans returns, for each Wavy span in the built visual model, its text
+// mapped to its undercurl color — enough to tell grammar (green) from spell (red).
+func grammarSpans(e *Editor) map[string]string {
+ got := map[string]string{}
+ for _, vr := range e.buildVisual() {
+ for _, sp := range vr.spans {
+ if sp.Wavy {
+ got[sp.Text] = string(sp.UnderColor)
+ }
+ }
+ }
+ return got
+}
+
+func grammarEditor(t *testing.T, content string) *Editor {
+ t.Helper()
+ e := New()
+ e.SetTheme(theme.FlexokiDark())
+ e.SetContent([]byte(content))
+ e.SetGrammar(true)
+ return e
+}
+
+func TestGrammarUnderlinesRange(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ // "a a" spans rune columns [8,11).
+ e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+ green := string(theme.FlexokiDark().Grammar)
+ spans := grammarSpans(e)
+ if spans["a a"] != green {
+ t.Errorf("expected \"a a\" underlined green (%s); got wavy spans %v", green, spans)
+ }
+}
+
+func TestGrammarInactiveWhenOff(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+ e.SetGrammar(false)
+ if s := grammarSpans(e); len(s) != 0 {
+ t.Errorf("grammar off should render no undercurl; got %v", s)
+ }
+}
+
+func TestGrammarInactiveOnCodeFile(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ e.SetLanguage("main.go") // code file: grammar skipped like spellcheck
+ e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+ if s := grammarSpans(e); len(s) != 0 {
+ t.Errorf("grammar should skip code files; got %v", s)
+ }
+}
+
+// TestSpellingWinsOverGrammar asserts a misspelled word inside a grammar range
+// keeps its red spell underline rather than being repainted green.
+func TestSpellingWinsOverGrammar(t *testing.T) {
+ d, err := spell.Load()
+ if err != nil {
+ t.Fatalf("spell.Load: %v", err)
+ }
+ e := New()
+ e.SetTheme(theme.FlexokiDark())
+ e.SetContent([]byte("This recieve is wrong"))
+ e.SetDict(d)
+ e.SetSpell(true)
+ e.SetGrammar(true)
+ // "recieve" is a misspelling at rune columns [5,12); cover it with a grammar range.
+ e.SetGrammarDiags(map[int][][2]int{0: {{5, 12}}})
+ spans := grammarSpans(e)
+ red := string(theme.FlexokiDark().Spell)
+ if spans["recieve"] != red {
+ t.Errorf("misspelled \"recieve\" should stay red (%s), not grammar green; got %v", red, spans)
+ }
+}
internal/editor/wrap.go +3 −0
@@ -98,6 +98,9 @@ }
if e.spellActive() {
all = e.spellPass(all)
}
+ if e.grammarActive() {
+ all = e.grammarPass(all)
+ }
applyConflictHighlight(all, e.Lines, e.theme)
var rows []vrow
for li := range e.Lines {
internal/grammar/client.go +298 −0
@@ -0,0 +1,298 @@
+package grammar
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+)
+
+// binary is the harper-ls executable name; a var so tests can point it elsewhere.
+var binary = "harper-ls"
+
+// Available reports whether harper-ls is on PATH. When false, callers should skip
+// grammar entirely — no subprocess, no cost.
+func Available() bool {
+ _, err := exec.LookPath(binary)
+ return err == nil
+}
+
+// Client is a running harper-ls session reached over stdio. It tracks the text of
+// each open document so it can translate LSP diagnostic ranges (UTF-16) into
+// glint's rune columns, and publishes diagnostic batches on Diagnostics().
+type Client struct {
+ cmd *exec.Cmd
+ stdin io.WriteCloser
+
+ writeMu sync.Mutex
+ nextID int
+
+ docMu sync.Mutex
+ docs map[string]docState // uri -> latest text/version
+
+ diags chan []Diag
+ ready chan struct{}
+ done chan struct{}
+}
+
+type docState struct {
+ version int
+ lines []string
+}
+
+// Start launches harper-ls, performs the LSP initialize handshake, and begins
+// serving diagnostics. The returned Client is ready for DidOpen once Start
+// returns. Callers should guard with Available first.
+func Start() (*Client, error) {
+ cmd := exec.Command(binary, "--stdio")
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, err
+ }
+ cmd.Stderr = nil // discard harper's log noise
+ if err := cmd.Start(); err != nil {
+ return nil, err
+ }
+ c := &Client{
+ cmd: cmd,
+ stdin: stdin,
+ docs: map[string]docState{},
+ diags: make(chan []Diag, 1),
+ ready: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+ go c.readLoop(bufio.NewReader(stdout))
+ c.initialize()
+ return c, nil
+}
+
+// Diagnostics is the stream of diagnostic batches, one per harper
+// publishDiagnostics for a tracked document, already mapped to rune columns. The
+// channel is buffered depth 1 and always holds the latest batch: a slow reader
+// never blocks the client, but never sees a stale batch either.
+func (c *Client) Diagnostics() <-chan []Diag { return c.diags }
+
+// DidOpen registers a document with harper and requests its first diagnostics.
+func (c *Client) DidOpen(path, text string) {
+ uri := pathToURI(path)
+ c.setDoc(uri, 1, text)
+ c.notify("textDocument/didOpen", map[string]any{
+ "textDocument": map[string]any{
+ "uri": uri, "languageId": "markdown", "version": 1, "text": text,
+ },
+ })
+}
+
+// DidChange sends the full new text for an open document (harper uses Full sync),
+// bumping its version so diagnostics refresh.
+func (c *Client) DidChange(path, text string) {
+ uri := pathToURI(path)
+ ver := c.bumpDoc(uri, text)
+ c.notify("textDocument/didChange", map[string]any{
+ "textDocument": map[string]any{"uri": uri, "version": ver},
+ "contentChanges": []any{map[string]any{"text": text}},
+ })
+}
+
+// DidClose stops diagnostics for a document (e.g. when switching files).
+func (c *Client) DidClose(path string) {
+ uri := pathToURI(path)
+ c.docMu.Lock()
+ delete(c.docs, uri)
+ c.docMu.Unlock()
+ c.notify("textDocument/didClose", map[string]any{
+ "textDocument": map[string]any{"uri": uri},
+ })
+}
+
+// Close shuts the subprocess down. Best-effort: it asks harper to exit, closes
+// stdin, then kills and reaps the process asynchronously so Close never blocks
+// and leaves no zombie behind.
+func (c *Client) Close() error {
+ c.notify("exit", nil)
+ _ = c.stdin.Close()
+ err := c.cmd.Process.Kill()
+ go func() { _ = c.cmd.Wait() }() // reap the killed process
+ return err
+}
+
+// --- document bookkeeping -------------------------------------------------
+
+func (c *Client) setDoc(uri string, version int, text string) {
+ c.docMu.Lock()
+ c.docs[uri] = docState{version: version, lines: strings.Split(text, "\n")}
+ c.docMu.Unlock()
+}
+
+func (c *Client) bumpDoc(uri, text string) int {
+ c.docMu.Lock()
+ defer c.docMu.Unlock()
+ d := c.docs[uri]
+ d.version++
+ if d.version < 1 {
+ d.version = 1
+ }
+ d.lines = strings.Split(text, "\n")
+ c.docs[uri] = d
+ return d.version
+}
+
+func (c *Client) docLines(uri string) ([]string, bool) {
+ c.docMu.Lock()
+ defer c.docMu.Unlock()
+ d, ok := c.docs[uri]
+ return d.lines, ok
+}
+
+// --- LSP wire -------------------------------------------------------------
+
+func (c *Client) initialize() {
+ root := "file://" + osGetwd()
+ c.request("initialize", map[string]any{
+ "processId": os.Getpid(),
+ "rootUri": root,
+ "capabilities": map[string]any{
+ "workspace": map[string]any{"configuration": true},
+ "textDocument": map[string]any{"publishDiagnostics": map[string]any{}},
+ },
+ })
+ <-c.ready // block until harper answers initialize
+ c.notify("initialized", map[string]any{})
+}
+
+func (c *Client) request(method string, params any) int {
+ c.writeMu.Lock()
+ c.nextID++
+ id := c.nextID
+ c.writeMu.Unlock()
+ c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
+ return id
+}
+
+func (c *Client) notify(method string, params any) {
+ c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
+}
+
+func (c *Client) respond(id json.RawMessage, result any) {
+ c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
+}
+
+func (c *Client) send(v map[string]any) {
+ body, err := json.Marshal(v)
+ if err != nil {
+ return
+ }
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ _ = writeFrame(c.stdin, body)
+}
+
+// readLoop parses harper's frames until the stream closes, answering the server
+// requests that gate diagnostics and forwarding publishDiagnostics onward.
+func (c *Client) readLoop(r *bufio.Reader) {
+ defer close(c.done)
+ initID := 1 // initialize is always the first request
+ for {
+ body, err := readFrame(r)
+ if err != nil {
+ return
+ }
+ var m struct {
+ ID json.RawMessage `json:"id"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+ }
+ if json.Unmarshal(body, &m) != nil {
+ continue
+ }
+ switch {
+ case m.Method == "" && len(m.ID) > 0: // response to one of our requests
+ if string(m.ID) == itoa(initID) {
+ c.signalReady()
+ }
+ case len(m.ID) > 0: // server -> client request: must answer or diagnostics stall
+ c.answerRequest(m.ID, m.Method, m.Params)
+ case m.Method == "textDocument/publishDiagnostics":
+ c.handleDiagnostics(m.Params)
+ }
+ }
+}
+
+func (c *Client) signalReady() {
+ select {
+ case <-c.ready:
+ default:
+ close(c.ready)
+ }
+}
+
+// answerRequest replies to the server-initiated requests harper needs before it
+// will emit diagnostics: workspace/configuration wants one config object per
+// requested item (empty = harper defaults); everything else gets a null result.
+func (c *Client) answerRequest(id json.RawMessage, method string, params json.RawMessage) {
+ if method == "workspace/configuration" {
+ var p struct {
+ Items []json.RawMessage `json:"items"`
+ }
+ _ = json.Unmarshal(params, &p)
+ result := make([]any, len(p.Items))
+ for i := range result {
+ result[i] = map[string]any{}
+ }
+ c.respond(id, result)
+ return
+ }
+ c.respond(id, nil)
+}
+
+func (c *Client) handleDiagnostics(params json.RawMessage) {
+ var p struct {
+ URI string `json:"uri"`
+ Diagnostics []struct {
+ Range struct {
+ Start struct{ Line, Character int } `json:"start"`
+ End struct{ Line, Character int } `json:"end"`
+ } `json:"range"`
+ Message string `json:"message"`
+ Code any `json:"code"`
+ } `json:"diagnostics"`
+ }
+ if json.Unmarshal(params, &p) != nil {
+ return
+ }
+ lines, ok := c.docLines(p.URI)
+ if !ok {
+ return // diagnostics for a document we no longer track
+ }
+ var batch []Diag
+ for _, d := range p.Diagnostics {
+ batch = append(batch, lspRangeToDiags(lines,
+ d.Range.Start.Line, d.Range.Start.Character,
+ d.Range.End.Line, d.Range.End.Character,
+ d.Message, codeString(d.Code))...)
+ }
+ c.publish(batch)
+}
+
+// publish delivers batch on the depth-1 channel, replacing any undrained batch so
+// the reader always gets the newest diagnostics and the read loop never blocks.
+func (c *Client) publish(batch []Diag) {
+ for {
+ select {
+ case c.diags <- batch:
+ return
+ default:
+ select {
+ case <-c.diags:
+ default:
+ }
+ }
+ }
+}
internal/grammar/framing.go +58 −0
@@ -0,0 +1,58 @@
+// Package grammar drives an optional Harper grammar checker for glint. It speaks
+// LSP (JSON-RPC over stdio) to a harper-ls subprocess, so glint stays pure-Go
+// and zero-cgo: harper-core is Rust, but it lives in a separate process reached
+// only through pipes. The feature is entirely optional — with no harper-ls on
+// PATH the package is inert (Available reports false) and glint behaves exactly
+// as before.
+package grammar
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+)
+
+// writeFrame writes one LSP message: a Content-Length header, a blank line, then
+// the JSON body. Callers serialize concurrent writes; this does no locking.
+func writeFrame(w io.Writer, body []byte) error {
+ if _, err := fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(body)); err != nil {
+ return err
+ }
+ _, err := w.Write(body)
+ return err
+}
+
+// readFrame reads one LSP message body, parsing the Content-Length header and
+// discarding any other headers up to the blank separator line. It returns
+// io.EOF (possibly wrapped) once the stream closes.
+func readFrame(r *bufio.Reader) ([]byte, error) {
+ length := -1
+ for {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return nil, err
+ }
+ trimmed := strings.TrimRight(line, "\r\n")
+ if trimmed == "" { // blank line: headers done
+ break
+ }
+ if name, val, ok := strings.Cut(trimmed, ":"); ok &&
+ strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
+ n, err := strconv.Atoi(strings.TrimSpace(val))
+ if err != nil {
+ return nil, fmt.Errorf("grammar: bad Content-Length %q: %w", val, err)
+ }
+ length = n
+ }
+ }
+ if length < 0 {
+ return nil, fmt.Errorf("grammar: frame missing Content-Length")
+ }
+ body := make([]byte, length)
+ if _, err := io.ReadFull(r, body); err != nil {
+ return nil, err
+ }
+ return body, nil
+}
internal/grammar/grammar_test.go +158 −0
@@ -0,0 +1,158 @@
+package grammar
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+ "time"
+)
+
+func TestFrameRoundTrip(t *testing.T) {
+ var buf bytes.Buffer
+ bodies := [][]byte{
+ []byte(`{"jsonrpc":"2.0","id":1}`),
+ []byte(`{"method":"textDocument/didOpen"}`),
+ []byte(`{}`),
+ }
+ for _, b := range bodies {
+ if err := writeFrame(&buf, b); err != nil {
+ t.Fatalf("writeFrame: %v", err)
+ }
+ }
+ r := bufio.NewReader(&buf)
+ for i, want := range bodies {
+ got, err := readFrame(r)
+ if err != nil {
+ t.Fatalf("readFrame %d: %v", i, err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("frame %d = %q, want %q", i, got, want)
+ }
+ }
+}
+
+func TestReadFrameCaseInsensitiveHeader(t *testing.T) {
+ // Some servers vary header casing; the length header must still parse.
+ raw := "content-length: 2\r\n\r\n{}"
+ got, err := readFrame(bufio.NewReader(bytes.NewBufferString(raw)))
+ if err != nil {
+ t.Fatalf("readFrame: %v", err)
+ }
+ if string(got) != "{}" {
+ t.Errorf("got %q, want {}", got)
+ }
+}
+
+func TestUTF16ToRuneCol(t *testing.T) {
+ cases := []struct {
+ line string
+ u16 int
+ want int
+ }{
+ {"hello", 0, 0},
+ {"hello", 3, 3},
+ {"hello", 99, 5}, // clamp past end
+ {"a😀b", 0, 0}, // emoji is 2 UTF-16 units, 1 rune
+ {"a😀b", 1, 1}, // before the emoji
+ {"a😀b", 3, 2}, // after the emoji (1 + 2 units) -> rune col 2
+ }
+ for _, c := range cases {
+ if got := utf16ToRuneCol(c.line, c.u16); got != c.want {
+ t.Errorf("utf16ToRuneCol(%q, %d) = %d, want %d", c.line, c.u16, got, c.want)
+ }
+ }
+}
+
+func TestLSPRangeToDiagsSingleLine(t *testing.T) {
+ lines := []string{"This is a a sentence."}
+ got := lspRangeToDiags(lines, 0, 8, 0, 11, "Did you mean to repeat this word?", "RepeatedWords")
+ if len(got) != 1 {
+ t.Fatalf("got %d diags, want 1", len(got))
+ }
+ d := got[0]
+ if d.Line != 0 || d.StartCol != 8 || d.EndCol != 11 {
+ t.Errorf("range = line %d [%d,%d), want line 0 [8,11)", d.Line, d.StartCol, d.EndCol)
+ }
+ if d.Code != "RepeatedWords" {
+ t.Errorf("code = %q", d.Code)
+ }
+}
+
+func TestLSPRangeToDiagsMultiLine(t *testing.T) {
+ lines := []string{"first line", "second", "third line"}
+ got := lspRangeToDiags(lines, 0, 6, 2, 5, "m", "C")
+ if len(got) != 3 {
+ t.Fatalf("got %d diags, want 3 (one per covered line)", len(got))
+ }
+ // start line: from col 6 to end (10); middle: full; end: 0..5
+ if got[0].StartCol != 6 || got[0].EndCol != 10 {
+ t.Errorf("start line range [%d,%d), want [6,10)", got[0].StartCol, got[0].EndCol)
+ }
+ if got[1].StartCol != 0 || got[1].EndCol != 6 {
+ t.Errorf("middle line range [%d,%d), want [0,6)", got[1].StartCol, got[1].EndCol)
+ }
+ if got[2].StartCol != 0 || got[2].EndCol != 5 {
+ t.Errorf("end line range [%d,%d), want [0,5)", got[2].StartCol, got[2].EndCol)
+ }
+}
+
+func TestLSPRangeToDiagsSkipsOutOfRangeAndEmpty(t *testing.T) {
+ lines := []string{"only line"}
+ // Line 5 doesn't exist -> skipped, no panic.
+ if got := lspRangeToDiags(lines, 5, 0, 5, 3, "m", "C"); got != nil {
+ t.Errorf("out-of-range line produced %v, want nil", got)
+ }
+ // Zero-width range -> no diag.
+ if got := lspRangeToDiags(lines, 0, 2, 0, 2, "m", "C"); got != nil {
+ t.Errorf("zero-width range produced %v, want nil", got)
+ }
+}
+
+func TestCodeString(t *testing.T) {
+ if got := codeString("RepeatedWords"); got != "RepeatedWords" {
+ t.Errorf("string code = %q", got)
+ }
+ if got := codeString(float64(42)); got != "42" {
+ t.Errorf("number code = %q, want 42", got)
+ }
+ if got := codeString(nil); got != "" {
+ t.Errorf("nil code = %q, want empty", got)
+ }
+}
+
+// TestLiveHarper exercises the real harper-ls end to end. Skipped when the binary
+// is absent or under -short, so CI without harper stays green.
+func TestLiveHarper(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping live harper test under -short")
+ }
+ if !Available() {
+ t.Skip("harper-ls not on PATH")
+ }
+ c, err := Start()
+ if err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ defer c.Close()
+
+ // "a a" is a repeated word; harper should flag it.
+ c.DidOpen("/tmp/glint-grammar-test.md", "This is a a test.\n")
+
+ select {
+ case batch := <-c.Diagnostics():
+ if len(batch) == 0 {
+ t.Fatal("harper returned an empty diagnostic batch")
+ }
+ found := false
+ for _, d := range batch {
+ if d.Line == 0 && d.Message != "" {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("no usable diagnostic in batch: %+v", batch)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("timed out waiting for harper diagnostics")
+ }
+}
internal/grammar/position.go +70 −0
@@ -0,0 +1,70 @@
+package grammar
+
+// Diag is one grammar issue for a single logical line, in glint's coordinate
+// system: rune columns, not the LSP wire's UTF-16 units. Multi-line LSP ranges
+// are split into one Diag per covered line before reaching this type.
+type Diag struct {
+ Line int // 0-based logical line
+ StartCol int // 0-based rune column, inclusive
+ EndCol int // rune column, exclusive
+ Message string // human-readable description from Harper
+ Code string // Harper rule code (e.g. "RepeatedWords")
+}
+
+// utf16ToRuneCol converts a UTF-16 code-unit offset within line (LSP's default
+// position encoding) to a rune column. Characters outside the BMP count as two
+// UTF-16 units but one rune, so plain len-based indexing would drift once a doc
+// contains emoji or other astral characters. An offset past the line's end
+// clamps to the rune length.
+func utf16ToRuneCol(line string, u16 int) int {
+ if u16 <= 0 {
+ return 0
+ }
+ units, col := 0, 0
+ for _, r := range line {
+ if units >= u16 {
+ return col
+ }
+ if r > 0xFFFF {
+ units += 2
+ } else {
+ units++
+ }
+ col++
+ }
+ return col
+}
+
+// lspRangeToDiags splits one LSP diagnostic range (start/end line+character in
+// UTF-16 units) into per-line Diags with rune columns, using lines as the
+// authoritative document text. A single-line range yields one Diag; a range that
+// spans lines covers the start line from its column to end-of-line, every whole
+// intermediate line, and the end line up to its column. Out-of-range lines are
+// skipped so a stale diagnostic can never index past the buffer.
+func lspRangeToDiags(lines []string, startLine, startChar, endLine, endChar int, msg, code string) []Diag {
+ line := func(i int) (string, bool) {
+ if i < 0 || i >= len(lines) {
+ return "", false
+ }
+ return lines[i], true
+ }
+ var out []Diag
+ for ln := startLine; ln <= endLine; ln++ {
+ text, ok := line(ln)
+ if !ok {
+ continue
+ }
+ runes := len([]rune(text))
+ start, end := 0, runes
+ if ln == startLine {
+ start = utf16ToRuneCol(text, startChar)
+ }
+ if ln == endLine {
+ end = utf16ToRuneCol(text, endChar)
+ }
+ if end > start {
+ out = append(out, Diag{Line: ln, StartCol: start, EndCol: end, Message: msg, Code: code})
+ }
+ }
+ return out
+}
internal/grammar/util.go +49 −0
@@ -0,0 +1,49 @@
+package grammar
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// pathToURI turns an editor file path into an LSP document URI. Unnamed buffers
+// (empty path) get a stable synthetic URI so harper still checks them.
+func pathToURI(path string) string {
+ if strings.TrimSpace(path) == "" {
+ return "file:///glint-untitled.md"
+ }
+ abs := path
+ if p, err := filepath.Abs(path); err == nil {
+ abs = p
+ }
+ return "file://" + (&url.URL{Path: abs}).EscapedPath()
+}
+
+// osGetwd returns the working directory for the initialize rootUri, falling back
+// to the temp dir so a missing cwd never breaks startup.
+func osGetwd() string {
+ if wd, err := os.Getwd(); err == nil {
+ return wd
+ }
+ return os.TempDir()
+}
+
+func itoa(n int) string { return strconv.Itoa(n) }
+
+// codeString renders an LSP diagnostic code, which may be a string or a number,
+// as a string.
+func codeString(code any) string {
+ switch v := code.(type) {
+ case string:
+ return v
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ case nil:
+ return ""
+ default:
+ return fmt.Sprint(v)
+ }
+}
internal/help/help.go +9 −3
@@ -51,9 +51,10 @@ Ctrl+D today's daily note
Ctrl+N new note in the current directory
Ctrl+B new note in the inbox
Ctrl+T cycle theme (flexoki-light / flexoki-dark / charm)
- Alt+; spellcheck popup on the misspelled word at the cursor
+ Alt+; proofing popup on the misspelled word at the cursor
(pick a suggestion 1-9, a add to dictionary, i ignore,
- t toggle spellcheck); clicking an underlined word opens it
+ t toggle spellcheck, g toggle grammar); clicking an
+ underlined word opens it
Ctrl+C / Ctrl+X / Ctrl+V copy / cut / paste (system clipboard)
Shift+arrows select text (Ctrl+Shift+left/right by word)
Alt+left / Alt+right move by word
@@ -69,11 +70,16 @@ ( [ and backtick auto-close (no selection): inserts the matching closer
with the cursor between; type the closer to step past
paste a URL on a selection wraps it as a [selection](url) link
-SPELLCHECK
+SPELLCHECK & GRAMMAR
Misspelled prose gets a red curly underline. Code, inline code, URLs,
wikilinks, link targets, and frontmatter are never flagged; code files are
off entirely. Personal words live in ~/.config/glint/dict.txt (hand-editable).
Config key spellcheck = auto | on | off.
+
+ Grammar checking (green curly underline) is optional and powered by Harper:
+ install harper-ls (brew install harper) and glint uses it automatically. A
+ word that is both misspelled and in a grammar span keeps its red underline.
+ Config key grammar = auto | on | off (auto = on when harper-ls is present).
CONFIG
~/.config/glint/config.toml (run 'glint -c' to set it up)
internal/theme/theme.go +1 −0
@@ -24,6 +24,7 @@ Comment lipgloss.Color // HTML / %% comments — visible, not dimmed
Accent lipgloss.Color // frontmatter keys, selection
Highlight lipgloss.Color // ==highlight== background tint
Spell lipgloss.Color // misspelled-word undercurl (red)
+ Grammar lipgloss.Color // grammar-issue undercurl (green; Harper, TASK-043)
// Merge-conflict highlighting (git markers <<<<<<< ||||||| ======= >>>>>>>).
ConflictMarker lipgloss.Color // bold marker lines
internal/theme/themes.go +3 −0
@@ -29,6 +29,7 @@ Comment: lipgloss.Color("#DA702C"), // orange-400 — visible meta
Accent: lipgloss.Color("#D0A215"), // yellow-400
Highlight: lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
Spell: lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
+ Grammar: lipgloss.Color("#879A39"), // green-400 — grammar undercurl (Harper)
ConflictMarker: lipgloss.Color("#8B7EC8"), // purple-400 — conflict marker lines
ConflictOurs: lipgloss.Color("#1A1E0C"), // green-950 — "ours" block tint
@@ -62,6 +63,7 @@ Comment: lipgloss.Color("#BC5215"), // orange-600 — visible meta
Accent: lipgloss.Color("#AD8301"), // yellow-600
Highlight: lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
Spell: lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
+ Grammar: lipgloss.Color("#66800B"), // green-600 — grammar undercurl (Harper)
ConflictMarker: lipgloss.Color("#5E409D"), // purple-600 — conflict marker lines
ConflictOurs: lipgloss.Color("#EDEECF"), // green-50 — "ours" block tint
@@ -95,6 +97,7 @@ Comment: lipgloss.Color("#FFB454"),
Accent: lipgloss.Color("#FFD500"),
Highlight: lipgloss.Color("#3A2E4D"),
Spell: lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
+ Grammar: lipgloss.Color("#A6E22E"), // lime — grammar undercurl (Harper)
ConflictMarker: lipgloss.Color("#B794F6"), // soft purple — conflict marker lines
ConflictOurs: lipgloss.Color("#17251C"), // dark green — "ours" block tint
main.go +3 −1
@@ -177,7 +177,9 @@ opts := []tea.ProgramOption{tea.WithAltScreen()}
if !a.InPreview() {
opts = append(opts, tea.WithMouseCellMotion())
}
- if _, err := tea.NewProgram(a, opts...).Run(); err != nil {
+ _, err := tea.NewProgram(a, opts...).Run()
+ a.Close() // shut down the harper subprocess if one is running
+ if err != nil {
fmt.Fprintln(os.Stderr, "glint:", err)
os.Exit(1)
}