package editor 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. 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 } // 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 // 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)...) }