feat: grammar fixes & session ignore via Harper codeAction (TASK-044)
fe49582bfe8df263678c7784e33a7bd50e65487c
humdrum <me@humdrum.me> · 2026-07-14 12:30
parent 20055b72
feat: grammar fixes & session ignore via Harper codeAction (TASK-044)
Make the green grammar underlines actionable. Alt+; (or a click) on a grammar
span opens the proofing popup listing Harper's replacement fixes as numbered
rows plus an Ignore action; picking a fix rewrites the text, Ignore hides the
hint for the session.
Grounded in a live harper-ls 2.6 probe:
- Replacement: textDocument/codeAction returns quickfix edits with clean
titles; wired end to end. Added LSP request/response correlation to the
client (id->reply channel) and CodeActions(), which maps harper's UTF-16
edit ranges to glint rune coords and dedups by title.
- Ignore: HarperIgnoreLint is a no-op over LSP, so ignore is glint-side — a
session set keyed by (rule code, flagged text), mirroring spellcheck's
IgnoreWord. Incoming batches are filtered through it; an ignored underline
clears immediately and resets on next launch.
- Add-to-dictionary: harper offers none for grammar lints; dropped from scope.
- Fixed the workspace/configuration reply to wrap under {"harper-ls":{}};
the bare {} was making harper log 'Settings must contain a harper-ls key.'
Editor gains GrammarSpanAt, RuneRangeText, and a general ReplaceRuneRange.
TDD throughout, including gated live-harper tests for codeAction and the
popup round-trip. Spec: docs/superpowers/specs/2026-07-14-harper-grammar-actions-design.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 13 files changed
README.md +11 −5
@@ -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 | 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 |
+| `Alt+;` · click | proofing popup on the misspelled word or grammar span at the cursor (or click an underlined word): pick a suggestion / grammar fix `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 |
@@ -220,10 +220,16 @@
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.
+UI.
+
+`Alt+;` on a grammar span (or clicking it) opens the proofing popup with
+**Harper's fixes** as the numbered rows — pick one to rewrite the text — plus
+`i` **Ignore**, which hides that hint for the rest of the session. (Harper's own
+ignore command is a no-op over LSP, so glint keeps the ignore list itself; it
+resets on the next launch.) Toggle grammar at runtime with `g` in the same 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.
## TK markers
- → Grammar-suggestions-ignore-via-Harper-codeAction.md +17 −5
@@ -1,9 +1,10 @@
---
id: TASK-044
title: Grammar suggestions & ignore via Harper codeAction
-status: "\U0001F7E6 Backlog"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-07-14 15:48'
+updated_date: '2026-07-14 19:29'
labels:
- feature
dependencies: []
@@ -19,8 +20,19 @@ <!-- 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
+- [x] #1 Grammar span at cursor offers Harper's fix suggestions in the Alt+; popup
+- [x] #2 Selecting a fix applies harper's replacement to the buffer
+- [x] #3 Ignore removes the grammar underline for the session (glint-side; HarperIgnoreLint is a no-op over LSP)
<!-- AC:END -->
+
+
+
+
+
+
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented via generic relay of harper codeAction results. Live-probed harper-ls 2.6: replacement quickfixes work great; HarperIgnoreLint is a no-op over LSP and harper offers no add-to-dict for grammar lints. So: (1)(2) replacement fixes wired into the Alt+; popup (grammar package request/response correlation + CodeActions mapping UTF-16 edits to rune coords; app applyGrammarFix applies edits last-first); (3) Ignore is glint-side session ignore keyed by rule+text, mirroring spell IgnoreWord (resets next launch); (4) add-to-dict dropped — not offered by harper for grammar. Bonus: fixed workspace/configuration reply to wrap under {"harper-ls":{}} (was triggering harper's 'Settings must contain a harper-ls key' error). Spec: docs/superpowers/specs/2026-07-14-harper-grammar-actions-design.md
+<!-- SECTION:NOTES:END -->
internal/app/app.go +3 −0
@@ -94,6 +94,9 @@ 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
+
+ lastGrammarDiags []grammar.Diag // most recent batch, retained so an ignore can re-filter it
+ grammarIgnores map[string]struct{} // session-ignored grammar lints, keyed by rule+text (TASK-044)
}
// New builds an App with an empty editor.
internal/app/grammar.go +39 −3
@@ -53,14 +53,50 @@ return grammarDiagMsg{diags: batch}
}
}
-// applyGrammarDiags buckets a diagnostic batch by logical line and hands it to
-// the editor as green-undercurl ranges.
+// applyGrammarDiags retains the latest batch and renders it, dropping any lint the
+// user has ignored this session.
func (a *App) applyGrammarDiags(diags []grammar.Diag) {
+ a.lastGrammarDiags = diags
+ a.refreshGrammarDiags()
+}
+
+// refreshGrammarDiags buckets the retained batch by logical line and hands it to the
+// editor, skipping session-ignored lints. Called after a new batch and after an
+// ignore, so an ignored underline clears immediately without waiting for harper.
+func (a *App) refreshGrammarDiags() {
byLine := map[int][][2]int{}
- for _, d := range diags {
+ for _, d := range a.lastGrammarDiags {
+ if a.grammarIgnored(d.Code, a.editor.RuneRangeText(d.Line, d.StartCol, d.EndCol)) {
+ continue
+ }
byLine[d.Line] = append(byLine[d.Line], [2]int{d.StartCol, d.EndCol})
}
a.editor.SetGrammarDiags(byLine)
+}
+
+// grammarIgnoreKey identifies an ignored grammar lint by its rule code and the exact
+// flagged text, so the ignore survives edits that move the span and suppresses every
+// identical occurrence — mirroring spellcheck's whole-word Ignore.
+func grammarIgnoreKey(code, text string) string { return code + "\x00" + text }
+
+// grammarIgnored reports whether a (code, text) lint is ignored this session.
+func (a *App) grammarIgnored(code, text string) bool {
+ if a.grammarIgnores == nil {
+ return false
+ }
+ _, ok := a.grammarIgnores[grammarIgnoreKey(code, text)]
+ return ok
+}
+
+// ignoreGrammar suppresses a grammar lint for the rest of the session and re-renders
+// so its underline disappears at once. (Harper's own HarperIgnoreLint is a no-op over
+// LSP, so glint owns the ignore list; it resets on the next launch.)
+func (a *App) ignoreGrammar(code, text string) {
+ if a.grammarIgnores == nil {
+ a.grammarIgnores = map[string]struct{}{}
+ }
+ a.grammarIgnores[grammarIgnoreKey(code, text)] = struct{}{}
+ a.refreshGrammarDiags()
}
// grammarOpen (re)registers the current buffer with harper after the open
internal/app/grammar_test.go +132 −0
@@ -60,6 +60,138 @@ t.Error("no green grammar undercurl after live harper round-trip")
}
}
+// TestGrammarIgnoreClearsUnderline: ignoring a lint drops its underline immediately
+// and keeps it dropped when harper re-publishes the same batch.
+func TestGrammarIgnoreClearsUnderline(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)
+
+ const greenSGR = "58:2::135:154:57"
+ batch := []grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Code: "RepeatedWords", Message: "repeat"}}
+ a.applyGrammarDiags(batch)
+ if !strings.Contains(a.editor.View(), greenSGR) {
+ t.Fatal("expected a grammar underline before ignore")
+ }
+
+ a.ignoreGrammar("RepeatedWords", "a a")
+ if strings.Contains(a.editor.View(), greenSGR) {
+ t.Error("underline should vanish immediately after ignore")
+ }
+ // A fresh identical batch stays suppressed.
+ a.applyGrammarDiags(batch)
+ if strings.Contains(a.editor.View(), greenSGR) {
+ t.Error("ignored lint should stay suppressed across re-batches")
+ }
+}
+
+// TestApplyGrammarFix applies Harper replacement edits to the buffer.
+func TestApplyGrammarFix(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("This is a a test"))
+ a.applyGrammarFix([]grammar.TextEdit{{StartLine: 0, StartCol: 8, EndLine: 0, EndCol: 11, NewText: "a"}})
+ if got := string(a.editor.Bytes()); got != "This is a test" {
+ t.Errorf("got %q, want %q", got, "This is a test")
+ }
+}
+
+// TestApplyGrammarFixMultiEdit applies two edits on one line; last-first ordering
+// keeps the earlier edit's range valid after the later one shifts the text.
+func TestApplyGrammarFixMultiEdit(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("aa bb"))
+ a.applyGrammarFix([]grammar.TextEdit{
+ {StartLine: 0, StartCol: 0, EndLine: 0, EndCol: 2, NewText: "AA"},
+ {StartLine: 0, StartCol: 3, EndLine: 0, EndCol: 5, NewText: "BBB"},
+ })
+ if got := string(a.editor.Bytes()); got != "AA BBB" {
+ t.Errorf("got %q, want %q", got, "AA BBB")
+ }
+}
+
+// TestGrammarCodeAt maps a span back to its rule code from the retained batch.
+func TestGrammarCodeAt(t *testing.T) {
+ a := newApp()
+ a.lastGrammarDiags = []grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Code: "RepeatedWords"}}
+ if got := a.grammarCodeAt(0, 8, 11); got != "RepeatedWords" {
+ t.Errorf("grammarCodeAt = %q, want RepeatedWords", got)
+ }
+ if got := a.grammarCodeAt(0, 0, 3); got != "" {
+ t.Errorf("unmatched span code = %q, want empty", got)
+ }
+}
+
+// TestGrammarPopupApplyFix drives the popup apply path directly (no client needed):
+// a grammarFix option, when applied, edits the buffer and returns to editor mode.
+func TestGrammarPopupApplyFix(t *testing.T) {
+ a := newApp()
+ a.setSize(100, 24)
+ a.editor.SetContent([]byte("This is a a test"))
+ a.mode = ModeSpell
+ a.spell = spellPopup{
+ word: "a a", code: "RepeatedWords", row: 0, start: 8, end: 11,
+ options: []spellOption{
+ {label: `Replace with: "a"`, kind: grammarFix, edits: []grammar.TextEdit{{StartLine: 0, StartCol: 8, EndLine: 0, EndCol: 11, NewText: "a"}}},
+ {label: "Ignore", kind: grammarIgnore},
+ },
+ }
+ a.applySpell(0)
+ if got := string(a.editor.Bytes()); got != "This is a test" {
+ t.Errorf("got %q, want %q", got, "This is a test")
+ }
+ if a.mode != ModeEditor {
+ t.Error("popup should close after applying a fix")
+ }
+}
+
+// TestGrammarPopupLive opens the popup on a real harper-flagged span and applies the
+// first fix. Gated on harper + not -short.
+func TestGrammarPopupLive(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.SetContent([]byte("This is a a test.\n"))
+ listen := a.Init()
+ defer a.Close()
+ msg := listen()
+ diag, ok := msg.(grammarDiagMsg)
+ if !ok {
+ t.Fatalf("listener returned %T", msg)
+ }
+ a.applyGrammarDiags(diag.diags)
+
+ d := diag.diags[0]
+ if !a.openGrammarPopupAt(d.Line, d.StartCol) {
+ t.Fatal("openGrammarPopupAt did not open on a flagged span")
+ }
+ if a.mode != ModeSpell {
+ t.Fatal("expected ModeSpell after opening the grammar popup")
+ }
+ fixes := 0
+ for _, o := range a.spell.options {
+ if o.kind == grammarFix {
+ fixes++
+ }
+ }
+ if fixes == 0 {
+ t.Fatal("grammar popup listed no replacement fixes from harper")
+ }
+ before := string(a.editor.Bytes())
+ a.applySpell(0) // apply the first fix
+ if string(a.editor.Bytes()) == before {
+ t.Error("applying a harper fix did not change the buffer")
+ }
+}
+
// 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) {
internal/app/spell.go +88 −7
@@ -2,7 +2,10 @@ package app
import (
"fmt"
+ "sort"
"strings"
+
+ "glint/internal/grammar"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -17,19 +20,24 @@ 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)
+ grammarFix // apply a Harper replacement (edits) (TASK-044)
+ grammarIgnore // ignore this grammar lint for the session (TASK-044)
)
// spellOption is one selectable row in the misspelled-word popup.
type spellOption struct {
label string
kind spellKind
- value string // replacement word for spellSuggest
+ value string // replacement word for spellSuggest
+ edits []grammar.TextEdit // buffer edits for grammarFix (Harper's replacement)
}
-// spellPopup is the state of the active misspelled-word popup: the flagged word,
-// its location, the choice list, and the cursor within it.
+// spellPopup is the state of the active proofing popup: the flagged word (or grammar
+// span text), its location, the grammar rule code (for grammarIgnore), the choice
+// list, and the cursor within it.
type spellPopup struct {
word string
+ code string // Harper rule code, set for grammar popups
row, start, end int
options []spellOption
sel int
@@ -40,7 +48,8 @@ // returning false (and doing nothing) when no misspelled word sits there.
func (a *App) openSpellPopupAt(row, col int) bool {
word, start, end, ok := a.editor.FlaggedWordAt(row, col)
if !ok {
- return false
+ // Spelling wins on overlap; with no misspelling here, try a grammar span.
+ return a.openGrammarPopupAt(row, col)
}
opts := make([]spellOption, 0, 7)
for _, s := range a.editor.Suggest(word, 5) {
@@ -58,6 +67,53 @@ a.status = ""
return true
}
+// openGrammarPopupAt opens the proofing popup on a grammar span at (row, col): it
+// asks harper for the fixes on that span and lists them as replacement rows, followed
+// by Ignore and the grammar toggle. Returns false when grammar is off or no grammar
+// span sits there. Fixes may be empty (harper offered none, or the request timed out)
+// — the popup still opens so the span can be ignored.
+func (a *App) openGrammarPopupAt(row, col int) bool {
+ if a.grammar == nil {
+ return false
+ }
+ start, end, ok := a.editor.GrammarSpanAt(row, col)
+ if !ok {
+ return false
+ }
+ text := a.editor.RuneRangeText(row, start, end)
+ actions, _ := a.grammar.CodeActions(a.grammarPath, row, start, end)
+ opts := make([]spellOption, 0, len(actions)+2)
+ for _, ac := range actions {
+ opts = append(opts, spellOption{label: ac.Title, kind: grammarFix, edits: ac.Edits})
+ }
+ opts = append(opts,
+ spellOption{label: "Ignore", kind: grammarIgnore},
+ spellOption{label: "Toggle grammar", kind: grammarToggle},
+ )
+ a.spell = spellPopup{
+ word: text,
+ code: a.grammarCodeAt(row, start, end),
+ row: row,
+ start: start,
+ end: end,
+ options: opts,
+ }
+ a.mode = ModeSpell
+ a.status = ""
+ return true
+}
+
+// grammarCodeAt returns the Harper rule code of the retained diagnostic matching the
+// span (row, [start,end)), or "" if none — used to key the session ignore.
+func (a *App) grammarCodeAt(row, start, end int) string {
+ for _, d := range a.lastGrammarDiags {
+ if d.Line == row && d.StartCol == start && d.EndCol == end {
+ return d.Code
+ }
+ }
+ return ""
+}
+
// openSpellPopup is the Alt+; handler: it opens the full popup on a flagged word
// at the cursor, or, with no misspelling there, a minimal popup offering just the
// session toggle (so spellcheck can always be turned back on even when no
@@ -104,7 +160,10 @@ }
case r == 'a' || r == 'A':
return a.applySpell(a.kindIndex(spellAdd))
case r == 'i' || r == 'I':
- return a.applySpell(a.kindIndex(spellIgnore))
+ if idx := a.kindIndex(spellIgnore); idx >= 0 {
+ return a.applySpell(idx)
+ }
+ return a.applySpell(a.kindIndex(grammarIgnore))
case r == 't' || r == 'T':
return a.applySpell(a.kindIndex(spellToggle))
case r == 'g' || r == 'G':
@@ -151,6 +210,12 @@ a.status = "Spellcheck on"
} else {
a.status = "Spellcheck off"
}
+ case grammarFix:
+ a.applyGrammarFix(opt.edits)
+ a.status = "Applied: " + opt.label
+ case grammarIgnore:
+ a.ignoreGrammar(a.spell.code, a.spell.word)
+ a.status = "Ignored grammar hint"
case grammarToggle:
cmd := a.toggleGrammar()
a.mode = ModeEditor
@@ -160,6 +225,22 @@ a.mode = ModeEditor
return a, nil
}
+// applyGrammarFix applies Harper's replacement edits to the buffer. Edits are applied
+// last-first (by document position) so earlier edits don't shift later ranges.
+func (a *App) applyGrammarFix(edits []grammar.TextEdit) {
+ sorted := make([]grammar.TextEdit, len(edits))
+ copy(sorted, edits)
+ sort.Slice(sorted, func(i, j int) bool {
+ if sorted[i].StartLine != sorted[j].StartLine {
+ return sorted[i].StartLine > sorted[j].StartLine
+ }
+ return sorted[i].StartCol > sorted[j].StartCol
+ })
+ for _, e := range sorted {
+ a.editor.ReplaceRuneRange(e.StartLine, e.StartCol, e.EndLine, e.EndCol, e.NewText)
+ }
+}
+
// spellBar renders the popup as a themed full-width bottom bar: the misspelled
// word, then numbered choices with the current selection highlighted, and the
// add/ignore hints.
@@ -179,11 +260,11 @@ }
for i, o := range a.spell.options {
var label string
switch o.kind {
- case spellSuggest:
+ case spellSuggest, grammarFix:
label = fmt.Sprintf("%d %s", i+1, o.label)
case spellAdd:
label = "a Add"
- case spellIgnore:
+ case spellIgnore, grammarIgnore:
label = "i Ignore"
case spellToggle:
label = "t Spell"
internal/editor/grammar.go +83 −1
@@ -1,6 +1,10 @@
package editor
-import "github.com/charmbracelet/lipgloss"
+import (
+ "strings"
+
+ "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.
@@ -25,6 +29,84 @@ // 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
+}
+
+// GrammarSpanAt returns the rune range [start,end) of the grammar underline covering
+// rune column col on row (inclusive of the column just past the span, so a cursor
+// resting at its end still resolves it). ok is false when grammar is inactive or no
+// span sits there.
+func (e *Editor) GrammarSpanAt(row, col int) (start, end int, ok bool) {
+ if !e.grammarActive() {
+ return 0, 0, false
+ }
+ for _, r := range e.grammarDiags[row] {
+ if col >= r[0] && col <= r[1] {
+ return r[0], r[1], true
+ }
+ }
+ return 0, 0, false
+}
+
+// RuneRangeText returns the substring of row over rune range [start,end), clamped to
+// the line, or "" when the row is out of range.
+func (e *Editor) RuneRangeText(row, start, end int) string {
+ if row < 0 || row >= len(e.Lines) {
+ return ""
+ }
+ r := []rune(e.Lines[row])
+ if start < 0 {
+ start = 0
+ }
+ if end > len(r) {
+ end = len(r)
+ }
+ if start >= end {
+ return ""
+ }
+ return string(r[start:end])
+}
+
+// ReplaceRuneRange replaces the document span from (startRow,startCol) to
+// (endRow,endCol) — rune columns, end exclusive — with s, which may itself contain
+// newlines. It parks the cursor after the inserted text, marks the buffer dirty, and
+// records an undo checkpoint. Out-of-range coordinates are a no-op. This applies a
+// single Harper fix edit (usually single-line, but multi-line ranges are honored).
+func (e *Editor) ReplaceRuneRange(startRow, startCol, endRow, endCol int, s string) {
+ if startRow < 0 || endRow >= len(e.Lines) || startRow > endRow {
+ return
+ }
+ first := []rune(e.Lines[startRow])
+ last := []rune(e.Lines[endRow])
+ if startCol < 0 || startCol > len(first) || endCol < 0 || endCol > len(last) {
+ return
+ }
+ if startRow == endRow && startCol > endCol {
+ return
+ }
+ e.PushUndo()
+ merged := string(first[:startCol]) + s + string(last[endCol:])
+ parts := strings.Split(merged, "\n")
+
+ rebuilt := make([]string, 0, len(e.Lines)-(endRow-startRow)+len(parts)-1)
+ rebuilt = append(rebuilt, e.Lines[:startRow]...)
+ rebuilt = append(rebuilt, parts...)
+ rebuilt = append(rebuilt, e.Lines[endRow+1:]...)
+ e.Lines = rebuilt
+
+ // Park the cursor at the end of the inserted text (not the merged line): count
+ // s's own newlines, so a trailing suffix after the edit stays to the cursor's right.
+ sLines := strings.Split(s, "\n")
+ curRow := startRow + len(sLines) - 1
+ curCol := len([]rune(sLines[len(sLines)-1]))
+ if len(sLines) == 1 {
+ curCol = startCol + curCol
+ }
+ e.Cursor = Position{Row: curRow, Col: curCol}
+ e.anchor = nil
+ e.Dirty = true
+ e.invalidate()
+ e.setGoal()
+ e.followCursor()
}
// grammarPass overlays a green undercurl on each grammar range, layered after
internal/editor/grammar_test.go +68 −0
@@ -41,6 +41,74 @@ t.Errorf("expected \"a a\" underlined green (%s); got wavy spans %v", green, spans)
}
}
+func TestGrammarSpanAt(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+ // Inside and at both boundaries (end inclusive) resolves the span.
+ for _, col := range []int{8, 9, 11} {
+ s, en, ok := e.GrammarSpanAt(0, col)
+ if !ok || s != 8 || en != 11 {
+ t.Errorf("GrammarSpanAt(0,%d) = %d,%d,%v; want 8,11,true", col, s, en, ok)
+ }
+ }
+ // Outside the span, and when grammar is off.
+ if _, _, ok := e.GrammarSpanAt(0, 3); ok {
+ t.Error("col 3 should not be in a grammar span")
+ }
+ e.SetGrammar(false)
+ if _, _, ok := e.GrammarSpanAt(0, 9); ok {
+ t.Error("grammar off should report no span")
+ }
+}
+
+func TestReplaceRuneRangeSingleLine(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ e.ReplaceRuneRange(0, 8, 0, 11, "a") // collapse "a a" -> "a"
+ if got := string(e.Bytes()); got != "This is a test" {
+ t.Errorf("got %q, want %q", got, "This is a test")
+ }
+ if e.Cursor.Row != 0 || e.Cursor.Col != 9 {
+ t.Errorf("cursor = %d,%d; want 0,9 (after inserted text)", e.Cursor.Row, e.Cursor.Col)
+ }
+ if !e.Dirty {
+ t.Error("replacement should mark the buffer dirty")
+ }
+}
+
+func TestReplaceRuneRangeMultiLineInsert(t *testing.T) {
+ e := grammarEditor(t, "one two three")
+ // Replace "two" (runes [4,7)) with a two-line insertion.
+ e.ReplaceRuneRange(0, 4, 0, 7, "X\nY")
+ if got := string(e.Bytes()); got != "one X\nY three" {
+ t.Errorf("got %q, want %q", got, "one X\nY three")
+ }
+ if e.Cursor.Row != 1 || e.Cursor.Col != 1 {
+ t.Errorf("cursor = %d,%d; want 1,1", e.Cursor.Row, e.Cursor.Col)
+ }
+}
+
+func TestReplaceRuneRangeUndo(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ e.ReplaceRuneRange(0, 8, 0, 11, "a")
+ e.Undo()
+ if got := string(e.Bytes()); got != "This is a a test" {
+ t.Errorf("undo left %q, want original", got)
+ }
+}
+
+func TestRuneRangeText(t *testing.T) {
+ e := grammarEditor(t, "This is a a test")
+ if got := e.RuneRangeText(0, 8, 11); got != "a a" {
+ t.Errorf("RuneRangeText = %q, want %q", got, "a a")
+ }
+ if got := e.RuneRangeText(0, 8, 999); got != "a a test" {
+ t.Errorf("clamped RuneRangeText = %q", got)
+ }
+ if got := e.RuneRangeText(5, 0, 3); got != "" {
+ t.Errorf("out-of-range row = %q, want empty", got)
+ }
+}
+
func TestGrammarInactiveWhenOff(t *testing.T) {
e := grammarEditor(t, "This is a a test")
e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
internal/grammar/actions.go +81 −0
@@ -0,0 +1,81 @@
+package grammar
+
+import "encoding/json"
+
+// Action is one grammar fix harper offers for a diagnostic: a human-readable title
+// (e.g. `Replace with: "an"`) and the buffer edits that apply it, already mapped to
+// glint's rune coordinates. Only replacement (quickfix) actions are surfaced; harper's
+// ignore/telemetry commands are not — ignore is handled glint-side.
+type Action struct {
+ Title string
+ Edits []TextEdit
+}
+
+// TextEdit replaces the rune range [Start..End) of the document with NewText. Ranges
+// are inclusive of the start position and exclusive of the end, in rune columns per
+// line. Grammar fixes are single-line in practice, but a multi-line range (Start and
+// End on different lines) is represented faithfully so callers can apply it correctly.
+type TextEdit struct {
+ StartLine, StartCol int
+ EndLine, EndCol int
+ NewText string
+}
+
+// rawAction mirrors the subset of an LSP CodeAction glint needs: the title and, for
+// quickfix actions, a WorkspaceEdit's per-URI TextEdits. Bare Command actions decode
+// with an empty Edit and are dropped.
+type rawAction struct {
+ Title string `json:"title"`
+ Edit struct {
+ Changes map[string][]struct {
+ Range struct {
+ Start struct{ Line, Character int } `json:"start"`
+ End struct{ Line, Character int } `json:"end"`
+ } `json:"range"`
+ NewText string `json:"newText"`
+ } `json:"changes"`
+ } `json:"edit"`
+}
+
+// parseActions decodes a textDocument/codeAction result into replacement Actions,
+// mapping each edit's UTF-16 ranges to rune columns against lines (the authoritative
+// document text). Actions without edits (ignore/telemetry commands) are skipped, and
+// duplicate titles — harper returns one per overlapping lint — are collapsed to the
+// first, preserving order.
+func parseActions(result json.RawMessage, lines []string) ([]Action, error) {
+ var raws []rawAction
+ if err := json.Unmarshal(result, &raws); err != nil {
+ return nil, err
+ }
+ lineAt := func(i int) string {
+ if i >= 0 && i < len(lines) {
+ return lines[i]
+ }
+ return ""
+ }
+ var out []Action
+ seen := map[string]bool{}
+ for _, ra := range raws {
+ if seen[ra.Title] {
+ continue
+ }
+ var edits []TextEdit
+ for _, tes := range ra.Edit.Changes {
+ for _, te := range tes {
+ edits = append(edits, TextEdit{
+ StartLine: te.Range.Start.Line,
+ StartCol: utf16ToRuneCol(lineAt(te.Range.Start.Line), te.Range.Start.Character),
+ EndLine: te.Range.End.Line,
+ EndCol: utf16ToRuneCol(lineAt(te.Range.End.Line), te.Range.End.Character),
+ NewText: te.NewText,
+ })
+ }
+ }
+ if len(edits) == 0 {
+ continue // ignore / telemetry command, not a replacement
+ }
+ seen[ra.Title] = true
+ out = append(out, Action{Title: ra.Title, Edits: edits})
+ }
+ return out, nil
+}
internal/grammar/client.go +107 −9
@@ -3,16 +3,29 @@
import (
"bufio"
"encoding/json"
+ "errors"
"io"
"os"
"os/exec"
"strings"
"sync"
+ "time"
)
// binary is the harper-ls executable name; a var so tests can point it elsewhere.
var binary = "harper-ls"
+// codeActionTimeout bounds how long CodeActions waits for harper's reply. Harper is
+// a local subprocess, so a fix list normally returns in a few milliseconds; the cap
+// keeps a hung server from freezing the popup.
+const codeActionTimeout = 400 * time.Millisecond
+
+var (
+ errTimeout = errors.New("grammar: request timed out")
+ errClosed = errors.New("grammar: client closed")
+ errNoDoc = errors.New("grammar: document not open")
+)
+
// Available reports whether harper-ls is on PATH. When false, callers should skip
// grammar entirely — no subprocess, no cost.
func Available() bool {
@@ -28,7 +41,10 @@ cmd *exec.Cmd
stdin io.WriteCloser
writeMu sync.Mutex
+
+ pendMu sync.Mutex
nextID int
+ pending map[int]chan json.RawMessage // id -> reply channel for in-flight requests
docMu sync.Mutex
docs map[string]docState // uri -> latest text/version
@@ -61,12 +77,13 @@ 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{}),
+ cmd: cmd,
+ stdin: stdin,
+ pending: map[int]chan json.RawMessage{},
+ 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()
@@ -168,14 +185,71 @@ c.notify("initialized", map[string]any{})
}
func (c *Client) request(method string, params any) int {
- c.writeMu.Lock()
+ c.pendMu.Lock()
c.nextID++
id := c.nextID
- c.writeMu.Unlock()
+ c.pendMu.Unlock()
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
return id
}
+// requestWait sends a request and blocks for its response, up to timeout. It
+// registers a reply channel keyed by the request id before sending, so the read
+// loop can route the matching response back. Returns errTimeout on timeout and
+// errClosed if the client shuts down first.
+func (c *Client) requestWait(method string, params any, timeout time.Duration) (json.RawMessage, error) {
+ c.pendMu.Lock()
+ c.nextID++
+ id := c.nextID
+ ch := make(chan json.RawMessage, 1)
+ c.pending[id] = ch
+ c.pendMu.Unlock()
+ defer func() {
+ c.pendMu.Lock()
+ delete(c.pending, id)
+ c.pendMu.Unlock()
+ }()
+ c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
+ select {
+ case res := <-ch:
+ return res, nil
+ case <-time.After(timeout):
+ return nil, errTimeout
+ case <-c.done:
+ return nil, errClosed
+ }
+}
+
+// CodeActions asks harper for the fixes it offers on the grammar span covering rune
+// range [startRune,endRune) of the given line, returning only replacement actions
+// with their edits mapped to rune coordinates. The rune range is phrased as an LSP
+// (UTF-16) range using the client's tracked document text. A missing document, a
+// timeout, or a shutdown returns an error and no actions.
+func (c *Client) CodeActions(path string, line, startRune, endRune int) ([]Action, error) {
+ uri := pathToURI(path)
+ lines, ok := c.docLines(uri)
+ if !ok {
+ return nil, errNoDoc
+ }
+ ltext := ""
+ if line >= 0 && line < len(lines) {
+ ltext = lines[line]
+ }
+ rng := map[string]any{
+ "start": map[string]any{"line": line, "character": runeColToUTF16(ltext, startRune)},
+ "end": map[string]any{"line": line, "character": runeColToUTF16(ltext, endRune)},
+ }
+ res, err := c.requestWait("textDocument/codeAction", map[string]any{
+ "textDocument": map[string]any{"uri": uri},
+ "range": rng,
+ "context": map[string]any{"diagnostics": []any{}},
+ }, codeActionTimeout)
+ if err != nil {
+ return nil, err
+ }
+ return parseActions(res, lines)
+}
+
func (c *Client) notify(method string, params any) {
c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
}
@@ -208,12 +282,14 @@ var m struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
+ Result json.RawMessage `json:"result"`
}
if json.Unmarshal(body, &m) != nil {
continue
}
switch {
case m.Method == "" && len(m.ID) > 0: // response to one of our requests
+ c.deliverResponse(m.ID, m.Result)
if string(m.ID) == itoa(initID) {
c.signalReady()
}
@@ -225,6 +301,25 @@ }
}
}
+// deliverResponse routes a response body to the goroutine waiting on its request id,
+// if one registered via requestWait. Fire-and-forget requests (initialize) have no
+// pending channel and are ignored here.
+func (c *Client) deliverResponse(rawID json.RawMessage, result json.RawMessage) {
+ var id int
+ if json.Unmarshal(rawID, &id) != nil {
+ return
+ }
+ c.pendMu.Lock()
+ ch, ok := c.pending[id]
+ c.pendMu.Unlock()
+ if ok {
+ select {
+ case ch <- result:
+ default:
+ }
+ }
+}
+
func (c *Client) signalReady() {
select {
case <-c.ready:
@@ -242,9 +337,12 @@ var p struct {
Items []json.RawMessage `json:"items"`
}
_ = json.Unmarshal(params, &p)
+ // Each item must be wrapped under the "harper-ls" key; a bare {} makes harper
+ // log `Settings must contain a "harper-ls" key.` and skip our config. An empty
+ // inner object still means "use harper's defaults".
result := make([]any, len(p.Items))
for i := range result {
- result[i] = map[string]any{}
+ result[i] = map[string]any{"harper-ls": map[string]any{}}
}
c.respond(id, result)
return
internal/grammar/grammar_test.go +113 −0
@@ -120,6 +120,76 @@ t.Errorf("nil code = %q, want empty", got)
}
}
+func TestRuneColToUTF16(t *testing.T) {
+ cases := []struct {
+ line string
+ col int
+ want int
+ }{
+ {"hello", 0, 0},
+ {"hello", 3, 3},
+ {"hello", 99, 5}, // clamp past end
+ {"a😀b", 1, 1}, // before the emoji
+ {"a😀b", 2, 3}, // after the emoji: 1 + 2 UTF-16 units
+ {"a😀b", 3, 4}, // whole string
+ }
+ for _, c := range cases {
+ if got := runeColToUTF16(c.line, c.col); got != c.want {
+ t.Errorf("runeColToUTF16(%q, %d) = %d, want %d", c.line, c.col, got, c.want)
+ }
+ }
+ // Round-trips with utf16ToRuneCol at rune boundaries.
+ for _, line := range []string{"plain", "a😀b😀c"} {
+ for col := 0; col <= len([]rune(line)); col++ {
+ if got := utf16ToRuneCol(line, runeColToUTF16(line, col)); got != col {
+ t.Errorf("round-trip %q col %d -> %d", line, col, got)
+ }
+ }
+ }
+}
+
+func TestParseActions(t *testing.T) {
+ lines := []string{"This is a a test."}
+ // A quickfix replacement plus a bare ignore command and a duplicate title.
+ result := []byte(`[
+ {"title":"Replace with: \"a\"","kind":"quickfix","edit":{"changes":{"file:///x.md":[
+ {"range":{"start":{"line":0,"character":8},"end":{"line":0,"character":11}},"newText":"a"}]}}},
+ {"title":"Ignore Harper error.","command":"HarperIgnoreLint","arguments":["file:///x.md",{}]},
+ {"title":"Replace with: \"a\"","kind":"quickfix","edit":{"changes":{"file:///x.md":[
+ {"range":{"start":{"line":0,"character":8},"end":{"line":0,"character":11}},"newText":"a"}]}}}
+ ]`)
+ got, err := parseActions(result, lines)
+ if err != nil {
+ t.Fatalf("parseActions: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("got %d actions, want 1 (ignore excluded, duplicate collapsed): %+v", len(got), got)
+ }
+ a := got[0]
+ if a.Title != `Replace with: "a"` {
+ t.Errorf("title = %q", a.Title)
+ }
+ if len(a.Edits) != 1 {
+ t.Fatalf("got %d edits, want 1", len(a.Edits))
+ }
+ e := a.Edits[0]
+ if e.StartLine != 0 || e.StartCol != 8 || e.EndLine != 0 || e.EndCol != 11 || e.NewText != "a" {
+ t.Errorf("edit = %+v, want line0 [8,11) -> \"a\"", e)
+ }
+}
+
+func TestParseActionsEmpty(t *testing.T) {
+ // A response of only ignore/telemetry commands yields no replacement actions.
+ result := []byte(`[{"title":"Ignore Harper error.","command":"HarperIgnoreLint","arguments":[]}]`)
+ got, err := parseActions(result, []string{"x"})
+ if err != nil {
+ t.Fatalf("parseActions: %v", err)
+ }
+ if len(got) != 0 {
+ t.Errorf("got %d actions, want 0", len(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) {
@@ -156,3 +226,46 @@ case <-time.After(10 * time.Second):
t.Fatal("timed out waiting for harper diagnostics")
}
}
+
+// TestLiveHarperCodeActions asks the real harper-ls for fixes on a flagged span and
+// expects at least one replacement action. Gated like TestLiveHarper.
+func TestLiveHarperCodeActions(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()
+
+ path := "/tmp/glint-grammar-ca-test.md"
+ c.DidOpen(path, "This is a a test.\n") // "a a" repeated word, runes [8,11)
+
+ var d Diag
+ select {
+ case batch := <-c.Diagnostics():
+ if len(batch) == 0 {
+ t.Fatal("empty diagnostic batch")
+ }
+ d = batch[0]
+ case <-time.After(10 * time.Second):
+ t.Fatal("timed out waiting for diagnostics")
+ }
+
+ actions, err := c.CodeActions(path, d.Line, d.StartCol, d.EndCol)
+ if err != nil {
+ t.Fatalf("CodeActions: %v", err)
+ }
+ if len(actions) == 0 {
+ t.Fatal("harper offered no replacement actions for a flagged span")
+ }
+ for _, a := range actions {
+ if a.Title == "" || len(a.Edits) == 0 {
+ t.Errorf("action missing title or edits: %+v", a)
+ }
+ }
+}
internal/grammar/position.go +23 −0
@@ -35,6 +35,29 @@ }
return col
}
+// runeColToUTF16 converts a rune column within line to a UTF-16 code-unit offset,
+// the inverse of utf16ToRuneCol. It is used to phrase a glint rune range as an LSP
+// position when requesting code actions. A column past the line's end clamps to the
+// line's total UTF-16 length.
+func runeColToUTF16(line string, col int) int {
+ if col <= 0 {
+ return 0
+ }
+ units, seen := 0, 0
+ for _, r := range line {
+ if seen >= col {
+ return units
+ }
+ if r > 0xFFFF {
+ units += 2
+ } else {
+ units++
+ }
+ seen++
+ }
+ return units
+}
+
// 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
internal/help/help.go +8 −4
@@ -51,9 +51,11 @@ 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+; proofing popup on the misspelled word at the cursor
- (pick a suggestion 1-9, a add to dictionary, i ignore,
- t toggle spellcheck, g toggle grammar); clicking an
+ Alt+; proofing popup on the misspelled word OR grammar span at
+ the cursor (pick a suggestion 1-9, a add to dictionary,
+ i ignore, t toggle spellcheck, g toggle grammar); on a
+ grammar span the numbered rows are Harper's fixes and
+ i ignores the hint for the session; 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)
@@ -79,7 +81,9 @@
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).
+ Alt+; on a grammar span lists Harper's fixes (apply one to rewrite the text)
+ and an Ignore that hides the hint for the session. Config key
+ grammar = auto | on | off (auto = on when harper-ls is present).
TK markers: the journalism "TK" placeholder (to come) — TK, tk, or TKTK as a
whole word — renders as a bold filled badge so unfinished spots pop. Prose