# 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