Harper grammar actions — replacement & ignore (TASK-044)
Goal
Make glint's green grammar underlines actionable. On a grammar span at the cursor (Alt+;) or under a click, offer Harper's fix suggestions as replacement rows in the existing proofing popup, plus an Ignore action. Selecting a replacement applies Harper's edit to the buffer; Ignore suppresses that lint for the session.
Builds on TASK-043, which renders diagnostics only. Reuses the spell popup
(spellOption/applySpell) plumbing.
Findings from live harper-ls 2.6 probe
These facts drove the design and narrowed scope:
- Replacement works.
textDocument/codeActionfor a diagnostic's range returns quickfixCodeActions: a Harper-authoredtitle(e.g.Replace with: "an") plusedit.changes[uri]=[{range, newText}]. Each also carries aHarperRecordLinttelemetry command we ignore. The response also includes bareCommandobjects{command:"HarperIgnoreLint", arguments:[uri, lint], title:"Ignore Harper error."}. - HarperIgnoreLint is a no-op over LSP. Executing the command harper returns
(verbatim args) yields
nulland never drops the lint — no changed re-publish afterdidChange, nothing written to~/.config/harper-ls/ignored_lints/. So ignore must be implemented glint-side. - Add-to-dictionary is never offered for grammar lints (spelling-only concept). Dropped from scope.
- Config reply bug. glint answers
workspace/configurationwith bare{}, causing harper'sERROR: Settings must contain a "harper-ls" key.. Reply must be{"harper-ls": {}}. Harmless to diagnostics but fixed here.
Scope
In: grammar-span replacement suggestions; client-side session ignore; the config fix. Out: add-to-dictionary for grammar; persistent (cross-launch) ignore; executing HarperIgnoreLint.
Design
grammar package
- Request/response correlation. Add
pending map[int]chan json.RawMessageguarded by a mutex. InreadLoop, the response case (method=="" && id present) routes the result topending[id]if registered, else keeps the existing initialize-ready signal. CodeActions(path string, line, startRune, endRune int) ([]Action, error)— converts the rune range to UTF-16 using the client's tracked doc lines, sendstextDocument/codeAction, waits on the reply channel with a ~400ms timeout, parses the result into[]Action. Only actions carrying aneditbecome replacement Actions; their TextEdit ranges are mapped UTF-16 → rune coords. Bare-command actions (ignore) are not returned — ignore is glint-side. Dedup replacement Actions by title.- Types:
type Action struct { Title string; Edits []TextEdit } // replacement type TextEdit struct { StartLine, StartCol, EndLine, EndCol int; NewText string } // rune coords - Config reply wraps each configuration item as
{"harper-ls": {}}. runeColToUTF16(line, col)helper (inverse of existingutf16ToRuneCol).
Synchronous CodeActions (blocking ≤400ms) is acceptable: user-initiated, rare, harper is
local. readLoop runs in its own goroutine, so the Update-thread block resolves without
deadlock.
editor package
GrammarSpanAt(row, col) (start, end int, ok bool)— the rune range of the grammar underline coveringcol, fromgrammarDiags[row].ReplaceRuneRange(startRow, startCol, endRow, endCol int, s string)— general range replacement (grammar fixes are single-line in practice; support multi-line for safety).- Grammar session ignore:
grammarIgnores map[string]struct{}keyed bycode + "\x00" + flaggedText.IgnoreGrammar(code, text string)adds; a filter drops any incoming diag whose(code, buffer-substring at its range)is in the set. Mirrors spell'sIgnoreWord. Because the key is content-based, it survives edits that move the span.
app package
- Extend
spellKindwithgrammarFix(replacement) rows; the popup for a grammar span is built fromCodeActionsresults + ani Ignorerow + the existingg Grammartoggle. spellOptiongains a way to carry anAction(edits) forgrammarFix.openSpellPopupAt: spell word wins (existing check first); else ifGrammarSpanAthits, requestCodeActions, build the grammar popup. Store the span (row,start,end) + rule code- flagged text for the Ignore action.
applySpell:grammarFix→editor.ReplaceRuneRangefor each edit (reverse doc order); ignore →editor.IgnoreGrammar(code, text)+ re-filter current batch so the underline clears immediately.spellBarlabels the new rows (1 …,i Ignore,g Grammar).
Testing (TDD)
- grammar: UTF-16↔rune round-trip; codeAction JSON →
[]Actionparse (edits mapped, bare commands excluded, dedup by title); request/response correlation; config reply shape. Gated live test: real harper returns a replacement Action for "a a". - editor:
GrammarSpanAthit/miss/boundary;ReplaceRuneRangesingle & multi-line; grammar-ignore filter drops matching diag, keeps others, content-keyed across an edit. - app: grammar-span popup lists replacements + ignore; applying a fix mutates the buffer; ignore clears the underline; spell still wins when a word is both.
Acceptance criteria (revised)
- Grammar span at cursor offers Harper's fix suggestions in the Alt+; popup
- Selecting a fix applies harper's replacement to the buffer
- Ignore removes the grammar underline (glint-side session ignore, not HarperIgnoreLint)
-
Add-to-dictionary— dropped: harper offers no add-to-dict for grammar lints