docs: design for Harper grammar actions — replacement & ignore (TASK-044)
20055b72e1129906de48d449cb6232c580f0b4ad
humdrum <me@humdrum.me> · 2026-07-14 12:20
parent 4a2593db
docs: design for Harper grammar actions — replacement & ignore (TASK-044) Live-probed harper-ls 2.6: codeAction returns quickfix replacement edits cleanly, but HarperIgnoreLint is a no-op over LSP and no add-to-dict is offered for grammar lints. Scope: replacement suggestions + client-side session ignore; drop add-to-dict. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 files changed
docs/superpowers/specs/2026-07-14-harper-grammar-actions-design.md +102 −0
@@ -0,0 +1,102 @@
+# 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:
+
+1. **Replacement works.** `textDocument/codeAction` for a diagnostic's range returns
+ quickfix `CodeAction`s: a Harper-authored `title` (e.g. `Replace with: "an"`) plus
+ `edit.changes[uri]` = `[{range, newText}]`. Each also carries a `HarperRecordLint`
+ telemetry command we ignore. The response also includes bare `Command` objects
+ `{command:"HarperIgnoreLint", arguments:[uri, lint], title:"Ignore Harper error."}`.
+2. **HarperIgnoreLint is a no-op over LSP.** Executing the command harper returns
+ (verbatim args) yields `null` and never drops the lint — no changed re-publish after
+ `didChange`, nothing written to `~/.config/harper-ls/ignored_lints/`. So ignore must
+ be implemented glint-side.
+3. **Add-to-dictionary is never offered for grammar lints** (spelling-only concept).
+ Dropped from scope.
+4. **Config reply bug.** glint answers `workspace/configuration` with bare `{}`, causing
+ harper's `ERROR: 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.RawMessage` guarded by
+ a mutex. In `readLoop`, the response case (`method=="" && id present`) routes the result
+ to `pending[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, sends
+ `textDocument/codeAction`, waits on the reply channel with a ~400ms timeout, parses the
+ result into `[]Action`. Only actions carrying an `edit` become 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:**
+ ```go
+ 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 existing `utf16ToRuneCol`).
+
+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 covering `col`, from `grammarDiags[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 by
+ `code + "\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's
+ `IgnoreWord`. Because the key is content-based, it survives edits that move the span.
+
+### app package
+
+- Extend `spellKind` with `grammarFix` (replacement) rows; the popup for a grammar span is
+ built from `CodeActions` results + an `i Ignore` row + the existing `g Grammar` toggle.
+- `spellOption` gains a way to carry an `Action` (edits) for `grammarFix`.
+- `openSpellPopupAt`: spell word wins (existing check first); else if `GrammarSpanAt` hits,
+ request `CodeActions`, build the grammar popup. Store the span (row,start,end) + rule code
+ + flagged text for the Ignore action.
+- `applySpell`: `grammarFix` → `editor.ReplaceRuneRange` for each edit (reverse doc order);
+ ignore → `editor.IgnoreGrammar(code, text)` + re-filter current batch so the underline
+ clears immediately.
+- `spellBar` labels the new rows (`1 …`, `i Ignore`, `g Grammar`).
+
+## Testing (TDD)
+
+- grammar: UTF-16↔rune round-trip; codeAction JSON → `[]Action` parse (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: `GrammarSpanAt` hit/miss/boundary; `ReplaceRuneRange` single & 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)
+
+- [x] Grammar span at cursor offers Harper's fix suggestions in the Alt+; popup
+- [x] Selecting a fix applies harper's replacement to the buffer
+- [x] Ignore removes the grammar underline (glint-side session ignore, not HarperIgnoreLint)
+- [ ] ~~Add-to-dictionary~~ — dropped: harper offers no add-to-dict for grammar lints